extraction.test.ts 347 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291
  1. /**
  2. * Extraction Tests
  3. *
  4. * Tests for the tree-sitter extraction system.
  5. */
  6. import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
  7. import * as fs from 'fs';
  8. import * as path from 'path';
  9. import * as os from 'os';
  10. import { CodeGraph } from '../src';
  11. import { extractFromSource, scanDirectory, buildDefaultIgnore, discoverEmbeddedRepoRoots, buildScopeIgnore } from '../src/extraction';
  12. import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars, isSourceFile } from '../src/extraction/grammars';
  13. import { stripCppTemplateArgs, blankCppExportMacros, blankCppInlineMacros, blankMetalAttributes, recoverMangledCppName } from '../src/extraction/languages/c-cpp';
  14. import { normalizePath } from '../src/utils';
  15. beforeAll(async () => {
  16. await initGrammars();
  17. await loadAllGrammars();
  18. });
  19. // Create a temporary directory for each test
  20. function createTempDir(): string {
  21. return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-'));
  22. }
  23. // Clean up temporary directory
  24. function cleanupTempDir(dir: string): void {
  25. if (fs.existsSync(dir)) {
  26. fs.rmSync(dir, { recursive: true, force: true });
  27. }
  28. }
  29. describe('Language Detection', () => {
  30. it('should detect TypeScript files', () => {
  31. expect(detectLanguage('src/index.ts')).toBe('typescript');
  32. expect(detectLanguage('components/Button.tsx')).toBe('tsx');
  33. });
  34. it('should detect JavaScript files', () => {
  35. expect(detectLanguage('index.js')).toBe('javascript');
  36. expect(detectLanguage('App.jsx')).toBe('jsx');
  37. expect(detectLanguage('config.mjs')).toBe('javascript');
  38. });
  39. it('should detect Python files', () => {
  40. expect(detectLanguage('main.py')).toBe('python');
  41. });
  42. it('should detect Go files', () => {
  43. expect(detectLanguage('main.go')).toBe('go');
  44. });
  45. it('should detect Rust files', () => {
  46. expect(detectLanguage('lib.rs')).toBe('rust');
  47. });
  48. it('should detect Java files', () => {
  49. expect(detectLanguage('Main.java')).toBe('java');
  50. });
  51. it('should detect C files', () => {
  52. expect(detectLanguage('main.c')).toBe('c');
  53. expect(detectLanguage('utils.h')).toBe('c');
  54. });
  55. it('should detect C++ files', () => {
  56. expect(detectLanguage('main.cpp')).toBe('cpp');
  57. expect(detectLanguage('class.hpp')).toBe('cpp');
  58. });
  59. it('should detect C# files', () => {
  60. expect(detectLanguage('Program.cs')).toBe('csharp');
  61. });
  62. it('should detect PHP files', () => {
  63. expect(detectLanguage('index.php')).toBe('php');
  64. });
  65. it('should detect Ruby files', () => {
  66. expect(detectLanguage('app.rb')).toBe('ruby');
  67. });
  68. it('should detect Swift files', () => {
  69. expect(detectLanguage('ViewController.swift')).toBe('swift');
  70. });
  71. it('should detect Kotlin files', () => {
  72. expect(detectLanguage('MainActivity.kt')).toBe('kotlin');
  73. expect(detectLanguage('build.gradle.kts')).toBe('kotlin');
  74. });
  75. it('should detect Dart files', () => {
  76. expect(detectLanguage('main.dart')).toBe('dart');
  77. });
  78. it('should detect Objective-C files', () => {
  79. expect(detectLanguage('AppDelegate.m')).toBe('objc');
  80. expect(detectLanguage('ViewController.mm')).toBe('objc');
  81. const objcHeader = '@interface Foo : NSObject\n@end\n';
  82. expect(detectLanguage('Foo.h', objcHeader)).toBe('objc');
  83. expect(detectLanguage('stdio.h', '#ifndef STDIO_H\nvoid printf();\n#endif\n')).toBe('c');
  84. });
  85. it('should detect Metal shader files as C++ (#1121)', () => {
  86. expect(detectLanguage('Shaders.metal')).toBe('cpp');
  87. expect(isSourceFile('Renderer/Shaders.metal')).toBe(true);
  88. });
  89. it('should detect Erlang files', () => {
  90. expect(detectLanguage('src/my_server.erl')).toBe('erlang');
  91. expect(detectLanguage('include/records.hrl')).toBe('erlang');
  92. expect(detectLanguage('bin/release_tool.escript')).toBe('erlang');
  93. // OTP app resource files route by full suffix — `.src` alone is too generic.
  94. expect(detectLanguage('src/myapp.app.src')).toBe('erlang');
  95. expect(detectLanguage('ebin/myapp.app')).toBe('erlang');
  96. expect(detectLanguage('legacy/module.src')).toBe('unknown');
  97. expect(isSourceFile('src/myapp.app.src')).toBe(true);
  98. expect(isSourceFile('ebin/myapp.app')).toBe(true);
  99. expect(isSourceFile('legacy/module.src')).toBe(false);
  100. });
  101. it('should return unknown for unsupported extensions', () => {
  102. expect(detectLanguage('styles.css')).toBe('unknown');
  103. expect(detectLanguage('data.json')).toBe('unknown');
  104. });
  105. });
  106. describe('Language Support', () => {
  107. it('should report supported languages', () => {
  108. expect(isLanguageSupported('typescript')).toBe(true);
  109. expect(isLanguageSupported('python')).toBe(true);
  110. expect(isLanguageSupported('go')).toBe(true);
  111. expect(isLanguageSupported('unknown')).toBe(false);
  112. });
  113. it('should list all supported languages', () => {
  114. const languages = getSupportedLanguages();
  115. expect(languages).toContain('typescript');
  116. expect(languages).toContain('javascript');
  117. expect(languages).toContain('python');
  118. expect(languages).toContain('go');
  119. expect(languages).toContain('rust');
  120. expect(languages).toContain('java');
  121. expect(languages).toContain('csharp');
  122. expect(languages).toContain('php');
  123. expect(languages).toContain('ruby');
  124. expect(languages).toContain('swift');
  125. expect(languages).toContain('kotlin');
  126. expect(languages).toContain('dart');
  127. });
  128. });
  129. describe('TypeScript Extraction', () => {
  130. it('should extract function declarations', () => {
  131. const code = `
  132. export function processPayment(amount: number): Promise<Receipt> {
  133. return stripe.charge(amount);
  134. }
  135. `;
  136. const result = extractFromSource('payment.ts', code);
  137. // File node + function node
  138. const fileNode = result.nodes.find((n) => n.kind === 'file');
  139. expect(fileNode).toBeDefined();
  140. expect(fileNode?.name).toBe('payment.ts');
  141. const funcNode = result.nodes.find((n) => n.kind === 'function');
  142. expect(funcNode).toMatchObject({
  143. kind: 'function',
  144. name: 'processPayment',
  145. language: 'typescript',
  146. isExported: true,
  147. });
  148. expect(funcNode?.signature).toContain('amount: number');
  149. });
  150. it('should extract class declarations', () => {
  151. const code = `
  152. export class PaymentService {
  153. private stripe: StripeClient;
  154. constructor(apiKey: string) {
  155. this.stripe = new StripeClient(apiKey);
  156. }
  157. async charge(amount: number): Promise<Receipt> {
  158. return this.stripe.charge(amount);
  159. }
  160. }
  161. `;
  162. const result = extractFromSource('service.ts', code);
  163. const classNode = result.nodes.find((n) => n.kind === 'class');
  164. const methodNodes = result.nodes.filter((n) => n.kind === 'method');
  165. expect(classNode).toBeDefined();
  166. expect(classNode?.name).toBe('PaymentService');
  167. expect(classNode?.isExported).toBe(true);
  168. expect(methodNodes.length).toBeGreaterThanOrEqual(1);
  169. const chargeMethod = methodNodes.find((m) => m.name === 'charge');
  170. expect(chargeMethod).toBeDefined();
  171. });
  172. it('captures docstrings for export- and const-wrapped declarations (#780)', () => {
  173. const code = `
  174. // plain class control
  175. class Ledger {}
  176. // exported class
  177. export class Invoice {}
  178. // export default
  179. export default function settle() { return true; }
  180. // exported arrow const
  181. export const refund = (amount: number) => amount;
  182. // non-export arrow const
  183. const audit = (amount: number) => amount;
  184. `;
  185. const byName = new Map(extractFromSource('doc.ts', code).nodes.map((n) => [n.name, n]));
  186. expect(byName.get('Ledger')?.docstring).toBe('plain class control'); // control still works
  187. expect(byName.get('Invoice')?.docstring).toBe('exported class');
  188. expect(byName.get('settle')?.docstring).toBe('export default');
  189. expect(byName.get('refund')?.docstring).toBe('exported arrow const');
  190. expect(byName.get('audit')?.docstring).toBe('non-export arrow const');
  191. });
  192. it('does not mis-attribute a class comment to an uncommented member (#780)', () => {
  193. const code = `
  194. // Comment for Box
  195. export class Box {
  196. noComment() {}
  197. // own comment
  198. withComment() {}
  199. }
  200. `;
  201. const byName = new Map(extractFromSource('box.ts', code).nodes.map((n) => [n.name, n]));
  202. expect(byName.get('Box')?.docstring).toBe('Comment for Box');
  203. expect(byName.get('noComment')?.docstring ?? null).toBeNull(); // no over-walk
  204. expect(byName.get('withComment')?.docstring).toBe('own comment');
  205. });
  206. it('captures docstrings for decorated Python declarations, stripping `#` (#780)', () => {
  207. const code = [
  208. '# decorated function',
  209. '@app.route("/x")',
  210. 'def py_handler():',
  211. ' return 1',
  212. '',
  213. '',
  214. '# plain function control',
  215. 'def py_plain():',
  216. ' return 1',
  217. '',
  218. '',
  219. '# decorated class',
  220. '@dataclass',
  221. 'class PyModel:',
  222. ' pass',
  223. '',
  224. ].join('\n');
  225. const byName = new Map(extractFromSource('mod.py', code).nodes.map((n) => [n.name, n]));
  226. expect(byName.get('py_handler')?.docstring).toBe('decorated function');
  227. expect(byName.get('py_plain')?.docstring).toBe('plain function control'); // `#` stripped
  228. expect(byName.get('PyModel')?.docstring).toBe('decorated class');
  229. });
  230. it('cleans comment markers across language styles (#780)', () => {
  231. const doc = (file: string, code: string, name: string) =>
  232. new Map(extractFromSource(file, code).nodes.map((n) => [n.name, n])).get(name)?.docstring;
  233. // Rust doc lines (`///`, `//!`) — the trailing slash used to leak through.
  234. expect(doc('m.rs', '/// rust doc line\nfn rs_fn() {}', 'rs_fn')).toBe('rust doc line');
  235. // Lua line + long-bracket comments.
  236. expect(doc('m.lua', '-- lua line\nfunction lua_fn() end', 'lua_fn')).toBe('lua line');
  237. expect(doc('b.lua', '--[[ lua block ]]\nfunction lua_b() end', 'lua_b')).toBe('lua block');
  238. // Pascal brace and paren-star comments.
  239. const pasUnit = (c: string) =>
  240. `unit U;\ninterface\n${c}\nprocedure P;\nimplementation\nprocedure P;\nbegin\nend;\nend.\n`;
  241. expect(doc('a.pas', pasUnit('{ pascal brace }'), 'P')).toBe('pascal brace');
  242. expect(doc('c.pas', pasUnit('(* pascal paren *)'), 'P')).toBe('pascal paren');
  243. // C block comment still clean (no regression).
  244. expect(doc('m.c', '/* c block */\nvoid c_fn(void) {}', 'c_fn')).toBe('c block');
  245. });
  246. it('should extract interfaces', () => {
  247. const code = `
  248. export interface User {
  249. id: string;
  250. name: string;
  251. email: string;
  252. }
  253. `;
  254. const result = extractFromSource('types.ts', code);
  255. const fileNode = result.nodes.find((n) => n.kind === 'file');
  256. expect(fileNode).toBeDefined();
  257. const ifaceNode = result.nodes.find((n) => n.kind === 'interface');
  258. expect(ifaceNode).toMatchObject({
  259. kind: 'interface',
  260. name: 'User',
  261. isExported: true,
  262. });
  263. });
  264. it('should extract type references from interface property signatures', () => {
  265. const code = `
  266. import type { IPage } from '../PromoterList';
  267. import type { IOrderField } from '../types';
  268. interface Hprops {
  269. value?: Partial<IPage> & Partial<IOrderField>;
  270. }
  271. `;
  272. const result = extractFromSource('HeaderFilter.ts', code);
  273. const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
  274. expect(refs.some((r) => r.referenceName === 'IPage')).toBe(true);
  275. expect(refs.some((r) => r.referenceName === 'IOrderField')).toBe(true);
  276. });
  277. it('should extract type references from interface method signatures', () => {
  278. const code = `
  279. import type { IPage } from '../PromoterList';
  280. import type { IOrderField } from '../types';
  281. interface MethodForm {
  282. fetchPage(arg: IPage): IOrderField;
  283. }
  284. `;
  285. const result = extractFromSource('MethodForm.ts', code);
  286. const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
  287. expect(refs.some((r) => r.referenceName === 'IPage')).toBe(true);
  288. expect(refs.some((r) => r.referenceName === 'IOrderField')).toBe(true);
  289. });
  290. it('extracts type references from in-body local variable annotations', () => {
  291. // A function that uses a type ONLY in its body — `const items: Foo[] = []` —
  292. // still depends on Foo. The body walker used to capture calls but never type
  293. // annotations, so impact / `affected` missed the dependency. Must cover
  294. // function, class-method, and object-literal-method bodies — and must NOT
  295. // turn the locals themselves into graph nodes (that would explode the graph).
  296. const code = `
  297. import { Foo } from './types';
  298. export function build(): void {
  299. const items: Foo[] = [];
  300. void items;
  301. }
  302. export class K {
  303. run(): void {
  304. const a: Foo = { x: 1 };
  305. void a;
  306. }
  307. }
  308. export const handler = {
  309. handle(): void {
  310. const b: Foo = { x: 1 };
  311. void b;
  312. },
  313. };
  314. `;
  315. const result = extractFromSource('inbody.ts', code);
  316. const fooRefs = result.unresolvedReferences.filter(
  317. (r) => r.referenceKind === 'references' && r.referenceName === 'Foo'
  318. );
  319. // One per body scope: build(), K.run(), handler.handle().
  320. expect(fooRefs.length).toBeGreaterThanOrEqual(3);
  321. // Each reference is attributed to its enclosing function/method node — never
  322. // to a local-variable node, because locals are intentionally not extracted.
  323. const byId = new Map(result.nodes.map((n) => [n.id, n]));
  324. for (const ref of fooRefs) {
  325. const owner = byId.get(ref.fromNodeId);
  326. expect(owner).toBeDefined();
  327. expect(['function', 'method']).toContain(owner!.kind);
  328. }
  329. // The locals (items/a/b) must not leak in as symbols.
  330. expect(result.nodes.some((n) => ['items', 'a', 'b'].includes(n.name))).toBe(false);
  331. });
  332. it('should track function calls', () => {
  333. const code = `
  334. function main() {
  335. const result = processData();
  336. console.log(result);
  337. }
  338. `;
  339. const result = extractFromSource('main.ts', code);
  340. expect(result.unresolvedReferences.length).toBeGreaterThan(0);
  341. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
  342. expect(calls.some((c) => c.referenceName === 'processData')).toBe(true);
  343. });
  344. });
  345. describe('Arrow Function Export Extraction', () => {
  346. it('should extract exported arrow functions assigned to const', () => {
  347. const code = `
  348. export const useAuth = (): AuthContextValue => {
  349. return useContext(AuthContext);
  350. };
  351. `;
  352. const result = extractFromSource('hooks.ts', code);
  353. const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'useAuth');
  354. expect(funcNode).toBeDefined();
  355. expect(funcNode).toMatchObject({
  356. kind: 'function',
  357. name: 'useAuth',
  358. isExported: true,
  359. });
  360. });
  361. it('should extract exported function expressions assigned to const', () => {
  362. const code = `
  363. export const processData = function(input: string): string {
  364. return input.trim();
  365. };
  366. `;
  367. const result = extractFromSource('utils.ts', code);
  368. const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'processData');
  369. expect(funcNode).toBeDefined();
  370. expect(funcNode).toMatchObject({
  371. kind: 'function',
  372. name: 'processData',
  373. isExported: true,
  374. });
  375. });
  376. it('should not extract non-exported arrow functions as exported', () => {
  377. const code = `
  378. const internalHelper = () => {
  379. return 42;
  380. };
  381. `;
  382. const result = extractFromSource('internal.ts', code);
  383. const helperNode = result.nodes.find((n) => n.name === 'internalHelper');
  384. expect(helperNode).toBeDefined();
  385. expect(helperNode?.isExported).toBeFalsy();
  386. });
  387. it('should still skip truly anonymous arrow functions', () => {
  388. const code = `
  389. const items = [1, 2, 3].map((x) => x * 2);
  390. `;
  391. const result = extractFromSource('anon.ts', code);
  392. // The inline arrow function passed to .map() has no variable_declarator parent
  393. // and should remain anonymous (skipped)
  394. const anonFunctions = result.nodes.filter(
  395. (n) => n.kind === 'function' && n.name === '<anonymous>'
  396. );
  397. expect(anonFunctions).toHaveLength(0);
  398. });
  399. it('should extract multiple exported arrow functions from the same file', () => {
  400. const code = `
  401. export const add = (a: number, b: number): number => a + b;
  402. export const subtract = (a: number, b: number): number => a - b;
  403. const internal = () => 'not exported';
  404. `;
  405. const result = extractFromSource('math.ts', code);
  406. const exported = result.nodes.filter((n) => n.kind === 'function' && n.isExported);
  407. expect(exported).toHaveLength(2);
  408. expect(exported.map((n) => n.name).sort()).toEqual(['add', 'subtract']);
  409. const internalNode = result.nodes.find((n) => n.name === 'internal');
  410. expect(internalNode).toBeDefined();
  411. expect(internalNode?.isExported).toBeFalsy();
  412. });
  413. it('should extract arrow functions in JavaScript files', () => {
  414. const code = `
  415. export const fetchData = async () => {
  416. const response = await fetch('/api/data');
  417. return response.json();
  418. };
  419. `;
  420. const result = extractFromSource('api.js', code);
  421. const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'fetchData');
  422. expect(funcNode).toBeDefined();
  423. expect(funcNode).toMatchObject({
  424. kind: 'function',
  425. name: 'fetchData',
  426. isExported: true,
  427. });
  428. });
  429. });
  430. describe('Type Alias Extraction', () => {
  431. it('should extract exported type aliases in TypeScript', () => {
  432. const code = `
  433. export type AuthContextValue = {
  434. user: User | null;
  435. login: () => void;
  436. logout: () => void;
  437. };
  438. `;
  439. const result = extractFromSource('types.ts', code);
  440. const typeNode = result.nodes.find((n) => n.kind === 'type_alias');
  441. expect(typeNode).toMatchObject({
  442. kind: 'type_alias',
  443. name: 'AuthContextValue',
  444. isExported: true,
  445. });
  446. });
  447. it('should extract non-exported type aliases', () => {
  448. const code = `
  449. type InternalState = {
  450. loading: boolean;
  451. error: string | null;
  452. };
  453. `;
  454. const result = extractFromSource('internal.ts', code);
  455. const typeNode = result.nodes.find((n) => n.kind === 'type_alias');
  456. expect(typeNode).toMatchObject({
  457. kind: 'type_alias',
  458. name: 'InternalState',
  459. isExported: false,
  460. });
  461. });
  462. it('should extract multiple type aliases from the same file', () => {
  463. const code = `
  464. export type UnitSystem = 'metric' | 'imperial';
  465. export type DateFormat = 'ISO' | 'US' | 'EU';
  466. type Internal = string;
  467. `;
  468. const result = extractFromSource('config.ts', code);
  469. const typeAliases = result.nodes.filter((n) => n.kind === 'type_alias');
  470. expect(typeAliases).toHaveLength(3);
  471. const exported = typeAliases.filter((n) => n.isExported);
  472. expect(exported).toHaveLength(2);
  473. expect(exported.map((n) => n.name).sort()).toEqual(['DateFormat', 'UnitSystem']);
  474. });
  475. // A service/contract registry written as a tuple of generic instantiations —
  476. // the names are string-literal type arguments, not declarations, so static
  477. // extraction otherwise never indexes them (issue #634).
  478. it('extracts string-literal contract names from a generic tuple type alias (#634)', () => {
  479. const code = `
  480. interface Service<Name extends string, Req, Resp> { name: Name; }
  481. export type MyServiceList = [
  482. Service<'query_apply_record', { pageNo: number }, { ok: boolean }>,
  483. Service<'apply_confirm', { code: string }, { ok: boolean }>
  484. ];
  485. `;
  486. const result = extractFromSource('services/api.ts', code);
  487. const names = result.nodes.filter(
  488. (n) => n.kind === 'method' && n.qualifiedName.startsWith('MyServiceList::')
  489. );
  490. expect(names.map((n) => n.name).sort()).toEqual(['apply_confirm', 'query_apply_record']);
  491. const queryNode = names.find((n) => n.name === 'query_apply_record');
  492. expect(queryNode?.qualifiedName).toBe('MyServiceList::query_apply_record');
  493. // Signature carries the full contract entry so search results show context.
  494. expect(queryNode?.signature).toContain("Service<'query_apply_record'");
  495. // The string-literal name is contained by the type alias.
  496. const alias = result.nodes.find((n) => n.kind === 'type_alias' && n.name === 'MyServiceList');
  497. const containsEdge = result.edges.find(
  498. (e) => e.kind === 'contains' && e.source === alias?.id && e.target === queryNode?.id
  499. );
  500. expect(containsEdge).toBeDefined();
  501. });
  502. it('does not extract string literals from utility types or nested generics (#634)', () => {
  503. const code = `
  504. interface User { id: string; name: string; }
  505. interface Service<Name extends string, Req, Resp> { name: Name; }
  506. export type Picked = Pick<User, 'id' | 'name'>;
  507. export type Rec = Record<'foo' | 'bar', number>;
  508. // Tuple entry, but the name is a non-identifier route path; the nested Pick's
  509. // 'id' must also stay out (only DIRECT literal args of a tuple's generic count).
  510. export type Routes = [Service<'/api/users', Pick<User, 'id'>, {}>];
  511. // Bare string-literal tuple — not generic type arguments.
  512. export type Names = ['alpha', 'beta'];
  513. `;
  514. const result = extractFromSource('noise.ts', code);
  515. const leaked = result.nodes.filter(
  516. (n) =>
  517. (n.kind === 'method' || n.kind === 'property') &&
  518. ['id', 'name', 'foo', 'bar', 'alpha', 'beta'].includes(n.name)
  519. );
  520. expect(leaked).toEqual([]);
  521. });
  522. });
  523. describe('Exported Variable Extraction', () => {
  524. it('should extract exported const with call expression (Zustand store)', () => {
  525. const code = `
  526. export const useUIStore = create<UIState>((set) => ({
  527. isOpen: false,
  528. toggle: () => set((s) => ({ isOpen: !s.isOpen })),
  529. }));
  530. `;
  531. const result = extractFromSource('store.ts', code);
  532. const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'useUIStore');
  533. expect(varNode).toBeDefined();
  534. expect(varNode?.isExported).toBe(true);
  535. });
  536. it('should extract exported const with object literal', () => {
  537. const code = `
  538. export const config = {
  539. apiUrl: 'https://api.example.com',
  540. timeout: 5000,
  541. };
  542. `;
  543. const result = extractFromSource('config.ts', code);
  544. const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'config');
  545. expect(varNode).toBeDefined();
  546. expect(varNode?.isExported).toBe(true);
  547. });
  548. it('should extract exported const with array literal', () => {
  549. const code = `
  550. export const SCREEN_NAMES = ['home', 'settings', 'profile'] as const;
  551. `;
  552. const result = extractFromSource('constants.ts', code);
  553. const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'SCREEN_NAMES');
  554. expect(varNode).toBeDefined();
  555. expect(varNode?.isExported).toBe(true);
  556. });
  557. it('should extract exported const with primitive value', () => {
  558. const code = `
  559. export const MAX_RETRIES = 3;
  560. export const API_VERSION = "v2";
  561. `;
  562. const result = extractFromSource('constants.ts', code);
  563. const variables = result.nodes.filter((n) => n.kind === 'constant');
  564. expect(variables).toHaveLength(2);
  565. expect(variables.map((n) => n.name).sort()).toEqual(['API_VERSION', 'MAX_RETRIES']);
  566. });
  567. it('should NOT duplicate arrow functions as both function and variable', () => {
  568. const code = `
  569. export const useAuth = () => {
  570. return useContext(AuthContext);
  571. };
  572. `;
  573. const result = extractFromSource('hooks.ts', code);
  574. // Should be extracted as function (from arrow function handler), NOT as variable
  575. const funcNodes = result.nodes.filter((n) => n.kind === 'function' && n.name === 'useAuth');
  576. const varNodes = result.nodes.filter((n) => n.kind === 'variable' && n.name === 'useAuth');
  577. expect(funcNodes).toHaveLength(1);
  578. expect(varNodes).toHaveLength(0);
  579. });
  580. it('should extract non-exported const as non-exported variable', () => {
  581. const code = `
  582. const internalConfig = {
  583. debug: true,
  584. };
  585. `;
  586. const result = extractFromSource('internal.ts', code);
  587. // Non-exported const at file level should be extracted as a constant (not exported)
  588. const varNodes = result.nodes.filter((n) => (n.kind === 'variable' || n.kind === 'constant') && n.name === 'internalConfig');
  589. expect(varNodes).toHaveLength(1);
  590. expect(varNodes[0]?.isExported).toBeFalsy();
  591. });
  592. it('should extract Zod schema exports', () => {
  593. const code = `
  594. export const userSchema = z.object({
  595. id: z.string(),
  596. name: z.string(),
  597. email: z.string().email(),
  598. });
  599. `;
  600. const result = extractFromSource('schemas.ts', code);
  601. const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'userSchema');
  602. expect(varNode).toBeDefined();
  603. expect(varNode?.isExported).toBe(true);
  604. });
  605. it('should extract XState machine exports', () => {
  606. const code = `
  607. export const authMachine = createMachine({
  608. id: "auth",
  609. initial: "idle",
  610. states: {
  611. idle: {},
  612. authenticated: {},
  613. },
  614. });
  615. `;
  616. const result = extractFromSource('machine.ts', code);
  617. const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'authMachine');
  618. expect(varNode).toBeDefined();
  619. expect(varNode?.isExported).toBe(true);
  620. });
  621. it('should extract calls from a top-level variable initializer (issue #425)', () => {
  622. const code = `
  623. import { getTokenMp } from './api/upload';
  624. const token = getTokenMp();
  625. `;
  626. const result = extractFromSource('app.ts', code);
  627. const call = result.unresolvedReferences.find(
  628. (ref) => ref.referenceKind === 'calls' && ref.referenceName === 'getTokenMp'
  629. );
  630. expect(call).toBeDefined();
  631. });
  632. });
  633. describe('File Node Extraction', () => {
  634. it('should create a file-kind node for each parsed file', () => {
  635. const code = `
  636. export function greet(name: string): string {
  637. return "Hello " + name;
  638. }
  639. `;
  640. const result = extractFromSource('greeter.ts', code);
  641. const fileNode = result.nodes.find((n) => n.kind === 'file');
  642. expect(fileNode).toBeDefined();
  643. expect(fileNode?.name).toBe('greeter.ts');
  644. expect(fileNode?.filePath).toBe('greeter.ts');
  645. expect(fileNode?.language).toBe('typescript');
  646. expect(fileNode?.startLine).toBe(1);
  647. });
  648. it('should create file nodes for Python files', () => {
  649. const code = `
  650. def main():
  651. pass
  652. `;
  653. const result = extractFromSource('main.py', code);
  654. const fileNode = result.nodes.find((n) => n.kind === 'file');
  655. expect(fileNode).toBeDefined();
  656. expect(fileNode?.name).toBe('main.py');
  657. expect(fileNode?.language).toBe('python');
  658. });
  659. it('should create containment edges from file node to top-level declarations', () => {
  660. const code = `
  661. export function foo() {}
  662. export function bar() {}
  663. `;
  664. const result = extractFromSource('fns.ts', code);
  665. const fileNode = result.nodes.find((n) => n.kind === 'file');
  666. expect(fileNode).toBeDefined();
  667. // There should be contains edges from the file node to each function
  668. const containsEdges = result.edges.filter(
  669. (e) => e.source === fileNode?.id && e.kind === 'contains'
  670. );
  671. expect(containsEdges.length).toBeGreaterThanOrEqual(2);
  672. });
  673. });
  674. describe('Python Extraction', () => {
  675. it('should extract function definitions', () => {
  676. const code = `
  677. def calculate_total(items: list, tax_rate: float) -> float:
  678. """Calculate total with tax."""
  679. subtotal = sum(item.price for item in items)
  680. return subtotal * (1 + tax_rate)
  681. `;
  682. const result = extractFromSource('calc.py', code);
  683. const fileNode = result.nodes.find((n) => n.kind === 'file');
  684. expect(fileNode).toBeDefined();
  685. const funcNode = result.nodes.find((n) => n.kind === 'function');
  686. expect(funcNode).toMatchObject({
  687. kind: 'function',
  688. name: 'calculate_total',
  689. language: 'python',
  690. });
  691. });
  692. it('should extract class definitions', () => {
  693. const code = `
  694. class UserService:
  695. """Service for managing users."""
  696. def __init__(self, db):
  697. self.db = db
  698. def get_user(self, user_id: str) -> User:
  699. return self.db.find_user(user_id)
  700. `;
  701. const result = extractFromSource('service.py', code);
  702. const classNode = result.nodes.find((n) => n.kind === 'class');
  703. expect(classNode).toBeDefined();
  704. expect(classNode?.name).toBe('UserService');
  705. });
  706. });
  707. describe('Go Extraction', () => {
  708. it('should extract function declarations', () => {
  709. const code = `
  710. package main
  711. func ProcessOrder(order Order) (Receipt, error) {
  712. // Process the order
  713. return Receipt{}, nil
  714. }
  715. `;
  716. const result = extractFromSource('main.go', code);
  717. const funcNode = result.nodes.find((n) => n.kind === 'function');
  718. expect(funcNode).toBeDefined();
  719. expect(funcNode?.name).toBe('ProcessOrder');
  720. });
  721. it('should extract method declarations', () => {
  722. const code = `
  723. package main
  724. type Service struct {
  725. db *Database
  726. }
  727. func (s *Service) GetUser(id string) (*User, error) {
  728. return s.db.FindUser(id)
  729. }
  730. `;
  731. const result = extractFromSource('service.go', code);
  732. const methodNode = result.nodes.find((n) => n.kind === 'method');
  733. expect(methodNode).toBeDefined();
  734. expect(methodNode?.name).toBe('GetUser');
  735. });
  736. });
  737. describe('Rust Extraction', () => {
  738. it('should extract function declarations', () => {
  739. const code = `
  740. pub fn process_data(input: &str) -> Result<Output, Error> {
  741. // Process data
  742. Ok(Output::new())
  743. }
  744. `;
  745. const result = extractFromSource('lib.rs', code);
  746. const funcNode = result.nodes.find((n) => n.kind === 'function');
  747. expect(funcNode).toBeDefined();
  748. expect(funcNode?.name).toBe('process_data');
  749. expect(funcNode?.visibility).toBe('public');
  750. });
  751. it('should extract struct declarations', () => {
  752. const code = `
  753. pub struct User {
  754. pub id: String,
  755. pub name: String,
  756. email: String,
  757. }
  758. `;
  759. const result = extractFromSource('models.rs', code);
  760. const structNode = result.nodes.find((n) => n.kind === 'struct');
  761. expect(structNode).toBeDefined();
  762. expect(structNode?.name).toBe('User');
  763. });
  764. it('should extract trait declarations', () => {
  765. const code = `
  766. pub trait Repository {
  767. fn find(&self, id: &str) -> Option<Entity>;
  768. fn save(&mut self, entity: Entity) -> Result<(), Error>;
  769. }
  770. `;
  771. const result = extractFromSource('traits.rs', code);
  772. const traitNode = result.nodes.find((n) => n.kind === 'trait');
  773. expect(traitNode).toBeDefined();
  774. expect(traitNode?.name).toBe('Repository');
  775. });
  776. it('should extract impl Trait for Type as implements edges', () => {
  777. const code = `
  778. pub struct MyCache {}
  779. pub trait Cache {
  780. fn get(&self, key: &str) -> Option<String>;
  781. }
  782. impl Cache for MyCache {
  783. fn get(&self, key: &str) -> Option<String> {
  784. None
  785. }
  786. }
  787. `;
  788. const result = extractFromSource('cache.rs', code);
  789. // Should have an unresolved reference for implements
  790. const implRef = result.unresolvedReferences.find(
  791. (r) => r.referenceKind === 'implements' && r.referenceName === 'Cache'
  792. );
  793. expect(implRef).toBeDefined();
  794. // The struct MyCache should be the source
  795. const myCacheNode = result.nodes.find((n) => n.name === 'MyCache' && n.kind === 'struct');
  796. expect(myCacheNode).toBeDefined();
  797. expect(implRef?.fromNodeId).toBe(myCacheNode?.id);
  798. });
  799. it('should extract trait supertraits as extends references', () => {
  800. const code = `
  801. pub trait Display {}
  802. pub trait Error: Display {
  803. fn description(&self) -> &str;
  804. }
  805. `;
  806. const result = extractFromSource('error.rs', code);
  807. const extendsRef = result.unresolvedReferences.find(
  808. (r) => r.referenceKind === 'extends' && r.referenceName === 'Display'
  809. );
  810. expect(extendsRef).toBeDefined();
  811. const errorTrait = result.nodes.find((n) => n.name === 'Error' && n.kind === 'trait');
  812. expect(errorTrait).toBeDefined();
  813. expect(extendsRef?.fromNodeId).toBe(errorTrait?.id);
  814. });
  815. it('should not create implements edges for plain impl blocks', () => {
  816. const code = `
  817. pub struct Counter {
  818. count: u32,
  819. }
  820. impl Counter {
  821. pub fn new() -> Counter {
  822. Counter { count: 0 }
  823. }
  824. pub fn increment(&mut self) {
  825. self.count += 1;
  826. }
  827. }
  828. `;
  829. const result = extractFromSource('counter.rs', code);
  830. // Should have no implements references (no trait involved)
  831. const implRefs = result.unresolvedReferences.filter(
  832. (r) => r.referenceKind === 'implements'
  833. );
  834. expect(implRefs).toHaveLength(0);
  835. });
  836. });
  837. describe('Java Extraction', () => {
  838. it('should extract class declarations', () => {
  839. const code = `
  840. public class UserService {
  841. private final UserRepository repository;
  842. public UserService(UserRepository repository) {
  843. this.repository = repository;
  844. }
  845. public User getUser(String id) {
  846. return repository.findById(id);
  847. }
  848. }
  849. `;
  850. const result = extractFromSource('UserService.java', code);
  851. const classNode = result.nodes.find((n) => n.kind === 'class');
  852. expect(classNode).toBeDefined();
  853. expect(classNode?.name).toBe('UserService');
  854. expect(classNode?.visibility).toBe('public');
  855. });
  856. it('should extract method declarations', () => {
  857. const code = `
  858. public class Calculator {
  859. public static int add(int a, int b) {
  860. return a + b;
  861. }
  862. }
  863. `;
  864. const result = extractFromSource('Calculator.java', code);
  865. const methodNode = result.nodes.find((n) => n.kind === 'method' && n.name === 'add');
  866. expect(methodNode).toBeDefined();
  867. expect(methodNode?.isStatic).toBe(true);
  868. });
  869. it('wraps top-level declarations in a namespace from package_declaration', () => {
  870. const code = `
  871. package com.example.foo;
  872. public class Bar {
  873. public String greet() { return "hi"; }
  874. }
  875. `;
  876. const result = extractFromSource('Bar.java', code);
  877. const ns = result.nodes.find((n) => n.kind === 'namespace');
  878. expect(ns?.name).toBe('com.example.foo');
  879. const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
  880. expect(cls?.qualifiedName).toBe('com.example.foo::Bar');
  881. const greet = result.nodes.find((n) => n.kind === 'method' && n.name === 'greet');
  882. expect(greet?.qualifiedName).toBe('com.example.foo::Bar::greet');
  883. });
  884. it('does not wrap when no package is declared', () => {
  885. const code = `
  886. public class Bar {
  887. public String greet() { return "hi"; }
  888. }
  889. `;
  890. const result = extractFromSource('Bar.java', code);
  891. expect(result.nodes.find((n) => n.kind === 'namespace')).toBeUndefined();
  892. const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
  893. expect(cls?.qualifiedName).toBe('Bar');
  894. });
  895. it('extracts anonymous-class overrides from `new T() { ... }`', () => {
  896. // The pattern that breaks the trace through `strategy.foo()` in
  897. // libraries like guava's Splitter: the lambda-returned anonymous
  898. // class overrides abstract methods on the base, but without
  899. // extracting those overrides the interface→impl synthesizer has
  900. // nothing to bridge.
  901. const code = `
  902. package com.example;
  903. abstract class Base {
  904. abstract int compute(int x);
  905. }
  906. public class Factory {
  907. public Base make() {
  908. return new Base() {
  909. @Override
  910. int compute(int x) { return x + 1; }
  911. };
  912. }
  913. }
  914. `;
  915. const result = extractFromSource('Factory.java', code);
  916. const anon = result.nodes.find((n) => n.kind === 'class' && /Base\$anon@/.test(n.name));
  917. expect(anon, 'anonymous Base subclass should be extracted as a class').toBeDefined();
  918. const compute = result.nodes.find(
  919. (n) => n.kind === 'method' && n.name === 'compute' && n.qualifiedName.includes('$anon@')
  920. );
  921. expect(compute, 'override method should be a method on the anon class').toBeDefined();
  922. expect(compute!.qualifiedName).toContain('Factory::make::<Base$anon@');
  923. expect(compute!.qualifiedName.endsWith('::compute')).toBe(true);
  924. // Anon class must extend Base so Phase 5.5 (interface-impl) can bridge.
  925. const extendsRef = result.unresolvedReferences.find(
  926. (r) => r.referenceKind === 'extends' && r.referenceName === 'Base' && r.fromNodeId === anon!.id
  927. );
  928. expect(extendsRef, 'anon class should carry an `extends Base` reference').toBeDefined();
  929. // The enclosing `make` method still emits an instantiates edge to Base —
  930. // anon extraction must not swallow that signal.
  931. const instantiatesRef = result.unresolvedReferences.find(
  932. (r) => r.referenceKind === 'instantiates' && r.referenceName === 'Base'
  933. );
  934. expect(instantiatesRef, 'enclosing method should still instantiate Base').toBeDefined();
  935. });
  936. it('extracts anonymous-class overrides inside a lambda body', () => {
  937. // The exact guava pattern: a lambda is passed to a constructor, and the
  938. // lambda body returns `new T() { @Override ... }`. The anon class must
  939. // still surface even though it sits inside a lambda_expression node.
  940. const code = `
  941. package com.example;
  942. interface Strategy {
  943. java.util.Iterator<String> iterator(String s);
  944. }
  945. abstract class BaseIter implements java.util.Iterator<String> {
  946. abstract int separatorStart(int start);
  947. }
  948. public class Splitter {
  949. private final Strategy strategy;
  950. public Splitter(Strategy s) { this.strategy = s; }
  951. public static Splitter on(char c) {
  952. return new Splitter((seq) ->
  953. new BaseIter() {
  954. @Override
  955. int separatorStart(int start) { return start + 1; }
  956. @Override public boolean hasNext() { return false; }
  957. @Override public String next() { return null; }
  958. });
  959. }
  960. }
  961. `;
  962. const result = extractFromSource('Splitter.java', code);
  963. const anon = result.nodes.find((n) => n.kind === 'class' && /BaseIter\$anon@/.test(n.name));
  964. expect(anon, 'anon BaseIter inside the lambda body should be extracted').toBeDefined();
  965. const sepStart = result.nodes.find(
  966. (n) =>
  967. n.kind === 'method' &&
  968. n.name === 'separatorStart' &&
  969. n.qualifiedName.includes('$anon@')
  970. );
  971. expect(sepStart, 'override inside the lambda-returned anon class should be a method node').toBeDefined();
  972. });
  973. });
  974. describe('C# Extraction', () => {
  975. it('should extract class declarations', () => {
  976. const code = `
  977. public class OrderService
  978. {
  979. private readonly IOrderRepository _repository;
  980. public OrderService(IOrderRepository repository)
  981. {
  982. _repository = repository;
  983. }
  984. public async Task<Order> GetOrderAsync(string id)
  985. {
  986. return await _repository.FindByIdAsync(id);
  987. }
  988. }
  989. `;
  990. const result = extractFromSource('OrderService.cs', code);
  991. const classNode = result.nodes.find((n) => n.kind === 'class');
  992. expect(classNode).toBeDefined();
  993. expect(classNode?.name).toBe('OrderService');
  994. expect(classNode?.visibility).toBe('public');
  995. });
  996. it('indexes every record form with the right kind (#831)', () => {
  997. // The grammar parses ALL record forms as record_declaration — there is no
  998. // record_struct_declaration node — so the value-type forms are told apart
  999. // by their `struct` keyword child. Positional one-liners have no body
  1000. // block and must still index (the no-body gate is for C/C++ forward
  1001. // declarations, not records).
  1002. const code = `
  1003. namespace Fixture;
  1004. public record SimplePositional(int A);
  1005. public record WithBody(int A) { public int DoubleIt() => A * 2; }
  1006. public record class ExplicitClassRec(string Name);
  1007. public record struct ValueRec(int X);
  1008. public readonly record struct ReadonlyRec(int X, int Y);
  1009. public record DerivedRec(int A, string B) : SimplePositional(A);
  1010. public record GenericRec<T>(T Value);
  1011. public partial record PartialRec(int A);
  1012. `;
  1013. const result = extractFromSource('Records.cs', code);
  1014. const kindOf = (name: string) => result.nodes.find((n) => n.name === name)?.kind;
  1015. expect(kindOf('SimplePositional')).toBe('class');
  1016. expect(kindOf('WithBody')).toBe('class');
  1017. expect(kindOf('ExplicitClassRec')).toBe('class');
  1018. expect(kindOf('DerivedRec')).toBe('class');
  1019. expect(kindOf('GenericRec')).toBe('class');
  1020. expect(kindOf('PartialRec')).toBe('class');
  1021. // Value-type records are structs, not classes.
  1022. expect(kindOf('ValueRec')).toBe('struct');
  1023. expect(kindOf('ReadonlyRec')).toBe('struct');
  1024. // Members of a bodied record still extract.
  1025. expect(kindOf('DoubleIt')).toBe('method');
  1026. });
  1027. it('indexes primary-constructor classes, including keyed-DI attribute params (#237)', () => {
  1028. // C# 12 primary constructors (`class Foo(IDep dep) { … }`) are parsed
  1029. // natively by the vendored tree-sitter-c-sharp 0.23.x grammar. The worst
  1030. // shape under the previous (older) grammar — an attribute-with-args on a
  1031. // ctor param (`[FromKeyedServices("primary")] …`, the ASP.NET keyed-DI
  1032. // pattern) — used to parse as an ERROR that swallowed the whole class, so
  1033. // the class and all its methods vanished. They now index in every case.
  1034. const code = `
  1035. public class DataService(IMemoryCache cache)
  1036. {
  1037. public void Warm() { }
  1038. }
  1039. public class InstanceService(InstanceManager m, ProfileManager p)
  1040. {
  1041. public void DeployAndLaunchAsync() { }
  1042. public void Deploy() { }
  1043. }
  1044. public partial class UpdateService(int x) : ILifetimeService
  1045. {
  1046. public void Run() { }
  1047. }
  1048. public class K1KeyedDi([FromKeyedServices("primary")] IMemoryCache cache)
  1049. {
  1050. public void Warm() { }
  1051. }
  1052. public record CatalogBrand(int Id, string Name);
  1053. `;
  1054. const result = extractFromSource('Services.cs', code);
  1055. const classNames = result.nodes.filter((n) => n.kind === 'class').map((n) => n.name);
  1056. expect(classNames).toContain('DataService');
  1057. expect(classNames).toContain('InstanceService');
  1058. expect(classNames).toContain('UpdateService'); // partial + base list
  1059. expect(classNames).toContain('K1KeyedDi'); // attribute-arg ctor param — used to vanish entirely
  1060. expect(classNames).toContain('CatalogBrand'); // record
  1061. const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
  1062. expect(methods).toContain('DeployAndLaunchAsync');
  1063. expect(methods).toContain('Deploy');
  1064. expect(methods).toContain('Run');
  1065. });
  1066. it('keeps a class indexable when a nested enum has #if-guarded members (#237)', () => {
  1067. // A `#if` directive inside an enum member list (the multi-targeting pattern
  1068. // in libraries like Newtonsoft.Json) makes the grammar emit an ERROR that,
  1069. // for a nested enum, detaches the enclosing class's member list — dropping
  1070. // most of the class's methods. A pre-parse pass blanks the directive lines
  1071. // (keeping both branches), so the class and all its methods still index.
  1072. const code = `
  1073. public class Reader
  1074. {
  1075. private enum ReadType
  1076. {
  1077. #if HAVE_DATE_TIME_OFFSET
  1078. ReadAsDateTimeOffset,
  1079. #endif
  1080. ReadAsDouble,
  1081. ReadAsString,
  1082. }
  1083. public void Open() { }
  1084. public void Close() { }
  1085. public int ReadInt() { return 0; }
  1086. }
  1087. `;
  1088. const result = extractFromSource('Reader.cs', code);
  1089. const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
  1090. // All three methods after the #if-bearing enum must survive.
  1091. expect(methods).toContain('Open');
  1092. expect(methods).toContain('Close');
  1093. expect(methods).toContain('ReadInt');
  1094. // Both enum branches are kept.
  1095. const enumMembers = result.nodes.filter((n) => n.kind === 'enum_member').map((n) => n.name);
  1096. expect(enumMembers).toContain('ReadAsDateTimeOffset');
  1097. expect(enumMembers).toContain('ReadAsDouble');
  1098. });
  1099. });
  1100. describe('PHP Extraction', () => {
  1101. it('should extract class declarations', () => {
  1102. const code = `<?php
  1103. class UserController
  1104. {
  1105. private UserService $userService;
  1106. public function __construct(UserService $userService)
  1107. {
  1108. $this->userService = $userService;
  1109. }
  1110. public function show(string $id): User
  1111. {
  1112. return $this->userService->find($id);
  1113. }
  1114. }
  1115. `;
  1116. const result = extractFromSource('UserController.php', code);
  1117. const classNode = result.nodes.find((n) => n.kind === 'class');
  1118. expect(classNode).toBeDefined();
  1119. expect(classNode?.name).toBe('UserController');
  1120. });
  1121. it('should extract class inheritance (extends) and interface implementation', () => {
  1122. const code = `<?php
  1123. class ChildController extends BaseController implements Serializable, JsonSerializable
  1124. {
  1125. public function serialize(): string
  1126. {
  1127. return json_encode($this);
  1128. }
  1129. }
  1130. `;
  1131. const result = extractFromSource('ChildController.php', code);
  1132. const classNode = result.nodes.find((n) => n.kind === 'class');
  1133. expect(classNode).toBeDefined();
  1134. expect(classNode?.name).toBe('ChildController');
  1135. const extendsRef = result.unresolvedReferences.find(
  1136. (r) => r.referenceKind === 'extends'
  1137. );
  1138. expect(extendsRef).toBeDefined();
  1139. expect(extendsRef?.referenceName).toBe('BaseController');
  1140. const implementsRefs = result.unresolvedReferences.filter(
  1141. (r) => r.referenceKind === 'implements'
  1142. );
  1143. expect(implementsRefs.length).toBe(2);
  1144. expect(implementsRefs.map((r) => r.referenceName)).toContain('Serializable');
  1145. expect(implementsRefs.map((r) => r.referenceName)).toContain('JsonSerializable');
  1146. });
  1147. });
  1148. describe('Swift Extraction', () => {
  1149. it('should extract class declarations', () => {
  1150. const code = `
  1151. public class NetworkManager {
  1152. private let session: URLSession
  1153. public init(session: URLSession = .shared) {
  1154. self.session = session
  1155. }
  1156. public func fetchData(from url: URL) async throws -> Data {
  1157. let (data, _) = try await session.data(from: url)
  1158. return data
  1159. }
  1160. }
  1161. `;
  1162. const result = extractFromSource('NetworkManager.swift', code);
  1163. const classNode = result.nodes.find((n) => n.kind === 'class');
  1164. expect(classNode).toBeDefined();
  1165. expect(classNode?.name).toBe('NetworkManager');
  1166. });
  1167. it('should extract function declarations', () => {
  1168. const code = `
  1169. func calculateSum(_ numbers: [Int]) -> Int {
  1170. return numbers.reduce(0, +)
  1171. }
  1172. public func formatCurrency(amount: Double) -> String {
  1173. return String(format: "$%.2f", amount)
  1174. }
  1175. `;
  1176. const result = extractFromSource('utils.swift', code);
  1177. const functions = result.nodes.filter((n) => n.kind === 'function');
  1178. expect(functions.length).toBeGreaterThanOrEqual(1);
  1179. });
  1180. it('should extract struct declarations', () => {
  1181. const code = `
  1182. public struct User {
  1183. let id: UUID
  1184. var name: String
  1185. var email: String
  1186. func displayName() -> String {
  1187. return name
  1188. }
  1189. }
  1190. `;
  1191. const result = extractFromSource('User.swift', code);
  1192. const structNode = result.nodes.find((n) => n.kind === 'struct');
  1193. expect(structNode).toBeDefined();
  1194. expect(structNode?.name).toBe('User');
  1195. });
  1196. it('should extract protocol declarations', () => {
  1197. const code = `
  1198. public protocol Repository {
  1199. associatedtype Entity
  1200. func find(id: String) async throws -> Entity?
  1201. func save(_ entity: Entity) async throws
  1202. }
  1203. `;
  1204. const result = extractFromSource('Repository.swift', code);
  1205. const protocolNode = result.nodes.find((n) => n.kind === 'interface');
  1206. expect(protocolNode).toBeDefined();
  1207. expect(protocolNode?.name).toBe('Repository');
  1208. });
  1209. it('should extract class inheritance and protocol conformance', () => {
  1210. const code = `
  1211. class DataRequest: Request {
  1212. func validate() {}
  1213. }
  1214. class UploadRequest: DataRequest, Sendable {
  1215. func upload() {}
  1216. }
  1217. enum AFError: Error {
  1218. case invalidURL
  1219. }
  1220. struct HTTPMethod: RawRepresentable {
  1221. let rawValue: String
  1222. }
  1223. protocol UploadConvertible: URLRequestConvertible {
  1224. func asURLRequest() throws -> URLRequest
  1225. }
  1226. `;
  1227. const result = extractFromSource('Inheritance.swift', code);
  1228. const extendsRefs = result.unresolvedReferences.filter(
  1229. (r) => r.referenceKind === 'extends'
  1230. );
  1231. // DataRequest extends Request
  1232. expect(extendsRefs.find((r) => r.referenceName === 'Request')).toBeDefined();
  1233. // UploadRequest extends DataRequest and Sendable
  1234. expect(extendsRefs.find((r) => r.referenceName === 'DataRequest')).toBeDefined();
  1235. expect(extendsRefs.find((r) => r.referenceName === 'Sendable')).toBeDefined();
  1236. // AFError extends Error
  1237. expect(extendsRefs.find((r) => r.referenceName === 'Error')).toBeDefined();
  1238. // HTTPMethod extends RawRepresentable
  1239. expect(extendsRefs.find((r) => r.referenceName === 'RawRepresentable')).toBeDefined();
  1240. // UploadConvertible extends URLRequestConvertible
  1241. expect(extendsRefs.find((r) => r.referenceName === 'URLRequestConvertible')).toBeDefined();
  1242. });
  1243. it('indexes Swift properties so they are findable: computed → property, stored → field, static → constant/variable (#1020)', () => {
  1244. const code = `
  1245. struct ReproConfig {
  1246. let reproStoredValue: Int
  1247. var reproComputedFlag: Bool {
  1248. reproStoredValue > 0
  1249. }
  1250. static let sharedLimit = 10
  1251. static var sharedCount = 0
  1252. func reproControlMethod() -> Bool {
  1253. reproComputedFlag
  1254. }
  1255. }
  1256. final class ReproService {
  1257. private let reproClassStored: String = "x"
  1258. var reproClassComputed: Int { reproClassStored.count }
  1259. }
  1260. `;
  1261. const result = extractFromSource('Repro.swift', code);
  1262. const byName = (name: string) => result.nodes.find((n) => n.name === name);
  1263. // Computed properties are the regression this fix targets: before #1020 they
  1264. // were dropped entirely, so search/explore returned nothing for them.
  1265. expect(byName('reproComputedFlag')?.kind).toBe('property');
  1266. expect(byName('reproClassComputed')?.kind).toBe('property');
  1267. // Stored instance properties stay `field` (fixed earlier in #708 — guard it).
  1268. expect(byName('reproStoredValue')?.kind).toBe('field');
  1269. expect(byName('reproClassStored')?.kind).toBe('field');
  1270. // `static let`/`static var` members remain shared constant/variable nodes.
  1271. expect(byName('sharedLimit')?.kind).toBe('constant');
  1272. expect(byName('sharedCount')?.kind).toBe('variable');
  1273. // The control method is unaffected.
  1274. expect(byName('reproControlMethod')?.kind).toBe('method');
  1275. });
  1276. it("attributes a computed property's getter calls to the property, not the type (SwiftUI body flow) (#1020)", () => {
  1277. const code = `
  1278. struct GreetingView {
  1279. let name: String
  1280. var body: some View {
  1281. let prefix = "Hi"
  1282. return VStack {
  1283. Text(greeting(prefix))
  1284. }
  1285. }
  1286. func greeting(_ p: String) -> String { p }
  1287. }
  1288. `;
  1289. const result = extractFromSource('View.swift', code);
  1290. const body = result.nodes.find((n) => n.kind === 'property' && n.name === 'body');
  1291. expect(body).toBeDefined();
  1292. // The getter's call to greeting() must originate from `body` (so a SwiftUI
  1293. // view's render flow is reachable through the property), not flatten onto the
  1294. // enclosing struct.
  1295. const callsFromBody = result.unresolvedReferences.filter(
  1296. (r) => r.fromNodeId === body!.id && r.referenceKind === 'calls'
  1297. );
  1298. expect(callsFromBody.some((r) => r.referenceName === 'greeting')).toBe(true);
  1299. // The getter is walked as a body, so a local declared inside it is NOT
  1300. // node-ified (locals are the data-flow frontier we leave uncovered). Before
  1301. // this fix the generic walker treated such a local as a struct `field`.
  1302. expect(result.nodes.find((n) => n.name === 'prefix')).toBeUndefined();
  1303. });
  1304. it('indexes a Swift protocol property requirement as a findable property (#1020)', () => {
  1305. const code = `
  1306. protocol Themable {
  1307. var accentColor: Color { get }
  1308. var title: String { get set }
  1309. }
  1310. `;
  1311. const result = extractFromSource('Themable.swift', code);
  1312. expect(result.nodes.find((n) => n.name === 'accentColor')?.kind).toBe('property');
  1313. expect(result.nodes.find((n) => n.name === 'title')?.kind).toBe('property');
  1314. });
  1315. });
  1316. describe('Kotlin Extraction', () => {
  1317. it('should extract class declarations', () => {
  1318. const code = `
  1319. class UserRepository(private val database: Database) {
  1320. fun findById(id: String): User? {
  1321. return database.query("SELECT * FROM users WHERE id = ?", id)
  1322. }
  1323. suspend fun save(user: User) {
  1324. database.insert(user)
  1325. }
  1326. }
  1327. `;
  1328. const result = extractFromSource('UserRepository.kt', code);
  1329. const classNode = result.nodes.find((n) => n.kind === 'class');
  1330. expect(classNode).toBeDefined();
  1331. expect(classNode?.name).toBe('UserRepository');
  1332. });
  1333. it('should extract function declarations', () => {
  1334. const code = `
  1335. fun calculateTotal(items: List<Item>): Double {
  1336. return items.sumOf { it.price }
  1337. }
  1338. suspend fun fetchUserData(userId: String): User {
  1339. return api.getUser(userId)
  1340. }
  1341. `;
  1342. const result = extractFromSource('utils.kt', code);
  1343. const functions = result.nodes.filter((n) => n.kind === 'function');
  1344. expect(functions.length).toBeGreaterThanOrEqual(1);
  1345. });
  1346. it('should detect suspend functions as async', () => {
  1347. const code = `
  1348. suspend fun loadData(): List<String> {
  1349. delay(1000)
  1350. return listOf("a", "b", "c")
  1351. }
  1352. `;
  1353. const result = extractFromSource('loader.kt', code);
  1354. const funcNode = result.nodes.find((n) => n.kind === 'function');
  1355. expect(funcNode).toBeDefined();
  1356. expect(funcNode?.isAsync).toBe(true);
  1357. });
  1358. it('should extract fun interface declarations', () => {
  1359. const code = `
  1360. fun interface OnObjectRetainedListener {
  1361. fun onObjectRetained()
  1362. }
  1363. `;
  1364. const result = extractFromSource('listener.kt', code);
  1365. const ifaceNode = result.nodes.find((n) => n.kind === 'interface');
  1366. expect(ifaceNode).toBeDefined();
  1367. expect(ifaceNode?.name).toBe('OnObjectRetainedListener');
  1368. const methodNode = result.nodes.find((n) => n.kind === 'method');
  1369. expect(methodNode).toBeDefined();
  1370. expect(methodNode?.name).toBe('onObjectRetained');
  1371. expect(methodNode?.qualifiedName).toBe('OnObjectRetainedListener::onObjectRetained');
  1372. });
  1373. it('should extract complex fun interface with nested classes', () => {
  1374. const code = `
  1375. fun interface EventListener {
  1376. fun onEvent(event: Event)
  1377. sealed class Event {
  1378. class DumpingHeap : Event()
  1379. }
  1380. }
  1381. `;
  1382. const result = extractFromSource('events.kt', code);
  1383. const ifaceNode = result.nodes.find((n) => n.kind === 'interface');
  1384. expect(ifaceNode).toBeDefined();
  1385. expect(ifaceNode?.name).toBe('EventListener');
  1386. // Nested sealed class should still be extracted (as sibling due to grammar limitations)
  1387. const eventClass = result.nodes.find((n) => n.kind === 'class' && n.name === 'Event');
  1388. expect(eventClass).toBeDefined();
  1389. const dumpingHeap = result.nodes.find((n) => n.kind === 'class' && n.name === 'DumpingHeap');
  1390. expect(dumpingHeap).toBeDefined();
  1391. });
  1392. it('should not affect regular function declarations', () => {
  1393. const code = `
  1394. fun interface MyCallback {
  1395. fun invoke(value: Int)
  1396. }
  1397. fun regularFunction(): String {
  1398. return "hello"
  1399. }
  1400. `;
  1401. const result = extractFromSource('mixed.kt', code);
  1402. const ifaceNode = result.nodes.find((n) => n.kind === 'interface');
  1403. expect(ifaceNode).toBeDefined();
  1404. expect(ifaceNode?.name).toBe('MyCallback');
  1405. const funcNode = result.nodes.find((n) => n.kind === 'function');
  1406. expect(funcNode).toBeDefined();
  1407. expect(funcNode?.name).toBe('regularFunction');
  1408. });
  1409. it('should extract fun interface with annotation on method (Pattern 2b)', () => {
  1410. // When the SAM method has annotations like @Throws, tree-sitter produces a different
  1411. // misparse: function_declaration > ERROR("interface Name {") instead of
  1412. // function_declaration > user_type("interface"). This is the OkHttp Interceptor pattern.
  1413. const code = `
  1414. import java.io.IOException
  1415. fun interface Interceptor {
  1416. @Throws(IOException::class)
  1417. fun intercept(chain: Chain): Response
  1418. }
  1419. `;
  1420. const result = extractFromSource('interceptor.kt', code);
  1421. const ifaceNode = result.nodes.find((n) => n.kind === 'interface');
  1422. expect(ifaceNode).toBeDefined();
  1423. expect(ifaceNode?.name).toBe('Interceptor');
  1424. });
  1425. it('should extract methods from interface with nested fun interface', () => {
  1426. // When an interface contains a nested `fun interface`, tree-sitter misparsed
  1427. // the parent body as ERROR. Methods inside should still be extracted.
  1428. const code = `
  1429. interface WebSocket {
  1430. fun request(): Request
  1431. fun send(text: String): Boolean
  1432. fun cancel()
  1433. fun interface Factory {
  1434. fun newWebSocket(request: Request): WebSocket
  1435. }
  1436. }
  1437. `;
  1438. const result = extractFromSource('websocket.kt', code);
  1439. const wsIface = result.nodes.find((n) => n.kind === 'interface' && n.name === 'WebSocket');
  1440. expect(wsIface).toBeDefined();
  1441. const methods = result.nodes.filter((n) => n.kind === 'method' && n.qualifiedName?.startsWith('WebSocket::'));
  1442. const methodNames = methods.map((m) => m.name);
  1443. expect(methodNames).toContain('request');
  1444. expect(methodNames).toContain('send');
  1445. expect(methodNames).toContain('cancel');
  1446. });
  1447. it('wraps top-level declarations in a namespace from package_header', () => {
  1448. const code = `
  1449. package com.example.foo
  1450. class Bar {
  1451. fun greet(): String = "hi"
  1452. }
  1453. fun util(): Int = 42
  1454. `;
  1455. const result = extractFromSource('Bar.kt', code);
  1456. const ns = result.nodes.find((n) => n.kind === 'namespace');
  1457. expect(ns?.name).toBe('com.example.foo');
  1458. const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
  1459. expect(cls?.qualifiedName).toBe('com.example.foo::Bar');
  1460. const greet = result.nodes.find((n) => n.kind === 'method' && n.name === 'greet');
  1461. expect(greet?.qualifiedName).toBe('com.example.foo::Bar::greet');
  1462. const util = result.nodes.find((n) => n.kind === 'function' && n.name === 'util');
  1463. expect(util?.qualifiedName).toBe('com.example.foo::util');
  1464. });
  1465. it('handles a single-segment package', () => {
  1466. const code = `
  1467. package foo
  1468. class Bar
  1469. `;
  1470. const result = extractFromSource('Bar.kt', code);
  1471. const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
  1472. expect(cls?.qualifiedName).toBe('foo::Bar');
  1473. });
  1474. it('does not wrap when no package is declared', () => {
  1475. const code = `
  1476. class Bar {
  1477. fun greet() = "hi"
  1478. }
  1479. `;
  1480. const result = extractFromSource('Bar.kt', code);
  1481. expect(result.nodes.find((n) => n.kind === 'namespace')).toBeUndefined();
  1482. const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
  1483. expect(cls?.qualifiedName).toBe('Bar');
  1484. });
  1485. });
  1486. describe('Dart Extraction', () => {
  1487. it('should extract class declarations', () => {
  1488. const code = `
  1489. class UserService {
  1490. final Database _db;
  1491. Future<User> findById(String id) async {
  1492. return await _db.query(id);
  1493. }
  1494. void _privateMethod() {}
  1495. }
  1496. `;
  1497. const result = extractFromSource('service.dart', code);
  1498. const classNode = result.nodes.find((n) => n.kind === 'class');
  1499. expect(classNode).toBeDefined();
  1500. expect(classNode?.name).toBe('UserService');
  1501. expect(classNode?.visibility).toBe('public');
  1502. const methodNodes = result.nodes.filter((n) => n.kind === 'method');
  1503. expect(methodNodes.length).toBeGreaterThanOrEqual(2);
  1504. const findById = methodNodes.find((m) => m.name === 'findById');
  1505. expect(findById).toBeDefined();
  1506. expect(findById?.isAsync).toBe(true);
  1507. const privateMethod = methodNodes.find((m) => m.name === '_privateMethod');
  1508. expect(privateMethod).toBeDefined();
  1509. expect(privateMethod?.visibility).toBe('private');
  1510. // Dart models a method body as a SIBLING of the signature, so the method
  1511. // node must be extended to span its body (not just the signature line) —
  1512. // required for body-level analysis (callees, the callback synthesizer).
  1513. expect(findById!.endLine).toBeGreaterThan(findById!.startLine);
  1514. });
  1515. it('should extract top-level function declarations', () => {
  1516. const code = `
  1517. void topLevelFunction(String name) {
  1518. print(name);
  1519. }
  1520. `;
  1521. const result = extractFromSource('utils.dart', code);
  1522. const funcNode = result.nodes.find((n) => n.kind === 'function');
  1523. expect(funcNode).toBeDefined();
  1524. expect(funcNode?.name).toBe('topLevelFunction');
  1525. expect(funcNode?.language).toBe('dart');
  1526. });
  1527. it('should extract enum declarations', () => {
  1528. const code = `
  1529. enum Status { active, inactive, pending }
  1530. `;
  1531. const result = extractFromSource('models.dart', code);
  1532. const enumNode = result.nodes.find((n) => n.kind === 'enum');
  1533. expect(enumNode).toBeDefined();
  1534. expect(enumNode?.name).toBe('Status');
  1535. });
  1536. it('should extract mixin declarations', () => {
  1537. const code = `
  1538. mixin LoggerMixin {
  1539. void log(String message) {}
  1540. }
  1541. `;
  1542. const result = extractFromSource('mixins.dart', code);
  1543. const classNode = result.nodes.find((n) => n.kind === 'class');
  1544. expect(classNode).toBeDefined();
  1545. expect(classNode?.name).toBe('LoggerMixin');
  1546. const methodNode = result.nodes.find((n) => n.kind === 'method');
  1547. expect(methodNode).toBeDefined();
  1548. expect(methodNode?.name).toBe('log');
  1549. });
  1550. it('should extract extension declarations', () => {
  1551. const code = `
  1552. extension StringExt on String {
  1553. bool get isBlank => trim().isEmpty;
  1554. }
  1555. `;
  1556. const result = extractFromSource('extensions.dart', code);
  1557. const classNode = result.nodes.find((n) => n.kind === 'class');
  1558. expect(classNode).toBeDefined();
  1559. expect(classNode?.name).toBe('StringExt');
  1560. });
  1561. it('should detect static methods', () => {
  1562. const code = `
  1563. class Utils {
  1564. static void doWork() {}
  1565. }
  1566. `;
  1567. const result = extractFromSource('utils.dart', code);
  1568. const methodNode = result.nodes.find((n) => n.kind === 'method');
  1569. expect(methodNode).toBeDefined();
  1570. expect(methodNode?.name).toBe('doWork');
  1571. expect(methodNode?.isStatic).toBe(true);
  1572. });
  1573. it('should detect async functions', () => {
  1574. const code = `
  1575. Future<String> fetchData() async {
  1576. return await http.get('/data');
  1577. }
  1578. `;
  1579. const result = extractFromSource('api.dart', code);
  1580. const funcNode = result.nodes.find((n) => n.kind === 'function');
  1581. expect(funcNode).toBeDefined();
  1582. expect(funcNode?.name).toBe('fetchData');
  1583. expect(funcNode?.isAsync).toBe(true);
  1584. });
  1585. it('should detect private visibility via underscore convention', () => {
  1586. const code = `
  1587. void _privateHelper() {}
  1588. void publicFunction() {}
  1589. `;
  1590. const result = extractFromSource('helpers.dart', code);
  1591. const functions = result.nodes.filter((n) => n.kind === 'function');
  1592. const privateFunc = functions.find((f) => f.name === '_privateHelper');
  1593. const publicFunc = functions.find((f) => f.name === 'publicFunction');
  1594. expect(privateFunc?.visibility).toBe('private');
  1595. expect(publicFunc?.visibility).toBe('public');
  1596. });
  1597. });
  1598. describe('Import Extraction', () => {
  1599. describe('TypeScript/JavaScript imports', () => {
  1600. it('should extract default imports', () => {
  1601. const code = `import React from 'react';`;
  1602. const result = extractFromSource('app.tsx', code);
  1603. const importNode = result.nodes.find((n) => n.kind === 'import');
  1604. expect(importNode).toBeDefined();
  1605. expect(importNode?.name).toBe('react');
  1606. expect(importNode?.signature).toBe("import React from 'react';");
  1607. });
  1608. it('should extract named imports', () => {
  1609. const code = `import { Bug, Database } from '@phosphor-icons/react';`;
  1610. const result = extractFromSource('icons.tsx', code);
  1611. const importNode = result.nodes.find((n) => n.kind === 'import');
  1612. expect(importNode).toBeDefined();
  1613. expect(importNode?.name).toBe('@phosphor-icons/react');
  1614. expect(importNode?.signature).toContain('Bug');
  1615. expect(importNode?.signature).toContain('Database');
  1616. });
  1617. it('should extract namespace imports', () => {
  1618. const code = `import * as Icons from '@phosphor-icons/react';`;
  1619. const result = extractFromSource('icons.tsx', code);
  1620. const importNode = result.nodes.find((n) => n.kind === 'import');
  1621. expect(importNode).toBeDefined();
  1622. expect(importNode?.name).toBe('@phosphor-icons/react');
  1623. expect(importNode?.signature).toContain('* as Icons');
  1624. });
  1625. it('should extract side-effect imports', () => {
  1626. const code = `import './styles.css';`;
  1627. const result = extractFromSource('app.tsx', code);
  1628. const importNode = result.nodes.find((n) => n.kind === 'import');
  1629. expect(importNode).toBeDefined();
  1630. expect(importNode?.name).toBe('./styles.css');
  1631. });
  1632. it('should extract mixed imports (default + named)', () => {
  1633. const code = `import React, { useState, useEffect } from 'react';`;
  1634. const result = extractFromSource('app.tsx', code);
  1635. const importNode = result.nodes.find((n) => n.kind === 'import');
  1636. expect(importNode).toBeDefined();
  1637. expect(importNode?.name).toBe('react');
  1638. expect(importNode?.signature).toContain('React');
  1639. expect(importNode?.signature).toContain('useState');
  1640. expect(importNode?.signature).toContain('useEffect');
  1641. });
  1642. it('should extract multiple import statements', () => {
  1643. const code = `
  1644. import React from 'react';
  1645. import { Button } from './components';
  1646. import './styles.css';
  1647. `;
  1648. const result = extractFromSource('app.tsx', code);
  1649. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  1650. expect(importNodes.length).toBe(3);
  1651. const names = importNodes.map((n) => n.name);
  1652. expect(names).toContain('react');
  1653. expect(names).toContain('./components');
  1654. expect(names).toContain('./styles.css');
  1655. });
  1656. it('should extract type imports', () => {
  1657. const code = `import type { FC, ReactNode } from 'react';`;
  1658. const result = extractFromSource('types.ts', code);
  1659. const importNode = result.nodes.find((n) => n.kind === 'import');
  1660. expect(importNode).toBeDefined();
  1661. expect(importNode?.name).toBe('react');
  1662. expect(importNode?.signature).toContain('type');
  1663. expect(importNode?.signature).toContain('FC');
  1664. });
  1665. it('should extract aliased named imports', () => {
  1666. const code = `import { useState as useStateAlias } from 'react';`;
  1667. const result = extractFromSource('hooks.ts', code);
  1668. const importNode = result.nodes.find((n) => n.kind === 'import');
  1669. expect(importNode).toBeDefined();
  1670. expect(importNode?.name).toBe('react');
  1671. expect(importNode?.signature).toContain('useState');
  1672. expect(importNode?.signature).toContain('useStateAlias');
  1673. });
  1674. it('should extract relative path imports', () => {
  1675. const code = `import { helper } from '../utils/helper';`;
  1676. const result = extractFromSource('components/Button.tsx', code);
  1677. const importNode = result.nodes.find((n) => n.kind === 'import');
  1678. expect(importNode).toBeDefined();
  1679. expect(importNode?.name).toBe('../utils/helper');
  1680. expect(importNode?.signature).toContain('helper');
  1681. });
  1682. });
  1683. describe('Python imports', () => {
  1684. it('should extract simple import statement', () => {
  1685. const code = `import json`;
  1686. const result = extractFromSource('utils.py', code);
  1687. const importNode = result.nodes.find((n) => n.kind === 'import');
  1688. expect(importNode).toBeDefined();
  1689. expect(importNode?.name).toBe('json');
  1690. });
  1691. it('should extract from import statement', () => {
  1692. const code = `from os import path`;
  1693. const result = extractFromSource('utils.py', code);
  1694. const importNode = result.nodes.find((n) => n.kind === 'import');
  1695. expect(importNode).toBeDefined();
  1696. expect(importNode?.name).toBe('os');
  1697. expect(importNode?.signature).toContain('path');
  1698. });
  1699. it('should extract multiple imports from same module', () => {
  1700. const code = `from typing import List, Dict, Optional`;
  1701. const result = extractFromSource('types.py', code);
  1702. const importNode = result.nodes.find((n) => n.kind === 'import');
  1703. expect(importNode).toBeDefined();
  1704. expect(importNode?.name).toBe('typing');
  1705. expect(importNode?.signature).toContain('List');
  1706. expect(importNode?.signature).toContain('Dict');
  1707. });
  1708. it('should extract multiple import statements', () => {
  1709. const code = `
  1710. import os
  1711. import sys
  1712. `;
  1713. const result = extractFromSource('main.py', code);
  1714. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  1715. expect(importNodes.length).toBe(2);
  1716. const names = importNodes.map((n) => n.name);
  1717. expect(names).toContain('os');
  1718. expect(names).toContain('sys');
  1719. });
  1720. it('should extract aliased import', () => {
  1721. const code = `import numpy as np`;
  1722. const result = extractFromSource('data.py', code);
  1723. const importNode = result.nodes.find((n) => n.kind === 'import');
  1724. expect(importNode).toBeDefined();
  1725. expect(importNode?.name).toBe('numpy');
  1726. expect(importNode?.signature).toContain('as np');
  1727. });
  1728. it('should extract relative import', () => {
  1729. const code = `from .utils import helper`;
  1730. const result = extractFromSource('module.py', code);
  1731. const importNode = result.nodes.find((n) => n.kind === 'import');
  1732. expect(importNode).toBeDefined();
  1733. expect(importNode?.name).toBe('.utils');
  1734. expect(importNode?.signature).toContain('helper');
  1735. });
  1736. it('should extract wildcard import', () => {
  1737. const code = `from typing import *`;
  1738. const result = extractFromSource('types.py', code);
  1739. const importNode = result.nodes.find((n) => n.kind === 'import');
  1740. expect(importNode).toBeDefined();
  1741. expect(importNode?.name).toBe('typing');
  1742. expect(importNode?.signature).toContain('*');
  1743. });
  1744. });
  1745. describe('Rust imports', () => {
  1746. it('should extract simple use declaration', () => {
  1747. const code = `use std::io;`;
  1748. const result = extractFromSource('main.rs', code);
  1749. const importNode = result.nodes.find((n) => n.kind === 'import');
  1750. expect(importNode).toBeDefined();
  1751. expect(importNode?.name).toBe('std');
  1752. expect(importNode?.signature).toBe('use std::io;');
  1753. });
  1754. it('should extract scoped use list', () => {
  1755. const code = `use std::{ffi::OsStr, io, path::Path};`;
  1756. const result = extractFromSource('main.rs', code);
  1757. const importNode = result.nodes.find((n) => n.kind === 'import');
  1758. expect(importNode).toBeDefined();
  1759. expect(importNode?.name).toBe('std');
  1760. expect(importNode?.signature).toContain('ffi::OsStr');
  1761. expect(importNode?.signature).toContain('path::Path');
  1762. });
  1763. it('should extract crate imports', () => {
  1764. const code = `use crate::error::Error;`;
  1765. const result = extractFromSource('lib.rs', code);
  1766. const importNode = result.nodes.find((n) => n.kind === 'import');
  1767. expect(importNode).toBeDefined();
  1768. expect(importNode?.name).toBe('crate');
  1769. });
  1770. it('should extract super imports', () => {
  1771. const code = `use super::utils;`;
  1772. const result = extractFromSource('submod.rs', code);
  1773. const importNode = result.nodes.find((n) => n.kind === 'import');
  1774. expect(importNode).toBeDefined();
  1775. expect(importNode?.name).toBe('super');
  1776. });
  1777. it('should extract external crate imports', () => {
  1778. const code = `use serde::{Serialize, Deserialize};`;
  1779. const result = extractFromSource('types.rs', code);
  1780. const importNode = result.nodes.find((n) => n.kind === 'import');
  1781. expect(importNode).toBeDefined();
  1782. expect(importNode?.name).toBe('serde');
  1783. expect(importNode?.signature).toContain('Serialize');
  1784. expect(importNode?.signature).toContain('Deserialize');
  1785. });
  1786. });
  1787. describe('Go imports', () => {
  1788. it('should extract single import', () => {
  1789. const code = `
  1790. package main
  1791. import "fmt"
  1792. `;
  1793. const result = extractFromSource('main.go', code);
  1794. const importNode = result.nodes.find((n) => n.kind === 'import');
  1795. expect(importNode).toBeDefined();
  1796. expect(importNode?.name).toBe('fmt');
  1797. });
  1798. it('should extract grouped imports', () => {
  1799. const code = `
  1800. package main
  1801. import (
  1802. "fmt"
  1803. "os"
  1804. "encoding/json"
  1805. )
  1806. `;
  1807. const result = extractFromSource('main.go', code);
  1808. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  1809. expect(importNodes.length).toBe(3);
  1810. const names = importNodes.map((n) => n.name);
  1811. expect(names).toContain('fmt');
  1812. expect(names).toContain('os');
  1813. expect(names).toContain('encoding/json');
  1814. });
  1815. it('should extract aliased import', () => {
  1816. const code = `
  1817. package main
  1818. import f "fmt"
  1819. `;
  1820. const result = extractFromSource('main.go', code);
  1821. const importNode = result.nodes.find((n) => n.kind === 'import');
  1822. expect(importNode).toBeDefined();
  1823. expect(importNode?.name).toBe('fmt');
  1824. expect(importNode?.signature).toContain('f');
  1825. });
  1826. it('should extract dot import', () => {
  1827. const code = `
  1828. package main
  1829. import . "math"
  1830. `;
  1831. const result = extractFromSource('main.go', code);
  1832. const importNode = result.nodes.find((n) => n.kind === 'import');
  1833. expect(importNode).toBeDefined();
  1834. expect(importNode?.name).toBe('math');
  1835. expect(importNode?.signature).toContain('.');
  1836. });
  1837. it('should extract blank import', () => {
  1838. const code = `
  1839. package main
  1840. import _ "github.com/go-sql-driver/mysql"
  1841. `;
  1842. const result = extractFromSource('main.go', code);
  1843. const importNode = result.nodes.find((n) => n.kind === 'import');
  1844. expect(importNode).toBeDefined();
  1845. expect(importNode?.name).toBe('github.com/go-sql-driver/mysql');
  1846. expect(importNode?.signature).toContain('_');
  1847. });
  1848. });
  1849. describe('Swift imports', () => {
  1850. it('should extract simple import', () => {
  1851. const code = `import Foundation`;
  1852. const result = extractFromSource('main.swift', code);
  1853. const importNode = result.nodes.find((n) => n.kind === 'import');
  1854. expect(importNode).toBeDefined();
  1855. expect(importNode?.name).toBe('Foundation');
  1856. expect(importNode?.signature).toBe('import Foundation');
  1857. });
  1858. it('should extract @testable import', () => {
  1859. const code = `@testable import Alamofire`;
  1860. const result = extractFromSource('Tests.swift', code);
  1861. const importNode = result.nodes.find((n) => n.kind === 'import');
  1862. expect(importNode).toBeDefined();
  1863. expect(importNode?.name).toBe('Alamofire');
  1864. expect(importNode?.signature).toContain('@testable');
  1865. });
  1866. it('should extract @preconcurrency import', () => {
  1867. const code = `@preconcurrency import Security`;
  1868. const result = extractFromSource('Auth.swift', code);
  1869. const importNode = result.nodes.find((n) => n.kind === 'import');
  1870. expect(importNode).toBeDefined();
  1871. expect(importNode?.name).toBe('Security');
  1872. });
  1873. it('should extract multiple imports', () => {
  1874. const code = `
  1875. import Foundation
  1876. import UIKit
  1877. import Alamofire
  1878. `;
  1879. const result = extractFromSource('App.swift', code);
  1880. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  1881. expect(importNodes.length).toBe(3);
  1882. const names = importNodes.map((n) => n.name);
  1883. expect(names).toContain('Foundation');
  1884. expect(names).toContain('UIKit');
  1885. expect(names).toContain('Alamofire');
  1886. });
  1887. });
  1888. describe('Kotlin imports', () => {
  1889. it('should extract simple import', () => {
  1890. const code = `import java.io.IOException`;
  1891. const result = extractFromSource('Main.kt', code);
  1892. const importNode = result.nodes.find((n) => n.kind === 'import');
  1893. expect(importNode).toBeDefined();
  1894. expect(importNode?.name).toBe('java.io.IOException');
  1895. expect(importNode?.signature).toBe('import java.io.IOException');
  1896. });
  1897. it('should extract aliased import', () => {
  1898. const code = `import okhttp3.Request.Builder as RequestBuilder`;
  1899. const result = extractFromSource('Utils.kt', code);
  1900. const importNode = result.nodes.find((n) => n.kind === 'import');
  1901. expect(importNode).toBeDefined();
  1902. expect(importNode?.name).toBe('okhttp3.Request.Builder');
  1903. expect(importNode?.signature).toContain('as RequestBuilder');
  1904. });
  1905. it('should extract wildcard import', () => {
  1906. const code = `import java.util.concurrent.TimeUnit.*`;
  1907. const result = extractFromSource('Time.kt', code);
  1908. const importNode = result.nodes.find((n) => n.kind === 'import');
  1909. expect(importNode).toBeDefined();
  1910. expect(importNode?.name).toBe('java.util.concurrent.TimeUnit');
  1911. expect(importNode?.signature).toContain('.*');
  1912. });
  1913. it('should extract multiple imports', () => {
  1914. const code = `
  1915. import java.io.IOException
  1916. import kotlin.test.assertFailsWith
  1917. import okhttp3.OkHttpClient
  1918. `;
  1919. const result = extractFromSource('Test.kt', code);
  1920. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  1921. expect(importNodes.length).toBe(3);
  1922. const names = importNodes.map((n) => n.name);
  1923. expect(names).toContain('java.io.IOException');
  1924. expect(names).toContain('kotlin.test.assertFailsWith');
  1925. expect(names).toContain('okhttp3.OkHttpClient');
  1926. });
  1927. });
  1928. describe('Java imports', () => {
  1929. it('should extract simple import', () => {
  1930. const code = `import java.util.List;`;
  1931. const result = extractFromSource('Main.java', code);
  1932. const importNode = result.nodes.find((n) => n.kind === 'import');
  1933. expect(importNode).toBeDefined();
  1934. expect(importNode?.name).toBe('java.util.List');
  1935. expect(importNode?.signature).toBe('import java.util.List;');
  1936. });
  1937. it('should extract static import', () => {
  1938. const code = `import static java.util.Collections.emptyList;`;
  1939. const result = extractFromSource('Utils.java', code);
  1940. const importNode = result.nodes.find((n) => n.kind === 'import');
  1941. expect(importNode).toBeDefined();
  1942. expect(importNode?.name).toBe('java.util.Collections.emptyList');
  1943. expect(importNode?.signature).toContain('static');
  1944. });
  1945. it('should extract wildcard import', () => {
  1946. const code = `import java.util.*;`;
  1947. const result = extractFromSource('App.java', code);
  1948. const importNode = result.nodes.find((n) => n.kind === 'import');
  1949. expect(importNode).toBeDefined();
  1950. expect(importNode?.name).toBe('java.util');
  1951. expect(importNode?.signature).toContain('.*');
  1952. });
  1953. it('should extract nested class import', () => {
  1954. const code = `import java.util.Map.Entry;`;
  1955. const result = extractFromSource('MapUtil.java', code);
  1956. const importNode = result.nodes.find((n) => n.kind === 'import');
  1957. expect(importNode).toBeDefined();
  1958. expect(importNode?.name).toBe('java.util.Map.Entry');
  1959. });
  1960. it('should extract multiple imports', () => {
  1961. const code = `
  1962. import java.util.List;
  1963. import java.util.Map;
  1964. import java.io.IOException;
  1965. `;
  1966. const result = extractFromSource('Service.java', code);
  1967. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  1968. expect(importNodes.length).toBe(3);
  1969. const names = importNodes.map((n) => n.name);
  1970. expect(names).toContain('java.util.List');
  1971. expect(names).toContain('java.util.Map');
  1972. expect(names).toContain('java.io.IOException');
  1973. });
  1974. });
  1975. describe('C# imports', () => {
  1976. it('should extract simple using', () => {
  1977. const code = `using System;`;
  1978. const result = extractFromSource('Program.cs', code);
  1979. const importNode = result.nodes.find((n) => n.kind === 'import');
  1980. expect(importNode).toBeDefined();
  1981. expect(importNode?.name).toBe('System');
  1982. expect(importNode?.signature).toBe('using System;');
  1983. });
  1984. it('should extract qualified using', () => {
  1985. const code = `using System.Collections.Generic;`;
  1986. const result = extractFromSource('Utils.cs', code);
  1987. const importNode = result.nodes.find((n) => n.kind === 'import');
  1988. expect(importNode).toBeDefined();
  1989. expect(importNode?.name).toBe('System.Collections.Generic');
  1990. });
  1991. it('should extract static using', () => {
  1992. const code = `using static System.Console;`;
  1993. const result = extractFromSource('App.cs', code);
  1994. const importNode = result.nodes.find((n) => n.kind === 'import');
  1995. expect(importNode).toBeDefined();
  1996. expect(importNode?.name).toBe('System.Console');
  1997. expect(importNode?.signature).toContain('static');
  1998. });
  1999. it('should extract alias using', () => {
  2000. const code = `using MyList = System.Collections.Generic.List<int>;`;
  2001. const result = extractFromSource('Types.cs', code);
  2002. const importNode = result.nodes.find((n) => n.kind === 'import');
  2003. expect(importNode).toBeDefined();
  2004. expect(importNode?.name).toBe('System.Collections.Generic.List<int>');
  2005. expect(importNode?.signature).toContain('MyList =');
  2006. });
  2007. it('should extract multiple usings', () => {
  2008. const code = `
  2009. using System;
  2010. using System.Threading.Tasks;
  2011. using Microsoft.Extensions.DependencyInjection;
  2012. `;
  2013. const result = extractFromSource('Service.cs', code);
  2014. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  2015. expect(importNodes.length).toBe(3);
  2016. const names = importNodes.map((n) => n.name);
  2017. expect(names).toContain('System');
  2018. expect(names).toContain('System.Threading.Tasks');
  2019. expect(names).toContain('Microsoft.Extensions.DependencyInjection');
  2020. });
  2021. });
  2022. describe('PHP imports', () => {
  2023. it('should extract simple use', () => {
  2024. const code = `<?php use PHPUnit\\Framework\\TestCase;`;
  2025. const result = extractFromSource('Test.php', code);
  2026. const importNode = result.nodes.find((n) => n.kind === 'import');
  2027. expect(importNode).toBeDefined();
  2028. expect(importNode?.name).toBe('PHPUnit\\Framework\\TestCase');
  2029. });
  2030. it('should extract aliased use', () => {
  2031. const code = `<?php use Mockery as m;`;
  2032. const result = extractFromSource('Test.php', code);
  2033. const importNode = result.nodes.find((n) => n.kind === 'import');
  2034. expect(importNode).toBeDefined();
  2035. expect(importNode?.name).toBe('Mockery');
  2036. expect(importNode?.signature).toContain('as m');
  2037. });
  2038. it('should extract function use', () => {
  2039. const code = `<?php use function Illuminate\\Support\\env;`;
  2040. const result = extractFromSource('helpers.php', code);
  2041. const importNode = result.nodes.find((n) => n.kind === 'import');
  2042. expect(importNode).toBeDefined();
  2043. expect(importNode?.name).toBe('Illuminate\\Support\\env');
  2044. expect(importNode?.signature).toContain('function');
  2045. });
  2046. it('should extract grouped use', () => {
  2047. const code = `<?php use Illuminate\\Database\\{Model, Builder};`;
  2048. const result = extractFromSource('Models.php', code);
  2049. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  2050. expect(importNodes.length).toBe(2);
  2051. const names = importNodes.map((n) => n.name);
  2052. expect(names).toContain('Illuminate\\Database\\Model');
  2053. expect(names).toContain('Illuminate\\Database\\Builder');
  2054. });
  2055. it('should extract multiple uses', () => {
  2056. const code = `<?php
  2057. use Illuminate\\Support\\Collection;
  2058. use Illuminate\\Support\\Str;
  2059. use Closure;
  2060. `;
  2061. const result = extractFromSource('Service.php', code);
  2062. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  2063. expect(importNodes.length).toBe(3);
  2064. const names = importNodes.map((n) => n.name);
  2065. expect(names).toContain('Illuminate\\Support\\Collection');
  2066. expect(names).toContain('Illuminate\\Support\\Str');
  2067. expect(names).toContain('Closure');
  2068. });
  2069. it('should extract include/require (+_once) static paths as imports (#660)', () => {
  2070. const code = `<?php
  2071. require_once("lib.php");
  2072. include 'other.php';
  2073. require 'r.php';
  2074. include_once("io.php");
  2075. `;
  2076. const result = extractFromSource('page.php', code);
  2077. const names = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  2078. expect(names).toContain('lib.php');
  2079. expect(names).toContain('other.php');
  2080. expect(names).toContain('r.php');
  2081. expect(names).toContain('io.php');
  2082. });
  2083. it('should skip dynamic include/require with no static path (#660)', () => {
  2084. const code = `<?php
  2085. require_once(__DIR__ . '/dyn.php');
  2086. include $file;
  2087. include "tpl/{$name}.php";
  2088. `;
  2089. const result = extractFromSource('page.php', code);
  2090. const imports = result.nodes.filter((n) => n.kind === 'import');
  2091. expect(imports).toHaveLength(0);
  2092. });
  2093. it('should extract include alongside namespace use without interference (#660)', () => {
  2094. const code = `<?php
  2095. use App\\Service\\Mailer;
  2096. require_once("bootstrap.php");
  2097. `;
  2098. const result = extractFromSource('page.php', code);
  2099. const names = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  2100. expect(names).toContain('App\\Service\\Mailer');
  2101. expect(names).toContain('bootstrap.php');
  2102. });
  2103. });
  2104. describe('Ruby imports', () => {
  2105. it('should extract require', () => {
  2106. const code = `require 'json'`;
  2107. const result = extractFromSource('app.rb', code);
  2108. const importNode = result.nodes.find((n) => n.kind === 'import');
  2109. expect(importNode).toBeDefined();
  2110. expect(importNode?.name).toBe('json');
  2111. expect(importNode?.signature).toBe("require 'json'");
  2112. });
  2113. it('should extract require with path', () => {
  2114. const code = `require 'active_support/core_ext/string'`;
  2115. const result = extractFromSource('config.rb', code);
  2116. const importNode = result.nodes.find((n) => n.kind === 'import');
  2117. expect(importNode).toBeDefined();
  2118. expect(importNode?.name).toBe('active_support/core_ext/string');
  2119. });
  2120. it('should extract require_relative', () => {
  2121. const code = `require_relative '../test_helper'`;
  2122. const result = extractFromSource('test/my_test.rb', code);
  2123. const importNode = result.nodes.find((n) => n.kind === 'import');
  2124. expect(importNode).toBeDefined();
  2125. expect(importNode?.name).toBe('../test_helper');
  2126. expect(importNode?.signature).toContain('require_relative');
  2127. });
  2128. it('should not extract non-require calls', () => {
  2129. const code = `puts 'hello'`;
  2130. const result = extractFromSource('app.rb', code);
  2131. const importNode = result.nodes.find((n) => n.kind === 'import');
  2132. expect(importNode).toBeUndefined();
  2133. });
  2134. it('should extract multiple requires', () => {
  2135. const code = `
  2136. require 'json'
  2137. require 'yaml'
  2138. require_relative 'helper'
  2139. `;
  2140. const result = extractFromSource('lib.rb', code);
  2141. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  2142. expect(importNodes.length).toBe(3);
  2143. const names = importNodes.map((n) => n.name);
  2144. expect(names).toContain('json');
  2145. expect(names).toContain('yaml');
  2146. expect(names).toContain('helper');
  2147. });
  2148. });
  2149. describe('Ruby modules', () => {
  2150. it('should extract module as module node with containment', () => {
  2151. const code = `
  2152. module CachedCounting
  2153. def self.disable
  2154. @enabled = false
  2155. end
  2156. def perform_increment!(key, count)
  2157. write_cache!(key, count)
  2158. end
  2159. end
  2160. `;
  2161. const result = extractFromSource('concerns/cached_counting.rb', code);
  2162. const moduleNode = result.nodes.find((n) => n.kind === 'module' && n.name === 'CachedCounting');
  2163. expect(moduleNode).toBeDefined();
  2164. expect(moduleNode?.qualifiedName).toBe('CachedCounting');
  2165. // Methods inside module should have module-qualified names
  2166. const disableMethod = result.nodes.find((n) => n.name === 'disable' && n.kind === 'method');
  2167. expect(disableMethod).toBeDefined();
  2168. expect(disableMethod?.qualifiedName).toBe('CachedCounting::disable');
  2169. const incrementMethod = result.nodes.find((n) => n.name === 'perform_increment!' && n.kind === 'method');
  2170. expect(incrementMethod).toBeDefined();
  2171. expect(incrementMethod?.qualifiedName).toBe('CachedCounting::perform_increment!');
  2172. // Containment edge from module to methods
  2173. const containsEdges = result.edges.filter((e) => e.source === moduleNode?.id && e.kind === 'contains');
  2174. expect(containsEdges.length).toBeGreaterThanOrEqual(2);
  2175. });
  2176. it('should handle nested modules with classes', () => {
  2177. const code = `
  2178. module Discourse
  2179. module Auth
  2180. class AuthProvider
  2181. def authenticate(params)
  2182. validate(params)
  2183. end
  2184. end
  2185. end
  2186. end
  2187. `;
  2188. const result = extractFromSource('lib/auth.rb', code);
  2189. const discourseModule = result.nodes.find((n) => n.kind === 'module' && n.name === 'Discourse');
  2190. expect(discourseModule).toBeDefined();
  2191. const authModule = result.nodes.find((n) => n.kind === 'module' && n.name === 'Auth');
  2192. expect(authModule).toBeDefined();
  2193. expect(authModule?.qualifiedName).toBe('Discourse::Auth');
  2194. const authProvider = result.nodes.find((n) => n.kind === 'class' && n.name === 'AuthProvider');
  2195. expect(authProvider).toBeDefined();
  2196. expect(authProvider?.qualifiedName).toBe('Discourse::Auth::AuthProvider');
  2197. const authMethod = result.nodes.find((n) => n.name === 'authenticate');
  2198. expect(authMethod).toBeDefined();
  2199. expect(authMethod?.qualifiedName).toBe('Discourse::Auth::AuthProvider::authenticate');
  2200. });
  2201. });
  2202. describe('PHP return type capture (#608)', () => {
  2203. it('captures self/static factory returns as the `self` marker; primitives as undefined', () => {
  2204. const code = `<?php
  2205. class ApiClient {
  2206. public static function for(string $c): self { return new self; }
  2207. public static function make(): static { return new static; }
  2208. public function send(array $p): array { return []; }
  2209. }`;
  2210. const result = extractFromSource('ApiClient.php', code);
  2211. expect(result.nodes.find((n) => n.name === 'for' && n.kind === 'method')?.returnType).toBe('self');
  2212. expect(result.nodes.find((n) => n.name === 'make' && n.kind === 'method')?.returnType).toBe('self');
  2213. // `array` is not a class to chain on → no return type recorded.
  2214. expect(result.nodes.find((n) => n.name === 'send' && n.kind === 'method')?.returnType).toBeUndefined();
  2215. });
  2216. it('captures a concrete return type as its short class name', () => {
  2217. const code = `<?php
  2218. namespace App;
  2219. class WidgetFactory { public static function make(): Widget { return new Widget(); } }`;
  2220. const result = extractFromSource('WidgetFactory.php', code);
  2221. expect(result.nodes.find((n) => n.name === 'make' && n.kind === 'method')?.returnType).toBe('Widget');
  2222. });
  2223. });
  2224. describe('C/C++ return type capture (#645)', () => {
  2225. it('captures the normalized return type of a C++ method/function', () => {
  2226. const code = `
  2227. struct Widget { void draw(); };
  2228. class Factory { public: static Widget create(); };
  2229. Widget Factory::create() { return Widget(); }
  2230. void doNothing() {}
  2231. `;
  2232. const result = extractFromSource('f.cpp', code);
  2233. const create = result.nodes.find(
  2234. (n) => n.name === 'create' && (n.kind === 'method' || n.kind === 'function')
  2235. );
  2236. expect(create?.returnType).toBe('Widget');
  2237. // A `void` return records no type, so resolution never tries to resolve a
  2238. // method on it.
  2239. const doNothing = result.nodes.find((n) => n.name === 'doNothing');
  2240. expect(doNothing).toBeDefined();
  2241. expect(doNothing?.returnType).toBeUndefined();
  2242. });
  2243. it('unwraps a smart-pointer return type to its pointee', () => {
  2244. const code = `
  2245. #include <memory>
  2246. struct Widget {};
  2247. std::unique_ptr<Widget> makeWidget() { return nullptr; }
  2248. `;
  2249. const result = extractFromSource('f.cpp', code);
  2250. const make = result.nodes.find((n) => n.name === 'makeWidget');
  2251. expect(make?.returnType).toBe('Widget');
  2252. });
  2253. });
  2254. describe('C++ macro-prefixed class/struct misparse (#946 → recovered in #1061)', () => {
  2255. // An export/visibility macro before the class name (`class MACRO Name :
  2256. // public Base { … }`) makes tree-sitter read `class MACRO` as an elaborated
  2257. // type and the whole declaration as a function_definition named after the
  2258. // class — a phantom `function` that polluted callers/impact/blast-radius.
  2259. // #946 dropped that phantom; #1061's preParse (`blankCppExportMacros`) now
  2260. // blanks the ALL-CAPS macro before parsing, so the class parses normally and
  2261. // is *recovered* — node, members, and base edge all present — not just
  2262. // de-phantomed. The #946 drop survives as the fallback for any residual
  2263. // misparse the blanking doesn't catch.
  2264. it('recovers a macro-annotated class that inherits (no phantom, real class + base edge)', () => {
  2265. const code = `#pragma once
  2266. #define MAPCORE_EXPORT __attribute__((visibility("default")))
  2267. class DataProvider {
  2268. public:
  2269. virtual bool Request(void* param) = 0;
  2270. };
  2271. class MAPCORE_EXPORT LocalDataProvider : public DataProvider
  2272. {
  2273. public:
  2274. LocalDataProvider(int dataType);
  2275. virtual bool Request(void* param) override;
  2276. };
  2277. `;
  2278. // A header rich in C++ (class / public: / virtual) detects as C++ — the
  2279. // issue's exact scenario (a `.h` file). Guard it so a detection regression
  2280. // can't make this test pass for the wrong reason.
  2281. expect(detectLanguage('provider.h', code)).toBe('cpp');
  2282. const result = extractFromSource('provider.h', code);
  2283. // The misparse used to surface as `function | LocalDataProvider` spanning
  2284. // the whole class body — a false caller in the graph. It's gone.
  2285. expect(
  2286. result.nodes.find((n) => n.name === 'LocalDataProvider' && n.kind === 'function')
  2287. ).toBeUndefined();
  2288. // …and the class is now recovered (was dropped under #946), with its
  2289. // `extends DataProvider` edge — the whole point of #1061.
  2290. expect(result.nodes.find((n) => n.name === 'LocalDataProvider')?.kind).toBe('class');
  2291. expect(
  2292. result.unresolvedReferences.find(
  2293. (r) => r.referenceKind === 'extends' && r.referenceName === 'DataProvider'
  2294. )
  2295. ).toBeTruthy();
  2296. // The sibling class without the macro is unaffected — still a class.
  2297. expect(result.nodes.find((n) => n.name === 'DataProvider')?.kind).toBe('class');
  2298. });
  2299. it('recovers the struct variant too, without disturbing a genuine class', () => {
  2300. const code = `
  2301. #define API __declspec(dllexport)
  2302. struct API Widget : public Base { int x; };
  2303. class Plain : public Base { public: int y; };
  2304. `;
  2305. const result = extractFromSource('widget.cpp', code);
  2306. // `struct MACRO Name : Base { … }` misparses the same way — no phantom
  2307. // function, and the struct is recovered with its base edge.
  2308. expect(
  2309. result.nodes.find((n) => n.name === 'Widget' && n.kind === 'function')
  2310. ).toBeUndefined();
  2311. expect(result.nodes.find((n) => n.name === 'Widget')?.kind).toBe('struct');
  2312. // A normal class with a base clause and no macro is untouched.
  2313. expect(result.nodes.find((n) => n.name === 'Plain')?.kind).toBe('class');
  2314. const exts = result.unresolvedReferences
  2315. .filter((r) => r.referenceKind === 'extends')
  2316. .map((r) => r.referenceName);
  2317. expect(exts.filter((n) => n === 'Base').length).toBe(2); // Widget + Plain both extend Base
  2318. });
  2319. });
  2320. describe('C++ export-macro class recovery (#1061)', () => {
  2321. // Unreal-Engine style: `class MYGAME_API UMyComponent : public UActorComponent`.
  2322. // The leading `*_API` macro alone (base clause or not) triggers the #946
  2323. // misparse and dropped the class — breaking subclass / type-hierarchy /
  2324. // inheritance-impact queries for effectively every gameplay class in a UE
  2325. // project. blankCppExportMacros recovers them.
  2326. it('recovers UE *_API classes and the inheritance edge (the issue repro)', () => {
  2327. const code = `class ENGINE_API UActorComponent { };
  2328. class MYGAME_API UMyComponent : public UActorComponent { };
  2329. `;
  2330. const result = extractFromSource('ue.cpp', code);
  2331. const classes = result.nodes.filter((n) => n.kind === 'class').map((n) => n.name);
  2332. expect(classes).toContain('UActorComponent'); // macro, no base — also was dropped
  2333. expect(classes).toContain('UMyComponent');
  2334. expect(result.nodes.find((n) => n.kind === 'function')).toBeUndefined(); // no phantom
  2335. expect(
  2336. result.unresolvedReferences.find(
  2337. (r) => r.referenceKind === 'extends' && r.referenceName === 'UActorComponent'
  2338. )
  2339. ).toBeTruthy();
  2340. });
  2341. it('blankCppExportMacros blanks only the header macro, offset-preserving', () => {
  2342. // Blanking replaces the macro with equal-length spaces, so the output is
  2343. // byte-for-byte the same length and identical *except* the macro is gone —
  2344. // every downstream line/column stays exact.
  2345. const check = (inp: string, macro: string, rest: string) => {
  2346. const out = blankCppExportMacros(inp);
  2347. expect(out.length).toBe(inp.length); // every byte offset preserved
  2348. expect(out).not.toContain(macro); // the macro token is blanked
  2349. expect(out.replace(/ +/g, ' ')).toBe(rest); // nothing else changed
  2350. };
  2351. // Generalizes across the export-macro space: UE _API, Qt/Boost _EXPORT,
  2352. // LLVM _ABI, bare API.
  2353. check(
  2354. 'class MYGAME_API UMyComponent : public UActorComponent { };',
  2355. 'MYGAME_API',
  2356. 'class UMyComponent : public UActorComponent { };'
  2357. );
  2358. check('struct MAPCORE_EXPORT W : B {}', 'MAPCORE_EXPORT', 'struct W : B {}');
  2359. check('class LLVM_ABI Foo {}', 'LLVM_ABI', 'class Foo {}');
  2360. });
  2361. it('does NOT blank an all-caps class NAME or an elaborated-type var decl', () => {
  2362. // The name itself being ALL-CAPS (with or without a base) must survive —
  2363. // the macro is only the token *before* the name, gated on a `: { ` def.
  2364. for (const c of [
  2365. 'class FOO { int x; };',
  2366. 'class FOO : public Base { int x; };',
  2367. 'struct BAR : public Base { int y; };',
  2368. 'enum class COLOR { Red, Green };',
  2369. // elaborated-type variable declarations end in ; = [ — never : {
  2370. 'struct FOO bar;',
  2371. 'class FOO obj = make();',
  2372. 'struct FOO arr[10];',
  2373. // a *_API macro used as an ordinary value elsewhere
  2374. 'int x = SOME_API; void f() { use(MYMODULE_API); }',
  2375. ]) {
  2376. expect(blankCppExportMacros(c)).toBe(c);
  2377. }
  2378. // And the all-caps-named class keeps its base edge through real extraction.
  2379. const result = extractFromSource('ctrl.cpp', 'class FOO : public Base { int x; };');
  2380. expect(result.nodes.find((n) => n.name === 'FOO')?.kind).toBe('class');
  2381. expect(
  2382. result.unresolvedReferences.find(
  2383. (r) => r.referenceKind === 'extends' && r.referenceName === 'Base'
  2384. )
  2385. ).toBeTruthy();
  2386. });
  2387. });
  2388. describe('Metal shader extraction (#1121)', () => {
  2389. // Metal Shading Language (≈ C++14) parses with the C++ grammar. MSL puts
  2390. // `[[attribute]]` annotations AFTER the declarator — a position
  2391. // tree-sitter-cpp misparses: a struct field with a trailing attribute
  2392. // emitted a spurious `extends` ref from the struct to the field's own type.
  2393. // blankMetalAttributes (preParse, `.metal`-gated) blanks them so extraction
  2394. // matches plain C++.
  2395. const METAL = `#include <metal_stdlib>
  2396. using namespace metal;
  2397. struct VertexIn {
  2398. float3 position [[attribute(0)]];
  2399. float2 texCoord [[attribute(1)]];
  2400. };
  2401. struct VertexOut {
  2402. float4 position [[position]];
  2403. float2 texCoord;
  2404. };
  2405. struct Uniforms {
  2406. float4x4 modelViewProjection;
  2407. };
  2408. static float4 applyGamma(float4 color) {
  2409. return pow(color, float4(1.0 / 2.2));
  2410. }
  2411. vertex VertexOut vertexShader(VertexIn in [[stage_in]],
  2412. constant Uniforms &uniforms [[buffer(0)]]) {
  2413. VertexOut out;
  2414. out.position = uniforms.modelViewProjection * float4(in.position, 1.0);
  2415. out.texCoord = in.texCoord;
  2416. return out;
  2417. }
  2418. fragment float4 fragmentShader(VertexOut in [[stage_in]],
  2419. texture2d<float> colorTexture [[texture(0)]],
  2420. sampler textureSampler [[sampler(0)]]) {
  2421. float4 color = colorTexture.sample(textureSampler, in.texCoord);
  2422. return applyGamma(color);
  2423. }
  2424. kernel void computeBlur(texture2d<float, access::read> inTexture [[texture(0)]],
  2425. texture2d<float, access::write> outTexture [[texture(1)]],
  2426. uint2 gid [[thread_position_in_grid]]) {
  2427. float4 color = inTexture.read(gid);
  2428. outTexture.write(color, gid);
  2429. }
  2430. `;
  2431. it('extracts vertex/fragment/kernel functions, structs, and calls from a .metal file', () => {
  2432. const result = extractFromSource('Shaders.metal', METAL);
  2433. expect(result.errors).toHaveLength(0);
  2434. const functions = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  2435. expect(functions).toEqual(
  2436. expect.arrayContaining(['applyGamma', 'vertexShader', 'fragmentShader', 'computeBlur'])
  2437. );
  2438. const structs = result.nodes.filter((n) => n.kind === 'struct').map((n) => n.name);
  2439. expect(structs).toEqual(expect.arrayContaining(['VertexIn', 'VertexOut', 'Uniforms']));
  2440. expect(result.nodes.find((n) => n.kind === 'import')?.name).toBe('metal_stdlib');
  2441. // Attribute blanking is offset-preserving, so positions stay exact.
  2442. const vertexFn = result.nodes.find((n) => n.name === 'vertexShader')!;
  2443. expect(vertexFn.startLine).toBe(22);
  2444. // The shader call graph connects: fragmentShader → applyGamma.
  2445. expect(
  2446. result.unresolvedReferences.find(
  2447. (r) => r.referenceKind === 'calls' && r.referenceName === 'applyGamma'
  2448. )
  2449. ).toBeTruthy();
  2450. // The regression the blanking fixes: field attributes (`float3 position
  2451. // [[attribute(0)]];`) misparsed into `extends` refs from the struct to the
  2452. // field's type — a wrong inheritance edge whenever the repo defines that
  2453. // type itself (simd typedefs in a shared ShaderTypes.h are common).
  2454. expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'extends')).toHaveLength(0);
  2455. });
  2456. it('blankMetalAttributes blanks every attribute form, offset-preserving', () => {
  2457. const inp = [
  2458. 'float4 position [[position]];',
  2459. 'constant Uniforms &u [[buffer(0)]]',
  2460. 'float2 uv [[user(locn0)]];',
  2461. 'device float *out [[buffer(0), raster_order_group(0)]]',
  2462. ].join('\n');
  2463. const out = blankMetalAttributes(inp);
  2464. expect(out.length).toBe(inp.length); // every byte offset preserved
  2465. expect(out).not.toContain('[[');
  2466. // Nothing but the attributes changed: collapsing the blank runs gives the
  2467. // plain declarations back, newlines untouched.
  2468. expect(out.split('\n').map((l) => l.replace(/ +/g, ' ').trimEnd())).toEqual([
  2469. 'float4 position ;',
  2470. 'constant Uniforms &u',
  2471. 'float2 uv ;',
  2472. 'device float *out',
  2473. ]);
  2474. });
  2475. it('blankMetalAttributes never touches non-attribute [[ sequences', () => {
  2476. for (const c of [
  2477. 'auto x = arr[[]{ return 0; }()];', // lambda in subscript — the only other [[ in C++-family code
  2478. 'int y = a[b[i]];', // nested subscript
  2479. 'int z = 1;', // no [[ at all — early-return path
  2480. ]) {
  2481. expect(blankMetalAttributes(c)).toBe(c);
  2482. }
  2483. });
  2484. });
  2485. describe('C++ forward declarations do not mint phantom class nodes (#1093)', () => {
  2486. // `class Foo;` parses as a bodiless class_specifier. Repeated across headers,
  2487. // each forward decl minted a phantom bodiless `class` node that crowded out —
  2488. // and could be picked as the blast-radius representative over — the single
  2489. // real definition. Bodiless struct/enum specifiers were already skipped;
  2490. // classes now are too, but ONLY for C/C++ (opt-in flag), never for languages
  2491. // where a bodiless class is a complete definition.
  2492. it('keeps only the real definition, dropping repeated forward decls', () => {
  2493. const code = `
  2494. class APXCharacter; // forward decl (header 1)
  2495. class APXCharacter; // forward decl (header 2)
  2496. friend class APXCharacter; // elaborated / friend forward reference
  2497. class APXCharacter { // the one real definition
  2498. int hp;
  2499. void takeDamage(int amount) { hp -= amount; }
  2500. };
  2501. `;
  2502. const result = extractFromSource('character.cpp', code);
  2503. const classes = result.nodes.filter(
  2504. (n) => n.kind === 'class' && n.name === 'APXCharacter'
  2505. );
  2506. // Exactly one class node, and it's the definition (carries the member).
  2507. expect(classes).toHaveLength(1);
  2508. expect(classes[0].startLine).toBe(6);
  2509. expect(
  2510. result.nodes.some((n) => n.kind === 'method' && n.name === 'takeDamage')
  2511. ).toBe(true);
  2512. });
  2513. it('elaborated type references in declarations create no phantom class', () => {
  2514. // `class Foo obj;` is a variable declaration using an elaborated type, not
  2515. // a class definition — it must not mint a `Foo` class node.
  2516. const result = extractFromSource('use.cpp', 'class Foo;\nvoid f() { class Foo *p = nullptr; (void)p; }\n');
  2517. expect(result.nodes.filter((n) => n.kind === 'class' && n.name === 'Foo')).toHaveLength(0);
  2518. });
  2519. it('does NOT affect languages where a bodiless class is complete', () => {
  2520. // Kotlin `class Empty` and Scala `trait`/`case object`/`class` with no body
  2521. // are complete definitions — the C/C++-only skip must leave them indexed.
  2522. const kt = extractFromSource('Empty.kt', 'class Empty\nclass Full { val x = 1 }\n');
  2523. const ktClasses = kt.nodes.filter((n) => n.kind === 'class').map((n) => n.name);
  2524. expect(ktClasses).toContain('Empty');
  2525. expect(ktClasses).toContain('Full');
  2526. const scala = extractFromSource('M.scala', 'trait Marker\ncase object Red\nclass Foo\n');
  2527. const scalaNames = scala.nodes
  2528. .filter((n) => ['class', 'trait', 'interface'].includes(n.kind))
  2529. .map((n) => n.name);
  2530. expect(scalaNames).toEqual(expect.arrayContaining(['Marker', 'Red', 'Foo']));
  2531. });
  2532. });
  2533. describe('C++ reference-return method/function names (#1093 follow-up)', () => {
  2534. // An inline method/function returning a reference parses with a
  2535. // `reference_declarator` wrapping the `function_declarator`. That wrapper
  2536. // wasn't unwrapped (only `pointer_declarator` was), so the name captured the
  2537. // whole declarator — `const int& getRef() const {…}` became the method named
  2538. // "& getRef() const" instead of "getRef", polluting search and callers. Very
  2539. // common in Unreal Engine headers (`const FGameplayTagContainer& GetActiveTags() const`).
  2540. const namesOf = (code: string) =>
  2541. extractFromSource('r.cpp', code).nodes
  2542. .filter((n) => n.kind === 'method' || n.kind === 'function')
  2543. .map((n) => n.name);
  2544. it('names an inline reference-returning method by its identifier, not the declarator', () => {
  2545. const names = namesOf('class C {\npublic:\n const int& getRef() const { return x; }\n int& mutRef() { return x; }\n int x;\n};');
  2546. expect(names).toContain('getRef');
  2547. expect(names).toContain('mutRef');
  2548. // No name leaks the reference sigil or the parameter/qualifier tail.
  2549. expect(names.some((n) => /[&()]/.test(n))).toBe(false);
  2550. });
  2551. it('handles rvalue-reference returns and reference-returning free functions', () => {
  2552. expect(namesOf('class C { int&& take() { return 1; } };')).toContain('take');
  2553. expect(namesOf('const int& globalRef() { static int x; return x; }')).toContain('globalRef');
  2554. });
  2555. it('leaves pointer, value, and out-of-line reference returns unchanged (controls)', () => {
  2556. expect(namesOf('class C { int* getPtr() { return &x; } int x; };')).toContain('getPtr');
  2557. expect(namesOf('class C { int getVal() const { return x; } int x; };')).toContain('getVal');
  2558. // Out-of-line `T& C::f()` already resolves via the qualified-name hook.
  2559. expect(namesOf('const int& C::getRef() const { return x; }')).toContain('getRef');
  2560. });
  2561. });
  2562. describe('C++ user-defined conversion operator names (#1093 follow-up)', () => {
  2563. // A conversion operator's declarator is an `operator_cast` (target type +
  2564. // `() const` tail). It was named with the whole declarator —
  2565. // `operator EALSMovementState() const` — so it didn't match the symbolic-
  2566. // overload style (`operator+`) and carried parameter noise. It's now named
  2567. // `operator <type>`. Common in Unreal Engine enum-wrapper structs.
  2568. const namesOf = (code: string) =>
  2569. extractFromSource('o.cpp', code).nodes
  2570. .filter((n) => n.kind === 'method' || n.kind === 'function')
  2571. .map((n) => n.name);
  2572. it('names a conversion operator as "operator <type>", not the full declarator', () => {
  2573. const names = namesOf('struct S {\n operator int() const { return 1; }\n operator bool() { return true; }\n int x;\n};');
  2574. expect(names).toContain('operator int');
  2575. expect(names).toContain('operator bool');
  2576. expect(names.some((n) => n.includes('(') || n.includes('const'))).toBe(false);
  2577. });
  2578. it('handles a user-type conversion operator', () => {
  2579. expect(
  2580. namesOf('struct FALSMovementState {\n operator EALSMovementState() const { return State; }\n EALSMovementState State;\n};')
  2581. ).toContain('operator EALSMovementState');
  2582. });
  2583. it('leaves symbolic operator overloads unchanged (control)', () => {
  2584. const names = namesOf('struct S {\n S operator+(const S& o) const { return o; }\n int& operator[](int i) { return x; }\n int x;\n};');
  2585. expect(names).toContain('operator+');
  2586. expect(names).toContain('operator[]'); // reference-returning subscript, name still clean
  2587. });
  2588. });
  2589. describe('C++ macro-prefixed function names (#1093 follow-up)', () => {
  2590. // An unknown inline-specifier macro before the return type
  2591. // (`FORCEINLINE FString GetName(…)`) threw tree-sitter into error recovery:
  2592. // the macro became the return type and — for a non-primitive return — the
  2593. // return type was glued onto the name (`"FString GetName"`), so the function
  2594. // was unfindable by name and its callers didn't link. `blankCppInlineMacros`
  2595. // blanks the known UE inline macros before parsing (offset-preserving), the
  2596. // same recover-don't-drop approach as the macro-annotated-class fix. Pervasive
  2597. // in Unreal Engine (`FORCEINLINE`).
  2598. const infoOf = (code: string) =>
  2599. extractFromSource('m.cpp', code).nodes
  2600. .filter((n) => n.kind === 'method' || n.kind === 'function')
  2601. .map((n) => ({ name: n.name, ret: n.returnType }));
  2602. it('recovers the real name AND return type of a FORCEINLINE function', () => {
  2603. expect(infoOf('static FORCEINLINE FString GetName(int V) { return H(V); }')).toEqual([
  2604. { name: 'GetName', ret: 'FString' },
  2605. ]);
  2606. });
  2607. it('handles the templated UE helper shape (GetEnumerationToString)', () => {
  2608. const names = infoOf(
  2609. 'template <typename E> static FORCEINLINE FString GetEnumerationToString(const E V) { return H(V); }'
  2610. ).map((x) => x.name);
  2611. expect(names).toContain('GetEnumerationToString');
  2612. });
  2613. it('handles FORCENOINLINE / FORCEINLINE_DEBUGGABLE, methods, void, and reference returns', () => {
  2614. expect(infoOf('FORCENOINLINE FString A(int V){return H(V);}').map((x) => x.name)).toContain('A');
  2615. expect(infoOf('FORCEINLINE_DEBUGGABLE FString B(int V){return H(V);}').map((x) => x.name)).toContain('B');
  2616. expect(infoOf('struct S { FORCEINLINE FString GetName(int V) { return H(V); } };').map((x) => x.name)).toContain('GetName');
  2617. expect(infoOf('static FORCEINLINE void DoThing(int V) { H(V); }').map((x) => x.name)).toContain('DoThing');
  2618. expect(infoOf('static FORCEINLINE const FString& GetRef(int V) { return H(V); }').map((x) => x.name)).toContain('GetRef');
  2619. });
  2620. it('handles common third-party inline macros (pugixml, Godot, Boost, generic)', () => {
  2621. // pugixml: PUGI__FN before the return type; PUGIXML_FUNCTION (linkage)
  2622. // between the return type and the name — both recovered.
  2623. expect(infoOf('PUGI__FN void* default_allocate(size_t n) { return H(n); }').map((x) => x.name)).toContain('default_allocate');
  2624. expect(infoOf('PUGI__FN_NO_INLINE bool strequal(const char_t* a) { return H(a); }').map((x) => x.name)).toContain('strequal');
  2625. expect(infoOf('std::string PUGIXML_FUNCTION as_utf8(const wchar_t* s) { return H(s); }').map((x) => x.name)).toContain('as_utf8');
  2626. // Godot / Boost / generic inline hints
  2627. expect(infoOf('_FORCE_INLINE_ String get_name() const { return H(); }').map((x) => x.name)).toContain('get_name');
  2628. expect(infoOf('_ALWAYS_INLINE_ Vector2 get_pos() { return H(); }').map((x) => x.name)).toContain('get_pos');
  2629. expect(infoOf('BOOST_FORCEINLINE result_type call() { return H(); }').map((x) => x.name)).toContain('call');
  2630. expect(infoOf('ALWAYS_INLINE MyType compute() { return H(); }').map((x) => x.name)).toContain('compute');
  2631. });
  2632. it('leaves ordinary functions and real all-caps return types untouched (controls)', () => {
  2633. expect(infoOf('FString GetName(int V) { return H(V); }')).toEqual([{ name: 'GetName', ret: 'FString' }]);
  2634. // A real all-caps type that is NOT a listed inline macro stays the return type.
  2635. expect(infoOf('HRESULT DoIt(int V) { return H(V); }')).toEqual([{ name: 'DoIt', ret: 'HRESULT' }]);
  2636. });
  2637. it('blankCppInlineMacros preserves offsets and only touches specifier-position macros', () => {
  2638. // Blanked with equal-length spaces (byte offsets preserved).
  2639. expect(blankCppInlineMacros('FORCEINLINE FString F()')).toBe(' FString F()');
  2640. expect(blankCppInlineMacros('FORCEINLINE FString F()')).toHaveLength('FORCEINLINE FString F()'.length);
  2641. // Not in specifier position → untouched: string literals, expressions,
  2642. // longer word (`FORCEINLINE_COUNT`), and the fast path.
  2643. expect(blankCppInlineMacros('const char* s = "FORCEINLINE";')).toBe('const char* s = "FORCEINLINE";');
  2644. expect(blankCppInlineMacros('x = FORCEINLINE + 1;')).toBe('x = FORCEINLINE + 1;');
  2645. expect(blankCppInlineMacros('int FORCEINLINE_COUNT = 3;')).toBe('int FORCEINLINE_COUNT = 3;');
  2646. expect(blankCppInlineMacros('no macros here')).toBe('no macros here');
  2647. });
  2648. });
  2649. describe('C++ universal macro-mangled name recovery', () => {
  2650. // Curated pre-parse blanking can't list every library's inline macro, so a
  2651. // post-parse salvage recovers the real function name from ANY leftover
  2652. // `MACRO Ret name(…)` mangle — no list needed. It only ever touches an
  2653. // already-mangled name, so it can't corrupt a clean one.
  2654. const namesOf = (code: string, file = 's.cpp') =>
  2655. extractFromSource(file, code).nodes
  2656. .filter((n) => n.kind === 'method' || n.kind === 'function')
  2657. .map((n) => n.name);
  2658. it('recovers the name from a completely unknown macro (no list entry)', () => {
  2659. expect(namesOf('WEBKIT_EXPORT WTFString computeThing(int x) { return H(x); }')).toContain('computeThing');
  2660. expect(namesOf('SOMELIB_INLINE MyResult doWork(int x) { return H(x); }')).toContain('doWork');
  2661. expect(namesOf('MZ_FORCEINLINE char_t* to_str(double v) { return H(v); }')).toContain('to_str');
  2662. });
  2663. it('recoverMangledCppName only touches already-mangled names, with guards', () => {
  2664. // Recovered:
  2665. expect(recoverMangledCppName('WTFString computeThing')).toBe('computeThing');
  2666. expect(recoverMangledCppName('char_t* to_str(double v)')).toBe('to_str');
  2667. expect(recoverMangledCppName('unspecified_bool_type() const')).toBe('unspecified_bool_type');
  2668. // Left unchanged — clean names, operators, destructors, the `Ret (name)`
  2669. // idiom, and non-identifier tails:
  2670. expect(recoverMangledCppName('computeThing')).toBe('computeThing');
  2671. expect(recoverMangledCppName('operator EALSMovementState')).toBe('operator EALSMovementState');
  2672. expect(recoverMangledCppName('~Widget')).toBe('~Widget');
  2673. expect(recoverMangledCppName('bool (likely)')).toBe('bool (likely)');
  2674. expect(recoverMangledCppName('void (free)')).toBe('void (free)');
  2675. expect(recoverMangledCppName('QDockWidget *')).toBe('QDockWidget *');
  2676. });
  2677. it('does not disturb clean C++ names or non-C++ (Kotlin backtick) names', () => {
  2678. expect(namesOf('int foo(int x) { return x; }')).toEqual(['foo']);
  2679. // Kotlin backtick identifiers legitimately contain spaces; the salvage is
  2680. // C/C++-only, so they are untouched.
  2681. const kt = extractFromSource('T.kt', 'class T {\n fun `decode simple cert`() { }\n}').nodes
  2682. .filter((n) => n.kind === 'method' || n.kind === 'function')
  2683. .map((n) => n.name);
  2684. expect(kt).toContain('`decode simple cert`');
  2685. });
  2686. it('curated list now also covers Qt / Folly / Abseil / LLVM / V8 / Eigen / rapidjson (full recovery)', () => {
  2687. const info = (c: string) =>
  2688. extractFromSource('x.cpp', c).nodes
  2689. .filter((n) => n.kind === 'method' || n.kind === 'function')
  2690. .map((n) => ({ name: n.name, ret: n.returnType }));
  2691. expect(info('FOLLY_ALWAYS_INLINE Str f(int x) { return H(x); }')).toEqual([{ name: 'f', ret: 'Str' }]);
  2692. expect(namesOf('Q_INVOKABLE void onClicked() { H(); }')).toContain('onClicked');
  2693. expect(namesOf('ABSL_ATTRIBUTE_ALWAYS_INLINE int hash(int x) { return H(x); }')).toContain('hash');
  2694. expect(namesOf('EIGEN_STRONG_INLINE Scalar dot(const V& v) { return H(v); }')).toContain('dot');
  2695. expect(namesOf('V8_INLINE MaybeLocal Get(int i) { return H(i); }')).toContain('Get');
  2696. expect(namesOf('RAPIDJSON_FORCEINLINE bool Parse(const char* s) { return H(s); }')).toContain('Parse');
  2697. });
  2698. it('curated list spans the broader ecosystem (Mozilla, GLM, Bullet, OpenCV, Skia, EASTL, protobuf, fmt, Windows conventions)', () => {
  2699. const info = (c: string) =>
  2700. extractFromSource('x.cpp', c).nodes
  2701. .filter((n) => n.kind === 'method' || n.kind === 'function')
  2702. .map((n) => ({ name: n.name, ret: n.returnType }));
  2703. expect(info('MOZ_ALWAYS_INLINE Value get(int i) { return H(i); }')).toEqual([{ name: 'get', ret: 'Value' }]);
  2704. expect(info('GLM_FUNC_QUALIFIER vec3 cross(const vec3& a) { return H(a); }')).toEqual([{ name: 'cross', ret: 'vec3' }]);
  2705. expect(info('SIMD_FORCE_INLINE btScalar dot(const btVector3& v) const { return H(v); }')).toEqual([{ name: 'dot', ret: 'btScalar' }]);
  2706. expect(info('CV_INLINE Mat clone() const { return H(); }')).toEqual([{ name: 'clone', ret: 'Mat' }]);
  2707. expect(namesOf('PROTOBUF_ALWAYS_INLINE int size() const { return H(); }')).toContain('size');
  2708. expect(namesOf('FMT_CONSTEXPR auto parse(int x) { return H(x); }')).toContain('parse');
  2709. expect(namesOf('SK_ALWAYS_INLINE SkScalar width() const { return H(); }')).toContain('width');
  2710. expect(namesOf('EA_FORCE_INLINE size_type size() const { return H(); }')).toContain('size');
  2711. // Windows calling-convention macros sit between return type and name; the
  2712. // macro is blanked so the real return type survives.
  2713. expect(info('HRESULT WINAPI CreateThing(int x) { return H(x); }')).toEqual([{ name: 'CreateThing', ret: 'HRESULT' }]);
  2714. expect(info('ULONG STDMETHODCALLTYPE AddRef() { return H(); }')).toEqual([{ name: 'AddRef', ret: 'ULONG' }]);
  2715. });
  2716. });
  2717. describe('C++ templated base-class inheritance (#1043)', () => {
  2718. // Inheriting from a template (`class D : public Base<int>`) recorded the base
  2719. // ref as the full instantiation `Base<int>`, which never name-matched the
  2720. // template indexed as the bare node `Base`. The `<…>` args are stripped so the
  2721. // `extends` reference matches.
  2722. it('strips template args from a templated base so the extends ref is the bare name', () => {
  2723. const code = `
  2724. template<typename T> class Base {};
  2725. template<typename D> class CRTPBase {};
  2726. namespace ns { template<typename T> class Tpl {}; }
  2727. class Plain {};
  2728. class Widget : public Base<int> {};
  2729. class App : public CRTPBase<App> {};
  2730. class Q : public ns::Tpl<int> {};
  2731. class Both : public Base<char>, public Plain {};
  2732. `;
  2733. const extendsRefs = extractFromSource('f.cpp', code).unresolvedReferences.filter(
  2734. (r) => r.referenceKind === 'extends'
  2735. );
  2736. const names = extendsRefs.map((r) => r.referenceName);
  2737. // Templated bases carry the bare name, NOT the `<…>` instantiation.
  2738. expect(names).toContain('Base'); // from Base<int> / Base<char>
  2739. expect(names).toContain('CRTPBase'); // from CRTPBase<App> (CRTP)
  2740. expect(names).toContain('ns::Tpl'); // qualified head preserved, args dropped
  2741. expect(names).toContain('Plain'); // non-templated base unchanged
  2742. // No reference still carries angle brackets.
  2743. expect(names.find((n) => n.includes('<'))).toBeUndefined();
  2744. });
  2745. it('stripCppTemplateArgs removes balanced <…> at any depth and is a no-op without them', () => {
  2746. expect(stripCppTemplateArgs('Base<int>')).toBe('Base');
  2747. expect(stripCppTemplateArgs('ns::Tpl<int>')).toBe('ns::Tpl');
  2748. expect(stripCppTemplateArgs('ns::Tpl<Foo<int>>')).toBe('ns::Tpl'); // nested
  2749. expect(stripCppTemplateArgs('Outer<int>::Inner')).toBe('Outer::Inner'); // mid-name
  2750. expect(stripCppTemplateArgs('Base')).toBe('Base'); // no-op
  2751. expect(stripCppTemplateArgs('ns::Plain')).toBe('ns::Plain'); // no-op qualified
  2752. });
  2753. });
  2754. describe('C++ stack-allocation construction (#1035)', () => {
  2755. // `Calculator calc(0)` (direct-init) and `Widget w{1, 2}` (brace-init) carry
  2756. // the constructor args directly on the declarator — no call/new node — so
  2757. // they emitted no constructor reference, unlike heap `new Calculator(0)`. An
  2758. // `instantiates` ref to the constructed type is now emitted for both.
  2759. const instNames = (code: string) =>
  2760. extractFromSource('f.cpp', `void run() {\n${code}\n}`)
  2761. .unresolvedReferences.filter((r) => r.referenceKind === 'instantiates')
  2762. .map((r) => r.referenceName);
  2763. it('emits an instantiates ref for direct-init and brace-init', () => {
  2764. expect(instNames('Calculator calc(0);')).toEqual(['Calculator']);
  2765. expect(instNames('Widget w{1, 2};')).toEqual(['Widget']);
  2766. });
  2767. it('strips template args and namespace to the bare class name', () => {
  2768. // `std::vector<int> v(10)` → `vector`; `ns::Widget w(0)` → `Widget`.
  2769. expect(instNames('std::vector<int> v(10);')).toEqual(['vector']);
  2770. expect(instNames('ns::Widget w(0);')).toEqual(['Widget']);
  2771. });
  2772. it('does not emit for primitives, default construction, or the most-vexing parse', () => {
  2773. expect(instNames('int x(5);')).toEqual([]); // primitive direct-init
  2774. expect(instNames('int y{6};')).toEqual([]); // primitive brace-init
  2775. expect(instNames('auto z = make();')).toEqual([]); // auto + call (handled elsewhere)
  2776. expect(instNames('Calculator deferred;')).toEqual([]); // default construction, no args
  2777. expect(instNames('Calculator calc();')).toEqual([]); // function declaration (most-vexing parse)
  2778. });
  2779. it('emits a single instantiates ref for a multi-declarator statement', () => {
  2780. // `Calculator a(1), b(2);` shares one `type` field; both construct a
  2781. // Calculator, so one ref suffices (it dedups to one edge regardless).
  2782. expect(instNames('Calculator a(1), b(2);')).toEqual(['Calculator']);
  2783. });
  2784. });
  2785. describe('C/C++ imports', () => {
  2786. it('should extract system include', () => {
  2787. const code = `#include <iostream>`;
  2788. const result = extractFromSource('main.cpp', code);
  2789. const importNode = result.nodes.find((n) => n.kind === 'import');
  2790. expect(importNode).toBeDefined();
  2791. expect(importNode?.name).toBe('iostream');
  2792. expect(importNode?.signature).toBe('#include <iostream>');
  2793. });
  2794. it('should extract system include with path', () => {
  2795. const code = `#include <nlohmann/json.hpp>`;
  2796. const result = extractFromSource('app.cpp', code);
  2797. const importNode = result.nodes.find((n) => n.kind === 'import');
  2798. expect(importNode).toBeDefined();
  2799. expect(importNode?.name).toBe('nlohmann/json.hpp');
  2800. });
  2801. it('should extract local include', () => {
  2802. const code = `#include "myheader.h"`;
  2803. const result = extractFromSource('main.cpp', code);
  2804. const importNode = result.nodes.find((n) => n.kind === 'import');
  2805. expect(importNode).toBeDefined();
  2806. expect(importNode?.name).toBe('myheader.h');
  2807. });
  2808. it('should extract C header', () => {
  2809. const code = `#include <stdio.h>`;
  2810. const result = extractFromSource('main.c', code);
  2811. const importNode = result.nodes.find((n) => n.kind === 'import');
  2812. expect(importNode).toBeDefined();
  2813. expect(importNode?.name).toBe('stdio.h');
  2814. });
  2815. it('should extract multiple includes', () => {
  2816. const code = `
  2817. #include <iostream>
  2818. #include <vector>
  2819. #include "config.h"
  2820. `;
  2821. const result = extractFromSource('app.cpp', code);
  2822. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  2823. expect(importNodes.length).toBe(3);
  2824. const names = importNodes.map((n) => n.name);
  2825. expect(names).toContain('iostream');
  2826. expect(names).toContain('vector');
  2827. expect(names).toContain('config.h');
  2828. });
  2829. it('should create unresolved references for local includes', () => {
  2830. const code = `#include "myheader.h"`;
  2831. const result = extractFromSource('main.cpp', code);
  2832. const importRef = result.unresolvedReferences.find(
  2833. (r) => r.referenceKind === 'imports' && r.referenceName === 'myheader.h'
  2834. );
  2835. expect(importRef).toBeDefined();
  2836. expect(importRef?.line).toBe(1);
  2837. });
  2838. it('should create unresolved references for system includes', () => {
  2839. const code = `#include <iostream>`;
  2840. const result = extractFromSource('main.cpp', code);
  2841. const importRef = result.unresolvedReferences.find(
  2842. (r) => r.referenceKind === 'imports' && r.referenceName === 'iostream'
  2843. );
  2844. expect(importRef).toBeDefined();
  2845. });
  2846. });
  2847. describe('Dart imports', () => {
  2848. it('should extract dart: import', () => {
  2849. const code = `import 'dart:async';`;
  2850. const result = extractFromSource('main.dart', code);
  2851. const importNode = result.nodes.find((n) => n.kind === 'import');
  2852. expect(importNode).toBeDefined();
  2853. expect(importNode?.name).toBe('dart:async');
  2854. expect(importNode?.signature).toBe("import 'dart:async';");
  2855. });
  2856. it('should extract package import', () => {
  2857. const code = `import 'package:flutter/material.dart';`;
  2858. const result = extractFromSource('app.dart', code);
  2859. const importNode = result.nodes.find((n) => n.kind === 'import');
  2860. expect(importNode).toBeDefined();
  2861. expect(importNode?.name).toBe('package:flutter/material.dart');
  2862. });
  2863. it('should extract aliased import', () => {
  2864. const code = `import 'package:http/http.dart' as http;`;
  2865. const result = extractFromSource('api.dart', code);
  2866. const importNode = result.nodes.find((n) => n.kind === 'import');
  2867. expect(importNode).toBeDefined();
  2868. expect(importNode?.name).toBe('package:http/http.dart');
  2869. expect(importNode?.signature).toContain('as http');
  2870. });
  2871. it('should extract multiple imports', () => {
  2872. const code = `
  2873. import 'dart:async';
  2874. import 'dart:convert';
  2875. import 'package:flutter/material.dart';
  2876. `;
  2877. const result = extractFromSource('main.dart', code);
  2878. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  2879. expect(importNodes.length).toBe(3);
  2880. const names = importNodes.map((n) => n.name);
  2881. expect(names).toContain('dart:async');
  2882. expect(names).toContain('dart:convert');
  2883. expect(names).toContain('package:flutter/material.dart');
  2884. });
  2885. it('should extract relative import', () => {
  2886. const code = `import '../utils/helpers.dart';`;
  2887. const result = extractFromSource('lib/main.dart', code);
  2888. const importNode = result.nodes.find((n) => n.kind === 'import');
  2889. expect(importNode).toBeDefined();
  2890. expect(importNode?.name).toBe('../utils/helpers.dart');
  2891. });
  2892. });
  2893. describe('Liquid imports', () => {
  2894. it('should extract render tag', () => {
  2895. const code = `{% render 'loading-spinner' %}`;
  2896. const result = extractFromSource('template.liquid', code);
  2897. const importNode = result.nodes.find((n) => n.kind === 'import');
  2898. expect(importNode).toBeDefined();
  2899. expect(importNode?.name).toBe('loading-spinner');
  2900. expect(importNode?.signature).toContain('render');
  2901. });
  2902. it('should extract section tag', () => {
  2903. const code = `{% section 'header' %}`;
  2904. const result = extractFromSource('layout/theme.liquid', code);
  2905. const importNode = result.nodes.find((n) => n.kind === 'import');
  2906. expect(importNode).toBeDefined();
  2907. expect(importNode?.name).toBe('header');
  2908. expect(importNode?.signature).toContain('section');
  2909. });
  2910. it('should extract include tag', () => {
  2911. const code = `{% include 'icon-cart' %}`;
  2912. const result = extractFromSource('snippets/header.liquid', code);
  2913. const importNode = result.nodes.find((n) => n.kind === 'import');
  2914. expect(importNode).toBeDefined();
  2915. expect(importNode?.name).toBe('icon-cart');
  2916. expect(importNode?.signature).toContain('include');
  2917. });
  2918. it('should extract render with whitespace control', () => {
  2919. const code = `{%- render 'price' -%}`;
  2920. const result = extractFromSource('snippets/product.liquid', code);
  2921. const importNode = result.nodes.find((n) => n.kind === 'import');
  2922. expect(importNode).toBeDefined();
  2923. expect(importNode?.name).toBe('price');
  2924. });
  2925. it('should extract multiple imports', () => {
  2926. const code = `
  2927. {% section 'header' %}
  2928. {% render 'loading-spinner' %}
  2929. {% render 'cart-drawer' %}
  2930. `;
  2931. const result = extractFromSource('layout/theme.liquid', code);
  2932. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  2933. expect(importNodes.length).toBe(3);
  2934. const names = importNodes.map((n) => n.name);
  2935. expect(names).toContain('header');
  2936. expect(names).toContain('loading-spinner');
  2937. expect(names).toContain('cart-drawer');
  2938. });
  2939. });
  2940. });
  2941. // =============================================================================
  2942. // Pascal / Delphi Extraction
  2943. // =============================================================================
  2944. describe('Pascal / Delphi Extraction', () => {
  2945. describe('Language detection', () => {
  2946. it('should detect Pascal files', () => {
  2947. expect(detectLanguage('UAuth.pas')).toBe('pascal');
  2948. expect(detectLanguage('App.dpr')).toBe('pascal');
  2949. expect(detectLanguage('Package.dpk')).toBe('pascal');
  2950. expect(detectLanguage('App.lpr')).toBe('pascal');
  2951. expect(detectLanguage('MainForm.dfm')).toBe('pascal');
  2952. expect(detectLanguage('MainForm.fmx')).toBe('pascal');
  2953. });
  2954. it('should report Pascal as supported', () => {
  2955. expect(isLanguageSupported('pascal')).toBe(true);
  2956. expect(getSupportedLanguages()).toContain('pascal');
  2957. });
  2958. });
  2959. describe('Unit extraction', () => {
  2960. it('should extract unit as module', () => {
  2961. const code = `unit MyUnit;\ninterface\nimplementation\nend.`;
  2962. const result = extractFromSource('MyUnit.pas', code);
  2963. const moduleNode = result.nodes.find((n) => n.kind === 'module');
  2964. expect(moduleNode).toBeDefined();
  2965. expect(moduleNode?.name).toBe('MyUnit');
  2966. expect(moduleNode?.language).toBe('pascal');
  2967. });
  2968. it('should extract program as module', () => {
  2969. const code = `program MyApp;\nbegin\nend.`;
  2970. const result = extractFromSource('MyApp.dpr', code);
  2971. const moduleNode = result.nodes.find((n) => n.kind === 'module');
  2972. expect(moduleNode).toBeDefined();
  2973. expect(moduleNode?.name).toBe('MyApp');
  2974. });
  2975. it('should fallback to filename when module name is empty', () => {
  2976. // Some .dpr templates use "program;" without a name
  2977. const code = `program;\nuses SysUtils;\nbegin\nend.`;
  2978. const result = extractFromSource('Console.dpr', code);
  2979. const moduleNode = result.nodes.find((n) => n.kind === 'module');
  2980. expect(moduleNode).toBeDefined();
  2981. expect(moduleNode?.name).toBe('Console');
  2982. });
  2983. });
  2984. describe('Uses clause (imports)', () => {
  2985. it('should extract uses as individual imports', () => {
  2986. const code = `unit Test;\ninterface\nuses\n System.SysUtils,\n System.Classes;\nimplementation\nend.`;
  2987. const result = extractFromSource('Test.pas', code);
  2988. const imports = result.nodes.filter((n) => n.kind === 'import');
  2989. expect(imports.length).toBe(2);
  2990. expect(imports.map((n) => n.name)).toContain('System.SysUtils');
  2991. expect(imports.map((n) => n.name)).toContain('System.Classes');
  2992. });
  2993. it('should create unresolved references for imports', () => {
  2994. const code = `unit Test;\ninterface\nuses\n UAuth;\nimplementation\nend.`;
  2995. const result = extractFromSource('Test.pas', code);
  2996. const importRef = result.unresolvedReferences.find(
  2997. (r) => r.referenceKind === 'imports'
  2998. );
  2999. expect(importRef).toBeDefined();
  3000. expect(importRef?.referenceName).toBe('UAuth');
  3001. });
  3002. });
  3003. describe('Class extraction', () => {
  3004. it('should extract class declarations', () => {
  3005. const code = `unit Test;\ninterface\ntype\n TMyClass = class\n public\n procedure DoSomething;\n end;\nimplementation\nend.`;
  3006. const result = extractFromSource('Test.pas', code);
  3007. const classNode = result.nodes.find((n) => n.kind === 'class');
  3008. expect(classNode).toBeDefined();
  3009. expect(classNode?.name).toBe('TMyClass');
  3010. });
  3011. it('should extract class with inheritance', () => {
  3012. const code = `unit Test;\ninterface\ntype\n TChild = class(TParent)\n end;\nimplementation\nend.`;
  3013. const result = extractFromSource('Test.pas', code);
  3014. const extendsRef = result.unresolvedReferences.find(
  3015. (r) => r.referenceKind === 'extends'
  3016. );
  3017. expect(extendsRef).toBeDefined();
  3018. expect(extendsRef?.referenceName).toBe('TParent');
  3019. });
  3020. it('should extract class with interface implementation', () => {
  3021. const code = `unit Test;\ninterface\ntype\n TService = class(TInterfacedObject, ILogger)\n end;\nimplementation\nend.`;
  3022. const result = extractFromSource('Test.pas', code);
  3023. const extendsRef = result.unresolvedReferences.find(
  3024. (r) => r.referenceKind === 'extends'
  3025. );
  3026. const implementsRef = result.unresolvedReferences.find(
  3027. (r) => r.referenceKind === 'implements'
  3028. );
  3029. expect(extendsRef?.referenceName).toBe('TInterfacedObject');
  3030. expect(implementsRef?.referenceName).toBe('ILogger');
  3031. });
  3032. });
  3033. describe('Record extraction', () => {
  3034. it('should extract records as class nodes', () => {
  3035. const code = `unit Test;\ninterface\ntype\n TPoint = record\n X: Double;\n Y: Double;\n end;\nimplementation\nend.`;
  3036. const result = extractFromSource('Test.pas', code);
  3037. const classNode = result.nodes.find((n) => n.kind === 'class');
  3038. expect(classNode).toBeDefined();
  3039. expect(classNode?.name).toBe('TPoint');
  3040. const fields = result.nodes.filter((n) => n.kind === 'field');
  3041. expect(fields.length).toBe(2);
  3042. expect(fields.map((f) => f.name)).toContain('X');
  3043. expect(fields.map((f) => f.name)).toContain('Y');
  3044. });
  3045. });
  3046. describe('Interface extraction', () => {
  3047. it('should extract interface declarations', () => {
  3048. const code = `unit Test;\ninterface\ntype\n ILogger = interface\n procedure Log(const AMsg: string);\n end;\nimplementation\nend.`;
  3049. const result = extractFromSource('Test.pas', code);
  3050. const ifaceNode = result.nodes.find((n) => n.kind === 'interface');
  3051. expect(ifaceNode).toBeDefined();
  3052. expect(ifaceNode?.name).toBe('ILogger');
  3053. });
  3054. });
  3055. describe('Method extraction', () => {
  3056. it('should extract methods with visibility', () => {
  3057. const code = `unit Test;\ninterface\ntype\n TMyClass = class\n private\n FValue: Integer;\n public\n constructor Create;\n function GetValue: Integer;\n end;\nimplementation\nend.`;
  3058. const result = extractFromSource('Test.pas', code);
  3059. const methods = result.nodes.filter((n) => n.kind === 'method');
  3060. expect(methods.length).toBe(2);
  3061. const createMethod = methods.find((m) => m.name === 'Create');
  3062. expect(createMethod?.visibility).toBe('public');
  3063. const getValue = methods.find((m) => m.name === 'GetValue');
  3064. expect(getValue?.visibility).toBe('public');
  3065. const fields = result.nodes.filter((n) => n.kind === 'field');
  3066. const fValue = fields.find((f) => f.name === 'FValue');
  3067. expect(fValue?.visibility).toBe('private');
  3068. });
  3069. it('should detect static methods (class methods)', () => {
  3070. const code = `unit Test;\ninterface\ntype\n THelper = class\n public\n class function Create: THelper; static;\n end;\nimplementation\nend.`;
  3071. const result = extractFromSource('Test.pas', code);
  3072. const methods = result.nodes.filter((n) => n.kind === 'method');
  3073. const staticMethod = methods.find((m) => m.name === 'Create');
  3074. expect(staticMethod?.isStatic).toBe(true);
  3075. });
  3076. });
  3077. describe('Enum extraction', () => {
  3078. it('should extract enums with members', () => {
  3079. const code = `unit Test;\ninterface\ntype\n TColor = (clRed, clGreen, clBlue);\nimplementation\nend.`;
  3080. const result = extractFromSource('Test.pas', code);
  3081. const enumNode = result.nodes.find((n) => n.kind === 'enum');
  3082. expect(enumNode).toBeDefined();
  3083. expect(enumNode?.name).toBe('TColor');
  3084. const members = result.nodes.filter((n) => n.kind === 'enum_member');
  3085. expect(members.length).toBe(3);
  3086. expect(members.map((m) => m.name)).toEqual(['clRed', 'clGreen', 'clBlue']);
  3087. });
  3088. });
  3089. describe('Property extraction', () => {
  3090. it('should extract properties', () => {
  3091. const code = `unit Test;\ninterface\ntype\n TObj = class\n public\n property Name: string read FName write FName;\n end;\nimplementation\nend.`;
  3092. const result = extractFromSource('Test.pas', code);
  3093. const propNode = result.nodes.find((n) => n.kind === 'property');
  3094. expect(propNode).toBeDefined();
  3095. expect(propNode?.name).toBe('Name');
  3096. expect(propNode?.visibility).toBe('public');
  3097. });
  3098. });
  3099. describe('Constant extraction', () => {
  3100. it('should extract constants', () => {
  3101. const code = `unit Test;\ninterface\nconst\n MAX_RETRIES = 3;\n APP_NAME = 'MyApp';\nimplementation\nend.`;
  3102. const result = extractFromSource('Test.pas', code);
  3103. const constants = result.nodes.filter((n) => n.kind === 'constant');
  3104. expect(constants.length).toBe(2);
  3105. expect(constants.map((c) => c.name)).toContain('MAX_RETRIES');
  3106. expect(constants.map((c) => c.name)).toContain('APP_NAME');
  3107. });
  3108. });
  3109. describe('Type alias extraction', () => {
  3110. it('should extract type aliases', () => {
  3111. const code = `unit Test;\ninterface\ntype\n TUserName = string;\nimplementation\nend.`;
  3112. const result = extractFromSource('Test.pas', code);
  3113. const aliasNode = result.nodes.find((n) => n.kind === 'type_alias');
  3114. expect(aliasNode).toBeDefined();
  3115. expect(aliasNode?.name).toBe('TUserName');
  3116. });
  3117. });
  3118. describe('Call extraction', () => {
  3119. it('should extract calls from implementation bodies', () => {
  3120. const code = `unit Test;\ninterface\ntype\n TObj = class\n public\n procedure DoWork;\n end;\nimplementation\nprocedure TObj.DoWork;\nbegin\n WriteLn('hello');\nend;\nend.`;
  3121. const result = extractFromSource('Test.pas', code);
  3122. const callRef = result.unresolvedReferences.find(
  3123. (r) => r.referenceKind === 'calls'
  3124. );
  3125. expect(callRef).toBeDefined();
  3126. expect(callRef?.referenceName).toBe('WriteLn');
  3127. });
  3128. });
  3129. describe('Containment edges', () => {
  3130. it('should create contains edges for class members', () => {
  3131. const code = `unit Test;\ninterface\ntype\n TObj = class\n public\n procedure Foo;\n end;\nimplementation\nend.`;
  3132. const result = extractFromSource('Test.pas', code);
  3133. const classNode = result.nodes.find((n) => n.kind === 'class');
  3134. const methodNode = result.nodes.find((n) => n.kind === 'method');
  3135. expect(classNode).toBeDefined();
  3136. expect(methodNode).toBeDefined();
  3137. const containsEdge = result.edges.find(
  3138. (e) => e.source === classNode?.id && e.target === methodNode?.id && e.kind === 'contains'
  3139. );
  3140. expect(containsEdge).toBeDefined();
  3141. });
  3142. });
  3143. describe('Full fixture: UAuth.pas', () => {
  3144. const code = `unit UAuth;
  3145. interface
  3146. uses
  3147. System.SysUtils,
  3148. System.Classes;
  3149. type
  3150. ITokenValidator = interface
  3151. ['{11111111-1111-1111-1111-111111111111}']
  3152. function Validate(const AToken: string): Boolean;
  3153. end;
  3154. TAuthService = class(TInterfacedObject, ITokenValidator)
  3155. private
  3156. FToken: string;
  3157. FLoginCount: Integer;
  3158. procedure IncLoginCount;
  3159. protected
  3160. function GetToken: string;
  3161. public
  3162. constructor Create;
  3163. destructor Destroy; override;
  3164. function Validate(const AToken: string): Boolean;
  3165. function Login(const AUser, APass: string): string;
  3166. property Token: string read GetToken;
  3167. property LoginCount: Integer read FLoginCount;
  3168. end;
  3169. implementation
  3170. constructor TAuthService.Create;
  3171. begin
  3172. inherited Create;
  3173. FToken := '';
  3174. FLoginCount := 0;
  3175. end;
  3176. destructor TAuthService.Destroy;
  3177. begin
  3178. FToken := '';
  3179. inherited Destroy;
  3180. end;
  3181. procedure TAuthService.IncLoginCount;
  3182. begin
  3183. Inc(FLoginCount);
  3184. end;
  3185. function TAuthService.GetToken: string;
  3186. begin
  3187. Result := FToken;
  3188. end;
  3189. function TAuthService.Validate(const AToken: string): Boolean;
  3190. begin
  3191. Result := AToken <> '';
  3192. end;
  3193. function TAuthService.Login(const AUser, APass: string): string;
  3194. begin
  3195. IncLoginCount;
  3196. if Validate(AUser + ':' + APass) then
  3197. begin
  3198. FToken := AUser;
  3199. Result := 'ok';
  3200. end
  3201. else
  3202. Result := '';
  3203. end;
  3204. end.`;
  3205. it('should extract all expected nodes', () => {
  3206. const result = extractFromSource('UAuth.pas', code);
  3207. expect(result.errors).toHaveLength(0);
  3208. // Module
  3209. const moduleNode = result.nodes.find((n) => n.kind === 'module');
  3210. expect(moduleNode?.name).toBe('UAuth');
  3211. // Imports
  3212. const imports = result.nodes.filter((n) => n.kind === 'import');
  3213. expect(imports.length).toBe(2);
  3214. // Interface
  3215. const ifaceNode = result.nodes.find((n) => n.kind === 'interface');
  3216. expect(ifaceNode?.name).toBe('ITokenValidator');
  3217. // Class
  3218. const classNode = result.nodes.find((n) => n.kind === 'class');
  3219. expect(classNode?.name).toBe('TAuthService');
  3220. // Methods
  3221. const methods = result.nodes.filter((n) => n.kind === 'method');
  3222. expect(methods.length).toBeGreaterThanOrEqual(6);
  3223. expect(methods.map((m) => m.name)).toContain('Create');
  3224. expect(methods.map((m) => m.name)).toContain('Destroy');
  3225. expect(methods.map((m) => m.name)).toContain('Login');
  3226. // Fields
  3227. const fields = result.nodes.filter((n) => n.kind === 'field');
  3228. expect(fields.length).toBe(2);
  3229. expect(fields.every((f) => f.visibility === 'private')).toBe(true);
  3230. // Properties
  3231. const props = result.nodes.filter((n) => n.kind === 'property');
  3232. expect(props.length).toBe(2);
  3233. expect(props.map((p) => p.name)).toContain('Token');
  3234. expect(props.map((p) => p.name)).toContain('LoginCount');
  3235. });
  3236. it('should extract inheritance and interface implementation', () => {
  3237. const result = extractFromSource('UAuth.pas', code);
  3238. const extendsRef = result.unresolvedReferences.find(
  3239. (r) => r.referenceKind === 'extends'
  3240. );
  3241. expect(extendsRef?.referenceName).toBe('TInterfacedObject');
  3242. const implementsRef = result.unresolvedReferences.find(
  3243. (r) => r.referenceKind === 'implements'
  3244. );
  3245. expect(implementsRef?.referenceName).toBe('ITokenValidator');
  3246. });
  3247. it('should extract calls from implementation', () => {
  3248. const result = extractFromSource('UAuth.pas', code);
  3249. const callRefs = result.unresolvedReferences.filter(
  3250. (r) => r.referenceKind === 'calls'
  3251. );
  3252. expect(callRefs.map((r) => r.referenceName)).toContain('Inc');
  3253. expect(callRefs.map((r) => r.referenceName)).toContain('Validate');
  3254. });
  3255. });
  3256. describe('Full fixture: UTypes.pas', () => {
  3257. const code = `unit UTypes;
  3258. interface
  3259. uses
  3260. System.SysUtils;
  3261. const
  3262. C_MAX_RETRIES = 3;
  3263. C_DEFAULT_NAME = 'Guest';
  3264. type
  3265. TUserRole = (urAdmin, urEditor, urViewer);
  3266. TPoint2D = record
  3267. X: Double;
  3268. Y: Double;
  3269. end;
  3270. TUserName = string;
  3271. TUserInfo = class
  3272. public
  3273. type
  3274. TAddress = record
  3275. Street: string;
  3276. City: string;
  3277. Zip: string;
  3278. end;
  3279. private
  3280. FName: TUserName;
  3281. FRole: TUserRole;
  3282. FAddress: TAddress;
  3283. public
  3284. constructor Create(const AName: TUserName; ARole: TUserRole);
  3285. function GetDisplayName: string;
  3286. class function CreateAdmin(const AName: TUserName): TUserInfo; static;
  3287. property Name: TUserName read FName write FName;
  3288. property Role: TUserRole read FRole;
  3289. property Address: TAddress read FAddress write FAddress;
  3290. end;
  3291. implementation
  3292. constructor TUserInfo.Create(const AName: TUserName; ARole: TUserRole);
  3293. begin
  3294. FName := AName;
  3295. FRole := ARole;
  3296. end;
  3297. function TUserInfo.GetDisplayName: string;
  3298. begin
  3299. if FRole = urAdmin then
  3300. Result := '[Admin] ' + FName
  3301. else
  3302. Result := FName;
  3303. end;
  3304. class function TUserInfo.CreateAdmin(const AName: TUserName): TUserInfo;
  3305. begin
  3306. Result := TUserInfo.Create(AName, urAdmin);
  3307. end;
  3308. end.`;
  3309. it('should extract enums with members', () => {
  3310. const result = extractFromSource('UTypes.pas', code);
  3311. const enumNode = result.nodes.find((n) => n.kind === 'enum');
  3312. expect(enumNode?.name).toBe('TUserRole');
  3313. const members = result.nodes.filter((n) => n.kind === 'enum_member');
  3314. expect(members.length).toBe(3);
  3315. expect(members.map((m) => m.name)).toEqual(['urAdmin', 'urEditor', 'urViewer']);
  3316. });
  3317. it('should extract constants', () => {
  3318. const result = extractFromSource('UTypes.pas', code);
  3319. const constants = result.nodes.filter((n) => n.kind === 'constant');
  3320. expect(constants.length).toBe(2);
  3321. expect(constants.map((c) => c.name)).toContain('C_MAX_RETRIES');
  3322. expect(constants.map((c) => c.name)).toContain('C_DEFAULT_NAME');
  3323. });
  3324. it('should extract type aliases', () => {
  3325. const result = extractFromSource('UTypes.pas', code);
  3326. const aliases = result.nodes.filter((n) => n.kind === 'type_alias');
  3327. expect(aliases.map((a) => a.name)).toContain('TUserName');
  3328. });
  3329. it('should extract records as classes with fields', () => {
  3330. const result = extractFromSource('UTypes.pas', code);
  3331. const classes = result.nodes.filter((n) => n.kind === 'class');
  3332. expect(classes.map((c) => c.name)).toContain('TPoint2D');
  3333. // TPoint2D fields
  3334. const fields = result.nodes.filter((n) => n.kind === 'field');
  3335. expect(fields.map((f) => f.name)).toContain('X');
  3336. expect(fields.map((f) => f.name)).toContain('Y');
  3337. });
  3338. it('should extract static class methods', () => {
  3339. const result = extractFromSource('UTypes.pas', code);
  3340. const methods = result.nodes.filter((n) => n.kind === 'method');
  3341. const staticMethod = methods.find((m) => m.name === 'CreateAdmin');
  3342. expect(staticMethod).toBeDefined();
  3343. expect(staticMethod?.isStatic).toBe(true);
  3344. });
  3345. it('should extract nested types', () => {
  3346. const result = extractFromSource('UTypes.pas', code);
  3347. const classes = result.nodes.filter((n) => n.kind === 'class');
  3348. expect(classes.map((c) => c.name)).toContain('TAddress');
  3349. });
  3350. });
  3351. });
  3352. // =============================================================================
  3353. // DFM/FMX Extraction
  3354. // =============================================================================
  3355. describe('DFM/FMX Extraction', () => {
  3356. it('should extract components from DFM', () => {
  3357. const code = `object Form1: TForm1
  3358. Left = 0
  3359. Top = 0
  3360. Caption = 'My Form'
  3361. object Button1: TButton
  3362. Left = 10
  3363. Top = 10
  3364. Caption = 'Click Me'
  3365. end
  3366. end`;
  3367. const result = extractFromSource('Form1.dfm', code);
  3368. const components = result.nodes.filter((n) => n.kind === 'component');
  3369. expect(components.length).toBe(2);
  3370. expect(components.map((c) => c.name)).toContain('Form1');
  3371. expect(components.map((c) => c.name)).toContain('Button1');
  3372. const button = components.find((c) => c.name === 'Button1');
  3373. expect(button?.signature).toBe('TButton');
  3374. });
  3375. it('should extract nested component hierarchy', () => {
  3376. const code = `object Form1: TForm1
  3377. object Panel1: TPanel
  3378. object Label1: TLabel
  3379. Caption = 'Hello'
  3380. end
  3381. end
  3382. end`;
  3383. const result = extractFromSource('Form1.dfm', code);
  3384. const components = result.nodes.filter((n) => n.kind === 'component');
  3385. expect(components.length).toBe(3);
  3386. // Check nesting: Panel1 contains Label1
  3387. const panel = components.find((c) => c.name === 'Panel1');
  3388. const label = components.find((c) => c.name === 'Label1');
  3389. const containsEdge = result.edges.find(
  3390. (e) => e.source === panel?.id && e.target === label?.id && e.kind === 'contains'
  3391. );
  3392. expect(containsEdge).toBeDefined();
  3393. });
  3394. it('should extract event handler references', () => {
  3395. const code = `object Form1: TForm1
  3396. OnCreate = FormCreate
  3397. OnDestroy = FormDestroy
  3398. object Button1: TButton
  3399. OnClick = Button1Click
  3400. end
  3401. end`;
  3402. const result = extractFromSource('Form1.dfm', code);
  3403. const refs = result.unresolvedReferences;
  3404. expect(refs.length).toBe(3);
  3405. expect(refs.map((r) => r.referenceName)).toContain('FormCreate');
  3406. expect(refs.map((r) => r.referenceName)).toContain('FormDestroy');
  3407. expect(refs.map((r) => r.referenceName)).toContain('Button1Click');
  3408. expect(refs.every((r) => r.referenceKind === 'references')).toBe(true);
  3409. });
  3410. it('should handle multi-line properties', () => {
  3411. const code = `object Form1: TForm1
  3412. SQL.Strings = (
  3413. 'SELECT * FROM users'
  3414. 'WHERE active = 1')
  3415. object Button1: TButton
  3416. OnClick = Button1Click
  3417. end
  3418. end`;
  3419. const result = extractFromSource('Form1.dfm', code);
  3420. const components = result.nodes.filter((n) => n.kind === 'component');
  3421. expect(components.length).toBe(2);
  3422. const refs = result.unresolvedReferences;
  3423. expect(refs.length).toBe(1);
  3424. expect(refs[0]?.referenceName).toBe('Button1Click');
  3425. });
  3426. it('should handle inherited keyword', () => {
  3427. const code = `inherited Form1: TForm1
  3428. Caption = 'Inherited Form'
  3429. object Button1: TButton
  3430. OnClick = Button1Click
  3431. end
  3432. end`;
  3433. const result = extractFromSource('Form1.dfm', code);
  3434. const components = result.nodes.filter((n) => n.kind === 'component');
  3435. expect(components.length).toBe(2);
  3436. expect(components.map((c) => c.name)).toContain('Form1');
  3437. });
  3438. it('should handle item collection properties', () => {
  3439. const code = `object Form1: TForm1
  3440. object StatusBar1: TStatusBar
  3441. Panels = <
  3442. item
  3443. Width = 200
  3444. end
  3445. item
  3446. Width = 200
  3447. end>
  3448. end
  3449. end`;
  3450. const result = extractFromSource('Form1.dfm', code);
  3451. const components = result.nodes.filter((n) => n.kind === 'component');
  3452. expect(components.length).toBe(2);
  3453. });
  3454. describe('Full fixture: MainForm.dfm', () => {
  3455. const code = `object frmMain: TfrmMain
  3456. Left = 0
  3457. Top = 0
  3458. Caption = 'CodeGraph DFM Fixture'
  3459. ClientHeight = 480
  3460. ClientWidth = 640
  3461. OnCreate = FormCreate
  3462. OnDestroy = FormDestroy
  3463. object pnlTop: TPanel
  3464. Left = 0
  3465. Top = 0
  3466. Width = 640
  3467. Height = 50
  3468. object lblTitle: TLabel
  3469. Left = 16
  3470. Top = 16
  3471. Caption = 'Authentication Service'
  3472. end
  3473. object btnLogin: TButton
  3474. Left = 540
  3475. Top = 12
  3476. OnClick = btnLoginClick
  3477. end
  3478. end
  3479. object pnlContent: TPanel
  3480. Left = 0
  3481. Top = 50
  3482. object edtUsername: TEdit
  3483. Left = 16
  3484. Top = 16
  3485. OnChange = edtUsernameChange
  3486. end
  3487. object edtPassword: TEdit
  3488. Left = 16
  3489. Top = 48
  3490. OnKeyPress = edtPasswordKeyPress
  3491. end
  3492. object mmoLog: TMemo
  3493. Left = 16
  3494. Top = 88
  3495. end
  3496. end
  3497. object pnlStatus: TStatusBar
  3498. Left = 0
  3499. Top = 440
  3500. Panels = <
  3501. item
  3502. Width = 200
  3503. end
  3504. item
  3505. Width = 200
  3506. end>
  3507. end
  3508. end`;
  3509. it('should extract all components', () => {
  3510. const result = extractFromSource('MainForm.dfm', code);
  3511. const components = result.nodes.filter((n) => n.kind === 'component');
  3512. expect(components.length).toBe(9);
  3513. expect(components.map((c) => c.name)).toEqual(
  3514. expect.arrayContaining([
  3515. 'frmMain', 'pnlTop', 'lblTitle', 'btnLogin',
  3516. 'pnlContent', 'edtUsername', 'edtPassword', 'mmoLog', 'pnlStatus',
  3517. ])
  3518. );
  3519. });
  3520. it('should extract all event handlers', () => {
  3521. const result = extractFromSource('MainForm.dfm', code);
  3522. const refs = result.unresolvedReferences;
  3523. expect(refs.length).toBe(5);
  3524. expect(refs.map((r) => r.referenceName)).toEqual(
  3525. expect.arrayContaining([
  3526. 'FormCreate', 'FormDestroy', 'btnLoginClick',
  3527. 'edtUsernameChange', 'edtPasswordKeyPress',
  3528. ])
  3529. );
  3530. });
  3531. });
  3532. });
  3533. describe('Kotlin Multiplatform expect/actual', () => {
  3534. let tempDir: string;
  3535. let cg: CodeGraph;
  3536. beforeEach(() => {
  3537. tempDir = createTempDir();
  3538. });
  3539. afterEach(() => {
  3540. if (cg) cg.close();
  3541. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  3542. });
  3543. it('links expect declarations to platform actual implementations and surfaces them in impact', async () => {
  3544. const common = path.join(tempDir, 'src', 'commonMain');
  3545. const jvm = path.join(tempDir, 'src', 'jvmMain');
  3546. fs.mkdirSync(common, { recursive: true });
  3547. fs.mkdirSync(jvm, { recursive: true });
  3548. // common source set: expect declarations + a caller that uses them
  3549. fs.writeFileSync(
  3550. path.join(common, 'SystemProps.kt'),
  3551. `package demo.internal
  3552. expect fun systemProp(name: String): String?
  3553. expect class Platform {
  3554. fun describe(): String
  3555. }
  3556. `
  3557. );
  3558. fs.writeFileSync(
  3559. path.join(common, 'Caller.kt'),
  3560. `package demo
  3561. import demo.internal.systemProp
  3562. import demo.internal.Platform
  3563. fun useIt(): String {
  3564. val v = systemProp("os.name")
  3565. return Platform().describe() + v
  3566. }
  3567. `
  3568. );
  3569. // jvm source set: actual implementations
  3570. fs.writeFileSync(
  3571. path.join(jvm, 'SystemProps.kt'),
  3572. `package demo.internal
  3573. actual fun systemProp(name: String): String? = System.getProperty(name)
  3574. actual class Platform {
  3575. actual fun describe(): String = "JVM"
  3576. }
  3577. `
  3578. );
  3579. cg = CodeGraph.initSync(tempDir);
  3580. await cg.indexAll();
  3581. cg.resolveReferences();
  3582. // The expect/actual markers are captured onto the node's decorators.
  3583. const fns = cg.getNodesByKind('function');
  3584. const actualFn = fns.find(
  3585. (n) => n.name === 'systemProp' && n.decorators?.includes('actual')
  3586. );
  3587. const expectFn = fns.find(
  3588. (n) => n.name === 'systemProp' && n.decorators?.includes('expect')
  3589. );
  3590. expect(actualFn).toBeDefined();
  3591. expect(expectFn).toBeDefined();
  3592. expect(actualFn!.filePath).not.toBe(expectFn!.filePath);
  3593. // Editing the JVM actual must surface the common expect AND its caller —
  3594. // before the expect/actual bridge the actual had zero dependents.
  3595. const impact = cg.getImpactRadius(actualFn!.id, 3);
  3596. const impacted = [...impact.nodes.values()].map((n) => n.name);
  3597. expect(impacted).toContain('systemProp'); // the common expect
  3598. expect(impacted).toContain('useIt'); // the caller, reached transitively
  3599. // The bridging edge is a heuristic `calls` edge tagged by the synthesizer.
  3600. const bridge = impact.edges.find(
  3601. (e) =>
  3602. e.target === actualFn!.id &&
  3603. e.provenance === 'heuristic' &&
  3604. (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy ===
  3605. 'kotlin-expect-actual'
  3606. );
  3607. expect(bridge).toBeDefined();
  3608. expect(bridge!.source).toBe(expectFn!.id);
  3609. });
  3610. it('links an expect class to an actual typealias (different node kinds)', async () => {
  3611. const common = path.join(tempDir, 'src', 'commonMain');
  3612. const jvm = path.join(tempDir, 'src', 'jvmMain');
  3613. fs.mkdirSync(common, { recursive: true });
  3614. fs.mkdirSync(jvm, { recursive: true });
  3615. fs.writeFileSync(
  3616. path.join(common, 'Lock.kt'),
  3617. `package demo
  3618. expect class Lock {
  3619. fun acquire()
  3620. }
  3621. `
  3622. );
  3623. fs.writeFileSync(
  3624. path.join(jvm, 'Lock.kt'),
  3625. `package demo
  3626. actual typealias Lock = java.util.concurrent.locks.ReentrantLock
  3627. `
  3628. );
  3629. cg = CodeGraph.initSync(tempDir);
  3630. await cg.indexAll();
  3631. cg.resolveReferences();
  3632. const aliasNode = cg
  3633. .getNodesByKind('type_alias')
  3634. .find((n) => n.name === 'Lock' && n.decorators?.includes('actual'));
  3635. expect(aliasNode).toBeDefined();
  3636. // The actual typealias is now a cross-file dependency target (linked from
  3637. // the expect class), so it participates in impact rather than being orphaned.
  3638. const impact = cg.getImpactRadius(aliasNode!.id, 3);
  3639. const bridge = impact.edges.find(
  3640. (e) =>
  3641. e.target === aliasNode!.id &&
  3642. (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy ===
  3643. 'kotlin-expect-actual'
  3644. );
  3645. expect(bridge).toBeDefined();
  3646. });
  3647. });
  3648. describe('Scala cross-file dependencies', () => {
  3649. let tempDir: string;
  3650. let cg: CodeGraph;
  3651. beforeEach(() => {
  3652. tempDir = createTempDir();
  3653. });
  3654. afterEach(() => {
  3655. if (cg) cg.close();
  3656. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  3657. });
  3658. it('links parameterized supertypes, type annotations, and implicit params across files', async () => {
  3659. const src = path.join(tempDir, 'src', 'main', 'scala', 'demo');
  3660. fs.mkdirSync(src, { recursive: true });
  3661. fs.writeFileSync(
  3662. path.join(src, 'Semigroup.scala'),
  3663. `package demo
  3664. trait Semigroup[A] {
  3665. def combine(x: A, y: A): A
  3666. }
  3667. `
  3668. );
  3669. fs.writeFileSync(
  3670. path.join(src, 'Monoid.scala'),
  3671. `package demo
  3672. trait Monoid[A] extends Semigroup[A] {
  3673. def empty: A
  3674. }
  3675. `
  3676. );
  3677. fs.writeFileSync(
  3678. path.join(src, 'Instances.scala'),
  3679. `package demo
  3680. object Instances {
  3681. implicit val intMonoid: Monoid[Int] = new Monoid[Int] {
  3682. def empty: Int = 0
  3683. def combine(x: Int, y: Int): Int = x + y
  3684. }
  3685. }
  3686. `
  3687. );
  3688. fs.writeFileSync(
  3689. path.join(src, 'Folding.scala'),
  3690. `package demo
  3691. object Folding {
  3692. def fold[A](xs: List[A])(implicit M: Monoid[A]): A =
  3693. xs.foldLeft(M.empty)(M.combine)
  3694. }
  3695. `
  3696. );
  3697. cg = CodeGraph.initSync(tempDir);
  3698. await cg.indexAll();
  3699. cg.resolveReferences();
  3700. const monoid = cg.getNodesByKind('trait').find((n) => n.name === 'Monoid');
  3701. const semigroup = cg.getNodesByKind('trait').find((n) => n.name === 'Semigroup');
  3702. expect(monoid).toBeDefined();
  3703. expect(semigroup).toBeDefined();
  3704. expect(monoid!.filePath).not.toBe(semigroup!.filePath);
  3705. // Parameterized supertype `extends Semigroup[A]` must create an extends edge —
  3706. // the whole point of the fix (the `[A]` used to defeat name matching).
  3707. const semaImpact = cg.getImpactRadius(semigroup!.id, 3);
  3708. expect([...semaImpact.nodes.values()].map((n) => n.name)).toContain('Monoid');
  3709. // Editing Monoid surfaces the cross-file users: the instance val typed
  3710. // `Monoid[Int]` and the method taking it as an implicit (curried) param.
  3711. const impacted = [...cg.getImpactRadius(monoid!.id, 3).nodes.values()].map((n) => n.name);
  3712. expect(impacted).toContain('intMonoid'); // field type annotation
  3713. expect(impacted).toContain('fold'); // trailing implicit parameter list
  3714. });
  3715. });
  3716. describe('PHP namespace + import resolution', () => {
  3717. let tempDir: string;
  3718. let cg: CodeGraph;
  3719. beforeEach(() => {
  3720. tempDir = createTempDir();
  3721. });
  3722. afterEach(() => {
  3723. if (cg) cg.close();
  3724. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  3725. });
  3726. it('resolves `use` imports to the namespace-qualified definition and type-hints across files', async () => {
  3727. const src = path.join(tempDir, 'src');
  3728. // Two interfaces with the SAME simple name in different namespaces — the
  3729. // exact ambiguity (Laravel has 7+ `Factory`) that bare-name matching can't
  3730. // resolve. The namespace qualifies them; the `use` import disambiguates.
  3731. fs.mkdirSync(path.join(src, 'Cache'), { recursive: true });
  3732. fs.mkdirSync(path.join(src, 'Mail'), { recursive: true });
  3733. fs.mkdirSync(path.join(src, 'App'), { recursive: true });
  3734. fs.writeFileSync(
  3735. path.join(src, 'Cache', 'Factory.php'),
  3736. `<?php
  3737. namespace Contracts\\Cache;
  3738. interface Factory {
  3739. public function store(): object;
  3740. }
  3741. `
  3742. );
  3743. fs.writeFileSync(
  3744. path.join(src, 'Mail', 'Factory.php'),
  3745. `<?php
  3746. namespace Contracts\\Mail;
  3747. interface Factory {
  3748. public function mailer(): object;
  3749. }
  3750. `
  3751. );
  3752. fs.writeFileSync(
  3753. path.join(src, 'App', 'Service.php'),
  3754. `<?php
  3755. namespace App;
  3756. use Contracts\\Cache\\Factory;
  3757. class Service {
  3758. public function make(): Factory {
  3759. return resolve(Factory::class);
  3760. }
  3761. }
  3762. `
  3763. );
  3764. cg = CodeGraph.initSync(tempDir);
  3765. await cg.indexAll();
  3766. cg.resolveReferences();
  3767. // The PHP namespace is captured into the qualified name, so the two
  3768. // same-named interfaces are distinguishable.
  3769. const cacheFactory = cg
  3770. .getNodesByKind('interface')
  3771. .find((n) => n.qualifiedName === 'Contracts\\Cache::Factory');
  3772. const mailFactory = cg
  3773. .getNodesByKind('interface')
  3774. .find((n) => n.qualifiedName === 'Contracts\\Mail::Factory');
  3775. expect(cacheFactory).toBeDefined();
  3776. expect(mailFactory).toBeDefined();
  3777. // Service `use`s Contracts\Cache\Factory, so editing THAT interface reaches
  3778. // Service.php — and editing the same-named Contracts\Mail\Factory must NOT
  3779. // (the import resolved to the right namespace, not an arbitrary `Factory`).
  3780. const serviceFile = 'src/App/Service.php';
  3781. const cacheReaches = [...cg.getImpactRadius(cacheFactory!.id, 3).nodes.values()].some(
  3782. (n) => (n.filePath ?? '').endsWith(serviceFile)
  3783. );
  3784. const mailReaches = [...cg.getImpactRadius(mailFactory!.id, 3).nodes.values()].some(
  3785. (n) => (n.filePath ?? '').endsWith(serviceFile)
  3786. );
  3787. expect(cacheReaches).toBe(true);
  3788. expect(mailReaches).toBe(false);
  3789. });
  3790. });
  3791. describe('Ruby mixins (include/extend/prepend)', () => {
  3792. let tempDir: string;
  3793. let cg: CodeGraph;
  3794. beforeEach(() => {
  3795. tempDir = createTempDir();
  3796. });
  3797. afterEach(() => {
  3798. if (cg) cg.close();
  3799. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  3800. });
  3801. it('links include/extend/prepend to the mixed-in module across files', async () => {
  3802. const lib = path.join(tempDir, 'lib');
  3803. fs.mkdirSync(lib, { recursive: true });
  3804. fs.writeFileSync(
  3805. path.join(lib, 'concerns.rb'),
  3806. `module Trackable
  3807. def track; end
  3808. end
  3809. module Cacheable
  3810. def cache; end
  3811. end
  3812. module Loggable
  3813. def log; end
  3814. end
  3815. `
  3816. );
  3817. fs.writeFileSync(
  3818. path.join(lib, 'model.rb'),
  3819. `class Model
  3820. include Trackable
  3821. prepend Cacheable
  3822. extend Loggable
  3823. end
  3824. `
  3825. );
  3826. cg = CodeGraph.initSync(tempDir);
  3827. await cg.indexAll();
  3828. cg.resolveReferences();
  3829. const model = cg.getNodesByKind('class').find((n) => n.name === 'Model');
  3830. expect(model).toBeDefined();
  3831. // All three mixin forms create an `implements` edge Model → module, so
  3832. // editing a concern surfaces every class that mixes it in (across files).
  3833. for (const moduleName of ['Trackable', 'Cacheable', 'Loggable']) {
  3834. const mod = cg.getNodesByKind('module').find((n) => n.name === moduleName);
  3835. expect(mod, moduleName).toBeDefined();
  3836. const impacted = [...cg.getImpactRadius(mod!.id, 3).nodes.values()].map((n) => n.name);
  3837. expect(impacted, `${moduleName} should be depended on by Model`).toContain('Model');
  3838. }
  3839. });
  3840. it('resolves require / require_relative to the required file', async () => {
  3841. const lib = path.join(tempDir, 'lib');
  3842. fs.mkdirSync(path.join(lib, 'app'), { recursive: true });
  3843. // A leaf file whose class is referenced only dynamically — so without
  3844. // require resolution it would look like nothing depends on it.
  3845. fs.writeFileSync(
  3846. path.join(lib, 'app', 'fetcher.rb'),
  3847. `module App
  3848. class Fetcher
  3849. def fetch; end
  3850. end
  3851. end
  3852. `
  3853. );
  3854. // Pulled in by a load-path `require` …
  3855. fs.writeFileSync(
  3856. path.join(lib, 'app', 'worker.rb'),
  3857. `require "app/fetcher"
  3858. module App
  3859. class Worker; end
  3860. end
  3861. `
  3862. );
  3863. // … and a sibling pulled in by `require_relative`.
  3864. fs.writeFileSync(
  3865. path.join(lib, 'app', 'boot.rb'),
  3866. `require_relative "fetcher"
  3867. `
  3868. );
  3869. cg = CodeGraph.initSync(tempDir);
  3870. await cg.indexAll();
  3871. cg.resolveReferences();
  3872. // The require edges target fetcher.rb's FILE node. Editing it should reach
  3873. // BOTH the load-path requirer (worker.rb) and the require_relative one
  3874. // (boot.rb) — without require resolution its file would have no dependents.
  3875. const fetcher = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('app/fetcher.rb'));
  3876. expect(fetcher, 'fetcher.rb indexed').toBeDefined();
  3877. const reached = [...cg.getImpactRadius(fetcher!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  3878. expect(reached.some((p) => p.endsWith('app/worker.rb'))).toBe(true);
  3879. expect(reached.some((p) => p.endsWith('app/boot.rb'))).toBe(true);
  3880. });
  3881. });
  3882. describe('C++ free-function name extraction', () => {
  3883. let tempDir: string;
  3884. let cg: CodeGraph;
  3885. beforeEach(() => {
  3886. tempDir = createTempDir();
  3887. });
  3888. afterEach(() => {
  3889. if (cg) cg.close();
  3890. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  3891. });
  3892. it('names a free function correctly when it has qualified-type params or a trailing return type', async () => {
  3893. const src = path.join(tempDir, 'src');
  3894. fs.mkdirSync(src, { recursive: true });
  3895. // TableFileName has a `const std::string&` parameter; BuildName uses an
  3896. // `auto … -> std::string` trailing return type. Both used to be named
  3897. // `string` (picked up from the parameter / return type), so callers never
  3898. // resolved and the defining file looked like nothing depended on it.
  3899. fs.writeFileSync(
  3900. path.join(src, 'names.cc'),
  3901. `#include <string>
  3902. std::string TableFileName(const std::string& dbname, int number) {
  3903. return dbname;
  3904. }
  3905. auto BuildName(const std::string& a) -> std::string {
  3906. return a;
  3907. }
  3908. `
  3909. );
  3910. fs.writeFileSync(
  3911. path.join(src, 'user.cc'),
  3912. `#include <string>
  3913. std::string use() {
  3914. return TableFileName("db", 1) + BuildName("x");
  3915. }
  3916. `
  3917. );
  3918. cg = CodeGraph.initSync(tempDir);
  3919. await cg.indexAll();
  3920. cg.resolveReferences();
  3921. // The functions are extracted under their real names, not `string`.
  3922. const fns = cg.getNodesByKind('function');
  3923. const tableFn = fns.find((n) => n.name === 'TableFileName');
  3924. const buildFn = fns.find((n) => n.name === 'BuildName');
  3925. expect(tableFn, 'TableFileName extracted (not "string")').toBeDefined();
  3926. expect(buildFn, 'BuildName extracted (not "string")').toBeDefined();
  3927. // And the cross-file calls resolve to them, so editing names.cc surfaces user.cc.
  3928. for (const fn of [tableFn!, buildFn!]) {
  3929. const reached = [...cg.getImpactRadius(fn.id, 3).nodes.values()].map((n) => n.filePath ?? '');
  3930. expect(reached.some((p) => p.endsWith('user.cc')), `${fn.name} should be called from user.cc`).toBe(true);
  3931. }
  3932. });
  3933. });
  3934. describe('Dart mixins and type references', () => {
  3935. let tempDir: string;
  3936. let cg: CodeGraph;
  3937. beforeEach(() => {
  3938. tempDir = createTempDir();
  3939. });
  3940. afterEach(() => {
  3941. if (cg) cg.close();
  3942. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  3943. });
  3944. it('links `with` mixins and method parameter/return types across files', async () => {
  3945. const lib = path.join(tempDir, 'lib');
  3946. fs.mkdirSync(lib, { recursive: true });
  3947. fs.writeFileSync(
  3948. path.join(lib, 'models.dart'),
  3949. `class User {
  3950. final String name;
  3951. User(this.name);
  3952. }
  3953. mixin Loggable {
  3954. void log() {}
  3955. }
  3956. abstract class Repository {
  3957. User find(int id);
  3958. }
  3959. `
  3960. );
  3961. fs.writeFileSync(
  3962. path.join(lib, 'service.dart'),
  3963. `import 'models.dart';
  3964. class UserService extends Repository with Loggable {
  3965. @override
  3966. User find(int id) => User('x');
  3967. List<User> all() => [];
  3968. }
  3969. `
  3970. );
  3971. cg = CodeGraph.initSync(tempDir);
  3972. await cg.indexAll();
  3973. cg.resolveReferences();
  3974. const inModels = (name: string) =>
  3975. cg.getNodesByKind('class').concat(cg.getNodesByKind('module'))
  3976. .find((n) => n.name === name && n.filePath.endsWith('models.dart'));
  3977. // The `with Loggable` mixin records a dependency — editing the mixin surfaces
  3978. // the class that mixes it in (across files). Loggable is a `mixin`, indexed
  3979. // as a class-like node.
  3980. const loggable = cg.getNodesByKind('class').find((n) => n.name === 'Loggable')
  3981. ?? cg.getNodesByKind('module').find((n) => n.name === 'Loggable');
  3982. expect(loggable, 'Loggable mixin indexed').toBeDefined();
  3983. const mixinUsers = [...cg.getImpactRadius(loggable!.id, 3).nodes.values()].map((n) => n.name);
  3984. expect(mixinUsers).toContain('UserService');
  3985. // `User` is used only as a method parameter/return type in service.dart —
  3986. // editing it must still surface service.dart via the type references.
  3987. const user = inModels('User') ?? cg.getNodesByKind('class').find((n) => n.name === 'User');
  3988. expect(user, 'User indexed').toBeDefined();
  3989. const userDeps = [...cg.getImpactRadius(user!.id, 3).nodes.values()].map((n) => n.filePath ?? '');
  3990. expect(userDeps.some((p) => p.endsWith('service.dart'))).toBe(true);
  3991. });
  3992. });
  3993. describe('Static-member / value-read references', () => {
  3994. let tempDir: string;
  3995. let cg: CodeGraph;
  3996. beforeEach(() => {
  3997. tempDir = createTempDir();
  3998. });
  3999. afterEach(() => {
  4000. if (cg) cg.close();
  4001. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4002. });
  4003. it('links a type referenced only via a static field / enum value (and ignores lowercase receivers)', async () => {
  4004. fs.writeFileSync(
  4005. path.join(tempDir, 'JsonScope.java'),
  4006. `class JsonScope {
  4007. static final int EMPTY_DOCUMENT = 1;
  4008. }
  4009. `
  4010. );
  4011. fs.writeFileSync(
  4012. path.join(tempDir, 'Reader.java'),
  4013. `class Reader {
  4014. private int helper;
  4015. int peek() {
  4016. return JsonScope.EMPTY_DOCUMENT;
  4017. }
  4018. int noop() {
  4019. return this.helper;
  4020. }
  4021. }
  4022. `
  4023. );
  4024. cg = CodeGraph.initSync(tempDir);
  4025. await cg.indexAll();
  4026. cg.resolveReferences();
  4027. // JsonScope is used ONLY as `JsonScope.EMPTY_DOCUMENT` (a static-field value
  4028. // read — never constructed or called), so before the static-member pass it
  4029. // had no dependents. Editing it now surfaces Reader.java.
  4030. const scope = cg.getNodesByKind('class').find((n) => n.name === 'JsonScope');
  4031. expect(scope, 'JsonScope indexed').toBeDefined();
  4032. const reached = [...cg.getImpactRadius(scope!.id, 3).nodes.values()].map((n) => n.filePath ?? '');
  4033. expect(reached.some((p) => p.endsWith('Reader.java'))).toBe(true);
  4034. // A lowercase receiver (`this.helper`) must NOT be emitted as a type ref —
  4035. // only Capitalized receivers (types) are. No node named `this`/`helper`
  4036. // should appear as a reference target from peek/noop beyond JsonScope.
  4037. const refTargets = cg
  4038. .getNodesByKind('class')
  4039. .filter((n) => n.name === 'this' || n.name === 'helper');
  4040. expect(refTargets.length).toBe(0);
  4041. });
  4042. it('does not link a static-member read across language families (coincidental name)', async () => {
  4043. // A native (Kotlin) `Build.VERSION` reads the Android system class — it must
  4044. // NOT link to a coincidentally same-named TS class (the cross-language false
  4045. // positive that name-matching produces; `references` edges are language-local).
  4046. fs.writeFileSync(
  4047. path.join(tempDir, 'Build.ts'),
  4048. `export class Build {\n static version = 1;\n}\n`
  4049. );
  4050. fs.writeFileSync(
  4051. path.join(tempDir, 'Device.kt'),
  4052. `package app\nclass Device {\n fun sdk(): Int = Build.VERSION\n}\n`
  4053. );
  4054. cg = CodeGraph.initSync(tempDir);
  4055. await cg.indexAll();
  4056. cg.resolveReferences();
  4057. const tsBuild = cg.getNodesByKind('class').find((n) => n.name === 'Build' && n.filePath.endsWith('Build.ts'));
  4058. expect(tsBuild).toBeDefined();
  4059. // The Kotlin file is `app/Device.kt`; the TS Build must have NO dependent there.
  4060. const deps = [...cg.getImpactRadius(tsBuild!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4061. expect(deps.some((p) => p.endsWith('Device.kt'))).toBe(false);
  4062. });
  4063. });
  4064. describe('Cross-language type/import gate (RN name collisions)', () => {
  4065. let tempDir: string;
  4066. let cg: CodeGraph;
  4067. beforeEach(() => {
  4068. tempDir = createTempDir();
  4069. });
  4070. afterEach(() => {
  4071. if (cg) cg.close();
  4072. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4073. });
  4074. it('a TS PascalCase type ref lands on the TS type, never a same-named native class', async () => {
  4075. // react-native-async-storage's example app has a TS `type TestRunner` AND a
  4076. // Kotlin `class TestRunner`. The React PascalCase resolver name-matched the
  4077. // Kotlin `class` (its COMPONENT_KINDS includes `class`) with no language
  4078. // check at confidence 0.8, outranking the (cross-language-penalized 0.5)
  4079. // TS name-match — so a TS ref to `TestRunner` crossed web→jvm. The ref here
  4080. // is intentionally NOT imported: a clean relative import would mask the bug
  4081. // by resolving via the import map before the framework strategy can win.
  4082. fs.writeFileSync(
  4083. path.join(tempDir, 'package.json'),
  4084. JSON.stringify({ dependencies: { 'react-native': '*' } })
  4085. );
  4086. fs.writeFileSync(
  4087. path.join(tempDir, 'useTests.ts'),
  4088. `export type TestRunner = { run: () => void };\n`
  4089. );
  4090. fs.writeFileSync(
  4091. path.join(tempDir, 'basic.tsx'),
  4092. `export function useBasicTest(r: TestRunner): TestRunner {\n return r;\n}\n`
  4093. );
  4094. fs.writeFileSync(
  4095. path.join(tempDir, 'TestUtils.kt'),
  4096. `package app\nclass TestRunner {\n fun run() {}\n}\n`
  4097. );
  4098. cg = CodeGraph.initSync(tempDir);
  4099. await cg.indexAll();
  4100. cg.resolveReferences();
  4101. const ktRunner = cg
  4102. .getNodesByKind('class')
  4103. .find((n) => n.name === 'TestRunner' && n.filePath.endsWith('TestUtils.kt'));
  4104. expect(ktRunner, 'Kotlin TestRunner class').toBeDefined();
  4105. const ktDeps = [...cg.getImpactRadius(ktRunner!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4106. expect(ktDeps.some((p) => p.endsWith('basic.tsx')), 'Kotlin class has NO TS dependent').toBe(false);
  4107. const tsRunner = cg.getNodesByKind('type_alias').find((n) => n.name === 'TestRunner');
  4108. expect(tsRunner, 'TS TestRunner type_alias').toBeDefined();
  4109. const tsDeps = [...cg.getImpactRadius(tsRunner!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4110. expect(tsDeps.some((p) => p.endsWith('basic.tsx')), 'TS type captured the ref (re-pointed)').toBe(true);
  4111. });
  4112. it('gates a cross-family import name collision but keeps same-family imports', async () => {
  4113. // A TS `import { Widget }` that only matches a Swift `class Widget` must not
  4114. // create a web→apple dependency — but a sibling TS module imported by
  4115. // another TS file (same family) must still resolve (no over-gating).
  4116. fs.writeFileSync(path.join(tempDir, 'Widget.swift'), `class Widget {\n func render() {}\n}\n`);
  4117. fs.writeFileSync(
  4118. path.join(tempDir, 'widget.ts'),
  4119. `import { Widget } from './native';\nexport function mount(w: Widget) {}\n`
  4120. );
  4121. fs.writeFileSync(path.join(tempDir, 'util.ts'), `export class Helper {}\n`);
  4122. fs.writeFileSync(
  4123. path.join(tempDir, 'app.ts'),
  4124. `import { Helper } from './util';\nexport const h = new Helper();\n`
  4125. );
  4126. cg = CodeGraph.initSync(tempDir);
  4127. await cg.indexAll();
  4128. cg.resolveReferences();
  4129. const swiftWidget = cg
  4130. .getNodesByKind('class')
  4131. .find((n) => n.name === 'Widget' && n.filePath.endsWith('.swift'));
  4132. expect(swiftWidget, 'Swift Widget class').toBeDefined();
  4133. const wDeps = [...cg.getImpactRadius(swiftWidget!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4134. expect(wDeps.some((p) => p.endsWith('widget.ts')), 'Swift class has NO TS dependent').toBe(false);
  4135. // Same-family control — the TS Helper must still see its TS dependent.
  4136. const helper = cg.getNodesByKind('class').find((n) => n.name === 'Helper');
  4137. expect(helper, 'TS Helper class').toBeDefined();
  4138. const hDeps = [...cg.getImpactRadius(helper!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4139. expect(hDeps.some((p) => p.endsWith('app.ts')), 'same-family TS import preserved').toBe(true);
  4140. });
  4141. });
  4142. describe('Python absolute module import resolution', () => {
  4143. let tempDir: string;
  4144. let cg: CodeGraph;
  4145. beforeEach(() => {
  4146. tempDir = createTempDir();
  4147. });
  4148. afterEach(() => {
  4149. if (cg) cg.close();
  4150. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4151. });
  4152. it('links a bare `import pkg.module` of an internal module to its file', async () => {
  4153. // `import conduit.apps.signals` (a Django-style side-effect import, and any
  4154. // dotted absolute module import) had no edge to the module file — only
  4155. // `from x import y` was linked — so a module imported by its dotted path
  4156. // looked like nothing depended on it.
  4157. fs.mkdirSync(path.join(tempDir, 'conduit/apps'), { recursive: true });
  4158. fs.writeFileSync(path.join(tempDir, 'conduit/__init__.py'), '');
  4159. fs.writeFileSync(path.join(tempDir, 'conduit/apps/__init__.py'), '');
  4160. fs.writeFileSync(path.join(tempDir, 'conduit/apps/signals.py'), `def handler():\n pass\n`);
  4161. fs.writeFileSync(
  4162. path.join(tempDir, 'conduit/apps/app.py'),
  4163. `import conduit.apps.signals\nimport os\n\nVALUE = 1\n`
  4164. );
  4165. cg = CodeGraph.initSync(tempDir);
  4166. await cg.indexAll();
  4167. cg.resolveReferences();
  4168. const signals = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('conduit/apps/signals.py'));
  4169. expect(signals, 'signals.py indexed').toBeDefined();
  4170. const deps = [...cg.getImpactRadius(signals!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4171. expect(deps.some((p) => p.endsWith('app.py')), 'importer depends on the module').toBe(true);
  4172. // `import os` (stdlib) must NOT fabricate an edge — no os.py file in the repo.
  4173. const osNode = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('/os.py'));
  4174. expect(osNode, 'no stdlib os.py node').toBeUndefined();
  4175. });
  4176. it('Django include() links the root URLconf to the included app urls module', async () => {
  4177. // `url(r'^api/', include('app.urls'))` should record a dependency from the
  4178. // root urlconf onto the included app's `urls.py` — so editing an app's routes
  4179. // surfaces the project urlconf that mounts them.
  4180. fs.mkdirSync(path.join(tempDir, 'app'), { recursive: true });
  4181. fs.writeFileSync(path.join(tempDir, 'requirements.txt'), `django==4.0\n`);
  4182. fs.writeFileSync(path.join(tempDir, 'app/__init__.py'), '');
  4183. fs.writeFileSync(path.join(tempDir, 'app/views.py'), `def home(request):\n return None\n`);
  4184. fs.writeFileSync(
  4185. path.join(tempDir, 'app/urls.py'),
  4186. `from django.conf.urls import url\nfrom . import views\nurlpatterns = [url(r'^$', views.home)]\n`
  4187. );
  4188. fs.writeFileSync(
  4189. path.join(tempDir, 'urls.py'),
  4190. `from django.conf.urls import include, url\nurlpatterns = [url(r'^app/', include('app.urls'))]\n`
  4191. );
  4192. cg = CodeGraph.initSync(tempDir);
  4193. await cg.indexAll();
  4194. cg.resolveReferences();
  4195. const appUrls = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('app/urls.py'));
  4196. expect(appUrls, 'app/urls.py indexed').toBeDefined();
  4197. const deps = [...cg.getImpactRadius(appUrls!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4198. expect(deps.some((p) => p.endsWith('urls.py') && !p.endsWith('app/urls.py')), 'root urlconf depends on the included app urls').toBe(true);
  4199. });
  4200. it('resolves `from pkg import submodule` to the submodule under that package, not a same-named one', async () => {
  4201. // FastAPI router-aggregator pattern: `from app.api.routes import authentication`
  4202. // with same-named modules in sibling packages must resolve via the import's
  4203. // SOURCE (the package), not a coincidental same-basename file elsewhere.
  4204. fs.mkdirSync(path.join(tempDir, 'app/api/routes'), { recursive: true });
  4205. fs.mkdirSync(path.join(tempDir, 'app/api/dependencies'), { recursive: true });
  4206. for (const p of ['app/__init__.py', 'app/api/__init__.py', 'app/api/routes/__init__.py', 'app/api/dependencies/__init__.py']) {
  4207. fs.writeFileSync(path.join(tempDir, p), '');
  4208. }
  4209. fs.writeFileSync(path.join(tempDir, 'app/api/routes/authentication.py'), `def login():\n pass\n`);
  4210. fs.writeFileSync(path.join(tempDir, 'app/api/dependencies/authentication.py'), `def get_user():\n pass\n`);
  4211. fs.writeFileSync(
  4212. path.join(tempDir, 'app/api/routes/api.py'),
  4213. `from app.api.routes import authentication\n\nROUTER = authentication\n`
  4214. );
  4215. cg = CodeGraph.initSync(tempDir);
  4216. await cg.indexAll();
  4217. cg.resolveReferences();
  4218. const routesAuth = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('routes/authentication.py'));
  4219. const depsAuth = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('dependencies/authentication.py'));
  4220. expect(routesAuth && depsAuth).toBeTruthy();
  4221. const routesDeps = [...cg.getImpactRadius(routesAuth!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4222. const depsDeps = [...cg.getImpactRadius(depsAuth!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4223. expect(routesDeps.some((p) => p.endsWith('routes/api.py')), 'submodule under the imported package is the dependent').toBe(true);
  4224. expect(depsDeps.some((p) => p.endsWith('routes/api.py')), 'same-named module in a sibling package is NOT').toBe(false);
  4225. });
  4226. });
  4227. describe('Razor / Blazor markup extraction', () => {
  4228. let tempDir: string;
  4229. let cg: CodeGraph;
  4230. beforeEach(() => {
  4231. tempDir = createTempDir();
  4232. });
  4233. afterEach(() => {
  4234. if (cg) cg.close();
  4235. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4236. });
  4237. it('links @model and Blazor component tags to their C# types; ignores HTML elements', async () => {
  4238. fs.mkdirSync(path.join(tempDir, 'Views'), { recursive: true });
  4239. fs.writeFileSync(
  4240. path.join(tempDir, 'LoginViewModel.cs'),
  4241. `namespace App { public class LoginViewModel { public string Email { get; set; } } }`
  4242. );
  4243. fs.writeFileSync(
  4244. path.join(tempDir, 'ToastComponent.cs'),
  4245. `namespace App { public class ToastComponent { } }`
  4246. );
  4247. fs.writeFileSync(
  4248. path.join(tempDir, 'Views/Login.cshtml'),
  4249. `@model LoginViewModel\n<div class="form">\n <input asp-for="Email" />\n</div>\n`
  4250. );
  4251. fs.writeFileSync(
  4252. path.join(tempDir, 'Index.razor'),
  4253. `<div>\n <ToastComponent />\n</div>\n`
  4254. );
  4255. cg = CodeGraph.initSync(tempDir);
  4256. await cg.indexAll();
  4257. cg.resolveReferences();
  4258. // `@model LoginViewModel` → the view-model class.
  4259. const vm = cg.getNodesByKind('class').find((n) => n.name === 'LoginViewModel');
  4260. expect(vm, 'LoginViewModel class').toBeDefined();
  4261. const vmDeps = [...cg.getImpactRadius(vm!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4262. expect(vmDeps.some((p) => p.endsWith('Login.cshtml')), '@model links the view').toBe(true);
  4263. // `<ToastComponent />` → the component class.
  4264. const toast = cg.getNodesByKind('class').find((n) => n.name === 'ToastComponent');
  4265. expect(toast, 'ToastComponent class').toBeDefined();
  4266. const toastDeps = [...cg.getImpactRadius(toast!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4267. expect(toastDeps.some((p) => p.endsWith('Index.razor')), 'Blazor tag links the component').toBe(true);
  4268. // HTML elements (`<div>`, `<input>`) must NOT become component references.
  4269. const htmlNodes = cg.getNodesByKind('class').filter((n) => n.name === 'div' || n.name === 'input');
  4270. expect(htmlNodes.length, 'no node for HTML elements').toBe(0);
  4271. });
  4272. it('C# namespaces qualify type names so same-named types are distinct', async () => {
  4273. fs.writeFileSync(path.join(tempDir, 'entity.cs'), `namespace App.Entities { public class CatalogBrand { } }`);
  4274. fs.writeFileSync(path.join(tempDir, 'dto.cs'), `namespace App.Models { public class CatalogBrand { } }`);
  4275. cg = CodeGraph.initSync(tempDir);
  4276. await cg.indexAll();
  4277. const brands = cg.getNodesByKind('class').filter((n) => n.name === 'CatalogBrand');
  4278. expect(brands.length, 'both CatalogBrand classes indexed').toBe(2);
  4279. const qns = brands.map((b) => b.qualifiedName).sort();
  4280. expect(qns[0]).not.toBe(qns[1]); // distinct qualified names (namespace-scoped)
  4281. expect(qns.some((q) => q.includes('Entities') && q.endsWith('CatalogBrand'))).toBe(true);
  4282. expect(qns.some((q) => q.includes('Models') && q.endsWith('CatalogBrand'))).toBe(true);
  4283. });
  4284. it('disambiguates a Razor type ref via @using (incl. folder _Imports.razor)', async () => {
  4285. // `CatalogBrand` exists as both a domain entity and a DTO; the component
  4286. // `@using`s the DTO's namespace (here via the folder _Imports.razor), so the
  4287. // ref must resolve to the DTO, not the same-named entity.
  4288. fs.mkdirSync(path.join(tempDir, 'Models'), { recursive: true });
  4289. fs.mkdirSync(path.join(tempDir, 'Entities'), { recursive: true });
  4290. fs.mkdirSync(path.join(tempDir, 'Pages'), { recursive: true });
  4291. fs.writeFileSync(path.join(tempDir, 'Models/CatalogBrand.cs'), `namespace App.Models { public class CatalogBrand { public int Id { get; set; } } }`);
  4292. fs.writeFileSync(path.join(tempDir, 'Entities/CatalogBrand.cs'), `namespace App.Entities { public class CatalogBrand { public int Id { get; set; } } }`);
  4293. fs.writeFileSync(path.join(tempDir, 'Pages/_Imports.razor'), `@using App.Models\n`);
  4294. fs.writeFileSync(
  4295. path.join(tempDir, 'Pages/List.razor'),
  4296. `<h1>List</h1>\n@code {\n private CatalogBrand _b = new CatalogBrand();\n}\n`
  4297. );
  4298. cg = CodeGraph.initSync(tempDir);
  4299. await cg.indexAll();
  4300. cg.resolveReferences();
  4301. const dto = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'App.Models::CatalogBrand');
  4302. const entity = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'App.Entities::CatalogBrand');
  4303. expect(dto && entity, 'both CatalogBrand classes').toBeTruthy();
  4304. const dtoDeps = [...cg.getImpactRadius(dto!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4305. const entityDeps = [...cg.getImpactRadius(entity!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4306. expect(dtoDeps.some((p) => p.endsWith('List.razor')), 'resolves to the @using\'d DTO').toBe(true);
  4307. expect(entityDeps.some((p) => p.endsWith('List.razor')), 'NOT the same-named entity').toBe(false);
  4308. });
  4309. it('delegates Blazor @code block C# to cover types used in component logic', async () => {
  4310. fs.writeFileSync(
  4311. path.join(tempDir, 'CatalogService.cs'),
  4312. `namespace App { public class CatalogService { public void Load() { } } }`
  4313. );
  4314. fs.writeFileSync(
  4315. path.join(tempDir, 'List.razor'),
  4316. `<h1>Catalog</h1>\n\n@code {\n private CatalogService _svc = new CatalogService();\n void Refresh() { _svc.Load(); }\n}\n`
  4317. );
  4318. cg = CodeGraph.initSync(tempDir);
  4319. await cg.indexAll();
  4320. cg.resolveReferences();
  4321. const svc = cg.getNodesByKind('class').find((n) => n.name === 'CatalogService');
  4322. expect(svc, 'CatalogService class').toBeDefined();
  4323. const deps = [...cg.getImpactRadius(svc!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4324. expect(deps.some((p) => p.endsWith('List.razor')), '@code usage links the component to the service').toBe(true);
  4325. });
  4326. });
  4327. describe('Default import resolution (renamed default export)', () => {
  4328. let tempDir: string;
  4329. let cg: CodeGraph;
  4330. beforeEach(() => {
  4331. tempDir = createTempDir();
  4332. });
  4333. afterEach(() => {
  4334. if (cg) cg.close();
  4335. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4336. });
  4337. it('links a renamed default import to the module file', async () => {
  4338. // Express route aggregator: `import articlesController from './controller'`
  4339. // where the module does `export default router`. The renamed local can't be
  4340. // found as a symbol, so the controller file had no dependent — the dependency
  4341. // is on the module file regardless of the default export's name.
  4342. fs.mkdirSync(path.join(tempDir, 'app'), { recursive: true });
  4343. fs.writeFileSync(path.join(tempDir, 'app/controller.ts'), `const router = { get() {} };\nexport default router;\n`);
  4344. fs.writeFileSync(path.join(tempDir, 'app/routes.ts'), `import myController from './controller';\nexport const api = myController;\n`);
  4345. cg = CodeGraph.initSync(tempDir);
  4346. await cg.indexAll();
  4347. cg.resolveReferences();
  4348. const controller = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('app/controller.ts'));
  4349. expect(controller, 'controller.ts indexed').toBeDefined();
  4350. const deps = [...cg.getImpactRadius(controller!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4351. expect(deps.some((p) => p.endsWith('routes.ts')), 'importer depends on the default-exporting module').toBe(true);
  4352. });
  4353. });
  4354. describe('Chained method-call resolution (C# extension methods)', () => {
  4355. let tempDir: string;
  4356. let cg: CodeGraph;
  4357. beforeEach(() => {
  4358. tempDir = createTempDir();
  4359. });
  4360. afterEach(() => {
  4361. if (cg) cg.close();
  4362. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4363. });
  4364. it('resolves a chained extension-method call (a.b.Method()) to its definition', async () => {
  4365. // ASP.NET DI registration: `builder.Services.AddCoreServices(...)` calls a
  4366. // static extension method elsewhere. A multi-dot receiver chain matched no
  4367. // method-call pattern before, so the extension method had no caller.
  4368. fs.mkdirSync(path.join(tempDir, 'cfg'), { recursive: true });
  4369. fs.writeFileSync(
  4370. path.join(tempDir, 'cfg/Ext.cs'),
  4371. `namespace App {\n public static class Ext {\n public static object AddCoreServices(this object services, int x) { return services; }\n }\n}\n`
  4372. );
  4373. fs.writeFileSync(
  4374. path.join(tempDir, 'Program.cs'),
  4375. `namespace App {\n public class Program {\n public void Run(object builder) {\n builder.Services.AddCoreServices(1);\n }\n }\n}\n`
  4376. );
  4377. cg = CodeGraph.initSync(tempDir);
  4378. await cg.indexAll();
  4379. cg.resolveReferences();
  4380. const ext = cg
  4381. .getNodesByKind('method')
  4382. .find((n) => n.name === 'AddCoreServices')
  4383. ?? cg.getNodesByKind('function').find((n) => n.name === 'AddCoreServices');
  4384. expect(ext, 'AddCoreServices defined').toBeDefined();
  4385. const callers = [...cg.getImpactRadius(ext!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4386. expect(callers.some((p) => p.endsWith('Program.cs')), 'chained extension call resolves to its definition').toBe(true);
  4387. });
  4388. });
  4389. describe('Same-directory include + KMP import resolution', () => {
  4390. let tempDir: string;
  4391. let cg: CodeGraph;
  4392. beforeEach(() => {
  4393. tempDir = createTempDir();
  4394. });
  4395. afterEach(() => {
  4396. if (cg) cg.close();
  4397. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4398. });
  4399. it('a C/C++ #include resolves to the same-directory header, not a same-named one elsewhere', async () => {
  4400. // A multi-platform native module has a header of the same basename per
  4401. // platform. `windows/Provider.cpp`'s `#include "Storage.h"` means its OWN
  4402. // sibling header — not `apple/Storage.h` (which sorts first and so was
  4403. // picked arbitrarily before, leaving the real local header with 0 deps).
  4404. fs.mkdirSync(path.join(tempDir, 'apple'), { recursive: true });
  4405. fs.mkdirSync(path.join(tempDir, 'windows'), { recursive: true });
  4406. fs.writeFileSync(path.join(tempDir, 'apple', 'Storage.h'), `#pragma once\nstruct Storage { int n; };\n`);
  4407. fs.writeFileSync(path.join(tempDir, 'windows', 'Storage.h'), `#pragma once\nstruct Storage { int n; };\n`);
  4408. fs.writeFileSync(
  4409. path.join(tempDir, 'windows', 'Provider.cpp'),
  4410. `#include "Storage.h"\nint use() { Storage s; return s.n; }\n`
  4411. );
  4412. cg = CodeGraph.initSync(tempDir);
  4413. await cg.indexAll();
  4414. cg.resolveReferences();
  4415. const winHeader = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('windows/Storage.h'));
  4416. const appleHeader = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('apple/Storage.h'));
  4417. expect(winHeader, 'windows/Storage.h indexed').toBeDefined();
  4418. expect(appleHeader, 'apple/Storage.h indexed').toBeDefined();
  4419. const winDeps = [...cg.getImpactRadius(winHeader!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4420. const appleDeps = [...cg.getImpactRadius(appleHeader!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4421. expect(winDeps.some((p) => p.endsWith('Provider.cpp')), 'same-dir header gets the includer').toBe(true);
  4422. expect(appleDeps.some((p) => p.endsWith('Provider.cpp')), 'other-platform header does NOT').toBe(false);
  4423. });
  4424. it('a Kotlin Multiplatform commonMain import resolves to the expect, not a platform actual', async () => {
  4425. const common = path.join(tempDir, 'src/commonMain/kotlin/app');
  4426. const android = path.join(tempDir, 'src/androidMain/kotlin/app');
  4427. fs.mkdirSync(common, { recursive: true });
  4428. fs.mkdirSync(android, { recursive: true });
  4429. fs.writeFileSync(path.join(common, 'Platform.kt'), `package app\nexpect class PlatformContext\n`);
  4430. fs.writeFileSync(path.join(android, 'Platform.android.kt'), `package app\nactual class PlatformContext\n`);
  4431. fs.writeFileSync(
  4432. path.join(common, 'Db.kt'),
  4433. `package app\nimport app.PlatformContext\nclass Db {\n fun open(ctx: PlatformContext) {}\n}\n`
  4434. );
  4435. cg = CodeGraph.initSync(tempDir);
  4436. await cg.indexAll();
  4437. cg.resolveReferences();
  4438. const expectCtx = cg
  4439. .getNodesByKind('class')
  4440. .find((n) => n.name === 'PlatformContext' && n.filePath.endsWith('commonMain/kotlin/app/Platform.kt'));
  4441. expect(expectCtx, 'commonMain expect PlatformContext').toBeDefined();
  4442. const deps = [...cg.getImpactRadius(expectCtx!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4443. expect(deps.some((p) => p.endsWith('Db.kt')), 'commonMain import lands on the expect, not the actual').toBe(true);
  4444. });
  4445. });
  4446. describe('Delphi form code-behind pairing', () => {
  4447. let tempDir: string;
  4448. let cg: CodeGraph;
  4449. beforeEach(() => {
  4450. tempDir = createTempDir();
  4451. });
  4452. afterEach(() => {
  4453. if (cg) cg.close();
  4454. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4455. });
  4456. it('links a `.dfm` form to its sibling `.pas` code-behind unit', async () => {
  4457. // A Delphi form unit owns its visual form definition via `{$R *.dfm}`, not a
  4458. // `uses` clause — so a `.dfm` used only as a form definition looked orphaned.
  4459. fs.writeFileSync(path.join(tempDir, 'UFRMAbout.dfm'),
  4460. `object FRMAbout: TFRMAbout\n Caption = 'About'\nend\n`);
  4461. fs.writeFileSync(path.join(tempDir, 'UFRMAbout.pas'),
  4462. `unit UFRMAbout;\ninterface\nuses Forms;\ntype\n TFRMAbout = class(TForm)\n end;\nimplementation\n{$R *.dfm}\nend.\n`);
  4463. cg = CodeGraph.initSync(tempDir);
  4464. await cg.indexAll();
  4465. cg.resolveReferences();
  4466. const dfm = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('UFRMAbout.dfm'));
  4467. expect(dfm, 'UFRMAbout.dfm file node').toBeDefined();
  4468. const deps = cg.getFileDependents(dfm!.filePath);
  4469. expect(deps.some((p) => p.endsWith('UFRMAbout.pas')), 'the .pas unit links its .dfm form').toBe(true);
  4470. });
  4471. });
  4472. describe('Liquid Shopify JSON template section resolution', () => {
  4473. let tempDir: string;
  4474. let cg: CodeGraph;
  4475. beforeEach(() => {
  4476. tempDir = createTempDir();
  4477. });
  4478. afterEach(() => {
  4479. if (cg) cg.close();
  4480. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4481. });
  4482. it('links a Shopify JSON template section `type` to its sections/<type>.liquid', async () => {
  4483. // Shopify OS 2.0 templates are JSON, referencing sections by `type` — not
  4484. // a `{% section %}` Liquid tag — so a section used only from a JSON template
  4485. // looked unused. The JSON is now indexed and its `type`s linked.
  4486. fs.mkdirSync(path.join(tempDir, 'sections'), { recursive: true });
  4487. fs.mkdirSync(path.join(tempDir, 'templates/customers'), { recursive: true });
  4488. fs.writeFileSync(path.join(tempDir, 'sections/main-product.liquid'), `<div>{{ product.title }}</div>\n`);
  4489. fs.writeFileSync(path.join(tempDir, 'sections/main-login.liquid'), `<form>{{ 'customer.login' | t }}</form>\n`);
  4490. fs.writeFileSync(path.join(tempDir, 'templates/product.json'), JSON.stringify({ sections: { main: { type: 'main-product' } }, order: ['main'] }));
  4491. // Nested template dir (templates/customers/login.json) must resolve too.
  4492. fs.writeFileSync(path.join(tempDir, 'templates/customers/login.json'), JSON.stringify({ sections: { main: { type: 'main-login' } }, order: ['main'] }));
  4493. cg = CodeGraph.initSync(tempDir);
  4494. await cg.indexAll();
  4495. cg.resolveReferences();
  4496. const product = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('sections/main-product.liquid'));
  4497. const login = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('sections/main-login.liquid'));
  4498. expect(product, 'main-product section').toBeDefined();
  4499. expect(login, 'main-login section').toBeDefined();
  4500. expect(cg.getFileDependents(product!.filePath).some((p) => p.endsWith('templates/product.json')), 'top-level JSON template links its section').toBe(true);
  4501. expect(cg.getFileDependents(login!.filePath).some((p) => p.endsWith('customers/login.json')), 'nested JSON template links its section').toBe(true);
  4502. });
  4503. });
  4504. describe('Lua/Luau require resolution', () => {
  4505. let tempDir: string;
  4506. let cg: CodeGraph;
  4507. beforeEach(() => {
  4508. tempDir = createTempDir();
  4509. });
  4510. afterEach(() => {
  4511. if (cg) cg.close();
  4512. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4513. });
  4514. it('resolves a dotted Lua require and an instance-path Luau require to their module files', async () => {
  4515. // The require is the ONLY link (no method call), so coverage here proves the
  4516. // require resolver specifically, not method-call name-matching.
  4517. // Lua dotted module path: require("myapp.config") → lua/myapp/config.lua.
  4518. fs.mkdirSync(path.join(tempDir, 'lua/myapp'), { recursive: true });
  4519. fs.writeFileSync(path.join(tempDir, 'lua/myapp/config.lua'), `local M = {}\nfunction M.setup() end\nreturn M\n`);
  4520. fs.writeFileSync(path.join(tempDir, 'lua/myapp/init.lua'), `local config = require("myapp.config")\nreturn config\n`);
  4521. // Luau Roblox instance-path require (only the leaf survives extraction):
  4522. // require(script.Util.helper) → src/Util/helper.luau.
  4523. fs.mkdirSync(path.join(tempDir, 'src/Util'), { recursive: true });
  4524. fs.writeFileSync(path.join(tempDir, 'src/Util/helper.luau'), `local H = {}\nfunction H.go() end\nreturn H\n`);
  4525. fs.writeFileSync(path.join(tempDir, 'src/init.luau'), `local helper = require(script.Util.helper)\nreturn helper\n`);
  4526. cg = CodeGraph.initSync(tempDir);
  4527. await cg.indexAll();
  4528. cg.resolveReferences();
  4529. const config = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('myapp/config.lua'));
  4530. const helper = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('Util/helper.luau'));
  4531. expect(config, 'config.lua file node').toBeDefined();
  4532. expect(helper, 'helper.luau file node').toBeDefined();
  4533. const cfgDeps = cg.getFileDependents(config!.filePath);
  4534. const helpDeps = cg.getFileDependents(helper!.filePath);
  4535. expect(cfgDeps.some((p) => p.endsWith('myapp/init.lua')), 'dotted Lua require resolves to the module').toBe(true);
  4536. expect(helpDeps.some((p) => p.endsWith('src/init.luau')), 'instance-path Luau require resolves to the module').toBe(true);
  4537. });
  4538. });
  4539. describe('Rust module-path call resolution', () => {
  4540. let tempDir: string;
  4541. let cg: CodeGraph;
  4542. beforeEach(() => {
  4543. tempDir = createTempDir();
  4544. });
  4545. afterEach(() => {
  4546. if (cg) cg.close();
  4547. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4548. });
  4549. it('a bare submodule call (`users::router()`) resolves self-relative to the submodule fn', async () => {
  4550. // The canonical Axum router-assembly pattern: a parent module calls each
  4551. // submodule's `router()`. `users::` / `profiles::` are SELF-relative
  4552. // submodule prefixes (2018 edition) — `mod users;` makes `users` a child of
  4553. // the CURRENT module, NOT `crate::users`. Before the fix the bare prefix was
  4554. // resolved crate-relative only (looking for `src/users.rs`), so it found
  4555. // nothing and the handler modules looked dependent-less.
  4556. const http = path.join(tempDir, 'src/http');
  4557. fs.mkdirSync(http, { recursive: true });
  4558. fs.writeFileSync(path.join(tempDir, 'src/lib.rs'), `pub mod http;\n`);
  4559. fs.writeFileSync(
  4560. path.join(http, 'mod.rs'),
  4561. `mod users;\nmod profiles;\npub fn api_router() {\n users::router();\n profiles::router();\n}\n`
  4562. );
  4563. fs.writeFileSync(path.join(http, 'users.rs'), `pub fn router() -> i32 { 1 }\n`);
  4564. fs.writeFileSync(path.join(http, 'profiles.rs'), `pub fn router() -> i32 { 2 }\n`);
  4565. cg = CodeGraph.initSync(tempDir);
  4566. await cg.indexAll();
  4567. cg.resolveReferences();
  4568. // Each submodule's same-named `router` fn must get mod.rs as a dependent —
  4569. // proving the bare prefix resolved self-relative AND disambiguated the
  4570. // colliding `router` name to the correct file (not an arbitrary one).
  4571. const routers = cg.getNodesByKind('function').filter((n) => n.name === 'router');
  4572. const usersRouter = routers.find((n) => n.filePath.endsWith('http/users.rs'));
  4573. const profilesRouter = routers.find((n) => n.filePath.endsWith('http/profiles.rs'));
  4574. expect(usersRouter, 'users.rs router fn').toBeDefined();
  4575. expect(profilesRouter, 'profiles.rs router fn').toBeDefined();
  4576. const usersDeps = [...cg.getImpactRadius(usersRouter!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4577. const profilesDeps = [...cg.getImpactRadius(profilesRouter!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4578. expect(usersDeps.some((p) => p.endsWith('http/mod.rs')), 'users::router() lands on users.rs').toBe(true);
  4579. expect(profilesDeps.some((p) => p.endsWith('http/mod.rs')), 'profiles::router() lands on profiles.rs').toBe(true);
  4580. });
  4581. it('a 3-segment module-path call (`database::profiles::find()`) resolves to the leaf fn', async () => {
  4582. // A 2-level module path — the common `db.run(move |c| database::profiles::find(c))`
  4583. // / `crate::a::b::func()` shape. The reference-resolver pre-filter used to drop any
  4584. // `a::b::c` whose leaf it never checked (it tested only the first segment and the
  4585. // `b::c` remainder, neither of which names a symbol), so the call never reached the
  4586. // Rust path resolver and the leaf module looked dependent-less.
  4587. const routes = path.join(tempDir, 'src/routes');
  4588. const database = path.join(tempDir, 'src/database');
  4589. fs.mkdirSync(routes, { recursive: true });
  4590. fs.mkdirSync(database, { recursive: true });
  4591. fs.writeFileSync(path.join(tempDir, 'src/lib.rs'), `pub mod routes;\npub mod database;\n`);
  4592. fs.writeFileSync(path.join(database, 'mod.rs'), `pub mod profiles;\n`);
  4593. fs.writeFileSync(path.join(database, 'profiles.rs'), `pub fn find(id: i32) -> i32 { id }\n`);
  4594. fs.writeFileSync(
  4595. path.join(routes, 'mod.rs'),
  4596. `use crate::database;\npub fn get_profile(id: i32) -> i32 {\n database::profiles::find(id)\n}\n`
  4597. );
  4598. cg = CodeGraph.initSync(tempDir);
  4599. await cg.indexAll();
  4600. cg.resolveReferences();
  4601. const find = cg
  4602. .getNodesByKind('function')
  4603. .find((n) => n.name === 'find' && n.filePath.endsWith('database/profiles.rs'));
  4604. expect(find, 'database/profiles.rs find fn').toBeDefined();
  4605. const deps = [...cg.getImpactRadius(find!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4606. expect(deps.some((p) => p.endsWith('routes/mod.rs')), 'database::profiles::find() resolves to the leaf fn').toBe(true);
  4607. });
  4608. it('Rocket `routes![…]` / `catchers![…]` macros link the mount to the handler fns', async () => {
  4609. // Tree-sitter leaves the macro body as a raw token tree, so the handler
  4610. // paths inside `routes![a::b::handler, …]` are invisible to the call walker
  4611. // and the handlers — mounted by Rocket at runtime, not called in-repo — look
  4612. // like they have no caller. The route-macro extractor reconstructs each path
  4613. // and emits a reference, which the Rust path resolver links to the handler.
  4614. const routes = path.join(tempDir, 'src/routes');
  4615. fs.mkdirSync(routes, { recursive: true });
  4616. fs.writeFileSync(path.join(tempDir, 'src/lib.rs'),
  4617. `mod routes;\nfn not_found() {}\npub fn rocket() {\n` +
  4618. ` rocket::build()\n` +
  4619. ` .mount("/api", routes![routes::users::post_users, routes::users::get_user])\n` +
  4620. ` .register("/", catchers![not_found]);\n}\n`);
  4621. fs.writeFileSync(path.join(routes, 'mod.rs'), `pub mod users;\n`);
  4622. fs.writeFileSync(path.join(routes, 'users.rs'), `pub fn post_users() {}\npub fn get_user() {}\n`);
  4623. cg = CodeGraph.initSync(tempDir);
  4624. await cg.indexAll();
  4625. cg.resolveReferences();
  4626. const handlers = cg.getNodesByKind('function').filter((n) => n.filePath.endsWith('routes/users.rs'));
  4627. expect(handlers.length, 'both handler fns indexed').toBe(2);
  4628. for (const h of handlers) {
  4629. const deps = [...cg.getImpactRadius(h.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4630. expect(deps.some((p) => p.endsWith('lib.rs')), `routes![] links ${h.name} to its mount in lib.rs`).toBe(true);
  4631. }
  4632. });
  4633. });
  4634. describe('SvelteKit load → page synthesizer', () => {
  4635. let tempDir: string;
  4636. let cg: CodeGraph;
  4637. beforeEach(() => {
  4638. tempDir = createTempDir();
  4639. });
  4640. afterEach(() => {
  4641. if (cg) cg.close();
  4642. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4643. });
  4644. it('links a +page.svelte to its OWN directory\'s +page.server.js load, not another route\'s', async () => {
  4645. // SvelteKit wires +page.server.js's `load` to +page.svelte's `data` BY FILE
  4646. // PATH — there is no static import — so editing a loader showed no impact on
  4647. // the page it feeds. The synthesizer links each page component to the `load`
  4648. // in its OWN directory (path-deterministic, so it never crosses routes).
  4649. const login = path.join(tempDir, 'src/routes/login');
  4650. const register = path.join(tempDir, 'src/routes/register');
  4651. fs.mkdirSync(login, { recursive: true });
  4652. fs.mkdirSync(register, { recursive: true });
  4653. fs.writeFileSync(path.join(login, '+page.svelte'), `<script>export let data;</script>\n<h1>Login {data.x}</h1>\n`);
  4654. fs.writeFileSync(path.join(login, '+page.server.js'), `export function load() { return { x: 1 }; }\n`);
  4655. fs.writeFileSync(path.join(register, '+page.svelte'), `<script>export let data;</script>\n<h1>Register</h1>\n`);
  4656. fs.writeFileSync(path.join(register, '+page.server.js'), `export function load() { return { y: 2 }; }\n`);
  4657. cg = CodeGraph.initSync(tempDir);
  4658. await cg.indexAll();
  4659. cg.resolveReferences();
  4660. const loginLoad = cg
  4661. .getNodesByKind('function')
  4662. .find((n) => n.name === 'load' && n.filePath.endsWith('login/+page.server.js'));
  4663. expect(loginLoad, 'login load fn').toBeDefined();
  4664. const impacted = [...cg.getImpactRadius(loginLoad!.id, 3).nodes.values()].map((n) => n.filePath ?? '');
  4665. // editing login's load surfaces login's page (the framework-wired data flow)…
  4666. expect(impacted.some((p) => p.endsWith('login/+page.svelte')), 'load links to its own page').toBe(true);
  4667. // …but never register's page (same-directory only).
  4668. expect(impacted.some((p) => p.endsWith('register/+page.svelte')), 'does not cross routes').toBe(false);
  4669. });
  4670. });
  4671. describe('Nuxt nested auto-imported component resolution', () => {
  4672. let tempDir: string;
  4673. let cg: CodeGraph;
  4674. beforeEach(() => {
  4675. tempDir = createTempDir();
  4676. });
  4677. afterEach(() => {
  4678. if (cg) cg.close();
  4679. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4680. });
  4681. it('links a `<MediaCard/>` usage to components/media/Card.vue (Nuxt dir-prefixed auto-import)', async () => {
  4682. // Nuxt auto-imports a nested component by a DIRECTORY-PREFIXED name —
  4683. // components/media/Card.vue is used as <MediaCard/>, not <Card/> — but the
  4684. // component node is named by basename (`Card`), so the PascalCase usage
  4685. // didn't resolve and the nested component looked unused.
  4686. const media = path.join(tempDir, 'components/media');
  4687. fs.mkdirSync(media, { recursive: true });
  4688. fs.writeFileSync(path.join(media, 'Card.vue'), `<template><div>card</div></template>\n<script setup>defineProps(['item'])</script>\n`);
  4689. fs.writeFileSync(
  4690. path.join(tempDir, 'components/Grid.vue'),
  4691. `<template>\n <div><MediaCard :item="i" /></div>\n</template>\n<script setup>const i = {}</script>\n`
  4692. );
  4693. cg = CodeGraph.initSync(tempDir);
  4694. await cg.indexAll();
  4695. cg.resolveReferences();
  4696. const card = cg.getNodesByKind('component').find((n) => n.filePath.endsWith('media/Card.vue'));
  4697. expect(card, 'media/Card.vue component').toBeDefined();
  4698. const deps = [...cg.getImpactRadius(card!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4699. expect(deps.some((p) => p.endsWith('components/Grid.vue')), '<MediaCard> links Grid to media/Card.vue').toBe(true);
  4700. });
  4701. });
  4702. describe('Swift property-wrapper attribute type references', () => {
  4703. let tempDir: string;
  4704. let cg: CodeGraph;
  4705. beforeEach(() => {
  4706. tempDir = createTempDir();
  4707. });
  4708. afterEach(() => {
  4709. if (cg) cg.close();
  4710. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4711. });
  4712. it('a Fluent `@Siblings(through: Pivot.self)` links the model to the pivot type', async () => {
  4713. // A many-to-many pivot/join model is referenced ONLY through the relationship
  4714. // property wrapper's metatype argument (`Pivot.self`), never by a controller
  4715. // query. The wrapper type was captured but the argument expression wasn't
  4716. // walked, so the pivot model looked like nothing depended on it.
  4717. fs.writeFileSync(path.join(tempDir, 'Pivot.swift'),
  4718. `import Fluent\nfinal class AcronymCategoryPivot: Model {\n static let schema = "acronym-category"\n}\n`);
  4719. fs.writeFileSync(path.join(tempDir, 'Acronym.swift'),
  4720. `import Fluent\nfinal class Acronym: Model {\n` +
  4721. ` @Siblings(through: AcronymCategoryPivot.self, from: \\.$acronym, to: \\.$category)\n` +
  4722. ` var categories: [Category]\n}\n`);
  4723. cg = CodeGraph.initSync(tempDir);
  4724. await cg.indexAll();
  4725. cg.resolveReferences();
  4726. const pivot = cg.getNodesByKind('class').find((n) => n.name === 'AcronymCategoryPivot');
  4727. expect(pivot, 'pivot model class').toBeDefined();
  4728. const deps = [...cg.getImpactRadius(pivot!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4729. expect(deps.some((p) => p.endsWith('Acronym.swift')), '@Siblings metatype arg links Acronym to the pivot').toBe(true);
  4730. });
  4731. });
  4732. describe('Objective-C messages, class receivers, and #import', () => {
  4733. let tempDir: string;
  4734. let cg: CodeGraph;
  4735. beforeEach(() => {
  4736. tempDir = createTempDir();
  4737. });
  4738. afterEach(() => {
  4739. if (cg) cg.close();
  4740. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4741. });
  4742. it('resolves single-arg selectors, class-message receivers, and #import headers', async () => {
  4743. fs.writeFileSync(
  4744. path.join(tempDir, 'SDImageCache.h'),
  4745. `#import <Foundation/Foundation.h>
  4746. @interface SDImageCache : NSObject
  4747. + (instancetype)sharedCache;
  4748. + (void)storeImage:(NSString *)key;
  4749. @end
  4750. `
  4751. );
  4752. fs.writeFileSync(
  4753. path.join(tempDir, 'SDImageCache.m'),
  4754. `#import "SDImageCache.h"
  4755. @implementation SDImageCache
  4756. + (instancetype)sharedCache { return nil; }
  4757. + (void)storeImage:(NSString *)key { }
  4758. @end
  4759. `
  4760. );
  4761. fs.writeFileSync(
  4762. path.join(tempDir, 'SDManager.m'),
  4763. `#import "SDImageCache.h"
  4764. @interface SDManager : NSObject
  4765. @end
  4766. @implementation SDManager
  4767. - (void)run {
  4768. [SDImageCache sharedCache];
  4769. [SDImageCache storeImage:@"k"];
  4770. }
  4771. @end
  4772. `
  4773. );
  4774. cg = CodeGraph.initSync(tempDir);
  4775. await cg.indexAll();
  4776. cg.resolveReferences();
  4777. // 1. The single-argument selector `[SDImageCache storeImage:@"k"]` resolves
  4778. // to the `storeImage:` method — named WITH its colon both at the call site
  4779. // and the definition (before the fix the call site dropped the colon).
  4780. const storeImage = cg.getNodesByKind('method').find((n) => n.name === 'storeImage:');
  4781. expect(storeImage, 'storeImage: method').toBeDefined();
  4782. const storeCallers = [...cg.getImpactRadius(storeImage!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4783. expect(storeCallers.some((p) => p.endsWith('SDManager.m'))).toBe(true);
  4784. // 2. The class-message receiver `[SDImageCache sharedCache]` references the
  4785. // SDImageCache class (whose @interface lives in the header).
  4786. const cache = cg.getNodesByKind('class').find((n) => n.name === 'SDImageCache');
  4787. expect(cache, 'SDImageCache class').toBeDefined();
  4788. const classDeps = [...cg.getImpactRadius(cache!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4789. expect(classDeps.some((p) => p.endsWith('SDManager.m'))).toBe(true);
  4790. // 3. `#import "SDImageCache.h"` resolves to the header FILE — editing it
  4791. // surfaces both importers.
  4792. const header = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('SDImageCache.h'));
  4793. expect(header, 'SDImageCache.h indexed').toBeDefined();
  4794. const importers = [...cg.getImpactRadius(header!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4795. expect(importers.some((p) => p.endsWith('SDManager.m'))).toBe(true);
  4796. });
  4797. });
  4798. describe('Full Indexing', () => {
  4799. let tempDir: string;
  4800. beforeEach(() => {
  4801. tempDir = createTempDir();
  4802. });
  4803. afterEach(() => {
  4804. cleanupTempDir(tempDir);
  4805. });
  4806. it('should index a TypeScript file', async () => {
  4807. // Create test file
  4808. const srcDir = path.join(tempDir, 'src');
  4809. fs.mkdirSync(srcDir);
  4810. fs.writeFileSync(
  4811. path.join(srcDir, 'utils.ts'),
  4812. `
  4813. export function add(a: number, b: number): number {
  4814. return a + b;
  4815. }
  4816. export function multiply(a: number, b: number): number {
  4817. return a * b;
  4818. }
  4819. `
  4820. );
  4821. // Initialize and index
  4822. const cg = CodeGraph.initSync(tempDir);
  4823. const result = await cg.indexAll();
  4824. expect(result.success).toBe(true);
  4825. expect(result.filesIndexed).toBe(1);
  4826. expect(result.nodesCreated).toBeGreaterThanOrEqual(2);
  4827. // Check nodes were stored
  4828. const nodes = cg.getNodesInFile('src/utils.ts');
  4829. expect(nodes.length).toBeGreaterThanOrEqual(2);
  4830. const addFunc = nodes.find((n) => n.name === 'add');
  4831. expect(addFunc).toBeDefined();
  4832. expect(addFunc?.kind).toBe('function');
  4833. cg.close();
  4834. });
  4835. it('should index multiple files', async () => {
  4836. // Create test files
  4837. const srcDir = path.join(tempDir, 'src');
  4838. fs.mkdirSync(srcDir);
  4839. fs.writeFileSync(
  4840. path.join(srcDir, 'math.ts'),
  4841. `export function add(a: number, b: number) { return a + b; }`
  4842. );
  4843. fs.writeFileSync(
  4844. path.join(srcDir, 'string.ts'),
  4845. `export function capitalize(s: string) { return s.toUpperCase(); }`
  4846. );
  4847. // Initialize and index
  4848. const cg = CodeGraph.initSync(tempDir);
  4849. const result = await cg.indexAll();
  4850. expect(result.success).toBe(true);
  4851. expect(result.filesIndexed).toBe(2);
  4852. const files = cg.getFiles();
  4853. expect(files.length).toBe(2);
  4854. cg.close();
  4855. });
  4856. it('should track file hashes for incremental updates', async () => {
  4857. // Create initial file
  4858. const srcDir = path.join(tempDir, 'src');
  4859. fs.mkdirSync(srcDir);
  4860. fs.writeFileSync(path.join(srcDir, 'main.ts'), `export const x = 1;`);
  4861. // Initialize and index
  4862. const cg = CodeGraph.initSync(tempDir);
  4863. await cg.indexAll();
  4864. // Check file is tracked
  4865. const file = cg.getFile('src/main.ts');
  4866. expect(file).toBeDefined();
  4867. expect(file?.contentHash).toBeDefined();
  4868. // Modify file
  4869. fs.writeFileSync(path.join(srcDir, 'main.ts'), `export const x = 2;`);
  4870. // Check for changes
  4871. const changes = cg.getChangedFiles();
  4872. expect(changes.modified).toContain('src/main.ts');
  4873. cg.close();
  4874. });
  4875. it('should sync and detect changes', async () => {
  4876. // Create initial file
  4877. const srcDir = path.join(tempDir, 'src');
  4878. fs.mkdirSync(srcDir);
  4879. fs.writeFileSync(
  4880. path.join(srcDir, 'main.ts'),
  4881. `export function original() { return 1; }`
  4882. );
  4883. // Initialize and index
  4884. const cg = CodeGraph.initSync(tempDir);
  4885. await cg.indexAll();
  4886. const initialNodes = cg.getNodesInFile('src/main.ts');
  4887. expect(initialNodes.some((n) => n.name === 'original')).toBe(true);
  4888. // Modify file
  4889. fs.writeFileSync(
  4890. path.join(srcDir, 'main.ts'),
  4891. `export function updated() { return 2; }`
  4892. );
  4893. // Sync
  4894. const syncResult = await cg.sync();
  4895. expect(syncResult.filesModified).toBe(1);
  4896. // Check nodes were updated
  4897. const updatedNodes = cg.getNodesInFile('src/main.ts');
  4898. expect(updatedNodes.some((n) => n.name === 'updated')).toBe(true);
  4899. expect(updatedNodes.some((n) => n.name === 'original')).toBe(false);
  4900. cg.close();
  4901. });
  4902. it('should count file-level tracked YAML files as indexed', async () => {
  4903. fs.writeFileSync(path.join(tempDir, 'app.yaml'), 'name: test\n');
  4904. fs.writeFileSync(path.join(tempDir, 'routes.yml'), 'route: value\n');
  4905. const cg = CodeGraph.initSync(tempDir);
  4906. const result = await cg.indexAll();
  4907. expect(result.success).toBe(true);
  4908. expect(result.filesIndexed).toBe(2);
  4909. expect(result.filesSkipped).toBe(0);
  4910. expect(cg.getFiles().map((f) => f.path).sort()).toEqual(['app.yaml', 'routes.yml']);
  4911. cg.close();
  4912. });
  4913. it('should count file-level tracked YAML/Twig files as indexed in indexFiles()', async () => {
  4914. fs.writeFileSync(path.join(tempDir, 'app.yaml'), 'name: test\n');
  4915. fs.writeFileSync(path.join(tempDir, 'view.twig'), '{{ title }}\n');
  4916. const cg = CodeGraph.initSync(tempDir);
  4917. const result = await cg.indexFiles(['app.yaml', 'view.twig']);
  4918. expect(result.success).toBe(true);
  4919. expect(result.filesIndexed).toBe(2);
  4920. expect(result.filesSkipped).toBe(0);
  4921. const tracked = cg.getFiles().map((f) => `${f.path}:${f.language}`).sort();
  4922. expect(tracked).toEqual(['app.yaml:yaml', 'view.twig:twig']);
  4923. cg.close();
  4924. });
  4925. it('should count file-level tracked .properties files as indexed', async () => {
  4926. fs.writeFileSync(path.join(tempDir, 'application.properties'), 'server.port=8080\n');
  4927. fs.writeFileSync(path.join(tempDir, 'log.properties'), 'log.level=INFO\n');
  4928. const cg = CodeGraph.initSync(tempDir);
  4929. const result = await cg.indexAll();
  4930. expect(result.success).toBe(true);
  4931. expect(result.filesIndexed).toBe(2);
  4932. expect(result.filesSkipped).toBe(0);
  4933. cg.close();
  4934. });
  4935. it('should count the full file-level tracked class (yaml/twig/properties) in indexFiles()', async () => {
  4936. fs.writeFileSync(path.join(tempDir, 'app.yaml'), 'name: test\n');
  4937. fs.writeFileSync(path.join(tempDir, 'view.twig'), '{{ title }}\n');
  4938. fs.writeFileSync(path.join(tempDir, 'application.properties'), 'server.port=8080\n');
  4939. const cg = CodeGraph.initSync(tempDir);
  4940. const result = await cg.indexFiles(['app.yaml', 'view.twig', 'application.properties']);
  4941. expect(result.success).toBe(true);
  4942. expect(result.filesIndexed).toBe(3);
  4943. expect(result.filesSkipped).toBe(0);
  4944. const tracked = cg.getFiles().map((f) => `${f.path}:${f.language}`).sort();
  4945. expect(tracked).toEqual(['app.yaml:yaml', 'application.properties:properties', 'view.twig:twig']);
  4946. cg.close();
  4947. });
  4948. });
  4949. describe('Path Normalization', () => {
  4950. it('should convert backslashes to forward slashes', () => {
  4951. expect(normalizePath('gui\\node_modules\\foo')).toBe('gui/node_modules/foo');
  4952. expect(normalizePath('src\\components\\Button.tsx')).toBe('src/components/Button.tsx');
  4953. });
  4954. it('should leave forward-slash paths unchanged', () => {
  4955. expect(normalizePath('src/components/Button.tsx')).toBe('src/components/Button.tsx');
  4956. });
  4957. it('should handle empty string', () => {
  4958. expect(normalizePath('')).toBe('');
  4959. });
  4960. });
  4961. describe('Directory Exclusion', () => {
  4962. let tempDir: string;
  4963. beforeEach(() => {
  4964. tempDir = createTempDir();
  4965. });
  4966. afterEach(() => {
  4967. cleanupTempDir(tempDir);
  4968. });
  4969. it('should exclude directories listed in .gitignore', () => {
  4970. // Create structure: src/index.ts + node_modules/pkg/index.js, gitignore node_modules
  4971. const srcDir = path.join(tempDir, 'src');
  4972. const nmDir = path.join(tempDir, 'node_modules', 'pkg');
  4973. fs.mkdirSync(srcDir, { recursive: true });
  4974. fs.mkdirSync(nmDir, { recursive: true });
  4975. fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;');
  4976. fs.writeFileSync(path.join(nmDir, 'index.js'), 'module.exports = {};');
  4977. fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules/\n');
  4978. const files = scanDirectory(tempDir);
  4979. expect(files).toContain('src/index.ts');
  4980. expect(files.every((f) => !f.includes('node_modules'))).toBe(true);
  4981. });
  4982. it('should exclude nested node_modules via a root .gitignore', () => {
  4983. // A trailing-slash pattern with no leading slash matches at any depth.
  4984. const srcDir = path.join(tempDir, 'packages', 'app', 'src');
  4985. const nmDir = path.join(tempDir, 'packages', 'app', 'node_modules', 'pkg');
  4986. fs.mkdirSync(srcDir, { recursive: true });
  4987. fs.mkdirSync(nmDir, { recursive: true });
  4988. fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;');
  4989. fs.writeFileSync(path.join(nmDir, 'index.js'), 'module.exports = {};');
  4990. fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules/\n');
  4991. const files = scanDirectory(tempDir);
  4992. expect(files).toContain('packages/app/src/index.ts');
  4993. expect(files.every((f) => !f.includes('node_modules'))).toBe(true);
  4994. });
  4995. it('should apply a nested .gitignore only to its own subtree', () => {
  4996. const appSrc = path.join(tempDir, 'app', 'src');
  4997. fs.mkdirSync(appSrc, { recursive: true });
  4998. fs.writeFileSync(path.join(appSrc, 'keep.ts'), 'export const a = 1;');
  4999. fs.writeFileSync(path.join(appSrc, 'skip.ts'), 'export const b = 2;');
  5000. fs.writeFileSync(path.join(tempDir, 'app', '.gitignore'), 'src/skip.ts\n');
  5001. // A sibling with the same name outside app/ must NOT be ignored.
  5002. const otherDir = path.join(tempDir, 'other', 'src');
  5003. fs.mkdirSync(otherDir, { recursive: true });
  5004. fs.writeFileSync(path.join(otherDir, 'skip.ts'), 'export const c = 3;');
  5005. const files = scanDirectory(tempDir);
  5006. expect(files).toContain('app/src/keep.ts');
  5007. expect(files).not.toContain('app/src/skip.ts');
  5008. expect(files).toContain('other/src/skip.ts');
  5009. });
  5010. it('should always skip .git directories', () => {
  5011. const srcDir = path.join(tempDir, 'src');
  5012. const gitDir = path.join(tempDir, '.git', 'objects');
  5013. fs.mkdirSync(srcDir, { recursive: true });
  5014. fs.mkdirSync(gitDir, { recursive: true });
  5015. fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;');
  5016. fs.writeFileSync(path.join(gitDir, 'pack.ts'), 'export const y = 2;');
  5017. const files = scanDirectory(tempDir);
  5018. expect(files).toContain('src/index.ts');
  5019. expect(files.every((f) => !f.includes('.git'))).toBe(true);
  5020. });
  5021. it('should return forward-slash paths on all platforms', () => {
  5022. const srcDir = path.join(tempDir, 'src', 'components');
  5023. fs.mkdirSync(srcDir, { recursive: true });
  5024. fs.writeFileSync(path.join(srcDir, 'Button.tsx'), 'export function Button() {}');
  5025. const files = scanDirectory(tempDir);
  5026. expect(files.length).toBe(1);
  5027. expect(files[0]).toBe('src/components/Button.tsx');
  5028. expect(files[0]).not.toContain('\\');
  5029. });
  5030. });
  5031. describe('Git Submodules', () => {
  5032. let tempDir: string;
  5033. beforeEach(() => {
  5034. tempDir = createTempDir();
  5035. });
  5036. afterEach(() => {
  5037. cleanupTempDir(tempDir);
  5038. });
  5039. it('should index files inside git submodules (issue #147)', async () => {
  5040. const { execFileSync } = await import('child_process');
  5041. const git = (cwd: string, ...args: string[]) =>
  5042. execFileSync('git', args, { cwd, stdio: 'pipe' });
  5043. // Build a separate "library" repo to use as a submodule source.
  5044. const libDir = path.join(tempDir, '_lib');
  5045. fs.mkdirSync(libDir, { recursive: true });
  5046. git(libDir, 'init', '-q');
  5047. git(libDir, 'config', 'user.email', 'test@test.com');
  5048. git(libDir, 'config', 'user.name', 'Test');
  5049. fs.writeFileSync(path.join(libDir, 'lib.ts'), 'export const fromSubmodule = 1;');
  5050. git(libDir, 'add', '-A');
  5051. git(libDir, 'commit', '-q', '-m', 'lib init');
  5052. // Build the main repo and add the lib repo as a submodule.
  5053. const mainDir = path.join(tempDir, 'main');
  5054. fs.mkdirSync(mainDir, { recursive: true });
  5055. git(mainDir, 'init', '-q');
  5056. git(mainDir, 'config', 'user.email', 'test@test.com');
  5057. git(mainDir, 'config', 'user.name', 'Test');
  5058. fs.writeFileSync(path.join(mainDir, 'app.ts'), 'export const app = 1;');
  5059. git(mainDir, 'add', '-A');
  5060. git(mainDir, 'commit', '-q', '-m', 'app init');
  5061. // protocol.file.allow=always is required to add a local-path submodule on
  5062. // recent git versions (CVE-2022-39253 mitigation).
  5063. execFileSync(
  5064. 'git',
  5065. ['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', libDir, 'libs/lib'],
  5066. { cwd: mainDir, stdio: 'pipe' }
  5067. );
  5068. git(mainDir, 'commit', '-q', '-m', 'add submodule');
  5069. const files = scanDirectory(mainDir);
  5070. expect(files).toContain('app.ts');
  5071. expect(files).toContain('libs/lib/lib.ts');
  5072. });
  5073. });
  5074. describe('Nested gitlink repos (#1031, #1033)', () => {
  5075. let tempDir: string;
  5076. // Helper: make a self-contained git repo at `dir` with one committed TS file.
  5077. const makeRepo = async (dir: string, base: string) => {
  5078. const { execFileSync } = await import('child_process');
  5079. const git = (...args: string[]) => execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
  5080. fs.mkdirSync(dir, { recursive: true });
  5081. git('init', '-q');
  5082. git('config', 'user.email', 'test@test.com');
  5083. git('config', 'user.name', 'Test');
  5084. fs.writeFileSync(path.join(dir, `${base}.ts`), `export const ${base} = 1;`);
  5085. git('add', '-A');
  5086. git('commit', '-q', '-m', `${base} init`);
  5087. };
  5088. beforeEach(() => {
  5089. tempDir = createTempDir();
  5090. });
  5091. afterEach(() => {
  5092. cleanupTempDir(tempDir);
  5093. });
  5094. // The #1031 case: a nested repo `git add`ed inside the super-repo becomes a
  5095. // gitlink (mode 160000) with NO `.gitmodules`. It is tracked (so it never shows
  5096. // in the untracked `-o` listing) yet not an active submodule (so
  5097. // `--recurse-submodules` won't expand it) — it used to fall through both passes
  5098. // and only the super-repo's own files got indexed.
  5099. it('indexes a bare gitlink (git add\'ed embedded repo, no .gitmodules), recursively', async () => {
  5100. const { execFileSync } = await import('child_process');
  5101. const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' });
  5102. const root = path.join(tempDir, 'root');
  5103. await makeRepo(root, 'app');
  5104. // An embedded clone, itself holding a further nested clone (untracked inside it).
  5105. await makeRepo(path.join(root, 'embedded'), 'inner');
  5106. await makeRepo(path.join(root, 'embedded', 'deep'), 'deep');
  5107. // `git add embedded` records it as a 160000 gitlink (no fetch, no .gitmodules).
  5108. git(root, 'add', 'embedded');
  5109. git(root, 'commit', '-q', '-m', 'add embedded as gitlink');
  5110. expect(fs.existsSync(path.join(root, '.gitmodules'))).toBe(false);
  5111. const files = scanDirectory(root);
  5112. expect(files).toContain('app.ts');
  5113. expect(files).toContain('embedded/inner.ts'); // the gitlink's own source
  5114. expect(files).toContain('embedded/deep/deep.ts'); // recursion continues into its nested repo
  5115. });
  5116. // The -c → -s switch must not regress active submodules (#147): a repo can hold
  5117. // BOTH an active submodule (expanded by --recurse-submodules) and a bare gitlink
  5118. // (handled by the new pass), and the mixed 160000/100644 modes must parse right.
  5119. it('indexes a gitlink alongside an active submodule', async () => {
  5120. const { execFileSync } = await import('child_process');
  5121. const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' });
  5122. const lib = path.join(tempDir, '_lib');
  5123. await makeRepo(lib, 'lib');
  5124. const root = path.join(tempDir, 'root');
  5125. await makeRepo(root, 'app');
  5126. // A proper, active submodule.
  5127. execFileSync('git', ['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', lib, 'libs/lib'], { cwd: root, stdio: 'pipe' });
  5128. git(root, 'commit', '-q', '-m', 'add submodule');
  5129. // A bare gitlink in the same repo (under a non-ignored dir name).
  5130. await makeRepo(path.join(root, 'external', 'tool'), 'tool');
  5131. git(root, 'add', 'external/tool');
  5132. git(root, 'commit', '-q', '-m', 'add gitlink');
  5133. const files = scanDirectory(root);
  5134. expect(files).toContain('app.ts');
  5135. expect(files).toContain('libs/lib/lib.ts'); // active submodule still expands (#147)
  5136. expect(files).toContain('external/tool/tool.ts'); // bare gitlink now indexed
  5137. });
  5138. // A gitlink under a built-in default-ignored directory (vendor/, node_modules/,
  5139. // …) stays excluded — a committed dependency doesn't become project code just
  5140. // because it's a nested repo. Mirrors how the untracked-embedded path treats
  5141. // the same dirs (#407), so the two passes agree.
  5142. it('does not index a gitlink under a default-ignored directory (e.g. vendor/)', async () => {
  5143. const { execFileSync } = await import('child_process');
  5144. const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' });
  5145. const root = path.join(tempDir, 'root');
  5146. await makeRepo(root, 'app');
  5147. await makeRepo(path.join(root, 'vendor', 'pkg'), 'dep');
  5148. git(root, 'add', 'vendor/pkg');
  5149. git(root, 'commit', '-q', '-m', 'add vendored gitlink');
  5150. const files = scanDirectory(root);
  5151. expect(files).toContain('app.ts');
  5152. expect(files).not.toContain('vendor/pkg/dep.ts');
  5153. });
  5154. // A gitlink with NO working tree on disk (the common "cloned without
  5155. // --recurse-submodules" state) has nothing to index — we must leave it alone,
  5156. // not fabricate entries, and must not break the rest of the scan.
  5157. it('leaves an uninitialized submodule (no checkout on disk) alone', async () => {
  5158. const { execFileSync } = await import('child_process');
  5159. const lib = path.join(tempDir, '_lib');
  5160. await makeRepo(lib, 'lib');
  5161. const sup = path.join(tempDir, 'super');
  5162. await makeRepo(sup, 'app');
  5163. execFileSync('git', ['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', lib, 'libs/lib'], { cwd: sup, stdio: 'pipe' });
  5164. execFileSync('git', ['commit', '-q', '-m', 'add submodule'], { cwd: sup, stdio: 'pipe' });
  5165. // Clone the super-repo WITHOUT --recurse-submodules → libs/lib is an empty
  5166. // gitlink dir (mode 160000, no `.git` inside, no files).
  5167. const clone = path.join(tempDir, 'clone');
  5168. execFileSync('git', ['clone', '-q', sup, clone], { stdio: 'pipe' });
  5169. expect(fs.readdirSync(path.join(clone, 'libs', 'lib'))).toHaveLength(0);
  5170. const files = scanDirectory(clone);
  5171. expect(files).toContain('app.ts');
  5172. expect(files).not.toContain('libs/lib/lib.ts'); // not on disk → correctly absent
  5173. });
  5174. // #1065: a gitlink under a path the super-repo's OWN `.gitignore` covers is the
  5175. // tracked-gitlink twin of the untracked-ignored embedded repo (#514, #970). The
  5176. // gitlink-discovery pass must honor that `.gitignore` the same way — otherwise a
  5177. // gitignored reference/benchmark corpus full of `git add`ed clones gets pulled
  5178. // into the index (the 138k-file blow-up the reporter hit). Respect it by default;
  5179. // re-include only via `codegraph.json` `includeIgnored`.
  5180. it('does not index a gitlink under a gitignored directory by default (#1065)', async () => {
  5181. const { execFileSync } = await import('child_process');
  5182. const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' });
  5183. const root = path.join(tempDir, 'root');
  5184. await makeRepo(root, 'app');
  5185. // An embedded clone under a path the super-repo gitignores (a benchmark corpus).
  5186. await makeRepo(path.join(root, 'benchmark', 'repos', 'ref'), 'ref');
  5187. git(root, 'add', 'benchmark/repos/ref'); // tracked as a 160000 gitlink
  5188. fs.writeFileSync(path.join(root, '.gitignore'), 'benchmark/repos/\n');
  5189. git(root, 'add', '.gitignore');
  5190. git(root, 'commit', '-q', '-m', 'add gitignored gitlink + ignore rule');
  5191. const files = scanDirectory(root);
  5192. expect(files).toContain('app.ts');
  5193. expect(files).not.toContain('benchmark/repos/ref/ref.ts'); // gitignored → excluded
  5194. // The watcher path agrees: the ignored root is never discovered, and the dir is
  5195. // pruned (the reporter's exact clue — `ignores('benchmark/repos/')` was false).
  5196. expect(discoverEmbeddedRepoRoots(root)).toEqual([]);
  5197. expect(buildScopeIgnore(root).ignores('benchmark/repos/')).toBe(true);
  5198. expect(buildScopeIgnore(root).ignores('benchmark/repos/ref/ref.ts')).toBe(true);
  5199. });
  5200. it('re-includes a gitignored gitlink when codegraph.json includeIgnored opts in (#1065)', async () => {
  5201. const { execFileSync } = await import('child_process');
  5202. const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' });
  5203. const root = path.join(tempDir, 'root');
  5204. await makeRepo(root, 'app');
  5205. await makeRepo(path.join(root, 'benchmark', 'repos', 'ref'), 'ref');
  5206. git(root, 'add', 'benchmark/repos/ref');
  5207. fs.writeFileSync(path.join(root, '.gitignore'), 'benchmark/repos/\n');
  5208. fs.writeFileSync(path.join(root, 'codegraph.json'), JSON.stringify({ includeIgnored: ['benchmark/repos/'] }));
  5209. git(root, 'add', '.gitignore', 'codegraph.json');
  5210. git(root, 'commit', '-q', '-m', 'opt the gitignored gitlink back in');
  5211. const files = scanDirectory(root);
  5212. expect(files).toContain('app.ts');
  5213. expect(files).toContain('benchmark/repos/ref/ref.ts'); // opted in → indexed
  5214. expect(discoverEmbeddedRepoRoots(root)).toContain('benchmark/repos/ref/');
  5215. });
  5216. });
  5217. describe('Nested non-submodule git repos', () => {
  5218. let tempDir: string;
  5219. beforeEach(() => {
  5220. tempDir = createTempDir();
  5221. });
  5222. afterEach(() => {
  5223. cleanupTempDir(tempDir);
  5224. });
  5225. it('should index files in embedded git repos run from a git super-repo (issue #193)', async () => {
  5226. const { execFileSync } = await import('child_process');
  5227. const git = (cwd: string, ...args: string[]) =>
  5228. execFileSync('git', args, { cwd, stdio: 'pipe' });
  5229. // Top-level workspace is itself a git repo, holding no source directly —
  5230. // the CMake "super-repo" layout from the issue.
  5231. const root = path.join(tempDir, 'root');
  5232. fs.mkdirSync(path.join(root, 'coding'), { recursive: true });
  5233. git(root, 'init', '-q');
  5234. git(root, 'config', 'user.email', 'test@test.com');
  5235. git(root, 'config', 'user.name', 'Test');
  5236. fs.writeFileSync(path.join(root, 'CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.10)\n');
  5237. // Two independent clones living inside the workspace (NOT submodules):
  5238. // one with committed source, one with only untracked source.
  5239. const sub1 = path.join(root, 'sub_repo1', 'src');
  5240. fs.mkdirSync(sub1, { recursive: true });
  5241. git(path.join(root, 'sub_repo1'), 'init', '-q');
  5242. git(path.join(root, 'sub_repo1'), 'config', 'user.email', 'test@test.com');
  5243. git(path.join(root, 'sub_repo1'), 'config', 'user.name', 'Test');
  5244. fs.writeFileSync(path.join(sub1, 'one.ts'), 'export const one = 1;');
  5245. git(path.join(root, 'sub_repo1'), 'add', '-A');
  5246. git(path.join(root, 'sub_repo1'), 'commit', '-q', '-m', 'sub1 init');
  5247. const sub2 = path.join(root, 'sub_repo2', 'src');
  5248. fs.mkdirSync(sub2, { recursive: true });
  5249. git(path.join(root, 'sub_repo2'), 'init', '-q');
  5250. fs.writeFileSync(path.join(sub2, 'two.ts'), 'export const two = 2;');
  5251. const files = scanDirectory(root);
  5252. // Both committed and untracked source from the nested repos must be found.
  5253. expect(files).toContain('sub_repo1/src/one.ts');
  5254. expect(files).toContain('sub_repo2/src/two.ts');
  5255. });
  5256. it('should respect each embedded repo\'s own .gitignore', async () => {
  5257. const { execFileSync } = await import('child_process');
  5258. const git = (cwd: string, ...args: string[]) =>
  5259. execFileSync('git', args, { cwd, stdio: 'pipe' });
  5260. const root = path.join(tempDir, 'root');
  5261. fs.mkdirSync(root, { recursive: true });
  5262. git(root, 'init', '-q');
  5263. const sub = path.join(root, 'sub_repo', 'src');
  5264. fs.mkdirSync(sub, { recursive: true });
  5265. git(path.join(root, 'sub_repo'), 'init', '-q');
  5266. fs.writeFileSync(path.join(root, 'sub_repo', '.gitignore'), 'src/generated.ts\n');
  5267. fs.writeFileSync(path.join(sub, 'real.ts'), 'export const real = 1;');
  5268. fs.writeFileSync(path.join(sub, 'generated.ts'), 'export const generated = 1;');
  5269. const files = scanDirectory(root);
  5270. expect(files).toContain('sub_repo/src/real.ts');
  5271. expect(files).not.toContain('sub_repo/src/generated.ts');
  5272. });
  5273. // A .gitignore the `ignore` library can't compile to a regex must not abort
  5274. // the whole scan — the bad pattern is dropped, valid ones still apply (#682).
  5275. it('does not crash on a .gitignore with an uncompilable pattern (#682)', () => {
  5276. fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
  5277. fs.mkdirSync(path.join(tempDir, 'build'), { recursive: true });
  5278. fs.writeFileSync(path.join(tempDir, 'src', 'real.ts'), 'export const x = 1;');
  5279. fs.writeFileSync(path.join(tempDir, 'build', 'out.ts'), 'export const y = 2;');
  5280. // `\\[` makes the matcher build an unterminated character class — the throw
  5281. // is lazy (at match time), which is what escaped and killed sync.
  5282. fs.writeFileSync(path.join(tempDir, '.gitignore'), 'build/\n\\\\[\n');
  5283. let files: string[] = [];
  5284. expect(() => {
  5285. files = scanDirectory(tempDir);
  5286. }).not.toThrow();
  5287. expect(files).toContain('src/real.ts');
  5288. // The still-valid `build/` rule is honored; only the bad line was dropped.
  5289. expect(files.some((f) => f.startsWith('build/'))).toBe(false);
  5290. });
  5291. // A .gitignore that isn't valid UTF-8 — e.g. encrypted in place by corporate
  5292. // DLP / endpoint software (UTF-16 header + ciphertext) — is skipped whole,
  5293. // not fed to the matcher as garbage patterns (#682).
  5294. it('does not crash on a non-UTF-8 (DLP-encrypted) .gitignore (#682)', () => {
  5295. fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
  5296. fs.writeFileSync(path.join(tempDir, 'src', 'real.ts'), 'export const x = 1;');
  5297. const header = Buffer.concat([
  5298. Buffer.from([0x00, 0x00]),
  5299. Buffer.from('[notice][user]', 'utf16le'),
  5300. ]);
  5301. const junk = Buffer.from([0x5b, 0x99, 0xc3, 0x28, 0x5c, 0x5b, 0xff, 0xfd]);
  5302. fs.writeFileSync(path.join(tempDir, '.gitignore'), Buffer.concat([header, junk]));
  5303. let files: string[] = [];
  5304. expect(() => {
  5305. files = scanDirectory(tempDir);
  5306. }).not.toThrow();
  5307. expect(files).toContain('src/real.ts');
  5308. });
  5309. it('buildDefaultIgnore survives a bad .gitignore and still applies valid rules (#682)', () => {
  5310. fs.writeFileSync(path.join(tempDir, '.gitignore'), 'dist/\n\\\\[\n');
  5311. const ig = buildDefaultIgnore(tempDir);
  5312. expect(() => ig.ignores('src/app.ts')).not.toThrow();
  5313. expect(ig.ignores('dist/')).toBe(true); // valid rule survives
  5314. expect(ig.ignores('src/app.ts')).toBe(false);
  5315. });
  5316. });
  5317. // =============================================================================
  5318. // Scala
  5319. // =============================================================================
  5320. describe('Scala Extraction', () => {
  5321. describe('Language detection', () => {
  5322. it('should detect Scala files', () => {
  5323. expect(detectLanguage('Main.scala')).toBe('scala');
  5324. expect(detectLanguage('script.sc')).toBe('scala');
  5325. expect(detectLanguage('src/UserService.scala')).toBe('scala');
  5326. });
  5327. it('should report Scala as supported', () => {
  5328. expect(isLanguageSupported('scala')).toBe(true);
  5329. expect(getSupportedLanguages()).toContain('scala');
  5330. });
  5331. });
  5332. describe('Class extraction', () => {
  5333. it('should extract class definitions', () => {
  5334. const code = `
  5335. class UserService(private val repo: UserRepository) {
  5336. def findUser(id: String): Option[String] = Some(id)
  5337. }
  5338. `;
  5339. const result = extractFromSource('UserService.scala', code);
  5340. const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'UserService');
  5341. expect(cls).toBeDefined();
  5342. expect(cls?.language).toBe('scala');
  5343. });
  5344. it('should extract object definitions as class kind', () => {
  5345. const code = `
  5346. object DatabaseConfig {
  5347. val url = "jdbc:postgresql://localhost/mydb"
  5348. }
  5349. `;
  5350. const result = extractFromSource('Config.scala', code);
  5351. const obj = result.nodes.find((n) => n.kind === 'class' && n.name === 'DatabaseConfig');
  5352. expect(obj).toBeDefined();
  5353. });
  5354. it('should extract trait definitions as trait kind', () => {
  5355. const code = `
  5356. trait Repository[A] {
  5357. def findById(id: String): Option[A]
  5358. def save(entity: A): Unit
  5359. }
  5360. `;
  5361. const result = extractFromSource('Repository.scala', code);
  5362. const trait_ = result.nodes.find((n) => n.kind === 'trait' && n.name === 'Repository');
  5363. expect(trait_).toBeDefined();
  5364. });
  5365. });
  5366. describe('Method and function extraction', () => {
  5367. it('should extract method definitions inside a class', () => {
  5368. const code = `
  5369. class Calculator {
  5370. def add(a: Int, b: Int): Int = a + b
  5371. def divide(a: Double, b: Double): Double = a / b
  5372. }
  5373. `;
  5374. const result = extractFromSource('Calculator.scala', code);
  5375. const methods = result.nodes.filter((n) => n.kind === 'method');
  5376. expect(methods.find((m) => m.name === 'add')).toBeDefined();
  5377. expect(methods.find((m) => m.name === 'divide')).toBeDefined();
  5378. });
  5379. it('should extract method signatures', () => {
  5380. const code = `
  5381. class Greeter {
  5382. def greet(name: String): String = s"Hello, \${name}!"
  5383. }
  5384. `;
  5385. const result = extractFromSource('Greeter.scala', code);
  5386. const method = result.nodes.find((n) => n.name === 'greet');
  5387. expect(method?.signature).toContain('name: String');
  5388. expect(method?.signature).toContain('String');
  5389. });
  5390. it('should extract top-level function definitions as functions', () => {
  5391. const code = `
  5392. def factorial(n: Int): Int = if (n <= 1) 1 else n * factorial(n - 1)
  5393. def greet(name: String): String = s"Hello, \${name}!"
  5394. `;
  5395. const result = extractFromSource('utils.scala', code);
  5396. const fns = result.nodes.filter((n) => n.kind === 'function');
  5397. expect(fns.find((f) => f.name === 'factorial')).toBeDefined();
  5398. expect(fns.find((f) => f.name === 'greet')).toBeDefined();
  5399. });
  5400. });
  5401. describe('Val and var extraction', () => {
  5402. it('should extract val inside a class as field', () => {
  5403. const code = `
  5404. class Config {
  5405. val timeout: Int = 30
  5406. val host: String = "localhost"
  5407. }
  5408. `;
  5409. const result = extractFromSource('Config.scala', code);
  5410. const fields = result.nodes.filter((n) => n.kind === 'field');
  5411. expect(fields.find((f) => f.name === 'timeout')).toBeDefined();
  5412. expect(fields.find((f) => f.name === 'host')).toBeDefined();
  5413. });
  5414. it('should extract var inside a class as field', () => {
  5415. const code = `
  5416. class Counter {
  5417. var count: Int = 0
  5418. }
  5419. `;
  5420. const result = extractFromSource('Counter.scala', code);
  5421. const field = result.nodes.find((n) => n.kind === 'field' && n.name === 'count');
  5422. expect(field).toBeDefined();
  5423. });
  5424. it('should extract top-level val as constant', () => {
  5425. const code = `
  5426. val MaxConnections: Int = 100
  5427. val DefaultTimeout = 30
  5428. `;
  5429. const result = extractFromSource('constants.scala', code);
  5430. const consts = result.nodes.filter((n) => n.kind === 'constant');
  5431. expect(consts.find((c) => c.name === 'MaxConnections')).toBeDefined();
  5432. });
  5433. it('should extract top-level var as variable', () => {
  5434. const code = `
  5435. var retries: Int = 3
  5436. `;
  5437. const result = extractFromSource('state.scala', code);
  5438. const v = result.nodes.find((n) => n.kind === 'variable' && n.name === 'retries');
  5439. expect(v).toBeDefined();
  5440. });
  5441. it('should include type in val/var signature', () => {
  5442. const code = `
  5443. class Service {
  5444. val timeout: Int = 30
  5445. }
  5446. `;
  5447. const result = extractFromSource('Service.scala', code);
  5448. const field = result.nodes.find((n) => n.name === 'timeout');
  5449. expect(field?.signature).toContain('timeout');
  5450. expect(field?.signature).toContain('Int');
  5451. });
  5452. });
  5453. describe('Enum extraction', () => {
  5454. it('should extract enum definitions', () => {
  5455. const code = `
  5456. enum Color:
  5457. case Red
  5458. case Green
  5459. case Blue
  5460. `;
  5461. const result = extractFromSource('Color.scala', code);
  5462. const enumNode = result.nodes.find((n) => n.kind === 'enum' && n.name === 'Color');
  5463. expect(enumNode).toBeDefined();
  5464. });
  5465. it('should extract enum cases as enum_member', () => {
  5466. const code = `
  5467. enum Direction:
  5468. case North
  5469. case South
  5470. case East
  5471. case West
  5472. `;
  5473. const result = extractFromSource('Direction.scala', code);
  5474. const members = result.nodes.filter((n) => n.kind === 'enum_member');
  5475. expect(members.find((m) => m.name === 'North')).toBeDefined();
  5476. expect(members.find((m) => m.name === 'South')).toBeDefined();
  5477. expect(members.length).toBeGreaterThanOrEqual(4);
  5478. });
  5479. });
  5480. describe('Type alias extraction', () => {
  5481. it('should extract type aliases', () => {
  5482. const code = `
  5483. type UserId = String
  5484. type UserMap = Map[String, String]
  5485. `;
  5486. const result = extractFromSource('types.scala', code);
  5487. const aliases = result.nodes.filter((n) => n.kind === 'type_alias');
  5488. expect(aliases.find((a) => a.name === 'UserId')).toBeDefined();
  5489. expect(aliases.find((a) => a.name === 'UserMap')).toBeDefined();
  5490. });
  5491. });
  5492. describe('Import extraction', () => {
  5493. it('should extract import declarations', () => {
  5494. const code = `
  5495. import scala.collection.mutable.ListBuffer
  5496. import scala.concurrent.Future
  5497. `;
  5498. const result = extractFromSource('imports.scala', code);
  5499. const imports = result.nodes.filter((n) => n.kind === 'import');
  5500. expect(imports.length).toBeGreaterThanOrEqual(2);
  5501. });
  5502. });
  5503. describe('Visibility modifiers', () => {
  5504. it('should extract private visibility', () => {
  5505. const code = `
  5506. class Service {
  5507. private val secret: String = "abc"
  5508. private def helper(): Unit = {}
  5509. }
  5510. `;
  5511. const result = extractFromSource('Service.scala', code);
  5512. const secretField = result.nodes.find((n) => n.name === 'secret');
  5513. expect(secretField?.visibility).toBe('private');
  5514. const helperMethod = result.nodes.find((n) => n.name === 'helper');
  5515. expect(helperMethod?.visibility).toBe('private');
  5516. });
  5517. it('should extract protected visibility', () => {
  5518. const code = `
  5519. class Base {
  5520. protected def helperMethod(): Unit = {}
  5521. }
  5522. `;
  5523. const result = extractFromSource('Base.scala', code);
  5524. const method = result.nodes.find((n) => n.name === 'helperMethod');
  5525. expect(method?.visibility).toBe('protected');
  5526. });
  5527. it('should default to public visibility', () => {
  5528. const code = `
  5529. class Greeter {
  5530. def hello(): Unit = {}
  5531. }
  5532. `;
  5533. const result = extractFromSource('Greeter.scala', code);
  5534. const method = result.nodes.find((n) => n.name === 'hello');
  5535. expect(method?.visibility).toBe('public');
  5536. });
  5537. });
  5538. describe('Inheritance', () => {
  5539. it('should extract extends relationships', () => {
  5540. const code = `
  5541. class AdminUser extends User {
  5542. def adminAction(): Unit = {}
  5543. }
  5544. `;
  5545. const result = extractFromSource('AdminUser.scala', code);
  5546. const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
  5547. expect(extendsRefs.find((r) => r.referenceName === 'User')).toBeDefined();
  5548. });
  5549. });
  5550. describe('Call extraction', () => {
  5551. it('should extract function call expressions', () => {
  5552. const code = `
  5553. def processData(): Unit = {
  5554. val result = computeResult()
  5555. println(result)
  5556. }
  5557. `;
  5558. const result = extractFromSource('processor.scala', code);
  5559. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
  5560. expect(calls.length).toBeGreaterThan(0);
  5561. });
  5562. });
  5563. });
  5564. describe('Vue Extraction', () => {
  5565. it('should detect Vue files', () => {
  5566. expect(detectLanguage('App.vue')).toBe('vue');
  5567. expect(detectLanguage('components/Button.vue')).toBe('vue');
  5568. expect(isLanguageSupported('vue')).toBe(true);
  5569. });
  5570. it('should extract component node from a Vue SFC', () => {
  5571. const code = `<template>
  5572. <div>{{ message }}</div>
  5573. </template>
  5574. <script>
  5575. export default {
  5576. data() {
  5577. return { message: 'Hello' };
  5578. }
  5579. }
  5580. </script>
  5581. `;
  5582. const result = extractFromSource('HelloWorld.vue', code);
  5583. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5584. expect(componentNode).toBeDefined();
  5585. expect(componentNode?.name).toBe('HelloWorld');
  5586. expect(componentNode?.language).toBe('vue');
  5587. expect(componentNode?.isExported).toBe(true);
  5588. });
  5589. it('should extract functions from <script> block', () => {
  5590. const code = `<template>
  5591. <button @click="handleClick">Click</button>
  5592. </template>
  5593. <script>
  5594. function handleClick() {
  5595. console.log('clicked');
  5596. }
  5597. const count = 0;
  5598. </script>
  5599. `;
  5600. const result = extractFromSource('Button.vue', code);
  5601. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5602. expect(componentNode).toBeDefined();
  5603. expect(componentNode?.name).toBe('Button');
  5604. const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'handleClick');
  5605. expect(funcNode).toBeDefined();
  5606. expect(funcNode?.language).toBe('vue');
  5607. });
  5608. it('should extract from <script setup lang="ts"> block', () => {
  5609. const code = `<template>
  5610. <div>{{ count }}</div>
  5611. </template>
  5612. <script setup lang="ts">
  5613. import { ref } from 'vue';
  5614. const count = ref(0);
  5615. function increment(): void {
  5616. count.value++;
  5617. }
  5618. </script>
  5619. `;
  5620. const result = extractFromSource('Counter.vue', code);
  5621. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5622. expect(componentNode).toBeDefined();
  5623. expect(componentNode?.name).toBe('Counter');
  5624. const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'increment');
  5625. expect(funcNode).toBeDefined();
  5626. expect(funcNode?.language).toBe('vue');
  5627. // All nodes should be marked as vue language
  5628. for (const node of result.nodes) {
  5629. expect(node.language).toBe('vue');
  5630. }
  5631. });
  5632. it('should extract calls from top-level <script setup> initializers', () => {
  5633. const code = `<template>
  5634. <div>{{ token }}</div>
  5635. </template>
  5636. <script setup lang="ts">
  5637. import { getTokenMp } from './api/upload';
  5638. const token = getTokenMp();
  5639. </script>
  5640. `;
  5641. const result = extractFromSource('Issue425Setup.vue', code);
  5642. const call = result.unresolvedReferences.find(
  5643. (ref) => ref.referenceKind === 'calls' && ref.referenceName === 'getTokenMp'
  5644. );
  5645. expect(call).toBeDefined();
  5646. });
  5647. it('should extract calls from Vue Options API object methods', () => {
  5648. const code = `<template>
  5649. <button @click="save">Save</button>
  5650. </template>
  5651. <script>
  5652. import { getTokenMp } from './api/upload';
  5653. export default {
  5654. methods: {
  5655. save() {
  5656. return getTokenMp();
  5657. }
  5658. },
  5659. setup() {
  5660. return getTokenMp();
  5661. }
  5662. }
  5663. </script>
  5664. `;
  5665. const result = extractFromSource('Issue425Options.vue', code);
  5666. const calls = result.unresolvedReferences.filter(
  5667. (ref) => ref.referenceKind === 'calls' && ref.referenceName === 'getTokenMp'
  5668. );
  5669. expect(calls).toHaveLength(2);
  5670. });
  5671. it('should extract component usages from the Vue template (PascalCase + kebab, skipping built-ins) (#629)', () => {
  5672. const code = `<template>
  5673. <div class="wrap">
  5674. <UserCard :user="u" />
  5675. <my-button>Click</my-button>
  5676. <Transition><span>x</span></Transition>
  5677. </div>
  5678. </template>
  5679. <script setup lang="ts">
  5680. import UserCard from './UserCard.vue';
  5681. import MyButton from './MyButton.vue';
  5682. </script>
  5683. `;
  5684. const result = extractFromSource('Host.vue', code);
  5685. const refs = result.unresolvedReferences
  5686. .filter((r) => r.referenceKind === 'references')
  5687. .map((r) => r.referenceName);
  5688. expect(refs).toContain('UserCard'); // PascalCase tag
  5689. expect(refs).toContain('MyButton'); // kebab <my-button> → MyButton
  5690. expect(refs).not.toContain('Transition'); // Vue built-in skipped
  5691. expect(refs).not.toContain('Div'); // native HTML element skipped
  5692. expect(refs).not.toContain('Span');
  5693. });
  5694. it('should extract from both <script> and <script setup> blocks', () => {
  5695. const code = `<template>
  5696. <div>{{ msg }}</div>
  5697. </template>
  5698. <script>
  5699. export default {
  5700. name: 'DualScript'
  5701. }
  5702. </script>
  5703. <script setup>
  5704. const msg = 'hello';
  5705. function greet() {
  5706. return msg;
  5707. }
  5708. </script>
  5709. `;
  5710. const result = extractFromSource('DualScript.vue', code);
  5711. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5712. expect(componentNode).toBeDefined();
  5713. const greetFunc = result.nodes.find((n) => n.kind === 'function' && n.name === 'greet');
  5714. expect(greetFunc).toBeDefined();
  5715. });
  5716. it('should create component node for template-only Vue file', () => {
  5717. const code = `<template>
  5718. <div>Static content</div>
  5719. </template>
  5720. `;
  5721. const result = extractFromSource('Static.vue', code);
  5722. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5723. expect(componentNode).toBeDefined();
  5724. expect(componentNode?.name).toBe('Static');
  5725. expect(componentNode?.language).toBe('vue');
  5726. // Only the component node should exist (no script nodes)
  5727. expect(result.nodes.length).toBe(1);
  5728. });
  5729. it('should create containment edges from component to script nodes', () => {
  5730. const code = `<template>
  5731. <div>{{ value }}</div>
  5732. </template>
  5733. <script setup lang="ts">
  5734. const value = 42;
  5735. </script>
  5736. `;
  5737. const result = extractFromSource('Contained.vue', code);
  5738. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5739. expect(componentNode).toBeDefined();
  5740. // Should have containment edges from component to child nodes
  5741. const containEdges = result.edges.filter(
  5742. (e) => e.source === componentNode!.id && e.kind === 'contains'
  5743. );
  5744. expect(containEdges.length).toBeGreaterThan(0);
  5745. });
  5746. });
  5747. describe('Astro Extraction', () => {
  5748. it('should detect Astro files', () => {
  5749. expect(detectLanguage('src/pages/index.astro')).toBe('astro');
  5750. expect(detectLanguage('Layout.astro')).toBe('astro');
  5751. expect(isLanguageSupported('astro')).toBe(true);
  5752. });
  5753. it('should extract component node from an .astro file', () => {
  5754. const code = `---
  5755. const title = 'Hello';
  5756. ---
  5757. <h1>{title}</h1>
  5758. `;
  5759. const result = extractFromSource('Card.astro', code);
  5760. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5761. expect(componentNode).toBeDefined();
  5762. expect(componentNode?.name).toBe('Card');
  5763. expect(componentNode?.language).toBe('astro');
  5764. expect(componentNode?.isExported).toBe(true);
  5765. });
  5766. it('should extract frontmatter symbols with correct line numbers (#768)', () => {
  5767. const code = `---
  5768. import { formatDate } from '../utils/format';
  5769. function getIconNode(name: string): string {
  5770. return name;
  5771. }
  5772. const { title } = Astro.props;
  5773. ---
  5774. <span>{title}</span>
  5775. `;
  5776. const result = extractFromSource('navs.astro', code);
  5777. // The #768 repro: a function defined in frontmatter must be found
  5778. const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'getIconNode');
  5779. expect(fn).toBeDefined();
  5780. expect(fn?.language).toBe('astro');
  5781. expect(fn?.startLine).toBe(4);
  5782. const imp = result.nodes.find((n) => n.kind === 'import');
  5783. expect(imp).toBeDefined();
  5784. expect(imp?.startLine).toBe(2);
  5785. });
  5786. it('should extract exported getStaticPaths from frontmatter', () => {
  5787. const code = `---
  5788. export async function getStaticPaths() {
  5789. return [];
  5790. }
  5791. const { slug } = Astro.params;
  5792. ---
  5793. <p>{slug}</p>
  5794. `;
  5795. const result = extractFromSource('[slug].astro', code);
  5796. const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'getStaticPaths');
  5797. expect(fn).toBeDefined();
  5798. expect(fn?.isExported).toBe(true);
  5799. });
  5800. it('should extract calls from template expressions', () => {
  5801. const code = `---
  5802. import { formatDate } from '../utils/format';
  5803. const date = new Date();
  5804. ---
  5805. <time>{formatDate(date)}</time>
  5806. `;
  5807. const result = extractFromSource('Stamp.astro', code);
  5808. const call = result.unresolvedReferences.find(
  5809. (ref) => ref.referenceKind === 'calls' && ref.referenceName === 'formatDate' && ref.line === 5
  5810. );
  5811. expect(call).toBeDefined();
  5812. });
  5813. it('should extract calls from a multiline expression opening line', () => {
  5814. const code = `---
  5815. const posts = [];
  5816. ---
  5817. <ul>
  5818. {posts.map((post) => (
  5819. <li>{render(post)}</li>
  5820. ))}
  5821. </ul>
  5822. `;
  5823. const result = extractFromSource('List.astro', code);
  5824. const mapCall = result.unresolvedReferences.find(
  5825. (ref) => ref.referenceKind === 'calls' && ref.referenceName === 'posts.map'
  5826. );
  5827. expect(mapCall).toBeDefined();
  5828. const innerCall = result.unresolvedReferences.find(
  5829. (ref) => ref.referenceKind === 'calls' && ref.referenceName === 'render'
  5830. );
  5831. expect(innerCall).toBeDefined();
  5832. });
  5833. it('should extract PascalCase component usages from the template', () => {
  5834. const code = `---
  5835. import Layout from '../layouts/Layout.astro';
  5836. import PostCard from '../components/PostCard.astro';
  5837. ---
  5838. <Layout title="Home">
  5839. <PostCard />
  5840. <Fragment slot="head" />
  5841. <div class="plain-html" />
  5842. </Layout>
  5843. `;
  5844. const result = extractFromSource('index.astro', code);
  5845. const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
  5846. const names = refs.map((r) => r.referenceName);
  5847. expect(names).toContain('Layout');
  5848. expect(names).toContain('PostCard');
  5849. // Astro built-ins and lowercase HTML are not component references
  5850. expect(names).not.toContain('Fragment');
  5851. expect(names).not.toContain('div');
  5852. });
  5853. it('should not extract template patterns from frontmatter, script, or style content', () => {
  5854. const code = `---
  5855. // <FakeComponent /> inside frontmatter comment
  5856. const x = { y: maybeCall(1) };
  5857. ---
  5858. <div>real</div>
  5859. <script>
  5860. const z = { w: scriptCall(2) };
  5861. </script>
  5862. <style>
  5863. .a { color: red; }
  5864. </style>
  5865. `;
  5866. const result = extractFromSource('Guard.astro', code);
  5867. const templateRefs = result.unresolvedReferences.filter(
  5868. (r) => r.referenceKind === 'references' && r.referenceName === 'FakeComponent'
  5869. );
  5870. expect(templateRefs).toHaveLength(0);
  5871. // maybeCall/scriptCall come from the delegated TS extraction (once),
  5872. // not double-counted by the template scanner
  5873. const maybeCalls = result.unresolvedReferences.filter(
  5874. (r) => r.referenceName === 'maybeCall' && r.referenceKind === 'calls'
  5875. );
  5876. expect(maybeCalls.length).toBeLessThanOrEqual(1);
  5877. });
  5878. it('should extract <script> block symbols with correct line numbers', () => {
  5879. const code = `---
  5880. const a = 1;
  5881. ---
  5882. <div>hi</div>
  5883. <script>
  5884. function trackView(page: string) {
  5885. console.log(page);
  5886. }
  5887. </script>
  5888. `;
  5889. const result = extractFromSource('Tracker.astro', code);
  5890. const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'trackView');
  5891. expect(fn).toBeDefined();
  5892. expect(fn?.startLine).toBe(6);
  5893. expect(fn?.language).toBe('astro');
  5894. });
  5895. it('should create component node for a frontmatter-less template-only file', () => {
  5896. const code = `<div>Static content</div>
  5897. `;
  5898. const result = extractFromSource('Static.astro', code);
  5899. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5900. expect(componentNode).toBeDefined();
  5901. expect(componentNode?.name).toBe('Static');
  5902. expect(componentNode?.language).toBe('astro');
  5903. });
  5904. it('should treat an unclosed frontmatter fence as no frontmatter', () => {
  5905. const code = `---
  5906. const broken = true;
  5907. <div>never closed</div>
  5908. `;
  5909. const result = extractFromSource('Broken.astro', code);
  5910. // No TS delegation happened (the fence never closes), but the component
  5911. // node still exists and nothing throws.
  5912. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5913. expect(componentNode).toBeDefined();
  5914. expect(result.nodes.find((n) => n.name === 'broken')).toBeUndefined();
  5915. });
  5916. it('should create containment edges from component to frontmatter nodes', () => {
  5917. const code = `---
  5918. const value = 42;
  5919. ---
  5920. <div>{value}</div>
  5921. `;
  5922. const result = extractFromSource('Contained.astro', code);
  5923. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5924. expect(componentNode).toBeDefined();
  5925. const containEdges = result.edges.filter(
  5926. (e) => e.source === componentNode!.id && e.kind === 'contains'
  5927. );
  5928. expect(containEdges.length).toBeGreaterThan(0);
  5929. });
  5930. });
  5931. describe('Instantiates + Decorates edge extraction', () => {
  5932. it('emits an instantiates ref for `new Foo()`', () => {
  5933. const code = `
  5934. class Foo {}
  5935. function bootstrap() { return new Foo(); }
  5936. `;
  5937. const result = extractFromSource('app.ts', code);
  5938. const ref = result.unresolvedReferences.find(
  5939. (r) => r.referenceKind === 'instantiates' && r.referenceName === 'Foo'
  5940. );
  5941. expect(ref).toBeDefined();
  5942. });
  5943. it('strips type-argument suffix from generic constructors', () => {
  5944. const code = `
  5945. class Container<T> { constructor(_: T) {} }
  5946. function go() { return new Container<string>('x'); }
  5947. `;
  5948. const result = extractFromSource('app.ts', code);
  5949. const ref = result.unresolvedReferences.find(
  5950. (r) => r.referenceKind === 'instantiates'
  5951. );
  5952. expect(ref).toBeDefined();
  5953. // Container<string> must be normalised to "Container" — otherwise
  5954. // resolution can never match the class node.
  5955. expect(ref!.referenceName).toBe('Container');
  5956. });
  5957. it('keeps trailing identifier from qualified `new ns.Foo()`', () => {
  5958. const code = `
  5959. const ns = { Foo: class {} };
  5960. function go() { return new ns.Foo(); }
  5961. `;
  5962. const result = extractFromSource('app.ts', code);
  5963. const ref = result.unresolvedReferences.find(
  5964. (r) => r.referenceKind === 'instantiates'
  5965. );
  5966. // We can't always resolve which Foo, but the name should be the
  5967. // simple identifier so name-matching has a chance.
  5968. expect(ref?.referenceName).toBe('Foo');
  5969. });
  5970. it('emits a decorates ref for `@Foo class X {}`', () => {
  5971. const code = `
  5972. function Foo(_arg: string) { return (cls: any) => cls; }
  5973. @Foo('x')
  5974. class X {}
  5975. `;
  5976. const result = extractFromSource('app.ts', code);
  5977. const decorClass = result.unresolvedReferences.find(
  5978. (r) => r.referenceKind === 'decorates' && r.referenceName === 'Foo'
  5979. );
  5980. expect(decorClass).toBeDefined();
  5981. });
  5982. it('does NOT attribute a prior class\'s decorator to the next class', () => {
  5983. // Regression: the sibling-walk must stop at the first non-
  5984. // decorator separator. `@A class Foo {} @B class Bar {}` must
  5985. // produce `decorates(Foo, A)` and `decorates(Bar, B)` — never
  5986. // `decorates(Bar, A)`.
  5987. const code = `
  5988. function A(cls: any) { return cls; }
  5989. function B(cls: any) { return cls; }
  5990. @A
  5991. class Foo {}
  5992. @B
  5993. class Bar {}
  5994. `;
  5995. const result = extractFromSource('app.ts', code);
  5996. const decoratesEdges = result.unresolvedReferences.filter(
  5997. (r) => r.referenceKind === 'decorates'
  5998. );
  5999. // Exactly one decorates ref per decorated class, no cross-attribution.
  6000. const fromBar = decoratesEdges.filter((r) =>
  6001. result.nodes.find((n) => n.id === r.fromNodeId && n.name === 'Bar')
  6002. );
  6003. expect(fromBar.length).toBe(1);
  6004. expect(fromBar[0]!.referenceName).toBe('B');
  6005. });
  6006. it('emits a decorates ref for `@Foo method() {}`', () => {
  6007. const code = `
  6008. function Get(p: string) { return (t: any, k: string) => t; }
  6009. class Svc {
  6010. @Get('/x') method() { return 1; }
  6011. }
  6012. `;
  6013. const result = extractFromSource('app.ts', code);
  6014. const decorMethod = result.unresolvedReferences.find(
  6015. (r) => r.referenceKind === 'decorates' && r.referenceName === 'Get'
  6016. );
  6017. expect(decorMethod).toBeDefined();
  6018. // The decorated symbol must be `method`, not the constructor or class.
  6019. const decoratedNode = result.nodes.find((n) => n.id === decorMethod!.fromNodeId);
  6020. expect(decoratedNode?.name).toBe('method');
  6021. });
  6022. });
  6023. // =============================================================================
  6024. // Lua
  6025. // =============================================================================
  6026. describe('Lua Extraction', () => {
  6027. describe('Language detection', () => {
  6028. it('should detect Lua files', () => {
  6029. expect(detectLanguage('init.lua')).toBe('lua');
  6030. expect(detectLanguage('src/util.lua')).toBe('lua');
  6031. });
  6032. it('should report Lua as supported', () => {
  6033. expect(isLanguageSupported('lua')).toBe(true);
  6034. expect(getSupportedLanguages()).toContain('lua');
  6035. });
  6036. });
  6037. describe('Function extraction', () => {
  6038. it('should extract global and local functions', () => {
  6039. const code = `
  6040. function configure(opts) return opts end
  6041. local function helper(x) return x * 2 end
  6042. `;
  6043. const result = extractFromSource('init.lua', code);
  6044. const funcs = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  6045. expect(funcs).toContain('configure');
  6046. expect(funcs).toContain('helper');
  6047. const configure = result.nodes.find((n) => n.name === 'configure');
  6048. expect(configure?.language).toBe('lua');
  6049. expect(configure?.signature).toBe('(opts)');
  6050. });
  6051. it('should split table/method functions into a receiver and method name', () => {
  6052. const code = `
  6053. function M.connect(host, port) return host end
  6054. function M:send(data) return self end
  6055. `;
  6056. const result = extractFromSource('init.lua', code);
  6057. const methods = result.nodes.filter((n) => n.kind === 'method');
  6058. const connect = methods.find((m) => m.name === 'connect');
  6059. expect(connect?.qualifiedName).toBe('M::connect');
  6060. const send = methods.find((m) => m.name === 'send');
  6061. expect(send?.qualifiedName).toBe('M::send');
  6062. });
  6063. });
  6064. describe('Variable extraction', () => {
  6065. it('should extract local variable declarations', () => {
  6066. const code = `
  6067. local M = {}
  6068. local count = 0
  6069. `;
  6070. const result = extractFromSource('mod.lua', code);
  6071. const vars = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name);
  6072. expect(vars).toContain('M');
  6073. expect(vars).toContain('count');
  6074. });
  6075. });
  6076. describe('Import extraction (require)', () => {
  6077. it('should extract require() in local declarations and bare calls', () => {
  6078. const code = `
  6079. local socket = require("socket")
  6080. local http = require "resty.http"
  6081. require("side.effect")
  6082. `;
  6083. const result = extractFromSource('net.lua', code);
  6084. const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  6085. expect(imports).toContain('socket');
  6086. expect(imports).toContain('resty.http');
  6087. expect(imports).toContain('side.effect');
  6088. const ref = result.unresolvedReferences.find(
  6089. (r) => r.referenceKind === 'imports' && r.referenceName === 'socket'
  6090. );
  6091. expect(ref).toBeDefined();
  6092. });
  6093. // Regression: the tree-sitter-wasms Lua grammar (ABI 13) corrupts the shared
  6094. // WASM heap under web-tree-sitter 0.25, dropping nested calls/imports on every
  6095. // parse after the first. We vendor the ABI-15 grammar instead — this guards it
  6096. // by extracting several sources in sequence and asserting the LAST still works.
  6097. it('should keep extracting require across many sequential parses', () => {
  6098. let last;
  6099. for (let i = 0; i < 8; i++) {
  6100. last = extractFromSource(`f${i}.lua`, `local m = require("module.${i}")\nreturn m\n`);
  6101. }
  6102. const imports = last!.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  6103. expect(imports).toContain('module.7');
  6104. });
  6105. });
  6106. describe('Call extraction', () => {
  6107. it('should record intra-file calls as resolvable references', () => {
  6108. const code = `
  6109. local function helper(x) return x end
  6110. local function run(y) return helper(y) end
  6111. `;
  6112. const result = extractFromSource('calls.lua', code);
  6113. const call = result.unresolvedReferences.find(
  6114. (r) => r.referenceKind === 'calls' && r.referenceName === 'helper'
  6115. );
  6116. expect(call).toBeDefined();
  6117. });
  6118. });
  6119. });
  6120. // =============================================================================
  6121. // Luau (typed superset of Lua — https://luau.org)
  6122. // =============================================================================
  6123. describe('Luau Extraction', () => {
  6124. describe('Language detection', () => {
  6125. it('should detect Luau files', () => {
  6126. expect(detectLanguage('init.luau')).toBe('luau');
  6127. expect(detectLanguage('src/Client.luau')).toBe('luau');
  6128. });
  6129. it('should report Luau as supported', () => {
  6130. expect(isLanguageSupported('luau')).toBe(true);
  6131. expect(getSupportedLanguages()).toContain('luau');
  6132. });
  6133. });
  6134. describe('Type aliases', () => {
  6135. it('should extract `type` and `export type` definitions', () => {
  6136. const code = `
  6137. export type Vector = { x: number, y: number }
  6138. type Handler = (msg: string) -> boolean
  6139. `;
  6140. const result = extractFromSource('types.luau', code);
  6141. const aliases = result.nodes.filter((n) => n.kind === 'type_alias');
  6142. const vector = aliases.find((a) => a.name === 'Vector');
  6143. expect(vector).toBeDefined();
  6144. expect(vector?.isExported).toBe(true);
  6145. const handler = aliases.find((a) => a.name === 'Handler');
  6146. expect(handler).toBeDefined();
  6147. expect(handler?.isExported).toBe(false);
  6148. });
  6149. });
  6150. describe('Typed functions and methods', () => {
  6151. it('should capture typed signatures and split methods by receiver', () => {
  6152. const code = `
  6153. function configure(opts: { debug: boolean }): boolean
  6154. return opts.debug
  6155. end
  6156. function Client:fetch(path: string): Response
  6157. return path
  6158. end
  6159. `;
  6160. const result = extractFromSource('client.luau', code);
  6161. const configure = result.nodes.find((n) => n.kind === 'function' && n.name === 'configure');
  6162. expect(configure?.language).toBe('luau');
  6163. expect(configure?.signature).toBe('(opts: { debug: boolean }): boolean');
  6164. const fetch = result.nodes.find((n) => n.kind === 'method' && n.name === 'fetch');
  6165. expect(fetch?.qualifiedName).toBe('Client::fetch');
  6166. });
  6167. });
  6168. describe('Imports and variables', () => {
  6169. it('should extract string and Roblox instance-path require imports', () => {
  6170. const code = `
  6171. local http = require("http")
  6172. local Signal = require(script.Parent.Signal)
  6173. local count = 0
  6174. `;
  6175. const result = extractFromSource('mod.luau', code);
  6176. const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  6177. expect(imports).toContain('http'); // string require
  6178. expect(imports).toContain('Signal'); // Roblox instance-path require
  6179. const vars = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name);
  6180. expect(vars).toContain('count');
  6181. });
  6182. });
  6183. });
  6184. // =============================================================================
  6185. // Objective-C
  6186. // =============================================================================
  6187. describe('Objective-C Extraction', () => {
  6188. const sample = `
  6189. #import <Foundation/Foundation.h>
  6190. #import "MyClass.h"
  6191. @interface MyClass : NSObject <NSCopying>
  6192. @property (nonatomic, copy) NSString *name;
  6193. - (void)greet;
  6194. - (void)doThing:(id)x with:(id)y;
  6195. + (instancetype)shared;
  6196. @end
  6197. @implementation MyClass
  6198. - (void)greet {
  6199. NSLog(@"Hello");
  6200. [self doWork];
  6201. }
  6202. - (void)doThing:(id)x with:(id)y {
  6203. [self notify:x];
  6204. }
  6205. + (instancetype)shared {
  6206. return [[MyClass alloc] init];
  6207. }
  6208. @end
  6209. void helperFunction(int count) {
  6210. MyClass *obj = [MyClass shared];
  6211. [obj greet];
  6212. }
  6213. `;
  6214. it('should extract classes, methods, functions, and imports', () => {
  6215. const result = extractFromSource('App.m', sample);
  6216. const classes = result.nodes.filter((n) => n.kind === 'class');
  6217. expect(classes.filter((c) => c.name === 'MyClass')).toHaveLength(1);
  6218. const methods = result.nodes.filter((n) => n.kind === 'method');
  6219. expect(methods.map((m) => m.name).sort()).toEqual(['doThing:with:', 'greet', 'shared']);
  6220. const shared = methods.find((m) => m.name === 'shared');
  6221. expect(shared?.isStatic).toBe(true);
  6222. const properties = result.nodes.filter((n) => n.kind === 'property');
  6223. expect(properties.some((p) => p.name === 'name')).toBe(true);
  6224. const functions = result.nodes.filter((n) => n.kind === 'function');
  6225. expect(functions.some((f) => f.name === 'helperFunction')).toBe(true);
  6226. const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  6227. expect(imports).toContain('Foundation/Foundation.h');
  6228. expect(imports).toContain('MyClass.h');
  6229. });
  6230. it('should record inheritance and protocol conformance', () => {
  6231. const result = extractFromSource('App.m', sample);
  6232. const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
  6233. const implementsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'implements');
  6234. expect(extendsRefs.map((r) => r.referenceName)).toContain('NSObject');
  6235. expect(implementsRefs.map((r) => r.referenceName)).toContain('NSCopying');
  6236. });
  6237. it('should record message sends and C calls', () => {
  6238. const result = extractFromSource('App.m', sample);
  6239. const calls = result.unresolvedReferences
  6240. .filter((r) => r.referenceKind === 'calls')
  6241. .map((r) => r.referenceName);
  6242. expect(calls).toEqual(expect.arrayContaining(['NSLog', 'doWork', 'MyClass.shared', 'obj.greet']));
  6243. });
  6244. it('should reconstruct multi-keyword selectors at the call site so they resolve to the method definition', () => {
  6245. // Regression for the gap discovered post-#165: message_expression's
  6246. // multi-keyword form `[obj a:1 b:2]` was only emitting the first keyword,
  6247. // so calls never resolved to multi-part method definitions like
  6248. // `GET:parameters:headers:progress:success:failure:`. The call-site name
  6249. // must match the method-definition name with full keywords + trailing colons.
  6250. const code = `
  6251. @implementation Caller
  6252. - (void)demo {
  6253. NSMutableDictionary *d = [NSMutableDictionary new];
  6254. [d setObject:@"v" forKey:@"k"];
  6255. [d setObject:@"v2" forKey:@"k2" withRetry:@YES];
  6256. [self touchesBegan:nil withEvent:nil];
  6257. }
  6258. @end
  6259. `;
  6260. const result = extractFromSource('Caller.m', code);
  6261. const calls = result.unresolvedReferences
  6262. .filter((r) => r.referenceKind === 'calls')
  6263. .map((r) => r.referenceName);
  6264. expect(calls).toEqual(
  6265. expect.arrayContaining([
  6266. 'd.setObject:forKey:',
  6267. 'd.setObject:forKey:withRetry:',
  6268. 'touchesBegan:withEvent:',
  6269. ])
  6270. );
  6271. });
  6272. it('should not classify pure C headers with @end in comments as objc', () => {
  6273. const cHeader = '/* @end of file */\n#ifndef STDIO_H\nvoid printf(const char *);\n#endif\n';
  6274. expect(detectLanguage('stdio.h', cHeader)).toBe('c');
  6275. });
  6276. it('should extract protocol declarations', () => {
  6277. const code = `
  6278. @protocol DataSource <NSObject>
  6279. - (NSInteger)numberOfItems;
  6280. @end
  6281. `;
  6282. const result = extractFromSource('DataSource.h', code);
  6283. const protocol = result.nodes.find((n) => n.kind === 'protocol' && n.name === 'DataSource');
  6284. expect(protocol).toBeDefined();
  6285. });
  6286. it('should report Objective-C as supported', () => {
  6287. expect(isLanguageSupported('objc')).toBe(true);
  6288. expect(getSupportedLanguages()).toContain('objc');
  6289. });
  6290. });
  6291. describe('Regression: issue-specific extraction fixes', () => {
  6292. it('indexes inner functions of an anonymous AMD/CommonJS module wrapper (#528)', () => {
  6293. const code = `
  6294. define(['dep'], function (dep) {
  6295. function innerHelper(x) { return x + 1; }
  6296. function compute(y) { return innerHelper(y); }
  6297. return { compute: compute };
  6298. });
  6299. `;
  6300. const result = extractFromSource('amd-module.js', code);
  6301. const fns = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  6302. expect(fns).toContain('innerHelper');
  6303. expect(fns).toContain('compute');
  6304. });
  6305. it('attaches Go methods on generic receivers to their type (#583)', () => {
  6306. const code = `
  6307. package main
  6308. type Stack[T any] struct { items []T }
  6309. func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }
  6310. func (s Stack[T]) Len() int { return len(s.items) }
  6311. `;
  6312. const result = extractFromSource('stack.go', code);
  6313. const methods = result.nodes.filter((n) => n.kind === 'method');
  6314. expect(methods.find((m) => m.name === 'Push')?.qualifiedName).toBe('Stack::Push');
  6315. expect(methods.find((m) => m.name === 'Len')?.qualifiedName).toBe('Stack::Len');
  6316. });
  6317. it('indexes new module extensions: .mts/.cts (TS) and .xsjs/.xsjslib (JS) (#366, #556)', () => {
  6318. expect(isSourceFile('mod.mts')).toBe(true);
  6319. expect(isSourceFile('mod.cts')).toBe(true);
  6320. expect(isSourceFile('service.xsjs')).toBe(true);
  6321. expect(isSourceFile('lib.xsjslib')).toBe(true);
  6322. expect(detectLanguage('mod.mts')).toBe('typescript');
  6323. expect(detectLanguage('service.xsjs')).toBe('javascript');
  6324. // End-to-end: a .mts file is parsed as TS, a .xsjs file as JS.
  6325. const ts = extractFromSource('mod.mts', 'export function hello(): number { return 1; }');
  6326. expect(ts.nodes.find((n) => n.name === 'hello' && n.kind === 'function')).toBeDefined();
  6327. const js = extractFromSource('service.xsjs', 'function handleRequest() { return 1; }');
  6328. expect(js.nodes.find((n) => n.name === 'handleRequest' && n.kind === 'function')).toBeDefined();
  6329. });
  6330. });
  6331. describe('Import / re-export dependency linking (blast-radius recall)', () => {
  6332. // An import IS a dependency, but extraction only emits references for calls,
  6333. // instantiations, type annotations, and inheritance — so a symbol imported and
  6334. // then merely re-exported, placed in a registry array, passed as an argument,
  6335. // or used in JSX produced no cross-file edge, leaving the providing file with a
  6336. // false "0 dependents". These tests pin the import/re-export binding linking.
  6337. it('emits an imports reference per named, aliased, and default import binding', () => {
  6338. const code = `
  6339. import { widget, helper as h } from './foo';
  6340. import Thing from './thing';
  6341. import * as NS from './ns';
  6342. export const registry = [widget];
  6343. `;
  6344. const result = extractFromSource('bar.ts', code);
  6345. const names = result.unresolvedReferences
  6346. .filter((r) => r.referenceKind === 'imports')
  6347. .map((r) => r.referenceName);
  6348. expect(names).toContain('widget'); // named import → local name
  6349. expect(names).toContain('h'); // aliased import → local alias
  6350. expect(names).toContain('Thing'); // default import
  6351. expect(names).toContain('NS'); // namespace import → linked to the module file as a dependency
  6352. });
  6353. it('emits an imports reference per re-exported binding', () => {
  6354. const result = extractFromSource('barrel.ts', `export { alpha, beta as b } from './source';`);
  6355. const names = result.unresolvedReferences
  6356. .filter((r) => r.referenceKind === 'imports')
  6357. .map((r) => r.referenceName);
  6358. // Re-export links the SOURCE-side name, not the local alias.
  6359. expect(names).toContain('alpha');
  6360. expect(names).toContain('beta');
  6361. });
  6362. it('a value imported/re-exported but never called still makes the importer a dependent', async () => {
  6363. const dir = createTempDir();
  6364. try {
  6365. fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
  6366. fs.writeFileSync(
  6367. path.join(dir, 'src', 'foo.ts'),
  6368. `export const widget = { n: 1 };\nexport function helper(): void {}\n`
  6369. );
  6370. // bar uses widget ONLY in an array and re-exports helper — neither is
  6371. // called/typed, so before import-linking bar had no edge to foo at all.
  6372. fs.writeFileSync(
  6373. path.join(dir, 'src', 'bar.ts'),
  6374. `import { widget } from './foo';\nexport { helper } from './foo';\nexport const registry = [widget];\n`
  6375. );
  6376. const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.ts'], exclude: [] } });
  6377. await cg.indexAll();
  6378. cg.resolveReferences();
  6379. expect(cg.getFileDependents('src/foo.ts')).toContain('src/bar.ts');
  6380. cg.destroy();
  6381. } finally {
  6382. cleanupTempDir(dir);
  6383. }
  6384. });
  6385. it('a namespace import touched only via a value-member read still links the module file', async () => {
  6386. const dir = createTempDir();
  6387. try {
  6388. fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
  6389. fs.writeFileSync(path.join(dir, 'src', 'foo.ts'), `export const SOME_CONST = 42;\n`);
  6390. // `foo` is imported as a namespace and used ONLY via a value-member read
  6391. // (no call, no type) — `foo.helper()` would link on its own, but a bare
  6392. // `foo.SOME_CONST` would not, so the module-import backstop must link it.
  6393. fs.writeFileSync(path.join(dir, 'src', 'bar.ts'), `import * as foo from './foo';\nexport const x = foo.SOME_CONST;\n`);
  6394. const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.ts'], exclude: [] } });
  6395. await cg.indexAll();
  6396. cg.resolveReferences();
  6397. expect(cg.getFileDependents('src/foo.ts')).toContain('src/bar.ts');
  6398. cg.destroy();
  6399. } finally {
  6400. cleanupTempDir(dir);
  6401. }
  6402. });
  6403. });
  6404. describe('Python import dependency linking (blast-radius recall)', () => {
  6405. // Same recall gap as TS: Python only linked called/instantiated imports, so a
  6406. // name brought in with `from module import X` and then merely stored, used as
  6407. // a decorator/argument, or re-exported through an `__init__.py` produced no
  6408. // cross-file edge — the providing module showed a false "0 dependents".
  6409. it('emits an imports reference per name in a `from module import ...` (incl. value/aliased)', () => {
  6410. const code = [
  6411. 'from foo import helper, widget',
  6412. 'from foo import Thing as T',
  6413. 'from . import sibling',
  6414. 'from bar import *',
  6415. ].join('\n');
  6416. const names = extractFromSource('mod.py', code)
  6417. .unresolvedReferences.filter((r) => r.referenceKind === 'imports')
  6418. .map((r) => r.referenceName);
  6419. expect(names).toContain('helper');
  6420. expect(names).toContain('widget'); // value import
  6421. expect(names).toContain('T'); // aliased import → local name
  6422. expect(names).toContain('sibling'); // `from . import <name>`
  6423. expect(names).not.toContain('*'); // wildcard import has no names
  6424. });
  6425. it('a Python value imported but never called still makes the importer a dependent', async () => {
  6426. const dir = createTempDir();
  6427. try {
  6428. fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true });
  6429. fs.writeFileSync(path.join(dir, 'pkg', 'foo.py'), `widget = {"n": 1}\ndef helper():\n return 1\n`);
  6430. // bar imports widget+helper but only stores widget in a list — nothing is
  6431. // called, so before import-linking bar had no edge to foo.
  6432. fs.writeFileSync(path.join(dir, 'pkg', 'bar.py'), `from foo import widget, helper\nregistry = [widget]\n`);
  6433. const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } });
  6434. await cg.indexAll();
  6435. cg.resolveReferences();
  6436. expect(cg.getFileDependents('pkg/foo.py')).toContain('pkg/bar.py');
  6437. cg.destroy();
  6438. } finally {
  6439. cleanupTempDir(dir);
  6440. }
  6441. });
  6442. it('resolves `from . import submodule` + `submodule.func()` to the submodule', async () => {
  6443. const dir = createTempDir();
  6444. try {
  6445. fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true });
  6446. fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), '');
  6447. fs.writeFileSync(path.join(dir, 'pkg', 'certs.py'), `def where():\n return "/ca.pem"\n`);
  6448. // certs is an imported MODULE (a file), and certs.where() is a qualified
  6449. // call through it — the receiver isn't a symbol, so plain name-matching
  6450. // can't link it. Also exercises the Python relative-dot path fix (`.certs`).
  6451. fs.writeFileSync(path.join(dir, 'pkg', 'utils.py'), `from . import certs\ndef go():\n return certs.where()\n`);
  6452. const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } });
  6453. await cg.indexAll();
  6454. cg.resolveReferences();
  6455. expect(cg.getFileDependents('pkg/certs.py')).toContain('pkg/utils.py');
  6456. cg.destroy();
  6457. } finally {
  6458. cleanupTempDir(dir);
  6459. }
  6460. });
  6461. it('a module import is a dependency even when the used member is re-exported elsewhere', async () => {
  6462. const dir = createTempDir();
  6463. try {
  6464. fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true });
  6465. fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), '');
  6466. // `where` is NOT defined in certs.py (re-exported from a 3rd-party pkg), so
  6467. // member resolution can't find it — the module-import backstop must still
  6468. // record utils -> certs. (Mirrors requests' real `certs.where`.)
  6469. fs.writeFileSync(path.join(dir, 'pkg', 'certs.py'), `from external_ca import where\n`);
  6470. fs.writeFileSync(path.join(dir, 'pkg', 'utils.py'), `from . import certs\nCA = certs.where()\n`);
  6471. const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } });
  6472. await cg.indexAll();
  6473. cg.resolveReferences();
  6474. expect(cg.getFileDependents('pkg/certs.py')).toContain('pkg/utils.py');
  6475. cg.destroy();
  6476. } finally {
  6477. cleanupTempDir(dir);
  6478. }
  6479. });
  6480. });
  6481. describe('Go cross-package composite literals (blast-radius recall)', () => {
  6482. // Go function calls and type references across packages already resolved, but
  6483. // struct composite literals — `render.XML{...}` / `pkga.Widget{...}` — were not
  6484. // extracted at all, so a package whose types are only INSTANTIATED elsewhere
  6485. // (gin's render/binding implementations) showed 0 dependents.
  6486. it('links a cross-package struct composite literal to the defining package', async () => {
  6487. const dir = createTempDir();
  6488. try {
  6489. fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
  6490. fs.mkdirSync(path.join(dir, 'render'), { recursive: true });
  6491. fs.writeFileSync(path.join(dir, 'render', 'xml.go'), `package render\n\ntype XML struct { Data any }\n`);
  6492. fs.writeFileSync(path.join(dir, 'app.go'), `package main\n\nimport "example.com/proj/render"\n\nfunc handle() any { return render.XML{} }\n`);
  6493. const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
  6494. await cg.indexAll();
  6495. cg.resolveReferences();
  6496. expect(cg.getFileDependents('render/xml.go')).toContain('app.go');
  6497. cg.destroy();
  6498. } finally {
  6499. cleanupTempDir(dir);
  6500. }
  6501. });
  6502. it('links a composite literal in a package-level var registry', async () => {
  6503. const dir = createTempDir();
  6504. try {
  6505. fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
  6506. fs.mkdirSync(path.join(dir, 'render'), { recursive: true });
  6507. fs.writeFileSync(path.join(dir, 'render', 'xml.go'), `package render\n\ntype XML struct {}\nfunc (XML) Render() {}\n`);
  6508. // The implementation is registered only in a top-level `var registry = {...}`
  6509. // map literal — the body walker doesn't cover top-level declarations, so this
  6510. // exercises the var-initializer walking added for Go.
  6511. fs.writeFileSync(path.join(dir, 'reg.go'), `package main\n\nimport "example.com/proj/render"\n\ntype R interface { Render() }\n\nvar registry = map[string]R{ "xml": render.XML{} }\n`);
  6512. const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
  6513. await cg.indexAll();
  6514. cg.resolveReferences();
  6515. expect(cg.getFileDependents('render/xml.go')).toContain('reg.go');
  6516. cg.destroy();
  6517. } finally {
  6518. cleanupTempDir(dir);
  6519. }
  6520. });
  6521. it('attributes a call inside a top-level closure (cobra RunE) to the var, not the file (#693)', async () => {
  6522. const dir = createTempDir();
  6523. try {
  6524. fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
  6525. // Wire is called ONLY from the anonymous RunE closure inside a top-level
  6526. // `var rootCmd = &Cmd{...}` — previously the call leaked to the file node,
  6527. // so `callers(Wire)` surfaced a file (or read as "no caller"). It must now
  6528. // attribute to the enclosing var.
  6529. fs.writeFileSync(path.join(dir, 'factory.go'), `package main\n\nfunc Wire() error { return nil }\n`);
  6530. fs.writeFileSync(
  6531. path.join(dir, 'root.go'),
  6532. `package main\n\ntype Cmd struct{ RunE func() error }\n\nvar rootCmd = &Cmd{\n\tRunE: func() error { return Wire() },\n}\n`
  6533. );
  6534. const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
  6535. await cg.indexAll();
  6536. cg.resolveReferences();
  6537. const wire = cg.getNodesByName('Wire').find((n) => n.kind === 'function');
  6538. expect(wire).toBeDefined();
  6539. const callers = cg.getCallers(wire!.id).map((c) => c.node);
  6540. expect(callers.some((n) => n.kind === 'variable' && n.name === 'rootCmd')).toBe(true);
  6541. expect(callers.some((n) => n.kind === 'file')).toBe(false);
  6542. cg.destroy();
  6543. } finally {
  6544. cleanupTempDir(dir);
  6545. }
  6546. });
  6547. it('links a parenthesized pointer type conversion `(*T)(x)` to the type', async () => {
  6548. const dir = createTempDir();
  6549. try {
  6550. fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
  6551. fs.writeFileSync(path.join(dir, 'types.go'), `package main\n\ntype Wrapped struct { N int }\n`);
  6552. // `(*Wrapped)(x)` parses as a call whose callee is the parenthesized type
  6553. // `(*Wrapped)` — without normalization it dropped on the floor.
  6554. fs.writeFileSync(path.join(dir, 'use.go'), `package main\n\nfunc run(x *int) { _ = (*Wrapped)(x) }\n`);
  6555. const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
  6556. await cg.indexAll();
  6557. cg.resolveReferences();
  6558. expect(cg.getFileDependents('types.go')).toContain('use.go');
  6559. cg.destroy();
  6560. } finally {
  6561. cleanupTempDir(dir);
  6562. }
  6563. });
  6564. it('links an implementation reached only through a Go interface (implicit satisfaction, #584)', async () => {
  6565. const dir = createTempDir();
  6566. try {
  6567. fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
  6568. fs.mkdirSync(path.join(dir, 'codec'), { recursive: true });
  6569. fs.writeFileSync(path.join(dir, 'codec', 'api.go'), `package codec\n\ntype Core interface {\n\tMarshal(v any) ([]byte, error)\n}\n\nvar API Core\n`);
  6570. // jsonApi satisfies Core structurally (no `implements` keyword) and is
  6571. // reached ONLY through the interface (API.Marshal). Without implicit
  6572. // interface satisfaction + dispatch, json.go shows 0 dependents.
  6573. fs.writeFileSync(path.join(dir, 'codec', 'json.go'), `package codec\n\ntype jsonApi struct{}\n\nfunc (j jsonApi) Marshal(v any) ([]byte, error) { return nil, nil }\n\nfunc init() { API = jsonApi{} }\n`);
  6574. const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
  6575. await cg.indexAll();
  6576. cg.resolveReferences();
  6577. expect(cg.getFileDependents('codec/json.go')).toContain('codec/api.go');
  6578. cg.destroy();
  6579. } finally {
  6580. cleanupTempDir(dir);
  6581. }
  6582. });
  6583. });
  6584. describe('C# records (blast-radius recall)', () => {
  6585. // Records are ubiquitous in modern C# (DTOs, value objects, CQRS messages),
  6586. // but `record` / `record struct` declarations weren't extracted as types — so
  6587. // every reference, generic-type-argument, and `new` of a record dropped on the
  6588. // floor and the defining file showed 0 dependents. (#237)
  6589. it('extracts a record as a graph node (record class + record struct)', () => {
  6590. const r = extractFromSource('r.cs', `namespace P;\npublic record Box(int N);\npublic record struct Pt(int X);\n`);
  6591. expect(r.nodes.find((n) => n.name === 'Box' && (n.kind === 'class' || n.kind === 'struct'))).toBeDefined();
  6592. expect(r.nodes.find((n) => n.name === 'Pt' && (n.kind === 'class' || n.kind === 'struct'))).toBeDefined();
  6593. });
  6594. it('resolves references / instantiations of a record across files', async () => {
  6595. const dir = createTempDir();
  6596. try {
  6597. fs.writeFileSync(path.join(dir, 'types.cs'), `namespace P;\npublic record Box(int N);\n`);
  6598. // Box is used as a generic type argument and instantiated — both require
  6599. // Box to be a node to resolve.
  6600. fs.writeFileSync(
  6601. path.join(dir, 'use.cs'),
  6602. `using System.Collections.Generic;\nnamespace P;\npublic class User {\n public IEnumerable<Box> Boxes { get; }\n public Box Make() => new Box(1);\n}\n`
  6603. );
  6604. const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.cs'], exclude: [] } });
  6605. await cg.indexAll();
  6606. cg.resolveReferences();
  6607. expect(cg.getFileDependents('types.cs')).toContain('use.cs');
  6608. cg.destroy();
  6609. } finally {
  6610. cleanupTempDir(dir);
  6611. }
  6612. });
  6613. });
  6614. describe('Rust cross-module recall', () => {
  6615. function rustProject(files: Record<string, string>): string {
  6616. const dir = createTempDir();
  6617. fs.writeFileSync(path.join(dir, 'Cargo.toml'), '[package]\nname = "proj"\nversion = "0.1.0"\nedition = "2021"\n');
  6618. fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
  6619. for (const [rel, content] of Object.entries(files)) {
  6620. const full = path.join(dir, 'src', rel);
  6621. fs.mkdirSync(path.dirname(full), { recursive: true });
  6622. fs.writeFileSync(full, content);
  6623. }
  6624. return dir;
  6625. }
  6626. it('extracts a struct literal `Foo { .. }` as an instantiation across modules', async () => {
  6627. const dir = rustProject({
  6628. 'lib.rs': 'pub mod types;\npub mod consumer;\n',
  6629. 'types.rs': 'pub struct Widget { pub n: i32 }\n',
  6630. 'consumer.rs': 'use crate::types::Widget;\npub fn build() -> Widget { Widget { n: 1 } }\n',
  6631. });
  6632. try {
  6633. const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } });
  6634. await cg.indexAll();
  6635. cg.resolveReferences();
  6636. expect(cg.getFileDependents('src/types.rs')).toContain('src/consumer.rs');
  6637. cg.destroy();
  6638. } finally { cleanupTempDir(dir); }
  6639. });
  6640. it('extracts trait method declarations and bridges trait dispatch to the impl', async () => {
  6641. const dir = rustProject({
  6642. 'lib.rs': 'pub mod types;\npub mod consumer;\n',
  6643. 'types.rs': 'pub trait Render { fn render(&self) -> i32; }\n',
  6644. // Mine implements Render structurally; reached via &dyn Render dispatch.
  6645. 'consumer.rs': 'use crate::types::Render;\npub struct Mine { pub x: i32 }\nimpl Render for Mine { fn render(&self) -> i32 { self.x } }\n',
  6646. });
  6647. try {
  6648. const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } });
  6649. await cg.indexAll();
  6650. cg.resolveReferences();
  6651. // implements edge (Mine -> Render) makes types.rs a dependent of consumer.rs's struct.
  6652. expect(cg.getFileDependents('src/types.rs')).toContain('src/consumer.rs');
  6653. cg.destroy();
  6654. } finally { cleanupTempDir(dir); }
  6655. });
  6656. it('links `pub use` re-export hubs to the modules they re-export', async () => {
  6657. const dir = rustProject({
  6658. 'lib.rs': 'pub mod api;\n',
  6659. 'api/mod.rs': 'mod widget;\npub use self::widget::Widget;\n',
  6660. 'api/widget.rs': 'pub struct Widget { pub n: i32 }\n',
  6661. });
  6662. try {
  6663. const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } });
  6664. await cg.indexAll();
  6665. cg.resolveReferences();
  6666. // The re-export hub depends on the module it re-exports from.
  6667. expect(cg.getFileDependents('src/api/widget.rs')).toContain('src/api/mod.rs');
  6668. cg.destroy();
  6669. } finally { cleanupTempDir(dir); }
  6670. });
  6671. it('resolves a qualified path to the correct module when the leaf name collides', async () => {
  6672. const dir = rustProject({
  6673. 'lib.rs': 'pub mod fast;\npub mod slow;\npub mod hub;\n',
  6674. 'fast.rs': 'pub fn read() -> i32 { 1 }\n',
  6675. 'slow.rs': 'pub fn read() -> i32 { 2 }\n',
  6676. // `read` exists in BOTH fast.rs and slow.rs — module-path resolution must
  6677. // send this re-export to fast.rs specifically, not name-match either.
  6678. 'hub.rs': 'pub use crate::fast::read;\n',
  6679. });
  6680. try {
  6681. const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } });
  6682. await cg.indexAll();
  6683. cg.resolveReferences();
  6684. expect(cg.getFileDependents('src/fast.rs')).toContain('src/hub.rs');
  6685. expect(cg.getFileDependents('src/slow.rs')).not.toContain('src/hub.rs');
  6686. cg.destroy();
  6687. } finally { cleanupTempDir(dir); }
  6688. });
  6689. });
  6690. describe('Java annotations (blast-radius recall)', () => {
  6691. it('indexes @interface definitions and links @Annotation usages to them', async () => {
  6692. const dir = createTempDir();
  6693. try {
  6694. fs.mkdirSync(path.join(dir, 'p'), { recursive: true });
  6695. // The annotation DEFINITION must be a node, and the @MyAnno usages (which
  6696. // live inside a `modifiers` node on the class/field/method) must extract.
  6697. fs.writeFileSync(path.join(dir, 'p', 'MyAnno.java'), `package p;\npublic @interface MyAnno { String value() default ""; }\n`);
  6698. fs.writeFileSync(
  6699. path.join(dir, 'p', 'User.java'),
  6700. `package p;\n@MyAnno("c")\npublic class User {\n @MyAnno("f") int field;\n @MyAnno("m") void go() {}\n}\n`
  6701. );
  6702. const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.java'], exclude: [] } });
  6703. await cg.indexAll();
  6704. cg.resolveReferences();
  6705. expect(cg.getFileDependents('p/MyAnno.java')).toContain('p/User.java');
  6706. cg.destroy();
  6707. } finally { cleanupTempDir(dir); }
  6708. });
  6709. });
  6710. describe('Swift property wrappers / attributes (blast-radius recall)', () => {
  6711. it('links a @propertyWrapper usage to the wrapper type', async () => {
  6712. const dir = createTempDir();
  6713. try {
  6714. fs.mkdirSync(path.join(dir, 'Sources', 'M'), { recursive: true });
  6715. fs.writeFileSync(path.join(dir, 'Sources', 'M', 'Wrap.swift'), `@propertyWrapper\npublic struct Argument<T> { public var wrappedValue: T }\n`);
  6716. // `@Argument` is a Swift attribute on a stored property — it lives in the
  6717. // property's `modifiers` and Swift doesn't extract instance properties as
  6718. // their own nodes, so without the fix the wrapper type has no users.
  6719. fs.writeFileSync(path.join(dir, 'Sources', 'M', 'Cmd.swift'), `public struct MyCommand {\n @Argument var name: String\n @Argument var count: Int\n}\n`);
  6720. const cg = CodeGraph.initSync(dir, { config: { include: ['Sources/**/*.swift'], exclude: [] } });
  6721. await cg.indexAll();
  6722. cg.resolveReferences();
  6723. expect(cg.getFileDependents('Sources/M/Wrap.swift')).toContain('Sources/M/Cmd.swift');
  6724. cg.destroy();
  6725. } finally { cleanupTempDir(dir); }
  6726. });
  6727. });
  6728. describe('R Extraction', () => {
  6729. describe('Language detection', () => {
  6730. it('should detect R files (both extension cases)', () => {
  6731. expect(detectLanguage('analysis.R')).toBe('r');
  6732. expect(detectLanguage('scripts/clean.r')).toBe('r');
  6733. });
  6734. it('should report R as supported', () => {
  6735. expect(isLanguageSupported('r')).toBe(true);
  6736. expect(getSupportedLanguages()).toContain('r');
  6737. });
  6738. });
  6739. describe('Function extraction', () => {
  6740. it('extracts every assignment form, lambdas, and nested functions', () => {
  6741. const code = `
  6742. clean_data <- function(df, threshold = 0.5) {
  6743. helper <- function(d) scale(d)
  6744. helper(df)
  6745. }
  6746. normalize = function(v) (v - mean(v)) / sd(v)
  6747. double_it <- \\(x) x * 2
  6748. `;
  6749. const result = extractFromSource('analysis.R', code);
  6750. const funcs = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  6751. expect(funcs).toContain('clean_data');
  6752. expect(funcs).toContain('normalize');
  6753. expect(funcs).toContain('double_it');
  6754. expect(funcs).toContain('helper'); // nested, inside clean_data's scope
  6755. const cleanData = result.nodes.find((n) => n.name === 'clean_data');
  6756. expect(cleanData?.language).toBe('r');
  6757. expect(cleanData?.signature).toBe('(df, threshold = 0.5)');
  6758. });
  6759. it('attributes body calls to the enclosing function', () => {
  6760. const code = `
  6761. prep <- function(d) scale(d)
  6762. fit_model <- function(data) {
  6763. lm(y ~ x, data = prep(data))
  6764. }
  6765. `;
  6766. const result = extractFromSource('models.R', code);
  6767. const prepCall = result.unresolvedReferences.find(
  6768. (r) => r.referenceName === 'prep' && r.referenceKind === 'calls'
  6769. );
  6770. expect(prepCall).toBeDefined();
  6771. const fitModel = result.nodes.find((n) => n.name === 'fit_model');
  6772. expect(prepCall?.fromNodeId).toBe(fitModel?.id);
  6773. });
  6774. });
  6775. describe('Imports', () => {
  6776. it('extracts library/require/source as imports, not calls', () => {
  6777. const code = `
  6778. library(dplyr)
  6779. require(stats)
  6780. requireNamespace("jsonlite")
  6781. source("helpers.R")
  6782. `;
  6783. const result = extractFromSource('main.R', code);
  6784. const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  6785. expect(imports).toContain('dplyr');
  6786. expect(imports).toContain('stats');
  6787. expect(imports).toContain('jsonlite');
  6788. expect(imports).toContain('helpers.R');
  6789. // Claimed by the hook — no call references to the import machinery.
  6790. const libCalls = result.unresolvedReferences.filter(
  6791. (r) => r.referenceKind === 'calls' && (r.referenceName === 'library' || r.referenceName === 'source')
  6792. );
  6793. expect(libCalls).toHaveLength(0);
  6794. });
  6795. });
  6796. describe('Variables and constants', () => {
  6797. it('extracts top-level assignments; ALL_CAPS as constants; right-assign too', () => {
  6798. const code = `
  6799. ALPHA <- 0.05
  6800. max_iter = 100
  6801. compute_stats(df) -> stats_result
  6802. inner <- function() {
  6803. local_var <- 1
  6804. }
  6805. `;
  6806. const result = extractFromSource('config.R', code);
  6807. const constant = result.nodes.find((n) => n.name === 'ALPHA');
  6808. expect(constant?.kind).toBe('constant');
  6809. const variable = result.nodes.find((n) => n.name === 'max_iter');
  6810. expect(variable?.kind).toBe('variable');
  6811. const rightAssigned = result.nodes.find((n) => n.name === 'stats_result');
  6812. expect(rightAssigned?.kind).toBe('variable');
  6813. // Locals inside functions are deliberately NOT extracted.
  6814. expect(result.nodes.find((n) => n.name === 'local_var')).toBeUndefined();
  6815. });
  6816. });
  6817. describe('Classes', () => {
  6818. it('extracts S4/R5/R6 class calls as classes with their list methods', () => {
  6819. const code = `
  6820. setClass("Patient", representation(id = "character"))
  6821. Account <- setRefClass("Account",
  6822. fields = list(balance = "numeric"),
  6823. methods = list(deposit = function(x) { balance <<- balance + x })
  6824. )
  6825. Stack <- R6Class("Stack",
  6826. public = list(push = function(v) invisible(v))
  6827. )
  6828. setGeneric("describe", function(obj) standardGeneric("describe"))
  6829. setMethod("describe", "Patient", function(obj) paste(obj@id))
  6830. `;
  6831. const result = extractFromSource('classes.R', code);
  6832. const classes = result.nodes.filter((n) => n.kind === 'class').map((n) => n.name);
  6833. expect(classes).toContain('Patient');
  6834. expect(classes).toContain('Account');
  6835. expect(classes).toContain('Stack');
  6836. const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
  6837. expect(methods).toContain('deposit');
  6838. expect(methods).toContain('push');
  6839. // setGeneric/setMethod produce functions named by their string argument.
  6840. const describes = result.nodes.filter((n) => n.name === 'describe' && n.kind === 'function');
  6841. expect(describes.length).toBeGreaterThanOrEqual(2);
  6842. // The class-assignment idiom must not ALSO produce a variable node.
  6843. expect(result.nodes.find((n) => n.name === 'Account' && n.kind === 'variable')).toBeUndefined();
  6844. });
  6845. it('extracts ggproto classes with direct-arg methods and the parent as extends', () => {
  6846. // ggplot2's OO system — every Geom/Stat/Scale in the ecosystem uses it.
  6847. const code = `
  6848. GeomPoint <- ggproto("GeomPoint", Geom,
  6849. required_aes = c("x", "y"),
  6850. draw_panel = function(data, panel_params, coord) {
  6851. coords <- coord$transform(data, panel_params)
  6852. grid::pointsGrob(coords$x, coords$y)
  6853. },
  6854. draw_key = draw_key_point
  6855. )
  6856. `;
  6857. const result = extractFromSource('geom-point.R', code);
  6858. const cls = result.nodes.find((n) => n.name === 'GeomPoint' && n.kind === 'class');
  6859. expect(cls).toBeDefined();
  6860. const method = result.nodes.find((n) => n.name === 'draw_panel' && n.kind === 'method');
  6861. expect(method).toBeDefined();
  6862. const ext = result.unresolvedReferences.find(
  6863. (r) => r.referenceKind === 'extends' && r.referenceName === 'Geom'
  6864. );
  6865. expect(ext?.fromNodeId).toBe(cls?.id);
  6866. // No twin variable for the assignment.
  6867. expect(result.nodes.find((n) => n.name === 'GeomPoint' && n.kind === 'variable')).toBeUndefined();
  6868. });
  6869. });
  6870. });
  6871. // =============================================================================
  6872. // CFML (ColdFusion Markup Language — .cfc/.cfm tag-based and bare-script, .cfs)
  6873. // =============================================================================
  6874. describe('CFML Extraction', () => {
  6875. describe('Language detection', () => {
  6876. it('should detect .cfc/.cfm as cfml and .cfs as cfscript', () => {
  6877. expect(detectLanguage('Service.cfc')).toBe('cfml');
  6878. expect(detectLanguage('index.cfm')).toBe('cfml');
  6879. expect(detectLanguage('Helper.cfs')).toBe('cfscript');
  6880. });
  6881. it('should report cfml and cfscript as supported', () => {
  6882. expect(isLanguageSupported('cfml')).toBe(true);
  6883. expect(isLanguageSupported('cfscript')).toBe(true);
  6884. expect(getSupportedLanguages()).toContain('cfml');
  6885. expect(getSupportedLanguages()).toContain('cfscript');
  6886. });
  6887. });
  6888. describe('Bare-script .cfc (component { ... })', () => {
  6889. const code = `
  6890. component extends="BaseService" implements="IService" {
  6891. property name="name" type="string";
  6892. function init(required string name) {
  6893. variables.name = arguments.name;
  6894. return this;
  6895. }
  6896. public string function getName() {
  6897. return variables.name;
  6898. }
  6899. private void function logSomething(required string msg) {
  6900. writeLog(text=msg);
  6901. }
  6902. }
  6903. `;
  6904. it('should name the component from the file name (the grammar has no name field)', () => {
  6905. const result = extractFromSource('SampleService.cfc', code);
  6906. const cls = result.nodes.find((n) => n.kind === 'class');
  6907. expect(cls).toBeDefined();
  6908. expect(cls?.name).toBe('SampleService');
  6909. expect(cls?.language).toBe('cfml');
  6910. });
  6911. it('should extract methods with visibility and contains edges to the class', () => {
  6912. const result = extractFromSource('SampleService.cfc', code);
  6913. const cls = result.nodes.find((n) => n.kind === 'class');
  6914. const methods = result.nodes.filter((n) => n.kind === 'method');
  6915. expect(methods.map((m) => m.name)).toEqual(
  6916. expect.arrayContaining(['init', 'getName', 'logSomething'])
  6917. );
  6918. const logSomething = methods.find((m) => m.name === 'logSomething');
  6919. expect(logSomething?.visibility).toBe('private');
  6920. const containsLog = result.edges.find(
  6921. (e) => e.source === cls?.id && e.target === logSomething?.id && e.kind === 'contains'
  6922. );
  6923. expect(containsLog).toBeDefined();
  6924. });
  6925. it('should extract extends/implements as unresolved references from the class', () => {
  6926. const result = extractFromSource('SampleService.cfc', code);
  6927. const cls = result.nodes.find((n) => n.kind === 'class');
  6928. const extendsRef = result.unresolvedReferences.find((r) => r.referenceKind === 'extends');
  6929. expect(extendsRef?.referenceName).toBe('BaseService');
  6930. expect(extendsRef?.fromNodeId).toBe(cls?.id);
  6931. const implRef = result.unresolvedReferences.find((r) => r.referenceKind === 'implements');
  6932. expect(implRef?.referenceName).toBe('IService');
  6933. expect(implRef?.fromNodeId).toBe(cls?.id);
  6934. });
  6935. });
  6936. describe('Standalone .cfs (pure CFScript)', () => {
  6937. it('should also name an anonymous component from the file name', () => {
  6938. const code = `
  6939. component {
  6940. function ping() {
  6941. return "pong";
  6942. }
  6943. }
  6944. `;
  6945. const result = extractFromSource('Sample.cfs', code);
  6946. const cls = result.nodes.find((n) => n.kind === 'class');
  6947. expect(cls).toBeDefined();
  6948. expect(cls?.name).toBe('Sample');
  6949. expect(cls?.language).toBe('cfscript');
  6950. });
  6951. it('should extract top-level imports with no enclosing component', () => {
  6952. const code = `
  6953. import com.foo.Bar;
  6954. import foo.cfm;
  6955. `;
  6956. const result = extractFromSource('Includes.cfs', code);
  6957. const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  6958. expect(imports).toContain('com.foo.Bar');
  6959. expect(imports).toContain('foo.cfm');
  6960. });
  6961. });
  6962. describe('Tag-based .cfc (<cfcomponent>/<cffunction>)', () => {
  6963. const code = `<cfcomponent extends="Base" implements="IFoo,IBar" output="false">
  6964. \t<cffunction name="getName" access="public" returntype="string">
  6965. \t\t<cfreturn this.name>
  6966. \t</cffunction>
  6967. \t<cffunction name="doWork" access="private" returntype="void">
  6968. \t\t<cfscript>
  6969. \t\t\tvar x = helper();
  6970. \t\t\tanotherCall(x);
  6971. \t\t</cfscript>
  6972. \t</cffunction>
  6973. </cfcomponent>
  6974. `;
  6975. it('should name the component from the file name when the tag has no name attribute', () => {
  6976. const result = extractFromSource('TagStyle.cfc', code);
  6977. const cls = result.nodes.find((n) => n.kind === 'class');
  6978. expect(cls?.name).toBe('TagStyle');
  6979. expect(cls?.language).toBe('cfml');
  6980. });
  6981. it('should prefer an explicit name attribute on the cfcomponent tag', () => {
  6982. const named = `<cfcomponent name="ExplicitName">\n<cffunction name="a"><cfreturn 1></cffunction>\n</cfcomponent>`;
  6983. const result = extractFromSource('File.cfc', named);
  6984. expect(result.nodes.find((n) => n.kind === 'class')?.name).toBe('ExplicitName');
  6985. });
  6986. it('should extract cffunction tags as methods with access-derived visibility', () => {
  6987. const result = extractFromSource('TagStyle.cfc', code);
  6988. const methods = result.nodes.filter((n) => n.kind === 'method');
  6989. expect(methods.map((m) => m.name)).toEqual(expect.arrayContaining(['getName', 'doWork']));
  6990. const getName = methods.find((m) => m.name === 'getName');
  6991. expect(getName?.visibility).toBe('public');
  6992. expect(getName?.returnType).toBe('string');
  6993. const doWork = methods.find((m) => m.name === 'doWork');
  6994. expect(doWork?.visibility).toBe('private');
  6995. });
  6996. it('should not double-extract symbols from the component body (implicit-end-tag walk)', () => {
  6997. const result = extractFromSource('TagStyle.cfc', code);
  6998. const methods = result.nodes.filter((n) => n.kind === 'method' && n.name === 'getName');
  6999. expect(methods).toHaveLength(1);
  7000. const doWorkMethods = result.nodes.filter((n) => n.kind === 'method' && n.name === 'doWork');
  7001. expect(doWorkMethods).toHaveLength(1);
  7002. });
  7003. it('should delegate <cfscript> tag bodies to the cfscript grammar and attribute calls to the enclosing method', () => {
  7004. const result = extractFromSource('TagStyle.cfc', code);
  7005. const doWork = result.nodes.find((n) => n.kind === 'method' && n.name === 'doWork');
  7006. const helperCall = result.unresolvedReferences.find(
  7007. (r) => r.referenceKind === 'calls' && r.referenceName === 'helper'
  7008. );
  7009. expect(helperCall?.fromNodeId).toBe(doWork?.id);
  7010. });
  7011. it('should produce exactly one correctly-ranged file node, not a leaked snippet-scoped one', () => {
  7012. const result = extractFromSource('TagStyle.cfc', code);
  7013. const fileNodes = result.nodes.filter((n) => n.kind === 'file');
  7014. expect(fileNodes).toHaveLength(1);
  7015. expect(fileNodes[0].startLine).toBe(1);
  7016. const cls = result.nodes.find((n) => n.kind === 'class');
  7017. const containsClass = result.edges.find(
  7018. (e) => e.source === fileNodes[0].id && e.target === cls?.id && e.kind === 'contains'
  7019. );
  7020. expect(containsClass).toBeDefined();
  7021. });
  7022. });
  7023. describe('Top-level cffunction with no enclosing cfcomponent (.cfm template)', () => {
  7024. it('should extract as a top-level function contained by the file', () => {
  7025. const code = `<cffunction name="helper" access="public" returntype="string">
  7026. \t<cfreturn "hi">
  7027. </cffunction>
  7028. `;
  7029. const result = extractFromSource('helper.cfm', code);
  7030. const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'helper');
  7031. expect(fn).toBeDefined();
  7032. const fileNode = result.nodes.find((n) => n.kind === 'file');
  7033. const containsFn = result.edges.find(
  7034. (e) => e.source === fileNode?.id && e.target === fn?.id && e.kind === 'contains'
  7035. );
  7036. expect(containsFn).toBeDefined();
  7037. });
  7038. });
  7039. describe('<cfscript> nested inside control-flow tags (<cfif>/<cfloop>/<cftry>)', () => {
  7040. it('should delegate a <cfscript> body nested inside <cfif> within a <cffunction>', () => {
  7041. const code = `<cfcomponent>
  7042. <cffunction name="doStuff">
  7043. <cfif true>
  7044. <cfscript>
  7045. helper();
  7046. </cfscript>
  7047. </cfif>
  7048. </cffunction>
  7049. </cfcomponent>
  7050. `;
  7051. const result = extractFromSource('Nested.cfc', code);
  7052. const doStuff = result.nodes.find((n) => n.kind === 'method' && n.name === 'doStuff');
  7053. expect(doStuff).toBeDefined();
  7054. const helperCall = result.unresolvedReferences.find(
  7055. (r) => r.referenceKind === 'calls' && r.referenceName === 'helper'
  7056. );
  7057. expect(helperCall?.fromNodeId).toBe(doStuff?.id);
  7058. });
  7059. it('should delegate a <cfscript> body nested inside <cfif> at top-level component scope', () => {
  7060. const code = `<cfcomponent>
  7061. <cfif true>
  7062. <cfscript>
  7063. topLevelHelper();
  7064. </cfscript>
  7065. </cfif>
  7066. </cfcomponent>
  7067. `;
  7068. const result = extractFromSource('Nested2.cfc', code);
  7069. const cls = result.nodes.find((n) => n.kind === 'class');
  7070. expect(cls).toBeDefined();
  7071. const helperCall = result.unresolvedReferences.find(
  7072. (r) => r.referenceKind === 'calls' && r.referenceName === 'topLevelHelper'
  7073. );
  7074. expect(helperCall?.fromNodeId).toBe(cls?.id);
  7075. });
  7076. });
  7077. describe('<cfquery> SQL bodies (cfquery grammar)', () => {
  7078. it('should extract a call expression embedded in a #hash# inside the SQL body', () => {
  7079. const code = `<cfcomponent>
  7080. <cffunction name="getUsers">
  7081. <cfquery name="qUsers" datasource="#variables.dsn#">
  7082. SELECT id, name FROM users WHERE owner = #getCurrentUser().getId()#
  7083. </cfquery>
  7084. <cfreturn qUsers>
  7085. </cffunction>
  7086. </cfcomponent>
  7087. `;
  7088. const result = extractFromSource('Query.cfc', code);
  7089. const getUsers = result.nodes.find((n) => n.kind === 'method' && n.name === 'getUsers');
  7090. expect(getUsers).toBeDefined();
  7091. const getCurrentUserCall = result.unresolvedReferences.find(
  7092. (r) => r.referenceKind === 'calls' && r.referenceName === 'getCurrentUser'
  7093. );
  7094. expect(getCurrentUserCall?.fromNodeId).toBe(getUsers?.id);
  7095. const getIdCall = result.unresolvedReferences.find(
  7096. (r) => r.referenceKind === 'calls' && r.referenceName === 'getId'
  7097. );
  7098. expect(getIdCall?.fromNodeId).toBe(getUsers?.id);
  7099. });
  7100. });
  7101. describe('UTF-8 BOM handling (common in CFML saved by Windows editors)', () => {
  7102. it('should route a BOM-prefixed tag-based .cfc to the tag grammar, not the script grammar', () => {
  7103. const code = `\uFEFF<cfcomponent output="false">\n<cffunction name="configure" access="public">\n<cfreturn 1>\n</cffunction>\n</cfcomponent>\n`;
  7104. const result = extractFromSource('ModuleConfig.cfc', code);
  7105. const cls = result.nodes.find((n) => n.kind === 'class');
  7106. expect(cls?.name).toBe('ModuleConfig');
  7107. const configure = result.nodes.find((n) => n.kind === 'method' && n.name === 'configure');
  7108. expect(configure).toBeDefined();
  7109. });
  7110. it('should still treat a BOM-prefixed bare-script .cfc as script', () => {
  7111. const code = `\uFEFFcomponent {\n function ping() { return "pong"; }\n}\n`;
  7112. const result = extractFromSource('Ping.cfc', code);
  7113. expect(result.nodes.find((n) => n.kind === 'class')?.name).toBe('Ping');
  7114. expect(result.nodes.find((n) => n.kind === 'method')?.name).toBe('ping');
  7115. });
  7116. });
  7117. describe('Unquoted tag attribute values (legal in older CFML)', () => {
  7118. it('should extract functions and inheritance from unquoted attributes', () => {
  7119. const code = `<cfcomponent extends=Base>\n<cffunction name=doThing access=private>\n<cfreturn 1>\n</cffunction>\n</cfcomponent>\n`;
  7120. const result = extractFromSource('Unquoted.cfc', code);
  7121. const doThing = result.nodes.find((n) => n.kind === 'method' && n.name === 'doThing');
  7122. expect(doThing).toBeDefined();
  7123. expect(doThing?.visibility).toBe('private');
  7124. const extendsRef = result.unresolvedReferences.find((r) => r.referenceKind === 'extends');
  7125. expect(extendsRef?.referenceName).toBe('Base');
  7126. });
  7127. });
  7128. describe('Functions in a component-level <cfscript> block', () => {
  7129. it('should classify them as methods of the component (ColdBox ModuleConfig shape)', () => {
  7130. const code = `<cfcomponent output="false">\n<cfscript>\nfunction configure() {\n return settings();\n}\nfunction onLoad() {\n return 1;\n}\n</cfscript>\n</cfcomponent>\n`;
  7131. const result = extractFromSource('ModuleConfig.cfc', code);
  7132. const cls = result.nodes.find((n) => n.kind === 'class');
  7133. const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
  7134. expect(methods).toEqual(expect.arrayContaining(['configure', 'onLoad']));
  7135. expect(result.nodes.filter((n) => n.kind === 'function')).toHaveLength(0);
  7136. const configure = result.nodes.find((n) => n.kind === 'method' && n.name === 'configure');
  7137. const containsEdge = result.edges.find(
  7138. (e) => e.source === cls?.id && e.target === configure?.id && e.kind === 'contains'
  7139. );
  7140. expect(containsEdge).toBeDefined();
  7141. });
  7142. it('should keep kind function for a <cfscript> inside a cffunction body', () => {
  7143. const code = `<cfcomponent>\n<cffunction name="outer">\n<cfscript>\nfunction innerHelper() { return 1; }\n</cfscript>\n</cffunction>\n</cfcomponent>\n`;
  7144. const result = extractFromSource('Outer.cfc', code);
  7145. expect(result.nodes.find((n) => n.name === 'outer')?.kind).toBe('method');
  7146. expect(result.nodes.find((n) => n.name === 'innerHelper')?.kind).toBe('function');
  7147. });
  7148. });
  7149. });
  7150. describe('COBOL Extraction', () => {
  7151. it('should detect .cbl/.cob/.cpy as cobol (case-insensitive)', () => {
  7152. expect(detectLanguage('app/cbl/CBACT01C.cbl')).toBe('cobol');
  7153. expect(detectLanguage('app/cbl/CBSTM03A.CBL')).toBe('cobol');
  7154. expect(detectLanguage('prog.cob')).toBe('cobol');
  7155. expect(detectLanguage('app/cpy/CVACT01Y.cpy')).toBe('cobol');
  7156. expect(isSourceFile('CBACT01C.cbl')).toBe(true);
  7157. });
  7158. const FIXED = (body: string) =>
  7159. body
  7160. .split('\n')
  7161. .map((l) => (l.length > 0 ? ' ' + l : l))
  7162. .join('\n');
  7163. const PROGRAM = FIXED(`IDENTIFICATION DIVISION.
  7164. PROGRAM-ID. TESTPROG.
  7165. DATA DIVISION.
  7166. WORKING-STORAGE SECTION.
  7167. 01 WS-TOTALS.
  7168. 05 WS-COUNT PIC 9(4) VALUE ZERO.
  7169. 88 WS-DONE VALUE 'Y'.
  7170. 77 WS-FLAG PIC X.
  7171. COPY CVACT01Y.
  7172. PROCEDURE DIVISION.
  7173. MAIN-SECTION SECTION.
  7174. 0000-MAIN.
  7175. PERFORM 1000-INIT
  7176. PERFORM 2000-PROCESS THRU 2000-EXIT
  7177. CALL 'CBACT01C' USING WS-TOTALS
  7178. CALL WS-FLAG
  7179. GO TO 9999-END
  7180. .
  7181. 1000-INIT.
  7182. MOVE ZERO TO WS-COUNT.
  7183. 2000-PROCESS.
  7184. EXEC CICS LINK PROGRAM('COCOM01C') COMMAREA(WS-TOTALS)
  7185. END-EXEC.
  7186. 2000-EXIT.
  7187. EXIT.
  7188. 9999-END.
  7189. GOBACK.
  7190. `);
  7191. it('should extract the program as a module node', () => {
  7192. const result = extractFromSource('TESTPROG.cbl', PROGRAM);
  7193. const moduleNode = result.nodes.find((n) => n.kind === 'module');
  7194. expect(moduleNode).toBeDefined();
  7195. expect(moduleNode?.name).toBe('TESTPROG');
  7196. });
  7197. it('should extract sections and paragraphs as functions with reconstructed extents', () => {
  7198. const result = extractFromSource('TESTPROG.cbl', PROGRAM);
  7199. const fns = result.nodes.filter((n) => n.kind === 'function');
  7200. const names = fns.map((f) => f.name);
  7201. expect(names).toContain('MAIN-SECTION');
  7202. expect(names).toContain('0000-MAIN');
  7203. expect(names).toContain('2000-PROCESS');
  7204. // A paragraph spans from its header to the next header, not just one line.
  7205. const main = fns.find((f) => f.name === '0000-MAIN');
  7206. expect(main).toBeDefined();
  7207. expect(main!.endLine).toBeGreaterThan(main!.startLine + 3);
  7208. // Paragraphs are contained in their section (qualified name includes it).
  7209. expect(main!.qualifiedName).toContain('MAIN-SECTION');
  7210. });
  7211. it('should extract PERFORM, PERFORM THRU, GO TO, and CALL literal as calls references', () => {
  7212. const result = extractFromSource('TESTPROG.cbl', PROGRAM);
  7213. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
  7214. const targets = calls.map((c) => c.referenceName);
  7215. expect(targets).toContain('1000-INIT');
  7216. expect(targets).toContain('2000-PROCESS'); // PERFORM ... THRU start
  7217. expect(targets).toContain('2000-EXIT'); // PERFORM ... THRU end
  7218. expect(targets).toContain('9999-END'); // GO TO
  7219. expect(targets).toContain('CBACT01C'); // CALL 'literal'
  7220. expect(targets).toContain('COCOM01C'); // EXEC CICS LINK PROGRAM('...')
  7221. // Dynamic CALL through a data name is skipped — announce, don't guess.
  7222. expect(targets).not.toContain('WS-FLAG');
  7223. });
  7224. it('should extract COPY as an import node and imports reference', () => {
  7225. const result = extractFromSource('TESTPROG.cbl', PROGRAM);
  7226. const importNode = result.nodes.find((n) => n.kind === 'import');
  7227. expect(importNode).toBeDefined();
  7228. expect(importNode?.name).toBe('CVACT01Y');
  7229. const importRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'imports');
  7230. expect(importRefs.map((r) => r.referenceName)).toContain('CVACT01Y');
  7231. });
  7232. it('should extract data items as variables, fields, and 88-level constants', () => {
  7233. const result = extractFromSource('TESTPROG.cbl', PROGRAM);
  7234. const group = result.nodes.find((n) => n.name === 'WS-TOTALS');
  7235. expect(group?.kind).toBe('variable');
  7236. const nested = result.nodes.find((n) => n.name === 'WS-COUNT');
  7237. expect(nested?.kind).toBe('field');
  7238. expect(nested?.qualifiedName).toContain('WS-TOTALS');
  7239. const condition = result.nodes.find((n) => n.name === 'WS-DONE');
  7240. expect(condition?.kind).toBe('constant');
  7241. const standalone = result.nodes.find((n) => n.name === 'WS-FLAG');
  7242. expect(standalone?.kind).toBe('variable');
  7243. });
  7244. it('should extract EXEC SQL INCLUDE as an import', () => {
  7245. const code = FIXED(`IDENTIFICATION DIVISION.
  7246. PROGRAM-ID. SQLPROG.
  7247. DATA DIVISION.
  7248. WORKING-STORAGE SECTION.
  7249. EXEC SQL INCLUDE SQLCA END-EXEC.
  7250. 01 WS-X PIC X.
  7251. PROCEDURE DIVISION.
  7252. P1.
  7253. GOBACK.
  7254. `);
  7255. const result = extractFromSource('SQLPROG.cbl', code);
  7256. const importNode = result.nodes.find((n) => n.kind === 'import' && n.name === 'SQLCA');
  7257. expect(importNode).toBeDefined();
  7258. // The EXEC block must not break the rest of the file.
  7259. expect(result.nodes.find((n) => n.name === 'WS-X')).toBeDefined();
  7260. });
  7261. it('should extract a standalone data copybook (.cpy fragment)', () => {
  7262. const code = FIXED(`01 ACCOUNT-RECORD.
  7263. 05 ACCT-ID PIC 9(11).
  7264. 05 ACCT-CURR-BAL PIC S9(10)V99.
  7265. `);
  7266. const result = extractFromSource('CVACT01Y.cpy', code);
  7267. const record = result.nodes.find((n) => n.name === 'ACCOUNT-RECORD');
  7268. expect(record?.kind).toBe('variable');
  7269. const field = result.nodes.find((n) => n.name === 'ACCT-ID');
  7270. expect(field?.kind).toBe('field');
  7271. });
  7272. it('should extract a procedure copybook (.cpy fragment with paragraphs)', () => {
  7273. const code = FIXED(`EDIT-DATE.
  7274. MOVE 1 TO WS-X
  7275. PERFORM VALIDATE-YEAR.
  7276. VALIDATE-YEAR.
  7277. CONTINUE.
  7278. `);
  7279. const result = extractFromSource('CSUTLDPY.cpy', code);
  7280. const fns = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  7281. expect(fns).toContain('EDIT-DATE');
  7282. expect(fns).toContain('VALIDATE-YEAR');
  7283. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
  7284. expect(calls.map((c) => c.referenceName)).toContain('VALIDATE-YEAR');
  7285. });
  7286. it('should emit write-site references for MOVE/ADD/COMPUTE targets', () => {
  7287. const code = FIXED(`IDENTIFICATION DIVISION.
  7288. PROGRAM-ID. WRITEREF.
  7289. DATA DIVISION.
  7290. WORKING-STORAGE SECTION.
  7291. 01 WS-TOTAL PIC S9(7)V99.
  7292. 01 WS-COUNT PIC 9(4).
  7293. PROCEDURE DIVISION.
  7294. P1.
  7295. MOVE ZERO TO WS-TOTAL
  7296. ADD 1 TO WS-COUNT
  7297. COMPUTE WS-TOTAL = WS-TOTAL + 1
  7298. SUBTRACT 1 FROM WS-COUNT
  7299. MOVE 1 TO RETURN-CODE.
  7300. `);
  7301. const result = extractFromSource('WRITEREF.cbl', code);
  7302. const writes = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
  7303. const names = writes.map((w) => w.referenceName);
  7304. expect(names.filter((n) => n === 'WS-TOTAL').length).toBeGreaterThanOrEqual(2); // MOVE + COMPUTE
  7305. expect(names).toContain('WS-COUNT'); // ADD and SUBTRACT targets
  7306. // Special registers carry no declaration — never referenced.
  7307. expect(names).not.toContain('RETURN-CODE');
  7308. });
  7309. it('should emit cics-transid references for RETURN TRANSID, literal and via same-file VALUE', () => {
  7310. const code = FIXED(`IDENTIFICATION DIVISION.
  7311. PROGRAM-ID. TXPROG.
  7312. DATA DIVISION.
  7313. WORKING-STORAGE SECTION.
  7314. 01 WS-TRANID PIC X(04) VALUE 'CB00'.
  7315. PROCEDURE DIVISION.
  7316. P1.
  7317. EXEC CICS RETURN TRANSID('CC00') COMMAREA(WS-X) END-EXEC
  7318. .
  7319. P2.
  7320. EXEC CICS RETURN TRANSID(WS-TRANID) END-EXEC
  7321. .
  7322. P3.
  7323. EXEC CICS XCTL PROGRAM(WS-UNKNOWN-VAR) END-EXEC
  7324. .
  7325. `);
  7326. const result = extractFromSource('TXPROG.cbl', code);
  7327. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
  7328. const names = calls.map((c) => c.referenceName);
  7329. expect(names).toContain('cics-transid:CC00'); // literal
  7330. expect(names).toContain('cics-transid:CB00'); // dereferenced through WS-TRANID
  7331. // Un-derefable program variable: dynamic dispatch, no guessed edge.
  7332. expect(names.filter((n) => n.startsWith('cics-transid:')).length).toBe(2);
  7333. });
  7334. it('should shift free-format source so it still extracts (preParse)', () => {
  7335. const code = `IDENTIFICATION DIVISION.
  7336. PROGRAM-ID. FREEPROG.
  7337. PROCEDURE DIVISION.
  7338. DO-WORK.
  7339. DISPLAY 'HI'.
  7340. `;
  7341. const result = extractFromSource('freeprog.cbl', code);
  7342. expect(result.nodes.find((n) => n.kind === 'module')?.name).toBe('FREEPROG');
  7343. expect(result.nodes.find((n) => n.kind === 'function')?.name).toBe('DO-WORK');
  7344. });
  7345. });
  7346. // =============================================================================
  7347. // VB.NET (.vb) — vendored patched govindbanura/tree-sitter-vbnet grammar
  7348. // =============================================================================
  7349. describe('VB.NET Extraction', () => {
  7350. it('should detect .vb as vbnet', () => {
  7351. expect(detectLanguage('Service.vb')).toBe('vbnet');
  7352. expect(detectLanguage('app/Forms/MainForm.vb')).toBe('vbnet');
  7353. expect(isSourceFile('Service.vb')).toBe(true);
  7354. });
  7355. const SAMPLE = `Imports System
  7356. Imports System.Collections.Generic
  7357. Namespace Acme.Billing
  7358. Public Interface IRepository
  7359. Function GetById(ByVal id As Integer) As Invoice
  7360. End Interface
  7361. Public Enum InvoiceState
  7362. Draft = 0
  7363. Sent
  7364. Paid
  7365. End Enum
  7366. Public Structure Money
  7367. Public Amount As Decimal
  7368. End Structure
  7369. Public MustInherit Class EntityBase
  7370. Public Property Id As Integer
  7371. End Class
  7372. Public Class Invoice
  7373. Inherits EntityBase
  7374. Implements IRepository
  7375. Private ReadOnly _lines As New List(Of String)
  7376. Public Const MaxLines As Integer = 100
  7377. Public Event Paid(ByVal amount As Decimal)
  7378. Public Property State As InvoiceState
  7379. Public Sub New(ByVal id As Integer)
  7380. Me.Id = id
  7381. End Sub
  7382. Public Function GetById(ByVal id As Integer) As Invoice Implements IRepository.GetById
  7383. Return New Invoice(id)
  7384. End Function
  7385. Public Sub AddLine(ByVal description As String)
  7386. _lines.Add(description)
  7387. Validate(description)
  7388. End Sub
  7389. Private Sub Validate(ByVal text As String)
  7390. If text.Length > MaxLines Then Throw New ArgumentException("too long")
  7391. End Sub
  7392. End Class
  7393. ' lowercase keywords: VB is case-insensitive
  7394. public module Helpers
  7395. public function Twice(byval n as integer) as integer
  7396. return n * 2
  7397. end function
  7398. Public Sub Run()
  7399. Dim inv = New Invoice(1)
  7400. inv.AddLine("widget")
  7401. Dim d As New Dictionary(Of String, Integer)
  7402. Helpers.Twice(21)
  7403. End Sub
  7404. end module
  7405. End Namespace
  7406. `;
  7407. it('should extract classes, modules, interfaces, structures, and enums', () => {
  7408. const result = extractFromSource('Invoice.vb', SAMPLE);
  7409. const kinds = (kind: string) => result.nodes.filter((n) => n.kind === kind).map((n) => n.name);
  7410. expect(kinds('class')).toEqual(expect.arrayContaining(['EntityBase', 'Invoice', 'Helpers']));
  7411. expect(kinds('interface')).toContain('IRepository');
  7412. expect(kinds('struct')).toContain('Money');
  7413. expect(kinds('enum')).toContain('InvoiceState');
  7414. expect(kinds('enum_member')).toEqual(expect.arrayContaining(['Draft', 'Sent', 'Paid']));
  7415. });
  7416. it('should extract methods, constructors, properties, fields, and events (case-insensitive keywords)', () => {
  7417. const result = extractFromSource('Invoice.vb', SAMPLE);
  7418. const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
  7419. expect(methods).toEqual(expect.arrayContaining(['GetById', 'AddLine', 'Validate', 'Twice', 'Run']));
  7420. const props = result.nodes.filter((n) => n.kind === 'property').map((n) => n.name);
  7421. expect(props).toEqual(expect.arrayContaining(['Id', 'State']));
  7422. const fields = result.nodes.filter((n) => n.kind === 'field' || n.kind === 'constant').map((n) => n.name);
  7423. expect(fields).toEqual(expect.arrayContaining(['_lines', 'MaxLines']));
  7424. // Event declarations index as findable members
  7425. expect(fields).toContain('Paid');
  7426. });
  7427. it('should qualify types with their namespace', () => {
  7428. const result = extractFromSource('Invoice.vb', SAMPLE);
  7429. const invoice = result.nodes.find((n) => n.kind === 'class' && n.name === 'Invoice');
  7430. expect(invoice?.qualifiedName).toContain('Acme.Billing');
  7431. });
  7432. it('should emit Inherits as extends and Implements as implements references', () => {
  7433. const result = extractFromSource('Invoice.vb', SAMPLE);
  7434. const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
  7435. expect(extendsRefs.map((r) => r.referenceName)).toContain('EntityBase');
  7436. const implementsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'implements');
  7437. expect(implementsRefs.map((r) => r.referenceName)).toContain('IRepository');
  7438. });
  7439. it('should extract calls through both invocation and index-shaped parens', () => {
  7440. const result = extractFromSource('Invoice.vb', SAMPLE);
  7441. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7442. // `_lines.Add(description)` parses as array_access (non-empty parens) — still a call site
  7443. expect(calls).toContain('_lines.Add');
  7444. // bare call with args
  7445. expect(calls).toContain('Validate');
  7446. // qualified module call
  7447. expect(calls).toContain('Helpers.Twice');
  7448. });
  7449. it('should emit instantiates for New, with VB generic syntax stripped', () => {
  7450. const result = extractFromSource('Invoice.vb', SAMPLE);
  7451. const insts = result.unresolvedReferences.filter((r) => r.referenceKind === 'instantiates').map((r) => r.referenceName);
  7452. expect(insts).toContain('Invoice');
  7453. // `As New Dictionary(Of String, Integer)` → bare type name, not `Dictionary(Of ...)`
  7454. expect(insts.some((n) => n.includes('(') || /\bOf\b/.test(n))).toBe(false);
  7455. });
  7456. it('should extract Imports as import nodes', () => {
  7457. const result = extractFromSource('Invoice.vb', SAMPLE);
  7458. const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  7459. expect(imports).toEqual(expect.arrayContaining(['System', 'System.Collections.Generic']));
  7460. });
  7461. it('should parse a file without a trailing newline (preParse guard)', () => {
  7462. const code = 'Class Tail\n Sub Go()\n Log("x")\n End Sub\nEnd Class';
  7463. const result = extractFromSource('Tail.vb', code);
  7464. expect(result.nodes.find((n) => n.kind === 'class')?.name).toBe('Tail');
  7465. expect(result.nodes.find((n) => n.kind === 'method')?.name).toBe('Go');
  7466. });
  7467. });
  7468. describe('VB.NET Extraction — scanner-backed constructs', () => {
  7469. it('should parse XML literals as opaque literals without breaking siblings', () => {
  7470. const code = `Class Muxer
  7471. Function WriteTags() As Object
  7472. Dim xml = <Tags>
  7473. <%= From tag In Tags Select <Tag><Name><%= tag.Name %></Name></Tag> %>
  7474. </Tags>
  7475. Return xml
  7476. End Function
  7477. Sub After()
  7478. Log("still extracted")
  7479. End Sub
  7480. End Class
  7481. `;
  7482. const result = extractFromSource('Muxer.vb', code);
  7483. const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
  7484. expect(methods).toEqual(expect.arrayContaining(['WriteTags', 'After']));
  7485. });
  7486. it('should parse multi-line LINQ query clauses', () => {
  7487. const code = `Class T
  7488. Function Big() As Integer
  7489. Dim big = From l In _lines
  7490. Where l.Length > 3
  7491. Select l.Length
  7492. Return big.Sum()
  7493. End Function
  7494. End Class
  7495. `;
  7496. const result = extractFromSource('Linq.vb', code);
  7497. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7498. expect(calls).toContain('big.Sum');
  7499. expect(result.nodes.find((n) => n.kind === 'method')?.name).toBe('Big');
  7500. });
  7501. it('should extract MustOverride members without derailing following members', () => {
  7502. const code = `MustInherit Class VideoEncoder
  7503. MustOverride ReadOnly Property OutputExt As String
  7504. Public MustOverride Sub ShowConfigDialog(Optional param As Object = Nothing)
  7505. MustOverride Function GetError() As String
  7506. Sub New()
  7507. CanEdit = True
  7508. End Sub
  7509. End Class
  7510. `;
  7511. const result = extractFromSource('VideoEncoder.vb', code);
  7512. const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
  7513. expect(methods).toEqual(expect.arrayContaining(['ShowConfigDialog', 'GetError', 'New']));
  7514. const props = result.nodes.filter((n) => n.kind === 'property').map((n) => n.name);
  7515. expect(props).toContain('OutputExt');
  7516. });
  7517. it('should parse nullable declarator shorthand (Dim x? = expr)', () => {
  7518. const code = `Class T
  7519. Sub M(folderInfo As Object)
  7520. Dim SteamFolderData? = Parser.GetSteamNameAndID(folderInfo)
  7521. Use(SteamFolderData)
  7522. End Sub
  7523. End Class
  7524. `;
  7525. const result = extractFromSource('Factory.vb', code);
  7526. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7527. expect(calls).toContain('Parser.GetSteamNameAndID');
  7528. });
  7529. });
  7530. // =============================================================================
  7531. // Erlang (vendored WhatsApp/tree-sitter-erlang grammar — the ELP grammar)
  7532. // =============================================================================
  7533. describe('Erlang Extraction', () => {
  7534. describe('Language detection', () => {
  7535. it('should report Erlang as supported', () => {
  7536. expect(isLanguageSupported('erlang')).toBe(true);
  7537. expect(getSupportedLanguages()).toContain('erlang');
  7538. expect(isSourceFile('apps/app/src/foo.erl')).toBe(true);
  7539. expect(isSourceFile('include/foo.hrl')).toBe(true);
  7540. });
  7541. });
  7542. describe('Function extraction', () => {
  7543. it('should merge multi-clause functions into one node spanning all clauses', () => {
  7544. const code = `-module(m).
  7545. -export([classify/1]).
  7546. classify(X) when is_atom(X) ->
  7547. atom;
  7548. classify(X) when is_binary(X) ->
  7549. binary;
  7550. classify(_X) ->
  7551. other.
  7552. `;
  7553. const result = extractFromSource('src/m.erl', code);
  7554. const fns = result.nodes.filter((n) => n.kind === 'function' && n.name === 'classify');
  7555. expect(fns).toHaveLength(1);
  7556. expect(fns[0]!.startLine).toBe(4);
  7557. expect(fns[0]!.endLine).toBe(9);
  7558. expect(fns[0]!.language).toBe('erlang');
  7559. });
  7560. it('should qualify functions with the module namespace', () => {
  7561. const code = `-module(my_server).
  7562. -export([start/0]).
  7563. start() -> ok.
  7564. helper() -> ok.
  7565. `;
  7566. const result = extractFromSource('src/my_server.erl', code);
  7567. const ns = result.nodes.find((n) => n.kind === 'namespace');
  7568. expect(ns?.name).toBe('my_server');
  7569. const start = result.nodes.find((n) => n.kind === 'function' && n.name === 'start');
  7570. expect(start?.qualifiedName).toBe('my_server::start');
  7571. });
  7572. it('should flag exported functions and honor -compile(export_all)', () => {
  7573. const code = `-module(m).
  7574. -export([api/0]).
  7575. api() -> internal().
  7576. internal() -> ok.
  7577. `;
  7578. const result = extractFromSource('src/m.erl', code);
  7579. const api = result.nodes.find((n) => n.name === 'api');
  7580. const internal = result.nodes.find((n) => n.name === 'internal');
  7581. expect(api?.isExported).toBe(true);
  7582. expect(internal?.isExported).toBe(false);
  7583. const all = extractFromSource('src/all.erl', `-module(all).
  7584. -compile(export_all).
  7585. anything() -> ok.
  7586. `);
  7587. expect(all.nodes.find((n) => n.name === 'anything')?.isExported).toBe(true);
  7588. });
  7589. it('should use the preceding -spec as the signature and capture doc comments', () => {
  7590. const code = `-module(m).
  7591. %% Fetches a value by key.
  7592. -spec fetch(binary()) -> {ok, term()} | not_found.
  7593. fetch(Key) ->
  7594. lookup(Key).
  7595. lookup(_K) -> not_found.
  7596. `;
  7597. const result = extractFromSource('src/m.erl', code);
  7598. const fetch = result.nodes.find((n) => n.name === 'fetch');
  7599. expect(fetch?.signature).toBe('-spec fetch(binary()) -> {ok, term()} | not_found.');
  7600. expect(fetch?.docstring).toBe('Fetches a value by key.');
  7601. });
  7602. it('should fall back to the clause header as the signature', () => {
  7603. const code = `-module(m).
  7604. resize(W, H) when W > 0, H > 0 ->
  7605. {W, H}.
  7606. `;
  7607. const result = extractFromSource('src/m.erl', code);
  7608. const resize = result.nodes.find((n) => n.name === 'resize');
  7609. expect(resize?.signature).toBe('resize(W, H) when W > 0, H > 0');
  7610. });
  7611. });
  7612. describe('Record and type extraction', () => {
  7613. it('should extract records as structs with fields', () => {
  7614. const code = `-module(m).
  7615. -record(state, {
  7616. store = #{} :: map(),
  7617. counter = 0 :: non_neg_integer()
  7618. }).
  7619. `;
  7620. const result = extractFromSource('src/m.erl', code);
  7621. const rec = result.nodes.find((n) => n.kind === 'struct');
  7622. expect(rec?.name).toBe('state');
  7623. const fields = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name);
  7624. expect(fields).toContain('store');
  7625. expect(fields).toContain('counter');
  7626. });
  7627. it('should extract -type and -opaque as type aliases, without bogus type-call refs', () => {
  7628. const code = `-module(m).
  7629. -type key() :: atom() | binary().
  7630. -opaque handle() :: reference().
  7631. -spec noop(key()) -> ok.
  7632. noop(_K) -> ok.
  7633. `;
  7634. const result = extractFromSource('src/m.erl', code);
  7635. const aliases = result.nodes.filter((n) => n.kind === 'type_alias').map((n) => n.name);
  7636. expect(aliases).toContain('key');
  7637. expect(aliases).toContain('handle');
  7638. // Type-position expressions parse as `call` nodes — the spec/type subtrees
  7639. // must not leak `calls` refs to type names like atom()/binary().
  7640. const bogus = result.unresolvedReferences.filter(
  7641. (r) => r.referenceKind === 'calls' && ['atom', 'binary', 'reference', 'key'].includes(r.referenceName)
  7642. );
  7643. expect(bogus).toHaveLength(0);
  7644. });
  7645. it('should extract -define macros as constants', () => {
  7646. const code = `-module(m).
  7647. -define(TIMEOUT, 5000).
  7648. -define(WRAP(X), {ok, X}).
  7649. `;
  7650. const result = extractFromSource('src/m.erl', code);
  7651. const consts = result.nodes.filter((n) => n.kind === 'constant').map((n) => n.name);
  7652. expect(consts).toContain('TIMEOUT');
  7653. expect(consts).toContain('WRAP');
  7654. });
  7655. });
  7656. describe('Import extraction', () => {
  7657. it('should extract -include/-include_lib and -import', () => {
  7658. const code = `-module(m).
  7659. -include("records.hrl").
  7660. -include_lib("kernel/include/logger.hrl").
  7661. -import(lists, [map/2]).
  7662. `;
  7663. const result = extractFromSource('src/m.erl', code);
  7664. const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  7665. expect(imports).toContain('records.hrl');
  7666. expect(imports).toContain('kernel/include/logger.hrl');
  7667. expect(imports).toContain('lists');
  7668. const ref = result.unresolvedReferences.find(
  7669. (r) => r.referenceKind === 'imports' && r.referenceName === 'records.hrl'
  7670. );
  7671. expect(ref).toBeDefined();
  7672. });
  7673. });
  7674. describe('Call extraction', () => {
  7675. it('should record local calls bare and remote calls module-qualified', () => {
  7676. const code = `-module(m).
  7677. -export([run/1]).
  7678. run(X) ->
  7679. Y = prepare(X),
  7680. other_mod:process(Y).
  7681. prepare(X) -> X.
  7682. `;
  7683. const result = extractFromSource('src/m.erl', code);
  7684. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7685. expect(calls).toContain('prepare');
  7686. // `mod:fn(...)` is emitted as `mod::fn` — the same shape the module
  7687. // namespace gives every function's qualifiedName, so it resolves via
  7688. // the qualified-name matcher.
  7689. expect(calls).toContain('other_mod::process');
  7690. });
  7691. it('should not emit calls for dynamic dispatch (var module / var fun)', () => {
  7692. const code = `-module(m).
  7693. -export([run/2]).
  7694. run(Mod, F) ->
  7695. Mod:handle(x),
  7696. F(y).
  7697. `;
  7698. const result = extractFromSource('src/m.erl', code);
  7699. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7700. expect(calls).not.toContain('handle');
  7701. expect(calls).not.toContain('Mod::handle');
  7702. expect(calls).not.toContain('F');
  7703. });
  7704. it('should connect gen_server self-calls to the module handlers', () => {
  7705. const code = `-module(kv_store).
  7706. -behaviour(gen_server).
  7707. -export([get/1, put/2, drop/1]).
  7708. -export([init/1, handle_call/3, handle_cast/2]).
  7709. -define(SERVER, ?MODULE).
  7710. get(Key) ->
  7711. gen_server:call(?SERVER, {get, Key}).
  7712. put(Key, Value) ->
  7713. gen_server:cast(?MODULE, {put, Key, Value}).
  7714. drop(Key) ->
  7715. gen_server:call(kv_store, {drop, Key}).
  7716. init(_) -> {ok, #{}}.
  7717. handle_call({get, K}, _From, S) -> {reply, maps:find(K, S), S};
  7718. handle_call({drop, K}, _From, S) -> {reply, ok, maps:remove(K, S)}.
  7719. handle_cast({put, K, V}, S) -> {noreply, maps:put(K, V, S)}.
  7720. `;
  7721. const result = extractFromSource('src/kv_store.erl', code);
  7722. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7723. // ?SERVER (defined as ?MODULE), ?MODULE, and the module's own atom all
  7724. // count as self — public API wrappers connect to their handlers.
  7725. expect(calls.filter((c) => c === 'kv_store::handle_call')).toHaveLength(2);
  7726. expect(calls).toContain('kv_store::handle_cast');
  7727. });
  7728. it('should connect gen_server calls to a registered-name module, directly or via an atom macro', () => {
  7729. const code = `-module(kv_client).
  7730. -export([fetch/1, evict/1]).
  7731. -define(STORE, kv_store).
  7732. fetch(Key) ->
  7733. gen_server:call(kv_store, {get, Key}).
  7734. evict(Key) ->
  7735. gen_server:cast(?STORE, {evict, Key}).
  7736. `;
  7737. const result = extractFromSource('src/kv_client.erl', code);
  7738. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7739. // OTP's {local, ?MODULE} convention names a server after its module —
  7740. // a cross-module registered name targets that module's handlers. A name
  7741. // matching no module simply never resolves downstream.
  7742. expect(calls).toContain('kv_store::handle_call');
  7743. expect(calls).toContain('kv_store::handle_cast');
  7744. });
  7745. it('should not connect gen_server calls with dynamic targets', () => {
  7746. const code = `-module(m).
  7747. -export([go/2]).
  7748. go(Pid, Msg) ->
  7749. gen_server:call(Pid, Msg),
  7750. gen_server:cast({global, some_name}, Msg),
  7751. gen_server:call({some_name, node()}, Msg).
  7752. `;
  7753. const result = extractFromSource('src/m.erl', code);
  7754. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7755. expect(calls.filter((c) => c.includes('handle_call') || c.includes('handle_cast'))).toHaveLength(0);
  7756. });
  7757. it('should lift static MFA arguments of the spawn/apply family into call refs', () => {
  7758. const code = `-module(m).
  7759. -export([boot/2]).
  7760. boot(Req, Env) ->
  7761. Pid = proc_lib:spawn_link(?MODULE, request_process, [Req, Env]),
  7762. spawn(?MODULE, monitor_loop, [Pid]),
  7763. apply(other_mod, handle, [Req]),
  7764. timer:apply_after(500, other_mod, tick, []),
  7765. Pid.
  7766. request_process(_R, _E) -> ok.
  7767. monitor_loop(_P) -> ok.
  7768. `;
  7769. const result = extractFromSource('src/m.erl', code);
  7770. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7771. expect(calls).toContain('request_process'); // ?MODULE → bare, same-file resolution
  7772. expect(calls).toContain('monitor_loop');
  7773. expect(calls).toContain('other_mod::handle');
  7774. expect(calls).toContain('other_mod::tick');
  7775. });
  7776. it('should stay silent on dynamic spawn/apply (var module, fun value, or plain fun)', () => {
  7777. const code = `-module(m).
  7778. -export([go/3]).
  7779. go(M, F, A) ->
  7780. spawn(M, F, A),
  7781. spawn(fun() -> helper() end),
  7782. apply(M, F, A).
  7783. helper() -> ok.
  7784. `;
  7785. const result = extractFromSource('src/m.erl', code);
  7786. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7787. // The fun body's call is still walked; no phantom MFA targets appear.
  7788. expect(calls).toContain('helper');
  7789. expect(calls.filter((c) => c !== 'spawn' && c !== 'apply' && c !== 'helper')).toHaveLength(0);
  7790. });
  7791. it('should treat ?MODULE:fn calls as local calls', () => {
  7792. const code = `-module(m).
  7793. -export([kick/0]).
  7794. kick() ->
  7795. ?MODULE:work().
  7796. work() -> ok.
  7797. `;
  7798. const result = extractFromSource('src/m.erl', code);
  7799. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7800. expect(calls).toContain('work');
  7801. });
  7802. it('should capture fun name/arity values as function references', () => {
  7803. const code = `-module(m).
  7804. -export([wire/1]).
  7805. wire(Pids) ->
  7806. lists:foreach(fun notify/1, Pids),
  7807. lists:map(fun m:notify/1, Pids).
  7808. notify(_P) -> ok.
  7809. `;
  7810. const result = extractFromSource('src/m.erl', code);
  7811. const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references').map((r) => r.referenceName);
  7812. expect(refs).toContain('notify');
  7813. expect(refs).toContain('m::notify');
  7814. });
  7815. it('should reference records used in bodies and argument patterns', () => {
  7816. const code = `-module(m).
  7817. -export([mk/1, get_id/1]).
  7818. -record(req, {id, payload}).
  7819. mk(Id) -> #req{id = Id}.
  7820. get_id(#req{id = Id}) -> Id.
  7821. `;
  7822. const result = extractFromSource('src/m.erl', code);
  7823. const refs = result.unresolvedReferences.filter(
  7824. (r) => r.referenceKind === 'references' && r.referenceName === 'req'
  7825. );
  7826. expect(refs.length).toBeGreaterThanOrEqual(2);
  7827. });
  7828. it('should attribute calls from every clause of a multi-clause function', () => {
  7829. const code = `-module(m).
  7830. -export([handle/1]).
  7831. handle({a, X}) ->
  7832. first(X);
  7833. handle({b, X}) ->
  7834. second(X).
  7835. first(X) -> X.
  7836. second(X) -> X.
  7837. `;
  7838. const result = extractFromSource('src/m.erl', code);
  7839. const handle = result.nodes.find((n) => n.kind === 'function' && n.name === 'handle');
  7840. const calls = result.unresolvedReferences.filter(
  7841. (r) => r.referenceKind === 'calls' && r.fromNodeId === handle?.id
  7842. ).map((r) => r.referenceName);
  7843. expect(calls).toContain('first');
  7844. expect(calls).toContain('second');
  7845. });
  7846. });
  7847. describe('escript and app resource files', () => {
  7848. it('should extract functions and calls from an escript behind a shebang', () => {
  7849. const code = `#!/usr/bin/env escript
  7850. %%! -smp enable
  7851. main([Path]) ->
  7852. Result = analyze(Path),
  7853. io:format("~p~n", [Result]).
  7854. analyze(Path) ->
  7855. {ok, Bin} = file:read_file(Path),
  7856. byte_size(Bin).
  7857. `;
  7858. const result = extractFromSource('bin/tool.escript', code);
  7859. const fns = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  7860. expect(fns).toContain('main');
  7861. expect(fns).toContain('analyze');
  7862. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7863. expect(calls).toContain('analyze');
  7864. expect(calls).toContain('io::format');
  7865. });
  7866. it('should link an app resource file to its callback module and dependency apps', () => {
  7867. const code = `{application, sample, [
  7868. {description, "Sample application"},
  7869. {vsn, "1.0.0"},
  7870. {registered, [sample_server]},
  7871. {mod, {sample_app, []}},
  7872. {applications, [kernel, stdlib, sample_core]},
  7873. {included_applications, [sample_extra]},
  7874. {env, [{limit, 100}]},
  7875. {modules, []}
  7876. ]}.
  7877. `;
  7878. const result = extractFromSource('src/sample.app.src', code);
  7879. const refs = result.unresolvedReferences.map((r) => `${r.referenceKind}:${r.referenceName}`);
  7880. // The application-callback module is the app's entry point.
  7881. expect(refs).toContain('references:sample_app');
  7882. // Dependencies resolve to umbrella siblings; kernel/stdlib just drop.
  7883. expect(refs).toContain('imports:kernel');
  7884. expect(refs).toContain('imports:sample_core');
  7885. expect(refs).toContain('imports:sample_extra');
  7886. // Registered names, env values, and the like carry no graph structure.
  7887. expect(refs.filter((r) => r.endsWith(':sample_server'))).toHaveLength(0);
  7888. expect(refs.filter((r) => r.endsWith(':limit'))).toHaveLength(0);
  7889. });
  7890. });
  7891. describe('Macro linkage', () => {
  7892. it('should attribute macro-body calls to the macro and link function-like uses into the chain', () => {
  7893. const code = `-module(m).
  7894. -export([do_thing/1]).
  7895. -define(LOG_AUDIT(Event), audit_logger:log(Event, ?MODULE)).
  7896. do_thing(X) ->
  7897. ?LOG_AUDIT({thing, X}),
  7898. ok.
  7899. `;
  7900. const result = extractFromSource('src/m.erl', code);
  7901. const macro = result.nodes.find((n) => n.kind === 'constant' && n.name === 'LOG_AUDIT');
  7902. const doThing = result.nodes.find((n) => n.kind === 'function' && n.name === 'do_thing');
  7903. const refsFrom = (id?: string) =>
  7904. result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => `${r.referenceKind}:${r.referenceName}`);
  7905. // The body's remote call belongs to the macro node — true exactly once.
  7906. expect(refsFrom(macro?.id)).toContain('calls:audit_logger::log');
  7907. // The use site joins the call chain: do_thing -calls→ LOG_AUDIT.
  7908. expect(refsFrom(doThing?.id)).toContain('calls:LOG_AUDIT');
  7909. });
  7910. it('should reference bare macro reads without polluting call chains', () => {
  7911. const code = `-module(m).
  7912. -export([wait/0]).
  7913. -define(TIMEOUT, 5000).
  7914. wait() ->
  7915. receive after ?TIMEOUT -> ok end.
  7916. `;
  7917. const result = extractFromSource('src/m.erl', code);
  7918. const refs = result.unresolvedReferences.map((r) => `${r.referenceKind}:${r.referenceName}`);
  7919. expect(refs).toContain('references:TIMEOUT');
  7920. expect(refs).not.toContain('calls:TIMEOUT');
  7921. });
  7922. it('should skip compiler-predefined macros and keep walking macro-use arguments', () => {
  7923. const code = `-module(m).
  7924. -export([check/0]).
  7925. check() ->
  7926. ?assertEqual(ok, prepare()),
  7927. {?MODULE, ?LINE, ?FUNCTION_NAME}.
  7928. prepare() -> ok.
  7929. `;
  7930. const result = extractFromSource('src/m.erl', code);
  7931. const refs = result.unresolvedReferences.map((r) => r.referenceName);
  7932. // The nested call inside the macro's arguments still attributes to check/0.
  7933. expect(refs).toContain('prepare');
  7934. // ?assertEqual (an OTP header macro) is emitted and simply never resolves…
  7935. expect(refs).toContain('assertEqual');
  7936. // …but predefined macros have no definition to link.
  7937. expect(refs).not.toContain('MODULE');
  7938. expect(refs).not.toContain('LINE');
  7939. expect(refs).not.toContain('FUNCTION_NAME');
  7940. });
  7941. it('should chain macro-to-macro uses', () => {
  7942. const code = `-module(m).
  7943. -define(TARGET, target_fn()).
  7944. -define(ALIAS, ?TARGET).
  7945. `;
  7946. const result = extractFromSource('src/m.erl', code);
  7947. const target = result.nodes.find((n) => n.kind === 'constant' && n.name === 'TARGET');
  7948. const alias = result.nodes.find((n) => n.kind === 'constant' && n.name === 'ALIAS');
  7949. const refsFrom = (id?: string) =>
  7950. result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => `${r.referenceKind}:${r.referenceName}`);
  7951. expect(refsFrom(target?.id)).toContain('calls:target_fn');
  7952. expect(refsFrom(alias?.id)).toContain('references:TARGET');
  7953. });
  7954. });
  7955. describe('Behaviour extraction', () => {
  7956. it('should emit an implements reference for -behaviour', () => {
  7957. const code = `-module(m).
  7958. -behaviour(gen_server).
  7959. init(_) -> {ok, #{}}.
  7960. `;
  7961. const result = extractFromSource('src/m.erl', code);
  7962. const impl = result.unresolvedReferences.find((r) => r.referenceKind === 'implements');
  7963. expect(impl?.referenceName).toBe('gen_server');
  7964. });
  7965. it('should not create symbols from -callback declarations', () => {
  7966. const code = `-module(b).
  7967. -callback handle_thing(term()) -> ok.
  7968. -callback init(list()) -> {ok, term()}.
  7969. `;
  7970. const result = extractFromSource('src/b.erl', code);
  7971. const fns = result.nodes.filter((n) => n.kind === 'function');
  7972. expect(fns).toHaveLength(0);
  7973. });
  7974. });
  7975. });