extraction.test.ts 355 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291929292939294929592969297929892999300930193029303930493059306930793089309931093119312931393149315931693179318931993209321932293239324932593269327932893299330933193329333933493359336933793389339934093419342934393449345934693479348934993509351935293539354935593569357935893599360936193629363936493659366936793689369937093719372937393749375937693779378937993809381938293839384938593869387938893899390939193929393939493959396939793989399940094019402940394049405940694079408940994109411941294139414941594169417941894199420942194229423942494259426942794289429943094319432943394349435943694379438943994409441944294439444944594469447944894499450945194529453945494559456945794589459946094619462946394649465946694679468946994709471947294739474947594769477947894799480948194829483948494859486948794889489949094919492949394949495949694979498949995009501
  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 detect Solidity files', () => {
  102. expect(detectLanguage('contracts/Vault.sol')).toBe('solidity');
  103. });
  104. it('should return unknown for unsupported extensions', () => {
  105. expect(detectLanguage('styles.css')).toBe('unknown');
  106. expect(detectLanguage('data.json')).toBe('unknown');
  107. });
  108. });
  109. describe('Language Support', () => {
  110. it('should report supported languages', () => {
  111. expect(isLanguageSupported('typescript')).toBe(true);
  112. expect(isLanguageSupported('python')).toBe(true);
  113. expect(isLanguageSupported('go')).toBe(true);
  114. expect(isLanguageSupported('unknown')).toBe(false);
  115. });
  116. it('should list all supported languages', () => {
  117. const languages = getSupportedLanguages();
  118. expect(languages).toContain('typescript');
  119. expect(languages).toContain('javascript');
  120. expect(languages).toContain('python');
  121. expect(languages).toContain('go');
  122. expect(languages).toContain('rust');
  123. expect(languages).toContain('java');
  124. expect(languages).toContain('csharp');
  125. expect(languages).toContain('php');
  126. expect(languages).toContain('ruby');
  127. expect(languages).toContain('swift');
  128. expect(languages).toContain('kotlin');
  129. expect(languages).toContain('dart');
  130. expect(languages).toContain('solidity');
  131. });
  132. });
  133. describe('TypeScript Extraction', () => {
  134. it('should extract function declarations', () => {
  135. const code = `
  136. export function processPayment(amount: number): Promise<Receipt> {
  137. return stripe.charge(amount);
  138. }
  139. `;
  140. const result = extractFromSource('payment.ts', code);
  141. // File node + function node
  142. const fileNode = result.nodes.find((n) => n.kind === 'file');
  143. expect(fileNode).toBeDefined();
  144. expect(fileNode?.name).toBe('payment.ts');
  145. const funcNode = result.nodes.find((n) => n.kind === 'function');
  146. expect(funcNode).toMatchObject({
  147. kind: 'function',
  148. name: 'processPayment',
  149. language: 'typescript',
  150. isExported: true,
  151. });
  152. expect(funcNode?.signature).toContain('amount: number');
  153. });
  154. it('should extract class declarations', () => {
  155. const code = `
  156. export class PaymentService {
  157. private stripe: StripeClient;
  158. constructor(apiKey: string) {
  159. this.stripe = new StripeClient(apiKey);
  160. }
  161. async charge(amount: number): Promise<Receipt> {
  162. return this.stripe.charge(amount);
  163. }
  164. }
  165. `;
  166. const result = extractFromSource('service.ts', code);
  167. const classNode = result.nodes.find((n) => n.kind === 'class');
  168. const methodNodes = result.nodes.filter((n) => n.kind === 'method');
  169. expect(classNode).toBeDefined();
  170. expect(classNode?.name).toBe('PaymentService');
  171. expect(classNode?.isExported).toBe(true);
  172. expect(methodNodes.length).toBeGreaterThanOrEqual(1);
  173. const chargeMethod = methodNodes.find((m) => m.name === 'charge');
  174. expect(chargeMethod).toBeDefined();
  175. });
  176. it('captures docstrings for export- and const-wrapped declarations (#780)', () => {
  177. const code = `
  178. // plain class control
  179. class Ledger {}
  180. // exported class
  181. export class Invoice {}
  182. // export default
  183. export default function settle() { return true; }
  184. // exported arrow const
  185. export const refund = (amount: number) => amount;
  186. // non-export arrow const
  187. const audit = (amount: number) => amount;
  188. `;
  189. const byName = new Map(extractFromSource('doc.ts', code).nodes.map((n) => [n.name, n]));
  190. expect(byName.get('Ledger')?.docstring).toBe('plain class control'); // control still works
  191. expect(byName.get('Invoice')?.docstring).toBe('exported class');
  192. expect(byName.get('settle')?.docstring).toBe('export default');
  193. expect(byName.get('refund')?.docstring).toBe('exported arrow const');
  194. expect(byName.get('audit')?.docstring).toBe('non-export arrow const');
  195. });
  196. it('does not mis-attribute a class comment to an uncommented member (#780)', () => {
  197. const code = `
  198. // Comment for Box
  199. export class Box {
  200. noComment() {}
  201. // own comment
  202. withComment() {}
  203. }
  204. `;
  205. const byName = new Map(extractFromSource('box.ts', code).nodes.map((n) => [n.name, n]));
  206. expect(byName.get('Box')?.docstring).toBe('Comment for Box');
  207. expect(byName.get('noComment')?.docstring ?? null).toBeNull(); // no over-walk
  208. expect(byName.get('withComment')?.docstring).toBe('own comment');
  209. });
  210. it('captures docstrings for decorated Python declarations, stripping `#` (#780)', () => {
  211. const code = [
  212. '# decorated function',
  213. '@app.route("/x")',
  214. 'def py_handler():',
  215. ' return 1',
  216. '',
  217. '',
  218. '# plain function control',
  219. 'def py_plain():',
  220. ' return 1',
  221. '',
  222. '',
  223. '# decorated class',
  224. '@dataclass',
  225. 'class PyModel:',
  226. ' pass',
  227. '',
  228. ].join('\n');
  229. const byName = new Map(extractFromSource('mod.py', code).nodes.map((n) => [n.name, n]));
  230. expect(byName.get('py_handler')?.docstring).toBe('decorated function');
  231. expect(byName.get('py_plain')?.docstring).toBe('plain function control'); // `#` stripped
  232. expect(byName.get('PyModel')?.docstring).toBe('decorated class');
  233. });
  234. it('cleans comment markers across language styles (#780)', () => {
  235. const doc = (file: string, code: string, name: string) =>
  236. new Map(extractFromSource(file, code).nodes.map((n) => [n.name, n])).get(name)?.docstring;
  237. // Rust doc lines (`///`, `//!`) — the trailing slash used to leak through.
  238. expect(doc('m.rs', '/// rust doc line\nfn rs_fn() {}', 'rs_fn')).toBe('rust doc line');
  239. // Lua line + long-bracket comments.
  240. expect(doc('m.lua', '-- lua line\nfunction lua_fn() end', 'lua_fn')).toBe('lua line');
  241. expect(doc('b.lua', '--[[ lua block ]]\nfunction lua_b() end', 'lua_b')).toBe('lua block');
  242. // Pascal brace and paren-star comments.
  243. const pasUnit = (c: string) =>
  244. `unit U;\ninterface\n${c}\nprocedure P;\nimplementation\nprocedure P;\nbegin\nend;\nend.\n`;
  245. expect(doc('a.pas', pasUnit('{ pascal brace }'), 'P')).toBe('pascal brace');
  246. expect(doc('c.pas', pasUnit('(* pascal paren *)'), 'P')).toBe('pascal paren');
  247. // C block comment still clean (no regression).
  248. expect(doc('m.c', '/* c block */\nvoid c_fn(void) {}', 'c_fn')).toBe('c block');
  249. });
  250. it('should extract interfaces', () => {
  251. const code = `
  252. export interface User {
  253. id: string;
  254. name: string;
  255. email: string;
  256. }
  257. `;
  258. const result = extractFromSource('types.ts', code);
  259. const fileNode = result.nodes.find((n) => n.kind === 'file');
  260. expect(fileNode).toBeDefined();
  261. const ifaceNode = result.nodes.find((n) => n.kind === 'interface');
  262. expect(ifaceNode).toMatchObject({
  263. kind: 'interface',
  264. name: 'User',
  265. isExported: true,
  266. });
  267. });
  268. it('should extract type references from interface property signatures', () => {
  269. const code = `
  270. import type { IPage } from '../PromoterList';
  271. import type { IOrderField } from '../types';
  272. interface Hprops {
  273. value?: Partial<IPage> & Partial<IOrderField>;
  274. }
  275. `;
  276. const result = extractFromSource('HeaderFilter.ts', code);
  277. const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
  278. expect(refs.some((r) => r.referenceName === 'IPage')).toBe(true);
  279. expect(refs.some((r) => r.referenceName === 'IOrderField')).toBe(true);
  280. });
  281. it('should extract type references from interface method signatures', () => {
  282. const code = `
  283. import type { IPage } from '../PromoterList';
  284. import type { IOrderField } from '../types';
  285. interface MethodForm {
  286. fetchPage(arg: IPage): IOrderField;
  287. }
  288. `;
  289. const result = extractFromSource('MethodForm.ts', code);
  290. const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
  291. expect(refs.some((r) => r.referenceName === 'IPage')).toBe(true);
  292. expect(refs.some((r) => r.referenceName === 'IOrderField')).toBe(true);
  293. });
  294. it('extracts type references from in-body local variable annotations', () => {
  295. // A function that uses a type ONLY in its body — `const items: Foo[] = []` —
  296. // still depends on Foo. The body walker used to capture calls but never type
  297. // annotations, so impact / `affected` missed the dependency. Must cover
  298. // function, class-method, and object-literal-method bodies — and must NOT
  299. // turn the locals themselves into graph nodes (that would explode the graph).
  300. const code = `
  301. import { Foo } from './types';
  302. export function build(): void {
  303. const items: Foo[] = [];
  304. void items;
  305. }
  306. export class K {
  307. run(): void {
  308. const a: Foo = { x: 1 };
  309. void a;
  310. }
  311. }
  312. export const handler = {
  313. handle(): void {
  314. const b: Foo = { x: 1 };
  315. void b;
  316. },
  317. };
  318. `;
  319. const result = extractFromSource('inbody.ts', code);
  320. const fooRefs = result.unresolvedReferences.filter(
  321. (r) => r.referenceKind === 'references' && r.referenceName === 'Foo'
  322. );
  323. // One per body scope: build(), K.run(), handler.handle().
  324. expect(fooRefs.length).toBeGreaterThanOrEqual(3);
  325. // Each reference is attributed to its enclosing function/method node — never
  326. // to a local-variable node, because locals are intentionally not extracted.
  327. const byId = new Map(result.nodes.map((n) => [n.id, n]));
  328. for (const ref of fooRefs) {
  329. const owner = byId.get(ref.fromNodeId);
  330. expect(owner).toBeDefined();
  331. expect(['function', 'method']).toContain(owner!.kind);
  332. }
  333. // The locals (items/a/b) must not leak in as symbols.
  334. expect(result.nodes.some((n) => ['items', 'a', 'b'].includes(n.name))).toBe(false);
  335. });
  336. it('should track function calls', () => {
  337. const code = `
  338. function main() {
  339. const result = processData();
  340. console.log(result);
  341. }
  342. `;
  343. const result = extractFromSource('main.ts', code);
  344. expect(result.unresolvedReferences.length).toBeGreaterThan(0);
  345. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
  346. expect(calls.some((c) => c.referenceName === 'processData')).toBe(true);
  347. });
  348. });
  349. describe('Arrow Function Export Extraction', () => {
  350. it('should extract exported arrow functions assigned to const', () => {
  351. const code = `
  352. export const useAuth = (): AuthContextValue => {
  353. return useContext(AuthContext);
  354. };
  355. `;
  356. const result = extractFromSource('hooks.ts', code);
  357. const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'useAuth');
  358. expect(funcNode).toBeDefined();
  359. expect(funcNode).toMatchObject({
  360. kind: 'function',
  361. name: 'useAuth',
  362. isExported: true,
  363. });
  364. });
  365. it('should extract exported function expressions assigned to const', () => {
  366. const code = `
  367. export const processData = function(input: string): string {
  368. return input.trim();
  369. };
  370. `;
  371. const result = extractFromSource('utils.ts', code);
  372. const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'processData');
  373. expect(funcNode).toBeDefined();
  374. expect(funcNode).toMatchObject({
  375. kind: 'function',
  376. name: 'processData',
  377. isExported: true,
  378. });
  379. });
  380. it('should not extract non-exported arrow functions as exported', () => {
  381. const code = `
  382. const internalHelper = () => {
  383. return 42;
  384. };
  385. `;
  386. const result = extractFromSource('internal.ts', code);
  387. const helperNode = result.nodes.find((n) => n.name === 'internalHelper');
  388. expect(helperNode).toBeDefined();
  389. expect(helperNode?.isExported).toBeFalsy();
  390. });
  391. it('should still skip truly anonymous arrow functions', () => {
  392. const code = `
  393. const items = [1, 2, 3].map((x) => x * 2);
  394. `;
  395. const result = extractFromSource('anon.ts', code);
  396. // The inline arrow function passed to .map() has no variable_declarator parent
  397. // and should remain anonymous (skipped)
  398. const anonFunctions = result.nodes.filter(
  399. (n) => n.kind === 'function' && n.name === '<anonymous>'
  400. );
  401. expect(anonFunctions).toHaveLength(0);
  402. });
  403. it('should extract multiple exported arrow functions from the same file', () => {
  404. const code = `
  405. export const add = (a: number, b: number): number => a + b;
  406. export const subtract = (a: number, b: number): number => a - b;
  407. const internal = () => 'not exported';
  408. `;
  409. const result = extractFromSource('math.ts', code);
  410. const exported = result.nodes.filter((n) => n.kind === 'function' && n.isExported);
  411. expect(exported).toHaveLength(2);
  412. expect(exported.map((n) => n.name).sort()).toEqual(['add', 'subtract']);
  413. const internalNode = result.nodes.find((n) => n.name === 'internal');
  414. expect(internalNode).toBeDefined();
  415. expect(internalNode?.isExported).toBeFalsy();
  416. });
  417. it('should extract arrow functions in JavaScript files', () => {
  418. const code = `
  419. export const fetchData = async () => {
  420. const response = await fetch('/api/data');
  421. return response.json();
  422. };
  423. `;
  424. const result = extractFromSource('api.js', code);
  425. const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'fetchData');
  426. expect(funcNode).toBeDefined();
  427. expect(funcNode).toMatchObject({
  428. kind: 'function',
  429. name: 'fetchData',
  430. isExported: true,
  431. });
  432. });
  433. });
  434. describe('Type Alias Extraction', () => {
  435. it('should extract exported type aliases in TypeScript', () => {
  436. const code = `
  437. export type AuthContextValue = {
  438. user: User | null;
  439. login: () => void;
  440. logout: () => void;
  441. };
  442. `;
  443. const result = extractFromSource('types.ts', code);
  444. const typeNode = result.nodes.find((n) => n.kind === 'type_alias');
  445. expect(typeNode).toMatchObject({
  446. kind: 'type_alias',
  447. name: 'AuthContextValue',
  448. isExported: true,
  449. });
  450. });
  451. it('should extract non-exported type aliases', () => {
  452. const code = `
  453. type InternalState = {
  454. loading: boolean;
  455. error: string | null;
  456. };
  457. `;
  458. const result = extractFromSource('internal.ts', code);
  459. const typeNode = result.nodes.find((n) => n.kind === 'type_alias');
  460. expect(typeNode).toMatchObject({
  461. kind: 'type_alias',
  462. name: 'InternalState',
  463. isExported: false,
  464. });
  465. });
  466. it('should extract multiple type aliases from the same file', () => {
  467. const code = `
  468. export type UnitSystem = 'metric' | 'imperial';
  469. export type DateFormat = 'ISO' | 'US' | 'EU';
  470. type Internal = string;
  471. `;
  472. const result = extractFromSource('config.ts', code);
  473. const typeAliases = result.nodes.filter((n) => n.kind === 'type_alias');
  474. expect(typeAliases).toHaveLength(3);
  475. const exported = typeAliases.filter((n) => n.isExported);
  476. expect(exported).toHaveLength(2);
  477. expect(exported.map((n) => n.name).sort()).toEqual(['DateFormat', 'UnitSystem']);
  478. });
  479. // A service/contract registry written as a tuple of generic instantiations —
  480. // the names are string-literal type arguments, not declarations, so static
  481. // extraction otherwise never indexes them (issue #634).
  482. it('extracts string-literal contract names from a generic tuple type alias (#634)', () => {
  483. const code = `
  484. interface Service<Name extends string, Req, Resp> { name: Name; }
  485. export type MyServiceList = [
  486. Service<'query_apply_record', { pageNo: number }, { ok: boolean }>,
  487. Service<'apply_confirm', { code: string }, { ok: boolean }>
  488. ];
  489. `;
  490. const result = extractFromSource('services/api.ts', code);
  491. const names = result.nodes.filter(
  492. (n) => n.kind === 'method' && n.qualifiedName.startsWith('MyServiceList::')
  493. );
  494. expect(names.map((n) => n.name).sort()).toEqual(['apply_confirm', 'query_apply_record']);
  495. const queryNode = names.find((n) => n.name === 'query_apply_record');
  496. expect(queryNode?.qualifiedName).toBe('MyServiceList::query_apply_record');
  497. // Signature carries the full contract entry so search results show context.
  498. expect(queryNode?.signature).toContain("Service<'query_apply_record'");
  499. // The string-literal name is contained by the type alias.
  500. const alias = result.nodes.find((n) => n.kind === 'type_alias' && n.name === 'MyServiceList');
  501. const containsEdge = result.edges.find(
  502. (e) => e.kind === 'contains' && e.source === alias?.id && e.target === queryNode?.id
  503. );
  504. expect(containsEdge).toBeDefined();
  505. });
  506. it('does not extract string literals from utility types or nested generics (#634)', () => {
  507. const code = `
  508. interface User { id: string; name: string; }
  509. interface Service<Name extends string, Req, Resp> { name: Name; }
  510. export type Picked = Pick<User, 'id' | 'name'>;
  511. export type Rec = Record<'foo' | 'bar', number>;
  512. // Tuple entry, but the name is a non-identifier route path; the nested Pick's
  513. // 'id' must also stay out (only DIRECT literal args of a tuple's generic count).
  514. export type Routes = [Service<'/api/users', Pick<User, 'id'>, {}>];
  515. // Bare string-literal tuple — not generic type arguments.
  516. export type Names = ['alpha', 'beta'];
  517. `;
  518. const result = extractFromSource('noise.ts', code);
  519. const leaked = result.nodes.filter(
  520. (n) =>
  521. (n.kind === 'method' || n.kind === 'property') &&
  522. ['id', 'name', 'foo', 'bar', 'alpha', 'beta'].includes(n.name)
  523. );
  524. expect(leaked).toEqual([]);
  525. });
  526. });
  527. describe('Exported Variable Extraction', () => {
  528. it('should extract exported const with call expression (Zustand store)', () => {
  529. const code = `
  530. export const useUIStore = create<UIState>((set) => ({
  531. isOpen: false,
  532. toggle: () => set((s) => ({ isOpen: !s.isOpen })),
  533. }));
  534. `;
  535. const result = extractFromSource('store.ts', code);
  536. const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'useUIStore');
  537. expect(varNode).toBeDefined();
  538. expect(varNode?.isExported).toBe(true);
  539. });
  540. it('should extract exported const with object literal', () => {
  541. const code = `
  542. export const config = {
  543. apiUrl: 'https://api.example.com',
  544. timeout: 5000,
  545. };
  546. `;
  547. const result = extractFromSource('config.ts', code);
  548. const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'config');
  549. expect(varNode).toBeDefined();
  550. expect(varNode?.isExported).toBe(true);
  551. });
  552. it('should extract exported const with array literal', () => {
  553. const code = `
  554. export const SCREEN_NAMES = ['home', 'settings', 'profile'] as const;
  555. `;
  556. const result = extractFromSource('constants.ts', code);
  557. const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'SCREEN_NAMES');
  558. expect(varNode).toBeDefined();
  559. expect(varNode?.isExported).toBe(true);
  560. });
  561. it('should extract exported const with primitive value', () => {
  562. const code = `
  563. export const MAX_RETRIES = 3;
  564. export const API_VERSION = "v2";
  565. `;
  566. const result = extractFromSource('constants.ts', code);
  567. const variables = result.nodes.filter((n) => n.kind === 'constant');
  568. expect(variables).toHaveLength(2);
  569. expect(variables.map((n) => n.name).sort()).toEqual(['API_VERSION', 'MAX_RETRIES']);
  570. });
  571. it('should NOT duplicate arrow functions as both function and variable', () => {
  572. const code = `
  573. export const useAuth = () => {
  574. return useContext(AuthContext);
  575. };
  576. `;
  577. const result = extractFromSource('hooks.ts', code);
  578. // Should be extracted as function (from arrow function handler), NOT as variable
  579. const funcNodes = result.nodes.filter((n) => n.kind === 'function' && n.name === 'useAuth');
  580. const varNodes = result.nodes.filter((n) => n.kind === 'variable' && n.name === 'useAuth');
  581. expect(funcNodes).toHaveLength(1);
  582. expect(varNodes).toHaveLength(0);
  583. });
  584. it('should extract non-exported const as non-exported variable', () => {
  585. const code = `
  586. const internalConfig = {
  587. debug: true,
  588. };
  589. `;
  590. const result = extractFromSource('internal.ts', code);
  591. // Non-exported const at file level should be extracted as a constant (not exported)
  592. const varNodes = result.nodes.filter((n) => (n.kind === 'variable' || n.kind === 'constant') && n.name === 'internalConfig');
  593. expect(varNodes).toHaveLength(1);
  594. expect(varNodes[0]?.isExported).toBeFalsy();
  595. });
  596. it('should extract Zod schema exports', () => {
  597. const code = `
  598. export const userSchema = z.object({
  599. id: z.string(),
  600. name: z.string(),
  601. email: z.string().email(),
  602. });
  603. `;
  604. const result = extractFromSource('schemas.ts', code);
  605. const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'userSchema');
  606. expect(varNode).toBeDefined();
  607. expect(varNode?.isExported).toBe(true);
  608. });
  609. it('should extract XState machine exports', () => {
  610. const code = `
  611. export const authMachine = createMachine({
  612. id: "auth",
  613. initial: "idle",
  614. states: {
  615. idle: {},
  616. authenticated: {},
  617. },
  618. });
  619. `;
  620. const result = extractFromSource('machine.ts', code);
  621. const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'authMachine');
  622. expect(varNode).toBeDefined();
  623. expect(varNode?.isExported).toBe(true);
  624. });
  625. it('should extract calls from a top-level variable initializer (issue #425)', () => {
  626. const code = `
  627. import { getTokenMp } from './api/upload';
  628. const token = getTokenMp();
  629. `;
  630. const result = extractFromSource('app.ts', code);
  631. const call = result.unresolvedReferences.find(
  632. (ref) => ref.referenceKind === 'calls' && ref.referenceName === 'getTokenMp'
  633. );
  634. expect(call).toBeDefined();
  635. });
  636. });
  637. describe('File Node Extraction', () => {
  638. it('should create a file-kind node for each parsed file', () => {
  639. const code = `
  640. export function greet(name: string): string {
  641. return "Hello " + name;
  642. }
  643. `;
  644. const result = extractFromSource('greeter.ts', code);
  645. const fileNode = result.nodes.find((n) => n.kind === 'file');
  646. expect(fileNode).toBeDefined();
  647. expect(fileNode?.name).toBe('greeter.ts');
  648. expect(fileNode?.filePath).toBe('greeter.ts');
  649. expect(fileNode?.language).toBe('typescript');
  650. expect(fileNode?.startLine).toBe(1);
  651. });
  652. it('should create file nodes for Python files', () => {
  653. const code = `
  654. def main():
  655. pass
  656. `;
  657. const result = extractFromSource('main.py', code);
  658. const fileNode = result.nodes.find((n) => n.kind === 'file');
  659. expect(fileNode).toBeDefined();
  660. expect(fileNode?.name).toBe('main.py');
  661. expect(fileNode?.language).toBe('python');
  662. });
  663. it('should create containment edges from file node to top-level declarations', () => {
  664. const code = `
  665. export function foo() {}
  666. export function bar() {}
  667. `;
  668. const result = extractFromSource('fns.ts', code);
  669. const fileNode = result.nodes.find((n) => n.kind === 'file');
  670. expect(fileNode).toBeDefined();
  671. // There should be contains edges from the file node to each function
  672. const containsEdges = result.edges.filter(
  673. (e) => e.source === fileNode?.id && e.kind === 'contains'
  674. );
  675. expect(containsEdges.length).toBeGreaterThanOrEqual(2);
  676. });
  677. });
  678. describe('Python Extraction', () => {
  679. it('should extract function definitions', () => {
  680. const code = `
  681. def calculate_total(items: list, tax_rate: float) -> float:
  682. """Calculate total with tax."""
  683. subtotal = sum(item.price for item in items)
  684. return subtotal * (1 + tax_rate)
  685. `;
  686. const result = extractFromSource('calc.py', code);
  687. const fileNode = result.nodes.find((n) => n.kind === 'file');
  688. expect(fileNode).toBeDefined();
  689. const funcNode = result.nodes.find((n) => n.kind === 'function');
  690. expect(funcNode).toMatchObject({
  691. kind: 'function',
  692. name: 'calculate_total',
  693. language: 'python',
  694. });
  695. });
  696. it('should extract class definitions', () => {
  697. const code = `
  698. class UserService:
  699. """Service for managing users."""
  700. def __init__(self, db):
  701. self.db = db
  702. def get_user(self, user_id: str) -> User:
  703. return self.db.find_user(user_id)
  704. `;
  705. const result = extractFromSource('service.py', code);
  706. const classNode = result.nodes.find((n) => n.kind === 'class');
  707. expect(classNode).toBeDefined();
  708. expect(classNode?.name).toBe('UserService');
  709. });
  710. });
  711. describe('Go Extraction', () => {
  712. it('should extract function declarations', () => {
  713. const code = `
  714. package main
  715. func ProcessOrder(order Order) (Receipt, error) {
  716. // Process the order
  717. return Receipt{}, nil
  718. }
  719. `;
  720. const result = extractFromSource('main.go', code);
  721. const funcNode = result.nodes.find((n) => n.kind === 'function');
  722. expect(funcNode).toBeDefined();
  723. expect(funcNode?.name).toBe('ProcessOrder');
  724. });
  725. it('should extract method declarations', () => {
  726. const code = `
  727. package main
  728. type Service struct {
  729. db *Database
  730. }
  731. func (s *Service) GetUser(id string) (*User, error) {
  732. return s.db.FindUser(id)
  733. }
  734. `;
  735. const result = extractFromSource('service.go', code);
  736. const methodNode = result.nodes.find((n) => n.kind === 'method');
  737. expect(methodNode).toBeDefined();
  738. expect(methodNode?.name).toBe('GetUser');
  739. });
  740. });
  741. describe('Rust Extraction', () => {
  742. it('should extract function declarations', () => {
  743. const code = `
  744. pub fn process_data(input: &str) -> Result<Output, Error> {
  745. // Process data
  746. Ok(Output::new())
  747. }
  748. `;
  749. const result = extractFromSource('lib.rs', code);
  750. const funcNode = result.nodes.find((n) => n.kind === 'function');
  751. expect(funcNode).toBeDefined();
  752. expect(funcNode?.name).toBe('process_data');
  753. expect(funcNode?.visibility).toBe('public');
  754. });
  755. it('should extract struct declarations', () => {
  756. const code = `
  757. pub struct User {
  758. pub id: String,
  759. pub name: String,
  760. email: String,
  761. }
  762. `;
  763. const result = extractFromSource('models.rs', code);
  764. const structNode = result.nodes.find((n) => n.kind === 'struct');
  765. expect(structNode).toBeDefined();
  766. expect(structNode?.name).toBe('User');
  767. });
  768. it('should extract trait declarations', () => {
  769. const code = `
  770. pub trait Repository {
  771. fn find(&self, id: &str) -> Option<Entity>;
  772. fn save(&mut self, entity: Entity) -> Result<(), Error>;
  773. }
  774. `;
  775. const result = extractFromSource('traits.rs', code);
  776. const traitNode = result.nodes.find((n) => n.kind === 'trait');
  777. expect(traitNode).toBeDefined();
  778. expect(traitNode?.name).toBe('Repository');
  779. });
  780. it('should extract impl Trait for Type as implements edges', () => {
  781. const code = `
  782. pub struct MyCache {}
  783. pub trait Cache {
  784. fn get(&self, key: &str) -> Option<String>;
  785. }
  786. impl Cache for MyCache {
  787. fn get(&self, key: &str) -> Option<String> {
  788. None
  789. }
  790. }
  791. `;
  792. const result = extractFromSource('cache.rs', code);
  793. // Should have an unresolved reference for implements
  794. const implRef = result.unresolvedReferences.find(
  795. (r) => r.referenceKind === 'implements' && r.referenceName === 'Cache'
  796. );
  797. expect(implRef).toBeDefined();
  798. // The struct MyCache should be the source
  799. const myCacheNode = result.nodes.find((n) => n.name === 'MyCache' && n.kind === 'struct');
  800. expect(myCacheNode).toBeDefined();
  801. expect(implRef?.fromNodeId).toBe(myCacheNode?.id);
  802. });
  803. it('should extract trait supertraits as extends references', () => {
  804. const code = `
  805. pub trait Display {}
  806. pub trait Error: Display {
  807. fn description(&self) -> &str;
  808. }
  809. `;
  810. const result = extractFromSource('error.rs', code);
  811. const extendsRef = result.unresolvedReferences.find(
  812. (r) => r.referenceKind === 'extends' && r.referenceName === 'Display'
  813. );
  814. expect(extendsRef).toBeDefined();
  815. const errorTrait = result.nodes.find((n) => n.name === 'Error' && n.kind === 'trait');
  816. expect(errorTrait).toBeDefined();
  817. expect(extendsRef?.fromNodeId).toBe(errorTrait?.id);
  818. });
  819. it('should not create implements edges for plain impl blocks', () => {
  820. const code = `
  821. pub struct Counter {
  822. count: u32,
  823. }
  824. impl Counter {
  825. pub fn new() -> Counter {
  826. Counter { count: 0 }
  827. }
  828. pub fn increment(&mut self) {
  829. self.count += 1;
  830. }
  831. }
  832. `;
  833. const result = extractFromSource('counter.rs', code);
  834. // Should have no implements references (no trait involved)
  835. const implRefs = result.unresolvedReferences.filter(
  836. (r) => r.referenceKind === 'implements'
  837. );
  838. expect(implRefs).toHaveLength(0);
  839. });
  840. });
  841. describe('Java Extraction', () => {
  842. it('should extract class declarations', () => {
  843. const code = `
  844. public class UserService {
  845. private final UserRepository repository;
  846. public UserService(UserRepository repository) {
  847. this.repository = repository;
  848. }
  849. public User getUser(String id) {
  850. return repository.findById(id);
  851. }
  852. }
  853. `;
  854. const result = extractFromSource('UserService.java', code);
  855. const classNode = result.nodes.find((n) => n.kind === 'class');
  856. expect(classNode).toBeDefined();
  857. expect(classNode?.name).toBe('UserService');
  858. expect(classNode?.visibility).toBe('public');
  859. });
  860. it('should extract method declarations', () => {
  861. const code = `
  862. public class Calculator {
  863. public static int add(int a, int b) {
  864. return a + b;
  865. }
  866. }
  867. `;
  868. const result = extractFromSource('Calculator.java', code);
  869. const methodNode = result.nodes.find((n) => n.kind === 'method' && n.name === 'add');
  870. expect(methodNode).toBeDefined();
  871. expect(methodNode?.isStatic).toBe(true);
  872. });
  873. it('wraps top-level declarations in a namespace from package_declaration', () => {
  874. const code = `
  875. package com.example.foo;
  876. public class Bar {
  877. public String greet() { return "hi"; }
  878. }
  879. `;
  880. const result = extractFromSource('Bar.java', code);
  881. const ns = result.nodes.find((n) => n.kind === 'namespace');
  882. expect(ns?.name).toBe('com.example.foo');
  883. const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
  884. expect(cls?.qualifiedName).toBe('com.example.foo::Bar');
  885. const greet = result.nodes.find((n) => n.kind === 'method' && n.name === 'greet');
  886. expect(greet?.qualifiedName).toBe('com.example.foo::Bar::greet');
  887. });
  888. it('does not wrap when no package is declared', () => {
  889. const code = `
  890. public class Bar {
  891. public String greet() { return "hi"; }
  892. }
  893. `;
  894. const result = extractFromSource('Bar.java', code);
  895. expect(result.nodes.find((n) => n.kind === 'namespace')).toBeUndefined();
  896. const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
  897. expect(cls?.qualifiedName).toBe('Bar');
  898. });
  899. it('extracts anonymous-class overrides from `new T() { ... }`', () => {
  900. // The pattern that breaks the trace through `strategy.foo()` in
  901. // libraries like guava's Splitter: the lambda-returned anonymous
  902. // class overrides abstract methods on the base, but without
  903. // extracting those overrides the interface→impl synthesizer has
  904. // nothing to bridge.
  905. const code = `
  906. package com.example;
  907. abstract class Base {
  908. abstract int compute(int x);
  909. }
  910. public class Factory {
  911. public Base make() {
  912. return new Base() {
  913. @Override
  914. int compute(int x) { return x + 1; }
  915. };
  916. }
  917. }
  918. `;
  919. const result = extractFromSource('Factory.java', code);
  920. const anon = result.nodes.find((n) => n.kind === 'class' && /Base\$anon@/.test(n.name));
  921. expect(anon, 'anonymous Base subclass should be extracted as a class').toBeDefined();
  922. const compute = result.nodes.find(
  923. (n) => n.kind === 'method' && n.name === 'compute' && n.qualifiedName.includes('$anon@')
  924. );
  925. expect(compute, 'override method should be a method on the anon class').toBeDefined();
  926. expect(compute!.qualifiedName).toContain('Factory::make::<Base$anon@');
  927. expect(compute!.qualifiedName.endsWith('::compute')).toBe(true);
  928. // Anon class must extend Base so Phase 5.5 (interface-impl) can bridge.
  929. const extendsRef = result.unresolvedReferences.find(
  930. (r) => r.referenceKind === 'extends' && r.referenceName === 'Base' && r.fromNodeId === anon!.id
  931. );
  932. expect(extendsRef, 'anon class should carry an `extends Base` reference').toBeDefined();
  933. // The enclosing `make` method still emits an instantiates edge to Base —
  934. // anon extraction must not swallow that signal.
  935. const instantiatesRef = result.unresolvedReferences.find(
  936. (r) => r.referenceKind === 'instantiates' && r.referenceName === 'Base'
  937. );
  938. expect(instantiatesRef, 'enclosing method should still instantiate Base').toBeDefined();
  939. });
  940. it('extracts anonymous-class overrides inside a lambda body', () => {
  941. // The exact guava pattern: a lambda is passed to a constructor, and the
  942. // lambda body returns `new T() { @Override ... }`. The anon class must
  943. // still surface even though it sits inside a lambda_expression node.
  944. const code = `
  945. package com.example;
  946. interface Strategy {
  947. java.util.Iterator<String> iterator(String s);
  948. }
  949. abstract class BaseIter implements java.util.Iterator<String> {
  950. abstract int separatorStart(int start);
  951. }
  952. public class Splitter {
  953. private final Strategy strategy;
  954. public Splitter(Strategy s) { this.strategy = s; }
  955. public static Splitter on(char c) {
  956. return new Splitter((seq) ->
  957. new BaseIter() {
  958. @Override
  959. int separatorStart(int start) { return start + 1; }
  960. @Override public boolean hasNext() { return false; }
  961. @Override public String next() { return null; }
  962. });
  963. }
  964. }
  965. `;
  966. const result = extractFromSource('Splitter.java', code);
  967. const anon = result.nodes.find((n) => n.kind === 'class' && /BaseIter\$anon@/.test(n.name));
  968. expect(anon, 'anon BaseIter inside the lambda body should be extracted').toBeDefined();
  969. const sepStart = result.nodes.find(
  970. (n) =>
  971. n.kind === 'method' &&
  972. n.name === 'separatorStart' &&
  973. n.qualifiedName.includes('$anon@')
  974. );
  975. expect(sepStart, 'override inside the lambda-returned anon class should be a method node').toBeDefined();
  976. });
  977. });
  978. describe('C# Extraction', () => {
  979. it('should extract class declarations', () => {
  980. const code = `
  981. public class OrderService
  982. {
  983. private readonly IOrderRepository _repository;
  984. public OrderService(IOrderRepository repository)
  985. {
  986. _repository = repository;
  987. }
  988. public async Task<Order> GetOrderAsync(string id)
  989. {
  990. return await _repository.FindByIdAsync(id);
  991. }
  992. }
  993. `;
  994. const result = extractFromSource('OrderService.cs', code);
  995. const classNode = result.nodes.find((n) => n.kind === 'class');
  996. expect(classNode).toBeDefined();
  997. expect(classNode?.name).toBe('OrderService');
  998. expect(classNode?.visibility).toBe('public');
  999. });
  1000. it('indexes every record form with the right kind (#831)', () => {
  1001. // The grammar parses ALL record forms as record_declaration — there is no
  1002. // record_struct_declaration node — so the value-type forms are told apart
  1003. // by their `struct` keyword child. Positional one-liners have no body
  1004. // block and must still index (the no-body gate is for C/C++ forward
  1005. // declarations, not records).
  1006. const code = `
  1007. namespace Fixture;
  1008. public record SimplePositional(int A);
  1009. public record WithBody(int A) { public int DoubleIt() => A * 2; }
  1010. public record class ExplicitClassRec(string Name);
  1011. public record struct ValueRec(int X);
  1012. public readonly record struct ReadonlyRec(int X, int Y);
  1013. public record DerivedRec(int A, string B) : SimplePositional(A);
  1014. public record GenericRec<T>(T Value);
  1015. public partial record PartialRec(int A);
  1016. `;
  1017. const result = extractFromSource('Records.cs', code);
  1018. const kindOf = (name: string) => result.nodes.find((n) => n.name === name)?.kind;
  1019. expect(kindOf('SimplePositional')).toBe('class');
  1020. expect(kindOf('WithBody')).toBe('class');
  1021. expect(kindOf('ExplicitClassRec')).toBe('class');
  1022. expect(kindOf('DerivedRec')).toBe('class');
  1023. expect(kindOf('GenericRec')).toBe('class');
  1024. expect(kindOf('PartialRec')).toBe('class');
  1025. // Value-type records are structs, not classes.
  1026. expect(kindOf('ValueRec')).toBe('struct');
  1027. expect(kindOf('ReadonlyRec')).toBe('struct');
  1028. // Members of a bodied record still extract.
  1029. expect(kindOf('DoubleIt')).toBe('method');
  1030. });
  1031. it('indexes primary-constructor classes, including keyed-DI attribute params (#237)', () => {
  1032. // C# 12 primary constructors (`class Foo(IDep dep) { … }`) are parsed
  1033. // natively by the vendored tree-sitter-c-sharp 0.23.x grammar. The worst
  1034. // shape under the previous (older) grammar — an attribute-with-args on a
  1035. // ctor param (`[FromKeyedServices("primary")] …`, the ASP.NET keyed-DI
  1036. // pattern) — used to parse as an ERROR that swallowed the whole class, so
  1037. // the class and all its methods vanished. They now index in every case.
  1038. const code = `
  1039. public class DataService(IMemoryCache cache)
  1040. {
  1041. public void Warm() { }
  1042. }
  1043. public class InstanceService(InstanceManager m, ProfileManager p)
  1044. {
  1045. public void DeployAndLaunchAsync() { }
  1046. public void Deploy() { }
  1047. }
  1048. public partial class UpdateService(int x) : ILifetimeService
  1049. {
  1050. public void Run() { }
  1051. }
  1052. public class K1KeyedDi([FromKeyedServices("primary")] IMemoryCache cache)
  1053. {
  1054. public void Warm() { }
  1055. }
  1056. public record CatalogBrand(int Id, string Name);
  1057. `;
  1058. const result = extractFromSource('Services.cs', code);
  1059. const classNames = result.nodes.filter((n) => n.kind === 'class').map((n) => n.name);
  1060. expect(classNames).toContain('DataService');
  1061. expect(classNames).toContain('InstanceService');
  1062. expect(classNames).toContain('UpdateService'); // partial + base list
  1063. expect(classNames).toContain('K1KeyedDi'); // attribute-arg ctor param — used to vanish entirely
  1064. expect(classNames).toContain('CatalogBrand'); // record
  1065. const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
  1066. expect(methods).toContain('DeployAndLaunchAsync');
  1067. expect(methods).toContain('Deploy');
  1068. expect(methods).toContain('Run');
  1069. });
  1070. it('keeps a class indexable when a nested enum has #if-guarded members (#237)', () => {
  1071. // A `#if` directive inside an enum member list (the multi-targeting pattern
  1072. // in libraries like Newtonsoft.Json) makes the grammar emit an ERROR that,
  1073. // for a nested enum, detaches the enclosing class's member list — dropping
  1074. // most of the class's methods. A pre-parse pass blanks the directive lines
  1075. // (keeping both branches), so the class and all its methods still index.
  1076. const code = `
  1077. public class Reader
  1078. {
  1079. private enum ReadType
  1080. {
  1081. #if HAVE_DATE_TIME_OFFSET
  1082. ReadAsDateTimeOffset,
  1083. #endif
  1084. ReadAsDouble,
  1085. ReadAsString,
  1086. }
  1087. public void Open() { }
  1088. public void Close() { }
  1089. public int ReadInt() { return 0; }
  1090. }
  1091. `;
  1092. const result = extractFromSource('Reader.cs', code);
  1093. const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
  1094. // All three methods after the #if-bearing enum must survive.
  1095. expect(methods).toContain('Open');
  1096. expect(methods).toContain('Close');
  1097. expect(methods).toContain('ReadInt');
  1098. // Both enum branches are kept.
  1099. const enumMembers = result.nodes.filter((n) => n.kind === 'enum_member').map((n) => n.name);
  1100. expect(enumMembers).toContain('ReadAsDateTimeOffset');
  1101. expect(enumMembers).toContain('ReadAsDouble');
  1102. });
  1103. });
  1104. describe('PHP Extraction', () => {
  1105. it('should extract class declarations', () => {
  1106. const code = `<?php
  1107. class UserController
  1108. {
  1109. private UserService $userService;
  1110. public function __construct(UserService $userService)
  1111. {
  1112. $this->userService = $userService;
  1113. }
  1114. public function show(string $id): User
  1115. {
  1116. return $this->userService->find($id);
  1117. }
  1118. }
  1119. `;
  1120. const result = extractFromSource('UserController.php', code);
  1121. const classNode = result.nodes.find((n) => n.kind === 'class');
  1122. expect(classNode).toBeDefined();
  1123. expect(classNode?.name).toBe('UserController');
  1124. });
  1125. it('should extract class inheritance (extends) and interface implementation', () => {
  1126. const code = `<?php
  1127. class ChildController extends BaseController implements Serializable, JsonSerializable
  1128. {
  1129. public function serialize(): string
  1130. {
  1131. return json_encode($this);
  1132. }
  1133. }
  1134. `;
  1135. const result = extractFromSource('ChildController.php', code);
  1136. const classNode = result.nodes.find((n) => n.kind === 'class');
  1137. expect(classNode).toBeDefined();
  1138. expect(classNode?.name).toBe('ChildController');
  1139. const extendsRef = result.unresolvedReferences.find(
  1140. (r) => r.referenceKind === 'extends'
  1141. );
  1142. expect(extendsRef).toBeDefined();
  1143. expect(extendsRef?.referenceName).toBe('BaseController');
  1144. const implementsRefs = result.unresolvedReferences.filter(
  1145. (r) => r.referenceKind === 'implements'
  1146. );
  1147. expect(implementsRefs.length).toBe(2);
  1148. expect(implementsRefs.map((r) => r.referenceName)).toContain('Serializable');
  1149. expect(implementsRefs.map((r) => r.referenceName)).toContain('JsonSerializable');
  1150. });
  1151. });
  1152. describe('Swift Extraction', () => {
  1153. it('should extract class declarations', () => {
  1154. const code = `
  1155. public class NetworkManager {
  1156. private let session: URLSession
  1157. public init(session: URLSession = .shared) {
  1158. self.session = session
  1159. }
  1160. public func fetchData(from url: URL) async throws -> Data {
  1161. let (data, _) = try await session.data(from: url)
  1162. return data
  1163. }
  1164. }
  1165. `;
  1166. const result = extractFromSource('NetworkManager.swift', code);
  1167. const classNode = result.nodes.find((n) => n.kind === 'class');
  1168. expect(classNode).toBeDefined();
  1169. expect(classNode?.name).toBe('NetworkManager');
  1170. });
  1171. it('should extract function declarations', () => {
  1172. const code = `
  1173. func calculateSum(_ numbers: [Int]) -> Int {
  1174. return numbers.reduce(0, +)
  1175. }
  1176. public func formatCurrency(amount: Double) -> String {
  1177. return String(format: "$%.2f", amount)
  1178. }
  1179. `;
  1180. const result = extractFromSource('utils.swift', code);
  1181. const functions = result.nodes.filter((n) => n.kind === 'function');
  1182. expect(functions.length).toBeGreaterThanOrEqual(1);
  1183. });
  1184. it('should extract struct declarations', () => {
  1185. const code = `
  1186. public struct User {
  1187. let id: UUID
  1188. var name: String
  1189. var email: String
  1190. func displayName() -> String {
  1191. return name
  1192. }
  1193. }
  1194. `;
  1195. const result = extractFromSource('User.swift', code);
  1196. const structNode = result.nodes.find((n) => n.kind === 'struct');
  1197. expect(structNode).toBeDefined();
  1198. expect(structNode?.name).toBe('User');
  1199. });
  1200. it('should extract protocol declarations', () => {
  1201. const code = `
  1202. public protocol Repository {
  1203. associatedtype Entity
  1204. func find(id: String) async throws -> Entity?
  1205. func save(_ entity: Entity) async throws
  1206. }
  1207. `;
  1208. const result = extractFromSource('Repository.swift', code);
  1209. const protocolNode = result.nodes.find((n) => n.kind === 'interface');
  1210. expect(protocolNode).toBeDefined();
  1211. expect(protocolNode?.name).toBe('Repository');
  1212. });
  1213. it('should extract class inheritance and protocol conformance', () => {
  1214. const code = `
  1215. class DataRequest: Request {
  1216. func validate() {}
  1217. }
  1218. class UploadRequest: DataRequest, Sendable {
  1219. func upload() {}
  1220. }
  1221. enum AFError: Error {
  1222. case invalidURL
  1223. }
  1224. struct HTTPMethod: RawRepresentable {
  1225. let rawValue: String
  1226. }
  1227. protocol UploadConvertible: URLRequestConvertible {
  1228. func asURLRequest() throws -> URLRequest
  1229. }
  1230. `;
  1231. const result = extractFromSource('Inheritance.swift', code);
  1232. const extendsRefs = result.unresolvedReferences.filter(
  1233. (r) => r.referenceKind === 'extends'
  1234. );
  1235. // DataRequest extends Request
  1236. expect(extendsRefs.find((r) => r.referenceName === 'Request')).toBeDefined();
  1237. // UploadRequest extends DataRequest and Sendable
  1238. expect(extendsRefs.find((r) => r.referenceName === 'DataRequest')).toBeDefined();
  1239. expect(extendsRefs.find((r) => r.referenceName === 'Sendable')).toBeDefined();
  1240. // AFError extends Error
  1241. expect(extendsRefs.find((r) => r.referenceName === 'Error')).toBeDefined();
  1242. // HTTPMethod extends RawRepresentable
  1243. expect(extendsRefs.find((r) => r.referenceName === 'RawRepresentable')).toBeDefined();
  1244. // UploadConvertible extends URLRequestConvertible
  1245. expect(extendsRefs.find((r) => r.referenceName === 'URLRequestConvertible')).toBeDefined();
  1246. });
  1247. it('indexes Swift properties so they are findable: computed → property, stored → field, static → constant/variable (#1020)', () => {
  1248. const code = `
  1249. struct ReproConfig {
  1250. let reproStoredValue: Int
  1251. var reproComputedFlag: Bool {
  1252. reproStoredValue > 0
  1253. }
  1254. static let sharedLimit = 10
  1255. static var sharedCount = 0
  1256. func reproControlMethod() -> Bool {
  1257. reproComputedFlag
  1258. }
  1259. }
  1260. final class ReproService {
  1261. private let reproClassStored: String = "x"
  1262. var reproClassComputed: Int { reproClassStored.count }
  1263. }
  1264. `;
  1265. const result = extractFromSource('Repro.swift', code);
  1266. const byName = (name: string) => result.nodes.find((n) => n.name === name);
  1267. // Computed properties are the regression this fix targets: before #1020 they
  1268. // were dropped entirely, so search/explore returned nothing for them.
  1269. expect(byName('reproComputedFlag')?.kind).toBe('property');
  1270. expect(byName('reproClassComputed')?.kind).toBe('property');
  1271. // Stored instance properties stay `field` (fixed earlier in #708 — guard it).
  1272. expect(byName('reproStoredValue')?.kind).toBe('field');
  1273. expect(byName('reproClassStored')?.kind).toBe('field');
  1274. // `static let`/`static var` members remain shared constant/variable nodes.
  1275. expect(byName('sharedLimit')?.kind).toBe('constant');
  1276. expect(byName('sharedCount')?.kind).toBe('variable');
  1277. // The control method is unaffected.
  1278. expect(byName('reproControlMethod')?.kind).toBe('method');
  1279. });
  1280. it("attributes a computed property's getter calls to the property, not the type (SwiftUI body flow) (#1020)", () => {
  1281. const code = `
  1282. struct GreetingView {
  1283. let name: String
  1284. var body: some View {
  1285. let prefix = "Hi"
  1286. return VStack {
  1287. Text(greeting(prefix))
  1288. }
  1289. }
  1290. func greeting(_ p: String) -> String { p }
  1291. }
  1292. `;
  1293. const result = extractFromSource('View.swift', code);
  1294. const body = result.nodes.find((n) => n.kind === 'property' && n.name === 'body');
  1295. expect(body).toBeDefined();
  1296. // The getter's call to greeting() must originate from `body` (so a SwiftUI
  1297. // view's render flow is reachable through the property), not flatten onto the
  1298. // enclosing struct.
  1299. const callsFromBody = result.unresolvedReferences.filter(
  1300. (r) => r.fromNodeId === body!.id && r.referenceKind === 'calls'
  1301. );
  1302. expect(callsFromBody.some((r) => r.referenceName === 'greeting')).toBe(true);
  1303. // The getter is walked as a body, so a local declared inside it is NOT
  1304. // node-ified (locals are the data-flow frontier we leave uncovered). Before
  1305. // this fix the generic walker treated such a local as a struct `field`.
  1306. expect(result.nodes.find((n) => n.name === 'prefix')).toBeUndefined();
  1307. });
  1308. it('indexes a Swift protocol property requirement as a findable property (#1020)', () => {
  1309. const code = `
  1310. protocol Themable {
  1311. var accentColor: Color { get }
  1312. var title: String { get set }
  1313. }
  1314. `;
  1315. const result = extractFromSource('Themable.swift', code);
  1316. expect(result.nodes.find((n) => n.name === 'accentColor')?.kind).toBe('property');
  1317. expect(result.nodes.find((n) => n.name === 'title')?.kind).toBe('property');
  1318. });
  1319. });
  1320. describe('Kotlin Extraction', () => {
  1321. it('should extract class declarations', () => {
  1322. const code = `
  1323. class UserRepository(private val database: Database) {
  1324. fun findById(id: String): User? {
  1325. return database.query("SELECT * FROM users WHERE id = ?", id)
  1326. }
  1327. suspend fun save(user: User) {
  1328. database.insert(user)
  1329. }
  1330. }
  1331. `;
  1332. const result = extractFromSource('UserRepository.kt', code);
  1333. const classNode = result.nodes.find((n) => n.kind === 'class');
  1334. expect(classNode).toBeDefined();
  1335. expect(classNode?.name).toBe('UserRepository');
  1336. });
  1337. it('should extract function declarations', () => {
  1338. const code = `
  1339. fun calculateTotal(items: List<Item>): Double {
  1340. return items.sumOf { it.price }
  1341. }
  1342. suspend fun fetchUserData(userId: String): User {
  1343. return api.getUser(userId)
  1344. }
  1345. `;
  1346. const result = extractFromSource('utils.kt', code);
  1347. const functions = result.nodes.filter((n) => n.kind === 'function');
  1348. expect(functions.length).toBeGreaterThanOrEqual(1);
  1349. });
  1350. it('should detect suspend functions as async', () => {
  1351. const code = `
  1352. suspend fun loadData(): List<String> {
  1353. delay(1000)
  1354. return listOf("a", "b", "c")
  1355. }
  1356. `;
  1357. const result = extractFromSource('loader.kt', code);
  1358. const funcNode = result.nodes.find((n) => n.kind === 'function');
  1359. expect(funcNode).toBeDefined();
  1360. expect(funcNode?.isAsync).toBe(true);
  1361. });
  1362. it('should extract fun interface declarations', () => {
  1363. const code = `
  1364. fun interface OnObjectRetainedListener {
  1365. fun onObjectRetained()
  1366. }
  1367. `;
  1368. const result = extractFromSource('listener.kt', code);
  1369. const ifaceNode = result.nodes.find((n) => n.kind === 'interface');
  1370. expect(ifaceNode).toBeDefined();
  1371. expect(ifaceNode?.name).toBe('OnObjectRetainedListener');
  1372. const methodNode = result.nodes.find((n) => n.kind === 'method');
  1373. expect(methodNode).toBeDefined();
  1374. expect(methodNode?.name).toBe('onObjectRetained');
  1375. expect(methodNode?.qualifiedName).toBe('OnObjectRetainedListener::onObjectRetained');
  1376. });
  1377. it('should extract complex fun interface with nested classes', () => {
  1378. const code = `
  1379. fun interface EventListener {
  1380. fun onEvent(event: Event)
  1381. sealed class Event {
  1382. class DumpingHeap : Event()
  1383. }
  1384. }
  1385. `;
  1386. const result = extractFromSource('events.kt', code);
  1387. const ifaceNode = result.nodes.find((n) => n.kind === 'interface');
  1388. expect(ifaceNode).toBeDefined();
  1389. expect(ifaceNode?.name).toBe('EventListener');
  1390. // Nested sealed class should still be extracted (as sibling due to grammar limitations)
  1391. const eventClass = result.nodes.find((n) => n.kind === 'class' && n.name === 'Event');
  1392. expect(eventClass).toBeDefined();
  1393. const dumpingHeap = result.nodes.find((n) => n.kind === 'class' && n.name === 'DumpingHeap');
  1394. expect(dumpingHeap).toBeDefined();
  1395. });
  1396. it('should not affect regular function declarations', () => {
  1397. const code = `
  1398. fun interface MyCallback {
  1399. fun invoke(value: Int)
  1400. }
  1401. fun regularFunction(): String {
  1402. return "hello"
  1403. }
  1404. `;
  1405. const result = extractFromSource('mixed.kt', code);
  1406. const ifaceNode = result.nodes.find((n) => n.kind === 'interface');
  1407. expect(ifaceNode).toBeDefined();
  1408. expect(ifaceNode?.name).toBe('MyCallback');
  1409. const funcNode = result.nodes.find((n) => n.kind === 'function');
  1410. expect(funcNode).toBeDefined();
  1411. expect(funcNode?.name).toBe('regularFunction');
  1412. });
  1413. it('should extract fun interface with annotation on method (Pattern 2b)', () => {
  1414. // When the SAM method has annotations like @Throws, tree-sitter produces a different
  1415. // misparse: function_declaration > ERROR("interface Name {") instead of
  1416. // function_declaration > user_type("interface"). This is the OkHttp Interceptor pattern.
  1417. const code = `
  1418. import java.io.IOException
  1419. fun interface Interceptor {
  1420. @Throws(IOException::class)
  1421. fun intercept(chain: Chain): Response
  1422. }
  1423. `;
  1424. const result = extractFromSource('interceptor.kt', code);
  1425. const ifaceNode = result.nodes.find((n) => n.kind === 'interface');
  1426. expect(ifaceNode).toBeDefined();
  1427. expect(ifaceNode?.name).toBe('Interceptor');
  1428. });
  1429. it('should extract methods from interface with nested fun interface', () => {
  1430. // When an interface contains a nested `fun interface`, tree-sitter misparsed
  1431. // the parent body as ERROR. Methods inside should still be extracted.
  1432. const code = `
  1433. interface WebSocket {
  1434. fun request(): Request
  1435. fun send(text: String): Boolean
  1436. fun cancel()
  1437. fun interface Factory {
  1438. fun newWebSocket(request: Request): WebSocket
  1439. }
  1440. }
  1441. `;
  1442. const result = extractFromSource('websocket.kt', code);
  1443. const wsIface = result.nodes.find((n) => n.kind === 'interface' && n.name === 'WebSocket');
  1444. expect(wsIface).toBeDefined();
  1445. const methods = result.nodes.filter((n) => n.kind === 'method' && n.qualifiedName?.startsWith('WebSocket::'));
  1446. const methodNames = methods.map((m) => m.name);
  1447. expect(methodNames).toContain('request');
  1448. expect(methodNames).toContain('send');
  1449. expect(methodNames).toContain('cancel');
  1450. });
  1451. it('wraps top-level declarations in a namespace from package_header', () => {
  1452. const code = `
  1453. package com.example.foo
  1454. class Bar {
  1455. fun greet(): String = "hi"
  1456. }
  1457. fun util(): Int = 42
  1458. `;
  1459. const result = extractFromSource('Bar.kt', code);
  1460. const ns = result.nodes.find((n) => n.kind === 'namespace');
  1461. expect(ns?.name).toBe('com.example.foo');
  1462. const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
  1463. expect(cls?.qualifiedName).toBe('com.example.foo::Bar');
  1464. const greet = result.nodes.find((n) => n.kind === 'method' && n.name === 'greet');
  1465. expect(greet?.qualifiedName).toBe('com.example.foo::Bar::greet');
  1466. const util = result.nodes.find((n) => n.kind === 'function' && n.name === 'util');
  1467. expect(util?.qualifiedName).toBe('com.example.foo::util');
  1468. });
  1469. it('handles a single-segment package', () => {
  1470. const code = `
  1471. package foo
  1472. class Bar
  1473. `;
  1474. const result = extractFromSource('Bar.kt', code);
  1475. const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
  1476. expect(cls?.qualifiedName).toBe('foo::Bar');
  1477. });
  1478. it('does not wrap when no package is declared', () => {
  1479. const code = `
  1480. class Bar {
  1481. fun greet() = "hi"
  1482. }
  1483. `;
  1484. const result = extractFromSource('Bar.kt', code);
  1485. expect(result.nodes.find((n) => n.kind === 'namespace')).toBeUndefined();
  1486. const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
  1487. expect(cls?.qualifiedName).toBe('Bar');
  1488. });
  1489. });
  1490. describe('Dart Extraction', () => {
  1491. it('should extract class declarations', () => {
  1492. const code = `
  1493. class UserService {
  1494. final Database _db;
  1495. Future<User> findById(String id) async {
  1496. return await _db.query(id);
  1497. }
  1498. void _privateMethod() {}
  1499. }
  1500. `;
  1501. const result = extractFromSource('service.dart', code);
  1502. const classNode = result.nodes.find((n) => n.kind === 'class');
  1503. expect(classNode).toBeDefined();
  1504. expect(classNode?.name).toBe('UserService');
  1505. expect(classNode?.visibility).toBe('public');
  1506. const methodNodes = result.nodes.filter((n) => n.kind === 'method');
  1507. expect(methodNodes.length).toBeGreaterThanOrEqual(2);
  1508. const findById = methodNodes.find((m) => m.name === 'findById');
  1509. expect(findById).toBeDefined();
  1510. expect(findById?.isAsync).toBe(true);
  1511. const privateMethod = methodNodes.find((m) => m.name === '_privateMethod');
  1512. expect(privateMethod).toBeDefined();
  1513. expect(privateMethod?.visibility).toBe('private');
  1514. // Dart models a method body as a SIBLING of the signature, so the method
  1515. // node must be extended to span its body (not just the signature line) —
  1516. // required for body-level analysis (callees, the callback synthesizer).
  1517. expect(findById!.endLine).toBeGreaterThan(findById!.startLine);
  1518. });
  1519. it('should extract top-level function declarations', () => {
  1520. const code = `
  1521. void topLevelFunction(String name) {
  1522. print(name);
  1523. }
  1524. `;
  1525. const result = extractFromSource('utils.dart', code);
  1526. const funcNode = result.nodes.find((n) => n.kind === 'function');
  1527. expect(funcNode).toBeDefined();
  1528. expect(funcNode?.name).toBe('topLevelFunction');
  1529. expect(funcNode?.language).toBe('dart');
  1530. });
  1531. it('should extract enum declarations', () => {
  1532. const code = `
  1533. enum Status { active, inactive, pending }
  1534. `;
  1535. const result = extractFromSource('models.dart', code);
  1536. const enumNode = result.nodes.find((n) => n.kind === 'enum');
  1537. expect(enumNode).toBeDefined();
  1538. expect(enumNode?.name).toBe('Status');
  1539. });
  1540. it('should extract mixin declarations', () => {
  1541. const code = `
  1542. mixin LoggerMixin {
  1543. void log(String message) {}
  1544. }
  1545. `;
  1546. const result = extractFromSource('mixins.dart', code);
  1547. const classNode = result.nodes.find((n) => n.kind === 'class');
  1548. expect(classNode).toBeDefined();
  1549. expect(classNode?.name).toBe('LoggerMixin');
  1550. const methodNode = result.nodes.find((n) => n.kind === 'method');
  1551. expect(methodNode).toBeDefined();
  1552. expect(methodNode?.name).toBe('log');
  1553. });
  1554. it('should extract extension declarations', () => {
  1555. const code = `
  1556. extension StringExt on String {
  1557. bool get isBlank => trim().isEmpty;
  1558. }
  1559. `;
  1560. const result = extractFromSource('extensions.dart', code);
  1561. const classNode = result.nodes.find((n) => n.kind === 'class');
  1562. expect(classNode).toBeDefined();
  1563. expect(classNode?.name).toBe('StringExt');
  1564. });
  1565. it('should detect static methods', () => {
  1566. const code = `
  1567. class Utils {
  1568. static void doWork() {}
  1569. }
  1570. `;
  1571. const result = extractFromSource('utils.dart', code);
  1572. const methodNode = result.nodes.find((n) => n.kind === 'method');
  1573. expect(methodNode).toBeDefined();
  1574. expect(methodNode?.name).toBe('doWork');
  1575. expect(methodNode?.isStatic).toBe(true);
  1576. });
  1577. it('should detect async functions', () => {
  1578. const code = `
  1579. Future<String> fetchData() async {
  1580. return await http.get('/data');
  1581. }
  1582. `;
  1583. const result = extractFromSource('api.dart', code);
  1584. const funcNode = result.nodes.find((n) => n.kind === 'function');
  1585. expect(funcNode).toBeDefined();
  1586. expect(funcNode?.name).toBe('fetchData');
  1587. expect(funcNode?.isAsync).toBe(true);
  1588. });
  1589. it('should detect private visibility via underscore convention', () => {
  1590. const code = `
  1591. void _privateHelper() {}
  1592. void publicFunction() {}
  1593. `;
  1594. const result = extractFromSource('helpers.dart', code);
  1595. const functions = result.nodes.filter((n) => n.kind === 'function');
  1596. const privateFunc = functions.find((f) => f.name === '_privateHelper');
  1597. const publicFunc = functions.find((f) => f.name === 'publicFunction');
  1598. expect(privateFunc?.visibility).toBe('private');
  1599. expect(publicFunc?.visibility).toBe('public');
  1600. });
  1601. });
  1602. describe('Import Extraction', () => {
  1603. describe('TypeScript/JavaScript imports', () => {
  1604. it('should extract default imports', () => {
  1605. const code = `import React from 'react';`;
  1606. const result = extractFromSource('app.tsx', code);
  1607. const importNode = result.nodes.find((n) => n.kind === 'import');
  1608. expect(importNode).toBeDefined();
  1609. expect(importNode?.name).toBe('react');
  1610. expect(importNode?.signature).toBe("import React from 'react';");
  1611. });
  1612. it('should extract named imports', () => {
  1613. const code = `import { Bug, Database } from '@phosphor-icons/react';`;
  1614. const result = extractFromSource('icons.tsx', code);
  1615. const importNode = result.nodes.find((n) => n.kind === 'import');
  1616. expect(importNode).toBeDefined();
  1617. expect(importNode?.name).toBe('@phosphor-icons/react');
  1618. expect(importNode?.signature).toContain('Bug');
  1619. expect(importNode?.signature).toContain('Database');
  1620. });
  1621. it('should extract namespace imports', () => {
  1622. const code = `import * as Icons from '@phosphor-icons/react';`;
  1623. const result = extractFromSource('icons.tsx', code);
  1624. const importNode = result.nodes.find((n) => n.kind === 'import');
  1625. expect(importNode).toBeDefined();
  1626. expect(importNode?.name).toBe('@phosphor-icons/react');
  1627. expect(importNode?.signature).toContain('* as Icons');
  1628. });
  1629. it('should extract side-effect imports', () => {
  1630. const code = `import './styles.css';`;
  1631. const result = extractFromSource('app.tsx', code);
  1632. const importNode = result.nodes.find((n) => n.kind === 'import');
  1633. expect(importNode).toBeDefined();
  1634. expect(importNode?.name).toBe('./styles.css');
  1635. });
  1636. it('should extract mixed imports (default + named)', () => {
  1637. const code = `import React, { useState, useEffect } from 'react';`;
  1638. const result = extractFromSource('app.tsx', code);
  1639. const importNode = result.nodes.find((n) => n.kind === 'import');
  1640. expect(importNode).toBeDefined();
  1641. expect(importNode?.name).toBe('react');
  1642. expect(importNode?.signature).toContain('React');
  1643. expect(importNode?.signature).toContain('useState');
  1644. expect(importNode?.signature).toContain('useEffect');
  1645. });
  1646. it('should extract multiple import statements', () => {
  1647. const code = `
  1648. import React from 'react';
  1649. import { Button } from './components';
  1650. import './styles.css';
  1651. `;
  1652. const result = extractFromSource('app.tsx', code);
  1653. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  1654. expect(importNodes.length).toBe(3);
  1655. const names = importNodes.map((n) => n.name);
  1656. expect(names).toContain('react');
  1657. expect(names).toContain('./components');
  1658. expect(names).toContain('./styles.css');
  1659. });
  1660. it('should extract type imports', () => {
  1661. const code = `import type { FC, ReactNode } from 'react';`;
  1662. const result = extractFromSource('types.ts', code);
  1663. const importNode = result.nodes.find((n) => n.kind === 'import');
  1664. expect(importNode).toBeDefined();
  1665. expect(importNode?.name).toBe('react');
  1666. expect(importNode?.signature).toContain('type');
  1667. expect(importNode?.signature).toContain('FC');
  1668. });
  1669. it('should extract aliased named imports', () => {
  1670. const code = `import { useState as useStateAlias } from 'react';`;
  1671. const result = extractFromSource('hooks.ts', code);
  1672. const importNode = result.nodes.find((n) => n.kind === 'import');
  1673. expect(importNode).toBeDefined();
  1674. expect(importNode?.name).toBe('react');
  1675. expect(importNode?.signature).toContain('useState');
  1676. expect(importNode?.signature).toContain('useStateAlias');
  1677. });
  1678. it('should extract relative path imports', () => {
  1679. const code = `import { helper } from '../utils/helper';`;
  1680. const result = extractFromSource('components/Button.tsx', code);
  1681. const importNode = result.nodes.find((n) => n.kind === 'import');
  1682. expect(importNode).toBeDefined();
  1683. expect(importNode?.name).toBe('../utils/helper');
  1684. expect(importNode?.signature).toContain('helper');
  1685. });
  1686. });
  1687. describe('Python imports', () => {
  1688. it('should extract simple import statement', () => {
  1689. const code = `import json`;
  1690. const result = extractFromSource('utils.py', code);
  1691. const importNode = result.nodes.find((n) => n.kind === 'import');
  1692. expect(importNode).toBeDefined();
  1693. expect(importNode?.name).toBe('json');
  1694. });
  1695. it('should extract from import statement', () => {
  1696. const code = `from os import path`;
  1697. const result = extractFromSource('utils.py', code);
  1698. const importNode = result.nodes.find((n) => n.kind === 'import');
  1699. expect(importNode).toBeDefined();
  1700. expect(importNode?.name).toBe('os');
  1701. expect(importNode?.signature).toContain('path');
  1702. });
  1703. it('should extract multiple imports from same module', () => {
  1704. const code = `from typing import List, Dict, Optional`;
  1705. const result = extractFromSource('types.py', code);
  1706. const importNode = result.nodes.find((n) => n.kind === 'import');
  1707. expect(importNode).toBeDefined();
  1708. expect(importNode?.name).toBe('typing');
  1709. expect(importNode?.signature).toContain('List');
  1710. expect(importNode?.signature).toContain('Dict');
  1711. });
  1712. it('should extract multiple import statements', () => {
  1713. const code = `
  1714. import os
  1715. import sys
  1716. `;
  1717. const result = extractFromSource('main.py', code);
  1718. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  1719. expect(importNodes.length).toBe(2);
  1720. const names = importNodes.map((n) => n.name);
  1721. expect(names).toContain('os');
  1722. expect(names).toContain('sys');
  1723. });
  1724. it('should extract aliased import', () => {
  1725. const code = `import numpy as np`;
  1726. const result = extractFromSource('data.py', code);
  1727. const importNode = result.nodes.find((n) => n.kind === 'import');
  1728. expect(importNode).toBeDefined();
  1729. expect(importNode?.name).toBe('numpy');
  1730. expect(importNode?.signature).toContain('as np');
  1731. });
  1732. it('should extract relative import', () => {
  1733. const code = `from .utils import helper`;
  1734. const result = extractFromSource('module.py', code);
  1735. const importNode = result.nodes.find((n) => n.kind === 'import');
  1736. expect(importNode).toBeDefined();
  1737. expect(importNode?.name).toBe('.utils');
  1738. expect(importNode?.signature).toContain('helper');
  1739. });
  1740. it('should extract wildcard import', () => {
  1741. const code = `from typing import *`;
  1742. const result = extractFromSource('types.py', code);
  1743. const importNode = result.nodes.find((n) => n.kind === 'import');
  1744. expect(importNode).toBeDefined();
  1745. expect(importNode?.name).toBe('typing');
  1746. expect(importNode?.signature).toContain('*');
  1747. });
  1748. });
  1749. describe('Rust imports', () => {
  1750. it('should extract simple use declaration', () => {
  1751. const code = `use std::io;`;
  1752. const result = extractFromSource('main.rs', code);
  1753. const importNode = result.nodes.find((n) => n.kind === 'import');
  1754. expect(importNode).toBeDefined();
  1755. expect(importNode?.name).toBe('std');
  1756. expect(importNode?.signature).toBe('use std::io;');
  1757. });
  1758. it('should extract scoped use list', () => {
  1759. const code = `use std::{ffi::OsStr, io, path::Path};`;
  1760. const result = extractFromSource('main.rs', code);
  1761. const importNode = result.nodes.find((n) => n.kind === 'import');
  1762. expect(importNode).toBeDefined();
  1763. expect(importNode?.name).toBe('std');
  1764. expect(importNode?.signature).toContain('ffi::OsStr');
  1765. expect(importNode?.signature).toContain('path::Path');
  1766. });
  1767. it('should extract crate imports', () => {
  1768. const code = `use crate::error::Error;`;
  1769. const result = extractFromSource('lib.rs', code);
  1770. const importNode = result.nodes.find((n) => n.kind === 'import');
  1771. expect(importNode).toBeDefined();
  1772. expect(importNode?.name).toBe('crate');
  1773. });
  1774. it('should extract super imports', () => {
  1775. const code = `use super::utils;`;
  1776. const result = extractFromSource('submod.rs', code);
  1777. const importNode = result.nodes.find((n) => n.kind === 'import');
  1778. expect(importNode).toBeDefined();
  1779. expect(importNode?.name).toBe('super');
  1780. });
  1781. it('should extract external crate imports', () => {
  1782. const code = `use serde::{Serialize, Deserialize};`;
  1783. const result = extractFromSource('types.rs', code);
  1784. const importNode = result.nodes.find((n) => n.kind === 'import');
  1785. expect(importNode).toBeDefined();
  1786. expect(importNode?.name).toBe('serde');
  1787. expect(importNode?.signature).toContain('Serialize');
  1788. expect(importNode?.signature).toContain('Deserialize');
  1789. });
  1790. });
  1791. describe('Go imports', () => {
  1792. it('should extract single import', () => {
  1793. const code = `
  1794. package main
  1795. import "fmt"
  1796. `;
  1797. const result = extractFromSource('main.go', code);
  1798. const importNode = result.nodes.find((n) => n.kind === 'import');
  1799. expect(importNode).toBeDefined();
  1800. expect(importNode?.name).toBe('fmt');
  1801. });
  1802. it('should extract grouped imports', () => {
  1803. const code = `
  1804. package main
  1805. import (
  1806. "fmt"
  1807. "os"
  1808. "encoding/json"
  1809. )
  1810. `;
  1811. const result = extractFromSource('main.go', code);
  1812. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  1813. expect(importNodes.length).toBe(3);
  1814. const names = importNodes.map((n) => n.name);
  1815. expect(names).toContain('fmt');
  1816. expect(names).toContain('os');
  1817. expect(names).toContain('encoding/json');
  1818. });
  1819. it('should extract aliased import', () => {
  1820. const code = `
  1821. package main
  1822. import f "fmt"
  1823. `;
  1824. const result = extractFromSource('main.go', code);
  1825. const importNode = result.nodes.find((n) => n.kind === 'import');
  1826. expect(importNode).toBeDefined();
  1827. expect(importNode?.name).toBe('fmt');
  1828. expect(importNode?.signature).toContain('f');
  1829. });
  1830. it('should extract dot import', () => {
  1831. const code = `
  1832. package main
  1833. import . "math"
  1834. `;
  1835. const result = extractFromSource('main.go', code);
  1836. const importNode = result.nodes.find((n) => n.kind === 'import');
  1837. expect(importNode).toBeDefined();
  1838. expect(importNode?.name).toBe('math');
  1839. expect(importNode?.signature).toContain('.');
  1840. });
  1841. it('should extract blank import', () => {
  1842. const code = `
  1843. package main
  1844. import _ "github.com/go-sql-driver/mysql"
  1845. `;
  1846. const result = extractFromSource('main.go', code);
  1847. const importNode = result.nodes.find((n) => n.kind === 'import');
  1848. expect(importNode).toBeDefined();
  1849. expect(importNode?.name).toBe('github.com/go-sql-driver/mysql');
  1850. expect(importNode?.signature).toContain('_');
  1851. });
  1852. });
  1853. describe('Swift imports', () => {
  1854. it('should extract simple import', () => {
  1855. const code = `import Foundation`;
  1856. const result = extractFromSource('main.swift', code);
  1857. const importNode = result.nodes.find((n) => n.kind === 'import');
  1858. expect(importNode).toBeDefined();
  1859. expect(importNode?.name).toBe('Foundation');
  1860. expect(importNode?.signature).toBe('import Foundation');
  1861. });
  1862. it('should extract @testable import', () => {
  1863. const code = `@testable import Alamofire`;
  1864. const result = extractFromSource('Tests.swift', code);
  1865. const importNode = result.nodes.find((n) => n.kind === 'import');
  1866. expect(importNode).toBeDefined();
  1867. expect(importNode?.name).toBe('Alamofire');
  1868. expect(importNode?.signature).toContain('@testable');
  1869. });
  1870. it('should extract @preconcurrency import', () => {
  1871. const code = `@preconcurrency import Security`;
  1872. const result = extractFromSource('Auth.swift', code);
  1873. const importNode = result.nodes.find((n) => n.kind === 'import');
  1874. expect(importNode).toBeDefined();
  1875. expect(importNode?.name).toBe('Security');
  1876. });
  1877. it('should extract multiple imports', () => {
  1878. const code = `
  1879. import Foundation
  1880. import UIKit
  1881. import Alamofire
  1882. `;
  1883. const result = extractFromSource('App.swift', code);
  1884. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  1885. expect(importNodes.length).toBe(3);
  1886. const names = importNodes.map((n) => n.name);
  1887. expect(names).toContain('Foundation');
  1888. expect(names).toContain('UIKit');
  1889. expect(names).toContain('Alamofire');
  1890. });
  1891. });
  1892. describe('Kotlin imports', () => {
  1893. it('should extract simple import', () => {
  1894. const code = `import java.io.IOException`;
  1895. const result = extractFromSource('Main.kt', code);
  1896. const importNode = result.nodes.find((n) => n.kind === 'import');
  1897. expect(importNode).toBeDefined();
  1898. expect(importNode?.name).toBe('java.io.IOException');
  1899. expect(importNode?.signature).toBe('import java.io.IOException');
  1900. });
  1901. it('should extract aliased import', () => {
  1902. const code = `import okhttp3.Request.Builder as RequestBuilder`;
  1903. const result = extractFromSource('Utils.kt', code);
  1904. const importNode = result.nodes.find((n) => n.kind === 'import');
  1905. expect(importNode).toBeDefined();
  1906. expect(importNode?.name).toBe('okhttp3.Request.Builder');
  1907. expect(importNode?.signature).toContain('as RequestBuilder');
  1908. });
  1909. it('should extract wildcard import', () => {
  1910. const code = `import java.util.concurrent.TimeUnit.*`;
  1911. const result = extractFromSource('Time.kt', code);
  1912. const importNode = result.nodes.find((n) => n.kind === 'import');
  1913. expect(importNode).toBeDefined();
  1914. expect(importNode?.name).toBe('java.util.concurrent.TimeUnit');
  1915. expect(importNode?.signature).toContain('.*');
  1916. });
  1917. it('should extract multiple imports', () => {
  1918. const code = `
  1919. import java.io.IOException
  1920. import kotlin.test.assertFailsWith
  1921. import okhttp3.OkHttpClient
  1922. `;
  1923. const result = extractFromSource('Test.kt', code);
  1924. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  1925. expect(importNodes.length).toBe(3);
  1926. const names = importNodes.map((n) => n.name);
  1927. expect(names).toContain('java.io.IOException');
  1928. expect(names).toContain('kotlin.test.assertFailsWith');
  1929. expect(names).toContain('okhttp3.OkHttpClient');
  1930. });
  1931. });
  1932. describe('Java imports', () => {
  1933. it('should extract simple import', () => {
  1934. const code = `import java.util.List;`;
  1935. const result = extractFromSource('Main.java', code);
  1936. const importNode = result.nodes.find((n) => n.kind === 'import');
  1937. expect(importNode).toBeDefined();
  1938. expect(importNode?.name).toBe('java.util.List');
  1939. expect(importNode?.signature).toBe('import java.util.List;');
  1940. });
  1941. it('should extract static import', () => {
  1942. const code = `import static java.util.Collections.emptyList;`;
  1943. const result = extractFromSource('Utils.java', code);
  1944. const importNode = result.nodes.find((n) => n.kind === 'import');
  1945. expect(importNode).toBeDefined();
  1946. expect(importNode?.name).toBe('java.util.Collections.emptyList');
  1947. expect(importNode?.signature).toContain('static');
  1948. });
  1949. it('should extract wildcard import', () => {
  1950. const code = `import java.util.*;`;
  1951. const result = extractFromSource('App.java', code);
  1952. const importNode = result.nodes.find((n) => n.kind === 'import');
  1953. expect(importNode).toBeDefined();
  1954. expect(importNode?.name).toBe('java.util');
  1955. expect(importNode?.signature).toContain('.*');
  1956. });
  1957. it('should extract nested class import', () => {
  1958. const code = `import java.util.Map.Entry;`;
  1959. const result = extractFromSource('MapUtil.java', code);
  1960. const importNode = result.nodes.find((n) => n.kind === 'import');
  1961. expect(importNode).toBeDefined();
  1962. expect(importNode?.name).toBe('java.util.Map.Entry');
  1963. });
  1964. it('should extract multiple imports', () => {
  1965. const code = `
  1966. import java.util.List;
  1967. import java.util.Map;
  1968. import java.io.IOException;
  1969. `;
  1970. const result = extractFromSource('Service.java', code);
  1971. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  1972. expect(importNodes.length).toBe(3);
  1973. const names = importNodes.map((n) => n.name);
  1974. expect(names).toContain('java.util.List');
  1975. expect(names).toContain('java.util.Map');
  1976. expect(names).toContain('java.io.IOException');
  1977. });
  1978. });
  1979. describe('C# imports', () => {
  1980. it('should extract simple using', () => {
  1981. const code = `using System;`;
  1982. const result = extractFromSource('Program.cs', code);
  1983. const importNode = result.nodes.find((n) => n.kind === 'import');
  1984. expect(importNode).toBeDefined();
  1985. expect(importNode?.name).toBe('System');
  1986. expect(importNode?.signature).toBe('using System;');
  1987. });
  1988. it('should extract qualified using', () => {
  1989. const code = `using System.Collections.Generic;`;
  1990. const result = extractFromSource('Utils.cs', code);
  1991. const importNode = result.nodes.find((n) => n.kind === 'import');
  1992. expect(importNode).toBeDefined();
  1993. expect(importNode?.name).toBe('System.Collections.Generic');
  1994. });
  1995. it('should extract static using', () => {
  1996. const code = `using static System.Console;`;
  1997. const result = extractFromSource('App.cs', code);
  1998. const importNode = result.nodes.find((n) => n.kind === 'import');
  1999. expect(importNode).toBeDefined();
  2000. expect(importNode?.name).toBe('System.Console');
  2001. expect(importNode?.signature).toContain('static');
  2002. });
  2003. it('should extract alias using', () => {
  2004. const code = `using MyList = System.Collections.Generic.List<int>;`;
  2005. const result = extractFromSource('Types.cs', code);
  2006. const importNode = result.nodes.find((n) => n.kind === 'import');
  2007. expect(importNode).toBeDefined();
  2008. expect(importNode?.name).toBe('System.Collections.Generic.List<int>');
  2009. expect(importNode?.signature).toContain('MyList =');
  2010. });
  2011. it('should extract multiple usings', () => {
  2012. const code = `
  2013. using System;
  2014. using System.Threading.Tasks;
  2015. using Microsoft.Extensions.DependencyInjection;
  2016. `;
  2017. const result = extractFromSource('Service.cs', code);
  2018. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  2019. expect(importNodes.length).toBe(3);
  2020. const names = importNodes.map((n) => n.name);
  2021. expect(names).toContain('System');
  2022. expect(names).toContain('System.Threading.Tasks');
  2023. expect(names).toContain('Microsoft.Extensions.DependencyInjection');
  2024. });
  2025. });
  2026. describe('PHP imports', () => {
  2027. it('should extract simple use', () => {
  2028. const code = `<?php use PHPUnit\\Framework\\TestCase;`;
  2029. const result = extractFromSource('Test.php', code);
  2030. const importNode = result.nodes.find((n) => n.kind === 'import');
  2031. expect(importNode).toBeDefined();
  2032. expect(importNode?.name).toBe('PHPUnit\\Framework\\TestCase');
  2033. });
  2034. it('should extract aliased use', () => {
  2035. const code = `<?php use Mockery as m;`;
  2036. const result = extractFromSource('Test.php', code);
  2037. const importNode = result.nodes.find((n) => n.kind === 'import');
  2038. expect(importNode).toBeDefined();
  2039. expect(importNode?.name).toBe('Mockery');
  2040. expect(importNode?.signature).toContain('as m');
  2041. });
  2042. it('should extract function use', () => {
  2043. const code = `<?php use function Illuminate\\Support\\env;`;
  2044. const result = extractFromSource('helpers.php', code);
  2045. const importNode = result.nodes.find((n) => n.kind === 'import');
  2046. expect(importNode).toBeDefined();
  2047. expect(importNode?.name).toBe('Illuminate\\Support\\env');
  2048. expect(importNode?.signature).toContain('function');
  2049. });
  2050. it('should extract grouped use', () => {
  2051. const code = `<?php use Illuminate\\Database\\{Model, Builder};`;
  2052. const result = extractFromSource('Models.php', code);
  2053. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  2054. expect(importNodes.length).toBe(2);
  2055. const names = importNodes.map((n) => n.name);
  2056. expect(names).toContain('Illuminate\\Database\\Model');
  2057. expect(names).toContain('Illuminate\\Database\\Builder');
  2058. });
  2059. it('should extract multiple uses', () => {
  2060. const code = `<?php
  2061. use Illuminate\\Support\\Collection;
  2062. use Illuminate\\Support\\Str;
  2063. use Closure;
  2064. `;
  2065. const result = extractFromSource('Service.php', code);
  2066. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  2067. expect(importNodes.length).toBe(3);
  2068. const names = importNodes.map((n) => n.name);
  2069. expect(names).toContain('Illuminate\\Support\\Collection');
  2070. expect(names).toContain('Illuminate\\Support\\Str');
  2071. expect(names).toContain('Closure');
  2072. });
  2073. it('should extract include/require (+_once) static paths as imports (#660)', () => {
  2074. const code = `<?php
  2075. require_once("lib.php");
  2076. include 'other.php';
  2077. require 'r.php';
  2078. include_once("io.php");
  2079. `;
  2080. const result = extractFromSource('page.php', code);
  2081. const names = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  2082. expect(names).toContain('lib.php');
  2083. expect(names).toContain('other.php');
  2084. expect(names).toContain('r.php');
  2085. expect(names).toContain('io.php');
  2086. });
  2087. it('should skip dynamic include/require with no static path (#660)', () => {
  2088. const code = `<?php
  2089. require_once(__DIR__ . '/dyn.php');
  2090. include $file;
  2091. include "tpl/{$name}.php";
  2092. `;
  2093. const result = extractFromSource('page.php', code);
  2094. const imports = result.nodes.filter((n) => n.kind === 'import');
  2095. expect(imports).toHaveLength(0);
  2096. });
  2097. it('should extract include alongside namespace use without interference (#660)', () => {
  2098. const code = `<?php
  2099. use App\\Service\\Mailer;
  2100. require_once("bootstrap.php");
  2101. `;
  2102. const result = extractFromSource('page.php', code);
  2103. const names = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  2104. expect(names).toContain('App\\Service\\Mailer');
  2105. expect(names).toContain('bootstrap.php');
  2106. });
  2107. });
  2108. describe('Ruby imports', () => {
  2109. it('should extract require', () => {
  2110. const code = `require 'json'`;
  2111. const result = extractFromSource('app.rb', code);
  2112. const importNode = result.nodes.find((n) => n.kind === 'import');
  2113. expect(importNode).toBeDefined();
  2114. expect(importNode?.name).toBe('json');
  2115. expect(importNode?.signature).toBe("require 'json'");
  2116. });
  2117. it('should extract require with path', () => {
  2118. const code = `require 'active_support/core_ext/string'`;
  2119. const result = extractFromSource('config.rb', code);
  2120. const importNode = result.nodes.find((n) => n.kind === 'import');
  2121. expect(importNode).toBeDefined();
  2122. expect(importNode?.name).toBe('active_support/core_ext/string');
  2123. });
  2124. it('should extract require_relative', () => {
  2125. const code = `require_relative '../test_helper'`;
  2126. const result = extractFromSource('test/my_test.rb', code);
  2127. const importNode = result.nodes.find((n) => n.kind === 'import');
  2128. expect(importNode).toBeDefined();
  2129. expect(importNode?.name).toBe('../test_helper');
  2130. expect(importNode?.signature).toContain('require_relative');
  2131. });
  2132. it('should not extract non-require calls', () => {
  2133. const code = `puts 'hello'`;
  2134. const result = extractFromSource('app.rb', code);
  2135. const importNode = result.nodes.find((n) => n.kind === 'import');
  2136. expect(importNode).toBeUndefined();
  2137. });
  2138. it('should extract multiple requires', () => {
  2139. const code = `
  2140. require 'json'
  2141. require 'yaml'
  2142. require_relative 'helper'
  2143. `;
  2144. const result = extractFromSource('lib.rb', code);
  2145. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  2146. expect(importNodes.length).toBe(3);
  2147. const names = importNodes.map((n) => n.name);
  2148. expect(names).toContain('json');
  2149. expect(names).toContain('yaml');
  2150. expect(names).toContain('helper');
  2151. });
  2152. });
  2153. describe('Ruby modules', () => {
  2154. it('should extract module as module node with containment', () => {
  2155. const code = `
  2156. module CachedCounting
  2157. def self.disable
  2158. @enabled = false
  2159. end
  2160. def perform_increment!(key, count)
  2161. write_cache!(key, count)
  2162. end
  2163. end
  2164. `;
  2165. const result = extractFromSource('concerns/cached_counting.rb', code);
  2166. const moduleNode = result.nodes.find((n) => n.kind === 'module' && n.name === 'CachedCounting');
  2167. expect(moduleNode).toBeDefined();
  2168. expect(moduleNode?.qualifiedName).toBe('CachedCounting');
  2169. // Methods inside module should have module-qualified names
  2170. const disableMethod = result.nodes.find((n) => n.name === 'disable' && n.kind === 'method');
  2171. expect(disableMethod).toBeDefined();
  2172. expect(disableMethod?.qualifiedName).toBe('CachedCounting::disable');
  2173. const incrementMethod = result.nodes.find((n) => n.name === 'perform_increment!' && n.kind === 'method');
  2174. expect(incrementMethod).toBeDefined();
  2175. expect(incrementMethod?.qualifiedName).toBe('CachedCounting::perform_increment!');
  2176. // Containment edge from module to methods
  2177. const containsEdges = result.edges.filter((e) => e.source === moduleNode?.id && e.kind === 'contains');
  2178. expect(containsEdges.length).toBeGreaterThanOrEqual(2);
  2179. });
  2180. it('should handle nested modules with classes', () => {
  2181. const code = `
  2182. module Discourse
  2183. module Auth
  2184. class AuthProvider
  2185. def authenticate(params)
  2186. validate(params)
  2187. end
  2188. end
  2189. end
  2190. end
  2191. `;
  2192. const result = extractFromSource('lib/auth.rb', code);
  2193. const discourseModule = result.nodes.find((n) => n.kind === 'module' && n.name === 'Discourse');
  2194. expect(discourseModule).toBeDefined();
  2195. const authModule = result.nodes.find((n) => n.kind === 'module' && n.name === 'Auth');
  2196. expect(authModule).toBeDefined();
  2197. expect(authModule?.qualifiedName).toBe('Discourse::Auth');
  2198. const authProvider = result.nodes.find((n) => n.kind === 'class' && n.name === 'AuthProvider');
  2199. expect(authProvider).toBeDefined();
  2200. expect(authProvider?.qualifiedName).toBe('Discourse::Auth::AuthProvider');
  2201. const authMethod = result.nodes.find((n) => n.name === 'authenticate');
  2202. expect(authMethod).toBeDefined();
  2203. expect(authMethod?.qualifiedName).toBe('Discourse::Auth::AuthProvider::authenticate');
  2204. });
  2205. });
  2206. describe('PHP return type capture (#608)', () => {
  2207. it('captures self/static factory returns as the `self` marker; primitives as undefined', () => {
  2208. const code = `<?php
  2209. class ApiClient {
  2210. public static function for(string $c): self { return new self; }
  2211. public static function make(): static { return new static; }
  2212. public function send(array $p): array { return []; }
  2213. }`;
  2214. const result = extractFromSource('ApiClient.php', code);
  2215. expect(result.nodes.find((n) => n.name === 'for' && n.kind === 'method')?.returnType).toBe('self');
  2216. expect(result.nodes.find((n) => n.name === 'make' && n.kind === 'method')?.returnType).toBe('self');
  2217. // `array` is not a class to chain on → no return type recorded.
  2218. expect(result.nodes.find((n) => n.name === 'send' && n.kind === 'method')?.returnType).toBeUndefined();
  2219. });
  2220. it('captures a concrete return type as its short class name', () => {
  2221. const code = `<?php
  2222. namespace App;
  2223. class WidgetFactory { public static function make(): Widget { return new Widget(); } }`;
  2224. const result = extractFromSource('WidgetFactory.php', code);
  2225. expect(result.nodes.find((n) => n.name === 'make' && n.kind === 'method')?.returnType).toBe('Widget');
  2226. });
  2227. });
  2228. describe('C/C++ return type capture (#645)', () => {
  2229. it('captures the normalized return type of a C++ method/function', () => {
  2230. const code = `
  2231. struct Widget { void draw(); };
  2232. class Factory { public: static Widget create(); };
  2233. Widget Factory::create() { return Widget(); }
  2234. void doNothing() {}
  2235. `;
  2236. const result = extractFromSource('f.cpp', code);
  2237. const create = result.nodes.find(
  2238. (n) => n.name === 'create' && (n.kind === 'method' || n.kind === 'function')
  2239. );
  2240. expect(create?.returnType).toBe('Widget');
  2241. // A `void` return records no type, so resolution never tries to resolve a
  2242. // method on it.
  2243. const doNothing = result.nodes.find((n) => n.name === 'doNothing');
  2244. expect(doNothing).toBeDefined();
  2245. expect(doNothing?.returnType).toBeUndefined();
  2246. });
  2247. it('unwraps a smart-pointer return type to its pointee', () => {
  2248. const code = `
  2249. #include <memory>
  2250. struct Widget {};
  2251. std::unique_ptr<Widget> makeWidget() { return nullptr; }
  2252. `;
  2253. const result = extractFromSource('f.cpp', code);
  2254. const make = result.nodes.find((n) => n.name === 'makeWidget');
  2255. expect(make?.returnType).toBe('Widget');
  2256. });
  2257. });
  2258. describe('C++ macro-prefixed class/struct misparse (#946 → recovered in #1061)', () => {
  2259. // An export/visibility macro before the class name (`class MACRO Name :
  2260. // public Base { … }`) makes tree-sitter read `class MACRO` as an elaborated
  2261. // type and the whole declaration as a function_definition named after the
  2262. // class — a phantom `function` that polluted callers/impact/blast-radius.
  2263. // #946 dropped that phantom; #1061's preParse (`blankCppExportMacros`) now
  2264. // blanks the ALL-CAPS macro before parsing, so the class parses normally and
  2265. // is *recovered* — node, members, and base edge all present — not just
  2266. // de-phantomed. The #946 drop survives as the fallback for any residual
  2267. // misparse the blanking doesn't catch.
  2268. it('recovers a macro-annotated class that inherits (no phantom, real class + base edge)', () => {
  2269. const code = `#pragma once
  2270. #define MAPCORE_EXPORT __attribute__((visibility("default")))
  2271. class DataProvider {
  2272. public:
  2273. virtual bool Request(void* param) = 0;
  2274. };
  2275. class MAPCORE_EXPORT LocalDataProvider : public DataProvider
  2276. {
  2277. public:
  2278. LocalDataProvider(int dataType);
  2279. virtual bool Request(void* param) override;
  2280. };
  2281. `;
  2282. // A header rich in C++ (class / public: / virtual) detects as C++ — the
  2283. // issue's exact scenario (a `.h` file). Guard it so a detection regression
  2284. // can't make this test pass for the wrong reason.
  2285. expect(detectLanguage('provider.h', code)).toBe('cpp');
  2286. const result = extractFromSource('provider.h', code);
  2287. // The misparse used to surface as `function | LocalDataProvider` spanning
  2288. // the whole class body — a false caller in the graph. It's gone.
  2289. expect(
  2290. result.nodes.find((n) => n.name === 'LocalDataProvider' && n.kind === 'function')
  2291. ).toBeUndefined();
  2292. // …and the class is now recovered (was dropped under #946), with its
  2293. // `extends DataProvider` edge — the whole point of #1061.
  2294. expect(result.nodes.find((n) => n.name === 'LocalDataProvider')?.kind).toBe('class');
  2295. expect(
  2296. result.unresolvedReferences.find(
  2297. (r) => r.referenceKind === 'extends' && r.referenceName === 'DataProvider'
  2298. )
  2299. ).toBeTruthy();
  2300. // The sibling class without the macro is unaffected — still a class.
  2301. expect(result.nodes.find((n) => n.name === 'DataProvider')?.kind).toBe('class');
  2302. });
  2303. it('recovers the struct variant too, without disturbing a genuine class', () => {
  2304. const code = `
  2305. #define API __declspec(dllexport)
  2306. struct API Widget : public Base { int x; };
  2307. class Plain : public Base { public: int y; };
  2308. `;
  2309. const result = extractFromSource('widget.cpp', code);
  2310. // `struct MACRO Name : Base { … }` misparses the same way — no phantom
  2311. // function, and the struct is recovered with its base edge.
  2312. expect(
  2313. result.nodes.find((n) => n.name === 'Widget' && n.kind === 'function')
  2314. ).toBeUndefined();
  2315. expect(result.nodes.find((n) => n.name === 'Widget')?.kind).toBe('struct');
  2316. // A normal class with a base clause and no macro is untouched.
  2317. expect(result.nodes.find((n) => n.name === 'Plain')?.kind).toBe('class');
  2318. const exts = result.unresolvedReferences
  2319. .filter((r) => r.referenceKind === 'extends')
  2320. .map((r) => r.referenceName);
  2321. expect(exts.filter((n) => n === 'Base').length).toBe(2); // Widget + Plain both extend Base
  2322. });
  2323. });
  2324. describe('C++ export-macro class recovery (#1061)', () => {
  2325. // Unreal-Engine style: `class MYGAME_API UMyComponent : public UActorComponent`.
  2326. // The leading `*_API` macro alone (base clause or not) triggers the #946
  2327. // misparse and dropped the class — breaking subclass / type-hierarchy /
  2328. // inheritance-impact queries for effectively every gameplay class in a UE
  2329. // project. blankCppExportMacros recovers them.
  2330. it('recovers UE *_API classes and the inheritance edge (the issue repro)', () => {
  2331. const code = `class ENGINE_API UActorComponent { };
  2332. class MYGAME_API UMyComponent : public UActorComponent { };
  2333. `;
  2334. const result = extractFromSource('ue.cpp', code);
  2335. const classes = result.nodes.filter((n) => n.kind === 'class').map((n) => n.name);
  2336. expect(classes).toContain('UActorComponent'); // macro, no base — also was dropped
  2337. expect(classes).toContain('UMyComponent');
  2338. expect(result.nodes.find((n) => n.kind === 'function')).toBeUndefined(); // no phantom
  2339. expect(
  2340. result.unresolvedReferences.find(
  2341. (r) => r.referenceKind === 'extends' && r.referenceName === 'UActorComponent'
  2342. )
  2343. ).toBeTruthy();
  2344. });
  2345. it('blankCppExportMacros blanks only the header macro, offset-preserving', () => {
  2346. // Blanking replaces the macro with equal-length spaces, so the output is
  2347. // byte-for-byte the same length and identical *except* the macro is gone —
  2348. // every downstream line/column stays exact.
  2349. const check = (inp: string, macro: string, rest: string) => {
  2350. const out = blankCppExportMacros(inp);
  2351. expect(out.length).toBe(inp.length); // every byte offset preserved
  2352. expect(out).not.toContain(macro); // the macro token is blanked
  2353. expect(out.replace(/ +/g, ' ')).toBe(rest); // nothing else changed
  2354. };
  2355. // Generalizes across the export-macro space: UE _API, Qt/Boost _EXPORT,
  2356. // LLVM _ABI, bare API.
  2357. check(
  2358. 'class MYGAME_API UMyComponent : public UActorComponent { };',
  2359. 'MYGAME_API',
  2360. 'class UMyComponent : public UActorComponent { };'
  2361. );
  2362. check('struct MAPCORE_EXPORT W : B {}', 'MAPCORE_EXPORT', 'struct W : B {}');
  2363. check('class LLVM_ABI Foo {}', 'LLVM_ABI', 'class Foo {}');
  2364. });
  2365. it('does NOT blank an all-caps class NAME or an elaborated-type var decl', () => {
  2366. // The name itself being ALL-CAPS (with or without a base) must survive —
  2367. // the macro is only the token *before* the name, gated on a `: { ` def.
  2368. for (const c of [
  2369. 'class FOO { int x; };',
  2370. 'class FOO : public Base { int x; };',
  2371. 'struct BAR : public Base { int y; };',
  2372. 'enum class COLOR { Red, Green };',
  2373. // elaborated-type variable declarations end in ; = [ — never : {
  2374. 'struct FOO bar;',
  2375. 'class FOO obj = make();',
  2376. 'struct FOO arr[10];',
  2377. // a *_API macro used as an ordinary value elsewhere
  2378. 'int x = SOME_API; void f() { use(MYMODULE_API); }',
  2379. ]) {
  2380. expect(blankCppExportMacros(c)).toBe(c);
  2381. }
  2382. // And the all-caps-named class keeps its base edge through real extraction.
  2383. const result = extractFromSource('ctrl.cpp', 'class FOO : public Base { int x; };');
  2384. expect(result.nodes.find((n) => n.name === 'FOO')?.kind).toBe('class');
  2385. expect(
  2386. result.unresolvedReferences.find(
  2387. (r) => r.referenceKind === 'extends' && r.referenceName === 'Base'
  2388. )
  2389. ).toBeTruthy();
  2390. });
  2391. });
  2392. describe('Metal shader extraction (#1121)', () => {
  2393. // Metal Shading Language (≈ C++14) parses with the C++ grammar. MSL puts
  2394. // `[[attribute]]` annotations AFTER the declarator — a position
  2395. // tree-sitter-cpp misparses: a struct field with a trailing attribute
  2396. // emitted a spurious `extends` ref from the struct to the field's own type.
  2397. // blankMetalAttributes (preParse, `.metal`-gated) blanks them so extraction
  2398. // matches plain C++.
  2399. const METAL = `#include <metal_stdlib>
  2400. using namespace metal;
  2401. struct VertexIn {
  2402. float3 position [[attribute(0)]];
  2403. float2 texCoord [[attribute(1)]];
  2404. };
  2405. struct VertexOut {
  2406. float4 position [[position]];
  2407. float2 texCoord;
  2408. };
  2409. struct Uniforms {
  2410. float4x4 modelViewProjection;
  2411. };
  2412. static float4 applyGamma(float4 color) {
  2413. return pow(color, float4(1.0 / 2.2));
  2414. }
  2415. vertex VertexOut vertexShader(VertexIn in [[stage_in]],
  2416. constant Uniforms &uniforms [[buffer(0)]]) {
  2417. VertexOut out;
  2418. out.position = uniforms.modelViewProjection * float4(in.position, 1.0);
  2419. out.texCoord = in.texCoord;
  2420. return out;
  2421. }
  2422. fragment float4 fragmentShader(VertexOut in [[stage_in]],
  2423. texture2d<float> colorTexture [[texture(0)]],
  2424. sampler textureSampler [[sampler(0)]]) {
  2425. float4 color = colorTexture.sample(textureSampler, in.texCoord);
  2426. return applyGamma(color);
  2427. }
  2428. kernel void computeBlur(texture2d<float, access::read> inTexture [[texture(0)]],
  2429. texture2d<float, access::write> outTexture [[texture(1)]],
  2430. uint2 gid [[thread_position_in_grid]]) {
  2431. float4 color = inTexture.read(gid);
  2432. outTexture.write(color, gid);
  2433. }
  2434. `;
  2435. it('extracts vertex/fragment/kernel functions, structs, and calls from a .metal file', () => {
  2436. const result = extractFromSource('Shaders.metal', METAL);
  2437. expect(result.errors).toHaveLength(0);
  2438. const functions = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  2439. expect(functions).toEqual(
  2440. expect.arrayContaining(['applyGamma', 'vertexShader', 'fragmentShader', 'computeBlur'])
  2441. );
  2442. const structs = result.nodes.filter((n) => n.kind === 'struct').map((n) => n.name);
  2443. expect(structs).toEqual(expect.arrayContaining(['VertexIn', 'VertexOut', 'Uniforms']));
  2444. expect(result.nodes.find((n) => n.kind === 'import')?.name).toBe('metal_stdlib');
  2445. // Attribute blanking is offset-preserving, so positions stay exact.
  2446. const vertexFn = result.nodes.find((n) => n.name === 'vertexShader')!;
  2447. expect(vertexFn.startLine).toBe(22);
  2448. // The shader call graph connects: fragmentShader → applyGamma.
  2449. expect(
  2450. result.unresolvedReferences.find(
  2451. (r) => r.referenceKind === 'calls' && r.referenceName === 'applyGamma'
  2452. )
  2453. ).toBeTruthy();
  2454. // The regression the blanking fixes: field attributes (`float3 position
  2455. // [[attribute(0)]];`) misparsed into `extends` refs from the struct to the
  2456. // field's type — a wrong inheritance edge whenever the repo defines that
  2457. // type itself (simd typedefs in a shared ShaderTypes.h are common).
  2458. expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'extends')).toHaveLength(0);
  2459. });
  2460. it('blankMetalAttributes blanks every attribute form, offset-preserving', () => {
  2461. const inp = [
  2462. 'float4 position [[position]];',
  2463. 'constant Uniforms &u [[buffer(0)]]',
  2464. 'float2 uv [[user(locn0)]];',
  2465. 'device float *out [[buffer(0), raster_order_group(0)]]',
  2466. ].join('\n');
  2467. const out = blankMetalAttributes(inp);
  2468. expect(out.length).toBe(inp.length); // every byte offset preserved
  2469. expect(out).not.toContain('[[');
  2470. // Nothing but the attributes changed: collapsing the blank runs gives the
  2471. // plain declarations back, newlines untouched.
  2472. expect(out.split('\n').map((l) => l.replace(/ +/g, ' ').trimEnd())).toEqual([
  2473. 'float4 position ;',
  2474. 'constant Uniforms &u',
  2475. 'float2 uv ;',
  2476. 'device float *out',
  2477. ]);
  2478. });
  2479. it('blankMetalAttributes never touches non-attribute [[ sequences', () => {
  2480. for (const c of [
  2481. 'auto x = arr[[]{ return 0; }()];', // lambda in subscript — the only other [[ in C++-family code
  2482. 'int y = a[b[i]];', // nested subscript
  2483. 'int z = 1;', // no [[ at all — early-return path
  2484. ]) {
  2485. expect(blankMetalAttributes(c)).toBe(c);
  2486. }
  2487. });
  2488. });
  2489. describe('C++ forward declarations do not mint phantom class nodes (#1093)', () => {
  2490. // `class Foo;` parses as a bodiless class_specifier. Repeated across headers,
  2491. // each forward decl minted a phantom bodiless `class` node that crowded out —
  2492. // and could be picked as the blast-radius representative over — the single
  2493. // real definition. Bodiless struct/enum specifiers were already skipped;
  2494. // classes now are too, but ONLY for C/C++ (opt-in flag), never for languages
  2495. // where a bodiless class is a complete definition.
  2496. it('keeps only the real definition, dropping repeated forward decls', () => {
  2497. const code = `
  2498. class APXCharacter; // forward decl (header 1)
  2499. class APXCharacter; // forward decl (header 2)
  2500. friend class APXCharacter; // elaborated / friend forward reference
  2501. class APXCharacter { // the one real definition
  2502. int hp;
  2503. void takeDamage(int amount) { hp -= amount; }
  2504. };
  2505. `;
  2506. const result = extractFromSource('character.cpp', code);
  2507. const classes = result.nodes.filter(
  2508. (n) => n.kind === 'class' && n.name === 'APXCharacter'
  2509. );
  2510. // Exactly one class node, and it's the definition (carries the member).
  2511. expect(classes).toHaveLength(1);
  2512. expect(classes[0].startLine).toBe(6);
  2513. expect(
  2514. result.nodes.some((n) => n.kind === 'method' && n.name === 'takeDamage')
  2515. ).toBe(true);
  2516. });
  2517. it('elaborated type references in declarations create no phantom class', () => {
  2518. // `class Foo obj;` is a variable declaration using an elaborated type, not
  2519. // a class definition — it must not mint a `Foo` class node.
  2520. const result = extractFromSource('use.cpp', 'class Foo;\nvoid f() { class Foo *p = nullptr; (void)p; }\n');
  2521. expect(result.nodes.filter((n) => n.kind === 'class' && n.name === 'Foo')).toHaveLength(0);
  2522. });
  2523. it('does NOT affect languages where a bodiless class is complete', () => {
  2524. // Kotlin `class Empty` and Scala `trait`/`case object`/`class` with no body
  2525. // are complete definitions — the C/C++-only skip must leave them indexed.
  2526. const kt = extractFromSource('Empty.kt', 'class Empty\nclass Full { val x = 1 }\n');
  2527. const ktClasses = kt.nodes.filter((n) => n.kind === 'class').map((n) => n.name);
  2528. expect(ktClasses).toContain('Empty');
  2529. expect(ktClasses).toContain('Full');
  2530. const scala = extractFromSource('M.scala', 'trait Marker\ncase object Red\nclass Foo\n');
  2531. const scalaNames = scala.nodes
  2532. .filter((n) => ['class', 'trait', 'interface'].includes(n.kind))
  2533. .map((n) => n.name);
  2534. expect(scalaNames).toEqual(expect.arrayContaining(['Marker', 'Red', 'Foo']));
  2535. });
  2536. });
  2537. describe('C++ reference-return method/function names (#1093 follow-up)', () => {
  2538. // An inline method/function returning a reference parses with a
  2539. // `reference_declarator` wrapping the `function_declarator`. That wrapper
  2540. // wasn't unwrapped (only `pointer_declarator` was), so the name captured the
  2541. // whole declarator — `const int& getRef() const {…}` became the method named
  2542. // "& getRef() const" instead of "getRef", polluting search and callers. Very
  2543. // common in Unreal Engine headers (`const FGameplayTagContainer& GetActiveTags() const`).
  2544. const namesOf = (code: string) =>
  2545. extractFromSource('r.cpp', code).nodes
  2546. .filter((n) => n.kind === 'method' || n.kind === 'function')
  2547. .map((n) => n.name);
  2548. it('names an inline reference-returning method by its identifier, not the declarator', () => {
  2549. const names = namesOf('class C {\npublic:\n const int& getRef() const { return x; }\n int& mutRef() { return x; }\n int x;\n};');
  2550. expect(names).toContain('getRef');
  2551. expect(names).toContain('mutRef');
  2552. // No name leaks the reference sigil or the parameter/qualifier tail.
  2553. expect(names.some((n) => /[&()]/.test(n))).toBe(false);
  2554. });
  2555. it('handles rvalue-reference returns and reference-returning free functions', () => {
  2556. expect(namesOf('class C { int&& take() { return 1; } };')).toContain('take');
  2557. expect(namesOf('const int& globalRef() { static int x; return x; }')).toContain('globalRef');
  2558. });
  2559. it('leaves pointer, value, and out-of-line reference returns unchanged (controls)', () => {
  2560. expect(namesOf('class C { int* getPtr() { return &x; } int x; };')).toContain('getPtr');
  2561. expect(namesOf('class C { int getVal() const { return x; } int x; };')).toContain('getVal');
  2562. // Out-of-line `T& C::f()` already resolves via the qualified-name hook.
  2563. expect(namesOf('const int& C::getRef() const { return x; }')).toContain('getRef');
  2564. });
  2565. });
  2566. describe('C++ user-defined conversion operator names (#1093 follow-up)', () => {
  2567. // A conversion operator's declarator is an `operator_cast` (target type +
  2568. // `() const` tail). It was named with the whole declarator —
  2569. // `operator EALSMovementState() const` — so it didn't match the symbolic-
  2570. // overload style (`operator+`) and carried parameter noise. It's now named
  2571. // `operator <type>`. Common in Unreal Engine enum-wrapper structs.
  2572. const namesOf = (code: string) =>
  2573. extractFromSource('o.cpp', code).nodes
  2574. .filter((n) => n.kind === 'method' || n.kind === 'function')
  2575. .map((n) => n.name);
  2576. it('names a conversion operator as "operator <type>", not the full declarator', () => {
  2577. const names = namesOf('struct S {\n operator int() const { return 1; }\n operator bool() { return true; }\n int x;\n};');
  2578. expect(names).toContain('operator int');
  2579. expect(names).toContain('operator bool');
  2580. expect(names.some((n) => n.includes('(') || n.includes('const'))).toBe(false);
  2581. });
  2582. it('handles a user-type conversion operator', () => {
  2583. expect(
  2584. namesOf('struct FALSMovementState {\n operator EALSMovementState() const { return State; }\n EALSMovementState State;\n};')
  2585. ).toContain('operator EALSMovementState');
  2586. });
  2587. it('leaves symbolic operator overloads unchanged (control)', () => {
  2588. 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};');
  2589. expect(names).toContain('operator+');
  2590. expect(names).toContain('operator[]'); // reference-returning subscript, name still clean
  2591. });
  2592. });
  2593. describe('C++ macro-prefixed function names (#1093 follow-up)', () => {
  2594. // An unknown inline-specifier macro before the return type
  2595. // (`FORCEINLINE FString GetName(…)`) threw tree-sitter into error recovery:
  2596. // the macro became the return type and — for a non-primitive return — the
  2597. // return type was glued onto the name (`"FString GetName"`), so the function
  2598. // was unfindable by name and its callers didn't link. `blankCppInlineMacros`
  2599. // blanks the known UE inline macros before parsing (offset-preserving), the
  2600. // same recover-don't-drop approach as the macro-annotated-class fix. Pervasive
  2601. // in Unreal Engine (`FORCEINLINE`).
  2602. const infoOf = (code: string) =>
  2603. extractFromSource('m.cpp', code).nodes
  2604. .filter((n) => n.kind === 'method' || n.kind === 'function')
  2605. .map((n) => ({ name: n.name, ret: n.returnType }));
  2606. it('recovers the real name AND return type of a FORCEINLINE function', () => {
  2607. expect(infoOf('static FORCEINLINE FString GetName(int V) { return H(V); }')).toEqual([
  2608. { name: 'GetName', ret: 'FString' },
  2609. ]);
  2610. });
  2611. it('handles the templated UE helper shape (GetEnumerationToString)', () => {
  2612. const names = infoOf(
  2613. 'template <typename E> static FORCEINLINE FString GetEnumerationToString(const E V) { return H(V); }'
  2614. ).map((x) => x.name);
  2615. expect(names).toContain('GetEnumerationToString');
  2616. });
  2617. it('handles FORCENOINLINE / FORCEINLINE_DEBUGGABLE, methods, void, and reference returns', () => {
  2618. expect(infoOf('FORCENOINLINE FString A(int V){return H(V);}').map((x) => x.name)).toContain('A');
  2619. expect(infoOf('FORCEINLINE_DEBUGGABLE FString B(int V){return H(V);}').map((x) => x.name)).toContain('B');
  2620. expect(infoOf('struct S { FORCEINLINE FString GetName(int V) { return H(V); } };').map((x) => x.name)).toContain('GetName');
  2621. expect(infoOf('static FORCEINLINE void DoThing(int V) { H(V); }').map((x) => x.name)).toContain('DoThing');
  2622. expect(infoOf('static FORCEINLINE const FString& GetRef(int V) { return H(V); }').map((x) => x.name)).toContain('GetRef');
  2623. });
  2624. it('handles common third-party inline macros (pugixml, Godot, Boost, generic)', () => {
  2625. // pugixml: PUGI__FN before the return type; PUGIXML_FUNCTION (linkage)
  2626. // between the return type and the name — both recovered.
  2627. expect(infoOf('PUGI__FN void* default_allocate(size_t n) { return H(n); }').map((x) => x.name)).toContain('default_allocate');
  2628. expect(infoOf('PUGI__FN_NO_INLINE bool strequal(const char_t* a) { return H(a); }').map((x) => x.name)).toContain('strequal');
  2629. expect(infoOf('std::string PUGIXML_FUNCTION as_utf8(const wchar_t* s) { return H(s); }').map((x) => x.name)).toContain('as_utf8');
  2630. // Godot / Boost / generic inline hints
  2631. expect(infoOf('_FORCE_INLINE_ String get_name() const { return H(); }').map((x) => x.name)).toContain('get_name');
  2632. expect(infoOf('_ALWAYS_INLINE_ Vector2 get_pos() { return H(); }').map((x) => x.name)).toContain('get_pos');
  2633. expect(infoOf('BOOST_FORCEINLINE result_type call() { return H(); }').map((x) => x.name)).toContain('call');
  2634. expect(infoOf('ALWAYS_INLINE MyType compute() { return H(); }').map((x) => x.name)).toContain('compute');
  2635. });
  2636. it('leaves ordinary functions and real all-caps return types untouched (controls)', () => {
  2637. expect(infoOf('FString GetName(int V) { return H(V); }')).toEqual([{ name: 'GetName', ret: 'FString' }]);
  2638. // A real all-caps type that is NOT a listed inline macro stays the return type.
  2639. expect(infoOf('HRESULT DoIt(int V) { return H(V); }')).toEqual([{ name: 'DoIt', ret: 'HRESULT' }]);
  2640. });
  2641. it('blankCppInlineMacros preserves offsets and only touches specifier-position macros', () => {
  2642. // Blanked with equal-length spaces (byte offsets preserved).
  2643. expect(blankCppInlineMacros('FORCEINLINE FString F()')).toBe(' FString F()');
  2644. expect(blankCppInlineMacros('FORCEINLINE FString F()')).toHaveLength('FORCEINLINE FString F()'.length);
  2645. // Not in specifier position → untouched: string literals, expressions,
  2646. // longer word (`FORCEINLINE_COUNT`), and the fast path.
  2647. expect(blankCppInlineMacros('const char* s = "FORCEINLINE";')).toBe('const char* s = "FORCEINLINE";');
  2648. expect(blankCppInlineMacros('x = FORCEINLINE + 1;')).toBe('x = FORCEINLINE + 1;');
  2649. expect(blankCppInlineMacros('int FORCEINLINE_COUNT = 3;')).toBe('int FORCEINLINE_COUNT = 3;');
  2650. expect(blankCppInlineMacros('no macros here')).toBe('no macros here');
  2651. });
  2652. });
  2653. describe('C++ universal macro-mangled name recovery', () => {
  2654. // Curated pre-parse blanking can't list every library's inline macro, so a
  2655. // post-parse salvage recovers the real function name from ANY leftover
  2656. // `MACRO Ret name(…)` mangle — no list needed. It only ever touches an
  2657. // already-mangled name, so it can't corrupt a clean one.
  2658. const namesOf = (code: string, file = 's.cpp') =>
  2659. extractFromSource(file, code).nodes
  2660. .filter((n) => n.kind === 'method' || n.kind === 'function')
  2661. .map((n) => n.name);
  2662. it('recovers the name from a completely unknown macro (no list entry)', () => {
  2663. expect(namesOf('WEBKIT_EXPORT WTFString computeThing(int x) { return H(x); }')).toContain('computeThing');
  2664. expect(namesOf('SOMELIB_INLINE MyResult doWork(int x) { return H(x); }')).toContain('doWork');
  2665. expect(namesOf('MZ_FORCEINLINE char_t* to_str(double v) { return H(v); }')).toContain('to_str');
  2666. });
  2667. it('recoverMangledCppName only touches already-mangled names, with guards', () => {
  2668. // Recovered:
  2669. expect(recoverMangledCppName('WTFString computeThing')).toBe('computeThing');
  2670. expect(recoverMangledCppName('char_t* to_str(double v)')).toBe('to_str');
  2671. expect(recoverMangledCppName('unspecified_bool_type() const')).toBe('unspecified_bool_type');
  2672. // Left unchanged — clean names, operators, destructors, the `Ret (name)`
  2673. // idiom, and non-identifier tails:
  2674. expect(recoverMangledCppName('computeThing')).toBe('computeThing');
  2675. expect(recoverMangledCppName('operator EALSMovementState')).toBe('operator EALSMovementState');
  2676. expect(recoverMangledCppName('~Widget')).toBe('~Widget');
  2677. expect(recoverMangledCppName('bool (likely)')).toBe('bool (likely)');
  2678. expect(recoverMangledCppName('void (free)')).toBe('void (free)');
  2679. expect(recoverMangledCppName('QDockWidget *')).toBe('QDockWidget *');
  2680. });
  2681. it('does not disturb clean C++ names or non-C++ (Kotlin backtick) names', () => {
  2682. expect(namesOf('int foo(int x) { return x; }')).toEqual(['foo']);
  2683. // Kotlin backtick identifiers legitimately contain spaces; the salvage is
  2684. // C/C++-only, so they are untouched.
  2685. const kt = extractFromSource('T.kt', 'class T {\n fun `decode simple cert`() { }\n}').nodes
  2686. .filter((n) => n.kind === 'method' || n.kind === 'function')
  2687. .map((n) => n.name);
  2688. expect(kt).toContain('`decode simple cert`');
  2689. });
  2690. it('curated list now also covers Qt / Folly / Abseil / LLVM / V8 / Eigen / rapidjson (full recovery)', () => {
  2691. const info = (c: string) =>
  2692. extractFromSource('x.cpp', c).nodes
  2693. .filter((n) => n.kind === 'method' || n.kind === 'function')
  2694. .map((n) => ({ name: n.name, ret: n.returnType }));
  2695. expect(info('FOLLY_ALWAYS_INLINE Str f(int x) { return H(x); }')).toEqual([{ name: 'f', ret: 'Str' }]);
  2696. expect(namesOf('Q_INVOKABLE void onClicked() { H(); }')).toContain('onClicked');
  2697. expect(namesOf('ABSL_ATTRIBUTE_ALWAYS_INLINE int hash(int x) { return H(x); }')).toContain('hash');
  2698. expect(namesOf('EIGEN_STRONG_INLINE Scalar dot(const V& v) { return H(v); }')).toContain('dot');
  2699. expect(namesOf('V8_INLINE MaybeLocal Get(int i) { return H(i); }')).toContain('Get');
  2700. expect(namesOf('RAPIDJSON_FORCEINLINE bool Parse(const char* s) { return H(s); }')).toContain('Parse');
  2701. });
  2702. it('curated list spans the broader ecosystem (Mozilla, GLM, Bullet, OpenCV, Skia, EASTL, protobuf, fmt, Windows conventions)', () => {
  2703. const info = (c: string) =>
  2704. extractFromSource('x.cpp', c).nodes
  2705. .filter((n) => n.kind === 'method' || n.kind === 'function')
  2706. .map((n) => ({ name: n.name, ret: n.returnType }));
  2707. expect(info('MOZ_ALWAYS_INLINE Value get(int i) { return H(i); }')).toEqual([{ name: 'get', ret: 'Value' }]);
  2708. expect(info('GLM_FUNC_QUALIFIER vec3 cross(const vec3& a) { return H(a); }')).toEqual([{ name: 'cross', ret: 'vec3' }]);
  2709. expect(info('SIMD_FORCE_INLINE btScalar dot(const btVector3& v) const { return H(v); }')).toEqual([{ name: 'dot', ret: 'btScalar' }]);
  2710. expect(info('CV_INLINE Mat clone() const { return H(); }')).toEqual([{ name: 'clone', ret: 'Mat' }]);
  2711. expect(namesOf('PROTOBUF_ALWAYS_INLINE int size() const { return H(); }')).toContain('size');
  2712. expect(namesOf('FMT_CONSTEXPR auto parse(int x) { return H(x); }')).toContain('parse');
  2713. expect(namesOf('SK_ALWAYS_INLINE SkScalar width() const { return H(); }')).toContain('width');
  2714. expect(namesOf('EA_FORCE_INLINE size_type size() const { return H(); }')).toContain('size');
  2715. // Windows calling-convention macros sit between return type and name; the
  2716. // macro is blanked so the real return type survives.
  2717. expect(info('HRESULT WINAPI CreateThing(int x) { return H(x); }')).toEqual([{ name: 'CreateThing', ret: 'HRESULT' }]);
  2718. expect(info('ULONG STDMETHODCALLTYPE AddRef() { return H(); }')).toEqual([{ name: 'AddRef', ret: 'ULONG' }]);
  2719. });
  2720. });
  2721. describe('C++ templated base-class inheritance (#1043)', () => {
  2722. // Inheriting from a template (`class D : public Base<int>`) recorded the base
  2723. // ref as the full instantiation `Base<int>`, which never name-matched the
  2724. // template indexed as the bare node `Base`. The `<…>` args are stripped so the
  2725. // `extends` reference matches.
  2726. it('strips template args from a templated base so the extends ref is the bare name', () => {
  2727. const code = `
  2728. template<typename T> class Base {};
  2729. template<typename D> class CRTPBase {};
  2730. namespace ns { template<typename T> class Tpl {}; }
  2731. class Plain {};
  2732. class Widget : public Base<int> {};
  2733. class App : public CRTPBase<App> {};
  2734. class Q : public ns::Tpl<int> {};
  2735. class Both : public Base<char>, public Plain {};
  2736. `;
  2737. const extendsRefs = extractFromSource('f.cpp', code).unresolvedReferences.filter(
  2738. (r) => r.referenceKind === 'extends'
  2739. );
  2740. const names = extendsRefs.map((r) => r.referenceName);
  2741. // Templated bases carry the bare name, NOT the `<…>` instantiation.
  2742. expect(names).toContain('Base'); // from Base<int> / Base<char>
  2743. expect(names).toContain('CRTPBase'); // from CRTPBase<App> (CRTP)
  2744. expect(names).toContain('ns::Tpl'); // qualified head preserved, args dropped
  2745. expect(names).toContain('Plain'); // non-templated base unchanged
  2746. // No reference still carries angle brackets.
  2747. expect(names.find((n) => n.includes('<'))).toBeUndefined();
  2748. });
  2749. it('stripCppTemplateArgs removes balanced <…> at any depth and is a no-op without them', () => {
  2750. expect(stripCppTemplateArgs('Base<int>')).toBe('Base');
  2751. expect(stripCppTemplateArgs('ns::Tpl<int>')).toBe('ns::Tpl');
  2752. expect(stripCppTemplateArgs('ns::Tpl<Foo<int>>')).toBe('ns::Tpl'); // nested
  2753. expect(stripCppTemplateArgs('Outer<int>::Inner')).toBe('Outer::Inner'); // mid-name
  2754. expect(stripCppTemplateArgs('Base')).toBe('Base'); // no-op
  2755. expect(stripCppTemplateArgs('ns::Plain')).toBe('ns::Plain'); // no-op qualified
  2756. });
  2757. });
  2758. describe('C++ stack-allocation construction (#1035)', () => {
  2759. // `Calculator calc(0)` (direct-init) and `Widget w{1, 2}` (brace-init) carry
  2760. // the constructor args directly on the declarator — no call/new node — so
  2761. // they emitted no constructor reference, unlike heap `new Calculator(0)`. An
  2762. // `instantiates` ref to the constructed type is now emitted for both.
  2763. const instNames = (code: string) =>
  2764. extractFromSource('f.cpp', `void run() {\n${code}\n}`)
  2765. .unresolvedReferences.filter((r) => r.referenceKind === 'instantiates')
  2766. .map((r) => r.referenceName);
  2767. it('emits an instantiates ref for direct-init and brace-init', () => {
  2768. expect(instNames('Calculator calc(0);')).toEqual(['Calculator']);
  2769. expect(instNames('Widget w{1, 2};')).toEqual(['Widget']);
  2770. });
  2771. it('strips template args and namespace to the bare class name', () => {
  2772. // `std::vector<int> v(10)` → `vector`; `ns::Widget w(0)` → `Widget`.
  2773. expect(instNames('std::vector<int> v(10);')).toEqual(['vector']);
  2774. expect(instNames('ns::Widget w(0);')).toEqual(['Widget']);
  2775. });
  2776. it('does not emit for primitives, default construction, or the most-vexing parse', () => {
  2777. expect(instNames('int x(5);')).toEqual([]); // primitive direct-init
  2778. expect(instNames('int y{6};')).toEqual([]); // primitive brace-init
  2779. expect(instNames('auto z = make();')).toEqual([]); // auto + call (handled elsewhere)
  2780. expect(instNames('Calculator deferred;')).toEqual([]); // default construction, no args
  2781. expect(instNames('Calculator calc();')).toEqual([]); // function declaration (most-vexing parse)
  2782. });
  2783. it('emits a single instantiates ref for a multi-declarator statement', () => {
  2784. // `Calculator a(1), b(2);` shares one `type` field; both construct a
  2785. // Calculator, so one ref suffices (it dedups to one edge regardless).
  2786. expect(instNames('Calculator a(1), b(2);')).toEqual(['Calculator']);
  2787. });
  2788. });
  2789. describe('C/C++ imports', () => {
  2790. it('should extract system include', () => {
  2791. const code = `#include <iostream>`;
  2792. const result = extractFromSource('main.cpp', code);
  2793. const importNode = result.nodes.find((n) => n.kind === 'import');
  2794. expect(importNode).toBeDefined();
  2795. expect(importNode?.name).toBe('iostream');
  2796. expect(importNode?.signature).toBe('#include <iostream>');
  2797. });
  2798. it('should extract system include with path', () => {
  2799. const code = `#include <nlohmann/json.hpp>`;
  2800. const result = extractFromSource('app.cpp', code);
  2801. const importNode = result.nodes.find((n) => n.kind === 'import');
  2802. expect(importNode).toBeDefined();
  2803. expect(importNode?.name).toBe('nlohmann/json.hpp');
  2804. });
  2805. it('should extract local include', () => {
  2806. const code = `#include "myheader.h"`;
  2807. const result = extractFromSource('main.cpp', code);
  2808. const importNode = result.nodes.find((n) => n.kind === 'import');
  2809. expect(importNode).toBeDefined();
  2810. expect(importNode?.name).toBe('myheader.h');
  2811. });
  2812. it('should extract C header', () => {
  2813. const code = `#include <stdio.h>`;
  2814. const result = extractFromSource('main.c', code);
  2815. const importNode = result.nodes.find((n) => n.kind === 'import');
  2816. expect(importNode).toBeDefined();
  2817. expect(importNode?.name).toBe('stdio.h');
  2818. });
  2819. it('should extract multiple includes', () => {
  2820. const code = `
  2821. #include <iostream>
  2822. #include <vector>
  2823. #include "config.h"
  2824. `;
  2825. const result = extractFromSource('app.cpp', code);
  2826. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  2827. expect(importNodes.length).toBe(3);
  2828. const names = importNodes.map((n) => n.name);
  2829. expect(names).toContain('iostream');
  2830. expect(names).toContain('vector');
  2831. expect(names).toContain('config.h');
  2832. });
  2833. it('should create unresolved references for local includes', () => {
  2834. const code = `#include "myheader.h"`;
  2835. const result = extractFromSource('main.cpp', code);
  2836. const importRef = result.unresolvedReferences.find(
  2837. (r) => r.referenceKind === 'imports' && r.referenceName === 'myheader.h'
  2838. );
  2839. expect(importRef).toBeDefined();
  2840. expect(importRef?.line).toBe(1);
  2841. });
  2842. it('should create unresolved references for system includes', () => {
  2843. const code = `#include <iostream>`;
  2844. const result = extractFromSource('main.cpp', code);
  2845. const importRef = result.unresolvedReferences.find(
  2846. (r) => r.referenceKind === 'imports' && r.referenceName === 'iostream'
  2847. );
  2848. expect(importRef).toBeDefined();
  2849. });
  2850. });
  2851. describe('Dart imports', () => {
  2852. it('should extract dart: import', () => {
  2853. const code = `import 'dart:async';`;
  2854. const result = extractFromSource('main.dart', code);
  2855. const importNode = result.nodes.find((n) => n.kind === 'import');
  2856. expect(importNode).toBeDefined();
  2857. expect(importNode?.name).toBe('dart:async');
  2858. expect(importNode?.signature).toBe("import 'dart:async';");
  2859. });
  2860. it('should extract package import', () => {
  2861. const code = `import 'package:flutter/material.dart';`;
  2862. const result = extractFromSource('app.dart', code);
  2863. const importNode = result.nodes.find((n) => n.kind === 'import');
  2864. expect(importNode).toBeDefined();
  2865. expect(importNode?.name).toBe('package:flutter/material.dart');
  2866. });
  2867. it('should extract aliased import', () => {
  2868. const code = `import 'package:http/http.dart' as http;`;
  2869. const result = extractFromSource('api.dart', code);
  2870. const importNode = result.nodes.find((n) => n.kind === 'import');
  2871. expect(importNode).toBeDefined();
  2872. expect(importNode?.name).toBe('package:http/http.dart');
  2873. expect(importNode?.signature).toContain('as http');
  2874. });
  2875. it('should extract multiple imports', () => {
  2876. const code = `
  2877. import 'dart:async';
  2878. import 'dart:convert';
  2879. import 'package:flutter/material.dart';
  2880. `;
  2881. const result = extractFromSource('main.dart', code);
  2882. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  2883. expect(importNodes.length).toBe(3);
  2884. const names = importNodes.map((n) => n.name);
  2885. expect(names).toContain('dart:async');
  2886. expect(names).toContain('dart:convert');
  2887. expect(names).toContain('package:flutter/material.dart');
  2888. });
  2889. it('should extract relative import', () => {
  2890. const code = `import '../utils/helpers.dart';`;
  2891. const result = extractFromSource('lib/main.dart', code);
  2892. const importNode = result.nodes.find((n) => n.kind === 'import');
  2893. expect(importNode).toBeDefined();
  2894. expect(importNode?.name).toBe('../utils/helpers.dart');
  2895. });
  2896. });
  2897. describe('Liquid imports', () => {
  2898. it('should extract render tag', () => {
  2899. const code = `{% render 'loading-spinner' %}`;
  2900. const result = extractFromSource('template.liquid', code);
  2901. const importNode = result.nodes.find((n) => n.kind === 'import');
  2902. expect(importNode).toBeDefined();
  2903. expect(importNode?.name).toBe('loading-spinner');
  2904. expect(importNode?.signature).toContain('render');
  2905. });
  2906. it('should extract section tag', () => {
  2907. const code = `{% section 'header' %}`;
  2908. const result = extractFromSource('layout/theme.liquid', code);
  2909. const importNode = result.nodes.find((n) => n.kind === 'import');
  2910. expect(importNode).toBeDefined();
  2911. expect(importNode?.name).toBe('header');
  2912. expect(importNode?.signature).toContain('section');
  2913. });
  2914. it('should extract include tag', () => {
  2915. const code = `{% include 'icon-cart' %}`;
  2916. const result = extractFromSource('snippets/header.liquid', code);
  2917. const importNode = result.nodes.find((n) => n.kind === 'import');
  2918. expect(importNode).toBeDefined();
  2919. expect(importNode?.name).toBe('icon-cart');
  2920. expect(importNode?.signature).toContain('include');
  2921. });
  2922. it('should extract render with whitespace control', () => {
  2923. const code = `{%- render 'price' -%}`;
  2924. const result = extractFromSource('snippets/product.liquid', code);
  2925. const importNode = result.nodes.find((n) => n.kind === 'import');
  2926. expect(importNode).toBeDefined();
  2927. expect(importNode?.name).toBe('price');
  2928. });
  2929. it('should extract multiple imports', () => {
  2930. const code = `
  2931. {% section 'header' %}
  2932. {% render 'loading-spinner' %}
  2933. {% render 'cart-drawer' %}
  2934. `;
  2935. const result = extractFromSource('layout/theme.liquid', code);
  2936. const importNodes = result.nodes.filter((n) => n.kind === 'import');
  2937. expect(importNodes.length).toBe(3);
  2938. const names = importNodes.map((n) => n.name);
  2939. expect(names).toContain('header');
  2940. expect(names).toContain('loading-spinner');
  2941. expect(names).toContain('cart-drawer');
  2942. });
  2943. });
  2944. });
  2945. // =============================================================================
  2946. // Pascal / Delphi Extraction
  2947. // =============================================================================
  2948. describe('Pascal / Delphi Extraction', () => {
  2949. describe('Language detection', () => {
  2950. it('should detect Pascal files', () => {
  2951. expect(detectLanguage('UAuth.pas')).toBe('pascal');
  2952. expect(detectLanguage('App.dpr')).toBe('pascal');
  2953. expect(detectLanguage('Package.dpk')).toBe('pascal');
  2954. expect(detectLanguage('App.lpr')).toBe('pascal');
  2955. expect(detectLanguage('MainForm.dfm')).toBe('pascal');
  2956. expect(detectLanguage('MainForm.fmx')).toBe('pascal');
  2957. });
  2958. it('should report Pascal as supported', () => {
  2959. expect(isLanguageSupported('pascal')).toBe(true);
  2960. expect(getSupportedLanguages()).toContain('pascal');
  2961. });
  2962. });
  2963. describe('Unit extraction', () => {
  2964. it('should extract unit as module', () => {
  2965. const code = `unit MyUnit;\ninterface\nimplementation\nend.`;
  2966. const result = extractFromSource('MyUnit.pas', code);
  2967. const moduleNode = result.nodes.find((n) => n.kind === 'module');
  2968. expect(moduleNode).toBeDefined();
  2969. expect(moduleNode?.name).toBe('MyUnit');
  2970. expect(moduleNode?.language).toBe('pascal');
  2971. });
  2972. it('should extract program as module', () => {
  2973. const code = `program MyApp;\nbegin\nend.`;
  2974. const result = extractFromSource('MyApp.dpr', code);
  2975. const moduleNode = result.nodes.find((n) => n.kind === 'module');
  2976. expect(moduleNode).toBeDefined();
  2977. expect(moduleNode?.name).toBe('MyApp');
  2978. });
  2979. it('should fallback to filename when module name is empty', () => {
  2980. // Some .dpr templates use "program;" without a name
  2981. const code = `program;\nuses SysUtils;\nbegin\nend.`;
  2982. const result = extractFromSource('Console.dpr', code);
  2983. const moduleNode = result.nodes.find((n) => n.kind === 'module');
  2984. expect(moduleNode).toBeDefined();
  2985. expect(moduleNode?.name).toBe('Console');
  2986. });
  2987. });
  2988. describe('Uses clause (imports)', () => {
  2989. it('should extract uses as individual imports', () => {
  2990. const code = `unit Test;\ninterface\nuses\n System.SysUtils,\n System.Classes;\nimplementation\nend.`;
  2991. const result = extractFromSource('Test.pas', code);
  2992. const imports = result.nodes.filter((n) => n.kind === 'import');
  2993. expect(imports.length).toBe(2);
  2994. expect(imports.map((n) => n.name)).toContain('System.SysUtils');
  2995. expect(imports.map((n) => n.name)).toContain('System.Classes');
  2996. });
  2997. it('should create unresolved references for imports', () => {
  2998. const code = `unit Test;\ninterface\nuses\n UAuth;\nimplementation\nend.`;
  2999. const result = extractFromSource('Test.pas', code);
  3000. const importRef = result.unresolvedReferences.find(
  3001. (r) => r.referenceKind === 'imports'
  3002. );
  3003. expect(importRef).toBeDefined();
  3004. expect(importRef?.referenceName).toBe('UAuth');
  3005. });
  3006. });
  3007. describe('Class extraction', () => {
  3008. it('should extract class declarations', () => {
  3009. const code = `unit Test;\ninterface\ntype\n TMyClass = class\n public\n procedure DoSomething;\n end;\nimplementation\nend.`;
  3010. const result = extractFromSource('Test.pas', code);
  3011. const classNode = result.nodes.find((n) => n.kind === 'class');
  3012. expect(classNode).toBeDefined();
  3013. expect(classNode?.name).toBe('TMyClass');
  3014. });
  3015. it('should extract class with inheritance', () => {
  3016. const code = `unit Test;\ninterface\ntype\n TChild = class(TParent)\n end;\nimplementation\nend.`;
  3017. const result = extractFromSource('Test.pas', code);
  3018. const extendsRef = result.unresolvedReferences.find(
  3019. (r) => r.referenceKind === 'extends'
  3020. );
  3021. expect(extendsRef).toBeDefined();
  3022. expect(extendsRef?.referenceName).toBe('TParent');
  3023. });
  3024. it('should extract class with interface implementation', () => {
  3025. const code = `unit Test;\ninterface\ntype\n TService = class(TInterfacedObject, ILogger)\n end;\nimplementation\nend.`;
  3026. const result = extractFromSource('Test.pas', code);
  3027. const extendsRef = result.unresolvedReferences.find(
  3028. (r) => r.referenceKind === 'extends'
  3029. );
  3030. const implementsRef = result.unresolvedReferences.find(
  3031. (r) => r.referenceKind === 'implements'
  3032. );
  3033. expect(extendsRef?.referenceName).toBe('TInterfacedObject');
  3034. expect(implementsRef?.referenceName).toBe('ILogger');
  3035. });
  3036. });
  3037. describe('Record extraction', () => {
  3038. it('should extract records as class nodes', () => {
  3039. const code = `unit Test;\ninterface\ntype\n TPoint = record\n X: Double;\n Y: Double;\n end;\nimplementation\nend.`;
  3040. const result = extractFromSource('Test.pas', code);
  3041. const classNode = result.nodes.find((n) => n.kind === 'class');
  3042. expect(classNode).toBeDefined();
  3043. expect(classNode?.name).toBe('TPoint');
  3044. const fields = result.nodes.filter((n) => n.kind === 'field');
  3045. expect(fields.length).toBe(2);
  3046. expect(fields.map((f) => f.name)).toContain('X');
  3047. expect(fields.map((f) => f.name)).toContain('Y');
  3048. });
  3049. });
  3050. describe('Interface extraction', () => {
  3051. it('should extract interface declarations', () => {
  3052. const code = `unit Test;\ninterface\ntype\n ILogger = interface\n procedure Log(const AMsg: string);\n end;\nimplementation\nend.`;
  3053. const result = extractFromSource('Test.pas', code);
  3054. const ifaceNode = result.nodes.find((n) => n.kind === 'interface');
  3055. expect(ifaceNode).toBeDefined();
  3056. expect(ifaceNode?.name).toBe('ILogger');
  3057. });
  3058. });
  3059. describe('Method extraction', () => {
  3060. it('should extract methods with visibility', () => {
  3061. 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.`;
  3062. const result = extractFromSource('Test.pas', code);
  3063. const methods = result.nodes.filter((n) => n.kind === 'method');
  3064. expect(methods.length).toBe(2);
  3065. const createMethod = methods.find((m) => m.name === 'Create');
  3066. expect(createMethod?.visibility).toBe('public');
  3067. const getValue = methods.find((m) => m.name === 'GetValue');
  3068. expect(getValue?.visibility).toBe('public');
  3069. const fields = result.nodes.filter((n) => n.kind === 'field');
  3070. const fValue = fields.find((f) => f.name === 'FValue');
  3071. expect(fValue?.visibility).toBe('private');
  3072. });
  3073. it('should detect static methods (class methods)', () => {
  3074. const code = `unit Test;\ninterface\ntype\n THelper = class\n public\n class function Create: THelper; static;\n end;\nimplementation\nend.`;
  3075. const result = extractFromSource('Test.pas', code);
  3076. const methods = result.nodes.filter((n) => n.kind === 'method');
  3077. const staticMethod = methods.find((m) => m.name === 'Create');
  3078. expect(staticMethod?.isStatic).toBe(true);
  3079. });
  3080. });
  3081. describe('Enum extraction', () => {
  3082. it('should extract enums with members', () => {
  3083. const code = `unit Test;\ninterface\ntype\n TColor = (clRed, clGreen, clBlue);\nimplementation\nend.`;
  3084. const result = extractFromSource('Test.pas', code);
  3085. const enumNode = result.nodes.find((n) => n.kind === 'enum');
  3086. expect(enumNode).toBeDefined();
  3087. expect(enumNode?.name).toBe('TColor');
  3088. const members = result.nodes.filter((n) => n.kind === 'enum_member');
  3089. expect(members.length).toBe(3);
  3090. expect(members.map((m) => m.name)).toEqual(['clRed', 'clGreen', 'clBlue']);
  3091. });
  3092. });
  3093. describe('Property extraction', () => {
  3094. it('should extract properties', () => {
  3095. const code = `unit Test;\ninterface\ntype\n TObj = class\n public\n property Name: string read FName write FName;\n end;\nimplementation\nend.`;
  3096. const result = extractFromSource('Test.pas', code);
  3097. const propNode = result.nodes.find((n) => n.kind === 'property');
  3098. expect(propNode).toBeDefined();
  3099. expect(propNode?.name).toBe('Name');
  3100. expect(propNode?.visibility).toBe('public');
  3101. });
  3102. });
  3103. describe('Constant extraction', () => {
  3104. it('should extract constants', () => {
  3105. const code = `unit Test;\ninterface\nconst\n MAX_RETRIES = 3;\n APP_NAME = 'MyApp';\nimplementation\nend.`;
  3106. const result = extractFromSource('Test.pas', code);
  3107. const constants = result.nodes.filter((n) => n.kind === 'constant');
  3108. expect(constants.length).toBe(2);
  3109. expect(constants.map((c) => c.name)).toContain('MAX_RETRIES');
  3110. expect(constants.map((c) => c.name)).toContain('APP_NAME');
  3111. });
  3112. });
  3113. describe('Type alias extraction', () => {
  3114. it('should extract type aliases', () => {
  3115. const code = `unit Test;\ninterface\ntype\n TUserName = string;\nimplementation\nend.`;
  3116. const result = extractFromSource('Test.pas', code);
  3117. const aliasNode = result.nodes.find((n) => n.kind === 'type_alias');
  3118. expect(aliasNode).toBeDefined();
  3119. expect(aliasNode?.name).toBe('TUserName');
  3120. });
  3121. });
  3122. describe('Call extraction', () => {
  3123. it('should extract calls from implementation bodies', () => {
  3124. 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.`;
  3125. const result = extractFromSource('Test.pas', code);
  3126. const callRef = result.unresolvedReferences.find(
  3127. (r) => r.referenceKind === 'calls'
  3128. );
  3129. expect(callRef).toBeDefined();
  3130. expect(callRef?.referenceName).toBe('WriteLn');
  3131. });
  3132. });
  3133. describe('Containment edges', () => {
  3134. it('should create contains edges for class members', () => {
  3135. const code = `unit Test;\ninterface\ntype\n TObj = class\n public\n procedure Foo;\n end;\nimplementation\nend.`;
  3136. const result = extractFromSource('Test.pas', code);
  3137. const classNode = result.nodes.find((n) => n.kind === 'class');
  3138. const methodNode = result.nodes.find((n) => n.kind === 'method');
  3139. expect(classNode).toBeDefined();
  3140. expect(methodNode).toBeDefined();
  3141. const containsEdge = result.edges.find(
  3142. (e) => e.source === classNode?.id && e.target === methodNode?.id && e.kind === 'contains'
  3143. );
  3144. expect(containsEdge).toBeDefined();
  3145. });
  3146. });
  3147. describe('Full fixture: UAuth.pas', () => {
  3148. const code = `unit UAuth;
  3149. interface
  3150. uses
  3151. System.SysUtils,
  3152. System.Classes;
  3153. type
  3154. ITokenValidator = interface
  3155. ['{11111111-1111-1111-1111-111111111111}']
  3156. function Validate(const AToken: string): Boolean;
  3157. end;
  3158. TAuthService = class(TInterfacedObject, ITokenValidator)
  3159. private
  3160. FToken: string;
  3161. FLoginCount: Integer;
  3162. procedure IncLoginCount;
  3163. protected
  3164. function GetToken: string;
  3165. public
  3166. constructor Create;
  3167. destructor Destroy; override;
  3168. function Validate(const AToken: string): Boolean;
  3169. function Login(const AUser, APass: string): string;
  3170. property Token: string read GetToken;
  3171. property LoginCount: Integer read FLoginCount;
  3172. end;
  3173. implementation
  3174. constructor TAuthService.Create;
  3175. begin
  3176. inherited Create;
  3177. FToken := '';
  3178. FLoginCount := 0;
  3179. end;
  3180. destructor TAuthService.Destroy;
  3181. begin
  3182. FToken := '';
  3183. inherited Destroy;
  3184. end;
  3185. procedure TAuthService.IncLoginCount;
  3186. begin
  3187. Inc(FLoginCount);
  3188. end;
  3189. function TAuthService.GetToken: string;
  3190. begin
  3191. Result := FToken;
  3192. end;
  3193. function TAuthService.Validate(const AToken: string): Boolean;
  3194. begin
  3195. Result := AToken <> '';
  3196. end;
  3197. function TAuthService.Login(const AUser, APass: string): string;
  3198. begin
  3199. IncLoginCount;
  3200. if Validate(AUser + ':' + APass) then
  3201. begin
  3202. FToken := AUser;
  3203. Result := 'ok';
  3204. end
  3205. else
  3206. Result := '';
  3207. end;
  3208. end.`;
  3209. it('should extract all expected nodes', () => {
  3210. const result = extractFromSource('UAuth.pas', code);
  3211. expect(result.errors).toHaveLength(0);
  3212. // Module
  3213. const moduleNode = result.nodes.find((n) => n.kind === 'module');
  3214. expect(moduleNode?.name).toBe('UAuth');
  3215. // Imports
  3216. const imports = result.nodes.filter((n) => n.kind === 'import');
  3217. expect(imports.length).toBe(2);
  3218. // Interface
  3219. const ifaceNode = result.nodes.find((n) => n.kind === 'interface');
  3220. expect(ifaceNode?.name).toBe('ITokenValidator');
  3221. // Class
  3222. const classNode = result.nodes.find((n) => n.kind === 'class');
  3223. expect(classNode?.name).toBe('TAuthService');
  3224. // Methods
  3225. const methods = result.nodes.filter((n) => n.kind === 'method');
  3226. expect(methods.length).toBeGreaterThanOrEqual(6);
  3227. expect(methods.map((m) => m.name)).toContain('Create');
  3228. expect(methods.map((m) => m.name)).toContain('Destroy');
  3229. expect(methods.map((m) => m.name)).toContain('Login');
  3230. // Fields
  3231. const fields = result.nodes.filter((n) => n.kind === 'field');
  3232. expect(fields.length).toBe(2);
  3233. expect(fields.every((f) => f.visibility === 'private')).toBe(true);
  3234. // Properties
  3235. const props = result.nodes.filter((n) => n.kind === 'property');
  3236. expect(props.length).toBe(2);
  3237. expect(props.map((p) => p.name)).toContain('Token');
  3238. expect(props.map((p) => p.name)).toContain('LoginCount');
  3239. });
  3240. it('should extract inheritance and interface implementation', () => {
  3241. const result = extractFromSource('UAuth.pas', code);
  3242. const extendsRef = result.unresolvedReferences.find(
  3243. (r) => r.referenceKind === 'extends'
  3244. );
  3245. expect(extendsRef?.referenceName).toBe('TInterfacedObject');
  3246. const implementsRef = result.unresolvedReferences.find(
  3247. (r) => r.referenceKind === 'implements'
  3248. );
  3249. expect(implementsRef?.referenceName).toBe('ITokenValidator');
  3250. });
  3251. it('should extract calls from implementation', () => {
  3252. const result = extractFromSource('UAuth.pas', code);
  3253. const callRefs = result.unresolvedReferences.filter(
  3254. (r) => r.referenceKind === 'calls'
  3255. );
  3256. expect(callRefs.map((r) => r.referenceName)).toContain('Inc');
  3257. expect(callRefs.map((r) => r.referenceName)).toContain('Validate');
  3258. });
  3259. });
  3260. describe('Full fixture: UTypes.pas', () => {
  3261. const code = `unit UTypes;
  3262. interface
  3263. uses
  3264. System.SysUtils;
  3265. const
  3266. C_MAX_RETRIES = 3;
  3267. C_DEFAULT_NAME = 'Guest';
  3268. type
  3269. TUserRole = (urAdmin, urEditor, urViewer);
  3270. TPoint2D = record
  3271. X: Double;
  3272. Y: Double;
  3273. end;
  3274. TUserName = string;
  3275. TUserInfo = class
  3276. public
  3277. type
  3278. TAddress = record
  3279. Street: string;
  3280. City: string;
  3281. Zip: string;
  3282. end;
  3283. private
  3284. FName: TUserName;
  3285. FRole: TUserRole;
  3286. FAddress: TAddress;
  3287. public
  3288. constructor Create(const AName: TUserName; ARole: TUserRole);
  3289. function GetDisplayName: string;
  3290. class function CreateAdmin(const AName: TUserName): TUserInfo; static;
  3291. property Name: TUserName read FName write FName;
  3292. property Role: TUserRole read FRole;
  3293. property Address: TAddress read FAddress write FAddress;
  3294. end;
  3295. implementation
  3296. constructor TUserInfo.Create(const AName: TUserName; ARole: TUserRole);
  3297. begin
  3298. FName := AName;
  3299. FRole := ARole;
  3300. end;
  3301. function TUserInfo.GetDisplayName: string;
  3302. begin
  3303. if FRole = urAdmin then
  3304. Result := '[Admin] ' + FName
  3305. else
  3306. Result := FName;
  3307. end;
  3308. class function TUserInfo.CreateAdmin(const AName: TUserName): TUserInfo;
  3309. begin
  3310. Result := TUserInfo.Create(AName, urAdmin);
  3311. end;
  3312. end.`;
  3313. it('should extract enums with members', () => {
  3314. const result = extractFromSource('UTypes.pas', code);
  3315. const enumNode = result.nodes.find((n) => n.kind === 'enum');
  3316. expect(enumNode?.name).toBe('TUserRole');
  3317. const members = result.nodes.filter((n) => n.kind === 'enum_member');
  3318. expect(members.length).toBe(3);
  3319. expect(members.map((m) => m.name)).toEqual(['urAdmin', 'urEditor', 'urViewer']);
  3320. });
  3321. it('should extract constants', () => {
  3322. const result = extractFromSource('UTypes.pas', code);
  3323. const constants = result.nodes.filter((n) => n.kind === 'constant');
  3324. expect(constants.length).toBe(2);
  3325. expect(constants.map((c) => c.name)).toContain('C_MAX_RETRIES');
  3326. expect(constants.map((c) => c.name)).toContain('C_DEFAULT_NAME');
  3327. });
  3328. it('should extract type aliases', () => {
  3329. const result = extractFromSource('UTypes.pas', code);
  3330. const aliases = result.nodes.filter((n) => n.kind === 'type_alias');
  3331. expect(aliases.map((a) => a.name)).toContain('TUserName');
  3332. });
  3333. it('should extract records as classes with fields', () => {
  3334. const result = extractFromSource('UTypes.pas', code);
  3335. const classes = result.nodes.filter((n) => n.kind === 'class');
  3336. expect(classes.map((c) => c.name)).toContain('TPoint2D');
  3337. // TPoint2D fields
  3338. const fields = result.nodes.filter((n) => n.kind === 'field');
  3339. expect(fields.map((f) => f.name)).toContain('X');
  3340. expect(fields.map((f) => f.name)).toContain('Y');
  3341. });
  3342. it('should extract static class methods', () => {
  3343. const result = extractFromSource('UTypes.pas', code);
  3344. const methods = result.nodes.filter((n) => n.kind === 'method');
  3345. const staticMethod = methods.find((m) => m.name === 'CreateAdmin');
  3346. expect(staticMethod).toBeDefined();
  3347. expect(staticMethod?.isStatic).toBe(true);
  3348. });
  3349. it('should extract nested types', () => {
  3350. const result = extractFromSource('UTypes.pas', code);
  3351. const classes = result.nodes.filter((n) => n.kind === 'class');
  3352. expect(classes.map((c) => c.name)).toContain('TAddress');
  3353. });
  3354. });
  3355. });
  3356. // =============================================================================
  3357. // DFM/FMX Extraction
  3358. // =============================================================================
  3359. describe('DFM/FMX Extraction', () => {
  3360. it('should extract components from DFM', () => {
  3361. const code = `object Form1: TForm1
  3362. Left = 0
  3363. Top = 0
  3364. Caption = 'My Form'
  3365. object Button1: TButton
  3366. Left = 10
  3367. Top = 10
  3368. Caption = 'Click Me'
  3369. end
  3370. end`;
  3371. const result = extractFromSource('Form1.dfm', code);
  3372. const components = result.nodes.filter((n) => n.kind === 'component');
  3373. expect(components.length).toBe(2);
  3374. expect(components.map((c) => c.name)).toContain('Form1');
  3375. expect(components.map((c) => c.name)).toContain('Button1');
  3376. const button = components.find((c) => c.name === 'Button1');
  3377. expect(button?.signature).toBe('TButton');
  3378. });
  3379. it('should extract nested component hierarchy', () => {
  3380. const code = `object Form1: TForm1
  3381. object Panel1: TPanel
  3382. object Label1: TLabel
  3383. Caption = 'Hello'
  3384. end
  3385. end
  3386. end`;
  3387. const result = extractFromSource('Form1.dfm', code);
  3388. const components = result.nodes.filter((n) => n.kind === 'component');
  3389. expect(components.length).toBe(3);
  3390. // Check nesting: Panel1 contains Label1
  3391. const panel = components.find((c) => c.name === 'Panel1');
  3392. const label = components.find((c) => c.name === 'Label1');
  3393. const containsEdge = result.edges.find(
  3394. (e) => e.source === panel?.id && e.target === label?.id && e.kind === 'contains'
  3395. );
  3396. expect(containsEdge).toBeDefined();
  3397. });
  3398. it('should extract event handler references', () => {
  3399. const code = `object Form1: TForm1
  3400. OnCreate = FormCreate
  3401. OnDestroy = FormDestroy
  3402. object Button1: TButton
  3403. OnClick = Button1Click
  3404. end
  3405. end`;
  3406. const result = extractFromSource('Form1.dfm', code);
  3407. const refs = result.unresolvedReferences;
  3408. expect(refs.length).toBe(3);
  3409. expect(refs.map((r) => r.referenceName)).toContain('FormCreate');
  3410. expect(refs.map((r) => r.referenceName)).toContain('FormDestroy');
  3411. expect(refs.map((r) => r.referenceName)).toContain('Button1Click');
  3412. expect(refs.every((r) => r.referenceKind === 'references')).toBe(true);
  3413. });
  3414. it('should handle multi-line properties', () => {
  3415. const code = `object Form1: TForm1
  3416. SQL.Strings = (
  3417. 'SELECT * FROM users'
  3418. 'WHERE active = 1')
  3419. object Button1: TButton
  3420. OnClick = Button1Click
  3421. end
  3422. end`;
  3423. const result = extractFromSource('Form1.dfm', code);
  3424. const components = result.nodes.filter((n) => n.kind === 'component');
  3425. expect(components.length).toBe(2);
  3426. const refs = result.unresolvedReferences;
  3427. expect(refs.length).toBe(1);
  3428. expect(refs[0]?.referenceName).toBe('Button1Click');
  3429. });
  3430. it('should handle inherited keyword', () => {
  3431. const code = `inherited Form1: TForm1
  3432. Caption = 'Inherited Form'
  3433. object Button1: TButton
  3434. OnClick = Button1Click
  3435. end
  3436. end`;
  3437. const result = extractFromSource('Form1.dfm', code);
  3438. const components = result.nodes.filter((n) => n.kind === 'component');
  3439. expect(components.length).toBe(2);
  3440. expect(components.map((c) => c.name)).toContain('Form1');
  3441. });
  3442. it('should handle item collection properties', () => {
  3443. const code = `object Form1: TForm1
  3444. object StatusBar1: TStatusBar
  3445. Panels = <
  3446. item
  3447. Width = 200
  3448. end
  3449. item
  3450. Width = 200
  3451. end>
  3452. end
  3453. end`;
  3454. const result = extractFromSource('Form1.dfm', code);
  3455. const components = result.nodes.filter((n) => n.kind === 'component');
  3456. expect(components.length).toBe(2);
  3457. });
  3458. describe('Full fixture: MainForm.dfm', () => {
  3459. const code = `object frmMain: TfrmMain
  3460. Left = 0
  3461. Top = 0
  3462. Caption = 'CodeGraph DFM Fixture'
  3463. ClientHeight = 480
  3464. ClientWidth = 640
  3465. OnCreate = FormCreate
  3466. OnDestroy = FormDestroy
  3467. object pnlTop: TPanel
  3468. Left = 0
  3469. Top = 0
  3470. Width = 640
  3471. Height = 50
  3472. object lblTitle: TLabel
  3473. Left = 16
  3474. Top = 16
  3475. Caption = 'Authentication Service'
  3476. end
  3477. object btnLogin: TButton
  3478. Left = 540
  3479. Top = 12
  3480. OnClick = btnLoginClick
  3481. end
  3482. end
  3483. object pnlContent: TPanel
  3484. Left = 0
  3485. Top = 50
  3486. object edtUsername: TEdit
  3487. Left = 16
  3488. Top = 16
  3489. OnChange = edtUsernameChange
  3490. end
  3491. object edtPassword: TEdit
  3492. Left = 16
  3493. Top = 48
  3494. OnKeyPress = edtPasswordKeyPress
  3495. end
  3496. object mmoLog: TMemo
  3497. Left = 16
  3498. Top = 88
  3499. end
  3500. end
  3501. object pnlStatus: TStatusBar
  3502. Left = 0
  3503. Top = 440
  3504. Panels = <
  3505. item
  3506. Width = 200
  3507. end
  3508. item
  3509. Width = 200
  3510. end>
  3511. end
  3512. end`;
  3513. it('should extract all components', () => {
  3514. const result = extractFromSource('MainForm.dfm', code);
  3515. const components = result.nodes.filter((n) => n.kind === 'component');
  3516. expect(components.length).toBe(9);
  3517. expect(components.map((c) => c.name)).toEqual(
  3518. expect.arrayContaining([
  3519. 'frmMain', 'pnlTop', 'lblTitle', 'btnLogin',
  3520. 'pnlContent', 'edtUsername', 'edtPassword', 'mmoLog', 'pnlStatus',
  3521. ])
  3522. );
  3523. });
  3524. it('should extract all event handlers', () => {
  3525. const result = extractFromSource('MainForm.dfm', code);
  3526. const refs = result.unresolvedReferences;
  3527. expect(refs.length).toBe(5);
  3528. expect(refs.map((r) => r.referenceName)).toEqual(
  3529. expect.arrayContaining([
  3530. 'FormCreate', 'FormDestroy', 'btnLoginClick',
  3531. 'edtUsernameChange', 'edtPasswordKeyPress',
  3532. ])
  3533. );
  3534. });
  3535. });
  3536. });
  3537. describe('Kotlin Multiplatform expect/actual', () => {
  3538. let tempDir: string;
  3539. let cg: CodeGraph;
  3540. beforeEach(() => {
  3541. tempDir = createTempDir();
  3542. });
  3543. afterEach(() => {
  3544. if (cg) cg.close();
  3545. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  3546. });
  3547. it('links expect declarations to platform actual implementations and surfaces them in impact', async () => {
  3548. const common = path.join(tempDir, 'src', 'commonMain');
  3549. const jvm = path.join(tempDir, 'src', 'jvmMain');
  3550. fs.mkdirSync(common, { recursive: true });
  3551. fs.mkdirSync(jvm, { recursive: true });
  3552. // common source set: expect declarations + a caller that uses them
  3553. fs.writeFileSync(
  3554. path.join(common, 'SystemProps.kt'),
  3555. `package demo.internal
  3556. expect fun systemProp(name: String): String?
  3557. expect class Platform {
  3558. fun describe(): String
  3559. }
  3560. `
  3561. );
  3562. fs.writeFileSync(
  3563. path.join(common, 'Caller.kt'),
  3564. `package demo
  3565. import demo.internal.systemProp
  3566. import demo.internal.Platform
  3567. fun useIt(): String {
  3568. val v = systemProp("os.name")
  3569. return Platform().describe() + v
  3570. }
  3571. `
  3572. );
  3573. // jvm source set: actual implementations
  3574. fs.writeFileSync(
  3575. path.join(jvm, 'SystemProps.kt'),
  3576. `package demo.internal
  3577. actual fun systemProp(name: String): String? = System.getProperty(name)
  3578. actual class Platform {
  3579. actual fun describe(): String = "JVM"
  3580. }
  3581. `
  3582. );
  3583. cg = CodeGraph.initSync(tempDir);
  3584. await cg.indexAll();
  3585. cg.resolveReferences();
  3586. // The expect/actual markers are captured onto the node's decorators.
  3587. const fns = cg.getNodesByKind('function');
  3588. const actualFn = fns.find(
  3589. (n) => n.name === 'systemProp' && n.decorators?.includes('actual')
  3590. );
  3591. const expectFn = fns.find(
  3592. (n) => n.name === 'systemProp' && n.decorators?.includes('expect')
  3593. );
  3594. expect(actualFn).toBeDefined();
  3595. expect(expectFn).toBeDefined();
  3596. expect(actualFn!.filePath).not.toBe(expectFn!.filePath);
  3597. // Editing the JVM actual must surface the common expect AND its caller —
  3598. // before the expect/actual bridge the actual had zero dependents.
  3599. const impact = cg.getImpactRadius(actualFn!.id, 3);
  3600. const impacted = [...impact.nodes.values()].map((n) => n.name);
  3601. expect(impacted).toContain('systemProp'); // the common expect
  3602. expect(impacted).toContain('useIt'); // the caller, reached transitively
  3603. // The bridging edge is a heuristic `calls` edge tagged by the synthesizer.
  3604. const bridge = impact.edges.find(
  3605. (e) =>
  3606. e.target === actualFn!.id &&
  3607. e.provenance === 'heuristic' &&
  3608. (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy ===
  3609. 'kotlin-expect-actual'
  3610. );
  3611. expect(bridge).toBeDefined();
  3612. expect(bridge!.source).toBe(expectFn!.id);
  3613. });
  3614. it('links an expect class to an actual typealias (different node kinds)', async () => {
  3615. const common = path.join(tempDir, 'src', 'commonMain');
  3616. const jvm = path.join(tempDir, 'src', 'jvmMain');
  3617. fs.mkdirSync(common, { recursive: true });
  3618. fs.mkdirSync(jvm, { recursive: true });
  3619. fs.writeFileSync(
  3620. path.join(common, 'Lock.kt'),
  3621. `package demo
  3622. expect class Lock {
  3623. fun acquire()
  3624. }
  3625. `
  3626. );
  3627. fs.writeFileSync(
  3628. path.join(jvm, 'Lock.kt'),
  3629. `package demo
  3630. actual typealias Lock = java.util.concurrent.locks.ReentrantLock
  3631. `
  3632. );
  3633. cg = CodeGraph.initSync(tempDir);
  3634. await cg.indexAll();
  3635. cg.resolveReferences();
  3636. const aliasNode = cg
  3637. .getNodesByKind('type_alias')
  3638. .find((n) => n.name === 'Lock' && n.decorators?.includes('actual'));
  3639. expect(aliasNode).toBeDefined();
  3640. // The actual typealias is now a cross-file dependency target (linked from
  3641. // the expect class), so it participates in impact rather than being orphaned.
  3642. const impact = cg.getImpactRadius(aliasNode!.id, 3);
  3643. const bridge = impact.edges.find(
  3644. (e) =>
  3645. e.target === aliasNode!.id &&
  3646. (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy ===
  3647. 'kotlin-expect-actual'
  3648. );
  3649. expect(bridge).toBeDefined();
  3650. });
  3651. });
  3652. describe('Scala cross-file dependencies', () => {
  3653. let tempDir: string;
  3654. let cg: CodeGraph;
  3655. beforeEach(() => {
  3656. tempDir = createTempDir();
  3657. });
  3658. afterEach(() => {
  3659. if (cg) cg.close();
  3660. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  3661. });
  3662. it('links parameterized supertypes, type annotations, and implicit params across files', async () => {
  3663. const src = path.join(tempDir, 'src', 'main', 'scala', 'demo');
  3664. fs.mkdirSync(src, { recursive: true });
  3665. fs.writeFileSync(
  3666. path.join(src, 'Semigroup.scala'),
  3667. `package demo
  3668. trait Semigroup[A] {
  3669. def combine(x: A, y: A): A
  3670. }
  3671. `
  3672. );
  3673. fs.writeFileSync(
  3674. path.join(src, 'Monoid.scala'),
  3675. `package demo
  3676. trait Monoid[A] extends Semigroup[A] {
  3677. def empty: A
  3678. }
  3679. `
  3680. );
  3681. fs.writeFileSync(
  3682. path.join(src, 'Instances.scala'),
  3683. `package demo
  3684. object Instances {
  3685. implicit val intMonoid: Monoid[Int] = new Monoid[Int] {
  3686. def empty: Int = 0
  3687. def combine(x: Int, y: Int): Int = x + y
  3688. }
  3689. }
  3690. `
  3691. );
  3692. fs.writeFileSync(
  3693. path.join(src, 'Folding.scala'),
  3694. `package demo
  3695. object Folding {
  3696. def fold[A](xs: List[A])(implicit M: Monoid[A]): A =
  3697. xs.foldLeft(M.empty)(M.combine)
  3698. }
  3699. `
  3700. );
  3701. cg = CodeGraph.initSync(tempDir);
  3702. await cg.indexAll();
  3703. cg.resolveReferences();
  3704. const monoid = cg.getNodesByKind('trait').find((n) => n.name === 'Monoid');
  3705. const semigroup = cg.getNodesByKind('trait').find((n) => n.name === 'Semigroup');
  3706. expect(monoid).toBeDefined();
  3707. expect(semigroup).toBeDefined();
  3708. expect(monoid!.filePath).not.toBe(semigroup!.filePath);
  3709. // Parameterized supertype `extends Semigroup[A]` must create an extends edge —
  3710. // the whole point of the fix (the `[A]` used to defeat name matching).
  3711. const semaImpact = cg.getImpactRadius(semigroup!.id, 3);
  3712. expect([...semaImpact.nodes.values()].map((n) => n.name)).toContain('Monoid');
  3713. // Editing Monoid surfaces the cross-file users: the instance val typed
  3714. // `Monoid[Int]` and the method taking it as an implicit (curried) param.
  3715. const impacted = [...cg.getImpactRadius(monoid!.id, 3).nodes.values()].map((n) => n.name);
  3716. expect(impacted).toContain('intMonoid'); // field type annotation
  3717. expect(impacted).toContain('fold'); // trailing implicit parameter list
  3718. });
  3719. });
  3720. describe('PHP namespace + import resolution', () => {
  3721. let tempDir: string;
  3722. let cg: CodeGraph;
  3723. beforeEach(() => {
  3724. tempDir = createTempDir();
  3725. });
  3726. afterEach(() => {
  3727. if (cg) cg.close();
  3728. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  3729. });
  3730. it('resolves `use` imports to the namespace-qualified definition and type-hints across files', async () => {
  3731. const src = path.join(tempDir, 'src');
  3732. // Two interfaces with the SAME simple name in different namespaces — the
  3733. // exact ambiguity (Laravel has 7+ `Factory`) that bare-name matching can't
  3734. // resolve. The namespace qualifies them; the `use` import disambiguates.
  3735. fs.mkdirSync(path.join(src, 'Cache'), { recursive: true });
  3736. fs.mkdirSync(path.join(src, 'Mail'), { recursive: true });
  3737. fs.mkdirSync(path.join(src, 'App'), { recursive: true });
  3738. fs.writeFileSync(
  3739. path.join(src, 'Cache', 'Factory.php'),
  3740. `<?php
  3741. namespace Contracts\\Cache;
  3742. interface Factory {
  3743. public function store(): object;
  3744. }
  3745. `
  3746. );
  3747. fs.writeFileSync(
  3748. path.join(src, 'Mail', 'Factory.php'),
  3749. `<?php
  3750. namespace Contracts\\Mail;
  3751. interface Factory {
  3752. public function mailer(): object;
  3753. }
  3754. `
  3755. );
  3756. fs.writeFileSync(
  3757. path.join(src, 'App', 'Service.php'),
  3758. `<?php
  3759. namespace App;
  3760. use Contracts\\Cache\\Factory;
  3761. class Service {
  3762. public function make(): Factory {
  3763. return resolve(Factory::class);
  3764. }
  3765. }
  3766. `
  3767. );
  3768. cg = CodeGraph.initSync(tempDir);
  3769. await cg.indexAll();
  3770. cg.resolveReferences();
  3771. // The PHP namespace is captured into the qualified name, so the two
  3772. // same-named interfaces are distinguishable.
  3773. const cacheFactory = cg
  3774. .getNodesByKind('interface')
  3775. .find((n) => n.qualifiedName === 'Contracts\\Cache::Factory');
  3776. const mailFactory = cg
  3777. .getNodesByKind('interface')
  3778. .find((n) => n.qualifiedName === 'Contracts\\Mail::Factory');
  3779. expect(cacheFactory).toBeDefined();
  3780. expect(mailFactory).toBeDefined();
  3781. // Service `use`s Contracts\Cache\Factory, so editing THAT interface reaches
  3782. // Service.php — and editing the same-named Contracts\Mail\Factory must NOT
  3783. // (the import resolved to the right namespace, not an arbitrary `Factory`).
  3784. const serviceFile = 'src/App/Service.php';
  3785. const cacheReaches = [...cg.getImpactRadius(cacheFactory!.id, 3).nodes.values()].some(
  3786. (n) => (n.filePath ?? '').endsWith(serviceFile)
  3787. );
  3788. const mailReaches = [...cg.getImpactRadius(mailFactory!.id, 3).nodes.values()].some(
  3789. (n) => (n.filePath ?? '').endsWith(serviceFile)
  3790. );
  3791. expect(cacheReaches).toBe(true);
  3792. expect(mailReaches).toBe(false);
  3793. });
  3794. });
  3795. describe('Ruby mixins (include/extend/prepend)', () => {
  3796. let tempDir: string;
  3797. let cg: CodeGraph;
  3798. beforeEach(() => {
  3799. tempDir = createTempDir();
  3800. });
  3801. afterEach(() => {
  3802. if (cg) cg.close();
  3803. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  3804. });
  3805. it('links include/extend/prepend to the mixed-in module across files', async () => {
  3806. const lib = path.join(tempDir, 'lib');
  3807. fs.mkdirSync(lib, { recursive: true });
  3808. fs.writeFileSync(
  3809. path.join(lib, 'concerns.rb'),
  3810. `module Trackable
  3811. def track; end
  3812. end
  3813. module Cacheable
  3814. def cache; end
  3815. end
  3816. module Loggable
  3817. def log; end
  3818. end
  3819. `
  3820. );
  3821. fs.writeFileSync(
  3822. path.join(lib, 'model.rb'),
  3823. `class Model
  3824. include Trackable
  3825. prepend Cacheable
  3826. extend Loggable
  3827. end
  3828. `
  3829. );
  3830. cg = CodeGraph.initSync(tempDir);
  3831. await cg.indexAll();
  3832. cg.resolveReferences();
  3833. const model = cg.getNodesByKind('class').find((n) => n.name === 'Model');
  3834. expect(model).toBeDefined();
  3835. // All three mixin forms create an `implements` edge Model → module, so
  3836. // editing a concern surfaces every class that mixes it in (across files).
  3837. for (const moduleName of ['Trackable', 'Cacheable', 'Loggable']) {
  3838. const mod = cg.getNodesByKind('module').find((n) => n.name === moduleName);
  3839. expect(mod, moduleName).toBeDefined();
  3840. const impacted = [...cg.getImpactRadius(mod!.id, 3).nodes.values()].map((n) => n.name);
  3841. expect(impacted, `${moduleName} should be depended on by Model`).toContain('Model');
  3842. }
  3843. });
  3844. it('resolves require / require_relative to the required file', async () => {
  3845. const lib = path.join(tempDir, 'lib');
  3846. fs.mkdirSync(path.join(lib, 'app'), { recursive: true });
  3847. // A leaf file whose class is referenced only dynamically — so without
  3848. // require resolution it would look like nothing depends on it.
  3849. fs.writeFileSync(
  3850. path.join(lib, 'app', 'fetcher.rb'),
  3851. `module App
  3852. class Fetcher
  3853. def fetch; end
  3854. end
  3855. end
  3856. `
  3857. );
  3858. // Pulled in by a load-path `require` …
  3859. fs.writeFileSync(
  3860. path.join(lib, 'app', 'worker.rb'),
  3861. `require "app/fetcher"
  3862. module App
  3863. class Worker; end
  3864. end
  3865. `
  3866. );
  3867. // … and a sibling pulled in by `require_relative`.
  3868. fs.writeFileSync(
  3869. path.join(lib, 'app', 'boot.rb'),
  3870. `require_relative "fetcher"
  3871. `
  3872. );
  3873. cg = CodeGraph.initSync(tempDir);
  3874. await cg.indexAll();
  3875. cg.resolveReferences();
  3876. // The require edges target fetcher.rb's FILE node. Editing it should reach
  3877. // BOTH the load-path requirer (worker.rb) and the require_relative one
  3878. // (boot.rb) — without require resolution its file would have no dependents.
  3879. const fetcher = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('app/fetcher.rb'));
  3880. expect(fetcher, 'fetcher.rb indexed').toBeDefined();
  3881. const reached = [...cg.getImpactRadius(fetcher!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  3882. expect(reached.some((p) => p.endsWith('app/worker.rb'))).toBe(true);
  3883. expect(reached.some((p) => p.endsWith('app/boot.rb'))).toBe(true);
  3884. });
  3885. });
  3886. describe('C++ free-function name extraction', () => {
  3887. let tempDir: string;
  3888. let cg: CodeGraph;
  3889. beforeEach(() => {
  3890. tempDir = createTempDir();
  3891. });
  3892. afterEach(() => {
  3893. if (cg) cg.close();
  3894. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  3895. });
  3896. it('names a free function correctly when it has qualified-type params or a trailing return type', async () => {
  3897. const src = path.join(tempDir, 'src');
  3898. fs.mkdirSync(src, { recursive: true });
  3899. // TableFileName has a `const std::string&` parameter; BuildName uses an
  3900. // `auto … -> std::string` trailing return type. Both used to be named
  3901. // `string` (picked up from the parameter / return type), so callers never
  3902. // resolved and the defining file looked like nothing depended on it.
  3903. fs.writeFileSync(
  3904. path.join(src, 'names.cc'),
  3905. `#include <string>
  3906. std::string TableFileName(const std::string& dbname, int number) {
  3907. return dbname;
  3908. }
  3909. auto BuildName(const std::string& a) -> std::string {
  3910. return a;
  3911. }
  3912. `
  3913. );
  3914. fs.writeFileSync(
  3915. path.join(src, 'user.cc'),
  3916. `#include <string>
  3917. std::string use() {
  3918. return TableFileName("db", 1) + BuildName("x");
  3919. }
  3920. `
  3921. );
  3922. cg = CodeGraph.initSync(tempDir);
  3923. await cg.indexAll();
  3924. cg.resolveReferences();
  3925. // The functions are extracted under their real names, not `string`.
  3926. const fns = cg.getNodesByKind('function');
  3927. const tableFn = fns.find((n) => n.name === 'TableFileName');
  3928. const buildFn = fns.find((n) => n.name === 'BuildName');
  3929. expect(tableFn, 'TableFileName extracted (not "string")').toBeDefined();
  3930. expect(buildFn, 'BuildName extracted (not "string")').toBeDefined();
  3931. // And the cross-file calls resolve to them, so editing names.cc surfaces user.cc.
  3932. for (const fn of [tableFn!, buildFn!]) {
  3933. const reached = [...cg.getImpactRadius(fn.id, 3).nodes.values()].map((n) => n.filePath ?? '');
  3934. expect(reached.some((p) => p.endsWith('user.cc')), `${fn.name} should be called from user.cc`).toBe(true);
  3935. }
  3936. });
  3937. });
  3938. describe('Dart mixins and type references', () => {
  3939. let tempDir: string;
  3940. let cg: CodeGraph;
  3941. beforeEach(() => {
  3942. tempDir = createTempDir();
  3943. });
  3944. afterEach(() => {
  3945. if (cg) cg.close();
  3946. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  3947. });
  3948. it('links `with` mixins and method parameter/return types across files', async () => {
  3949. const lib = path.join(tempDir, 'lib');
  3950. fs.mkdirSync(lib, { recursive: true });
  3951. fs.writeFileSync(
  3952. path.join(lib, 'models.dart'),
  3953. `class User {
  3954. final String name;
  3955. User(this.name);
  3956. }
  3957. mixin Loggable {
  3958. void log() {}
  3959. }
  3960. abstract class Repository {
  3961. User find(int id);
  3962. }
  3963. `
  3964. );
  3965. fs.writeFileSync(
  3966. path.join(lib, 'service.dart'),
  3967. `import 'models.dart';
  3968. class UserService extends Repository with Loggable {
  3969. @override
  3970. User find(int id) => User('x');
  3971. List<User> all() => [];
  3972. }
  3973. `
  3974. );
  3975. cg = CodeGraph.initSync(tempDir);
  3976. await cg.indexAll();
  3977. cg.resolveReferences();
  3978. const inModels = (name: string) =>
  3979. cg.getNodesByKind('class').concat(cg.getNodesByKind('module'))
  3980. .find((n) => n.name === name && n.filePath.endsWith('models.dart'));
  3981. // The `with Loggable` mixin records a dependency — editing the mixin surfaces
  3982. // the class that mixes it in (across files). Loggable is a `mixin`, indexed
  3983. // as a class-like node.
  3984. const loggable = cg.getNodesByKind('class').find((n) => n.name === 'Loggable')
  3985. ?? cg.getNodesByKind('module').find((n) => n.name === 'Loggable');
  3986. expect(loggable, 'Loggable mixin indexed').toBeDefined();
  3987. const mixinUsers = [...cg.getImpactRadius(loggable!.id, 3).nodes.values()].map((n) => n.name);
  3988. expect(mixinUsers).toContain('UserService');
  3989. // `User` is used only as a method parameter/return type in service.dart —
  3990. // editing it must still surface service.dart via the type references.
  3991. const user = inModels('User') ?? cg.getNodesByKind('class').find((n) => n.name === 'User');
  3992. expect(user, 'User indexed').toBeDefined();
  3993. const userDeps = [...cg.getImpactRadius(user!.id, 3).nodes.values()].map((n) => n.filePath ?? '');
  3994. expect(userDeps.some((p) => p.endsWith('service.dart'))).toBe(true);
  3995. });
  3996. });
  3997. describe('Static-member / value-read references', () => {
  3998. let tempDir: string;
  3999. let cg: CodeGraph;
  4000. beforeEach(() => {
  4001. tempDir = createTempDir();
  4002. });
  4003. afterEach(() => {
  4004. if (cg) cg.close();
  4005. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4006. });
  4007. it('links a type referenced only via a static field / enum value (and ignores lowercase receivers)', async () => {
  4008. fs.writeFileSync(
  4009. path.join(tempDir, 'JsonScope.java'),
  4010. `class JsonScope {
  4011. static final int EMPTY_DOCUMENT = 1;
  4012. }
  4013. `
  4014. );
  4015. fs.writeFileSync(
  4016. path.join(tempDir, 'Reader.java'),
  4017. `class Reader {
  4018. private int helper;
  4019. int peek() {
  4020. return JsonScope.EMPTY_DOCUMENT;
  4021. }
  4022. int noop() {
  4023. return this.helper;
  4024. }
  4025. }
  4026. `
  4027. );
  4028. cg = CodeGraph.initSync(tempDir);
  4029. await cg.indexAll();
  4030. cg.resolveReferences();
  4031. // JsonScope is used ONLY as `JsonScope.EMPTY_DOCUMENT` (a static-field value
  4032. // read — never constructed or called), so before the static-member pass it
  4033. // had no dependents. Editing it now surfaces Reader.java.
  4034. const scope = cg.getNodesByKind('class').find((n) => n.name === 'JsonScope');
  4035. expect(scope, 'JsonScope indexed').toBeDefined();
  4036. const reached = [...cg.getImpactRadius(scope!.id, 3).nodes.values()].map((n) => n.filePath ?? '');
  4037. expect(reached.some((p) => p.endsWith('Reader.java'))).toBe(true);
  4038. // A lowercase receiver (`this.helper`) must NOT be emitted as a type ref —
  4039. // only Capitalized receivers (types) are. No node named `this`/`helper`
  4040. // should appear as a reference target from peek/noop beyond JsonScope.
  4041. const refTargets = cg
  4042. .getNodesByKind('class')
  4043. .filter((n) => n.name === 'this' || n.name === 'helper');
  4044. expect(refTargets.length).toBe(0);
  4045. });
  4046. it('does not link a static-member read across language families (coincidental name)', async () => {
  4047. // A native (Kotlin) `Build.VERSION` reads the Android system class — it must
  4048. // NOT link to a coincidentally same-named TS class (the cross-language false
  4049. // positive that name-matching produces; `references` edges are language-local).
  4050. fs.writeFileSync(
  4051. path.join(tempDir, 'Build.ts'),
  4052. `export class Build {\n static version = 1;\n}\n`
  4053. );
  4054. fs.writeFileSync(
  4055. path.join(tempDir, 'Device.kt'),
  4056. `package app\nclass Device {\n fun sdk(): Int = Build.VERSION\n}\n`
  4057. );
  4058. cg = CodeGraph.initSync(tempDir);
  4059. await cg.indexAll();
  4060. cg.resolveReferences();
  4061. const tsBuild = cg.getNodesByKind('class').find((n) => n.name === 'Build' && n.filePath.endsWith('Build.ts'));
  4062. expect(tsBuild).toBeDefined();
  4063. // The Kotlin file is `app/Device.kt`; the TS Build must have NO dependent there.
  4064. const deps = [...cg.getImpactRadius(tsBuild!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4065. expect(deps.some((p) => p.endsWith('Device.kt'))).toBe(false);
  4066. });
  4067. });
  4068. describe('Cross-language type/import gate (RN name collisions)', () => {
  4069. let tempDir: string;
  4070. let cg: CodeGraph;
  4071. beforeEach(() => {
  4072. tempDir = createTempDir();
  4073. });
  4074. afterEach(() => {
  4075. if (cg) cg.close();
  4076. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4077. });
  4078. it('a TS PascalCase type ref lands on the TS type, never a same-named native class', async () => {
  4079. // react-native-async-storage's example app has a TS `type TestRunner` AND a
  4080. // Kotlin `class TestRunner`. The React PascalCase resolver name-matched the
  4081. // Kotlin `class` (its COMPONENT_KINDS includes `class`) with no language
  4082. // check at confidence 0.8, outranking the (cross-language-penalized 0.5)
  4083. // TS name-match — so a TS ref to `TestRunner` crossed web→jvm. The ref here
  4084. // is intentionally NOT imported: a clean relative import would mask the bug
  4085. // by resolving via the import map before the framework strategy can win.
  4086. fs.writeFileSync(
  4087. path.join(tempDir, 'package.json'),
  4088. JSON.stringify({ dependencies: { 'react-native': '*' } })
  4089. );
  4090. fs.writeFileSync(
  4091. path.join(tempDir, 'useTests.ts'),
  4092. `export type TestRunner = { run: () => void };\n`
  4093. );
  4094. fs.writeFileSync(
  4095. path.join(tempDir, 'basic.tsx'),
  4096. `export function useBasicTest(r: TestRunner): TestRunner {\n return r;\n}\n`
  4097. );
  4098. fs.writeFileSync(
  4099. path.join(tempDir, 'TestUtils.kt'),
  4100. `package app\nclass TestRunner {\n fun run() {}\n}\n`
  4101. );
  4102. cg = CodeGraph.initSync(tempDir);
  4103. await cg.indexAll();
  4104. cg.resolveReferences();
  4105. const ktRunner = cg
  4106. .getNodesByKind('class')
  4107. .find((n) => n.name === 'TestRunner' && n.filePath.endsWith('TestUtils.kt'));
  4108. expect(ktRunner, 'Kotlin TestRunner class').toBeDefined();
  4109. const ktDeps = [...cg.getImpactRadius(ktRunner!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4110. expect(ktDeps.some((p) => p.endsWith('basic.tsx')), 'Kotlin class has NO TS dependent').toBe(false);
  4111. const tsRunner = cg.getNodesByKind('type_alias').find((n) => n.name === 'TestRunner');
  4112. expect(tsRunner, 'TS TestRunner type_alias').toBeDefined();
  4113. const tsDeps = [...cg.getImpactRadius(tsRunner!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4114. expect(tsDeps.some((p) => p.endsWith('basic.tsx')), 'TS type captured the ref (re-pointed)').toBe(true);
  4115. });
  4116. it('gates a cross-family import name collision but keeps same-family imports', async () => {
  4117. // A TS `import { Widget }` that only matches a Swift `class Widget` must not
  4118. // create a web→apple dependency — but a sibling TS module imported by
  4119. // another TS file (same family) must still resolve (no over-gating).
  4120. fs.writeFileSync(path.join(tempDir, 'Widget.swift'), `class Widget {\n func render() {}\n}\n`);
  4121. fs.writeFileSync(
  4122. path.join(tempDir, 'widget.ts'),
  4123. `import { Widget } from './native';\nexport function mount(w: Widget) {}\n`
  4124. );
  4125. fs.writeFileSync(path.join(tempDir, 'util.ts'), `export class Helper {}\n`);
  4126. fs.writeFileSync(
  4127. path.join(tempDir, 'app.ts'),
  4128. `import { Helper } from './util';\nexport const h = new Helper();\n`
  4129. );
  4130. cg = CodeGraph.initSync(tempDir);
  4131. await cg.indexAll();
  4132. cg.resolveReferences();
  4133. const swiftWidget = cg
  4134. .getNodesByKind('class')
  4135. .find((n) => n.name === 'Widget' && n.filePath.endsWith('.swift'));
  4136. expect(swiftWidget, 'Swift Widget class').toBeDefined();
  4137. const wDeps = [...cg.getImpactRadius(swiftWidget!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4138. expect(wDeps.some((p) => p.endsWith('widget.ts')), 'Swift class has NO TS dependent').toBe(false);
  4139. // Same-family control — the TS Helper must still see its TS dependent.
  4140. const helper = cg.getNodesByKind('class').find((n) => n.name === 'Helper');
  4141. expect(helper, 'TS Helper class').toBeDefined();
  4142. const hDeps = [...cg.getImpactRadius(helper!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4143. expect(hDeps.some((p) => p.endsWith('app.ts')), 'same-family TS import preserved').toBe(true);
  4144. });
  4145. });
  4146. describe('Python absolute module import resolution', () => {
  4147. let tempDir: string;
  4148. let cg: CodeGraph;
  4149. beforeEach(() => {
  4150. tempDir = createTempDir();
  4151. });
  4152. afterEach(() => {
  4153. if (cg) cg.close();
  4154. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4155. });
  4156. it('links a bare `import pkg.module` of an internal module to its file', async () => {
  4157. // `import conduit.apps.signals` (a Django-style side-effect import, and any
  4158. // dotted absolute module import) had no edge to the module file — only
  4159. // `from x import y` was linked — so a module imported by its dotted path
  4160. // looked like nothing depended on it.
  4161. fs.mkdirSync(path.join(tempDir, 'conduit/apps'), { recursive: true });
  4162. fs.writeFileSync(path.join(tempDir, 'conduit/__init__.py'), '');
  4163. fs.writeFileSync(path.join(tempDir, 'conduit/apps/__init__.py'), '');
  4164. fs.writeFileSync(path.join(tempDir, 'conduit/apps/signals.py'), `def handler():\n pass\n`);
  4165. fs.writeFileSync(
  4166. path.join(tempDir, 'conduit/apps/app.py'),
  4167. `import conduit.apps.signals\nimport os\n\nVALUE = 1\n`
  4168. );
  4169. cg = CodeGraph.initSync(tempDir);
  4170. await cg.indexAll();
  4171. cg.resolveReferences();
  4172. const signals = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('conduit/apps/signals.py'));
  4173. expect(signals, 'signals.py indexed').toBeDefined();
  4174. const deps = [...cg.getImpactRadius(signals!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4175. expect(deps.some((p) => p.endsWith('app.py')), 'importer depends on the module').toBe(true);
  4176. // `import os` (stdlib) must NOT fabricate an edge — no os.py file in the repo.
  4177. const osNode = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('/os.py'));
  4178. expect(osNode, 'no stdlib os.py node').toBeUndefined();
  4179. });
  4180. it('Django include() links the root URLconf to the included app urls module', async () => {
  4181. // `url(r'^api/', include('app.urls'))` should record a dependency from the
  4182. // root urlconf onto the included app's `urls.py` — so editing an app's routes
  4183. // surfaces the project urlconf that mounts them.
  4184. fs.mkdirSync(path.join(tempDir, 'app'), { recursive: true });
  4185. fs.writeFileSync(path.join(tempDir, 'requirements.txt'), `django==4.0\n`);
  4186. fs.writeFileSync(path.join(tempDir, 'app/__init__.py'), '');
  4187. fs.writeFileSync(path.join(tempDir, 'app/views.py'), `def home(request):\n return None\n`);
  4188. fs.writeFileSync(
  4189. path.join(tempDir, 'app/urls.py'),
  4190. `from django.conf.urls import url\nfrom . import views\nurlpatterns = [url(r'^$', views.home)]\n`
  4191. );
  4192. fs.writeFileSync(
  4193. path.join(tempDir, 'urls.py'),
  4194. `from django.conf.urls import include, url\nurlpatterns = [url(r'^app/', include('app.urls'))]\n`
  4195. );
  4196. cg = CodeGraph.initSync(tempDir);
  4197. await cg.indexAll();
  4198. cg.resolveReferences();
  4199. const appUrls = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('app/urls.py'));
  4200. expect(appUrls, 'app/urls.py indexed').toBeDefined();
  4201. const deps = [...cg.getImpactRadius(appUrls!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4202. expect(deps.some((p) => p.endsWith('urls.py') && !p.endsWith('app/urls.py')), 'root urlconf depends on the included app urls').toBe(true);
  4203. });
  4204. it('resolves `from pkg import submodule` to the submodule under that package, not a same-named one', async () => {
  4205. // FastAPI router-aggregator pattern: `from app.api.routes import authentication`
  4206. // with same-named modules in sibling packages must resolve via the import's
  4207. // SOURCE (the package), not a coincidental same-basename file elsewhere.
  4208. fs.mkdirSync(path.join(tempDir, 'app/api/routes'), { recursive: true });
  4209. fs.mkdirSync(path.join(tempDir, 'app/api/dependencies'), { recursive: true });
  4210. for (const p of ['app/__init__.py', 'app/api/__init__.py', 'app/api/routes/__init__.py', 'app/api/dependencies/__init__.py']) {
  4211. fs.writeFileSync(path.join(tempDir, p), '');
  4212. }
  4213. fs.writeFileSync(path.join(tempDir, 'app/api/routes/authentication.py'), `def login():\n pass\n`);
  4214. fs.writeFileSync(path.join(tempDir, 'app/api/dependencies/authentication.py'), `def get_user():\n pass\n`);
  4215. fs.writeFileSync(
  4216. path.join(tempDir, 'app/api/routes/api.py'),
  4217. `from app.api.routes import authentication\n\nROUTER = authentication\n`
  4218. );
  4219. cg = CodeGraph.initSync(tempDir);
  4220. await cg.indexAll();
  4221. cg.resolveReferences();
  4222. const routesAuth = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('routes/authentication.py'));
  4223. const depsAuth = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('dependencies/authentication.py'));
  4224. expect(routesAuth && depsAuth).toBeTruthy();
  4225. const routesDeps = [...cg.getImpactRadius(routesAuth!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4226. const depsDeps = [...cg.getImpactRadius(depsAuth!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4227. expect(routesDeps.some((p) => p.endsWith('routes/api.py')), 'submodule under the imported package is the dependent').toBe(true);
  4228. expect(depsDeps.some((p) => p.endsWith('routes/api.py')), 'same-named module in a sibling package is NOT').toBe(false);
  4229. });
  4230. });
  4231. describe('Razor / Blazor markup extraction', () => {
  4232. let tempDir: string;
  4233. let cg: CodeGraph;
  4234. beforeEach(() => {
  4235. tempDir = createTempDir();
  4236. });
  4237. afterEach(() => {
  4238. if (cg) cg.close();
  4239. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4240. });
  4241. it('links @model and Blazor component tags to their C# types; ignores HTML elements', async () => {
  4242. fs.mkdirSync(path.join(tempDir, 'Views'), { recursive: true });
  4243. fs.writeFileSync(
  4244. path.join(tempDir, 'LoginViewModel.cs'),
  4245. `namespace App { public class LoginViewModel { public string Email { get; set; } } }`
  4246. );
  4247. fs.writeFileSync(
  4248. path.join(tempDir, 'ToastComponent.cs'),
  4249. `namespace App { public class ToastComponent { } }`
  4250. );
  4251. fs.writeFileSync(
  4252. path.join(tempDir, 'Views/Login.cshtml'),
  4253. `@model LoginViewModel\n<div class="form">\n <input asp-for="Email" />\n</div>\n`
  4254. );
  4255. fs.writeFileSync(
  4256. path.join(tempDir, 'Index.razor'),
  4257. `<div>\n <ToastComponent />\n</div>\n`
  4258. );
  4259. cg = CodeGraph.initSync(tempDir);
  4260. await cg.indexAll();
  4261. cg.resolveReferences();
  4262. // `@model LoginViewModel` → the view-model class.
  4263. const vm = cg.getNodesByKind('class').find((n) => n.name === 'LoginViewModel');
  4264. expect(vm, 'LoginViewModel class').toBeDefined();
  4265. const vmDeps = [...cg.getImpactRadius(vm!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4266. expect(vmDeps.some((p) => p.endsWith('Login.cshtml')), '@model links the view').toBe(true);
  4267. // `<ToastComponent />` → the component class.
  4268. const toast = cg.getNodesByKind('class').find((n) => n.name === 'ToastComponent');
  4269. expect(toast, 'ToastComponent class').toBeDefined();
  4270. const toastDeps = [...cg.getImpactRadius(toast!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4271. expect(toastDeps.some((p) => p.endsWith('Index.razor')), 'Blazor tag links the component').toBe(true);
  4272. // HTML elements (`<div>`, `<input>`) must NOT become component references.
  4273. const htmlNodes = cg.getNodesByKind('class').filter((n) => n.name === 'div' || n.name === 'input');
  4274. expect(htmlNodes.length, 'no node for HTML elements').toBe(0);
  4275. });
  4276. it('C# namespaces qualify type names so same-named types are distinct', async () => {
  4277. fs.writeFileSync(path.join(tempDir, 'entity.cs'), `namespace App.Entities { public class CatalogBrand { } }`);
  4278. fs.writeFileSync(path.join(tempDir, 'dto.cs'), `namespace App.Models { public class CatalogBrand { } }`);
  4279. cg = CodeGraph.initSync(tempDir);
  4280. await cg.indexAll();
  4281. const brands = cg.getNodesByKind('class').filter((n) => n.name === 'CatalogBrand');
  4282. expect(brands.length, 'both CatalogBrand classes indexed').toBe(2);
  4283. const qns = brands.map((b) => b.qualifiedName).sort();
  4284. expect(qns[0]).not.toBe(qns[1]); // distinct qualified names (namespace-scoped)
  4285. expect(qns.some((q) => q.includes('Entities') && q.endsWith('CatalogBrand'))).toBe(true);
  4286. expect(qns.some((q) => q.includes('Models') && q.endsWith('CatalogBrand'))).toBe(true);
  4287. });
  4288. it('disambiguates a Razor type ref via @using (incl. folder _Imports.razor)', async () => {
  4289. // `CatalogBrand` exists as both a domain entity and a DTO; the component
  4290. // `@using`s the DTO's namespace (here via the folder _Imports.razor), so the
  4291. // ref must resolve to the DTO, not the same-named entity.
  4292. fs.mkdirSync(path.join(tempDir, 'Models'), { recursive: true });
  4293. fs.mkdirSync(path.join(tempDir, 'Entities'), { recursive: true });
  4294. fs.mkdirSync(path.join(tempDir, 'Pages'), { recursive: true });
  4295. fs.writeFileSync(path.join(tempDir, 'Models/CatalogBrand.cs'), `namespace App.Models { public class CatalogBrand { public int Id { get; set; } } }`);
  4296. fs.writeFileSync(path.join(tempDir, 'Entities/CatalogBrand.cs'), `namespace App.Entities { public class CatalogBrand { public int Id { get; set; } } }`);
  4297. fs.writeFileSync(path.join(tempDir, 'Pages/_Imports.razor'), `@using App.Models\n`);
  4298. fs.writeFileSync(
  4299. path.join(tempDir, 'Pages/List.razor'),
  4300. `<h1>List</h1>\n@code {\n private CatalogBrand _b = new CatalogBrand();\n}\n`
  4301. );
  4302. cg = CodeGraph.initSync(tempDir);
  4303. await cg.indexAll();
  4304. cg.resolveReferences();
  4305. const dto = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'App.Models::CatalogBrand');
  4306. const entity = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'App.Entities::CatalogBrand');
  4307. expect(dto && entity, 'both CatalogBrand classes').toBeTruthy();
  4308. const dtoDeps = [...cg.getImpactRadius(dto!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4309. const entityDeps = [...cg.getImpactRadius(entity!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4310. expect(dtoDeps.some((p) => p.endsWith('List.razor')), 'resolves to the @using\'d DTO').toBe(true);
  4311. expect(entityDeps.some((p) => p.endsWith('List.razor')), 'NOT the same-named entity').toBe(false);
  4312. });
  4313. it('delegates Blazor @code block C# to cover types used in component logic', async () => {
  4314. fs.writeFileSync(
  4315. path.join(tempDir, 'CatalogService.cs'),
  4316. `namespace App { public class CatalogService { public void Load() { } } }`
  4317. );
  4318. fs.writeFileSync(
  4319. path.join(tempDir, 'List.razor'),
  4320. `<h1>Catalog</h1>\n\n@code {\n private CatalogService _svc = new CatalogService();\n void Refresh() { _svc.Load(); }\n}\n`
  4321. );
  4322. cg = CodeGraph.initSync(tempDir);
  4323. await cg.indexAll();
  4324. cg.resolveReferences();
  4325. const svc = cg.getNodesByKind('class').find((n) => n.name === 'CatalogService');
  4326. expect(svc, 'CatalogService class').toBeDefined();
  4327. const deps = [...cg.getImpactRadius(svc!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4328. expect(deps.some((p) => p.endsWith('List.razor')), '@code usage links the component to the service').toBe(true);
  4329. });
  4330. });
  4331. describe('Default import resolution (renamed default export)', () => {
  4332. let tempDir: string;
  4333. let cg: CodeGraph;
  4334. beforeEach(() => {
  4335. tempDir = createTempDir();
  4336. });
  4337. afterEach(() => {
  4338. if (cg) cg.close();
  4339. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4340. });
  4341. it('links a renamed default import to the module file', async () => {
  4342. // Express route aggregator: `import articlesController from './controller'`
  4343. // where the module does `export default router`. The renamed local can't be
  4344. // found as a symbol, so the controller file had no dependent — the dependency
  4345. // is on the module file regardless of the default export's name.
  4346. fs.mkdirSync(path.join(tempDir, 'app'), { recursive: true });
  4347. fs.writeFileSync(path.join(tempDir, 'app/controller.ts'), `const router = { get() {} };\nexport default router;\n`);
  4348. fs.writeFileSync(path.join(tempDir, 'app/routes.ts'), `import myController from './controller';\nexport const api = myController;\n`);
  4349. cg = CodeGraph.initSync(tempDir);
  4350. await cg.indexAll();
  4351. cg.resolveReferences();
  4352. const controller = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('app/controller.ts'));
  4353. expect(controller, 'controller.ts indexed').toBeDefined();
  4354. const deps = [...cg.getImpactRadius(controller!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4355. expect(deps.some((p) => p.endsWith('routes.ts')), 'importer depends on the default-exporting module').toBe(true);
  4356. });
  4357. });
  4358. describe('Chained method-call resolution (C# extension methods)', () => {
  4359. let tempDir: string;
  4360. let cg: CodeGraph;
  4361. beforeEach(() => {
  4362. tempDir = createTempDir();
  4363. });
  4364. afterEach(() => {
  4365. if (cg) cg.close();
  4366. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4367. });
  4368. it('resolves a chained extension-method call (a.b.Method()) to its definition', async () => {
  4369. // ASP.NET DI registration: `builder.Services.AddCoreServices(...)` calls a
  4370. // static extension method elsewhere. A multi-dot receiver chain matched no
  4371. // method-call pattern before, so the extension method had no caller.
  4372. fs.mkdirSync(path.join(tempDir, 'cfg'), { recursive: true });
  4373. fs.writeFileSync(
  4374. path.join(tempDir, 'cfg/Ext.cs'),
  4375. `namespace App {\n public static class Ext {\n public static object AddCoreServices(this object services, int x) { return services; }\n }\n}\n`
  4376. );
  4377. fs.writeFileSync(
  4378. path.join(tempDir, 'Program.cs'),
  4379. `namespace App {\n public class Program {\n public void Run(object builder) {\n builder.Services.AddCoreServices(1);\n }\n }\n}\n`
  4380. );
  4381. cg = CodeGraph.initSync(tempDir);
  4382. await cg.indexAll();
  4383. cg.resolveReferences();
  4384. const ext = cg
  4385. .getNodesByKind('method')
  4386. .find((n) => n.name === 'AddCoreServices')
  4387. ?? cg.getNodesByKind('function').find((n) => n.name === 'AddCoreServices');
  4388. expect(ext, 'AddCoreServices defined').toBeDefined();
  4389. const callers = [...cg.getImpactRadius(ext!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4390. expect(callers.some((p) => p.endsWith('Program.cs')), 'chained extension call resolves to its definition').toBe(true);
  4391. });
  4392. });
  4393. describe('Same-directory include + KMP import resolution', () => {
  4394. let tempDir: string;
  4395. let cg: CodeGraph;
  4396. beforeEach(() => {
  4397. tempDir = createTempDir();
  4398. });
  4399. afterEach(() => {
  4400. if (cg) cg.close();
  4401. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4402. });
  4403. it('a C/C++ #include resolves to the same-directory header, not a same-named one elsewhere', async () => {
  4404. // A multi-platform native module has a header of the same basename per
  4405. // platform. `windows/Provider.cpp`'s `#include "Storage.h"` means its OWN
  4406. // sibling header — not `apple/Storage.h` (which sorts first and so was
  4407. // picked arbitrarily before, leaving the real local header with 0 deps).
  4408. fs.mkdirSync(path.join(tempDir, 'apple'), { recursive: true });
  4409. fs.mkdirSync(path.join(tempDir, 'windows'), { recursive: true });
  4410. fs.writeFileSync(path.join(tempDir, 'apple', 'Storage.h'), `#pragma once\nstruct Storage { int n; };\n`);
  4411. fs.writeFileSync(path.join(tempDir, 'windows', 'Storage.h'), `#pragma once\nstruct Storage { int n; };\n`);
  4412. fs.writeFileSync(
  4413. path.join(tempDir, 'windows', 'Provider.cpp'),
  4414. `#include "Storage.h"\nint use() { Storage s; return s.n; }\n`
  4415. );
  4416. cg = CodeGraph.initSync(tempDir);
  4417. await cg.indexAll();
  4418. cg.resolveReferences();
  4419. const winHeader = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('windows/Storage.h'));
  4420. const appleHeader = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('apple/Storage.h'));
  4421. expect(winHeader, 'windows/Storage.h indexed').toBeDefined();
  4422. expect(appleHeader, 'apple/Storage.h indexed').toBeDefined();
  4423. const winDeps = [...cg.getImpactRadius(winHeader!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4424. const appleDeps = [...cg.getImpactRadius(appleHeader!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4425. expect(winDeps.some((p) => p.endsWith('Provider.cpp')), 'same-dir header gets the includer').toBe(true);
  4426. expect(appleDeps.some((p) => p.endsWith('Provider.cpp')), 'other-platform header does NOT').toBe(false);
  4427. });
  4428. it('a Kotlin Multiplatform commonMain import resolves to the expect, not a platform actual', async () => {
  4429. const common = path.join(tempDir, 'src/commonMain/kotlin/app');
  4430. const android = path.join(tempDir, 'src/androidMain/kotlin/app');
  4431. fs.mkdirSync(common, { recursive: true });
  4432. fs.mkdirSync(android, { recursive: true });
  4433. fs.writeFileSync(path.join(common, 'Platform.kt'), `package app\nexpect class PlatformContext\n`);
  4434. fs.writeFileSync(path.join(android, 'Platform.android.kt'), `package app\nactual class PlatformContext\n`);
  4435. fs.writeFileSync(
  4436. path.join(common, 'Db.kt'),
  4437. `package app\nimport app.PlatformContext\nclass Db {\n fun open(ctx: PlatformContext) {}\n}\n`
  4438. );
  4439. cg = CodeGraph.initSync(tempDir);
  4440. await cg.indexAll();
  4441. cg.resolveReferences();
  4442. const expectCtx = cg
  4443. .getNodesByKind('class')
  4444. .find((n) => n.name === 'PlatformContext' && n.filePath.endsWith('commonMain/kotlin/app/Platform.kt'));
  4445. expect(expectCtx, 'commonMain expect PlatformContext').toBeDefined();
  4446. const deps = [...cg.getImpactRadius(expectCtx!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4447. expect(deps.some((p) => p.endsWith('Db.kt')), 'commonMain import lands on the expect, not the actual').toBe(true);
  4448. });
  4449. });
  4450. describe('Delphi form code-behind pairing', () => {
  4451. let tempDir: string;
  4452. let cg: CodeGraph;
  4453. beforeEach(() => {
  4454. tempDir = createTempDir();
  4455. });
  4456. afterEach(() => {
  4457. if (cg) cg.close();
  4458. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4459. });
  4460. it('links a `.dfm` form to its sibling `.pas` code-behind unit', async () => {
  4461. // A Delphi form unit owns its visual form definition via `{$R *.dfm}`, not a
  4462. // `uses` clause — so a `.dfm` used only as a form definition looked orphaned.
  4463. fs.writeFileSync(path.join(tempDir, 'UFRMAbout.dfm'),
  4464. `object FRMAbout: TFRMAbout\n Caption = 'About'\nend\n`);
  4465. fs.writeFileSync(path.join(tempDir, 'UFRMAbout.pas'),
  4466. `unit UFRMAbout;\ninterface\nuses Forms;\ntype\n TFRMAbout = class(TForm)\n end;\nimplementation\n{$R *.dfm}\nend.\n`);
  4467. cg = CodeGraph.initSync(tempDir);
  4468. await cg.indexAll();
  4469. cg.resolveReferences();
  4470. const dfm = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('UFRMAbout.dfm'));
  4471. expect(dfm, 'UFRMAbout.dfm file node').toBeDefined();
  4472. const deps = cg.getFileDependents(dfm!.filePath);
  4473. expect(deps.some((p) => p.endsWith('UFRMAbout.pas')), 'the .pas unit links its .dfm form').toBe(true);
  4474. });
  4475. });
  4476. describe('Liquid Shopify JSON template section resolution', () => {
  4477. let tempDir: string;
  4478. let cg: CodeGraph;
  4479. beforeEach(() => {
  4480. tempDir = createTempDir();
  4481. });
  4482. afterEach(() => {
  4483. if (cg) cg.close();
  4484. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4485. });
  4486. it('links a Shopify JSON template section `type` to its sections/<type>.liquid', async () => {
  4487. // Shopify OS 2.0 templates are JSON, referencing sections by `type` — not
  4488. // a `{% section %}` Liquid tag — so a section used only from a JSON template
  4489. // looked unused. The JSON is now indexed and its `type`s linked.
  4490. fs.mkdirSync(path.join(tempDir, 'sections'), { recursive: true });
  4491. fs.mkdirSync(path.join(tempDir, 'templates/customers'), { recursive: true });
  4492. fs.writeFileSync(path.join(tempDir, 'sections/main-product.liquid'), `<div>{{ product.title }}</div>\n`);
  4493. fs.writeFileSync(path.join(tempDir, 'sections/main-login.liquid'), `<form>{{ 'customer.login' | t }}</form>\n`);
  4494. fs.writeFileSync(path.join(tempDir, 'templates/product.json'), JSON.stringify({ sections: { main: { type: 'main-product' } }, order: ['main'] }));
  4495. // Nested template dir (templates/customers/login.json) must resolve too.
  4496. fs.writeFileSync(path.join(tempDir, 'templates/customers/login.json'), JSON.stringify({ sections: { main: { type: 'main-login' } }, order: ['main'] }));
  4497. cg = CodeGraph.initSync(tempDir);
  4498. await cg.indexAll();
  4499. cg.resolveReferences();
  4500. const product = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('sections/main-product.liquid'));
  4501. const login = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('sections/main-login.liquid'));
  4502. expect(product, 'main-product section').toBeDefined();
  4503. expect(login, 'main-login section').toBeDefined();
  4504. expect(cg.getFileDependents(product!.filePath).some((p) => p.endsWith('templates/product.json')), 'top-level JSON template links its section').toBe(true);
  4505. expect(cg.getFileDependents(login!.filePath).some((p) => p.endsWith('customers/login.json')), 'nested JSON template links its section').toBe(true);
  4506. });
  4507. });
  4508. describe('Lua/Luau require resolution', () => {
  4509. let tempDir: string;
  4510. let cg: CodeGraph;
  4511. beforeEach(() => {
  4512. tempDir = createTempDir();
  4513. });
  4514. afterEach(() => {
  4515. if (cg) cg.close();
  4516. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4517. });
  4518. it('resolves a dotted Lua require and an instance-path Luau require to their module files', async () => {
  4519. // The require is the ONLY link (no method call), so coverage here proves the
  4520. // require resolver specifically, not method-call name-matching.
  4521. // Lua dotted module path: require("myapp.config") → lua/myapp/config.lua.
  4522. fs.mkdirSync(path.join(tempDir, 'lua/myapp'), { recursive: true });
  4523. fs.writeFileSync(path.join(tempDir, 'lua/myapp/config.lua'), `local M = {}\nfunction M.setup() end\nreturn M\n`);
  4524. fs.writeFileSync(path.join(tempDir, 'lua/myapp/init.lua'), `local config = require("myapp.config")\nreturn config\n`);
  4525. // Luau Roblox instance-path require (only the leaf survives extraction):
  4526. // require(script.Util.helper) → src/Util/helper.luau.
  4527. fs.mkdirSync(path.join(tempDir, 'src/Util'), { recursive: true });
  4528. fs.writeFileSync(path.join(tempDir, 'src/Util/helper.luau'), `local H = {}\nfunction H.go() end\nreturn H\n`);
  4529. fs.writeFileSync(path.join(tempDir, 'src/init.luau'), `local helper = require(script.Util.helper)\nreturn helper\n`);
  4530. cg = CodeGraph.initSync(tempDir);
  4531. await cg.indexAll();
  4532. cg.resolveReferences();
  4533. const config = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('myapp/config.lua'));
  4534. const helper = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('Util/helper.luau'));
  4535. expect(config, 'config.lua file node').toBeDefined();
  4536. expect(helper, 'helper.luau file node').toBeDefined();
  4537. const cfgDeps = cg.getFileDependents(config!.filePath);
  4538. const helpDeps = cg.getFileDependents(helper!.filePath);
  4539. expect(cfgDeps.some((p) => p.endsWith('myapp/init.lua')), 'dotted Lua require resolves to the module').toBe(true);
  4540. expect(helpDeps.some((p) => p.endsWith('src/init.luau')), 'instance-path Luau require resolves to the module').toBe(true);
  4541. });
  4542. });
  4543. describe('Rust module-path call resolution', () => {
  4544. let tempDir: string;
  4545. let cg: CodeGraph;
  4546. beforeEach(() => {
  4547. tempDir = createTempDir();
  4548. });
  4549. afterEach(() => {
  4550. if (cg) cg.close();
  4551. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4552. });
  4553. it('a bare submodule call (`users::router()`) resolves self-relative to the submodule fn', async () => {
  4554. // The canonical Axum router-assembly pattern: a parent module calls each
  4555. // submodule's `router()`. `users::` / `profiles::` are SELF-relative
  4556. // submodule prefixes (2018 edition) — `mod users;` makes `users` a child of
  4557. // the CURRENT module, NOT `crate::users`. Before the fix the bare prefix was
  4558. // resolved crate-relative only (looking for `src/users.rs`), so it found
  4559. // nothing and the handler modules looked dependent-less.
  4560. const http = path.join(tempDir, 'src/http');
  4561. fs.mkdirSync(http, { recursive: true });
  4562. fs.writeFileSync(path.join(tempDir, 'src/lib.rs'), `pub mod http;\n`);
  4563. fs.writeFileSync(
  4564. path.join(http, 'mod.rs'),
  4565. `mod users;\nmod profiles;\npub fn api_router() {\n users::router();\n profiles::router();\n}\n`
  4566. );
  4567. fs.writeFileSync(path.join(http, 'users.rs'), `pub fn router() -> i32 { 1 }\n`);
  4568. fs.writeFileSync(path.join(http, 'profiles.rs'), `pub fn router() -> i32 { 2 }\n`);
  4569. cg = CodeGraph.initSync(tempDir);
  4570. await cg.indexAll();
  4571. cg.resolveReferences();
  4572. // Each submodule's same-named `router` fn must get mod.rs as a dependent —
  4573. // proving the bare prefix resolved self-relative AND disambiguated the
  4574. // colliding `router` name to the correct file (not an arbitrary one).
  4575. const routers = cg.getNodesByKind('function').filter((n) => n.name === 'router');
  4576. const usersRouter = routers.find((n) => n.filePath.endsWith('http/users.rs'));
  4577. const profilesRouter = routers.find((n) => n.filePath.endsWith('http/profiles.rs'));
  4578. expect(usersRouter, 'users.rs router fn').toBeDefined();
  4579. expect(profilesRouter, 'profiles.rs router fn').toBeDefined();
  4580. const usersDeps = [...cg.getImpactRadius(usersRouter!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4581. const profilesDeps = [...cg.getImpactRadius(profilesRouter!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4582. expect(usersDeps.some((p) => p.endsWith('http/mod.rs')), 'users::router() lands on users.rs').toBe(true);
  4583. expect(profilesDeps.some((p) => p.endsWith('http/mod.rs')), 'profiles::router() lands on profiles.rs').toBe(true);
  4584. });
  4585. it('a 3-segment module-path call (`database::profiles::find()`) resolves to the leaf fn', async () => {
  4586. // A 2-level module path — the common `db.run(move |c| database::profiles::find(c))`
  4587. // / `crate::a::b::func()` shape. The reference-resolver pre-filter used to drop any
  4588. // `a::b::c` whose leaf it never checked (it tested only the first segment and the
  4589. // `b::c` remainder, neither of which names a symbol), so the call never reached the
  4590. // Rust path resolver and the leaf module looked dependent-less.
  4591. const routes = path.join(tempDir, 'src/routes');
  4592. const database = path.join(tempDir, 'src/database');
  4593. fs.mkdirSync(routes, { recursive: true });
  4594. fs.mkdirSync(database, { recursive: true });
  4595. fs.writeFileSync(path.join(tempDir, 'src/lib.rs'), `pub mod routes;\npub mod database;\n`);
  4596. fs.writeFileSync(path.join(database, 'mod.rs'), `pub mod profiles;\n`);
  4597. fs.writeFileSync(path.join(database, 'profiles.rs'), `pub fn find(id: i32) -> i32 { id }\n`);
  4598. fs.writeFileSync(
  4599. path.join(routes, 'mod.rs'),
  4600. `use crate::database;\npub fn get_profile(id: i32) -> i32 {\n database::profiles::find(id)\n}\n`
  4601. );
  4602. cg = CodeGraph.initSync(tempDir);
  4603. await cg.indexAll();
  4604. cg.resolveReferences();
  4605. const find = cg
  4606. .getNodesByKind('function')
  4607. .find((n) => n.name === 'find' && n.filePath.endsWith('database/profiles.rs'));
  4608. expect(find, 'database/profiles.rs find fn').toBeDefined();
  4609. const deps = [...cg.getImpactRadius(find!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4610. expect(deps.some((p) => p.endsWith('routes/mod.rs')), 'database::profiles::find() resolves to the leaf fn').toBe(true);
  4611. });
  4612. it('Rocket `routes![…]` / `catchers![…]` macros link the mount to the handler fns', async () => {
  4613. // Tree-sitter leaves the macro body as a raw token tree, so the handler
  4614. // paths inside `routes![a::b::handler, …]` are invisible to the call walker
  4615. // and the handlers — mounted by Rocket at runtime, not called in-repo — look
  4616. // like they have no caller. The route-macro extractor reconstructs each path
  4617. // and emits a reference, which the Rust path resolver links to the handler.
  4618. const routes = path.join(tempDir, 'src/routes');
  4619. fs.mkdirSync(routes, { recursive: true });
  4620. fs.writeFileSync(path.join(tempDir, 'src/lib.rs'),
  4621. `mod routes;\nfn not_found() {}\npub fn rocket() {\n` +
  4622. ` rocket::build()\n` +
  4623. ` .mount("/api", routes![routes::users::post_users, routes::users::get_user])\n` +
  4624. ` .register("/", catchers![not_found]);\n}\n`);
  4625. fs.writeFileSync(path.join(routes, 'mod.rs'), `pub mod users;\n`);
  4626. fs.writeFileSync(path.join(routes, 'users.rs'), `pub fn post_users() {}\npub fn get_user() {}\n`);
  4627. cg = CodeGraph.initSync(tempDir);
  4628. await cg.indexAll();
  4629. cg.resolveReferences();
  4630. const handlers = cg.getNodesByKind('function').filter((n) => n.filePath.endsWith('routes/users.rs'));
  4631. expect(handlers.length, 'both handler fns indexed').toBe(2);
  4632. for (const h of handlers) {
  4633. const deps = [...cg.getImpactRadius(h.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4634. expect(deps.some((p) => p.endsWith('lib.rs')), `routes![] links ${h.name} to its mount in lib.rs`).toBe(true);
  4635. }
  4636. });
  4637. });
  4638. describe('SvelteKit load → page synthesizer', () => {
  4639. let tempDir: string;
  4640. let cg: CodeGraph;
  4641. beforeEach(() => {
  4642. tempDir = createTempDir();
  4643. });
  4644. afterEach(() => {
  4645. if (cg) cg.close();
  4646. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4647. });
  4648. it('links a +page.svelte to its OWN directory\'s +page.server.js load, not another route\'s', async () => {
  4649. // SvelteKit wires +page.server.js's `load` to +page.svelte's `data` BY FILE
  4650. // PATH — there is no static import — so editing a loader showed no impact on
  4651. // the page it feeds. The synthesizer links each page component to the `load`
  4652. // in its OWN directory (path-deterministic, so it never crosses routes).
  4653. const login = path.join(tempDir, 'src/routes/login');
  4654. const register = path.join(tempDir, 'src/routes/register');
  4655. fs.mkdirSync(login, { recursive: true });
  4656. fs.mkdirSync(register, { recursive: true });
  4657. fs.writeFileSync(path.join(login, '+page.svelte'), `<script>export let data;</script>\n<h1>Login {data.x}</h1>\n`);
  4658. fs.writeFileSync(path.join(login, '+page.server.js'), `export function load() { return { x: 1 }; }\n`);
  4659. fs.writeFileSync(path.join(register, '+page.svelte'), `<script>export let data;</script>\n<h1>Register</h1>\n`);
  4660. fs.writeFileSync(path.join(register, '+page.server.js'), `export function load() { return { y: 2 }; }\n`);
  4661. cg = CodeGraph.initSync(tempDir);
  4662. await cg.indexAll();
  4663. cg.resolveReferences();
  4664. const loginLoad = cg
  4665. .getNodesByKind('function')
  4666. .find((n) => n.name === 'load' && n.filePath.endsWith('login/+page.server.js'));
  4667. expect(loginLoad, 'login load fn').toBeDefined();
  4668. const impacted = [...cg.getImpactRadius(loginLoad!.id, 3).nodes.values()].map((n) => n.filePath ?? '');
  4669. // editing login's load surfaces login's page (the framework-wired data flow)…
  4670. expect(impacted.some((p) => p.endsWith('login/+page.svelte')), 'load links to its own page').toBe(true);
  4671. // …but never register's page (same-directory only).
  4672. expect(impacted.some((p) => p.endsWith('register/+page.svelte')), 'does not cross routes').toBe(false);
  4673. });
  4674. });
  4675. describe('Nuxt nested auto-imported component resolution', () => {
  4676. let tempDir: string;
  4677. let cg: CodeGraph;
  4678. beforeEach(() => {
  4679. tempDir = createTempDir();
  4680. });
  4681. afterEach(() => {
  4682. if (cg) cg.close();
  4683. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4684. });
  4685. it('links a `<MediaCard/>` usage to components/media/Card.vue (Nuxt dir-prefixed auto-import)', async () => {
  4686. // Nuxt auto-imports a nested component by a DIRECTORY-PREFIXED name —
  4687. // components/media/Card.vue is used as <MediaCard/>, not <Card/> — but the
  4688. // component node is named by basename (`Card`), so the PascalCase usage
  4689. // didn't resolve and the nested component looked unused.
  4690. const media = path.join(tempDir, 'components/media');
  4691. fs.mkdirSync(media, { recursive: true });
  4692. fs.writeFileSync(path.join(media, 'Card.vue'), `<template><div>card</div></template>\n<script setup>defineProps(['item'])</script>\n`);
  4693. fs.writeFileSync(
  4694. path.join(tempDir, 'components/Grid.vue'),
  4695. `<template>\n <div><MediaCard :item="i" /></div>\n</template>\n<script setup>const i = {}</script>\n`
  4696. );
  4697. cg = CodeGraph.initSync(tempDir);
  4698. await cg.indexAll();
  4699. cg.resolveReferences();
  4700. const card = cg.getNodesByKind('component').find((n) => n.filePath.endsWith('media/Card.vue'));
  4701. expect(card, 'media/Card.vue component').toBeDefined();
  4702. const deps = [...cg.getImpactRadius(card!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4703. expect(deps.some((p) => p.endsWith('components/Grid.vue')), '<MediaCard> links Grid to media/Card.vue').toBe(true);
  4704. });
  4705. });
  4706. describe('Swift property-wrapper attribute type references', () => {
  4707. let tempDir: string;
  4708. let cg: CodeGraph;
  4709. beforeEach(() => {
  4710. tempDir = createTempDir();
  4711. });
  4712. afterEach(() => {
  4713. if (cg) cg.close();
  4714. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4715. });
  4716. it('a Fluent `@Siblings(through: Pivot.self)` links the model to the pivot type', async () => {
  4717. // A many-to-many pivot/join model is referenced ONLY through the relationship
  4718. // property wrapper's metatype argument (`Pivot.self`), never by a controller
  4719. // query. The wrapper type was captured but the argument expression wasn't
  4720. // walked, so the pivot model looked like nothing depended on it.
  4721. fs.writeFileSync(path.join(tempDir, 'Pivot.swift'),
  4722. `import Fluent\nfinal class AcronymCategoryPivot: Model {\n static let schema = "acronym-category"\n}\n`);
  4723. fs.writeFileSync(path.join(tempDir, 'Acronym.swift'),
  4724. `import Fluent\nfinal class Acronym: Model {\n` +
  4725. ` @Siblings(through: AcronymCategoryPivot.self, from: \\.$acronym, to: \\.$category)\n` +
  4726. ` var categories: [Category]\n}\n`);
  4727. cg = CodeGraph.initSync(tempDir);
  4728. await cg.indexAll();
  4729. cg.resolveReferences();
  4730. const pivot = cg.getNodesByKind('class').find((n) => n.name === 'AcronymCategoryPivot');
  4731. expect(pivot, 'pivot model class').toBeDefined();
  4732. const deps = [...cg.getImpactRadius(pivot!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4733. expect(deps.some((p) => p.endsWith('Acronym.swift')), '@Siblings metatype arg links Acronym to the pivot').toBe(true);
  4734. });
  4735. });
  4736. describe('Objective-C messages, class receivers, and #import', () => {
  4737. let tempDir: string;
  4738. let cg: CodeGraph;
  4739. beforeEach(() => {
  4740. tempDir = createTempDir();
  4741. });
  4742. afterEach(() => {
  4743. if (cg) cg.close();
  4744. if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  4745. });
  4746. it('resolves single-arg selectors, class-message receivers, and #import headers', async () => {
  4747. fs.writeFileSync(
  4748. path.join(tempDir, 'SDImageCache.h'),
  4749. `#import <Foundation/Foundation.h>
  4750. @interface SDImageCache : NSObject
  4751. + (instancetype)sharedCache;
  4752. + (void)storeImage:(NSString *)key;
  4753. @end
  4754. `
  4755. );
  4756. fs.writeFileSync(
  4757. path.join(tempDir, 'SDImageCache.m'),
  4758. `#import "SDImageCache.h"
  4759. @implementation SDImageCache
  4760. + (instancetype)sharedCache { return nil; }
  4761. + (void)storeImage:(NSString *)key { }
  4762. @end
  4763. `
  4764. );
  4765. fs.writeFileSync(
  4766. path.join(tempDir, 'SDManager.m'),
  4767. `#import "SDImageCache.h"
  4768. @interface SDManager : NSObject
  4769. @end
  4770. @implementation SDManager
  4771. - (void)run {
  4772. [SDImageCache sharedCache];
  4773. [SDImageCache storeImage:@"k"];
  4774. }
  4775. @end
  4776. `
  4777. );
  4778. cg = CodeGraph.initSync(tempDir);
  4779. await cg.indexAll();
  4780. cg.resolveReferences();
  4781. // 1. The single-argument selector `[SDImageCache storeImage:@"k"]` resolves
  4782. // to the `storeImage:` method — named WITH its colon both at the call site
  4783. // and the definition (before the fix the call site dropped the colon).
  4784. const storeImage = cg.getNodesByKind('method').find((n) => n.name === 'storeImage:');
  4785. expect(storeImage, 'storeImage: method').toBeDefined();
  4786. const storeCallers = [...cg.getImpactRadius(storeImage!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4787. expect(storeCallers.some((p) => p.endsWith('SDManager.m'))).toBe(true);
  4788. // 2. The class-message receiver `[SDImageCache sharedCache]` references the
  4789. // SDImageCache class (whose @interface lives in the header).
  4790. const cache = cg.getNodesByKind('class').find((n) => n.name === 'SDImageCache');
  4791. expect(cache, 'SDImageCache class').toBeDefined();
  4792. const classDeps = [...cg.getImpactRadius(cache!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4793. expect(classDeps.some((p) => p.endsWith('SDManager.m'))).toBe(true);
  4794. // 3. `#import "SDImageCache.h"` resolves to the header FILE — editing it
  4795. // surfaces both importers.
  4796. const header = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('SDImageCache.h'));
  4797. expect(header, 'SDImageCache.h indexed').toBeDefined();
  4798. const importers = [...cg.getImpactRadius(header!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
  4799. expect(importers.some((p) => p.endsWith('SDManager.m'))).toBe(true);
  4800. });
  4801. });
  4802. describe('Full Indexing', () => {
  4803. let tempDir: string;
  4804. beforeEach(() => {
  4805. tempDir = createTempDir();
  4806. });
  4807. afterEach(() => {
  4808. cleanupTempDir(tempDir);
  4809. });
  4810. it('should index a TypeScript file', async () => {
  4811. // Create test file
  4812. const srcDir = path.join(tempDir, 'src');
  4813. fs.mkdirSync(srcDir);
  4814. fs.writeFileSync(
  4815. path.join(srcDir, 'utils.ts'),
  4816. `
  4817. export function add(a: number, b: number): number {
  4818. return a + b;
  4819. }
  4820. export function multiply(a: number, b: number): number {
  4821. return a * b;
  4822. }
  4823. `
  4824. );
  4825. // Initialize and index
  4826. const cg = CodeGraph.initSync(tempDir);
  4827. const result = await cg.indexAll();
  4828. expect(result.success).toBe(true);
  4829. expect(result.filesIndexed).toBe(1);
  4830. expect(result.nodesCreated).toBeGreaterThanOrEqual(2);
  4831. // Check nodes were stored
  4832. const nodes = cg.getNodesInFile('src/utils.ts');
  4833. expect(nodes.length).toBeGreaterThanOrEqual(2);
  4834. const addFunc = nodes.find((n) => n.name === 'add');
  4835. expect(addFunc).toBeDefined();
  4836. expect(addFunc?.kind).toBe('function');
  4837. cg.close();
  4838. });
  4839. it('should index multiple files', async () => {
  4840. // Create test files
  4841. const srcDir = path.join(tempDir, 'src');
  4842. fs.mkdirSync(srcDir);
  4843. fs.writeFileSync(
  4844. path.join(srcDir, 'math.ts'),
  4845. `export function add(a: number, b: number) { return a + b; }`
  4846. );
  4847. fs.writeFileSync(
  4848. path.join(srcDir, 'string.ts'),
  4849. `export function capitalize(s: string) { return s.toUpperCase(); }`
  4850. );
  4851. // Initialize and index
  4852. const cg = CodeGraph.initSync(tempDir);
  4853. const result = await cg.indexAll();
  4854. expect(result.success).toBe(true);
  4855. expect(result.filesIndexed).toBe(2);
  4856. const files = cg.getFiles();
  4857. expect(files.length).toBe(2);
  4858. cg.close();
  4859. });
  4860. it('should track file hashes for incremental updates', async () => {
  4861. // Create initial file
  4862. const srcDir = path.join(tempDir, 'src');
  4863. fs.mkdirSync(srcDir);
  4864. fs.writeFileSync(path.join(srcDir, 'main.ts'), `export const x = 1;`);
  4865. // Initialize and index
  4866. const cg = CodeGraph.initSync(tempDir);
  4867. await cg.indexAll();
  4868. // Check file is tracked
  4869. const file = cg.getFile('src/main.ts');
  4870. expect(file).toBeDefined();
  4871. expect(file?.contentHash).toBeDefined();
  4872. // Modify file
  4873. fs.writeFileSync(path.join(srcDir, 'main.ts'), `export const x = 2;`);
  4874. // Check for changes
  4875. const changes = cg.getChangedFiles();
  4876. expect(changes.modified).toContain('src/main.ts');
  4877. cg.close();
  4878. });
  4879. it('should sync and detect changes', async () => {
  4880. // Create initial file
  4881. const srcDir = path.join(tempDir, 'src');
  4882. fs.mkdirSync(srcDir);
  4883. fs.writeFileSync(
  4884. path.join(srcDir, 'main.ts'),
  4885. `export function original() { return 1; }`
  4886. );
  4887. // Initialize and index
  4888. const cg = CodeGraph.initSync(tempDir);
  4889. await cg.indexAll();
  4890. const initialNodes = cg.getNodesInFile('src/main.ts');
  4891. expect(initialNodes.some((n) => n.name === 'original')).toBe(true);
  4892. // Modify file
  4893. fs.writeFileSync(
  4894. path.join(srcDir, 'main.ts'),
  4895. `export function updated() { return 2; }`
  4896. );
  4897. // Sync
  4898. const syncResult = await cg.sync();
  4899. expect(syncResult.filesModified).toBe(1);
  4900. // Check nodes were updated
  4901. const updatedNodes = cg.getNodesInFile('src/main.ts');
  4902. expect(updatedNodes.some((n) => n.name === 'updated')).toBe(true);
  4903. expect(updatedNodes.some((n) => n.name === 'original')).toBe(false);
  4904. cg.close();
  4905. });
  4906. it('should count file-level tracked YAML files as indexed', async () => {
  4907. fs.writeFileSync(path.join(tempDir, 'app.yaml'), 'name: test\n');
  4908. fs.writeFileSync(path.join(tempDir, 'routes.yml'), 'route: value\n');
  4909. const cg = CodeGraph.initSync(tempDir);
  4910. const result = await cg.indexAll();
  4911. expect(result.success).toBe(true);
  4912. expect(result.filesIndexed).toBe(2);
  4913. expect(result.filesSkipped).toBe(0);
  4914. expect(cg.getFiles().map((f) => f.path).sort()).toEqual(['app.yaml', 'routes.yml']);
  4915. cg.close();
  4916. });
  4917. it('should count file-level tracked YAML/Twig files as indexed in indexFiles()', async () => {
  4918. fs.writeFileSync(path.join(tempDir, 'app.yaml'), 'name: test\n');
  4919. fs.writeFileSync(path.join(tempDir, 'view.twig'), '{{ title }}\n');
  4920. const cg = CodeGraph.initSync(tempDir);
  4921. const result = await cg.indexFiles(['app.yaml', 'view.twig']);
  4922. expect(result.success).toBe(true);
  4923. expect(result.filesIndexed).toBe(2);
  4924. expect(result.filesSkipped).toBe(0);
  4925. const tracked = cg.getFiles().map((f) => `${f.path}:${f.language}`).sort();
  4926. expect(tracked).toEqual(['app.yaml:yaml', 'view.twig:twig']);
  4927. cg.close();
  4928. });
  4929. it('should count file-level tracked .properties files as indexed', async () => {
  4930. fs.writeFileSync(path.join(tempDir, 'application.properties'), 'server.port=8080\n');
  4931. fs.writeFileSync(path.join(tempDir, 'log.properties'), 'log.level=INFO\n');
  4932. const cg = CodeGraph.initSync(tempDir);
  4933. const result = await cg.indexAll();
  4934. expect(result.success).toBe(true);
  4935. expect(result.filesIndexed).toBe(2);
  4936. expect(result.filesSkipped).toBe(0);
  4937. cg.close();
  4938. });
  4939. it('should count the full file-level tracked class (yaml/twig/properties) in indexFiles()', async () => {
  4940. fs.writeFileSync(path.join(tempDir, 'app.yaml'), 'name: test\n');
  4941. fs.writeFileSync(path.join(tempDir, 'view.twig'), '{{ title }}\n');
  4942. fs.writeFileSync(path.join(tempDir, 'application.properties'), 'server.port=8080\n');
  4943. const cg = CodeGraph.initSync(tempDir);
  4944. const result = await cg.indexFiles(['app.yaml', 'view.twig', 'application.properties']);
  4945. expect(result.success).toBe(true);
  4946. expect(result.filesIndexed).toBe(3);
  4947. expect(result.filesSkipped).toBe(0);
  4948. const tracked = cg.getFiles().map((f) => `${f.path}:${f.language}`).sort();
  4949. expect(tracked).toEqual(['app.yaml:yaml', 'application.properties:properties', 'view.twig:twig']);
  4950. cg.close();
  4951. });
  4952. });
  4953. describe('Path Normalization', () => {
  4954. it('should convert backslashes to forward slashes', () => {
  4955. expect(normalizePath('gui\\node_modules\\foo')).toBe('gui/node_modules/foo');
  4956. expect(normalizePath('src\\components\\Button.tsx')).toBe('src/components/Button.tsx');
  4957. });
  4958. it('should leave forward-slash paths unchanged', () => {
  4959. expect(normalizePath('src/components/Button.tsx')).toBe('src/components/Button.tsx');
  4960. });
  4961. it('should handle empty string', () => {
  4962. expect(normalizePath('')).toBe('');
  4963. });
  4964. });
  4965. describe('Directory Exclusion', () => {
  4966. let tempDir: string;
  4967. beforeEach(() => {
  4968. tempDir = createTempDir();
  4969. });
  4970. afterEach(() => {
  4971. cleanupTempDir(tempDir);
  4972. });
  4973. it('should exclude directories listed in .gitignore', () => {
  4974. // Create structure: src/index.ts + node_modules/pkg/index.js, gitignore node_modules
  4975. const srcDir = path.join(tempDir, 'src');
  4976. const nmDir = path.join(tempDir, 'node_modules', 'pkg');
  4977. fs.mkdirSync(srcDir, { recursive: true });
  4978. fs.mkdirSync(nmDir, { recursive: true });
  4979. fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;');
  4980. fs.writeFileSync(path.join(nmDir, 'index.js'), 'module.exports = {};');
  4981. fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules/\n');
  4982. const files = scanDirectory(tempDir);
  4983. expect(files).toContain('src/index.ts');
  4984. expect(files.every((f) => !f.includes('node_modules'))).toBe(true);
  4985. });
  4986. it('should exclude nested node_modules via a root .gitignore', () => {
  4987. // A trailing-slash pattern with no leading slash matches at any depth.
  4988. const srcDir = path.join(tempDir, 'packages', 'app', 'src');
  4989. const nmDir = path.join(tempDir, 'packages', 'app', 'node_modules', 'pkg');
  4990. fs.mkdirSync(srcDir, { recursive: true });
  4991. fs.mkdirSync(nmDir, { recursive: true });
  4992. fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;');
  4993. fs.writeFileSync(path.join(nmDir, 'index.js'), 'module.exports = {};');
  4994. fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules/\n');
  4995. const files = scanDirectory(tempDir);
  4996. expect(files).toContain('packages/app/src/index.ts');
  4997. expect(files.every((f) => !f.includes('node_modules'))).toBe(true);
  4998. });
  4999. it('should apply a nested .gitignore only to its own subtree', () => {
  5000. const appSrc = path.join(tempDir, 'app', 'src');
  5001. fs.mkdirSync(appSrc, { recursive: true });
  5002. fs.writeFileSync(path.join(appSrc, 'keep.ts'), 'export const a = 1;');
  5003. fs.writeFileSync(path.join(appSrc, 'skip.ts'), 'export const b = 2;');
  5004. fs.writeFileSync(path.join(tempDir, 'app', '.gitignore'), 'src/skip.ts\n');
  5005. // A sibling with the same name outside app/ must NOT be ignored.
  5006. const otherDir = path.join(tempDir, 'other', 'src');
  5007. fs.mkdirSync(otherDir, { recursive: true });
  5008. fs.writeFileSync(path.join(otherDir, 'skip.ts'), 'export const c = 3;');
  5009. const files = scanDirectory(tempDir);
  5010. expect(files).toContain('app/src/keep.ts');
  5011. expect(files).not.toContain('app/src/skip.ts');
  5012. expect(files).toContain('other/src/skip.ts');
  5013. });
  5014. it('should always skip .git directories', () => {
  5015. const srcDir = path.join(tempDir, 'src');
  5016. const gitDir = path.join(tempDir, '.git', 'objects');
  5017. fs.mkdirSync(srcDir, { recursive: true });
  5018. fs.mkdirSync(gitDir, { recursive: true });
  5019. fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;');
  5020. fs.writeFileSync(path.join(gitDir, 'pack.ts'), 'export const y = 2;');
  5021. const files = scanDirectory(tempDir);
  5022. expect(files).toContain('src/index.ts');
  5023. expect(files.every((f) => !f.includes('.git'))).toBe(true);
  5024. });
  5025. it('should return forward-slash paths on all platforms', () => {
  5026. const srcDir = path.join(tempDir, 'src', 'components');
  5027. fs.mkdirSync(srcDir, { recursive: true });
  5028. fs.writeFileSync(path.join(srcDir, 'Button.tsx'), 'export function Button() {}');
  5029. const files = scanDirectory(tempDir);
  5030. expect(files.length).toBe(1);
  5031. expect(files[0]).toBe('src/components/Button.tsx');
  5032. expect(files[0]).not.toContain('\\');
  5033. });
  5034. });
  5035. describe('Git Submodules', () => {
  5036. let tempDir: string;
  5037. beforeEach(() => {
  5038. tempDir = createTempDir();
  5039. });
  5040. afterEach(() => {
  5041. cleanupTempDir(tempDir);
  5042. });
  5043. it('should index files inside git submodules (issue #147)', async () => {
  5044. const { execFileSync } = await import('child_process');
  5045. const git = (cwd: string, ...args: string[]) =>
  5046. execFileSync('git', args, { cwd, stdio: 'pipe' });
  5047. // Build a separate "library" repo to use as a submodule source.
  5048. const libDir = path.join(tempDir, '_lib');
  5049. fs.mkdirSync(libDir, { recursive: true });
  5050. git(libDir, 'init', '-q');
  5051. git(libDir, 'config', 'user.email', 'test@test.com');
  5052. git(libDir, 'config', 'user.name', 'Test');
  5053. fs.writeFileSync(path.join(libDir, 'lib.ts'), 'export const fromSubmodule = 1;');
  5054. git(libDir, 'add', '-A');
  5055. git(libDir, 'commit', '-q', '-m', 'lib init');
  5056. // Build the main repo and add the lib repo as a submodule.
  5057. const mainDir = path.join(tempDir, 'main');
  5058. fs.mkdirSync(mainDir, { recursive: true });
  5059. git(mainDir, 'init', '-q');
  5060. git(mainDir, 'config', 'user.email', 'test@test.com');
  5061. git(mainDir, 'config', 'user.name', 'Test');
  5062. fs.writeFileSync(path.join(mainDir, 'app.ts'), 'export const app = 1;');
  5063. git(mainDir, 'add', '-A');
  5064. git(mainDir, 'commit', '-q', '-m', 'app init');
  5065. // protocol.file.allow=always is required to add a local-path submodule on
  5066. // recent git versions (CVE-2022-39253 mitigation).
  5067. execFileSync(
  5068. 'git',
  5069. ['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', libDir, 'libs/lib'],
  5070. { cwd: mainDir, stdio: 'pipe' }
  5071. );
  5072. git(mainDir, 'commit', '-q', '-m', 'add submodule');
  5073. const files = scanDirectory(mainDir);
  5074. expect(files).toContain('app.ts');
  5075. expect(files).toContain('libs/lib/lib.ts');
  5076. });
  5077. });
  5078. describe('Nested gitlink repos (#1031, #1033)', () => {
  5079. let tempDir: string;
  5080. // Helper: make a self-contained git repo at `dir` with one committed TS file.
  5081. const makeRepo = async (dir: string, base: string) => {
  5082. const { execFileSync } = await import('child_process');
  5083. const git = (...args: string[]) => execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
  5084. fs.mkdirSync(dir, { recursive: true });
  5085. git('init', '-q');
  5086. git('config', 'user.email', 'test@test.com');
  5087. git('config', 'user.name', 'Test');
  5088. fs.writeFileSync(path.join(dir, `${base}.ts`), `export const ${base} = 1;`);
  5089. git('add', '-A');
  5090. git('commit', '-q', '-m', `${base} init`);
  5091. };
  5092. beforeEach(() => {
  5093. tempDir = createTempDir();
  5094. });
  5095. afterEach(() => {
  5096. cleanupTempDir(tempDir);
  5097. });
  5098. // The #1031 case: a nested repo `git add`ed inside the super-repo becomes a
  5099. // gitlink (mode 160000) with NO `.gitmodules`. It is tracked (so it never shows
  5100. // in the untracked `-o` listing) yet not an active submodule (so
  5101. // `--recurse-submodules` won't expand it) — it used to fall through both passes
  5102. // and only the super-repo's own files got indexed.
  5103. it('indexes a bare gitlink (git add\'ed embedded repo, no .gitmodules), recursively', async () => {
  5104. const { execFileSync } = await import('child_process');
  5105. const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' });
  5106. const root = path.join(tempDir, 'root');
  5107. await makeRepo(root, 'app');
  5108. // An embedded clone, itself holding a further nested clone (untracked inside it).
  5109. await makeRepo(path.join(root, 'embedded'), 'inner');
  5110. await makeRepo(path.join(root, 'embedded', 'deep'), 'deep');
  5111. // `git add embedded` records it as a 160000 gitlink (no fetch, no .gitmodules).
  5112. git(root, 'add', 'embedded');
  5113. git(root, 'commit', '-q', '-m', 'add embedded as gitlink');
  5114. expect(fs.existsSync(path.join(root, '.gitmodules'))).toBe(false);
  5115. const files = scanDirectory(root);
  5116. expect(files).toContain('app.ts');
  5117. expect(files).toContain('embedded/inner.ts'); // the gitlink's own source
  5118. expect(files).toContain('embedded/deep/deep.ts'); // recursion continues into its nested repo
  5119. });
  5120. // The -c → -s switch must not regress active submodules (#147): a repo can hold
  5121. // BOTH an active submodule (expanded by --recurse-submodules) and a bare gitlink
  5122. // (handled by the new pass), and the mixed 160000/100644 modes must parse right.
  5123. it('indexes a gitlink alongside an active submodule', async () => {
  5124. const { execFileSync } = await import('child_process');
  5125. const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' });
  5126. const lib = path.join(tempDir, '_lib');
  5127. await makeRepo(lib, 'lib');
  5128. const root = path.join(tempDir, 'root');
  5129. await makeRepo(root, 'app');
  5130. // A proper, active submodule.
  5131. execFileSync('git', ['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', lib, 'libs/lib'], { cwd: root, stdio: 'pipe' });
  5132. git(root, 'commit', '-q', '-m', 'add submodule');
  5133. // A bare gitlink in the same repo (under a non-ignored dir name).
  5134. await makeRepo(path.join(root, 'external', 'tool'), 'tool');
  5135. git(root, 'add', 'external/tool');
  5136. git(root, 'commit', '-q', '-m', 'add gitlink');
  5137. const files = scanDirectory(root);
  5138. expect(files).toContain('app.ts');
  5139. expect(files).toContain('libs/lib/lib.ts'); // active submodule still expands (#147)
  5140. expect(files).toContain('external/tool/tool.ts'); // bare gitlink now indexed
  5141. });
  5142. // A gitlink under a built-in default-ignored directory (vendor/, node_modules/,
  5143. // …) stays excluded — a committed dependency doesn't become project code just
  5144. // because it's a nested repo. Mirrors how the untracked-embedded path treats
  5145. // the same dirs (#407), so the two passes agree.
  5146. it('does not index a gitlink under a default-ignored directory (e.g. vendor/)', async () => {
  5147. const { execFileSync } = await import('child_process');
  5148. const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' });
  5149. const root = path.join(tempDir, 'root');
  5150. await makeRepo(root, 'app');
  5151. await makeRepo(path.join(root, 'vendor', 'pkg'), 'dep');
  5152. git(root, 'add', 'vendor/pkg');
  5153. git(root, 'commit', '-q', '-m', 'add vendored gitlink');
  5154. const files = scanDirectory(root);
  5155. expect(files).toContain('app.ts');
  5156. expect(files).not.toContain('vendor/pkg/dep.ts');
  5157. });
  5158. // A gitlink with NO working tree on disk (the common "cloned without
  5159. // --recurse-submodules" state) has nothing to index — we must leave it alone,
  5160. // not fabricate entries, and must not break the rest of the scan.
  5161. it('leaves an uninitialized submodule (no checkout on disk) alone', async () => {
  5162. const { execFileSync } = await import('child_process');
  5163. const lib = path.join(tempDir, '_lib');
  5164. await makeRepo(lib, 'lib');
  5165. const sup = path.join(tempDir, 'super');
  5166. await makeRepo(sup, 'app');
  5167. execFileSync('git', ['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', lib, 'libs/lib'], { cwd: sup, stdio: 'pipe' });
  5168. execFileSync('git', ['commit', '-q', '-m', 'add submodule'], { cwd: sup, stdio: 'pipe' });
  5169. // Clone the super-repo WITHOUT --recurse-submodules → libs/lib is an empty
  5170. // gitlink dir (mode 160000, no `.git` inside, no files).
  5171. const clone = path.join(tempDir, 'clone');
  5172. execFileSync('git', ['clone', '-q', sup, clone], { stdio: 'pipe' });
  5173. expect(fs.readdirSync(path.join(clone, 'libs', 'lib'))).toHaveLength(0);
  5174. const files = scanDirectory(clone);
  5175. expect(files).toContain('app.ts');
  5176. expect(files).not.toContain('libs/lib/lib.ts'); // not on disk → correctly absent
  5177. });
  5178. // #1065: a gitlink under a path the super-repo's OWN `.gitignore` covers is the
  5179. // tracked-gitlink twin of the untracked-ignored embedded repo (#514, #970). The
  5180. // gitlink-discovery pass must honor that `.gitignore` the same way — otherwise a
  5181. // gitignored reference/benchmark corpus full of `git add`ed clones gets pulled
  5182. // into the index (the 138k-file blow-up the reporter hit). Respect it by default;
  5183. // re-include only via `codegraph.json` `includeIgnored`.
  5184. it('does not index a gitlink under a gitignored directory by default (#1065)', async () => {
  5185. const { execFileSync } = await import('child_process');
  5186. const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' });
  5187. const root = path.join(tempDir, 'root');
  5188. await makeRepo(root, 'app');
  5189. // An embedded clone under a path the super-repo gitignores (a benchmark corpus).
  5190. await makeRepo(path.join(root, 'benchmark', 'repos', 'ref'), 'ref');
  5191. git(root, 'add', 'benchmark/repos/ref'); // tracked as a 160000 gitlink
  5192. fs.writeFileSync(path.join(root, '.gitignore'), 'benchmark/repos/\n');
  5193. git(root, 'add', '.gitignore');
  5194. git(root, 'commit', '-q', '-m', 'add gitignored gitlink + ignore rule');
  5195. const files = scanDirectory(root);
  5196. expect(files).toContain('app.ts');
  5197. expect(files).not.toContain('benchmark/repos/ref/ref.ts'); // gitignored → excluded
  5198. // The watcher path agrees: the ignored root is never discovered, and the dir is
  5199. // pruned (the reporter's exact clue — `ignores('benchmark/repos/')` was false).
  5200. expect(discoverEmbeddedRepoRoots(root)).toEqual([]);
  5201. expect(buildScopeIgnore(root).ignores('benchmark/repos/')).toBe(true);
  5202. expect(buildScopeIgnore(root).ignores('benchmark/repos/ref/ref.ts')).toBe(true);
  5203. });
  5204. it('re-includes a gitignored gitlink when codegraph.json includeIgnored opts in (#1065)', async () => {
  5205. const { execFileSync } = await import('child_process');
  5206. const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe' });
  5207. const root = path.join(tempDir, 'root');
  5208. await makeRepo(root, 'app');
  5209. await makeRepo(path.join(root, 'benchmark', 'repos', 'ref'), 'ref');
  5210. git(root, 'add', 'benchmark/repos/ref');
  5211. fs.writeFileSync(path.join(root, '.gitignore'), 'benchmark/repos/\n');
  5212. fs.writeFileSync(path.join(root, 'codegraph.json'), JSON.stringify({ includeIgnored: ['benchmark/repos/'] }));
  5213. git(root, 'add', '.gitignore', 'codegraph.json');
  5214. git(root, 'commit', '-q', '-m', 'opt the gitignored gitlink back in');
  5215. const files = scanDirectory(root);
  5216. expect(files).toContain('app.ts');
  5217. expect(files).toContain('benchmark/repos/ref/ref.ts'); // opted in → indexed
  5218. expect(discoverEmbeddedRepoRoots(root)).toContain('benchmark/repos/ref/');
  5219. });
  5220. });
  5221. describe('Nested non-submodule git repos', () => {
  5222. let tempDir: string;
  5223. beforeEach(() => {
  5224. tempDir = createTempDir();
  5225. });
  5226. afterEach(() => {
  5227. cleanupTempDir(tempDir);
  5228. });
  5229. it('should index files in embedded git repos run from a git super-repo (issue #193)', async () => {
  5230. const { execFileSync } = await import('child_process');
  5231. const git = (cwd: string, ...args: string[]) =>
  5232. execFileSync('git', args, { cwd, stdio: 'pipe' });
  5233. // Top-level workspace is itself a git repo, holding no source directly —
  5234. // the CMake "super-repo" layout from the issue.
  5235. const root = path.join(tempDir, 'root');
  5236. fs.mkdirSync(path.join(root, 'coding'), { recursive: true });
  5237. git(root, 'init', '-q');
  5238. git(root, 'config', 'user.email', 'test@test.com');
  5239. git(root, 'config', 'user.name', 'Test');
  5240. fs.writeFileSync(path.join(root, 'CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.10)\n');
  5241. // Two independent clones living inside the workspace (NOT submodules):
  5242. // one with committed source, one with only untracked source.
  5243. const sub1 = path.join(root, 'sub_repo1', 'src');
  5244. fs.mkdirSync(sub1, { recursive: true });
  5245. git(path.join(root, 'sub_repo1'), 'init', '-q');
  5246. git(path.join(root, 'sub_repo1'), 'config', 'user.email', 'test@test.com');
  5247. git(path.join(root, 'sub_repo1'), 'config', 'user.name', 'Test');
  5248. fs.writeFileSync(path.join(sub1, 'one.ts'), 'export const one = 1;');
  5249. git(path.join(root, 'sub_repo1'), 'add', '-A');
  5250. git(path.join(root, 'sub_repo1'), 'commit', '-q', '-m', 'sub1 init');
  5251. const sub2 = path.join(root, 'sub_repo2', 'src');
  5252. fs.mkdirSync(sub2, { recursive: true });
  5253. git(path.join(root, 'sub_repo2'), 'init', '-q');
  5254. fs.writeFileSync(path.join(sub2, 'two.ts'), 'export const two = 2;');
  5255. const files = scanDirectory(root);
  5256. // Both committed and untracked source from the nested repos must be found.
  5257. expect(files).toContain('sub_repo1/src/one.ts');
  5258. expect(files).toContain('sub_repo2/src/two.ts');
  5259. });
  5260. it('should respect each embedded repo\'s own .gitignore', async () => {
  5261. const { execFileSync } = await import('child_process');
  5262. const git = (cwd: string, ...args: string[]) =>
  5263. execFileSync('git', args, { cwd, stdio: 'pipe' });
  5264. const root = path.join(tempDir, 'root');
  5265. fs.mkdirSync(root, { recursive: true });
  5266. git(root, 'init', '-q');
  5267. const sub = path.join(root, 'sub_repo', 'src');
  5268. fs.mkdirSync(sub, { recursive: true });
  5269. git(path.join(root, 'sub_repo'), 'init', '-q');
  5270. fs.writeFileSync(path.join(root, 'sub_repo', '.gitignore'), 'src/generated.ts\n');
  5271. fs.writeFileSync(path.join(sub, 'real.ts'), 'export const real = 1;');
  5272. fs.writeFileSync(path.join(sub, 'generated.ts'), 'export const generated = 1;');
  5273. const files = scanDirectory(root);
  5274. expect(files).toContain('sub_repo/src/real.ts');
  5275. expect(files).not.toContain('sub_repo/src/generated.ts');
  5276. });
  5277. // A .gitignore the `ignore` library can't compile to a regex must not abort
  5278. // the whole scan — the bad pattern is dropped, valid ones still apply (#682).
  5279. it('does not crash on a .gitignore with an uncompilable pattern (#682)', () => {
  5280. fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
  5281. fs.mkdirSync(path.join(tempDir, 'build'), { recursive: true });
  5282. fs.writeFileSync(path.join(tempDir, 'src', 'real.ts'), 'export const x = 1;');
  5283. fs.writeFileSync(path.join(tempDir, 'build', 'out.ts'), 'export const y = 2;');
  5284. // `\\[` makes the matcher build an unterminated character class — the throw
  5285. // is lazy (at match time), which is what escaped and killed sync.
  5286. fs.writeFileSync(path.join(tempDir, '.gitignore'), 'build/\n\\\\[\n');
  5287. let files: string[] = [];
  5288. expect(() => {
  5289. files = scanDirectory(tempDir);
  5290. }).not.toThrow();
  5291. expect(files).toContain('src/real.ts');
  5292. // The still-valid `build/` rule is honored; only the bad line was dropped.
  5293. expect(files.some((f) => f.startsWith('build/'))).toBe(false);
  5294. });
  5295. // A .gitignore that isn't valid UTF-8 — e.g. encrypted in place by corporate
  5296. // DLP / endpoint software (UTF-16 header + ciphertext) — is skipped whole,
  5297. // not fed to the matcher as garbage patterns (#682).
  5298. it('does not crash on a non-UTF-8 (DLP-encrypted) .gitignore (#682)', () => {
  5299. fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
  5300. fs.writeFileSync(path.join(tempDir, 'src', 'real.ts'), 'export const x = 1;');
  5301. const header = Buffer.concat([
  5302. Buffer.from([0x00, 0x00]),
  5303. Buffer.from('[notice][user]', 'utf16le'),
  5304. ]);
  5305. const junk = Buffer.from([0x5b, 0x99, 0xc3, 0x28, 0x5c, 0x5b, 0xff, 0xfd]);
  5306. fs.writeFileSync(path.join(tempDir, '.gitignore'), Buffer.concat([header, junk]));
  5307. let files: string[] = [];
  5308. expect(() => {
  5309. files = scanDirectory(tempDir);
  5310. }).not.toThrow();
  5311. expect(files).toContain('src/real.ts');
  5312. });
  5313. it('buildDefaultIgnore survives a bad .gitignore and still applies valid rules (#682)', () => {
  5314. fs.writeFileSync(path.join(tempDir, '.gitignore'), 'dist/\n\\\\[\n');
  5315. const ig = buildDefaultIgnore(tempDir);
  5316. expect(() => ig.ignores('src/app.ts')).not.toThrow();
  5317. expect(ig.ignores('dist/')).toBe(true); // valid rule survives
  5318. expect(ig.ignores('src/app.ts')).toBe(false);
  5319. });
  5320. });
  5321. // =============================================================================
  5322. // Scala
  5323. // =============================================================================
  5324. describe('Scala Extraction', () => {
  5325. describe('Language detection', () => {
  5326. it('should detect Scala files', () => {
  5327. expect(detectLanguage('Main.scala')).toBe('scala');
  5328. expect(detectLanguage('script.sc')).toBe('scala');
  5329. expect(detectLanguage('src/UserService.scala')).toBe('scala');
  5330. });
  5331. it('should report Scala as supported', () => {
  5332. expect(isLanguageSupported('scala')).toBe(true);
  5333. expect(getSupportedLanguages()).toContain('scala');
  5334. });
  5335. });
  5336. describe('Class extraction', () => {
  5337. it('should extract class definitions', () => {
  5338. const code = `
  5339. class UserService(private val repo: UserRepository) {
  5340. def findUser(id: String): Option[String] = Some(id)
  5341. }
  5342. `;
  5343. const result = extractFromSource('UserService.scala', code);
  5344. const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'UserService');
  5345. expect(cls).toBeDefined();
  5346. expect(cls?.language).toBe('scala');
  5347. });
  5348. it('should extract object definitions as class kind', () => {
  5349. const code = `
  5350. object DatabaseConfig {
  5351. val url = "jdbc:postgresql://localhost/mydb"
  5352. }
  5353. `;
  5354. const result = extractFromSource('Config.scala', code);
  5355. const obj = result.nodes.find((n) => n.kind === 'class' && n.name === 'DatabaseConfig');
  5356. expect(obj).toBeDefined();
  5357. });
  5358. it('should extract trait definitions as trait kind', () => {
  5359. const code = `
  5360. trait Repository[A] {
  5361. def findById(id: String): Option[A]
  5362. def save(entity: A): Unit
  5363. }
  5364. `;
  5365. const result = extractFromSource('Repository.scala', code);
  5366. const trait_ = result.nodes.find((n) => n.kind === 'trait' && n.name === 'Repository');
  5367. expect(trait_).toBeDefined();
  5368. });
  5369. });
  5370. describe('Method and function extraction', () => {
  5371. it('should extract method definitions inside a class', () => {
  5372. const code = `
  5373. class Calculator {
  5374. def add(a: Int, b: Int): Int = a + b
  5375. def divide(a: Double, b: Double): Double = a / b
  5376. }
  5377. `;
  5378. const result = extractFromSource('Calculator.scala', code);
  5379. const methods = result.nodes.filter((n) => n.kind === 'method');
  5380. expect(methods.find((m) => m.name === 'add')).toBeDefined();
  5381. expect(methods.find((m) => m.name === 'divide')).toBeDefined();
  5382. });
  5383. it('should extract method signatures', () => {
  5384. const code = `
  5385. class Greeter {
  5386. def greet(name: String): String = s"Hello, \${name}!"
  5387. }
  5388. `;
  5389. const result = extractFromSource('Greeter.scala', code);
  5390. const method = result.nodes.find((n) => n.name === 'greet');
  5391. expect(method?.signature).toContain('name: String');
  5392. expect(method?.signature).toContain('String');
  5393. });
  5394. it('should extract top-level function definitions as functions', () => {
  5395. const code = `
  5396. def factorial(n: Int): Int = if (n <= 1) 1 else n * factorial(n - 1)
  5397. def greet(name: String): String = s"Hello, \${name}!"
  5398. `;
  5399. const result = extractFromSource('utils.scala', code);
  5400. const fns = result.nodes.filter((n) => n.kind === 'function');
  5401. expect(fns.find((f) => f.name === 'factorial')).toBeDefined();
  5402. expect(fns.find((f) => f.name === 'greet')).toBeDefined();
  5403. });
  5404. });
  5405. describe('Val and var extraction', () => {
  5406. it('should extract val inside a class as field', () => {
  5407. const code = `
  5408. class Config {
  5409. val timeout: Int = 30
  5410. val host: String = "localhost"
  5411. }
  5412. `;
  5413. const result = extractFromSource('Config.scala', code);
  5414. const fields = result.nodes.filter((n) => n.kind === 'field');
  5415. expect(fields.find((f) => f.name === 'timeout')).toBeDefined();
  5416. expect(fields.find((f) => f.name === 'host')).toBeDefined();
  5417. });
  5418. it('should extract var inside a class as field', () => {
  5419. const code = `
  5420. class Counter {
  5421. var count: Int = 0
  5422. }
  5423. `;
  5424. const result = extractFromSource('Counter.scala', code);
  5425. const field = result.nodes.find((n) => n.kind === 'field' && n.name === 'count');
  5426. expect(field).toBeDefined();
  5427. });
  5428. it('should extract top-level val as constant', () => {
  5429. const code = `
  5430. val MaxConnections: Int = 100
  5431. val DefaultTimeout = 30
  5432. `;
  5433. const result = extractFromSource('constants.scala', code);
  5434. const consts = result.nodes.filter((n) => n.kind === 'constant');
  5435. expect(consts.find((c) => c.name === 'MaxConnections')).toBeDefined();
  5436. });
  5437. it('should extract top-level var as variable', () => {
  5438. const code = `
  5439. var retries: Int = 3
  5440. `;
  5441. const result = extractFromSource('state.scala', code);
  5442. const v = result.nodes.find((n) => n.kind === 'variable' && n.name === 'retries');
  5443. expect(v).toBeDefined();
  5444. });
  5445. it('should include type in val/var signature', () => {
  5446. const code = `
  5447. class Service {
  5448. val timeout: Int = 30
  5449. }
  5450. `;
  5451. const result = extractFromSource('Service.scala', code);
  5452. const field = result.nodes.find((n) => n.name === 'timeout');
  5453. expect(field?.signature).toContain('timeout');
  5454. expect(field?.signature).toContain('Int');
  5455. });
  5456. });
  5457. describe('Enum extraction', () => {
  5458. it('should extract enum definitions', () => {
  5459. const code = `
  5460. enum Color:
  5461. case Red
  5462. case Green
  5463. case Blue
  5464. `;
  5465. const result = extractFromSource('Color.scala', code);
  5466. const enumNode = result.nodes.find((n) => n.kind === 'enum' && n.name === 'Color');
  5467. expect(enumNode).toBeDefined();
  5468. });
  5469. it('should extract enum cases as enum_member', () => {
  5470. const code = `
  5471. enum Direction:
  5472. case North
  5473. case South
  5474. case East
  5475. case West
  5476. `;
  5477. const result = extractFromSource('Direction.scala', code);
  5478. const members = result.nodes.filter((n) => n.kind === 'enum_member');
  5479. expect(members.find((m) => m.name === 'North')).toBeDefined();
  5480. expect(members.find((m) => m.name === 'South')).toBeDefined();
  5481. expect(members.length).toBeGreaterThanOrEqual(4);
  5482. });
  5483. });
  5484. describe('Type alias extraction', () => {
  5485. it('should extract type aliases', () => {
  5486. const code = `
  5487. type UserId = String
  5488. type UserMap = Map[String, String]
  5489. `;
  5490. const result = extractFromSource('types.scala', code);
  5491. const aliases = result.nodes.filter((n) => n.kind === 'type_alias');
  5492. expect(aliases.find((a) => a.name === 'UserId')).toBeDefined();
  5493. expect(aliases.find((a) => a.name === 'UserMap')).toBeDefined();
  5494. });
  5495. });
  5496. describe('Import extraction', () => {
  5497. it('should extract import declarations', () => {
  5498. const code = `
  5499. import scala.collection.mutable.ListBuffer
  5500. import scala.concurrent.Future
  5501. `;
  5502. const result = extractFromSource('imports.scala', code);
  5503. const imports = result.nodes.filter((n) => n.kind === 'import');
  5504. expect(imports.length).toBeGreaterThanOrEqual(2);
  5505. });
  5506. });
  5507. describe('Visibility modifiers', () => {
  5508. it('should extract private visibility', () => {
  5509. const code = `
  5510. class Service {
  5511. private val secret: String = "abc"
  5512. private def helper(): Unit = {}
  5513. }
  5514. `;
  5515. const result = extractFromSource('Service.scala', code);
  5516. const secretField = result.nodes.find((n) => n.name === 'secret');
  5517. expect(secretField?.visibility).toBe('private');
  5518. const helperMethod = result.nodes.find((n) => n.name === 'helper');
  5519. expect(helperMethod?.visibility).toBe('private');
  5520. });
  5521. it('should extract protected visibility', () => {
  5522. const code = `
  5523. class Base {
  5524. protected def helperMethod(): Unit = {}
  5525. }
  5526. `;
  5527. const result = extractFromSource('Base.scala', code);
  5528. const method = result.nodes.find((n) => n.name === 'helperMethod');
  5529. expect(method?.visibility).toBe('protected');
  5530. });
  5531. it('should default to public visibility', () => {
  5532. const code = `
  5533. class Greeter {
  5534. def hello(): Unit = {}
  5535. }
  5536. `;
  5537. const result = extractFromSource('Greeter.scala', code);
  5538. const method = result.nodes.find((n) => n.name === 'hello');
  5539. expect(method?.visibility).toBe('public');
  5540. });
  5541. });
  5542. describe('Inheritance', () => {
  5543. it('should extract extends relationships', () => {
  5544. const code = `
  5545. class AdminUser extends User {
  5546. def adminAction(): Unit = {}
  5547. }
  5548. `;
  5549. const result = extractFromSource('AdminUser.scala', code);
  5550. const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
  5551. expect(extendsRefs.find((r) => r.referenceName === 'User')).toBeDefined();
  5552. });
  5553. });
  5554. describe('Call extraction', () => {
  5555. it('should extract function call expressions', () => {
  5556. const code = `
  5557. def processData(): Unit = {
  5558. val result = computeResult()
  5559. println(result)
  5560. }
  5561. `;
  5562. const result = extractFromSource('processor.scala', code);
  5563. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
  5564. expect(calls.length).toBeGreaterThan(0);
  5565. });
  5566. });
  5567. });
  5568. describe('Vue Extraction', () => {
  5569. it('should detect Vue files', () => {
  5570. expect(detectLanguage('App.vue')).toBe('vue');
  5571. expect(detectLanguage('components/Button.vue')).toBe('vue');
  5572. expect(isLanguageSupported('vue')).toBe(true);
  5573. });
  5574. it('should extract component node from a Vue SFC', () => {
  5575. const code = `<template>
  5576. <div>{{ message }}</div>
  5577. </template>
  5578. <script>
  5579. export default {
  5580. data() {
  5581. return { message: 'Hello' };
  5582. }
  5583. }
  5584. </script>
  5585. `;
  5586. const result = extractFromSource('HelloWorld.vue', code);
  5587. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5588. expect(componentNode).toBeDefined();
  5589. expect(componentNode?.name).toBe('HelloWorld');
  5590. expect(componentNode?.language).toBe('vue');
  5591. expect(componentNode?.isExported).toBe(true);
  5592. });
  5593. it('should extract functions from <script> block', () => {
  5594. const code = `<template>
  5595. <button @click="handleClick">Click</button>
  5596. </template>
  5597. <script>
  5598. function handleClick() {
  5599. console.log('clicked');
  5600. }
  5601. const count = 0;
  5602. </script>
  5603. `;
  5604. const result = extractFromSource('Button.vue', code);
  5605. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5606. expect(componentNode).toBeDefined();
  5607. expect(componentNode?.name).toBe('Button');
  5608. const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'handleClick');
  5609. expect(funcNode).toBeDefined();
  5610. expect(funcNode?.language).toBe('vue');
  5611. });
  5612. it('should extract from <script setup lang="ts"> block', () => {
  5613. const code = `<template>
  5614. <div>{{ count }}</div>
  5615. </template>
  5616. <script setup lang="ts">
  5617. import { ref } from 'vue';
  5618. const count = ref(0);
  5619. function increment(): void {
  5620. count.value++;
  5621. }
  5622. </script>
  5623. `;
  5624. const result = extractFromSource('Counter.vue', code);
  5625. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5626. expect(componentNode).toBeDefined();
  5627. expect(componentNode?.name).toBe('Counter');
  5628. const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'increment');
  5629. expect(funcNode).toBeDefined();
  5630. expect(funcNode?.language).toBe('vue');
  5631. // All nodes should be marked as vue language
  5632. for (const node of result.nodes) {
  5633. expect(node.language).toBe('vue');
  5634. }
  5635. });
  5636. it('should extract calls from top-level <script setup> initializers', () => {
  5637. const code = `<template>
  5638. <div>{{ token }}</div>
  5639. </template>
  5640. <script setup lang="ts">
  5641. import { getTokenMp } from './api/upload';
  5642. const token = getTokenMp();
  5643. </script>
  5644. `;
  5645. const result = extractFromSource('Issue425Setup.vue', code);
  5646. const call = result.unresolvedReferences.find(
  5647. (ref) => ref.referenceKind === 'calls' && ref.referenceName === 'getTokenMp'
  5648. );
  5649. expect(call).toBeDefined();
  5650. });
  5651. it('should extract calls from Vue Options API object methods', () => {
  5652. const code = `<template>
  5653. <button @click="save">Save</button>
  5654. </template>
  5655. <script>
  5656. import { getTokenMp } from './api/upload';
  5657. export default {
  5658. methods: {
  5659. save() {
  5660. return getTokenMp();
  5661. }
  5662. },
  5663. setup() {
  5664. return getTokenMp();
  5665. }
  5666. }
  5667. </script>
  5668. `;
  5669. const result = extractFromSource('Issue425Options.vue', code);
  5670. const calls = result.unresolvedReferences.filter(
  5671. (ref) => ref.referenceKind === 'calls' && ref.referenceName === 'getTokenMp'
  5672. );
  5673. expect(calls).toHaveLength(2);
  5674. });
  5675. it('should extract component usages from the Vue template (PascalCase + kebab, skipping built-ins) (#629)', () => {
  5676. const code = `<template>
  5677. <div class="wrap">
  5678. <UserCard :user="u" />
  5679. <my-button>Click</my-button>
  5680. <Transition><span>x</span></Transition>
  5681. </div>
  5682. </template>
  5683. <script setup lang="ts">
  5684. import UserCard from './UserCard.vue';
  5685. import MyButton from './MyButton.vue';
  5686. </script>
  5687. `;
  5688. const result = extractFromSource('Host.vue', code);
  5689. const refs = result.unresolvedReferences
  5690. .filter((r) => r.referenceKind === 'references')
  5691. .map((r) => r.referenceName);
  5692. expect(refs).toContain('UserCard'); // PascalCase tag
  5693. expect(refs).toContain('MyButton'); // kebab <my-button> → MyButton
  5694. expect(refs).not.toContain('Transition'); // Vue built-in skipped
  5695. expect(refs).not.toContain('Div'); // native HTML element skipped
  5696. expect(refs).not.toContain('Span');
  5697. });
  5698. it('should extract from both <script> and <script setup> blocks', () => {
  5699. const code = `<template>
  5700. <div>{{ msg }}</div>
  5701. </template>
  5702. <script>
  5703. export default {
  5704. name: 'DualScript'
  5705. }
  5706. </script>
  5707. <script setup>
  5708. const msg = 'hello';
  5709. function greet() {
  5710. return msg;
  5711. }
  5712. </script>
  5713. `;
  5714. const result = extractFromSource('DualScript.vue', code);
  5715. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5716. expect(componentNode).toBeDefined();
  5717. const greetFunc = result.nodes.find((n) => n.kind === 'function' && n.name === 'greet');
  5718. expect(greetFunc).toBeDefined();
  5719. });
  5720. it('should create component node for template-only Vue file', () => {
  5721. const code = `<template>
  5722. <div>Static content</div>
  5723. </template>
  5724. `;
  5725. const result = extractFromSource('Static.vue', code);
  5726. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5727. expect(componentNode).toBeDefined();
  5728. expect(componentNode?.name).toBe('Static');
  5729. expect(componentNode?.language).toBe('vue');
  5730. // Only the component node should exist (no script nodes)
  5731. expect(result.nodes.length).toBe(1);
  5732. });
  5733. it('should create containment edges from component to script nodes', () => {
  5734. const code = `<template>
  5735. <div>{{ value }}</div>
  5736. </template>
  5737. <script setup lang="ts">
  5738. const value = 42;
  5739. </script>
  5740. `;
  5741. const result = extractFromSource('Contained.vue', code);
  5742. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5743. expect(componentNode).toBeDefined();
  5744. // Should have containment edges from component to child nodes
  5745. const containEdges = result.edges.filter(
  5746. (e) => e.source === componentNode!.id && e.kind === 'contains'
  5747. );
  5748. expect(containEdges.length).toBeGreaterThan(0);
  5749. });
  5750. });
  5751. describe('Astro Extraction', () => {
  5752. it('should detect Astro files', () => {
  5753. expect(detectLanguage('src/pages/index.astro')).toBe('astro');
  5754. expect(detectLanguage('Layout.astro')).toBe('astro');
  5755. expect(isLanguageSupported('astro')).toBe(true);
  5756. });
  5757. it('should extract component node from an .astro file', () => {
  5758. const code = `---
  5759. const title = 'Hello';
  5760. ---
  5761. <h1>{title}</h1>
  5762. `;
  5763. const result = extractFromSource('Card.astro', code);
  5764. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5765. expect(componentNode).toBeDefined();
  5766. expect(componentNode?.name).toBe('Card');
  5767. expect(componentNode?.language).toBe('astro');
  5768. expect(componentNode?.isExported).toBe(true);
  5769. });
  5770. it('should extract frontmatter symbols with correct line numbers (#768)', () => {
  5771. const code = `---
  5772. import { formatDate } from '../utils/format';
  5773. function getIconNode(name: string): string {
  5774. return name;
  5775. }
  5776. const { title } = Astro.props;
  5777. ---
  5778. <span>{title}</span>
  5779. `;
  5780. const result = extractFromSource('navs.astro', code);
  5781. // The #768 repro: a function defined in frontmatter must be found
  5782. const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'getIconNode');
  5783. expect(fn).toBeDefined();
  5784. expect(fn?.language).toBe('astro');
  5785. expect(fn?.startLine).toBe(4);
  5786. const imp = result.nodes.find((n) => n.kind === 'import');
  5787. expect(imp).toBeDefined();
  5788. expect(imp?.startLine).toBe(2);
  5789. });
  5790. it('should extract exported getStaticPaths from frontmatter', () => {
  5791. const code = `---
  5792. export async function getStaticPaths() {
  5793. return [];
  5794. }
  5795. const { slug } = Astro.params;
  5796. ---
  5797. <p>{slug}</p>
  5798. `;
  5799. const result = extractFromSource('[slug].astro', code);
  5800. const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'getStaticPaths');
  5801. expect(fn).toBeDefined();
  5802. expect(fn?.isExported).toBe(true);
  5803. });
  5804. it('should extract calls from template expressions', () => {
  5805. const code = `---
  5806. import { formatDate } from '../utils/format';
  5807. const date = new Date();
  5808. ---
  5809. <time>{formatDate(date)}</time>
  5810. `;
  5811. const result = extractFromSource('Stamp.astro', code);
  5812. const call = result.unresolvedReferences.find(
  5813. (ref) => ref.referenceKind === 'calls' && ref.referenceName === 'formatDate' && ref.line === 5
  5814. );
  5815. expect(call).toBeDefined();
  5816. });
  5817. it('should extract calls from a multiline expression opening line', () => {
  5818. const code = `---
  5819. const posts = [];
  5820. ---
  5821. <ul>
  5822. {posts.map((post) => (
  5823. <li>{render(post)}</li>
  5824. ))}
  5825. </ul>
  5826. `;
  5827. const result = extractFromSource('List.astro', code);
  5828. const mapCall = result.unresolvedReferences.find(
  5829. (ref) => ref.referenceKind === 'calls' && ref.referenceName === 'posts.map'
  5830. );
  5831. expect(mapCall).toBeDefined();
  5832. const innerCall = result.unresolvedReferences.find(
  5833. (ref) => ref.referenceKind === 'calls' && ref.referenceName === 'render'
  5834. );
  5835. expect(innerCall).toBeDefined();
  5836. });
  5837. it('should extract PascalCase component usages from the template', () => {
  5838. const code = `---
  5839. import Layout from '../layouts/Layout.astro';
  5840. import PostCard from '../components/PostCard.astro';
  5841. ---
  5842. <Layout title="Home">
  5843. <PostCard />
  5844. <Fragment slot="head" />
  5845. <div class="plain-html" />
  5846. </Layout>
  5847. `;
  5848. const result = extractFromSource('index.astro', code);
  5849. const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
  5850. const names = refs.map((r) => r.referenceName);
  5851. expect(names).toContain('Layout');
  5852. expect(names).toContain('PostCard');
  5853. // Astro built-ins and lowercase HTML are not component references
  5854. expect(names).not.toContain('Fragment');
  5855. expect(names).not.toContain('div');
  5856. });
  5857. it('should not extract template patterns from frontmatter, script, or style content', () => {
  5858. const code = `---
  5859. // <FakeComponent /> inside frontmatter comment
  5860. const x = { y: maybeCall(1) };
  5861. ---
  5862. <div>real</div>
  5863. <script>
  5864. const z = { w: scriptCall(2) };
  5865. </script>
  5866. <style>
  5867. .a { color: red; }
  5868. </style>
  5869. `;
  5870. const result = extractFromSource('Guard.astro', code);
  5871. const templateRefs = result.unresolvedReferences.filter(
  5872. (r) => r.referenceKind === 'references' && r.referenceName === 'FakeComponent'
  5873. );
  5874. expect(templateRefs).toHaveLength(0);
  5875. // maybeCall/scriptCall come from the delegated TS extraction (once),
  5876. // not double-counted by the template scanner
  5877. const maybeCalls = result.unresolvedReferences.filter(
  5878. (r) => r.referenceName === 'maybeCall' && r.referenceKind === 'calls'
  5879. );
  5880. expect(maybeCalls.length).toBeLessThanOrEqual(1);
  5881. });
  5882. it('should extract <script> block symbols with correct line numbers', () => {
  5883. const code = `---
  5884. const a = 1;
  5885. ---
  5886. <div>hi</div>
  5887. <script>
  5888. function trackView(page: string) {
  5889. console.log(page);
  5890. }
  5891. </script>
  5892. `;
  5893. const result = extractFromSource('Tracker.astro', code);
  5894. const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'trackView');
  5895. expect(fn).toBeDefined();
  5896. expect(fn?.startLine).toBe(6);
  5897. expect(fn?.language).toBe('astro');
  5898. });
  5899. it('should create component node for a frontmatter-less template-only file', () => {
  5900. const code = `<div>Static content</div>
  5901. `;
  5902. const result = extractFromSource('Static.astro', code);
  5903. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5904. expect(componentNode).toBeDefined();
  5905. expect(componentNode?.name).toBe('Static');
  5906. expect(componentNode?.language).toBe('astro');
  5907. });
  5908. it('should treat an unclosed frontmatter fence as no frontmatter', () => {
  5909. const code = `---
  5910. const broken = true;
  5911. <div>never closed</div>
  5912. `;
  5913. const result = extractFromSource('Broken.astro', code);
  5914. // No TS delegation happened (the fence never closes), but the component
  5915. // node still exists and nothing throws.
  5916. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5917. expect(componentNode).toBeDefined();
  5918. expect(result.nodes.find((n) => n.name === 'broken')).toBeUndefined();
  5919. });
  5920. it('should create containment edges from component to frontmatter nodes', () => {
  5921. const code = `---
  5922. const value = 42;
  5923. ---
  5924. <div>{value}</div>
  5925. `;
  5926. const result = extractFromSource('Contained.astro', code);
  5927. const componentNode = result.nodes.find((n) => n.kind === 'component');
  5928. expect(componentNode).toBeDefined();
  5929. const containEdges = result.edges.filter(
  5930. (e) => e.source === componentNode!.id && e.kind === 'contains'
  5931. );
  5932. expect(containEdges.length).toBeGreaterThan(0);
  5933. });
  5934. });
  5935. describe('Instantiates + Decorates edge extraction', () => {
  5936. it('emits an instantiates ref for `new Foo()`', () => {
  5937. const code = `
  5938. class Foo {}
  5939. function bootstrap() { return new Foo(); }
  5940. `;
  5941. const result = extractFromSource('app.ts', code);
  5942. const ref = result.unresolvedReferences.find(
  5943. (r) => r.referenceKind === 'instantiates' && r.referenceName === 'Foo'
  5944. );
  5945. expect(ref).toBeDefined();
  5946. });
  5947. it('strips type-argument suffix from generic constructors', () => {
  5948. const code = `
  5949. class Container<T> { constructor(_: T) {} }
  5950. function go() { return new Container<string>('x'); }
  5951. `;
  5952. const result = extractFromSource('app.ts', code);
  5953. const ref = result.unresolvedReferences.find(
  5954. (r) => r.referenceKind === 'instantiates'
  5955. );
  5956. expect(ref).toBeDefined();
  5957. // Container<string> must be normalised to "Container" — otherwise
  5958. // resolution can never match the class node.
  5959. expect(ref!.referenceName).toBe('Container');
  5960. });
  5961. it('keeps trailing identifier from qualified `new ns.Foo()`', () => {
  5962. const code = `
  5963. const ns = { Foo: class {} };
  5964. function go() { return new ns.Foo(); }
  5965. `;
  5966. const result = extractFromSource('app.ts', code);
  5967. const ref = result.unresolvedReferences.find(
  5968. (r) => r.referenceKind === 'instantiates'
  5969. );
  5970. // We can't always resolve which Foo, but the name should be the
  5971. // simple identifier so name-matching has a chance.
  5972. expect(ref?.referenceName).toBe('Foo');
  5973. });
  5974. it('emits a decorates ref for `@Foo class X {}`', () => {
  5975. const code = `
  5976. function Foo(_arg: string) { return (cls: any) => cls; }
  5977. @Foo('x')
  5978. class X {}
  5979. `;
  5980. const result = extractFromSource('app.ts', code);
  5981. const decorClass = result.unresolvedReferences.find(
  5982. (r) => r.referenceKind === 'decorates' && r.referenceName === 'Foo'
  5983. );
  5984. expect(decorClass).toBeDefined();
  5985. });
  5986. it('does NOT attribute a prior class\'s decorator to the next class', () => {
  5987. // Regression: the sibling-walk must stop at the first non-
  5988. // decorator separator. `@A class Foo {} @B class Bar {}` must
  5989. // produce `decorates(Foo, A)` and `decorates(Bar, B)` — never
  5990. // `decorates(Bar, A)`.
  5991. const code = `
  5992. function A(cls: any) { return cls; }
  5993. function B(cls: any) { return cls; }
  5994. @A
  5995. class Foo {}
  5996. @B
  5997. class Bar {}
  5998. `;
  5999. const result = extractFromSource('app.ts', code);
  6000. const decoratesEdges = result.unresolvedReferences.filter(
  6001. (r) => r.referenceKind === 'decorates'
  6002. );
  6003. // Exactly one decorates ref per decorated class, no cross-attribution.
  6004. const fromBar = decoratesEdges.filter((r) =>
  6005. result.nodes.find((n) => n.id === r.fromNodeId && n.name === 'Bar')
  6006. );
  6007. expect(fromBar.length).toBe(1);
  6008. expect(fromBar[0]!.referenceName).toBe('B');
  6009. });
  6010. it('emits a decorates ref for `@Foo method() {}`', () => {
  6011. const code = `
  6012. function Get(p: string) { return (t: any, k: string) => t; }
  6013. class Svc {
  6014. @Get('/x') method() { return 1; }
  6015. }
  6016. `;
  6017. const result = extractFromSource('app.ts', code);
  6018. const decorMethod = result.unresolvedReferences.find(
  6019. (r) => r.referenceKind === 'decorates' && r.referenceName === 'Get'
  6020. );
  6021. expect(decorMethod).toBeDefined();
  6022. // The decorated symbol must be `method`, not the constructor or class.
  6023. const decoratedNode = result.nodes.find((n) => n.id === decorMethod!.fromNodeId);
  6024. expect(decoratedNode?.name).toBe('method');
  6025. });
  6026. });
  6027. // =============================================================================
  6028. // Lua
  6029. // =============================================================================
  6030. describe('Lua Extraction', () => {
  6031. describe('Language detection', () => {
  6032. it('should detect Lua files', () => {
  6033. expect(detectLanguage('init.lua')).toBe('lua');
  6034. expect(detectLanguage('src/util.lua')).toBe('lua');
  6035. });
  6036. it('should report Lua as supported', () => {
  6037. expect(isLanguageSupported('lua')).toBe(true);
  6038. expect(getSupportedLanguages()).toContain('lua');
  6039. });
  6040. });
  6041. describe('Function extraction', () => {
  6042. it('should extract global and local functions', () => {
  6043. const code = `
  6044. function configure(opts) return opts end
  6045. local function helper(x) return x * 2 end
  6046. `;
  6047. const result = extractFromSource('init.lua', code);
  6048. const funcs = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  6049. expect(funcs).toContain('configure');
  6050. expect(funcs).toContain('helper');
  6051. const configure = result.nodes.find((n) => n.name === 'configure');
  6052. expect(configure?.language).toBe('lua');
  6053. expect(configure?.signature).toBe('(opts)');
  6054. });
  6055. it('should split table/method functions into a receiver and method name', () => {
  6056. const code = `
  6057. function M.connect(host, port) return host end
  6058. function M:send(data) return self end
  6059. `;
  6060. const result = extractFromSource('init.lua', code);
  6061. const methods = result.nodes.filter((n) => n.kind === 'method');
  6062. const connect = methods.find((m) => m.name === 'connect');
  6063. expect(connect?.qualifiedName).toBe('M::connect');
  6064. const send = methods.find((m) => m.name === 'send');
  6065. expect(send?.qualifiedName).toBe('M::send');
  6066. });
  6067. });
  6068. describe('Variable extraction', () => {
  6069. it('should extract local variable declarations', () => {
  6070. const code = `
  6071. local M = {}
  6072. local count = 0
  6073. `;
  6074. const result = extractFromSource('mod.lua', code);
  6075. const vars = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name);
  6076. expect(vars).toContain('M');
  6077. expect(vars).toContain('count');
  6078. });
  6079. });
  6080. describe('Import extraction (require)', () => {
  6081. it('should extract require() in local declarations and bare calls', () => {
  6082. const code = `
  6083. local socket = require("socket")
  6084. local http = require "resty.http"
  6085. require("side.effect")
  6086. `;
  6087. const result = extractFromSource('net.lua', code);
  6088. const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  6089. expect(imports).toContain('socket');
  6090. expect(imports).toContain('resty.http');
  6091. expect(imports).toContain('side.effect');
  6092. const ref = result.unresolvedReferences.find(
  6093. (r) => r.referenceKind === 'imports' && r.referenceName === 'socket'
  6094. );
  6095. expect(ref).toBeDefined();
  6096. });
  6097. // Regression: the tree-sitter-wasms Lua grammar (ABI 13) corrupts the shared
  6098. // WASM heap under web-tree-sitter 0.25, dropping nested calls/imports on every
  6099. // parse after the first. We vendor the ABI-15 grammar instead — this guards it
  6100. // by extracting several sources in sequence and asserting the LAST still works.
  6101. it('should keep extracting require across many sequential parses', () => {
  6102. let last;
  6103. for (let i = 0; i < 8; i++) {
  6104. last = extractFromSource(`f${i}.lua`, `local m = require("module.${i}")\nreturn m\n`);
  6105. }
  6106. const imports = last!.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  6107. expect(imports).toContain('module.7');
  6108. });
  6109. });
  6110. describe('Call extraction', () => {
  6111. it('should record intra-file calls as resolvable references', () => {
  6112. const code = `
  6113. local function helper(x) return x end
  6114. local function run(y) return helper(y) end
  6115. `;
  6116. const result = extractFromSource('calls.lua', code);
  6117. const call = result.unresolvedReferences.find(
  6118. (r) => r.referenceKind === 'calls' && r.referenceName === 'helper'
  6119. );
  6120. expect(call).toBeDefined();
  6121. });
  6122. });
  6123. });
  6124. // =============================================================================
  6125. // Luau (typed superset of Lua — https://luau.org)
  6126. // =============================================================================
  6127. describe('Luau Extraction', () => {
  6128. describe('Language detection', () => {
  6129. it('should detect Luau files', () => {
  6130. expect(detectLanguage('init.luau')).toBe('luau');
  6131. expect(detectLanguage('src/Client.luau')).toBe('luau');
  6132. });
  6133. it('should report Luau as supported', () => {
  6134. expect(isLanguageSupported('luau')).toBe(true);
  6135. expect(getSupportedLanguages()).toContain('luau');
  6136. });
  6137. });
  6138. describe('Type aliases', () => {
  6139. it('should extract `type` and `export type` definitions', () => {
  6140. const code = `
  6141. export type Vector = { x: number, y: number }
  6142. type Handler = (msg: string) -> boolean
  6143. `;
  6144. const result = extractFromSource('types.luau', code);
  6145. const aliases = result.nodes.filter((n) => n.kind === 'type_alias');
  6146. const vector = aliases.find((a) => a.name === 'Vector');
  6147. expect(vector).toBeDefined();
  6148. expect(vector?.isExported).toBe(true);
  6149. const handler = aliases.find((a) => a.name === 'Handler');
  6150. expect(handler).toBeDefined();
  6151. expect(handler?.isExported).toBe(false);
  6152. });
  6153. });
  6154. describe('Typed functions and methods', () => {
  6155. it('should capture typed signatures and split methods by receiver', () => {
  6156. const code = `
  6157. function configure(opts: { debug: boolean }): boolean
  6158. return opts.debug
  6159. end
  6160. function Client:fetch(path: string): Response
  6161. return path
  6162. end
  6163. `;
  6164. const result = extractFromSource('client.luau', code);
  6165. const configure = result.nodes.find((n) => n.kind === 'function' && n.name === 'configure');
  6166. expect(configure?.language).toBe('luau');
  6167. expect(configure?.signature).toBe('(opts: { debug: boolean }): boolean');
  6168. const fetch = result.nodes.find((n) => n.kind === 'method' && n.name === 'fetch');
  6169. expect(fetch?.qualifiedName).toBe('Client::fetch');
  6170. });
  6171. });
  6172. describe('Imports and variables', () => {
  6173. it('should extract string and Roblox instance-path require imports', () => {
  6174. const code = `
  6175. local http = require("http")
  6176. local Signal = require(script.Parent.Signal)
  6177. local count = 0
  6178. `;
  6179. const result = extractFromSource('mod.luau', code);
  6180. const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  6181. expect(imports).toContain('http'); // string require
  6182. expect(imports).toContain('Signal'); // Roblox instance-path require
  6183. const vars = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name);
  6184. expect(vars).toContain('count');
  6185. });
  6186. });
  6187. });
  6188. // =============================================================================
  6189. // Objective-C
  6190. // =============================================================================
  6191. describe('Objective-C Extraction', () => {
  6192. const sample = `
  6193. #import <Foundation/Foundation.h>
  6194. #import "MyClass.h"
  6195. @interface MyClass : NSObject <NSCopying>
  6196. @property (nonatomic, copy) NSString *name;
  6197. - (void)greet;
  6198. - (void)doThing:(id)x with:(id)y;
  6199. + (instancetype)shared;
  6200. @end
  6201. @implementation MyClass
  6202. - (void)greet {
  6203. NSLog(@"Hello");
  6204. [self doWork];
  6205. }
  6206. - (void)doThing:(id)x with:(id)y {
  6207. [self notify:x];
  6208. }
  6209. + (instancetype)shared {
  6210. return [[MyClass alloc] init];
  6211. }
  6212. @end
  6213. void helperFunction(int count) {
  6214. MyClass *obj = [MyClass shared];
  6215. [obj greet];
  6216. }
  6217. `;
  6218. it('should extract classes, methods, functions, and imports', () => {
  6219. const result = extractFromSource('App.m', sample);
  6220. const classes = result.nodes.filter((n) => n.kind === 'class');
  6221. expect(classes.filter((c) => c.name === 'MyClass')).toHaveLength(1);
  6222. const methods = result.nodes.filter((n) => n.kind === 'method');
  6223. expect(methods.map((m) => m.name).sort()).toEqual(['doThing:with:', 'greet', 'shared']);
  6224. const shared = methods.find((m) => m.name === 'shared');
  6225. expect(shared?.isStatic).toBe(true);
  6226. const properties = result.nodes.filter((n) => n.kind === 'property');
  6227. expect(properties.some((p) => p.name === 'name')).toBe(true);
  6228. const functions = result.nodes.filter((n) => n.kind === 'function');
  6229. expect(functions.some((f) => f.name === 'helperFunction')).toBe(true);
  6230. const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  6231. expect(imports).toContain('Foundation/Foundation.h');
  6232. expect(imports).toContain('MyClass.h');
  6233. });
  6234. it('should record inheritance and protocol conformance', () => {
  6235. const result = extractFromSource('App.m', sample);
  6236. const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
  6237. const implementsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'implements');
  6238. expect(extendsRefs.map((r) => r.referenceName)).toContain('NSObject');
  6239. expect(implementsRefs.map((r) => r.referenceName)).toContain('NSCopying');
  6240. });
  6241. it('should record message sends and C calls', () => {
  6242. const result = extractFromSource('App.m', sample);
  6243. const calls = result.unresolvedReferences
  6244. .filter((r) => r.referenceKind === 'calls')
  6245. .map((r) => r.referenceName);
  6246. expect(calls).toEqual(expect.arrayContaining(['NSLog', 'doWork', 'MyClass.shared', 'obj.greet']));
  6247. });
  6248. it('should reconstruct multi-keyword selectors at the call site so they resolve to the method definition', () => {
  6249. // Regression for the gap discovered post-#165: message_expression's
  6250. // multi-keyword form `[obj a:1 b:2]` was only emitting the first keyword,
  6251. // so calls never resolved to multi-part method definitions like
  6252. // `GET:parameters:headers:progress:success:failure:`. The call-site name
  6253. // must match the method-definition name with full keywords + trailing colons.
  6254. const code = `
  6255. @implementation Caller
  6256. - (void)demo {
  6257. NSMutableDictionary *d = [NSMutableDictionary new];
  6258. [d setObject:@"v" forKey:@"k"];
  6259. [d setObject:@"v2" forKey:@"k2" withRetry:@YES];
  6260. [self touchesBegan:nil withEvent:nil];
  6261. }
  6262. @end
  6263. `;
  6264. const result = extractFromSource('Caller.m', code);
  6265. const calls = result.unresolvedReferences
  6266. .filter((r) => r.referenceKind === 'calls')
  6267. .map((r) => r.referenceName);
  6268. expect(calls).toEqual(
  6269. expect.arrayContaining([
  6270. 'd.setObject:forKey:',
  6271. 'd.setObject:forKey:withRetry:',
  6272. 'touchesBegan:withEvent:',
  6273. ])
  6274. );
  6275. });
  6276. it('should not classify pure C headers with @end in comments as objc', () => {
  6277. const cHeader = '/* @end of file */\n#ifndef STDIO_H\nvoid printf(const char *);\n#endif\n';
  6278. expect(detectLanguage('stdio.h', cHeader)).toBe('c');
  6279. });
  6280. it('should extract protocol declarations', () => {
  6281. const code = `
  6282. @protocol DataSource <NSObject>
  6283. - (NSInteger)numberOfItems;
  6284. @end
  6285. `;
  6286. const result = extractFromSource('DataSource.h', code);
  6287. const protocol = result.nodes.find((n) => n.kind === 'protocol' && n.name === 'DataSource');
  6288. expect(protocol).toBeDefined();
  6289. });
  6290. it('should report Objective-C as supported', () => {
  6291. expect(isLanguageSupported('objc')).toBe(true);
  6292. expect(getSupportedLanguages()).toContain('objc');
  6293. });
  6294. });
  6295. describe('Solidity Extraction', () => {
  6296. const code = `// SPDX-License-Identifier: MIT
  6297. pragma solidity ^0.8.20;
  6298. import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
  6299. interface IVault {
  6300. function deposit(uint256 amount) external returns (bool);
  6301. event Deposited(address indexed user, uint256 amount);
  6302. }
  6303. library SafeMath {
  6304. function add(uint256 a, uint256 b) internal pure returns (uint256) {
  6305. return a + b;
  6306. }
  6307. }
  6308. contract Vault is IVault {
  6309. using SafeMath for uint256;
  6310. enum Status { Active, Frozen, Closed }
  6311. struct UserInfo {
  6312. uint256 balance;
  6313. uint256 lastDeposit;
  6314. }
  6315. IERC20 public immutable token;
  6316. mapping(address => UserInfo) public users;
  6317. address public owner;
  6318. event Withdrawn(address indexed user, uint256 amount);
  6319. error NotOwner();
  6320. modifier onlyOwner() {
  6321. if (msg.sender != owner) revert NotOwner();
  6322. _;
  6323. }
  6324. constructor(address _token) {
  6325. token = IERC20(_token);
  6326. owner = msg.sender;
  6327. }
  6328. function deposit(uint256 amount) external override returns (bool) {
  6329. users[msg.sender].balance = users[msg.sender].balance.add(amount);
  6330. emit Deposited(msg.sender, amount);
  6331. return true;
  6332. }
  6333. function withdraw(uint256 amount) external onlyOwner {
  6334. emit Withdrawn(msg.sender, amount);
  6335. }
  6336. }
  6337. `;
  6338. describe('Language detection', () => {
  6339. it('should detect Solidity files', () => {
  6340. expect(detectLanguage('contracts/Vault.sol')).toBe('solidity');
  6341. });
  6342. it('should report Solidity as supported', () => {
  6343. expect(isLanguageSupported('solidity')).toBe(true);
  6344. expect(getSupportedLanguages()).toContain('solidity');
  6345. });
  6346. });
  6347. describe('Container extraction', () => {
  6348. it('should extract contract / interface / library as class-likes', () => {
  6349. const result = extractFromSource('Vault.sol', code);
  6350. // interface_declaration → interface
  6351. const iface = result.nodes.find((n) => n.kind === 'interface' && n.name === 'IVault');
  6352. expect(iface).toBeDefined();
  6353. expect(iface?.language).toBe('solidity');
  6354. // contract and library both map to 'class' (library has no special semantics
  6355. // a class node doesn't already cover — they share methodTypes/inheritance).
  6356. expect(result.nodes.find((n) => n.kind === 'class' && n.name === 'Vault')).toBeDefined();
  6357. expect(result.nodes.find((n) => n.kind === 'class' && n.name === 'SafeMath')).toBeDefined();
  6358. });
  6359. it('should emit extends references for `is X, Y` inheritance', () => {
  6360. // `Vault is IVault` — Solidity uses one keyword (`is`) for both class
  6361. // extension and interface implementation, so the extractor emits `extends`
  6362. // and the resolver's interface-impl synthesizer reclassifies to
  6363. // `implements` based on the target node kind.
  6364. const result = extractFromSource('Vault.sol', code);
  6365. const extendsRefs = result.unresolvedReferences.filter(
  6366. (r) => r.referenceKind === 'extends' && r.referenceName === 'IVault'
  6367. );
  6368. expect(extendsRefs).toHaveLength(1);
  6369. const vaultNode = result.nodes.find((n) => n.kind === 'class' && n.name === 'Vault');
  6370. expect(extendsRefs[0]?.fromNodeId).toBe(vaultNode?.id);
  6371. });
  6372. });
  6373. describe('Method extraction', () => {
  6374. it('should extract methods, modifiers, and constructor with signatures', () => {
  6375. const result = extractFromSource('Vault.sol', code);
  6376. const methods = result.nodes.filter((n) => n.kind === 'method');
  6377. const names = methods.map((n) => n.name);
  6378. expect(names).toContain('deposit');
  6379. expect(names).toContain('withdraw');
  6380. expect(names).toContain('add');
  6381. expect(names).toContain('onlyOwner'); // modifier_definition
  6382. expect(names).toContain('constructor'); // constructor_definition (synthetic name)
  6383. // Signature should capture parameters + visibility + state mutability + return type.
  6384. const add = methods.find((m) => m.name === 'add');
  6385. expect(add?.signature).toContain('uint256 a');
  6386. expect(add?.signature).toContain('internal');
  6387. expect(add?.signature).toContain('pure');
  6388. expect(add?.signature).toContain('returns (uint256)');
  6389. // `external` visibility should map to 'public' (callable from outside the contract).
  6390. const deposit = methods.find((m) => m.name === 'deposit');
  6391. expect(deposit?.visibility).toBe('public');
  6392. });
  6393. });
  6394. describe('Struct, enum, and field extraction', () => {
  6395. it('should extract struct, enum, and enum members', () => {
  6396. const result = extractFromSource('Vault.sol', code);
  6397. expect(result.nodes.find((n) => n.kind === 'struct' && n.name === 'UserInfo')).toBeDefined();
  6398. expect(result.nodes.find((n) => n.kind === 'enum' && n.name === 'Status')).toBeDefined();
  6399. const enumMembers = result.nodes.filter((n) => n.kind === 'enum_member').map((n) => n.name);
  6400. expect(enumMembers).toEqual(expect.arrayContaining(['Active', 'Frozen', 'Closed']));
  6401. });
  6402. it('should extract state variables, struct members, events, errors as fields', () => {
  6403. const result = extractFromSource('Vault.sol', code);
  6404. const fieldNames = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name);
  6405. // state variables
  6406. expect(fieldNames).toEqual(expect.arrayContaining(['token', 'users', 'owner']));
  6407. // struct members
  6408. expect(fieldNames).toEqual(expect.arrayContaining(['balance', 'lastDeposit']));
  6409. // event + error
  6410. expect(fieldNames).toEqual(expect.arrayContaining(['Deposited', 'Withdrawn', 'NotOwner']));
  6411. });
  6412. it('should treat `constant_variable_declaration` as a constant, not a variable', () => {
  6413. const constCode = `// SPDX-License-Identifier: MIT
  6414. pragma solidity ^0.8.20;
  6415. uint256 constant FILE_CONST = 42;
  6416. `;
  6417. const result = extractFromSource('consts.sol', constCode);
  6418. const node = result.nodes.find((n) => n.name === 'FILE_CONST');
  6419. expect(node?.kind).toBe('constant');
  6420. });
  6421. });
  6422. describe('Import and call extraction', () => {
  6423. it('should extract import directives with the source path as the module name', () => {
  6424. const result = extractFromSource('Vault.sol', code);
  6425. const imp = result.nodes.find((n) => n.kind === 'import');
  6426. expect(imp).toBeDefined();
  6427. expect(imp?.name).toBe('@openzeppelin/contracts/token/ERC20/IERC20.sol');
  6428. });
  6429. it('should produce calls refs for emit, revert, and library/method calls', () => {
  6430. const result = extractFromSource('Vault.sol', code);
  6431. const calls = result.unresolvedReferences
  6432. .filter((r) => r.referenceKind === 'calls')
  6433. .map((r) => r.referenceName);
  6434. // emit Deposited(...)
  6435. expect(calls).toContain('Deposited');
  6436. // revert NotOwner()
  6437. expect(calls).toContain('NotOwner');
  6438. // library call: balance.add(amount) — receiver-qualified
  6439. expect(calls.some((c) => c === 'add' || c === 'balance.add')).toBe(true);
  6440. });
  6441. it('should produce calls refs for modifier invocations and base-constructor invocations', () => {
  6442. // `withdraw(...) external onlyOwner` — the modifier sits in the function
  6443. // header, outside the body: field the call walker descends, so it goes
  6444. // through the decorator-position walk. It must emit `calls` (not
  6445. // `decorates`) so flow traversal rides the withdraw → onlyOwner →
  6446. // NotOwner audit path.
  6447. const result = extractFromSource('Vault.sol', code);
  6448. const withdrawNode = result.nodes.find((n) => n.kind === 'method' && n.name === 'withdraw');
  6449. const modifierCall = result.unresolvedReferences.find(
  6450. (r) => r.referenceKind === 'calls' && r.referenceName === 'onlyOwner'
  6451. );
  6452. expect(modifierCall).toBeDefined();
  6453. expect(modifierCall?.fromNodeId).toBe(withdrawNode?.id);
  6454. // Base-constructor invocation parses as the same modifier_invocation
  6455. // node: `constructor(address o) ERC20("T", "TOK") Ownable(o)` — the
  6456. // constructor-chain hop.
  6457. const ctorCode = `pragma solidity ^0.8.20;
  6458. contract MyToken is ERC20, Ownable {
  6459. constructor(address o) ERC20("Tok", "TOK") Ownable(o) {}
  6460. function grab(bytes32 r) external onlyRole(ADMIN_ROLE) returns (uint256) { return 1; }
  6461. }
  6462. `;
  6463. const ctorResult = extractFromSource('MyToken.sol', ctorCode);
  6464. const ctorCalls = ctorResult.unresolvedReferences
  6465. .filter((r) => r.referenceKind === 'calls')
  6466. .map((r) => r.referenceName);
  6467. expect(ctorCalls).toEqual(expect.arrayContaining(['ERC20', 'Ownable']));
  6468. // modifier WITH arguments still resolves to the bare modifier name
  6469. expect(ctorCalls).toContain('onlyRole');
  6470. });
  6471. });
  6472. });
  6473. describe('Regression: issue-specific extraction fixes', () => {
  6474. it('indexes inner functions of an anonymous AMD/CommonJS module wrapper (#528)', () => {
  6475. const code = `
  6476. define(['dep'], function (dep) {
  6477. function innerHelper(x) { return x + 1; }
  6478. function compute(y) { return innerHelper(y); }
  6479. return { compute: compute };
  6480. });
  6481. `;
  6482. const result = extractFromSource('amd-module.js', code);
  6483. const fns = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  6484. expect(fns).toContain('innerHelper');
  6485. expect(fns).toContain('compute');
  6486. });
  6487. it('attaches Go methods on generic receivers to their type (#583)', () => {
  6488. const code = `
  6489. package main
  6490. type Stack[T any] struct { items []T }
  6491. func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }
  6492. func (s Stack[T]) Len() int { return len(s.items) }
  6493. `;
  6494. const result = extractFromSource('stack.go', code);
  6495. const methods = result.nodes.filter((n) => n.kind === 'method');
  6496. expect(methods.find((m) => m.name === 'Push')?.qualifiedName).toBe('Stack::Push');
  6497. expect(methods.find((m) => m.name === 'Len')?.qualifiedName).toBe('Stack::Len');
  6498. });
  6499. it('indexes new module extensions: .mts/.cts (TS) and .xsjs/.xsjslib (JS) (#366, #556)', () => {
  6500. expect(isSourceFile('mod.mts')).toBe(true);
  6501. expect(isSourceFile('mod.cts')).toBe(true);
  6502. expect(isSourceFile('service.xsjs')).toBe(true);
  6503. expect(isSourceFile('lib.xsjslib')).toBe(true);
  6504. expect(detectLanguage('mod.mts')).toBe('typescript');
  6505. expect(detectLanguage('service.xsjs')).toBe('javascript');
  6506. // End-to-end: a .mts file is parsed as TS, a .xsjs file as JS.
  6507. const ts = extractFromSource('mod.mts', 'export function hello(): number { return 1; }');
  6508. expect(ts.nodes.find((n) => n.name === 'hello' && n.kind === 'function')).toBeDefined();
  6509. const js = extractFromSource('service.xsjs', 'function handleRequest() { return 1; }');
  6510. expect(js.nodes.find((n) => n.name === 'handleRequest' && n.kind === 'function')).toBeDefined();
  6511. });
  6512. });
  6513. describe('Import / re-export dependency linking (blast-radius recall)', () => {
  6514. // An import IS a dependency, but extraction only emits references for calls,
  6515. // instantiations, type annotations, and inheritance — so a symbol imported and
  6516. // then merely re-exported, placed in a registry array, passed as an argument,
  6517. // or used in JSX produced no cross-file edge, leaving the providing file with a
  6518. // false "0 dependents". These tests pin the import/re-export binding linking.
  6519. it('emits an imports reference per named, aliased, and default import binding', () => {
  6520. const code = `
  6521. import { widget, helper as h } from './foo';
  6522. import Thing from './thing';
  6523. import * as NS from './ns';
  6524. export const registry = [widget];
  6525. `;
  6526. const result = extractFromSource('bar.ts', code);
  6527. const names = result.unresolvedReferences
  6528. .filter((r) => r.referenceKind === 'imports')
  6529. .map((r) => r.referenceName);
  6530. expect(names).toContain('widget'); // named import → local name
  6531. expect(names).toContain('h'); // aliased import → local alias
  6532. expect(names).toContain('Thing'); // default import
  6533. expect(names).toContain('NS'); // namespace import → linked to the module file as a dependency
  6534. });
  6535. it('emits an imports reference per re-exported binding', () => {
  6536. const result = extractFromSource('barrel.ts', `export { alpha, beta as b } from './source';`);
  6537. const names = result.unresolvedReferences
  6538. .filter((r) => r.referenceKind === 'imports')
  6539. .map((r) => r.referenceName);
  6540. // Re-export links the SOURCE-side name, not the local alias.
  6541. expect(names).toContain('alpha');
  6542. expect(names).toContain('beta');
  6543. });
  6544. it('a value imported/re-exported but never called still makes the importer a dependent', async () => {
  6545. const dir = createTempDir();
  6546. try {
  6547. fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
  6548. fs.writeFileSync(
  6549. path.join(dir, 'src', 'foo.ts'),
  6550. `export const widget = { n: 1 };\nexport function helper(): void {}\n`
  6551. );
  6552. // bar uses widget ONLY in an array and re-exports helper — neither is
  6553. // called/typed, so before import-linking bar had no edge to foo at all.
  6554. fs.writeFileSync(
  6555. path.join(dir, 'src', 'bar.ts'),
  6556. `import { widget } from './foo';\nexport { helper } from './foo';\nexport const registry = [widget];\n`
  6557. );
  6558. const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.ts'], exclude: [] } });
  6559. await cg.indexAll();
  6560. cg.resolveReferences();
  6561. expect(cg.getFileDependents('src/foo.ts')).toContain('src/bar.ts');
  6562. cg.destroy();
  6563. } finally {
  6564. cleanupTempDir(dir);
  6565. }
  6566. });
  6567. it('a namespace import touched only via a value-member read still links the module file', async () => {
  6568. const dir = createTempDir();
  6569. try {
  6570. fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
  6571. fs.writeFileSync(path.join(dir, 'src', 'foo.ts'), `export const SOME_CONST = 42;\n`);
  6572. // `foo` is imported as a namespace and used ONLY via a value-member read
  6573. // (no call, no type) — `foo.helper()` would link on its own, but a bare
  6574. // `foo.SOME_CONST` would not, so the module-import backstop must link it.
  6575. fs.writeFileSync(path.join(dir, 'src', 'bar.ts'), `import * as foo from './foo';\nexport const x = foo.SOME_CONST;\n`);
  6576. const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.ts'], exclude: [] } });
  6577. await cg.indexAll();
  6578. cg.resolveReferences();
  6579. expect(cg.getFileDependents('src/foo.ts')).toContain('src/bar.ts');
  6580. cg.destroy();
  6581. } finally {
  6582. cleanupTempDir(dir);
  6583. }
  6584. });
  6585. });
  6586. describe('Python import dependency linking (blast-radius recall)', () => {
  6587. // Same recall gap as TS: Python only linked called/instantiated imports, so a
  6588. // name brought in with `from module import X` and then merely stored, used as
  6589. // a decorator/argument, or re-exported through an `__init__.py` produced no
  6590. // cross-file edge — the providing module showed a false "0 dependents".
  6591. it('emits an imports reference per name in a `from module import ...` (incl. value/aliased)', () => {
  6592. const code = [
  6593. 'from foo import helper, widget',
  6594. 'from foo import Thing as T',
  6595. 'from . import sibling',
  6596. 'from bar import *',
  6597. ].join('\n');
  6598. const names = extractFromSource('mod.py', code)
  6599. .unresolvedReferences.filter((r) => r.referenceKind === 'imports')
  6600. .map((r) => r.referenceName);
  6601. expect(names).toContain('helper');
  6602. expect(names).toContain('widget'); // value import
  6603. expect(names).toContain('T'); // aliased import → local name
  6604. expect(names).toContain('sibling'); // `from . import <name>`
  6605. expect(names).not.toContain('*'); // wildcard import has no names
  6606. });
  6607. it('a Python value imported but never called still makes the importer a dependent', async () => {
  6608. const dir = createTempDir();
  6609. try {
  6610. fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true });
  6611. fs.writeFileSync(path.join(dir, 'pkg', 'foo.py'), `widget = {"n": 1}\ndef helper():\n return 1\n`);
  6612. // bar imports widget+helper but only stores widget in a list — nothing is
  6613. // called, so before import-linking bar had no edge to foo.
  6614. fs.writeFileSync(path.join(dir, 'pkg', 'bar.py'), `from foo import widget, helper\nregistry = [widget]\n`);
  6615. const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } });
  6616. await cg.indexAll();
  6617. cg.resolveReferences();
  6618. expect(cg.getFileDependents('pkg/foo.py')).toContain('pkg/bar.py');
  6619. cg.destroy();
  6620. } finally {
  6621. cleanupTempDir(dir);
  6622. }
  6623. });
  6624. it('resolves `from . import submodule` + `submodule.func()` to the submodule', async () => {
  6625. const dir = createTempDir();
  6626. try {
  6627. fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true });
  6628. fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), '');
  6629. fs.writeFileSync(path.join(dir, 'pkg', 'certs.py'), `def where():\n return "/ca.pem"\n`);
  6630. // certs is an imported MODULE (a file), and certs.where() is a qualified
  6631. // call through it — the receiver isn't a symbol, so plain name-matching
  6632. // can't link it. Also exercises the Python relative-dot path fix (`.certs`).
  6633. fs.writeFileSync(path.join(dir, 'pkg', 'utils.py'), `from . import certs\ndef go():\n return certs.where()\n`);
  6634. const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } });
  6635. await cg.indexAll();
  6636. cg.resolveReferences();
  6637. expect(cg.getFileDependents('pkg/certs.py')).toContain('pkg/utils.py');
  6638. cg.destroy();
  6639. } finally {
  6640. cleanupTempDir(dir);
  6641. }
  6642. });
  6643. it('a module import is a dependency even when the used member is re-exported elsewhere', async () => {
  6644. const dir = createTempDir();
  6645. try {
  6646. fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true });
  6647. fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), '');
  6648. // `where` is NOT defined in certs.py (re-exported from a 3rd-party pkg), so
  6649. // member resolution can't find it — the module-import backstop must still
  6650. // record utils -> certs. (Mirrors requests' real `certs.where`.)
  6651. fs.writeFileSync(path.join(dir, 'pkg', 'certs.py'), `from external_ca import where\n`);
  6652. fs.writeFileSync(path.join(dir, 'pkg', 'utils.py'), `from . import certs\nCA = certs.where()\n`);
  6653. const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } });
  6654. await cg.indexAll();
  6655. cg.resolveReferences();
  6656. expect(cg.getFileDependents('pkg/certs.py')).toContain('pkg/utils.py');
  6657. cg.destroy();
  6658. } finally {
  6659. cleanupTempDir(dir);
  6660. }
  6661. });
  6662. });
  6663. describe('Go cross-package composite literals (blast-radius recall)', () => {
  6664. // Go function calls and type references across packages already resolved, but
  6665. // struct composite literals — `render.XML{...}` / `pkga.Widget{...}` — were not
  6666. // extracted at all, so a package whose types are only INSTANTIATED elsewhere
  6667. // (gin's render/binding implementations) showed 0 dependents.
  6668. it('links a cross-package struct composite literal to the defining package', async () => {
  6669. const dir = createTempDir();
  6670. try {
  6671. fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
  6672. fs.mkdirSync(path.join(dir, 'render'), { recursive: true });
  6673. fs.writeFileSync(path.join(dir, 'render', 'xml.go'), `package render\n\ntype XML struct { Data any }\n`);
  6674. fs.writeFileSync(path.join(dir, 'app.go'), `package main\n\nimport "example.com/proj/render"\n\nfunc handle() any { return render.XML{} }\n`);
  6675. const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
  6676. await cg.indexAll();
  6677. cg.resolveReferences();
  6678. expect(cg.getFileDependents('render/xml.go')).toContain('app.go');
  6679. cg.destroy();
  6680. } finally {
  6681. cleanupTempDir(dir);
  6682. }
  6683. });
  6684. it('links a composite literal in a package-level var registry', async () => {
  6685. const dir = createTempDir();
  6686. try {
  6687. fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
  6688. fs.mkdirSync(path.join(dir, 'render'), { recursive: true });
  6689. fs.writeFileSync(path.join(dir, 'render', 'xml.go'), `package render\n\ntype XML struct {}\nfunc (XML) Render() {}\n`);
  6690. // The implementation is registered only in a top-level `var registry = {...}`
  6691. // map literal — the body walker doesn't cover top-level declarations, so this
  6692. // exercises the var-initializer walking added for Go.
  6693. 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`);
  6694. const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
  6695. await cg.indexAll();
  6696. cg.resolveReferences();
  6697. expect(cg.getFileDependents('render/xml.go')).toContain('reg.go');
  6698. cg.destroy();
  6699. } finally {
  6700. cleanupTempDir(dir);
  6701. }
  6702. });
  6703. it('attributes a call inside a top-level closure (cobra RunE) to the var, not the file (#693)', async () => {
  6704. const dir = createTempDir();
  6705. try {
  6706. fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
  6707. // Wire is called ONLY from the anonymous RunE closure inside a top-level
  6708. // `var rootCmd = &Cmd{...}` — previously the call leaked to the file node,
  6709. // so `callers(Wire)` surfaced a file (or read as "no caller"). It must now
  6710. // attribute to the enclosing var.
  6711. fs.writeFileSync(path.join(dir, 'factory.go'), `package main\n\nfunc Wire() error { return nil }\n`);
  6712. fs.writeFileSync(
  6713. path.join(dir, 'root.go'),
  6714. `package main\n\ntype Cmd struct{ RunE func() error }\n\nvar rootCmd = &Cmd{\n\tRunE: func() error { return Wire() },\n}\n`
  6715. );
  6716. const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
  6717. await cg.indexAll();
  6718. cg.resolveReferences();
  6719. const wire = cg.getNodesByName('Wire').find((n) => n.kind === 'function');
  6720. expect(wire).toBeDefined();
  6721. const callers = cg.getCallers(wire!.id).map((c) => c.node);
  6722. expect(callers.some((n) => n.kind === 'variable' && n.name === 'rootCmd')).toBe(true);
  6723. expect(callers.some((n) => n.kind === 'file')).toBe(false);
  6724. cg.destroy();
  6725. } finally {
  6726. cleanupTempDir(dir);
  6727. }
  6728. });
  6729. it('links a parenthesized pointer type conversion `(*T)(x)` to the type', async () => {
  6730. const dir = createTempDir();
  6731. try {
  6732. fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
  6733. fs.writeFileSync(path.join(dir, 'types.go'), `package main\n\ntype Wrapped struct { N int }\n`);
  6734. // `(*Wrapped)(x)` parses as a call whose callee is the parenthesized type
  6735. // `(*Wrapped)` — without normalization it dropped on the floor.
  6736. fs.writeFileSync(path.join(dir, 'use.go'), `package main\n\nfunc run(x *int) { _ = (*Wrapped)(x) }\n`);
  6737. const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
  6738. await cg.indexAll();
  6739. cg.resolveReferences();
  6740. expect(cg.getFileDependents('types.go')).toContain('use.go');
  6741. cg.destroy();
  6742. } finally {
  6743. cleanupTempDir(dir);
  6744. }
  6745. });
  6746. it('links an implementation reached only through a Go interface (implicit satisfaction, #584)', async () => {
  6747. const dir = createTempDir();
  6748. try {
  6749. fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
  6750. fs.mkdirSync(path.join(dir, 'codec'), { recursive: true });
  6751. 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`);
  6752. // jsonApi satisfies Core structurally (no `implements` keyword) and is
  6753. // reached ONLY through the interface (API.Marshal). Without implicit
  6754. // interface satisfaction + dispatch, json.go shows 0 dependents.
  6755. 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`);
  6756. const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
  6757. await cg.indexAll();
  6758. cg.resolveReferences();
  6759. expect(cg.getFileDependents('codec/json.go')).toContain('codec/api.go');
  6760. cg.destroy();
  6761. } finally {
  6762. cleanupTempDir(dir);
  6763. }
  6764. });
  6765. });
  6766. describe('C# records (blast-radius recall)', () => {
  6767. // Records are ubiquitous in modern C# (DTOs, value objects, CQRS messages),
  6768. // but `record` / `record struct` declarations weren't extracted as types — so
  6769. // every reference, generic-type-argument, and `new` of a record dropped on the
  6770. // floor and the defining file showed 0 dependents. (#237)
  6771. it('extracts a record as a graph node (record class + record struct)', () => {
  6772. const r = extractFromSource('r.cs', `namespace P;\npublic record Box(int N);\npublic record struct Pt(int X);\n`);
  6773. expect(r.nodes.find((n) => n.name === 'Box' && (n.kind === 'class' || n.kind === 'struct'))).toBeDefined();
  6774. expect(r.nodes.find((n) => n.name === 'Pt' && (n.kind === 'class' || n.kind === 'struct'))).toBeDefined();
  6775. });
  6776. it('resolves references / instantiations of a record across files', async () => {
  6777. const dir = createTempDir();
  6778. try {
  6779. fs.writeFileSync(path.join(dir, 'types.cs'), `namespace P;\npublic record Box(int N);\n`);
  6780. // Box is used as a generic type argument and instantiated — both require
  6781. // Box to be a node to resolve.
  6782. fs.writeFileSync(
  6783. path.join(dir, 'use.cs'),
  6784. `using System.Collections.Generic;\nnamespace P;\npublic class User {\n public IEnumerable<Box> Boxes { get; }\n public Box Make() => new Box(1);\n}\n`
  6785. );
  6786. const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.cs'], exclude: [] } });
  6787. await cg.indexAll();
  6788. cg.resolveReferences();
  6789. expect(cg.getFileDependents('types.cs')).toContain('use.cs');
  6790. cg.destroy();
  6791. } finally {
  6792. cleanupTempDir(dir);
  6793. }
  6794. });
  6795. });
  6796. describe('Rust cross-module recall', () => {
  6797. function rustProject(files: Record<string, string>): string {
  6798. const dir = createTempDir();
  6799. fs.writeFileSync(path.join(dir, 'Cargo.toml'), '[package]\nname = "proj"\nversion = "0.1.0"\nedition = "2021"\n');
  6800. fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
  6801. for (const [rel, content] of Object.entries(files)) {
  6802. const full = path.join(dir, 'src', rel);
  6803. fs.mkdirSync(path.dirname(full), { recursive: true });
  6804. fs.writeFileSync(full, content);
  6805. }
  6806. return dir;
  6807. }
  6808. it('extracts a struct literal `Foo { .. }` as an instantiation across modules', async () => {
  6809. const dir = rustProject({
  6810. 'lib.rs': 'pub mod types;\npub mod consumer;\n',
  6811. 'types.rs': 'pub struct Widget { pub n: i32 }\n',
  6812. 'consumer.rs': 'use crate::types::Widget;\npub fn build() -> Widget { Widget { n: 1 } }\n',
  6813. });
  6814. try {
  6815. const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } });
  6816. await cg.indexAll();
  6817. cg.resolveReferences();
  6818. expect(cg.getFileDependents('src/types.rs')).toContain('src/consumer.rs');
  6819. cg.destroy();
  6820. } finally { cleanupTempDir(dir); }
  6821. });
  6822. it('extracts trait method declarations and bridges trait dispatch to the impl', async () => {
  6823. const dir = rustProject({
  6824. 'lib.rs': 'pub mod types;\npub mod consumer;\n',
  6825. 'types.rs': 'pub trait Render { fn render(&self) -> i32; }\n',
  6826. // Mine implements Render structurally; reached via &dyn Render dispatch.
  6827. 'consumer.rs': 'use crate::types::Render;\npub struct Mine { pub x: i32 }\nimpl Render for Mine { fn render(&self) -> i32 { self.x } }\n',
  6828. });
  6829. try {
  6830. const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } });
  6831. await cg.indexAll();
  6832. cg.resolveReferences();
  6833. // implements edge (Mine -> Render) makes types.rs a dependent of consumer.rs's struct.
  6834. expect(cg.getFileDependents('src/types.rs')).toContain('src/consumer.rs');
  6835. cg.destroy();
  6836. } finally { cleanupTempDir(dir); }
  6837. });
  6838. it('links `pub use` re-export hubs to the modules they re-export', async () => {
  6839. const dir = rustProject({
  6840. 'lib.rs': 'pub mod api;\n',
  6841. 'api/mod.rs': 'mod widget;\npub use self::widget::Widget;\n',
  6842. 'api/widget.rs': 'pub struct Widget { pub n: i32 }\n',
  6843. });
  6844. try {
  6845. const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } });
  6846. await cg.indexAll();
  6847. cg.resolveReferences();
  6848. // The re-export hub depends on the module it re-exports from.
  6849. expect(cg.getFileDependents('src/api/widget.rs')).toContain('src/api/mod.rs');
  6850. cg.destroy();
  6851. } finally { cleanupTempDir(dir); }
  6852. });
  6853. it('resolves a qualified path to the correct module when the leaf name collides', async () => {
  6854. const dir = rustProject({
  6855. 'lib.rs': 'pub mod fast;\npub mod slow;\npub mod hub;\n',
  6856. 'fast.rs': 'pub fn read() -> i32 { 1 }\n',
  6857. 'slow.rs': 'pub fn read() -> i32 { 2 }\n',
  6858. // `read` exists in BOTH fast.rs and slow.rs — module-path resolution must
  6859. // send this re-export to fast.rs specifically, not name-match either.
  6860. 'hub.rs': 'pub use crate::fast::read;\n',
  6861. });
  6862. try {
  6863. const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } });
  6864. await cg.indexAll();
  6865. cg.resolveReferences();
  6866. expect(cg.getFileDependents('src/fast.rs')).toContain('src/hub.rs');
  6867. expect(cg.getFileDependents('src/slow.rs')).not.toContain('src/hub.rs');
  6868. cg.destroy();
  6869. } finally { cleanupTempDir(dir); }
  6870. });
  6871. });
  6872. describe('Java annotations (blast-radius recall)', () => {
  6873. it('indexes @interface definitions and links @Annotation usages to them', async () => {
  6874. const dir = createTempDir();
  6875. try {
  6876. fs.mkdirSync(path.join(dir, 'p'), { recursive: true });
  6877. // The annotation DEFINITION must be a node, and the @MyAnno usages (which
  6878. // live inside a `modifiers` node on the class/field/method) must extract.
  6879. fs.writeFileSync(path.join(dir, 'p', 'MyAnno.java'), `package p;\npublic @interface MyAnno { String value() default ""; }\n`);
  6880. fs.writeFileSync(
  6881. path.join(dir, 'p', 'User.java'),
  6882. `package p;\n@MyAnno("c")\npublic class User {\n @MyAnno("f") int field;\n @MyAnno("m") void go() {}\n}\n`
  6883. );
  6884. const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.java'], exclude: [] } });
  6885. await cg.indexAll();
  6886. cg.resolveReferences();
  6887. expect(cg.getFileDependents('p/MyAnno.java')).toContain('p/User.java');
  6888. cg.destroy();
  6889. } finally { cleanupTempDir(dir); }
  6890. });
  6891. });
  6892. describe('Swift property wrappers / attributes (blast-radius recall)', () => {
  6893. it('links a @propertyWrapper usage to the wrapper type', async () => {
  6894. const dir = createTempDir();
  6895. try {
  6896. fs.mkdirSync(path.join(dir, 'Sources', 'M'), { recursive: true });
  6897. fs.writeFileSync(path.join(dir, 'Sources', 'M', 'Wrap.swift'), `@propertyWrapper\npublic struct Argument<T> { public var wrappedValue: T }\n`);
  6898. // `@Argument` is a Swift attribute on a stored property — it lives in the
  6899. // property's `modifiers` and Swift doesn't extract instance properties as
  6900. // their own nodes, so without the fix the wrapper type has no users.
  6901. fs.writeFileSync(path.join(dir, 'Sources', 'M', 'Cmd.swift'), `public struct MyCommand {\n @Argument var name: String\n @Argument var count: Int\n}\n`);
  6902. const cg = CodeGraph.initSync(dir, { config: { include: ['Sources/**/*.swift'], exclude: [] } });
  6903. await cg.indexAll();
  6904. cg.resolveReferences();
  6905. expect(cg.getFileDependents('Sources/M/Wrap.swift')).toContain('Sources/M/Cmd.swift');
  6906. cg.destroy();
  6907. } finally { cleanupTempDir(dir); }
  6908. });
  6909. });
  6910. describe('R Extraction', () => {
  6911. describe('Language detection', () => {
  6912. it('should detect R files (both extension cases)', () => {
  6913. expect(detectLanguage('analysis.R')).toBe('r');
  6914. expect(detectLanguage('scripts/clean.r')).toBe('r');
  6915. });
  6916. it('should report R as supported', () => {
  6917. expect(isLanguageSupported('r')).toBe(true);
  6918. expect(getSupportedLanguages()).toContain('r');
  6919. });
  6920. });
  6921. describe('Function extraction', () => {
  6922. it('extracts every assignment form, lambdas, and nested functions', () => {
  6923. const code = `
  6924. clean_data <- function(df, threshold = 0.5) {
  6925. helper <- function(d) scale(d)
  6926. helper(df)
  6927. }
  6928. normalize = function(v) (v - mean(v)) / sd(v)
  6929. double_it <- \\(x) x * 2
  6930. `;
  6931. const result = extractFromSource('analysis.R', code);
  6932. const funcs = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  6933. expect(funcs).toContain('clean_data');
  6934. expect(funcs).toContain('normalize');
  6935. expect(funcs).toContain('double_it');
  6936. expect(funcs).toContain('helper'); // nested, inside clean_data's scope
  6937. const cleanData = result.nodes.find((n) => n.name === 'clean_data');
  6938. expect(cleanData?.language).toBe('r');
  6939. expect(cleanData?.signature).toBe('(df, threshold = 0.5)');
  6940. });
  6941. it('attributes body calls to the enclosing function', () => {
  6942. const code = `
  6943. prep <- function(d) scale(d)
  6944. fit_model <- function(data) {
  6945. lm(y ~ x, data = prep(data))
  6946. }
  6947. `;
  6948. const result = extractFromSource('models.R', code);
  6949. const prepCall = result.unresolvedReferences.find(
  6950. (r) => r.referenceName === 'prep' && r.referenceKind === 'calls'
  6951. );
  6952. expect(prepCall).toBeDefined();
  6953. const fitModel = result.nodes.find((n) => n.name === 'fit_model');
  6954. expect(prepCall?.fromNodeId).toBe(fitModel?.id);
  6955. });
  6956. });
  6957. describe('Imports', () => {
  6958. it('extracts library/require/source as imports, not calls', () => {
  6959. const code = `
  6960. library(dplyr)
  6961. require(stats)
  6962. requireNamespace("jsonlite")
  6963. source("helpers.R")
  6964. `;
  6965. const result = extractFromSource('main.R', code);
  6966. const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  6967. expect(imports).toContain('dplyr');
  6968. expect(imports).toContain('stats');
  6969. expect(imports).toContain('jsonlite');
  6970. expect(imports).toContain('helpers.R');
  6971. // Claimed by the hook — no call references to the import machinery.
  6972. const libCalls = result.unresolvedReferences.filter(
  6973. (r) => r.referenceKind === 'calls' && (r.referenceName === 'library' || r.referenceName === 'source')
  6974. );
  6975. expect(libCalls).toHaveLength(0);
  6976. });
  6977. });
  6978. describe('Variables and constants', () => {
  6979. it('extracts top-level assignments; ALL_CAPS as constants; right-assign too', () => {
  6980. const code = `
  6981. ALPHA <- 0.05
  6982. max_iter = 100
  6983. compute_stats(df) -> stats_result
  6984. inner <- function() {
  6985. local_var <- 1
  6986. }
  6987. `;
  6988. const result = extractFromSource('config.R', code);
  6989. const constant = result.nodes.find((n) => n.name === 'ALPHA');
  6990. expect(constant?.kind).toBe('constant');
  6991. const variable = result.nodes.find((n) => n.name === 'max_iter');
  6992. expect(variable?.kind).toBe('variable');
  6993. const rightAssigned = result.nodes.find((n) => n.name === 'stats_result');
  6994. expect(rightAssigned?.kind).toBe('variable');
  6995. // Locals inside functions are deliberately NOT extracted.
  6996. expect(result.nodes.find((n) => n.name === 'local_var')).toBeUndefined();
  6997. });
  6998. });
  6999. describe('Classes', () => {
  7000. it('extracts S4/R5/R6 class calls as classes with their list methods', () => {
  7001. const code = `
  7002. setClass("Patient", representation(id = "character"))
  7003. Account <- setRefClass("Account",
  7004. fields = list(balance = "numeric"),
  7005. methods = list(deposit = function(x) { balance <<- balance + x })
  7006. )
  7007. Stack <- R6Class("Stack",
  7008. public = list(push = function(v) invisible(v))
  7009. )
  7010. setGeneric("describe", function(obj) standardGeneric("describe"))
  7011. setMethod("describe", "Patient", function(obj) paste(obj@id))
  7012. `;
  7013. const result = extractFromSource('classes.R', code);
  7014. const classes = result.nodes.filter((n) => n.kind === 'class').map((n) => n.name);
  7015. expect(classes).toContain('Patient');
  7016. expect(classes).toContain('Account');
  7017. expect(classes).toContain('Stack');
  7018. const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
  7019. expect(methods).toContain('deposit');
  7020. expect(methods).toContain('push');
  7021. // setGeneric/setMethod produce functions named by their string argument.
  7022. const describes = result.nodes.filter((n) => n.name === 'describe' && n.kind === 'function');
  7023. expect(describes.length).toBeGreaterThanOrEqual(2);
  7024. // The class-assignment idiom must not ALSO produce a variable node.
  7025. expect(result.nodes.find((n) => n.name === 'Account' && n.kind === 'variable')).toBeUndefined();
  7026. });
  7027. it('extracts ggproto classes with direct-arg methods and the parent as extends', () => {
  7028. // ggplot2's OO system — every Geom/Stat/Scale in the ecosystem uses it.
  7029. const code = `
  7030. GeomPoint <- ggproto("GeomPoint", Geom,
  7031. required_aes = c("x", "y"),
  7032. draw_panel = function(data, panel_params, coord) {
  7033. coords <- coord$transform(data, panel_params)
  7034. grid::pointsGrob(coords$x, coords$y)
  7035. },
  7036. draw_key = draw_key_point
  7037. )
  7038. `;
  7039. const result = extractFromSource('geom-point.R', code);
  7040. const cls = result.nodes.find((n) => n.name === 'GeomPoint' && n.kind === 'class');
  7041. expect(cls).toBeDefined();
  7042. const method = result.nodes.find((n) => n.name === 'draw_panel' && n.kind === 'method');
  7043. expect(method).toBeDefined();
  7044. const ext = result.unresolvedReferences.find(
  7045. (r) => r.referenceKind === 'extends' && r.referenceName === 'Geom'
  7046. );
  7047. expect(ext?.fromNodeId).toBe(cls?.id);
  7048. // No twin variable for the assignment.
  7049. expect(result.nodes.find((n) => n.name === 'GeomPoint' && n.kind === 'variable')).toBeUndefined();
  7050. });
  7051. });
  7052. });
  7053. // =============================================================================
  7054. // CFML (ColdFusion Markup Language — .cfc/.cfm tag-based and bare-script, .cfs)
  7055. // =============================================================================
  7056. describe('CFML Extraction', () => {
  7057. describe('Language detection', () => {
  7058. it('should detect .cfc/.cfm as cfml and .cfs as cfscript', () => {
  7059. expect(detectLanguage('Service.cfc')).toBe('cfml');
  7060. expect(detectLanguage('index.cfm')).toBe('cfml');
  7061. expect(detectLanguage('Helper.cfs')).toBe('cfscript');
  7062. });
  7063. it('should report cfml and cfscript as supported', () => {
  7064. expect(isLanguageSupported('cfml')).toBe(true);
  7065. expect(isLanguageSupported('cfscript')).toBe(true);
  7066. expect(getSupportedLanguages()).toContain('cfml');
  7067. expect(getSupportedLanguages()).toContain('cfscript');
  7068. });
  7069. });
  7070. describe('Bare-script .cfc (component { ... })', () => {
  7071. const code = `
  7072. component extends="BaseService" implements="IService" {
  7073. property name="name" type="string";
  7074. function init(required string name) {
  7075. variables.name = arguments.name;
  7076. return this;
  7077. }
  7078. public string function getName() {
  7079. return variables.name;
  7080. }
  7081. private void function logSomething(required string msg) {
  7082. writeLog(text=msg);
  7083. }
  7084. }
  7085. `;
  7086. it('should name the component from the file name (the grammar has no name field)', () => {
  7087. const result = extractFromSource('SampleService.cfc', code);
  7088. const cls = result.nodes.find((n) => n.kind === 'class');
  7089. expect(cls).toBeDefined();
  7090. expect(cls?.name).toBe('SampleService');
  7091. expect(cls?.language).toBe('cfml');
  7092. });
  7093. it('should extract methods with visibility and contains edges to the class', () => {
  7094. const result = extractFromSource('SampleService.cfc', code);
  7095. const cls = result.nodes.find((n) => n.kind === 'class');
  7096. const methods = result.nodes.filter((n) => n.kind === 'method');
  7097. expect(methods.map((m) => m.name)).toEqual(
  7098. expect.arrayContaining(['init', 'getName', 'logSomething'])
  7099. );
  7100. const logSomething = methods.find((m) => m.name === 'logSomething');
  7101. expect(logSomething?.visibility).toBe('private');
  7102. const containsLog = result.edges.find(
  7103. (e) => e.source === cls?.id && e.target === logSomething?.id && e.kind === 'contains'
  7104. );
  7105. expect(containsLog).toBeDefined();
  7106. });
  7107. it('should extract extends/implements as unresolved references from the class', () => {
  7108. const result = extractFromSource('SampleService.cfc', code);
  7109. const cls = result.nodes.find((n) => n.kind === 'class');
  7110. const extendsRef = result.unresolvedReferences.find((r) => r.referenceKind === 'extends');
  7111. expect(extendsRef?.referenceName).toBe('BaseService');
  7112. expect(extendsRef?.fromNodeId).toBe(cls?.id);
  7113. const implRef = result.unresolvedReferences.find((r) => r.referenceKind === 'implements');
  7114. expect(implRef?.referenceName).toBe('IService');
  7115. expect(implRef?.fromNodeId).toBe(cls?.id);
  7116. });
  7117. });
  7118. describe('Standalone .cfs (pure CFScript)', () => {
  7119. it('should also name an anonymous component from the file name', () => {
  7120. const code = `
  7121. component {
  7122. function ping() {
  7123. return "pong";
  7124. }
  7125. }
  7126. `;
  7127. const result = extractFromSource('Sample.cfs', code);
  7128. const cls = result.nodes.find((n) => n.kind === 'class');
  7129. expect(cls).toBeDefined();
  7130. expect(cls?.name).toBe('Sample');
  7131. expect(cls?.language).toBe('cfscript');
  7132. });
  7133. it('should extract top-level imports with no enclosing component', () => {
  7134. const code = `
  7135. import com.foo.Bar;
  7136. import foo.cfm;
  7137. `;
  7138. const result = extractFromSource('Includes.cfs', code);
  7139. const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  7140. expect(imports).toContain('com.foo.Bar');
  7141. expect(imports).toContain('foo.cfm');
  7142. });
  7143. });
  7144. describe('Tag-based .cfc (<cfcomponent>/<cffunction>)', () => {
  7145. const code = `<cfcomponent extends="Base" implements="IFoo,IBar" output="false">
  7146. \t<cffunction name="getName" access="public" returntype="string">
  7147. \t\t<cfreturn this.name>
  7148. \t</cffunction>
  7149. \t<cffunction name="doWork" access="private" returntype="void">
  7150. \t\t<cfscript>
  7151. \t\t\tvar x = helper();
  7152. \t\t\tanotherCall(x);
  7153. \t\t</cfscript>
  7154. \t</cffunction>
  7155. </cfcomponent>
  7156. `;
  7157. it('should name the component from the file name when the tag has no name attribute', () => {
  7158. const result = extractFromSource('TagStyle.cfc', code);
  7159. const cls = result.nodes.find((n) => n.kind === 'class');
  7160. expect(cls?.name).toBe('TagStyle');
  7161. expect(cls?.language).toBe('cfml');
  7162. });
  7163. it('should prefer an explicit name attribute on the cfcomponent tag', () => {
  7164. const named = `<cfcomponent name="ExplicitName">\n<cffunction name="a"><cfreturn 1></cffunction>\n</cfcomponent>`;
  7165. const result = extractFromSource('File.cfc', named);
  7166. expect(result.nodes.find((n) => n.kind === 'class')?.name).toBe('ExplicitName');
  7167. });
  7168. it('should extract cffunction tags as methods with access-derived visibility', () => {
  7169. const result = extractFromSource('TagStyle.cfc', code);
  7170. const methods = result.nodes.filter((n) => n.kind === 'method');
  7171. expect(methods.map((m) => m.name)).toEqual(expect.arrayContaining(['getName', 'doWork']));
  7172. const getName = methods.find((m) => m.name === 'getName');
  7173. expect(getName?.visibility).toBe('public');
  7174. expect(getName?.returnType).toBe('string');
  7175. const doWork = methods.find((m) => m.name === 'doWork');
  7176. expect(doWork?.visibility).toBe('private');
  7177. });
  7178. it('should not double-extract symbols from the component body (implicit-end-tag walk)', () => {
  7179. const result = extractFromSource('TagStyle.cfc', code);
  7180. const methods = result.nodes.filter((n) => n.kind === 'method' && n.name === 'getName');
  7181. expect(methods).toHaveLength(1);
  7182. const doWorkMethods = result.nodes.filter((n) => n.kind === 'method' && n.name === 'doWork');
  7183. expect(doWorkMethods).toHaveLength(1);
  7184. });
  7185. it('should delegate <cfscript> tag bodies to the cfscript grammar and attribute calls to the enclosing method', () => {
  7186. const result = extractFromSource('TagStyle.cfc', code);
  7187. const doWork = result.nodes.find((n) => n.kind === 'method' && n.name === 'doWork');
  7188. const helperCall = result.unresolvedReferences.find(
  7189. (r) => r.referenceKind === 'calls' && r.referenceName === 'helper'
  7190. );
  7191. expect(helperCall?.fromNodeId).toBe(doWork?.id);
  7192. });
  7193. it('should produce exactly one correctly-ranged file node, not a leaked snippet-scoped one', () => {
  7194. const result = extractFromSource('TagStyle.cfc', code);
  7195. const fileNodes = result.nodes.filter((n) => n.kind === 'file');
  7196. expect(fileNodes).toHaveLength(1);
  7197. expect(fileNodes[0].startLine).toBe(1);
  7198. const cls = result.nodes.find((n) => n.kind === 'class');
  7199. const containsClass = result.edges.find(
  7200. (e) => e.source === fileNodes[0].id && e.target === cls?.id && e.kind === 'contains'
  7201. );
  7202. expect(containsClass).toBeDefined();
  7203. });
  7204. });
  7205. describe('Top-level cffunction with no enclosing cfcomponent (.cfm template)', () => {
  7206. it('should extract as a top-level function contained by the file', () => {
  7207. const code = `<cffunction name="helper" access="public" returntype="string">
  7208. \t<cfreturn "hi">
  7209. </cffunction>
  7210. `;
  7211. const result = extractFromSource('helper.cfm', code);
  7212. const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'helper');
  7213. expect(fn).toBeDefined();
  7214. const fileNode = result.nodes.find((n) => n.kind === 'file');
  7215. const containsFn = result.edges.find(
  7216. (e) => e.source === fileNode?.id && e.target === fn?.id && e.kind === 'contains'
  7217. );
  7218. expect(containsFn).toBeDefined();
  7219. });
  7220. });
  7221. describe('<cfscript> nested inside control-flow tags (<cfif>/<cfloop>/<cftry>)', () => {
  7222. it('should delegate a <cfscript> body nested inside <cfif> within a <cffunction>', () => {
  7223. const code = `<cfcomponent>
  7224. <cffunction name="doStuff">
  7225. <cfif true>
  7226. <cfscript>
  7227. helper();
  7228. </cfscript>
  7229. </cfif>
  7230. </cffunction>
  7231. </cfcomponent>
  7232. `;
  7233. const result = extractFromSource('Nested.cfc', code);
  7234. const doStuff = result.nodes.find((n) => n.kind === 'method' && n.name === 'doStuff');
  7235. expect(doStuff).toBeDefined();
  7236. const helperCall = result.unresolvedReferences.find(
  7237. (r) => r.referenceKind === 'calls' && r.referenceName === 'helper'
  7238. );
  7239. expect(helperCall?.fromNodeId).toBe(doStuff?.id);
  7240. });
  7241. it('should delegate a <cfscript> body nested inside <cfif> at top-level component scope', () => {
  7242. const code = `<cfcomponent>
  7243. <cfif true>
  7244. <cfscript>
  7245. topLevelHelper();
  7246. </cfscript>
  7247. </cfif>
  7248. </cfcomponent>
  7249. `;
  7250. const result = extractFromSource('Nested2.cfc', code);
  7251. const cls = result.nodes.find((n) => n.kind === 'class');
  7252. expect(cls).toBeDefined();
  7253. const helperCall = result.unresolvedReferences.find(
  7254. (r) => r.referenceKind === 'calls' && r.referenceName === 'topLevelHelper'
  7255. );
  7256. expect(helperCall?.fromNodeId).toBe(cls?.id);
  7257. });
  7258. });
  7259. describe('<cfquery> SQL bodies (cfquery grammar)', () => {
  7260. it('should extract a call expression embedded in a #hash# inside the SQL body', () => {
  7261. const code = `<cfcomponent>
  7262. <cffunction name="getUsers">
  7263. <cfquery name="qUsers" datasource="#variables.dsn#">
  7264. SELECT id, name FROM users WHERE owner = #getCurrentUser().getId()#
  7265. </cfquery>
  7266. <cfreturn qUsers>
  7267. </cffunction>
  7268. </cfcomponent>
  7269. `;
  7270. const result = extractFromSource('Query.cfc', code);
  7271. const getUsers = result.nodes.find((n) => n.kind === 'method' && n.name === 'getUsers');
  7272. expect(getUsers).toBeDefined();
  7273. const getCurrentUserCall = result.unresolvedReferences.find(
  7274. (r) => r.referenceKind === 'calls' && r.referenceName === 'getCurrentUser'
  7275. );
  7276. expect(getCurrentUserCall?.fromNodeId).toBe(getUsers?.id);
  7277. const getIdCall = result.unresolvedReferences.find(
  7278. (r) => r.referenceKind === 'calls' && r.referenceName === 'getId'
  7279. );
  7280. expect(getIdCall?.fromNodeId).toBe(getUsers?.id);
  7281. });
  7282. });
  7283. describe('UTF-8 BOM handling (common in CFML saved by Windows editors)', () => {
  7284. it('should route a BOM-prefixed tag-based .cfc to the tag grammar, not the script grammar', () => {
  7285. const code = `\uFEFF<cfcomponent output="false">\n<cffunction name="configure" access="public">\n<cfreturn 1>\n</cffunction>\n</cfcomponent>\n`;
  7286. const result = extractFromSource('ModuleConfig.cfc', code);
  7287. const cls = result.nodes.find((n) => n.kind === 'class');
  7288. expect(cls?.name).toBe('ModuleConfig');
  7289. const configure = result.nodes.find((n) => n.kind === 'method' && n.name === 'configure');
  7290. expect(configure).toBeDefined();
  7291. });
  7292. it('should still treat a BOM-prefixed bare-script .cfc as script', () => {
  7293. const code = `\uFEFFcomponent {\n function ping() { return "pong"; }\n}\n`;
  7294. const result = extractFromSource('Ping.cfc', code);
  7295. expect(result.nodes.find((n) => n.kind === 'class')?.name).toBe('Ping');
  7296. expect(result.nodes.find((n) => n.kind === 'method')?.name).toBe('ping');
  7297. });
  7298. });
  7299. describe('Unquoted tag attribute values (legal in older CFML)', () => {
  7300. it('should extract functions and inheritance from unquoted attributes', () => {
  7301. const code = `<cfcomponent extends=Base>\n<cffunction name=doThing access=private>\n<cfreturn 1>\n</cffunction>\n</cfcomponent>\n`;
  7302. const result = extractFromSource('Unquoted.cfc', code);
  7303. const doThing = result.nodes.find((n) => n.kind === 'method' && n.name === 'doThing');
  7304. expect(doThing).toBeDefined();
  7305. expect(doThing?.visibility).toBe('private');
  7306. const extendsRef = result.unresolvedReferences.find((r) => r.referenceKind === 'extends');
  7307. expect(extendsRef?.referenceName).toBe('Base');
  7308. });
  7309. });
  7310. describe('Functions in a component-level <cfscript> block', () => {
  7311. it('should classify them as methods of the component (ColdBox ModuleConfig shape)', () => {
  7312. const code = `<cfcomponent output="false">\n<cfscript>\nfunction configure() {\n return settings();\n}\nfunction onLoad() {\n return 1;\n}\n</cfscript>\n</cfcomponent>\n`;
  7313. const result = extractFromSource('ModuleConfig.cfc', code);
  7314. const cls = result.nodes.find((n) => n.kind === 'class');
  7315. const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
  7316. expect(methods).toEqual(expect.arrayContaining(['configure', 'onLoad']));
  7317. expect(result.nodes.filter((n) => n.kind === 'function')).toHaveLength(0);
  7318. const configure = result.nodes.find((n) => n.kind === 'method' && n.name === 'configure');
  7319. const containsEdge = result.edges.find(
  7320. (e) => e.source === cls?.id && e.target === configure?.id && e.kind === 'contains'
  7321. );
  7322. expect(containsEdge).toBeDefined();
  7323. });
  7324. it('should keep kind function for a <cfscript> inside a cffunction body', () => {
  7325. const code = `<cfcomponent>\n<cffunction name="outer">\n<cfscript>\nfunction innerHelper() { return 1; }\n</cfscript>\n</cffunction>\n</cfcomponent>\n`;
  7326. const result = extractFromSource('Outer.cfc', code);
  7327. expect(result.nodes.find((n) => n.name === 'outer')?.kind).toBe('method');
  7328. expect(result.nodes.find((n) => n.name === 'innerHelper')?.kind).toBe('function');
  7329. });
  7330. });
  7331. });
  7332. describe('COBOL Extraction', () => {
  7333. it('should detect .cbl/.cob/.cpy as cobol (case-insensitive)', () => {
  7334. expect(detectLanguage('app/cbl/CBACT01C.cbl')).toBe('cobol');
  7335. expect(detectLanguage('app/cbl/CBSTM03A.CBL')).toBe('cobol');
  7336. expect(detectLanguage('prog.cob')).toBe('cobol');
  7337. expect(detectLanguage('app/cpy/CVACT01Y.cpy')).toBe('cobol');
  7338. expect(isSourceFile('CBACT01C.cbl')).toBe(true);
  7339. });
  7340. const FIXED = (body: string) =>
  7341. body
  7342. .split('\n')
  7343. .map((l) => (l.length > 0 ? ' ' + l : l))
  7344. .join('\n');
  7345. const PROGRAM = FIXED(`IDENTIFICATION DIVISION.
  7346. PROGRAM-ID. TESTPROG.
  7347. DATA DIVISION.
  7348. WORKING-STORAGE SECTION.
  7349. 01 WS-TOTALS.
  7350. 05 WS-COUNT PIC 9(4) VALUE ZERO.
  7351. 88 WS-DONE VALUE 'Y'.
  7352. 77 WS-FLAG PIC X.
  7353. COPY CVACT01Y.
  7354. PROCEDURE DIVISION.
  7355. MAIN-SECTION SECTION.
  7356. 0000-MAIN.
  7357. PERFORM 1000-INIT
  7358. PERFORM 2000-PROCESS THRU 2000-EXIT
  7359. CALL 'CBACT01C' USING WS-TOTALS
  7360. CALL WS-FLAG
  7361. GO TO 9999-END
  7362. .
  7363. 1000-INIT.
  7364. MOVE ZERO TO WS-COUNT.
  7365. 2000-PROCESS.
  7366. EXEC CICS LINK PROGRAM('COCOM01C') COMMAREA(WS-TOTALS)
  7367. END-EXEC.
  7368. 2000-EXIT.
  7369. EXIT.
  7370. 9999-END.
  7371. GOBACK.
  7372. `);
  7373. it('should extract the program as a module node', () => {
  7374. const result = extractFromSource('TESTPROG.cbl', PROGRAM);
  7375. const moduleNode = result.nodes.find((n) => n.kind === 'module');
  7376. expect(moduleNode).toBeDefined();
  7377. expect(moduleNode?.name).toBe('TESTPROG');
  7378. });
  7379. it('should extract sections and paragraphs as functions with reconstructed extents', () => {
  7380. const result = extractFromSource('TESTPROG.cbl', PROGRAM);
  7381. const fns = result.nodes.filter((n) => n.kind === 'function');
  7382. const names = fns.map((f) => f.name);
  7383. expect(names).toContain('MAIN-SECTION');
  7384. expect(names).toContain('0000-MAIN');
  7385. expect(names).toContain('2000-PROCESS');
  7386. // A paragraph spans from its header to the next header, not just one line.
  7387. const main = fns.find((f) => f.name === '0000-MAIN');
  7388. expect(main).toBeDefined();
  7389. expect(main!.endLine).toBeGreaterThan(main!.startLine + 3);
  7390. // Paragraphs are contained in their section (qualified name includes it).
  7391. expect(main!.qualifiedName).toContain('MAIN-SECTION');
  7392. });
  7393. it('should extract PERFORM, PERFORM THRU, GO TO, and CALL literal as calls references', () => {
  7394. const result = extractFromSource('TESTPROG.cbl', PROGRAM);
  7395. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
  7396. const targets = calls.map((c) => c.referenceName);
  7397. expect(targets).toContain('1000-INIT');
  7398. expect(targets).toContain('2000-PROCESS'); // PERFORM ... THRU start
  7399. expect(targets).toContain('2000-EXIT'); // PERFORM ... THRU end
  7400. expect(targets).toContain('9999-END'); // GO TO
  7401. expect(targets).toContain('CBACT01C'); // CALL 'literal'
  7402. expect(targets).toContain('COCOM01C'); // EXEC CICS LINK PROGRAM('...')
  7403. // Dynamic CALL through a data name is skipped — announce, don't guess.
  7404. expect(targets).not.toContain('WS-FLAG');
  7405. });
  7406. it('should extract COPY as an import node and imports reference', () => {
  7407. const result = extractFromSource('TESTPROG.cbl', PROGRAM);
  7408. const importNode = result.nodes.find((n) => n.kind === 'import');
  7409. expect(importNode).toBeDefined();
  7410. expect(importNode?.name).toBe('CVACT01Y');
  7411. const importRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'imports');
  7412. expect(importRefs.map((r) => r.referenceName)).toContain('CVACT01Y');
  7413. });
  7414. it('should extract data items as variables, fields, and 88-level constants', () => {
  7415. const result = extractFromSource('TESTPROG.cbl', PROGRAM);
  7416. const group = result.nodes.find((n) => n.name === 'WS-TOTALS');
  7417. expect(group?.kind).toBe('variable');
  7418. const nested = result.nodes.find((n) => n.name === 'WS-COUNT');
  7419. expect(nested?.kind).toBe('field');
  7420. expect(nested?.qualifiedName).toContain('WS-TOTALS');
  7421. const condition = result.nodes.find((n) => n.name === 'WS-DONE');
  7422. expect(condition?.kind).toBe('constant');
  7423. const standalone = result.nodes.find((n) => n.name === 'WS-FLAG');
  7424. expect(standalone?.kind).toBe('variable');
  7425. });
  7426. it('should extract EXEC SQL INCLUDE as an import', () => {
  7427. const code = FIXED(`IDENTIFICATION DIVISION.
  7428. PROGRAM-ID. SQLPROG.
  7429. DATA DIVISION.
  7430. WORKING-STORAGE SECTION.
  7431. EXEC SQL INCLUDE SQLCA END-EXEC.
  7432. 01 WS-X PIC X.
  7433. PROCEDURE DIVISION.
  7434. P1.
  7435. GOBACK.
  7436. `);
  7437. const result = extractFromSource('SQLPROG.cbl', code);
  7438. const importNode = result.nodes.find((n) => n.kind === 'import' && n.name === 'SQLCA');
  7439. expect(importNode).toBeDefined();
  7440. // The EXEC block must not break the rest of the file.
  7441. expect(result.nodes.find((n) => n.name === 'WS-X')).toBeDefined();
  7442. });
  7443. it('should extract a standalone data copybook (.cpy fragment)', () => {
  7444. const code = FIXED(`01 ACCOUNT-RECORD.
  7445. 05 ACCT-ID PIC 9(11).
  7446. 05 ACCT-CURR-BAL PIC S9(10)V99.
  7447. `);
  7448. const result = extractFromSource('CVACT01Y.cpy', code);
  7449. const record = result.nodes.find((n) => n.name === 'ACCOUNT-RECORD');
  7450. expect(record?.kind).toBe('variable');
  7451. const field = result.nodes.find((n) => n.name === 'ACCT-ID');
  7452. expect(field?.kind).toBe('field');
  7453. });
  7454. it('should extract a procedure copybook (.cpy fragment with paragraphs)', () => {
  7455. const code = FIXED(`EDIT-DATE.
  7456. MOVE 1 TO WS-X
  7457. PERFORM VALIDATE-YEAR.
  7458. VALIDATE-YEAR.
  7459. CONTINUE.
  7460. `);
  7461. const result = extractFromSource('CSUTLDPY.cpy', code);
  7462. const fns = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  7463. expect(fns).toContain('EDIT-DATE');
  7464. expect(fns).toContain('VALIDATE-YEAR');
  7465. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
  7466. expect(calls.map((c) => c.referenceName)).toContain('VALIDATE-YEAR');
  7467. });
  7468. it('should emit write-site references for MOVE/ADD/COMPUTE targets', () => {
  7469. const code = FIXED(`IDENTIFICATION DIVISION.
  7470. PROGRAM-ID. WRITEREF.
  7471. DATA DIVISION.
  7472. WORKING-STORAGE SECTION.
  7473. 01 WS-TOTAL PIC S9(7)V99.
  7474. 01 WS-COUNT PIC 9(4).
  7475. PROCEDURE DIVISION.
  7476. P1.
  7477. MOVE ZERO TO WS-TOTAL
  7478. ADD 1 TO WS-COUNT
  7479. COMPUTE WS-TOTAL = WS-TOTAL + 1
  7480. SUBTRACT 1 FROM WS-COUNT
  7481. MOVE 1 TO RETURN-CODE.
  7482. `);
  7483. const result = extractFromSource('WRITEREF.cbl', code);
  7484. const writes = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
  7485. const names = writes.map((w) => w.referenceName);
  7486. expect(names.filter((n) => n === 'WS-TOTAL').length).toBeGreaterThanOrEqual(2); // MOVE + COMPUTE
  7487. expect(names).toContain('WS-COUNT'); // ADD and SUBTRACT targets
  7488. // Special registers carry no declaration — never referenced.
  7489. expect(names).not.toContain('RETURN-CODE');
  7490. });
  7491. it('should emit cics-transid references for RETURN TRANSID, literal and via same-file VALUE', () => {
  7492. const code = FIXED(`IDENTIFICATION DIVISION.
  7493. PROGRAM-ID. TXPROG.
  7494. DATA DIVISION.
  7495. WORKING-STORAGE SECTION.
  7496. 01 WS-TRANID PIC X(04) VALUE 'CB00'.
  7497. PROCEDURE DIVISION.
  7498. P1.
  7499. EXEC CICS RETURN TRANSID('CC00') COMMAREA(WS-X) END-EXEC
  7500. .
  7501. P2.
  7502. EXEC CICS RETURN TRANSID(WS-TRANID) END-EXEC
  7503. .
  7504. P3.
  7505. EXEC CICS XCTL PROGRAM(WS-UNKNOWN-VAR) END-EXEC
  7506. .
  7507. `);
  7508. const result = extractFromSource('TXPROG.cbl', code);
  7509. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
  7510. const names = calls.map((c) => c.referenceName);
  7511. expect(names).toContain('cics-transid:CC00'); // literal
  7512. expect(names).toContain('cics-transid:CB00'); // dereferenced through WS-TRANID
  7513. // Un-derefable program variable: dynamic dispatch, no guessed edge.
  7514. expect(names.filter((n) => n.startsWith('cics-transid:')).length).toBe(2);
  7515. });
  7516. it('should shift free-format source so it still extracts (preParse)', () => {
  7517. const code = `IDENTIFICATION DIVISION.
  7518. PROGRAM-ID. FREEPROG.
  7519. PROCEDURE DIVISION.
  7520. DO-WORK.
  7521. DISPLAY 'HI'.
  7522. `;
  7523. const result = extractFromSource('freeprog.cbl', code);
  7524. expect(result.nodes.find((n) => n.kind === 'module')?.name).toBe('FREEPROG');
  7525. expect(result.nodes.find((n) => n.kind === 'function')?.name).toBe('DO-WORK');
  7526. });
  7527. });
  7528. // =============================================================================
  7529. // VB.NET (.vb) — vendored patched govindbanura/tree-sitter-vbnet grammar
  7530. // =============================================================================
  7531. describe('VB.NET Extraction', () => {
  7532. it('should detect .vb as vbnet', () => {
  7533. expect(detectLanguage('Service.vb')).toBe('vbnet');
  7534. expect(detectLanguage('app/Forms/MainForm.vb')).toBe('vbnet');
  7535. expect(isSourceFile('Service.vb')).toBe(true);
  7536. });
  7537. const SAMPLE = `Imports System
  7538. Imports System.Collections.Generic
  7539. Namespace Acme.Billing
  7540. Public Interface IRepository
  7541. Function GetById(ByVal id As Integer) As Invoice
  7542. End Interface
  7543. Public Enum InvoiceState
  7544. Draft = 0
  7545. Sent
  7546. Paid
  7547. End Enum
  7548. Public Structure Money
  7549. Public Amount As Decimal
  7550. End Structure
  7551. Public MustInherit Class EntityBase
  7552. Public Property Id As Integer
  7553. End Class
  7554. Public Class Invoice
  7555. Inherits EntityBase
  7556. Implements IRepository
  7557. Private ReadOnly _lines As New List(Of String)
  7558. Public Const MaxLines As Integer = 100
  7559. Public Event Paid(ByVal amount As Decimal)
  7560. Public Property State As InvoiceState
  7561. Public Sub New(ByVal id As Integer)
  7562. Me.Id = id
  7563. End Sub
  7564. Public Function GetById(ByVal id As Integer) As Invoice Implements IRepository.GetById
  7565. Return New Invoice(id)
  7566. End Function
  7567. Public Sub AddLine(ByVal description As String)
  7568. _lines.Add(description)
  7569. Validate(description)
  7570. End Sub
  7571. Private Sub Validate(ByVal text As String)
  7572. If text.Length > MaxLines Then Throw New ArgumentException("too long")
  7573. End Sub
  7574. End Class
  7575. ' lowercase keywords: VB is case-insensitive
  7576. public module Helpers
  7577. public function Twice(byval n as integer) as integer
  7578. return n * 2
  7579. end function
  7580. Public Sub Run()
  7581. Dim inv = New Invoice(1)
  7582. inv.AddLine("widget")
  7583. Dim d As New Dictionary(Of String, Integer)
  7584. Helpers.Twice(21)
  7585. End Sub
  7586. end module
  7587. End Namespace
  7588. `;
  7589. it('should extract classes, modules, interfaces, structures, and enums', () => {
  7590. const result = extractFromSource('Invoice.vb', SAMPLE);
  7591. const kinds = (kind: string) => result.nodes.filter((n) => n.kind === kind).map((n) => n.name);
  7592. expect(kinds('class')).toEqual(expect.arrayContaining(['EntityBase', 'Invoice', 'Helpers']));
  7593. expect(kinds('interface')).toContain('IRepository');
  7594. expect(kinds('struct')).toContain('Money');
  7595. expect(kinds('enum')).toContain('InvoiceState');
  7596. expect(kinds('enum_member')).toEqual(expect.arrayContaining(['Draft', 'Sent', 'Paid']));
  7597. });
  7598. it('should extract methods, constructors, properties, fields, and events (case-insensitive keywords)', () => {
  7599. const result = extractFromSource('Invoice.vb', SAMPLE);
  7600. const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
  7601. expect(methods).toEqual(expect.arrayContaining(['GetById', 'AddLine', 'Validate', 'Twice', 'Run']));
  7602. const props = result.nodes.filter((n) => n.kind === 'property').map((n) => n.name);
  7603. expect(props).toEqual(expect.arrayContaining(['Id', 'State']));
  7604. const fields = result.nodes.filter((n) => n.kind === 'field' || n.kind === 'constant').map((n) => n.name);
  7605. expect(fields).toEqual(expect.arrayContaining(['_lines', 'MaxLines']));
  7606. // Event declarations index as findable members
  7607. expect(fields).toContain('Paid');
  7608. });
  7609. it('should qualify types with their namespace', () => {
  7610. const result = extractFromSource('Invoice.vb', SAMPLE);
  7611. const invoice = result.nodes.find((n) => n.kind === 'class' && n.name === 'Invoice');
  7612. expect(invoice?.qualifiedName).toContain('Acme.Billing');
  7613. });
  7614. it('should emit Inherits as extends and Implements as implements references', () => {
  7615. const result = extractFromSource('Invoice.vb', SAMPLE);
  7616. const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
  7617. expect(extendsRefs.map((r) => r.referenceName)).toContain('EntityBase');
  7618. const implementsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'implements');
  7619. expect(implementsRefs.map((r) => r.referenceName)).toContain('IRepository');
  7620. });
  7621. it('should extract calls through both invocation and index-shaped parens', () => {
  7622. const result = extractFromSource('Invoice.vb', SAMPLE);
  7623. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7624. // `_lines.Add(description)` parses as array_access (non-empty parens) — still a call site
  7625. expect(calls).toContain('_lines.Add');
  7626. // bare call with args
  7627. expect(calls).toContain('Validate');
  7628. // qualified module call
  7629. expect(calls).toContain('Helpers.Twice');
  7630. });
  7631. it('should emit instantiates for New, with VB generic syntax stripped', () => {
  7632. const result = extractFromSource('Invoice.vb', SAMPLE);
  7633. const insts = result.unresolvedReferences.filter((r) => r.referenceKind === 'instantiates').map((r) => r.referenceName);
  7634. expect(insts).toContain('Invoice');
  7635. // `As New Dictionary(Of String, Integer)` → bare type name, not `Dictionary(Of ...)`
  7636. expect(insts.some((n) => n.includes('(') || /\bOf\b/.test(n))).toBe(false);
  7637. });
  7638. it('should extract Imports as import nodes', () => {
  7639. const result = extractFromSource('Invoice.vb', SAMPLE);
  7640. const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  7641. expect(imports).toEqual(expect.arrayContaining(['System', 'System.Collections.Generic']));
  7642. });
  7643. it('should parse a file without a trailing newline (preParse guard)', () => {
  7644. const code = 'Class Tail\n Sub Go()\n Log("x")\n End Sub\nEnd Class';
  7645. const result = extractFromSource('Tail.vb', code);
  7646. expect(result.nodes.find((n) => n.kind === 'class')?.name).toBe('Tail');
  7647. expect(result.nodes.find((n) => n.kind === 'method')?.name).toBe('Go');
  7648. });
  7649. });
  7650. describe('VB.NET Extraction — scanner-backed constructs', () => {
  7651. it('should parse XML literals as opaque literals without breaking siblings', () => {
  7652. const code = `Class Muxer
  7653. Function WriteTags() As Object
  7654. Dim xml = <Tags>
  7655. <%= From tag In Tags Select <Tag><Name><%= tag.Name %></Name></Tag> %>
  7656. </Tags>
  7657. Return xml
  7658. End Function
  7659. Sub After()
  7660. Log("still extracted")
  7661. End Sub
  7662. End Class
  7663. `;
  7664. const result = extractFromSource('Muxer.vb', code);
  7665. const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
  7666. expect(methods).toEqual(expect.arrayContaining(['WriteTags', 'After']));
  7667. });
  7668. it('should parse multi-line LINQ query clauses', () => {
  7669. const code = `Class T
  7670. Function Big() As Integer
  7671. Dim big = From l In _lines
  7672. Where l.Length > 3
  7673. Select l.Length
  7674. Return big.Sum()
  7675. End Function
  7676. End Class
  7677. `;
  7678. const result = extractFromSource('Linq.vb', code);
  7679. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7680. expect(calls).toContain('big.Sum');
  7681. expect(result.nodes.find((n) => n.kind === 'method')?.name).toBe('Big');
  7682. });
  7683. it('should extract MustOverride members without derailing following members', () => {
  7684. const code = `MustInherit Class VideoEncoder
  7685. MustOverride ReadOnly Property OutputExt As String
  7686. Public MustOverride Sub ShowConfigDialog(Optional param As Object = Nothing)
  7687. MustOverride Function GetError() As String
  7688. Sub New()
  7689. CanEdit = True
  7690. End Sub
  7691. End Class
  7692. `;
  7693. const result = extractFromSource('VideoEncoder.vb', code);
  7694. const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
  7695. expect(methods).toEqual(expect.arrayContaining(['ShowConfigDialog', 'GetError', 'New']));
  7696. const props = result.nodes.filter((n) => n.kind === 'property').map((n) => n.name);
  7697. expect(props).toContain('OutputExt');
  7698. });
  7699. it('should parse nullable declarator shorthand (Dim x? = expr)', () => {
  7700. const code = `Class T
  7701. Sub M(folderInfo As Object)
  7702. Dim SteamFolderData? = Parser.GetSteamNameAndID(folderInfo)
  7703. Use(SteamFolderData)
  7704. End Sub
  7705. End Class
  7706. `;
  7707. const result = extractFromSource('Factory.vb', code);
  7708. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7709. expect(calls).toContain('Parser.GetSteamNameAndID');
  7710. });
  7711. });
  7712. // =============================================================================
  7713. // Erlang (vendored WhatsApp/tree-sitter-erlang grammar — the ELP grammar)
  7714. // =============================================================================
  7715. describe('Erlang Extraction', () => {
  7716. describe('Language detection', () => {
  7717. it('should report Erlang as supported', () => {
  7718. expect(isLanguageSupported('erlang')).toBe(true);
  7719. expect(getSupportedLanguages()).toContain('erlang');
  7720. expect(isSourceFile('apps/app/src/foo.erl')).toBe(true);
  7721. expect(isSourceFile('include/foo.hrl')).toBe(true);
  7722. });
  7723. });
  7724. describe('Function extraction', () => {
  7725. it('should merge multi-clause functions into one node spanning all clauses', () => {
  7726. const code = `-module(m).
  7727. -export([classify/1]).
  7728. classify(X) when is_atom(X) ->
  7729. atom;
  7730. classify(X) when is_binary(X) ->
  7731. binary;
  7732. classify(_X) ->
  7733. other.
  7734. `;
  7735. const result = extractFromSource('src/m.erl', code);
  7736. const fns = result.nodes.filter((n) => n.kind === 'function' && n.name === 'classify');
  7737. expect(fns).toHaveLength(1);
  7738. expect(fns[0]!.startLine).toBe(4);
  7739. expect(fns[0]!.endLine).toBe(9);
  7740. expect(fns[0]!.language).toBe('erlang');
  7741. });
  7742. it('should qualify functions with the module namespace', () => {
  7743. const code = `-module(my_server).
  7744. -export([start/0]).
  7745. start() -> ok.
  7746. helper() -> ok.
  7747. `;
  7748. const result = extractFromSource('src/my_server.erl', code);
  7749. const ns = result.nodes.find((n) => n.kind === 'namespace');
  7750. expect(ns?.name).toBe('my_server');
  7751. const start = result.nodes.find((n) => n.kind === 'function' && n.name === 'start');
  7752. expect(start?.qualifiedName).toBe('my_server::start');
  7753. });
  7754. it('should flag exported functions and honor -compile(export_all)', () => {
  7755. const code = `-module(m).
  7756. -export([api/0]).
  7757. api() -> internal().
  7758. internal() -> ok.
  7759. `;
  7760. const result = extractFromSource('src/m.erl', code);
  7761. const api = result.nodes.find((n) => n.name === 'api');
  7762. const internal = result.nodes.find((n) => n.name === 'internal');
  7763. expect(api?.isExported).toBe(true);
  7764. expect(internal?.isExported).toBe(false);
  7765. const all = extractFromSource('src/all.erl', `-module(all).
  7766. -compile(export_all).
  7767. anything() -> ok.
  7768. `);
  7769. expect(all.nodes.find((n) => n.name === 'anything')?.isExported).toBe(true);
  7770. });
  7771. it('should use the preceding -spec as the signature and capture doc comments', () => {
  7772. const code = `-module(m).
  7773. %% Fetches a value by key.
  7774. -spec fetch(binary()) -> {ok, term()} | not_found.
  7775. fetch(Key) ->
  7776. lookup(Key).
  7777. lookup(_K) -> not_found.
  7778. `;
  7779. const result = extractFromSource('src/m.erl', code);
  7780. const fetch = result.nodes.find((n) => n.name === 'fetch');
  7781. expect(fetch?.signature).toBe('-spec fetch(binary()) -> {ok, term()} | not_found.');
  7782. expect(fetch?.docstring).toBe('Fetches a value by key.');
  7783. });
  7784. it('should fall back to the clause header as the signature', () => {
  7785. const code = `-module(m).
  7786. resize(W, H) when W > 0, H > 0 ->
  7787. {W, H}.
  7788. `;
  7789. const result = extractFromSource('src/m.erl', code);
  7790. const resize = result.nodes.find((n) => n.name === 'resize');
  7791. expect(resize?.signature).toBe('resize(W, H) when W > 0, H > 0');
  7792. });
  7793. });
  7794. describe('Record and type extraction', () => {
  7795. it('should extract records as structs with fields', () => {
  7796. const code = `-module(m).
  7797. -record(state, {
  7798. store = #{} :: map(),
  7799. counter = 0 :: non_neg_integer()
  7800. }).
  7801. `;
  7802. const result = extractFromSource('src/m.erl', code);
  7803. const rec = result.nodes.find((n) => n.kind === 'struct');
  7804. expect(rec?.name).toBe('state');
  7805. const fields = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name);
  7806. expect(fields).toContain('store');
  7807. expect(fields).toContain('counter');
  7808. });
  7809. it('should extract -type and -opaque as type aliases, without bogus type-call refs', () => {
  7810. const code = `-module(m).
  7811. -type key() :: atom() | binary().
  7812. -opaque handle() :: reference().
  7813. -spec noop(key()) -> ok.
  7814. noop(_K) -> ok.
  7815. `;
  7816. const result = extractFromSource('src/m.erl', code);
  7817. const aliases = result.nodes.filter((n) => n.kind === 'type_alias').map((n) => n.name);
  7818. expect(aliases).toContain('key');
  7819. expect(aliases).toContain('handle');
  7820. // Type-position expressions parse as `call` nodes — the spec/type subtrees
  7821. // must not leak `calls` refs to type names like atom()/binary().
  7822. const bogus = result.unresolvedReferences.filter(
  7823. (r) => r.referenceKind === 'calls' && ['atom', 'binary', 'reference', 'key'].includes(r.referenceName)
  7824. );
  7825. expect(bogus).toHaveLength(0);
  7826. });
  7827. it('should extract -define macros as constants', () => {
  7828. const code = `-module(m).
  7829. -define(TIMEOUT, 5000).
  7830. -define(WRAP(X), {ok, X}).
  7831. `;
  7832. const result = extractFromSource('src/m.erl', code);
  7833. const consts = result.nodes.filter((n) => n.kind === 'constant').map((n) => n.name);
  7834. expect(consts).toContain('TIMEOUT');
  7835. expect(consts).toContain('WRAP');
  7836. });
  7837. });
  7838. describe('Import extraction', () => {
  7839. it('should extract -include/-include_lib and -import', () => {
  7840. const code = `-module(m).
  7841. -include("records.hrl").
  7842. -include_lib("kernel/include/logger.hrl").
  7843. -import(lists, [map/2]).
  7844. `;
  7845. const result = extractFromSource('src/m.erl', code);
  7846. const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
  7847. expect(imports).toContain('records.hrl');
  7848. expect(imports).toContain('kernel/include/logger.hrl');
  7849. expect(imports).toContain('lists');
  7850. const ref = result.unresolvedReferences.find(
  7851. (r) => r.referenceKind === 'imports' && r.referenceName === 'records.hrl'
  7852. );
  7853. expect(ref).toBeDefined();
  7854. });
  7855. });
  7856. describe('Call extraction', () => {
  7857. it('should record local calls bare and remote calls module-qualified', () => {
  7858. const code = `-module(m).
  7859. -export([run/1]).
  7860. run(X) ->
  7861. Y = prepare(X),
  7862. other_mod:process(Y).
  7863. prepare(X) -> X.
  7864. `;
  7865. const result = extractFromSource('src/m.erl', code);
  7866. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7867. expect(calls).toContain('prepare');
  7868. // `mod:fn(...)` is emitted as `mod::fn` — the same shape the module
  7869. // namespace gives every function's qualifiedName, so it resolves via
  7870. // the qualified-name matcher.
  7871. expect(calls).toContain('other_mod::process');
  7872. });
  7873. it('should not emit calls for dynamic dispatch (var module / var fun)', () => {
  7874. const code = `-module(m).
  7875. -export([run/2]).
  7876. run(Mod, F) ->
  7877. Mod:handle(x),
  7878. F(y).
  7879. `;
  7880. const result = extractFromSource('src/m.erl', code);
  7881. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7882. expect(calls).not.toContain('handle');
  7883. expect(calls).not.toContain('Mod::handle');
  7884. expect(calls).not.toContain('F');
  7885. });
  7886. it('should connect gen_server self-calls to the module handlers', () => {
  7887. const code = `-module(kv_store).
  7888. -behaviour(gen_server).
  7889. -export([get/1, put/2, drop/1]).
  7890. -export([init/1, handle_call/3, handle_cast/2]).
  7891. -define(SERVER, ?MODULE).
  7892. get(Key) ->
  7893. gen_server:call(?SERVER, {get, Key}).
  7894. put(Key, Value) ->
  7895. gen_server:cast(?MODULE, {put, Key, Value}).
  7896. drop(Key) ->
  7897. gen_server:call(kv_store, {drop, Key}).
  7898. init(_) -> {ok, #{}}.
  7899. handle_call({get, K}, _From, S) -> {reply, maps:find(K, S), S};
  7900. handle_call({drop, K}, _From, S) -> {reply, ok, maps:remove(K, S)}.
  7901. handle_cast({put, K, V}, S) -> {noreply, maps:put(K, V, S)}.
  7902. `;
  7903. const result = extractFromSource('src/kv_store.erl', code);
  7904. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7905. // ?SERVER (defined as ?MODULE), ?MODULE, and the module's own atom all
  7906. // count as self — public API wrappers connect to their handlers.
  7907. expect(calls.filter((c) => c === 'kv_store::handle_call')).toHaveLength(2);
  7908. expect(calls).toContain('kv_store::handle_cast');
  7909. });
  7910. it('should connect gen_server calls to a registered-name module, directly or via an atom macro', () => {
  7911. const code = `-module(kv_client).
  7912. -export([fetch/1, evict/1]).
  7913. -define(STORE, kv_store).
  7914. fetch(Key) ->
  7915. gen_server:call(kv_store, {get, Key}).
  7916. evict(Key) ->
  7917. gen_server:cast(?STORE, {evict, Key}).
  7918. `;
  7919. const result = extractFromSource('src/kv_client.erl', code);
  7920. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7921. // OTP's {local, ?MODULE} convention names a server after its module —
  7922. // a cross-module registered name targets that module's handlers. A name
  7923. // matching no module simply never resolves downstream.
  7924. expect(calls).toContain('kv_store::handle_call');
  7925. expect(calls).toContain('kv_store::handle_cast');
  7926. });
  7927. it('should not connect gen_server calls with dynamic targets', () => {
  7928. const code = `-module(m).
  7929. -export([go/2]).
  7930. go(Pid, Msg) ->
  7931. gen_server:call(Pid, Msg),
  7932. gen_server:cast({global, some_name}, Msg),
  7933. gen_server:call({some_name, node()}, Msg).
  7934. `;
  7935. const result = extractFromSource('src/m.erl', code);
  7936. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7937. expect(calls.filter((c) => c.includes('handle_call') || c.includes('handle_cast'))).toHaveLength(0);
  7938. });
  7939. it('should lift static MFA arguments of the spawn/apply family into call refs', () => {
  7940. const code = `-module(m).
  7941. -export([boot/2]).
  7942. boot(Req, Env) ->
  7943. Pid = proc_lib:spawn_link(?MODULE, request_process, [Req, Env]),
  7944. spawn(?MODULE, monitor_loop, [Pid]),
  7945. apply(other_mod, handle, [Req]),
  7946. timer:apply_after(500, other_mod, tick, []),
  7947. Pid.
  7948. request_process(_R, _E) -> ok.
  7949. monitor_loop(_P) -> ok.
  7950. `;
  7951. const result = extractFromSource('src/m.erl', code);
  7952. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7953. expect(calls).toContain('request_process'); // ?MODULE → bare, same-file resolution
  7954. expect(calls).toContain('monitor_loop');
  7955. expect(calls).toContain('other_mod::handle');
  7956. expect(calls).toContain('other_mod::tick');
  7957. });
  7958. it('should stay silent on dynamic spawn/apply (var module, fun value, or plain fun)', () => {
  7959. const code = `-module(m).
  7960. -export([go/3]).
  7961. go(M, F, A) ->
  7962. spawn(M, F, A),
  7963. spawn(fun() -> helper() end),
  7964. apply(M, F, A).
  7965. helper() -> ok.
  7966. `;
  7967. const result = extractFromSource('src/m.erl', code);
  7968. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7969. // The fun body's call is still walked; no phantom MFA targets appear.
  7970. expect(calls).toContain('helper');
  7971. expect(calls.filter((c) => c !== 'spawn' && c !== 'apply' && c !== 'helper')).toHaveLength(0);
  7972. });
  7973. it('should treat ?MODULE:fn calls as local calls', () => {
  7974. const code = `-module(m).
  7975. -export([kick/0]).
  7976. kick() ->
  7977. ?MODULE:work().
  7978. work() -> ok.
  7979. `;
  7980. const result = extractFromSource('src/m.erl', code);
  7981. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  7982. expect(calls).toContain('work');
  7983. });
  7984. it('should capture fun name/arity values as function references', () => {
  7985. const code = `-module(m).
  7986. -export([wire/1]).
  7987. wire(Pids) ->
  7988. lists:foreach(fun notify/1, Pids),
  7989. lists:map(fun m:notify/1, Pids).
  7990. notify(_P) -> ok.
  7991. `;
  7992. const result = extractFromSource('src/m.erl', code);
  7993. const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references').map((r) => r.referenceName);
  7994. expect(refs).toContain('notify');
  7995. expect(refs).toContain('m::notify');
  7996. });
  7997. it('should reference records used in bodies and argument patterns', () => {
  7998. const code = `-module(m).
  7999. -export([mk/1, get_id/1]).
  8000. -record(req, {id, payload}).
  8001. mk(Id) -> #req{id = Id}.
  8002. get_id(#req{id = Id}) -> Id.
  8003. `;
  8004. const result = extractFromSource('src/m.erl', code);
  8005. const refs = result.unresolvedReferences.filter(
  8006. (r) => r.referenceKind === 'references' && r.referenceName === 'req'
  8007. );
  8008. expect(refs.length).toBeGreaterThanOrEqual(2);
  8009. });
  8010. it('should attribute calls from every clause of a multi-clause function', () => {
  8011. const code = `-module(m).
  8012. -export([handle/1]).
  8013. handle({a, X}) ->
  8014. first(X);
  8015. handle({b, X}) ->
  8016. second(X).
  8017. first(X) -> X.
  8018. second(X) -> X.
  8019. `;
  8020. const result = extractFromSource('src/m.erl', code);
  8021. const handle = result.nodes.find((n) => n.kind === 'function' && n.name === 'handle');
  8022. const calls = result.unresolvedReferences.filter(
  8023. (r) => r.referenceKind === 'calls' && r.fromNodeId === handle?.id
  8024. ).map((r) => r.referenceName);
  8025. expect(calls).toContain('first');
  8026. expect(calls).toContain('second');
  8027. });
  8028. });
  8029. describe('escript and app resource files', () => {
  8030. it('should extract functions and calls from an escript behind a shebang', () => {
  8031. const code = `#!/usr/bin/env escript
  8032. %%! -smp enable
  8033. main([Path]) ->
  8034. Result = analyze(Path),
  8035. io:format("~p~n", [Result]).
  8036. analyze(Path) ->
  8037. {ok, Bin} = file:read_file(Path),
  8038. byte_size(Bin).
  8039. `;
  8040. const result = extractFromSource('bin/tool.escript', code);
  8041. const fns = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  8042. expect(fns).toContain('main');
  8043. expect(fns).toContain('analyze');
  8044. const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
  8045. expect(calls).toContain('analyze');
  8046. expect(calls).toContain('io::format');
  8047. });
  8048. it('should link an app resource file to its callback module and dependency apps', () => {
  8049. const code = `{application, sample, [
  8050. {description, "Sample application"},
  8051. {vsn, "1.0.0"},
  8052. {registered, [sample_server]},
  8053. {mod, {sample_app, []}},
  8054. {applications, [kernel, stdlib, sample_core]},
  8055. {included_applications, [sample_extra]},
  8056. {env, [{limit, 100}]},
  8057. {modules, []}
  8058. ]}.
  8059. `;
  8060. const result = extractFromSource('src/sample.app.src', code);
  8061. const refs = result.unresolvedReferences.map((r) => `${r.referenceKind}:${r.referenceName}`);
  8062. // The application-callback module is the app's entry point.
  8063. expect(refs).toContain('references:sample_app');
  8064. // Dependencies resolve to umbrella siblings; kernel/stdlib just drop.
  8065. expect(refs).toContain('imports:kernel');
  8066. expect(refs).toContain('imports:sample_core');
  8067. expect(refs).toContain('imports:sample_extra');
  8068. // Registered names, env values, and the like carry no graph structure.
  8069. expect(refs.filter((r) => r.endsWith(':sample_server'))).toHaveLength(0);
  8070. expect(refs.filter((r) => r.endsWith(':limit'))).toHaveLength(0);
  8071. });
  8072. });
  8073. describe('Macro linkage', () => {
  8074. it('should attribute macro-body calls to the macro and link function-like uses into the chain', () => {
  8075. const code = `-module(m).
  8076. -export([do_thing/1]).
  8077. -define(LOG_AUDIT(Event), audit_logger:log(Event, ?MODULE)).
  8078. do_thing(X) ->
  8079. ?LOG_AUDIT({thing, X}),
  8080. ok.
  8081. `;
  8082. const result = extractFromSource('src/m.erl', code);
  8083. const macro = result.nodes.find((n) => n.kind === 'constant' && n.name === 'LOG_AUDIT');
  8084. const doThing = result.nodes.find((n) => n.kind === 'function' && n.name === 'do_thing');
  8085. const refsFrom = (id?: string) =>
  8086. result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => `${r.referenceKind}:${r.referenceName}`);
  8087. // The body's remote call belongs to the macro node — true exactly once.
  8088. expect(refsFrom(macro?.id)).toContain('calls:audit_logger::log');
  8089. // The use site joins the call chain: do_thing -calls→ LOG_AUDIT.
  8090. expect(refsFrom(doThing?.id)).toContain('calls:LOG_AUDIT');
  8091. });
  8092. it('should reference bare macro reads without polluting call chains', () => {
  8093. const code = `-module(m).
  8094. -export([wait/0]).
  8095. -define(TIMEOUT, 5000).
  8096. wait() ->
  8097. receive after ?TIMEOUT -> ok end.
  8098. `;
  8099. const result = extractFromSource('src/m.erl', code);
  8100. const refs = result.unresolvedReferences.map((r) => `${r.referenceKind}:${r.referenceName}`);
  8101. expect(refs).toContain('references:TIMEOUT');
  8102. expect(refs).not.toContain('calls:TIMEOUT');
  8103. });
  8104. it('should skip compiler-predefined macros and keep walking macro-use arguments', () => {
  8105. const code = `-module(m).
  8106. -export([check/0]).
  8107. check() ->
  8108. ?assertEqual(ok, prepare()),
  8109. {?MODULE, ?LINE, ?FUNCTION_NAME}.
  8110. prepare() -> ok.
  8111. `;
  8112. const result = extractFromSource('src/m.erl', code);
  8113. const refs = result.unresolvedReferences.map((r) => r.referenceName);
  8114. // The nested call inside the macro's arguments still attributes to check/0.
  8115. expect(refs).toContain('prepare');
  8116. // ?assertEqual (an OTP header macro) is emitted and simply never resolves…
  8117. expect(refs).toContain('assertEqual');
  8118. // …but predefined macros have no definition to link.
  8119. expect(refs).not.toContain('MODULE');
  8120. expect(refs).not.toContain('LINE');
  8121. expect(refs).not.toContain('FUNCTION_NAME');
  8122. });
  8123. it('should chain macro-to-macro uses', () => {
  8124. const code = `-module(m).
  8125. -define(TARGET, target_fn()).
  8126. -define(ALIAS, ?TARGET).
  8127. `;
  8128. const result = extractFromSource('src/m.erl', code);
  8129. const target = result.nodes.find((n) => n.kind === 'constant' && n.name === 'TARGET');
  8130. const alias = result.nodes.find((n) => n.kind === 'constant' && n.name === 'ALIAS');
  8131. const refsFrom = (id?: string) =>
  8132. result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => `${r.referenceKind}:${r.referenceName}`);
  8133. expect(refsFrom(target?.id)).toContain('calls:target_fn');
  8134. expect(refsFrom(alias?.id)).toContain('references:TARGET');
  8135. });
  8136. });
  8137. describe('Behaviour extraction', () => {
  8138. it('should emit an implements reference for -behaviour', () => {
  8139. const code = `-module(m).
  8140. -behaviour(gen_server).
  8141. init(_) -> {ok, #{}}.
  8142. `;
  8143. const result = extractFromSource('src/m.erl', code);
  8144. const impl = result.unresolvedReferences.find((r) => r.referenceKind === 'implements');
  8145. expect(impl?.referenceName).toBe('gen_server');
  8146. });
  8147. it('should not create symbols from -callback declarations', () => {
  8148. const code = `-module(b).
  8149. -callback handle_thing(term()) -> ok.
  8150. -callback init(list()) -> {ok, term()}.
  8151. `;
  8152. const result = extractFromSource('src/b.erl', code);
  8153. const fns = result.nodes.filter((n) => n.kind === 'function');
  8154. expect(fns).toHaveLength(0);
  8155. });
  8156. });
  8157. });