From c5514d82fd0bd4ee7c59ba97c1f63fd1dc979d54 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 23 Oct 2026 13:52:43 +0800 Subject: [PATCH 01/19] chore(store): move the extraction version to 23 v0.56 changes graph meaning (framework resolvers, synthesis, VB.NET, grammar bumps), so the extraction version moves once, first, to 23. A v0.55 index (schema 10, extraction version 22) is Outdated and upgrades through the existing full migration; v0.55 reports a version-23 index as Future and writes nothing. - upgrade_from_v055 covers the paths upgrade_from_v054 covers (CLI sync, catch-up, the cold MCP session's daemon upgrade, an older uninit stays Uninitialized, a failed upgrade still refuses, interrupted builds, tombstones and missing databases are skipped) over a fixture the official v0.55.2 linux-musl binary wrote; checksum, attestation and commands are in its README. The shared helpers move to tests/upgrade_support unchanged. - upgrade_from_schema_9 downgrades a fresh init of the mini corpus to schema 9 instead of planting rows from 3878d52, so its comparison with a fresh init checks only the migration. The release fixture keeps a third test that checks the in-place migration alone. --- .../tests/fixtures/upgrade_v0_55/README.md | 20 + .../tests/fixtures/upgrade_v0_55/codegraph.db | Bin 0 -> 180224 bytes .../tests/upgrade_from_schema_9.rs | 125 ++++- .../codegraph-cli/tests/upgrade_from_v054.rs | 469 +---------------- .../codegraph-cli/tests/upgrade_from_v055.rs | 254 ++++++++++ .../tests/upgrade_support/mod.rs | 472 ++++++++++++++++++ crates/codegraph-store/src/index_state.rs | 2 +- crates/codegraph-store/tests/index_state.rs | 2 +- 8 files changed, 865 insertions(+), 479 deletions(-) create mode 100644 crates/codegraph-cli/tests/fixtures/upgrade_v0_55/README.md create mode 100644 crates/codegraph-cli/tests/fixtures/upgrade_v0_55/codegraph.db create mode 100644 crates/codegraph-cli/tests/upgrade_from_v055.rs create mode 100644 crates/codegraph-cli/tests/upgrade_support/mod.rs diff --git a/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/README.md b/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/README.md new file mode 100644 index 00000000..730ae02c --- /dev/null +++ b/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/README.md @@ -0,0 +1,20 @@ +# Index written by codegraph v0.55.2 + +`codegraph.db` is the database the released v0.55.2 binary (schema 10, extraction +version 22) wrote for `crates/codegraph-bench/fixtures/mini/`. The upgrade tests +open it with the current binary to prove that an index from the previous release +migrates: the outdated extraction version triggers one full rebuild. + +Provenance: + +- Binary: `codegraph 0.55.2` from `codegraph-0.55.2-x86_64-unknown-linux-musl.tar.gz` + (checked against the release's `SHA256SUMS` and `gh attestation verify`), sha256 + `6c8f0093905b08d17848dfcc05b6d04f88256bca88a89186a71c052b6165d57c`. +- Command: `CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 codegraph init `, then `.codegraph/codegraph.db` copied + here. +- `codegraph.db` sha256: + `12cfc45a0c157873f75567fcd85d8ef0522eeead31bdfe2043513d9cecf16a0b`. + +Do not regenerate it with a newer binary: its value is that an old release +wrote it. diff --git a/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/codegraph.db b/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/codegraph.db new file mode 100644 index 0000000000000000000000000000000000000000..5131f3ea45eb9b14247df975f2abe0e7168f323a GIT binary patch literal 180224 zcmeI53zQ?*S)i*_rAO&?r@N;|9=FF+-Q!8O+>eo3tyj+u&OF-Vw#PkAPj|+XiLI2R zQnx&6N!DY=W5@Qkoj@S4;cUpkIfrFq;3VwXz;QMqEN}>fkP|iuEU>U-*+VuELda%; z1L3i};{ETfceOOq*beK*-}XqlumAq{yN|m6y;Zj^yyaZAqso;=Ye(tGDQ+L<@o;aJ zWsc(n_%{dtjK3N9A?l7h;hWcg8{fLq@g;BzANahNMkfDA17^uT2A>a}5BzTM4&OI? zKkB>S{f75_-kkVbVq5sQaMtrV&nEwM?rRPiFW<5I^tgZGP%QB5W~Wu%+EQEfMp2!pi!Fe11&}kWa0gU0n;x@TZ5Yu5Fyx z6ID9xnO5UkwLB-&e|2R?os%E!Dz$2*s+J3yyWA+XL5=#>oZPN%)s;@SrN)`Sx1Kz= zxpG0CSzEcdpo^hDsVMrbC8xjKBB`HEEOlC<=?gGT)9yltB>2V*FE<4%TEuVo~f1r_r?mXA4ksZ$K_M!&Yy}uHR_K|PxHIS zT{%|Ek6GG-W2|3J5H>y2R<|hZj@sIplbe8L&Yqh+IPNO(BP0G;Jl>MD0!rUWzDOT6ctUL(aX>T;MPtLHUGgSY0T#fo;sTjW-afz=~ zYiglZt!u4sUmxjaEK4}^YAT&==&v26)nP*7n-O0u8RH*y2ZkovN3I#m5^Q?Kw?qEe3E(2RL_6!6$&d7r?~H0?;`EQN27R%3 zjEB+HQNOf$nD}rX@ic-Zl-1^{)KkDF6n zyHUFe!*WZlv|Y}iE?4V1u0Yx7Hob-<`Q+xt`PDT@fB(wbhSm=%t;P;(fFsxcmJ2 z%IeuQdS#Jk?C9e1`pVra>nm%g;UdYZA1(`VTFqxx&aJ=|{Pf8Sr%#?)3C5o}=kv#6 zG5%fGSY6XHwAn|%QLoHPuT9#)HQnvk7luF|+X~xCTaV)8+q>vmVd&+q5B!}*S(_HX zyvw+@IRP!!r1PTEz;oZ6=!KjynmcQC+HCp{^7X~V|1@&)TT@4M)3Qh#$alela z9URlA{b&s)j0L6JX)t$zQ7k0cm-K+wM?2Xi#$AuvRv+!s*6nio9d+RfWP|w}q>Mis z5?NioyUS#%zomA1g_zCN)r)u3jzX{DEREHb z8pTJ{QinApdmq(uv2{(&quFX)RqG1S(y}(H(;6%s-9yuV*p8uP;Sit|b65ev_`9!q zSZ%!5Huqp%QFPP>^RjA#jaPg6RX>pII?!W?b6#e8RSj)}1*<0;ZCNY#4ZEp%Veiuw z)q0s-_xl8E69cHt?oPc=6e{6{5zGme)_|O|ESuleYP(vjLMOa#^Q^WD>SN7D%ifiZ zwxbUP5JhQUua`{y_J%_-I@KFRP>Nu>D5bu`O<659U_80ekkh%|q;=GSi#^P<`wvYm z6%3<#fkxNLNiR-yYNpm;OfAb=_%XPOcWWJ)MPm(TMqSuikWbIf%87}sea0_FgaHBDhwf91jeNk&SKyB9DDW1(ZcAwBj!_v08 zqZAZkk}@LhC|A_NR!dbo@VuhK(P=bl?PDxRZ7npfH?PCZ zwsk3VJG*m#86K5t#pU_$qTjoL@PW$*V{xhu&vPeCb+j9Fms6!gvXV;W@=Izuld3GG zvqg2WvY4+_DkUYEFKN+OR%fXyYHN9!xq9p1^@;RJzFI|l0Mn_;NC;FpbWB&}v|E*O zKATGyON-fLDybIJNj0CzCKr`#x>U&(^Qp9^N}*7NS$CmeYGZixR>&iWx9JKs;lY>c zW0p#5jk>xOJa8#CF)@EoqLl@;q*qp{rnKA3Syf5p%F1Fon@i^MsY*JTFQzg|E>Wzg znMyIkV(E(LU%j>PO}|4rkR6|#XBn~PV6+OjYkt|Ch~7%iAhX(ahq~Qb?T2?q0)Y2$ z^2j8s>62zz^qQ`u6Um~I&n+z_bD4axyqHdwGO3K3N|b?js;or=8>Xegvtpgo^vM=l z+on&uRXPUGDQ_=y+Vl=??Plgc$Ro_OJ8E-Tu6K8eYD+#LC*y|WX+QLZSX@^rHeuj} zCoP(4t8;x>EoKt=WF}i&DwR`-0>#C+o%+*sJpU*SK zLYuAXRk)#i%g5@MDIh`pO3wOKN_psiiE>F@T1@216{Va{F6Gn&)c9hiw8ZLH&sG2G znX5PK-<7;j)UmN6lM>HM+(k1dqc<;?N@^0SAgLyji|KMYU&$2J3KUsUv-whDsbWM_ z%D|x9Gatb}Q&TG)gYKLR4^(fPPLuk&cwl~f;s{km8+z<*%_f*OGrdA>zP6uMHAk)r zP`rtWN!qw1ZgOw&GKtJ$UV;9S&Ezx5Vy;+*p(R^dN)?N0MoDFj;67M;;)bjxI#s7X3o>?;ryHApsNWLCJ$>uWiKR_(&JxPz9}LU#(6_~F*Of(L~x zPp#WMD^`*%!$Av80a|QhVQ1h9kl&`>faMyWgXf_27VmyxUMmc>&GmH<(IH~cnub72 zJ>)`!XH=M#7&~hzfbRc~`$dlYEU6JW`g_rCbUgC8$X3K3{$%)}(4U1K4}K%KDgBzX z6!=tN!T%gY#Xlr~1dsp{KmxA|0=pyQ5?8n!YryX0&~u?7r$HsS5wHEk6gy|NOiw|Z zT23tF=cbI)2Txs}TAr%b;p8OamBaL)MC2p3g6X0C)8Z+BL z6R4Mv?e56P-u5ympCNG#rOuxA;*)lR`#`5c>>+!58Kjd5=@qFPZ5BH9q<8=B2ziwO z@_KfoQP!bu3XtBR22A>Irb8K!#L-71wCzqjB*CCV?HOxMK6JHc!UlF?BhS%#7_z+7 zE6X7Z$~HWQ&QH1+g8b$ozrEelD6wwz++<142PH0BI7F{LJ#9gg;Gi2_=-HldP;L8R zN#ZVDW&(#BM28*z4f%nWCV!CSZ0LW^4?r&U2Qplt3~3Mh+}aN40mBY%I*oUDLB3Xf z!!^92O?9ILXGMvdxACyq*Tdy#2)b7QLAI7=6J$2BfupBh>{So#@m3}E=<$O_Y(uTH za0kS!)?QtMht`t@-jrYn(w_hS3P=8ieC2g9nZrUL0VIF~kN^@u0!RP}AOR$R1dsp{ zcmom`^9ZxN>jg$(i5Fr#dt=Htwf}|aPjTc2$zk}yKO}$zkN^@u0!RP}AOR$R1dsp{ zKmz~&2{eV!{BGo+$Lr;}fcp%J#dIc-N-ZwsDp`0oA(hCd(@J`AQOPXA!3vd9dPzwa z%cW$clq_YHN+q3B)9Ix|O3fA*v!zn1sGVWKN7=c0Pq5FAcpIM3k=@9omZNR&zoe+e zqN2iKC8f$zS;?xIOu3v~%H~rE$U0kIg6IF?1c|JYFRE}7fSO9@a@lOLR4nC^YNeF3 zp1|k+!jWN}4^28U)Slu`CUWU4WS1{4Rf>z1Ofp$YCX%^iC0|jNN>EfamnkOUAOa=5 zR9sXRvx~)Kwo)nPmU8KQu2i<=_jC6A1p55HKz^Mge?|TXzVHtTAOR$R1dsp{Kmter z2_OL^fCP{L5_q)}pvMWgmqi339N}#%7oLbPDOpb*L?Wj+KKzFqocTZ9>%Uifkx@S+ zfCP{L5=~{=x$$IT-CNxeyF-mnV;c=0_%nJ+q{hy7H9;x$w~L&5c;Y0T5`*dbLg{BI7aoMiH$dY41Tbjy=!5? zeANQWz-O}*?K$vYEU*&aOb$kwz59D>%|G@^Y3=p&$>Bg@f;NPk(ON%@-+si0INDo0 z_p`HO6L6Zx-a3P#>jzx)OZv{)iQY+#{I!!e`cwyZV)oz!9I~-DXYI8Cdgi^3W&~(@c6*ASCed#Z@cD%?9_}>oQa|Heg7mk3#|X3j zwfg})l5kMc4i?bt|0GAg4sQVb0rC^@7QnZX)8sQGN&Y9C3GfB-*vkii5|98AKmter z2_OL^fCP{L5c^g?F2{Q4Tt07bv z2_OL^fCP{L51K_90kHEQqkC7rdN7CeW;(w#m8doT zpv_|ukpL1v0!RP}AOR$R1dsp{KmthMl_jwE#DE)||HsY`P+K>1{$HuVUfp=3^Z&}~ zPNVnve-Y_J90~k6NB%hQ=j6x9bo7Pj#mHwOo8jLK-x2y`=y>pH>9?gLuPhLhh6IoR z5noFpup8=c6=nlMR=a~@VvnDBF}qy-pBKPo)7T6#PdO(5Al4M=ONP7 zPyB26<3(<_2`OuniJX$DWEZoEVph$UGmDC<=9A@eUd<*d$;C`174ZmszZVGGWF?i#<(JfSCRJHVXN&4$Wiel=R7y%RUkZahUhUi}O(DVOQCndj z)y%h}cD5Vku%RUQilBX4tySnxkH^z#g+W~r)Fn~~{s?#M9ZcU)hy#QJ^xX}7RM+;I z{gCRNlZkRZn@blxTaPa2?a zg%oEd6IoSB<;u!pI-5)8@~KKXnJ=a?N-j~XsF_MJ6QrQE_j-YLMj?WFD-e$cL*M4& z8!UH8;-D4w&Hdz07+L!udfuC;RBNha6DmcaO+(v)#s;T{N1=7YFOm0l8jV_83V_2S zN~4kRPjPQrV;Kel9JKu28Se5Q(zAPpW_Q(-NCqf0-mjY;;DWs6N;;7&D*4>fQZkpx z7t4$3WGR!%sHsF5>M~UhfI5;?ZPJ`PVjy~SEEJ8T zxg+;5b^U%0`f3k#AN~=8%$p(NTLTGQMZcz^TFfN!$xODmR4S(u$)&}zQcf)v6DcKE z&Q)?*Kd2ZmU#ol|srId$p zB2g}>ON)tIxuTTw$)%i{fVQ%jDJ}WTmf?do>C+85t6@Y>aEJaC%h2oPpfVj9wtvXT z?GcDl6%+Ijcs(sImr809>LsZrl8fnbI$y~Y)e4kcQM36{VyWV#B{l{tFO*oUsg;hG zQa7r@8>H0%ZMRk3+Gatb7&#gVk?04xTQf{8QRJY(S!x~sL8ga?m~27dV2svfzy14-!cra^lZi|svzS+)<7G4X zOtP3OmSMchmX=b*qMA`sS;1aD0aVc2n7~Tthr{uiacr`VM95!o`|o5qdpsOmAZX6r z_ZZpMA>_(rVo5C}m((mYnM8RJI(QBUr!(nNKA%r5WpX7CWGg^LcpM#tx?udNv>H3~ zCyYz_>-YkIMyKP^+8VfgFeF4n(dBpPEFXugQ&7FlCT|iHY0sy^Vf*^YoGaxxJ5*%nXNtE-;UUR zhi$(@Vc`&8YScRlWFE9TB)bD{4zQy6?GB&a;k7$NyF;)$JR#wbWM&F=Jsy4^M=p{h z(Jw}yizLV&lg~vjMNdR0$$ud4C0w){`A?CjBKJoAB>cYcXTslpqcjdIClWvcNB{{S z0VIF~kN^@u0^eZ*DX|dajnn9s2OYGj*>1~X;pp&b99CXZyd2}TBQBQ*onFW?8#^e@ zf`V}7+A^HCc1(Mx8Veb9sy^sgI+kYSC8XK+64DSSqCvXZ5k)7&*$H=n4XgGBVc{Eu zg{H*pAx$PbkMfv)oaaLGx@nfC#7kiE)|7U8dC;+%MjGiQqzMdG#cvlo;2cLo72iup z<2@wK>r`mdq6Z}|blQeQ@rI$oZQ`sgt==s4bp;Pp#UaVvW{pJ$=+NV#PjlqX$Oj{{ z(eDd=EAqu~fqWqHjqqo~v*h7OiF`5i>8Kpt4F7%j{n1ZFzx5p+0Z=>=Kmter2_OL^ zfCP{L5FAKAmE98D?=(CIohz@I%g!`|3{TCFwwGuqqugai z52e|@o6d#Uxec%b?cAbiu>clyw_fVN4cE(qv@>k$Me)*vQA>kz+i5mhop$&(ao%RC z>jtDQFO(4rU?Cb>3|v_;%)=$|@*!h|WMK2s6G`?&gWl(17`Oqu$vogi8b%twJ)?n5 zMc2;9NEq*USRV972qQ1AUB3YpVx$qB&<9n$kxXzV8`x{~f_YLtiZBET zAOR$R1dsp{KmxB70tJ5owgLI!XUF=E;lTMYy|pwH8dv=Vu()nQ{&4$Vb|WJg-Cyuu zhP_kXn5++)4x6UHz*#9Rg)}mDw|^Go2i$vsrksJ(R3>M159~qdZf1MdCygln0>d!0 zUS`ChKzR4>`;L~t>mhrZ%OFB}a~WWi*qe(u8odXqa{}Hm=-L+?nucyyjz(7evzK}Z z8d53Ebd|yj{@M8nX>aQ?cG_Lm>;wKRn6tX}?oDK~48vNvFTNNo_%DH>%NrK=00VT` z0YJCRUVKq{N%DuaonL%0aKAqb8|Hn03^$$_CgF=O`d!)%Xj#MdP?LsF@n2%qH(VpR zXA>W=dG4z6J$2Bfy0JbY~fz$b7m!3_m7yO26l8) zl;^B}mbO>-w*F08|IqN^A}x?Fad7hgUy-lAR<78nClWvcNB{{S0VIF~kN^@u0!RP} zAOR#m2?T|MeDiu|yHOXS!X)3h-c;MAR<+sjM1)Dna%%SfSLplyzDd48I=<{6k`QHBxD+l8t0VIF~kN^@u0!RP}AOR$R z1dzZRfWW*^82<2-Fj$-m<|FTej%WzY3YUjH(ggOh`P7hy5l9f%?>hS;09ExZQ?o%2 zE8cA68u8~ zNB{{S0VIF~kN^@u0!RP}AOR$R1O^cp5vFJ6zY)e%;YAeK^&EKxJ7p^L` zu1aslZz<(6EKYEbw7T^(>P~|$N<3U@bn6g(p;|As)E%|np^FdqKuo%<@a8?Cbot=U z(^?w3IB+|}qzeKEbU(Tf5L{rd7z~o@VEG?2LGMa@gDSJNEr<{+VHuY5U^*y8Dfl z);>9JQ+&|f`+N4xsfqo!R>23Q*Z1Ckb@&u}a-?SayM|A1+s|KfGtl?d? zq0_eQ_uuM%RiM8A4@!)a=Q#3DCwm00|%gB!C2v01`j~NB{{S0VIF~kiaWMKomHBoPO-5A7k`mlzxoR zkA3um(2pqnh|rHP{Rq*IApMZ&M}U6#>4%SgcbNWP&Fig1dsp{Kmter2_OL^fCP{L5xNTe;`=uL9b zPtdE@+a0CesVW_{EspjQpBJFc#wH{UyYm=6D0+3L*=TjzJ(HgB(CY4;)W}~;-m^N6 z^N{@Dgw(6GXovjinfFqg?*E6P_j2T$aN^%DM!!tnOD>V==-))&8NE09)310pM}bHH z2_OL^fCP{L5I-ed}KRD|M5fZVy*Fe>!)mOT3*cEJ4>0A~M_ z^BkQ0|5aE4_$-|D{}J*W`Cf9Jl))SSkN^@u0!RP}AOR$R1dsp{Kmter2_S*J1ojK_ zy!$qha4*jg5svV5Ltel6Ly8KM%&on+FW_|golc+A>2*3qr&Dk`JrQBjb{he`|Nn@T z6a(TT`EMgAc2^N}BpT#GyyIUczs{14&Z4}T*3L*Zul zE#ZaWXM!IJ)`Ry4?+k{dk4U?c3S#gN2_OL^fCP{L53SIHLgMITD5+qefc34_H%bk zis^$+L3--MLOQ*mN%*;vm@>lrS`VcYCfSyd1m`o@3D6ubw_%n{lrvR ztv41PX*cSc%sI=WWcWPNh9Xs}TaIW=rF2EvQk{PF=FU6=1EK1|b;ZzhZVKk{^Btwz zsvcvX3sj;bZQlrKLk(&Q?9tZYX=+S;dnwo_ZXY14$hR#L09PnsQ0E6T)(I8VG=jh^Wb?V}hP#Yaj#gKsBPx%RbcrQ;0ODNaUd z_U-LPvs$@c=xnQd%QqQeQ5v;sx!Sqzl%j(=6sAg)Dm%5_!66#l>K1#ao($3qcGR7s z+A6fWtqL@I1A#VVyf7)zC|Y-PN6||n2gEFt1Rf_)+%a@5C;e2&l|~1~#XeD{e6-a( zqFhx9^g)N7r8(jiPk=7XrWVDeu&zx-^selN?5$$iZLU8trNR&g4SQ<&@)J`vrM}gL zpl5*?@GxyE6d&$lKoWUJu4$)RO8+& zBBOMR+r1JpLW{9?1!N!1w8zQ^q2isUtg+eb-YmBSZ!DUeO5B zpx!GP60O>vD;NQqsQ1c+pVn5N6${^O!U?|LN`+SzR(RX8)t&_kkxI5lu&pczw6{1R zdTtZ$lKL(K(EWceQR(ylzd}9=Pyefd-wod3`-bmFeHXmn@V?KR6Msu=3m+HGdOqjb z2;m0Gn@)mJI! zb#6tTb_Y+-T#+AGU%mh2`X%|kl}mH-)wq0db>p7%n;Y`_`HQP(nq&Uhv19x$apgvJ ztUhkbUiaAFFF!qedZtBYG)T*Uqdw2;FIKG_z06u%t6o|9D)D`eUgWzZ-Fhr%y@Ioj0xZ?dv1mjAaRD zUQIZn2l{JA+xe)g@y&=YmW=U_x^gj=i&+s!pG=HMRvv~={K2q~YTtE zu`I!+SA09i^q5vU14+7syJFbOnkVHcpAYH z%4%~}>M809h-RWfeMHd+mMH3qKU)p>W3#jT_eiYQbnA|JqRSc7#^m^4fX$w|VZI77m-aYD=xd?g~S2!x*e& zJz~(7ZR+C`Uux9)g($H92IG#}W@^pJ^Yik(7vNH-v|7q_*3Zob&o>9t8axTd@P z`oa+CV_RWcY3os(e0vvND-6Bd^??t2o7%KM>GX-gIuOho`bMA-;1b@gTM~?VNoe`2 ztyZJezjn9Oy4r&Jx2~>6(aZ`?1mkhPj}9Fi)2IDtt=drwO1INs?gFD&NU|^K0k4mC zvP+D+9<{AL+NG`A<@7u1!WGB{^EpTve>Nntx_oz+$yCc*YMXud9O;?YP4~=pquVN} z&Ni!ogOPqq?eq#Uo2#oA!&Au$y@sKrsI9s~%Px@3qZ6SXUGswZXis+F;|=o_^I2WWPbs zd70@|HM9*Dte$MNWv$#d?55_0y-!zE>+H6jetlV+7(i`ycj`BgaKi}Z1WRi`&RLet z?`pMOEmoluUblHx+i?34Jz>t)m5sKe4+RiKX&R3p zv$y0Kbzy5kK0P}tYsUoW5$EJeqXoaUTRG-g{Ayjl?rP>&w@ELBJ%?Ku1BDxnfvmk3 zlI+V1=4ybNth-Y@n{n(uaferoofu<76rB&T9|fojxUZuP)zXFmH6bSsi()J_#>P<^ zOB+|s5gH=w7sOePv`cU|h+-^_v=^s6;{5!Wra^y`E-5F+crnfqDb-c$1s?jOh&_p!+PBKyK02>m734E;pt9w=Xv z783qGrhN_fdDpBjc45BXwFHJgI$9Qtv9yqM`^4{h(~SgJQ1u(sl5U?nq<_+?=b6zv z*o^quhuC$jU&XM>n$kx%%NXfZhjc2!{AXY$vLau+XJt*Mw@?+XLYpnm7=?<<8)k@p z&!8DLCr{t8&;m2G4V^Wia2h1_}b#*(~Q}$To057dmxa6E3e_FfV17k1*Tp zx(kVQSz_gz?1RIKW}&eB;=92qAU@0Q=1uXoskJuMgJHquM{jeK_he>^U?!p&+Wcuk z$K-Ji`o4XZ9ltiI=`z++7vnS6@OTQV4^0HJ5b^xEgj^Y$C%$*z?> zitZ}x!2=pb8Yo4n=5buHkL)4abenEjzZe( z5Z1HUTy{>T6RtUV^=>+uoS~CZn58D;rVq?cm`A*DE93aI$x+Kf6H}m4n8z)9in=GB zrHfsMc;R5r3^aYM3}SsUu*I`7FbYf6i?80|S~t)%GgUv(M>36IiDaUlJsD%`)8Bo` zwmuD0sV=*a>T+Cb$6bBxc=LfXbY&STghd{>c5GgoVYGVr%gEDZwFYyn={foQxiiLs zGW|){IO#VXJhWfVskP>3&PO$;x!@N+&ZefXrPc3e_8-vp7@l_4p|*(ByAI2G9qMZf zue&;gSp~=OKe=b?1-C_B(zeLk=6$iFNBQfHg%2>NmW+iB`g_)BYHUT`9x~G@*5+Ue zI#*X8ovYV6bM-QOw3VG^O^x3-$7El2%TC%PB=77eJ88F&#&F? z8HZSRsBYq4Q8ioCzFVRh-7xd*_FP}Np|N#6?vI^@-JEgr3glRvaXK6PvcbW~LfeSzCXu z>WHIu-JyB~n~j#OkJ$U3eW9GqO7%H2w0rkbd4ycx#$i*RTn-;2Gr{MB=W+j^ZjR&r zKfNUa_y29H*0}$#-L~RfnbxlJxc{%&ZiZ3MT)6*_`~UPtF`RA5)42b?ZZ5KGL#AzI z(cDgR?pQJtr1yrDCW4MKxc|>4DQto7hL%sSJ9D%}Lv8--WUsAP_6lKZggx%SF}8QC z-AZ;V4etNz_dMBVQd;Zn2NL)H;TACsk-6xqH7Yjq!u|i(-2T62^E>zLUEBDA)8co= m)yFmcp%wFLv8PAD*?~2Bqj}#O#=x>=FPMb0X;_O2#{VBgb=a5y literal 0 HcmV?d00001 diff --git a/crates/codegraph-cli/tests/upgrade_from_schema_9.rs b/crates/codegraph-cli/tests/upgrade_from_schema_9.rs index 12705b00..b5b5e500 100644 --- a/crates/codegraph-cli/tests/upgrade_from_schema_9.rs +++ b/crates/codegraph-cli/tests/upgrade_from_schema_9.rs @@ -1,15 +1,21 @@ //! Migrating a current index one schema behind (W5-09e). //! +//! The first two tests index the mini corpus with this binary and take the +//! database back to schema 9, so its rows are this binary's graph and only the +//! schema is behind. Each plants it as a `Current` index, edits a file other +//! files depend on, and syncs it through one of the two incremental paths: +//! `codegraph sync` from the shell, and the watcher's sync of the changed +//! paths. Both must migrate the schema to the current one in place, without a +//! rebuild, leave the stored-references marker unwritten (the migration creates +//! `resolved_refs` empty, so the dependents are extracted), and leave a +//! canonical graph equal to a fresh `init` of the edited tree. A full `index` +//! afterwards writes the marker. +//! //! `tests/fixtures/upgrade_schema_9/codegraph.db` is the schema-9 database a -//! release build of 3878d52 (extraction version 22, the current one) wrote for -//! the mini corpus. Each test plants it in a fresh project namespace as a -//! `Current` index, edits a file other files depend on, and syncs it through -//! one of the two incremental paths: `codegraph sync` from the shell, and the -//! watcher's sync of the changed paths. Both must migrate the schema to the -//! current one in place, without a rebuild, leave the stored-references marker -//! unwritten (the migration creates `resolved_refs` empty, so the dependents -//! are extracted), and leave a canonical graph equal to a fresh `init` of the -//! edited tree. A full `index` afterwards writes the marker. +//! release build of 3878d52 (extraction version 22) wrote for the mini corpus. +//! The third test migrates that database's own DDL in place. Its rows are that +//! build's graph, which a later extraction version may change, so it compares +//! the schema only. use std::fs; use std::path::{Path, PathBuf}; @@ -19,7 +25,8 @@ use codegraph_bench::oracle::{canonicalize_db, diff_canonical}; use codegraph_core::IndexPaths; use codegraph_store::migrations::CURRENT_SCHEMA_VERSION; use codegraph_store::{ - CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, ExtractionStatus, Store, checksum_hex, + CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, EXTRACTION_VERSION_KEY, ExtractionStatus, + Store, checksum_hex, }; /// The edit: one line above everything `src/app.ts` imports from it. @@ -63,21 +70,96 @@ fn watcher_sync_migrates_a_schema_9_index_in_place() { assert_full_index_writes_the_marker(&project); } -/// Copy the mini corpus into `dir`, create a namespace with the current binary, -/// then replace its database with the schema-9 one and publish it as a current -/// index of this extraction version. +/// The release's schema-9 database migrates in place too. Only the schema and +/// the marker are compared: the rows are the 3878d52 build's graph. +#[test] +fn the_release_schema_9_database_migrates_in_place() { + let dir = TestDir::new("release"); + let project = plant_release_schema_9_index(&dir); + fs::write(project.join("src/math.ts"), MATH_EDITED).unwrap(); + + let paths = index_paths(&project); + let db = paths.current_db(); + let outcome = codegraph_watch::sync_changed_paths(&project, &db, ["src/math.ts"]) + .expect("watcher sync over the release's schema-9 index"); + assert_eq!(outcome.files_reindexed, 1, "an incremental sync"); + + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Current, + "the migrated namespace must stay current" + ); + let conn = open_immutable(&db); + assert_eq!( + schema_version(&conn), + CURRENT_SCHEMA_VERSION, + "schema must be current" + ); + assert!(has_table(&conn, "resolved_refs"), "migration 10 ran"); + assert_eq!( + marker(&conn), + None, + "a migrated index is not trusted for stored references" + ); +} + +/// Copy the mini corpus into `dir`, index it with the current binary, then take +/// the database back to schema 9 as a schema-9 build left it: no +/// `resolved_refs`, no stored-references marker, version 9 recorded. Its rows +/// stay this binary's graph, so a comparison with a fresh `init` checks the +/// migration alone. fn plant_schema_9_index(dir: &TestDir) -> PathBuf { let project = dir.path().join("project"); copy_tree(&mini_fixture(), &project); let init = cli(&["init", path_str(&project)]); assert!(init.ok, "init failed: {}", init.stderr); + let paths = index_paths(&project); + let db = paths.current_db(); + rewrite_database( + &db, + &format!( + "DROP TABLE resolved_refs; + DELETE FROM project_metadata WHERE key = '{marker}'; + DELETE FROM schema_versions WHERE version = {current}; + INSERT INTO schema_versions (version, applied_at, description) VALUES (9, 0, 'v9');", + marker = codegraph_watch::resolved_refs::VERSION_KEY, + current = CURRENT_SCHEMA_VERSION, + ), + ); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Current, + "the planted namespace must classify as current" + ); + let conn = open_immutable(&db); + assert_eq!(schema_version(&conn), 9, "the planted index is at schema 9"); + assert!(!has_table(&conn, "resolved_refs")); + project +} + +/// Copy the mini corpus into `dir`, create a namespace with the current binary, +/// then replace its database with the release's schema-9 one and publish it as +/// a current index of this extraction version. That build stamped extraction +/// version 22, so the planted copy's stamp is rewritten to this one. +fn plant_release_schema_9_index(dir: &TestDir) -> PathBuf { + let project = dir.path().join("project"); + copy_tree(&mini_fixture(), &project); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init failed: {}", init.stderr); + let paths = index_paths(&project); let db = paths.current_db(); for sidecar in ["-wal", "-shm"] { let _ = fs::remove_file(PathBuf::from(format!("{}{sidecar}", db.display()))); } fs::copy(schema_9_database(), &db).expect("plant the schema-9 database"); + rewrite_database( + &db, + &format!( + "UPDATE project_metadata SET value = '{CURRENT_EXTRACTION_VERSION}' WHERE key = '{EXTRACTION_VERSION_KEY}';" + ), + ); stage_state_slot( &paths, 2, @@ -155,6 +237,23 @@ fn assert_full_index_writes_the_marker(project: &Path) { ); } +/// Run `sql` on `db` and leave it without `-wal`/`-shm`, as a closed index is. +fn rewrite_database(db: &Path, sql: &str) { + let conn = rusqlite::Connection::open(db).expect("open the planted index"); + conn.execute_batch(sql).expect("rewrite the planted index"); + conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);") + .expect("checkpoint the planted index"); + drop(conn); + for sidecar in ["-wal", "-shm"] { + let path = PathBuf::from(format!("{}{sidecar}", db.display())); + assert!( + !path.exists(), + "closing the planted index left {}", + path.display() + ); + } +} + /// The stored-references marker (`codegraph_watch::resolved_refs::VERSION_KEY`). fn marker(conn: &rusqlite::Connection) -> Option { use rusqlite::OptionalExtension; diff --git a/crates/codegraph-cli/tests/upgrade_from_v054.rs b/crates/codegraph-cli/tests/upgrade_from_v054.rs index 39186e7b..c67828ac 100644 --- a/crates/codegraph-cli/tests/upgrade_from_v054.rs +++ b/crates/codegraph-cli/tests/upgrade_from_v054.rs @@ -11,16 +11,13 @@ //! outdated, rebuild it from source, and leave an index at the current schema //! whose canonical graph equals a fresh `init` of the same tree. +mod upgrade_support; + use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; -use codegraph_bench::oracle::{canonicalize_db, diff_canonical}; -use codegraph_core::IndexPaths; -use codegraph_store::migrations::CURRENT_SCHEMA_VERSION; -use codegraph_store::{ - CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, ExtractionStatus, Store, checksum_hex, -}; +use codegraph_store::{CURRENT_STORAGE_PROTOCOL, ExtractionStatus, Store}; +use upgrade_support::*; /// Extraction version the v0.54.0 release published. const V0_54_EXTRACTION_VERSION: u64 = 21; @@ -245,468 +242,12 @@ fn the_daemon_upgrade_skips_an_older_index_without_its_database() { assert_upgrade_skipped_untouched(&project, "database is missing"); } -fn assert_upgrade_skipped_untouched(project: &Path, reason: &str) { - let paths = index_paths(project); - let status = Store::extraction_status(&paths); - assert!( - matches!(status, ExtractionStatus::Outdated { .. }), - "each case starts outdated: {status:?}" - ); - let database = fs::read(paths.current_db()).ok(); - let tombstone = fs::read(paths.tombstone()).ok(); - let slots = state_slot_bytes(&paths); - match codegraph_watch::upgrade_outdated_index_once(project).expect("daemon upgrade entry") { - codegraph_watch::OutdatedUpgrade::Skipped(text) => { - assert!(text.contains(reason), "skipped for another reason: {text}"); - } - other => panic!("expected a skip ({reason}), got {other:?}"), - } - assert_eq!(Store::extraction_status(&paths), status); - assert_eq!(fs::read(paths.current_db()).ok(), database); - assert_eq!(fs::read(paths.tombstone()).ok(), tombstone); - assert_eq!(state_slot_bytes(&paths), slots); -} - -/// Both state slots, byte for byte (`None` for an absent slot). -fn state_slot_bytes(paths: &IndexPaths) -> [Option>; 2] { - paths.state_slots().map(|slot| fs::read(slot).ok()) -} - -/// A cold `serve --mcp --path ` over line-delimited JSON-RPC, the -/// harness of `cold_start_live_sync.rs` reduced to what the upgrade case needs. -#[cfg(unix)] -mod cold { - use std::fs; - use std::io::{BufRead, BufReader, Write}; - use std::path::{Path, PathBuf}; - use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; - use std::time::{Duration, Instant}; - - use serde_json::{Value, json}; - - /// Upper bound for the daemon to upgrade the mini corpus and publish. - pub const UPGRADED_WITHIN: Duration = Duration::from_secs(60); - /// Upper bound for an edit to reach `codegraph_search` once it serves. - const EDIT_VISIBLE_WITHIN: Duration = Duration::from_secs(10); - - pub struct Session { - child: Child, - stdin: Option, - stdout: BufReader, - stderr_log: PathBuf, - next_id: i64, - } - - impl Session { - pub fn start(project: &Path, scratch: &Path) -> Self { - let stderr_log = scratch.join("serve.stderr.log"); - let mut child = Command::new(env!("CARGO_BIN_EXE_codegraph")) - .args(["serve", "--mcp", "--path"]) - .arg(project) - .env_remove("CODEGRAPH_NO_DAEMON") - .env_remove("CODEGRAPH_NO_WATCH") - .env_remove("CODEGRAPH_DIR") - .env("CODEGRAPH_MCP_REGISTRY_DIR", scratch.join("mcp-registry")) - .env("CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS", "1000") - .env("CODEGRAPH_DAEMON_RETRY_MS", "200") - .env("CODEGRAPH_DAEMON_RETRY_MAX_MS", "1000") - .env("CODEGRAPH_WATCH_DEBOUNCE_MS", "100") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(fs::File::create(&stderr_log).expect("create serve stderr log")) - .spawn() - .expect("spawn cold serve --mcp"); - let stdin = child.stdin.take().expect("serve stdin"); - let stdout = BufReader::new(child.stdout.take().expect("serve stdout")); - let mut session = Self { - child, - stdin: Some(stdin), - stdout, - stderr_log, - next_id: 1, - }; - let id = session.next_id(); - session.write(json!({ - "jsonrpc": "2.0", - "id": id, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": { "name": "upgrade-from-v054", "version": "0" } - } - })); - let response = session.read_id(id); - assert_eq!( - response["result"]["serverInfo"]["name"], "codegraph", - "{response}" - ); - session.write(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })); - session - } - - fn next_id(&mut self) -> i64 { - let id = self.next_id; - self.next_id += 1; - id - } - - fn write(&mut self, value: Value) { - let stdin = self.stdin.as_mut().expect("serve stdin stays open"); - writeln!(stdin, "{value}").unwrap(); - stdin.flush().unwrap(); - } - - fn read_id(&mut self, want: i64) -> Value { - loop { - let mut line = String::new(); - let read = self.stdout.read_line(&mut line).expect("read serve stdout"); - assert!( - read > 0, - "serve closed stdout before response {want}; stderr:\n{}", - self.stderr() - ); - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let value: Value = serde_json::from_str(trimmed).unwrap_or_else(|error| { - panic!("non-JSON line on serve stdout ({error}): {trimmed:?}") - }); - if value.get("id").and_then(Value::as_i64) == Some(want) { - return value; - } - } - } - - fn search(&mut self, query: &str) -> String { - let id = self.next_id(); - self.write(json!({ - "jsonrpc": "2.0", - "id": id, - "method": "tools/call", - "params": { "name": "codegraph_search", "arguments": { "query": query } } - })); - let response = self.read_id(id); - response["result"]["content"][0]["text"] - .as_str() - .unwrap_or("") - .to_string() - } - - pub fn wait_for_symbol(&mut self, symbol: &str, file: &str) { - let deadline = Instant::now() + EDIT_VISIBLE_WITHIN; - loop { - let text = self.search(symbol); - if text.contains(symbol) && text.contains(file) { - return; - } - assert!( - Instant::now() < deadline, - "`{symbol}` was not searchable within {EDIT_VISIBLE_WITHIN:?}; last result:\n{text}\nstderr:\n{}", - self.stderr() - ); - std::thread::sleep(Duration::from_millis(100)); - } - } - - pub fn stderr(&self) -> String { - fs::read_to_string(&self.stderr_log).unwrap_or_default() - } - - pub fn close(mut self) { - drop(self.stdin.take()); - let deadline = Instant::now() + Duration::from_secs(10); - while Instant::now() < deadline { - if self.child.try_wait().unwrap().is_some() { - return; - } - std::thread::sleep(Duration::from_millis(20)); - } - panic!("cold serve did not exit after stdin EOF"); - } - } - - impl Drop for Session { - fn drop(&mut self) { - drop(self.stdin.take()); - if self.child.try_wait().ok().flatten().is_none() { - let _ = self.child.kill(); - let _ = self.child.wait(); - } - } - } - - /// Upper bound for a refusing daemon to write its log lines. - const REFUSED_WITHIN: Duration = Duration::from_secs(30); - - /// Whether a live daemon owns `project`. - pub fn daemon_live(project: &Path) -> bool { - codegraph_daemon::daemon_pid_path(project) - .ok() - .and_then(|path| fs::read_to_string(path).ok()) - .and_then(|raw| codegraph_daemon::decode_lock_info(&raw)) - .is_some_and(|info| info.pid > 0 && codegraph_daemon::is_process_alive(info.pid)) - } - - /// Wait until one line of the project's `daemon.log` contains both `first` - /// and `then`, in that order; returns the whole log. - pub fn wait_for_daemon_log( - paths: &codegraph_core::IndexPaths, - first: &str, - then: &str, - session: &Session, - ) -> String { - let deadline = Instant::now() + REFUSED_WITHIN; - loop { - let log = fs::read_to_string(paths.daemon_log()).unwrap_or_default(); - let shown = log.lines().any(|line| { - line.find(first) - .is_some_and(|at| line[at + first.len()..].contains(then)) - }); - if shown { - return log; - } - assert!( - Instant::now() < deadline, - "daemon.log did not show {first:?} … {then:?} within {REFUSED_WITHIN:?}:\n{log}\nsession stderr:\n{}", - session.stderr() - ); - std::thread::sleep(Duration::from_millis(50)); - } - } - - /// Wait until a live daemon owns `project`: it publishes only after the - /// upgrade and its startup gate. - pub fn wait_for_daemon(project: &Path, session: &Session) { - let deadline = Instant::now() + UPGRADED_WITHIN; - loop { - if daemon_live(project) { - return; - } - assert!( - Instant::now() < deadline, - "no live daemon within {UPGRADED_WITHIN:?}; stderr:\n{}", - session.stderr() - ); - std::thread::sleep(Duration::from_millis(25)); - } - } - - pub fn shutdown_daemon(project: &Path) { - let paths = codegraph_core::IndexPaths::resolve(project, None).unwrap(); - match codegraph_daemon::request_daemon_shutdown(project, paths.project_identity()) { - Ok(codegraph_daemon::ShutdownOutcome::NoDaemon) - | Ok(codegraph_daemon::ShutdownOutcome::Drained { .. }) => {} - other => panic!("failed to stop the daemon: {other:?}"), - } - } -} - /// Copy the mini corpus into `dir`, create a namespace with the current binary, /// then replace its database and state slot with what v0.54.0 left behind. fn plant_v0_54_index(dir: &TestDir) -> PathBuf { - let project = dir.path().join("project"); - copy_tree(&mini_fixture(), &project); - let init = cli(&["init", path_str(&project)]); - assert!(init.ok, "init failed: {}", init.stderr); - - let paths = index_paths(&project); - let db = paths.current_db(); - for sidecar in ["-wal", "-shm"] { - let _ = fs::remove_file(PathBuf::from(format!("{}{sidecar}", db.display()))); - } - fs::copy(v0_54_database(), &db).expect("plant the v0.54.0 database"); - stage_state_slot( - &paths, - 2, - CURRENT_STORAGE_PROTOCOL, - V0_54_EXTRACTION_VERSION, - "current", - ); - assert_eq!( - Store::extraction_status(&paths), - ExtractionStatus::Outdated { - built: V0_54_EXTRACTION_VERSION - }, - "the planted namespace must classify as outdated before the upgrade" - ); - project -} - -fn assert_upgraded_like_a_fresh_init(project: &Path, label: &str) { - let paths = index_paths(project); - assert_eq!( - Store::extraction_status(&paths), - ExtractionStatus::Current, - "the upgraded namespace must be current" - ); - // `immutable=1` takes no lock and leaves no `-wal`/`-shm` beside the index. - let conn = rusqlite::Connection::open_with_flags( - format!("{}?immutable=1", sqlite_file_uri(&paths.current_db())), - rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, - ) - .expect("open upgraded database"); - let schema: i64 = conn - .query_row("SELECT MAX(version) FROM schema_versions", [], |row| { - row.get(0) - }) - .expect("schema version"); - assert_eq!(schema, CURRENT_SCHEMA_VERSION, "schema must be current"); - let stamp: String = conn - .query_row( - "SELECT value FROM project_metadata WHERE key = 'indexed_with_extraction_version'", - [], - |row| row.get(0), - ) - .expect("extraction stamp"); - assert_eq!(stamp, CURRENT_EXTRACTION_VERSION.to_string()); - drop(conn); - - let peer_dir = TestDir::new(&format!("{label}-fresh")); - let peer = peer_dir.path().join("project"); - copy_tree(&mini_fixture(), &peer); - let init = cli(&["init", path_str(&peer)]); - assert!(init.ok, "fresh init failed: {}", init.stderr); - - let upgraded = canonicalize_db(&paths.current_db()).expect("canonicalize upgraded index"); - let fresh = - canonicalize_db(&index_paths(&peer).current_db()).expect("canonicalize fresh index"); - if let Err(report) = diff_canonical(&fresh, &upgraded, None) { - panic!("the upgraded index differs from a fresh init:\n{report}"); - } -} - -/// `path` as an absolute SQLite `file:` URI: a Windows verbatim prefix (`\\?\`) -/// is dropped and every byte outside the unreserved set is percent-encoded. -fn sqlite_file_uri(path: &Path) -> String { - let text = path.to_string_lossy().replace('\\', "/"); - let text = text.strip_prefix("//?/").unwrap_or(&text); - let mut uri = String::from("file://"); - if !text.starts_with('/') { - uri.push('/'); - } - for byte in text.bytes() { - if byte.is_ascii_alphanumeric() || b"/-._~:".contains(&byte) { - uri.push(char::from(byte)); - } else { - uri.push_str(&format!("%{byte:02X}")); - } - } - uri -} - -fn mini_fixture() -> PathBuf { - workspace_root().join("crates/codegraph-bench/fixtures/mini") + plant_index(dir, &v0_54_database(), V0_54_EXTRACTION_VERSION) } fn v0_54_database() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/upgrade_v0_54/codegraph.db") } - -fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("codegraph-cli is under crates/") - .to_path_buf() -} - -fn index_paths(project: &Path) -> IndexPaths { - IndexPaths::resolve(project, None).expect("resolve index paths") -} - -fn path_str(path: &Path) -> &str { - path.to_str().expect("utf-8 test path") -} - -/// Author one authoritative state slot and remove its companion, the same way -/// `batch_m_outdated_migration.rs` stages protocol records. -fn stage_state_slot( - paths: &IndexPaths, - sequence: u64, - storage_protocol: u64, - extraction_version: u64, - phase: &str, -) { - let identity = paths.project_identity(); - let checksum = checksum_hex( - sequence, - storage_protocol, - extraction_version, - phase, - identity, - ); - let body = format!( - "{{\"sequence\":{sequence},\"storageProtocol\":{storage_protocol},\ - \"extractionVersion\":{extraction_version},\"phase\":\"{phase}\",\ - \"projectIdentity\":\"{identity}\",\"checksum\":\"{checksum}\"}}\n" - ); - let [slot0, slot1] = paths.state_slots(); - fs::write(&slot0, body).expect("stage authoritative state slot"); - let _ = fs::remove_file(&slot1); -} - -struct Run { - stdout: String, - stderr: String, - ok: bool, -} - -fn cli(args: &[&str]) -> Run { - let output = Command::new(env!("CARGO_BIN_EXE_codegraph")) - .args(args) - .env("CODEGRAPH_NO_DAEMON", "1") - .env("CODEGRAPH_NO_WATCH", "1") - .env_remove("CODEGRAPH_DIR") - .output() - .expect("run codegraph binary"); - Run { - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - ok: output.status.success(), - } -} - -fn copy_tree(src: &Path, dst: &Path) { - fs::create_dir_all(dst).unwrap(); - for entry in fs::read_dir(src).unwrap() { - let entry = entry.unwrap(); - let from = entry.path(); - let to = dst.join(entry.file_name()); - if from.is_dir() { - copy_tree(&from, &to); - } else { - fs::copy(&from, &to).unwrap(); - } - } -} - -struct TestDir { - path: PathBuf, -} - -impl TestDir { - fn new(label: &str) -> Self { - let path = std::env::temp_dir().join(format!( - "codegraph-upgrade-v054-{label}-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - fs::create_dir_all(&path).unwrap(); - Self { path } - } - - fn path(&self) -> &Path { - &self.path - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } -} diff --git a/crates/codegraph-cli/tests/upgrade_from_v055.rs b/crates/codegraph-cli/tests/upgrade_from_v055.rs new file mode 100644 index 00000000..a08492b0 --- /dev/null +++ b/crates/codegraph-cli/tests/upgrade_from_v055.rs @@ -0,0 +1,254 @@ +//! Upgrading an index the previous release wrote. +//! +//! `tests/fixtures/upgrade_v0_55/codegraph.db` is the schema-10 database the +//! released v0.55.2 binary (extraction version 22) wrote for the mini corpus. +//! Each test plants it in a fresh project namespace, stages the state slot that +//! release publishes (`phase=current`, extraction version 22), and upgrades it +//! through the paths a user hits: `codegraph sync` from the shell, the catch-up +//! sync a daemon or MCP session runs when it first opens the project, and a cold +//! `serve --mcp` session in the default daemon mode, whose daemon must upgrade +//! the index before it starts serving. Each must classify the namespace as +//! outdated, rebuild it from source, and leave an index at the current schema +//! and extraction version whose canonical graph equals a fresh `init` of the +//! same tree. `upgrade_from_v054.rs` runs the same paths over v0.54.0's index. + +mod upgrade_support; + +use std::fs; +use std::path::{Path, PathBuf}; + +use codegraph_store::{CURRENT_STORAGE_PROTOCOL, ExtractionStatus, Store}; +use upgrade_support::*; + +/// Extraction version the v0.55.2 release published. +const V0_55_EXTRACTION_VERSION: u64 = 22; + +#[test] +fn cli_sync_upgrades_a_v0_55_index_to_the_current_schema_and_extraction() { + let dir = TestDir::new("cli"); + let project = plant_v0_55_index(&dir); + + let status = cli(&["status", path_str(&project), "--json"]); + assert!( + status.ok, + "status on a v0.55.2 index failed: {}", + status.stderr + ); + let json: serde_json::Value = serde_json::from_str(&status.stdout).expect("status JSON"); + assert_eq!( + json["extractionStatus"], "outdated", + "a v0.55.2 index must read as outdated: {}", + status.stdout + ); + + let sync = cli(&["sync", path_str(&project)]); + assert!( + sync.ok, + "sync over a v0.55.2 index failed:\nstdout:\n{}\nstderr:\n{}", + sync.stdout, sync.stderr + ); + + assert_upgraded_like_a_fresh_init(&project, "cli"); +} + +#[test] +fn catch_up_sync_upgrades_a_v0_55_index_to_the_current_schema_and_extraction() { + let dir = TestDir::new("catch-up"); + let project = plant_v0_55_index(&dir); + + // The catch-up a daemon or MCP session runs when it first opens a project. + codegraph_watch::sync_project_once(&project).expect("catch-up sync over a v0.55.2 index"); + + assert_upgraded_like_a_fresh_init(&project, "catch-up"); +} + +/// The default daemon mode: a cold `serve --mcp` session answers direct and +/// spawns the project's daemon, which used to refuse an `outdated` index at its +/// startup gate, so nothing ever upgraded it and every tool call failed until a +/// manual `codegraph sync`. The daemon must upgrade the index first, then serve +/// it and keep it live. +#[cfg(unix)] +#[test] +fn a_cold_mcp_session_upgrades_a_v0_55_index_through_its_daemon() { + let dir = TestDir::new("daemon"); + let project = plant_v0_55_index(&dir); + let paths = index_paths(&project); + let mut session = cold::Session::start(&project, dir.path()); + + let deadline = std::time::Instant::now() + cold::UPGRADED_WITHIN; + while Store::extraction_status(&paths) != ExtractionStatus::Current { + assert!( + std::time::Instant::now() < deadline, + "the v0.55.2 index was still {:?} {:?} after the cold session started;\n\ + daemon.log:\n{}\nsession stderr:\n{}", + Store::extraction_status(&paths), + cold::UPGRADED_WITHIN, + fs::read_to_string(paths.daemon_log()).unwrap_or_default(), + session.stderr() + ); + std::thread::sleep(std::time::Duration::from_millis(100)); + } + cold::wait_for_daemon(&project, &session); + assert_upgraded_like_a_fresh_init(&project, "daemon"); + + let marker = "upgradedDaemonSessionMarker"; + fs::write( + project.join("src/upgraded.ts"), + format!("export function {marker}(): number {{\n return 1;\n}}\n"), + ) + .unwrap(); + session.wait_for_symbol(marker, "src/upgraded.ts"); + session.close(); + cold::shutdown_daemon(&project); +} + +/// A daemon never undoes an older `uninit --force`. That leaves an +/// `uninitialized` slot at the older extraction version, a tombstone, and no +/// database, which the state gate classifies as `uninitialized`, like this +/// version's own uninit; the daemon must not try to upgrade it, must leave all +/// of it alone, and must refuse to start. +#[cfg(unix)] +#[test] +fn a_cold_mcp_session_leaves_an_older_uninit_alone() { + let dir = TestDir::new("daemon-uninit"); + let project = dir.path().join("project"); + copy_tree(&mini_fixture(), &project); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init failed: {}", init.stderr); + let uninit = cli(&["uninit", "--force", path_str(&project)]); + assert!(uninit.ok, "uninit failed: {}", uninit.stderr); + let paths = index_paths(&project); + stage_state_slot( + &paths, + 3, + CURRENT_STORAGE_PROTOCOL, + V0_55_EXTRACTION_VERSION, + "uninitialized", + ); + let uninitialized = ExtractionStatus::Uninitialized; + assert_eq!(Store::extraction_status(&paths), uninitialized); + assert!(paths.tombstone().is_file(), "uninit leaves its tombstone"); + assert!(!paths.current_db().exists(), "uninit removes the database"); + let slots = state_slot_bytes(&paths); + + let session = cold::Session::start(&project, dir.path()); + let log = cold::wait_for_daemon_log( + &paths, + "refusing to start a daemon for", + "state-gated Store open rejected", + &session, + ); + assert!( + !paths.current_db().exists(), + "the daemon recreated the database an uninit removed:\n{log}" + ); + assert!(paths.tombstone().is_file(), "the tombstone must stay"); + assert_eq!(Store::extraction_status(&paths), uninitialized); + assert_eq!( + state_slot_bytes(&paths), + slots, + "the state slots must not move" + ); + assert!( + !cold::daemon_live(&project), + "no daemon may serve this index" + ); + assert!( + log.contains("index state is uninitialized"), + "the daemon must refuse the uninit as uninitialized:\n{log}" + ); + assert!( + !log.contains("upgrading an index an older codegraph built"), + "the daemon must not try to upgrade an uninit:\n{log}" + ); + session.close(); +} + +/// A failed upgrade is a failed migration: the daemon stops before its startup +/// gate, and nothing changes. Here the project config cannot be parsed, so the +/// upgrade fails before it takes the index lease. +#[cfg(unix)] +#[test] +fn a_daemon_whose_upgrade_fails_still_refuses_the_outdated_index() { + let dir = TestDir::new("daemon-failed-upgrade"); + let project = plant_v0_55_index(&dir); + let paths = index_paths(&project); + fs::write(paths.config_toml(), "[indexing\n").unwrap(); + let database = fs::read(paths.current_db()).unwrap(); + let slots = state_slot_bytes(&paths); + + let session = cold::Session::start(&project, dir.path()); + let log = cold::wait_for_daemon_log( + &paths, + "refusing to start a daemon for", + "could not upgrade its outdated index", + &session, + ); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Outdated { + built: V0_55_EXTRACTION_VERSION + } + ); + assert_eq!( + fs::read(paths.current_db()).unwrap(), + database, + "a failed upgrade must leave the database as it was" + ); + assert_eq!( + state_slot_bytes(&paths), + slots, + "the state slots must not move" + ); + assert!( + !cold::daemon_live(&project), + "no daemon may serve this index" + ); + assert!( + !log.contains("state-gated Store open"), + "a failed upgrade must stop the daemon before its startup gate:\n{log}" + ); + session.close(); +} + +/// The upgrade a daemon runs rebuilds only a complete older index. Each +/// namespace below is one `codegraph sync` would still migrate. +#[test] +fn the_daemon_upgrade_skips_an_interrupted_older_build() { + let dir = TestDir::new("skip-building"); + let project = plant_v0_55_index(&dir); + stage_state_slot( + &index_paths(&project), + 3, + CURRENT_STORAGE_PROTOCOL, + V0_55_EXTRACTION_VERSION, + "building", + ); + assert_upgrade_skipped_untouched(&project, "records phase building"); +} + +#[test] +fn the_daemon_upgrade_skips_an_older_index_with_a_tombstone() { + let dir = TestDir::new("skip-tombstone"); + let project = plant_v0_55_index(&dir); + fs::write(index_paths(&project).tombstone(), b"uninitialized\n").unwrap(); + assert_upgrade_skipped_untouched(&project, "tombstone is present"); +} + +#[test] +fn the_daemon_upgrade_skips_an_older_index_without_its_database() { + let dir = TestDir::new("skip-missing-db"); + let project = plant_v0_55_index(&dir); + fs::remove_file(index_paths(&project).current_db()).unwrap(); + assert_upgrade_skipped_untouched(&project, "database is missing"); +} + +/// Copy the mini corpus into `dir`, create a namespace with the current binary, +/// then replace its database and state slot with what v0.55.2 left behind. +fn plant_v0_55_index(dir: &TestDir) -> PathBuf { + plant_index(dir, &v0_55_database(), V0_55_EXTRACTION_VERSION) +} + +fn v0_55_database() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/upgrade_v0_55/codegraph.db") +} diff --git a/crates/codegraph-cli/tests/upgrade_support/mod.rs b/crates/codegraph-cli/tests/upgrade_support/mod.rs new file mode 100644 index 00000000..801a8ac5 --- /dev/null +++ b/crates/codegraph-cli/tests/upgrade_support/mod.rs @@ -0,0 +1,472 @@ +//! Helpers the upgrade tests share: each plants an index a released binary +//! wrote, upgrades it through the paths a user hits, and compares the result +//! with a fresh `init` of the same tree. +//! +//! Not every test file uses every helper. +#![allow(dead_code)] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use codegraph_bench::oracle::{canonicalize_db, diff_canonical}; +use codegraph_core::IndexPaths; +use codegraph_store::migrations::CURRENT_SCHEMA_VERSION; +use codegraph_store::{ + CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, ExtractionStatus, Store, checksum_hex, +}; + +pub fn assert_upgrade_skipped_untouched(project: &Path, reason: &str) { + let paths = index_paths(project); + let status = Store::extraction_status(&paths); + assert!( + matches!(status, ExtractionStatus::Outdated { .. }), + "each case starts outdated: {status:?}" + ); + let database = fs::read(paths.current_db()).ok(); + let tombstone = fs::read(paths.tombstone()).ok(); + let slots = state_slot_bytes(&paths); + match codegraph_watch::upgrade_outdated_index_once(project).expect("daemon upgrade entry") { + codegraph_watch::OutdatedUpgrade::Skipped(text) => { + assert!(text.contains(reason), "skipped for another reason: {text}"); + } + other => panic!("expected a skip ({reason}), got {other:?}"), + } + assert_eq!(Store::extraction_status(&paths), status); + assert_eq!(fs::read(paths.current_db()).ok(), database); + assert_eq!(fs::read(paths.tombstone()).ok(), tombstone); + assert_eq!(state_slot_bytes(&paths), slots); +} + +/// Both state slots, byte for byte (`None` for an absent slot). +pub fn state_slot_bytes(paths: &IndexPaths) -> [Option>; 2] { + paths.state_slots().map(|slot| fs::read(slot).ok()) +} + +/// A cold `serve --mcp --path ` over line-delimited JSON-RPC, the +/// harness of `cold_start_live_sync.rs` reduced to what the upgrade case needs. +#[cfg(unix)] +pub mod cold { + use std::fs; + use std::io::{BufRead, BufReader, Write}; + use std::path::{Path, PathBuf}; + use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + use std::time::{Duration, Instant}; + + use serde_json::{Value, json}; + + /// Upper bound for the daemon to upgrade the mini corpus and publish. + pub const UPGRADED_WITHIN: Duration = Duration::from_secs(60); + /// Upper bound for an edit to reach `codegraph_search` once it serves. + const EDIT_VISIBLE_WITHIN: Duration = Duration::from_secs(10); + + pub struct Session { + child: Child, + stdin: Option, + stdout: BufReader, + stderr_log: PathBuf, + next_id: i64, + } + + impl Session { + pub fn start(project: &Path, scratch: &Path) -> Self { + let stderr_log = scratch.join("serve.stderr.log"); + let mut child = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(["serve", "--mcp", "--path"]) + .arg(project) + .env_remove("CODEGRAPH_NO_DAEMON") + .env_remove("CODEGRAPH_NO_WATCH") + .env_remove("CODEGRAPH_DIR") + .env("CODEGRAPH_MCP_REGISTRY_DIR", scratch.join("mcp-registry")) + .env("CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS", "1000") + .env("CODEGRAPH_DAEMON_RETRY_MS", "200") + .env("CODEGRAPH_DAEMON_RETRY_MAX_MS", "1000") + .env("CODEGRAPH_WATCH_DEBOUNCE_MS", "100") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(fs::File::create(&stderr_log).expect("create serve stderr log")) + .spawn() + .expect("spawn cold serve --mcp"); + let stdin = child.stdin.take().expect("serve stdin"); + let stdout = BufReader::new(child.stdout.take().expect("serve stdout")); + let mut session = Self { + child, + stdin: Some(stdin), + stdout, + stderr_log, + next_id: 1, + }; + let id = session.next_id(); + session.write(json!({ + "jsonrpc": "2.0", + "id": id, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "upgrade-from-release", "version": "0" } + } + })); + let response = session.read_id(id); + assert_eq!( + response["result"]["serverInfo"]["name"], "codegraph", + "{response}" + ); + session.write(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })); + session + } + + fn next_id(&mut self) -> i64 { + let id = self.next_id; + self.next_id += 1; + id + } + + fn write(&mut self, value: Value) { + let stdin = self.stdin.as_mut().expect("serve stdin stays open"); + writeln!(stdin, "{value}").unwrap(); + stdin.flush().unwrap(); + } + + fn read_id(&mut self, want: i64) -> Value { + loop { + let mut line = String::new(); + let read = self.stdout.read_line(&mut line).expect("read serve stdout"); + assert!( + read > 0, + "serve closed stdout before response {want}; stderr:\n{}", + self.stderr() + ); + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let value: Value = serde_json::from_str(trimmed).unwrap_or_else(|error| { + panic!("non-JSON line on serve stdout ({error}): {trimmed:?}") + }); + if value.get("id").and_then(Value::as_i64) == Some(want) { + return value; + } + } + } + + pub fn search(&mut self, query: &str) -> String { + let id = self.next_id(); + self.write(json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": "codegraph_search", "arguments": { "query": query } } + })); + let response = self.read_id(id); + response["result"]["content"][0]["text"] + .as_str() + .unwrap_or("") + .to_string() + } + + pub fn wait_for_symbol(&mut self, symbol: &str, file: &str) { + let deadline = Instant::now() + EDIT_VISIBLE_WITHIN; + loop { + let text = self.search(symbol); + if text.contains(symbol) && text.contains(file) { + return; + } + assert!( + Instant::now() < deadline, + "`{symbol}` was not searchable within {EDIT_VISIBLE_WITHIN:?}; last result:\n{text}\nstderr:\n{}", + self.stderr() + ); + std::thread::sleep(Duration::from_millis(100)); + } + } + + pub fn stderr(&self) -> String { + fs::read_to_string(&self.stderr_log).unwrap_or_default() + } + + pub fn close(mut self) { + drop(self.stdin.take()); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if self.child.try_wait().unwrap().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + panic!("cold serve did not exit after stdin EOF"); + } + } + + impl Drop for Session { + fn drop(&mut self) { + drop(self.stdin.take()); + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } + } + + /// Upper bound for a refusing daemon to write its log lines. + const REFUSED_WITHIN: Duration = Duration::from_secs(30); + + /// Whether a live daemon owns `project`. + pub fn daemon_live(project: &Path) -> bool { + codegraph_daemon::daemon_pid_path(project) + .ok() + .and_then(|path| fs::read_to_string(path).ok()) + .and_then(|raw| codegraph_daemon::decode_lock_info(&raw)) + .is_some_and(|info| info.pid > 0 && codegraph_daemon::is_process_alive(info.pid)) + } + + /// Wait until one line of the project's `daemon.log` contains both `first` + /// and `then`, in that order; returns the whole log. + pub fn wait_for_daemon_log( + paths: &codegraph_core::IndexPaths, + first: &str, + then: &str, + session: &Session, + ) -> String { + let deadline = Instant::now() + REFUSED_WITHIN; + loop { + let log = fs::read_to_string(paths.daemon_log()).unwrap_or_default(); + let shown = log.lines().any(|line| { + line.find(first) + .is_some_and(|at| line[at + first.len()..].contains(then)) + }); + if shown { + return log; + } + assert!( + Instant::now() < deadline, + "daemon.log did not show {first:?} … {then:?} within {REFUSED_WITHIN:?}:\n{log}\nsession stderr:\n{}", + session.stderr() + ); + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// Wait until a live daemon owns `project`: it publishes only after the + /// upgrade and its startup gate. + pub fn wait_for_daemon(project: &Path, session: &Session) { + let deadline = Instant::now() + UPGRADED_WITHIN; + loop { + if daemon_live(project) { + return; + } + assert!( + Instant::now() < deadline, + "no live daemon within {UPGRADED_WITHIN:?}; stderr:\n{}", + session.stderr() + ); + std::thread::sleep(Duration::from_millis(25)); + } + } + + pub fn shutdown_daemon(project: &Path) { + let paths = codegraph_core::IndexPaths::resolve(project, None).unwrap(); + match codegraph_daemon::request_daemon_shutdown(project, paths.project_identity()) { + Ok(codegraph_daemon::ShutdownOutcome::NoDaemon) + | Ok(codegraph_daemon::ShutdownOutcome::Drained { .. }) => {} + other => panic!("failed to stop the daemon: {other:?}"), + } + } +} + +/// Copy the mini corpus into `dir`, create a namespace with the current binary, +/// then replace its database and state slot with what the release that wrote +/// `database` (at extraction version `built`) left behind. +pub fn plant_index(dir: &TestDir, database: &Path, built: u64) -> PathBuf { + let project = dir.path().join("project"); + copy_tree(&mini_fixture(), &project); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init failed: {}", init.stderr); + + let paths = index_paths(&project); + let db = paths.current_db(); + for sidecar in ["-wal", "-shm"] { + let _ = fs::remove_file(PathBuf::from(format!("{}{sidecar}", db.display()))); + } + fs::copy(database, &db).expect("plant the release's database"); + stage_state_slot(&paths, 2, CURRENT_STORAGE_PROTOCOL, built, "current"); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Outdated { built }, + "the planted namespace must classify as outdated before the upgrade" + ); + project +} + +pub fn assert_upgraded_like_a_fresh_init(project: &Path, label: &str) { + let paths = index_paths(project); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Current, + "the upgraded namespace must be current" + ); + // `immutable=1` takes no lock and leaves no `-wal`/`-shm` beside the index. + let conn = rusqlite::Connection::open_with_flags( + format!("{}?immutable=1", sqlite_file_uri(&paths.current_db())), + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .expect("open upgraded database"); + let schema: i64 = conn + .query_row("SELECT MAX(version) FROM schema_versions", [], |row| { + row.get(0) + }) + .expect("schema version"); + assert_eq!(schema, CURRENT_SCHEMA_VERSION, "schema must be current"); + let stamp: String = conn + .query_row( + "SELECT value FROM project_metadata WHERE key = 'indexed_with_extraction_version'", + [], + |row| row.get(0), + ) + .expect("extraction stamp"); + assert_eq!(stamp, CURRENT_EXTRACTION_VERSION.to_string()); + drop(conn); + + let peer_dir = TestDir::new(&format!("{label}-fresh")); + let peer = peer_dir.path().join("project"); + copy_tree(&mini_fixture(), &peer); + let init = cli(&["init", path_str(&peer)]); + assert!(init.ok, "fresh init failed: {}", init.stderr); + + let upgraded = canonicalize_db(&paths.current_db()).expect("canonicalize upgraded index"); + let fresh = + canonicalize_db(&index_paths(&peer).current_db()).expect("canonicalize fresh index"); + if let Err(report) = diff_canonical(&fresh, &upgraded, None) { + panic!("the upgraded index differs from a fresh init:\n{report}"); + } +} + +/// `path` as an absolute SQLite `file:` URI: a Windows verbatim prefix (`\\?\`) +/// is dropped and every byte outside the unreserved set is percent-encoded. +pub fn sqlite_file_uri(path: &Path) -> String { + let text = path.to_string_lossy().replace('\\', "/"); + let text = text.strip_prefix("//?/").unwrap_or(&text); + let mut uri = String::from("file://"); + if !text.starts_with('/') { + uri.push('/'); + } + for byte in text.bytes() { + if byte.is_ascii_alphanumeric() || b"/-._~:".contains(&byte) { + uri.push(char::from(byte)); + } else { + uri.push_str(&format!("%{byte:02X}")); + } + } + uri +} + +pub fn mini_fixture() -> PathBuf { + workspace_root().join("crates/codegraph-bench/fixtures/mini") +} + +pub fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("codegraph-cli is under crates/") + .to_path_buf() +} + +pub fn index_paths(project: &Path) -> IndexPaths { + IndexPaths::resolve(project, None).expect("resolve index paths") +} + +pub fn path_str(path: &Path) -> &str { + path.to_str().expect("utf-8 test path") +} + +/// Author one authoritative state slot and remove its companion, the same way +/// `batch_m_outdated_migration.rs` stages protocol records. +pub fn stage_state_slot( + paths: &IndexPaths, + sequence: u64, + storage_protocol: u64, + extraction_version: u64, + phase: &str, +) { + let identity = paths.project_identity(); + let checksum = checksum_hex( + sequence, + storage_protocol, + extraction_version, + phase, + identity, + ); + let body = format!( + "{{\"sequence\":{sequence},\"storageProtocol\":{storage_protocol},\ + \"extractionVersion\":{extraction_version},\"phase\":\"{phase}\",\ + \"projectIdentity\":\"{identity}\",\"checksum\":\"{checksum}\"}}\n" + ); + let [slot0, slot1] = paths.state_slots(); + fs::write(&slot0, body).expect("stage authoritative state slot"); + let _ = fs::remove_file(&slot1); +} + +pub struct Run { + pub stdout: String, + pub stderr: String, + pub ok: bool, +} + +pub fn cli(args: &[&str]) -> Run { + let output = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(args) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .env_remove("CODEGRAPH_DIR") + .output() + .expect("run codegraph binary"); + Run { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + ok: output.status.success(), + } +} + +pub fn copy_tree(src: &Path, dst: &Path) { + fs::create_dir_all(dst).unwrap(); + for entry in fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let from = entry.path(); + let to = dst.join(entry.file_name()); + if from.is_dir() { + copy_tree(&from, &to); + } else { + fs::copy(&from, &to).unwrap(); + } + } +} + +pub struct TestDir { + path: PathBuf, +} + +impl TestDir { + pub fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-upgrade-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self { path } + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} diff --git a/crates/codegraph-store/src/index_state.rs b/crates/codegraph-store/src/index_state.rs index 1b3a0aaa..5bd0b2da 100644 --- a/crates/codegraph-store/src/index_state.rs +++ b/crates/codegraph-store/src/index_state.rs @@ -118,7 +118,7 @@ pub const CURRENT_STORAGE_PROTOCOL: u64 = 2; /// The extraction-pipeline version this binary produces. This is the single /// source of truth: no other crate may define its own copy. -pub const CURRENT_EXTRACTION_VERSION: u64 = 22; +pub const CURRENT_EXTRACTION_VERSION: u64 = 23; /// The `project_metadata` key under which a built index records the extraction /// version it was produced with. Single source of truth for the key spelling. diff --git a/crates/codegraph-store/tests/index_state.rs b/crates/codegraph-store/tests/index_state.rs index c83e09ea..e4676585 100644 --- a/crates/codegraph-store/tests/index_state.rs +++ b/crates/codegraph-store/tests/index_state.rs @@ -119,7 +119,7 @@ fn assert_corrupt( fn index_state_constants_and_canonical_payload_are_exact() { /// The extraction version the immediately preceding release wrote. Read from /// `index_state.rs` at implementation time; see plan decision C1 step 0. - const PREVIOUS_EXTRACTION_VERSION: u64 = 21; + const PREVIOUS_EXTRACTION_VERSION: u64 = 22; assert_eq!(CURRENT_STORAGE_PROTOCOL, 2); assert_eq!(CURRENT_EXTRACTION_VERSION, PREVIOUS_EXTRACTION_VERSION + 1); From 2a3dfded0b6555bebb09ab9e66b099cd485e3d6c Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 23 Oct 2026 13:53:23 +0800 Subject: [PATCH 02/19] fix(store): refuse an index a newer schema wrote A database whose schema_versions records a version above this binary's was written by a newer codegraph (OD-19). Reads, status, sync, MCP, the daemon, init and index --force now refuse it before they change a byte, and the error names both versions. ensure_schema_and_migrations returns MigrationError::NewerSchema before it creates a table, migrates or analyzes. The newer version may sit only in an un-checkpointed write-ahead log. The writers that tolerate a live log (an incremental sync, the daemon's recovery of a dead owner's log) and every rebuild entry (before an explicit init removes a copied project's state slots, before a lockless root gets its lock, before building is published) decide from schema_probe: the main file plus the log's committed frames, applied in memory the way SQLite's recovery does, so no -wal or -shm is created or folded. A file that is not SQLite is still replaced; a database whose version cannot be read is refused, and the error says nothing changed. uninit --force skips the check and stays the way to discard such an index; the refusal offers it only for the states uninit accepts. status reports the index as current with initialized false and both versions. --- crates/codegraph-cli/src/main.rs | 54 +++ .../codegraph-cli/tests/future_schema_cli.rs | 319 +++++++++++++ crates/codegraph-store/src/connection.rs | 182 ++++++- crates/codegraph-store/src/lib.rs | 5 +- crates/codegraph-store/src/migrations.rs | 66 ++- crates/codegraph-store/src/rebuild.rs | 46 ++ crates/codegraph-store/src/schema_probe.rs | 383 +++++++++++++++ crates/codegraph-store/src/uninit.rs | 31 +- crates/codegraph-store/tests/future_schema.rs | 451 ++++++++++++++++++ docs/cli.md | 10 + docs/data-model.md | 12 + docs/mcp.md | 6 +- 12 files changed, 1522 insertions(+), 43 deletions(-) create mode 100644 crates/codegraph-cli/tests/future_schema_cli.rs create mode 100644 crates/codegraph-store/src/schema_probe.rs create mode 100644 crates/codegraph-store/tests/future_schema.rs diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index e07c5635..0de9d1da 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -2152,6 +2152,60 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { } return Ok(()); } + // A database a newer codegraph wrote (OD-19) is unreadable here, and + // nothing may write it; report which versions disagree instead of + // failing like a read. + Err( + error @ codegraph_store::StoreError::FutureSchema { + found, supported, .. + }, + ) => { + if json_output { + let mut status = json!({ + "initialized": false, + "version": VERSION, + "projectPath": project, + "indexPath": index_root, + "lastIndexed": null, + "dbPath": db, + "dbExists": db_exists, + "dbSizeBytes": db_size, + "extractionStatus": "current", + "extractionStatusDetail": error.to_string(), + "recoveryCommand": null, + "legacyIndexPresent": legacy_index_present, + "legacyIndexPaths": legacy_index_paths, + "daemonRunning": daemon_running, + "daemonPidPath": daemon_pid_path, + "daemonSocketPath": daemon_socket_path, + "daemonLogPath": daemon_log_path, + }); + if wal_size > 0 { + status["walSizeBytes"] = json!(wal_size); + } + print_json(&status)?; + } else { + println!("\nCodeGraph Status\n"); + println!("Project: {}\n", project.display()); + println!( + "State: current (schema {found} is newer than this binary's {supported})" + ); + println!("Index Statistics:"); + println!(" DB Size: {:.2} MB", db_size as f64 / 1024.0 / 1024.0); + print_wal_status(wal_size, db_size); + println!("\n DB Path: {}", db.display()); + println!( + " Daemon: {}", + if daemon_running { "running" } else { "stopped" } + ); + println!( + "\nRecovery: a newer CodeGraph wrote this index. Upgrade CodeGraph, or run `codegraph uninit --force {}` and then `codegraph init {}` to discard and rebuild it.", + project.display(), + project.display() + ); + } + return Ok(()); + } Err(error) => return Err(error.into()), }; if status_open.rebuilding { diff --git a/crates/codegraph-cli/tests/future_schema_cli.rs b/crates/codegraph-cli/tests/future_schema_cli.rs new file mode 100644 index 00000000..cf922f5a --- /dev/null +++ b/crates/codegraph-cli/tests/future_schema_cli.rs @@ -0,0 +1,319 @@ +//! An index a newer codegraph wrote (OD-19), through the commands a user runs: +//! `status` reports it, reads, `sync` and `init` refuse it without changing a +//! byte, MCP answers with the refusal, a daemon does not start on it, and +//! `uninit --force` followed by `init` discards and rebuilds it. + +mod upgrade_support; + +use std::collections::BTreeMap; +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use codegraph_core::IndexPaths; +use codegraph_store::migrations::CURRENT_SCHEMA_VERSION; +use codegraph_store::{ExtractionStatus, Store}; +use serde_json::{Value, json}; +use upgrade_support::*; + +const NEWER: i64 = CURRENT_SCHEMA_VERSION + 1; + +fn sidecar(db: &Path, suffix: &str) -> PathBuf { + PathBuf::from(format!("{}{suffix}", db.display())) +} + +/// The mini corpus indexed by this binary. +fn indexed_project(dir: &TestDir) -> PathBuf { + let project = dir.path().join("project"); + copy_tree(&mini_fixture(), &project); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init failed: {}", init.stderr); + project +} + +/// Record schema `NEWER` in the project's database, checkpointed. +fn record_newer_schema(paths: &IndexPaths) { + let db = paths.current_db(); + let conn = rusqlite::Connection::open(&db).unwrap(); + conn.execute( + "INSERT INTO schema_versions (version, applied_at, description) VALUES (?1, 0, 'newer')", + [NEWER], + ) + .unwrap(); + conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);") + .unwrap(); + drop(conn); + for suffix in ["-wal", "-shm"] { + let _ = fs::remove_file(sidecar(&db, suffix)); + } +} + +/// Record schema `NEWER` in the database's log only, as a newer binary killed +/// before its checkpoint leaves it. +fn record_newer_schema_in_the_log_only(paths: &IndexPaths) { + let db = paths.current_db(); + let conn = rusqlite::Connection::open(&db).unwrap(); + conn.execute_batch("PRAGMA wal_autocheckpoint = 0;") + .unwrap(); + conn.execute( + "INSERT INTO schema_versions (version, applied_at, description) VALUES (?1, 0, 'newer')", + [NEWER], + ) + .unwrap(); + let main = fs::read(&db).unwrap(); + let wal = fs::read(sidecar(&db, "-wal")).unwrap(); + drop(conn); + fs::write(&db, main).unwrap(); + fs::write(sidecar(&db, "-wal"), wal).unwrap(); + let _ = fs::remove_file(sidecar(&db, "-shm")); +} + +/// The database, its sidecars and the state slots, byte for byte (`None` when +/// absent). The daemon's own log, pid and socket files are not part of it. +fn index_bytes(paths: &IndexPaths) -> BTreeMap>> { + let db = paths.current_db(); + let mut files = BTreeMap::new(); + for (label, path) in [ + ("db", db.clone()), + ("wal", sidecar(&db, "-wal")), + ("shm", sidecar(&db, "-shm")), + ("slot0", paths.state_slots()[0].clone()), + ("slot1", paths.state_slots()[1].clone()), + ("tombstone", paths.tombstone()), + ] { + files.insert(label.to_string(), fs::read(path).ok()); + } + files +} + +fn assert_unchanged(paths: &IndexPaths, before: &BTreeMap>>, what: &str) { + let after = index_bytes(paths); + for (label, bytes) in before { + assert!( + after.get(label) == Some(bytes), + "{what} changed the index's {label}" + ); + } +} + +#[test] +fn status_reports_an_index_a_newer_codegraph_wrote() { + let dir = TestDir::new("future-schema-status"); + let project = indexed_project(&dir); + record_newer_schema(&index_paths(&project)); + + let status = cli(&["status", path_str(&project), "--json"]); + assert!(status.ok, "status failed: {}", status.stderr); + let json: Value = serde_json::from_str(&status.stdout).expect("status JSON"); + assert_eq!(json["initialized"], false, "{}", status.stdout); + assert_eq!(json["extractionStatus"], "current", "{}", status.stdout); + let detail = json["extractionStatusDetail"].as_str().unwrap_or(""); + assert!( + detail.contains(&format!("schema {NEWER}")) && detail.contains("uninit --force"), + "{detail}" + ); + assert_eq!(json["recoveryCommand"], Value::Null); + + let human = cli(&["status", path_str(&project)]); + assert!(human.ok, "status failed: {}", human.stderr); + assert!( + human.stdout.contains(&format!( + "State: current (schema {NEWER} is newer than this binary's {CURRENT_SCHEMA_VERSION})" + )), + "{}", + human.stdout + ); + assert!( + human.stdout.contains("codegraph uninit --force"), + "{}", + human.stdout + ); +} + +#[test] +fn reads_sync_and_init_refuse_a_newer_schema_and_uninit_discards_it() { + let dir = TestDir::new("future-schema-commands"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema(&paths); + let before = index_bytes(&paths); + + let search = cli(&["search", "add", "-p", path_str(&project)]); + assert!(!search.ok, "search read a newer codegraph's index"); + assert!( + search.stderr.contains("newer CodeGraph") && search.stderr.contains("uninit --force"), + "{}", + search.stderr + ); + assert_unchanged(&paths, &before, "search"); + + let sync = cli(&["sync", path_str(&project)]); + assert!(!sync.ok, "sync wrote a newer codegraph's index"); + assert!(sync.stderr.contains("newer CodeGraph"), "{}", sync.stderr); + assert_unchanged(&paths, &before, "sync"); + + let init = cli(&["init", path_str(&project)]); + assert!(!init.ok, "init replaced a newer codegraph's index"); + assert!(init.stderr.contains("newer CodeGraph"), "{}", init.stderr); + assert_unchanged(&paths, &before, "init"); + + let uninit = cli(&["uninit", "--force", path_str(&project)]); + assert!(uninit.ok, "uninit failed: {}", uninit.stderr); + assert!(!paths.current_db().exists()); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init after uninit failed: {}", init.stderr); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); +} + +#[test] +fn sync_leaves_a_newer_schema_in_the_log_untouched() { + let dir = TestDir::new("future-schema-log-sync"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema_in_the_log_only(&paths); + let before = index_bytes(&paths); + assert!(before["wal"].is_some() && before["shm"].is_none()); + + let sync = cli(&["sync", path_str(&project)]); + assert!(!sync.ok, "sync wrote a newer codegraph's index"); + assert!(sync.stderr.contains("newer CodeGraph"), "{}", sync.stderr); + assert_unchanged(&paths, &before, "sync"); +} + +/// A daemon's catch-up runs `codegraph_watch::sync_project_once`: it returns +/// the refusal and changes no byte, whether the newer version is checkpointed +/// or only in the log. +#[test] +fn the_catch_up_sync_refuses_a_newer_schema() { + type Record = fn(&IndexPaths); + let record: [(&str, Record); 2] = [ + ("checkpointed", record_newer_schema), + ("log", record_newer_schema_in_the_log_only), + ]; + for (label, record) in record { + let dir = TestDir::new(&format!("future-schema-catch-up-{label}")); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record(&paths); + let before = index_bytes(&paths); + let error = codegraph_watch::sync_project_once(&project) + .err() + .unwrap_or_else(|| panic!("{label}: the catch-up wrote a newer codegraph's index")); + assert!( + format!("{error:#}").contains("newer CodeGraph"), + "{label}: {error:#}" + ); + assert_unchanged(&paths, &before, "the catch-up"); + } +} + +/// The MCP server answers a tool call with the refusal (direct mode). +#[test] +fn an_mcp_tool_call_answers_with_the_refusal() { + let dir = TestDir::new("future-schema-mcp"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema(&paths); + let before = index_bytes(&paths); + + let mut child = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(["serve", "--mcp", "--path"]) + .arg(&project) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .env( + "CODEGRAPH_MCP_REGISTRY_DIR", + dir.path().join("mcp-registry"), + ) + .env_remove("CODEGRAPH_DIR") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn serve --mcp"); + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut send = |value: Value| { + writeln!(stdin, "{value}").unwrap(); + stdin.flush().unwrap(); + }; + let mut read_id = |want: i64| -> Value { + loop { + let mut line = String::new(); + assert!( + stdout.read_line(&mut line).unwrap() > 0, + "serve closed stdout" + ); + let Ok(value) = serde_json::from_str::(line.trim()) else { + continue; + }; + if value.get("id").and_then(Value::as_i64) == Some(want) { + return value; + } + } + }; + send(json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { "protocolVersion": "2024-11-05", "capabilities": {}, + "clientInfo": { "name": "future-schema", "version": "0" } } + })); + read_id(1); + send(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })); + send(json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { "name": "codegraph_search", "arguments": { "query": "add" } } + })); + let response = read_id(2).to_string(); + assert!(response.contains("newer CodeGraph"), "{response}"); + let _ = child.kill(); + let _ = child.wait(); + assert_unchanged(&paths, &before, "the MCP session"); +} + +/// A cold MCP session in the default daemon mode on `project`: wait until the +/// project's daemon refuses to start, then return a tool call's answer. +#[cfg(unix)] +fn cold_session_answer(dir: &TestDir, project: &Path, paths: &IndexPaths) -> String { + let mut session = cold::Session::start(project, dir.path()); + let log = cold::wait_for_daemon_log(paths, "refusing to start a daemon for", "", &session); + assert!( + !cold::daemon_live(project), + "no daemon may serve it:\n{log}" + ); + let answer = session.search("add"); + session.close(); + answer +} + +/// In the default daemon mode the project's daemon does not start on a newer +/// codegraph's index, and a tool call answers with the refusal. +#[cfg(unix)] +#[test] +fn a_cold_mcp_session_answers_with_the_refusal() { + let dir = TestDir::new("future-schema-cold"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema(&paths); + let before = index_bytes(&paths); + + let answer = cold_session_answer(&dir, &project, &paths); + assert!(answer.contains("newer CodeGraph"), "{answer}"); + assert_unchanged(&paths, &before, "the cold session"); +} + +/// The same when the newer schema is only in the log: the daemon folds nothing +/// into the index, and a read refuses the live log as it refuses any. +#[cfg(unix)] +#[test] +fn a_daemon_does_not_start_on_a_newer_schema_in_the_log() { + let dir = TestDir::new("future-schema-log-daemon"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema_in_the_log_only(&paths); + let before = index_bytes(&paths); + + let answer = cold_session_answer(&dir, &project, &paths); + assert!(answer.contains("Failed to open project"), "{answer}"); + assert_unchanged(&paths, &before, "the daemon start"); +} diff --git a/crates/codegraph-store/src/connection.rs b/crates/codegraph-store/src/connection.rs index 72cabfac..f78534a5 100644 --- a/crates/codegraph-store/src/connection.rs +++ b/crates/codegraph-store/src/connection.rs @@ -156,6 +156,22 @@ impl StoreStatusOpen { } } +/// Why a database's schema version could not be read (OD-19). +#[derive(Debug, thiserror::Error)] +pub enum SchemaVersionReadError { + /// A file of the database (the main file or its write-ahead log) could + /// not be read. + #[error("failed to read {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + /// The database could not be queried. + #[error(transparent)] + Sqlite(#[from] rusqlite::Error), +} + #[derive(Debug, thiserror::Error)] pub enum StoreError { #[error("failed to create database directory {path}: {source}")] @@ -182,6 +198,30 @@ pub enum StoreError { #[source] source: rusqlite::Error, }, + /// The database records a schema this binary does not know: a newer + /// codegraph wrote it (OD-19). Nothing was changed. `discard_with_uninit` + /// is whether `uninit --force` accepts the namespace's state, so the + /// message offers it only where it works. + #[error( + "index database {path} was written by a newer CodeGraph (schema {found}; this binary supports schema {supported}); upgrade CodeGraph before using it{}", + future_schema_discard_hint(*.discard_with_uninit) + )] + FutureSchema { + path: PathBuf, + found: i64, + supported: i64, + discard_with_uninit: bool, + }, + /// The database's schema version could not be read, so it is not used or + /// replaced. Nothing was changed. + #[error( + "failed to read the schema version of {path}: {source}; nothing was changed (run `codegraph status` to inspect the index)" + )] + ReadSchemaVersion { + path: PathBuf, + #[source] + source: SchemaVersionReadError, + }, #[error(transparent)] Lease(#[from] IndexLeaseError), #[error(transparent)] @@ -313,12 +353,8 @@ impl Store { source, })?; - migrations::ensure_schema_and_migrations(&mut conn).map_err(|source| { - StoreError::Migrate { - path: db_path.to_path_buf(), - source, - } - })?; + migrations::ensure_schema_and_migrations(&mut conn) + .map_err(|error| migration_error(db_path, error, false))?; Ok(Self { conn, @@ -502,6 +538,7 @@ impl Store { // them for the same reason — and folds them away afterwards. let (corroboration, source_conn) = corroborate_current_database_options( paths, + true, false, matches!( purpose, @@ -532,12 +569,8 @@ impl Store { // behind (an extraction-version-22 index is schema 9 or later, // so this is v10's DDL at most): migrate it under the same // exclusive lease before the first write (W5-09e). - migrations::ensure_schema_and_migrations(&mut conn).map_err(|source| { - StoreError::Migrate { - path: db_path.clone(), - source, - } - })?; + migrations::ensure_schema_and_migrations(&mut conn) + .map_err(|error| migration_error(&db_path, error, true))?; Ok(StoreWriteOpen::Current(Box::new(Self { conn, _source_conn: None, @@ -602,7 +635,10 @@ impl Store { // sidecars. Rejecting them here would make that required // cleanup path unreachable. let (corroboration, source_conn) = - corroborate_current_database_options(paths, false, true)?; + // OD-19: no schema check, so uninit stays the way to + // discard a newer codegraph's index. It still reads + // the extraction stamp row. + corroborate_current_database_options(paths, false, false, true)?; drop(corroboration); drop(source_conn); } @@ -651,12 +687,8 @@ impl Store { })?; configure_connection_for_rebuild(&conn, paths, lease, &db_path)?; lease.validate_exclusive(paths)?; - migrations::ensure_schema_and_migrations(&mut conn).map_err(|source| { - StoreError::Migrate { - path: db_path.clone(), - source, - } - })?; + migrations::ensure_schema_and_migrations(&mut conn) + .map_err(|error| migration_error(&db_path, error, false))?; Ok(Self { conn, _source_conn: None, @@ -868,7 +900,7 @@ fn corroborate_current_database_with( paths: &IndexPaths, allow_tombstone: bool, ) -> Result<(Connection, Connection)> { - corroborate_current_database_options(paths, allow_tombstone, false) + corroborate_current_database_options(paths, true, allow_tombstone, false) } /// Corroborate a `Current` namespace from its main-database bytes. @@ -885,6 +917,7 @@ fn corroborate_current_database_with( /// already-checkpointed main-file bytes, so the version gate is unchanged. fn corroborate_current_database_options( paths: &IndexPaths, + check_schema: bool, allow_tombstone: bool, allow_live_sidecar: bool, ) -> Result<(Connection, Connection)> { @@ -913,9 +946,106 @@ fn corroborate_current_database_options( source, })?; validate_extraction_stamp(&db_path, stamp)?; + if check_schema { + if allow_live_sidecar { + // A writer's log may hold a newer schema the main image lacks. Read + // it as its next opener would, before any writable open touches + // the database (OD-19). + check_schema_version_with_log(&db_path)?; + } else { + // Sidecar-freedom was proved above, so the main image is the + // whole database. + check_schema_version(&db_path, &conn)?; + } + } Ok((conn, source_conn)) } +/// Refuse a database that records a schema newer than this binary's, read on +/// the connection that will be used (OD-19). An unreadable version fails +/// closed. +/// The recovery a [`StoreError::FutureSchema`] message offers besides +/// upgrading. +fn future_schema_discard_hint(discard_with_uninit: bool) -> &'static str { + if discard_with_uninit { + ", or discard it with `codegraph uninit --force` and rebuild it with `codegraph init`" + } else { + "" + } +} + +fn check_schema_version(db_path: &Path, conn: &Connection) -> Result<()> { + let found = + migrations::get_current_version(conn).map_err(|source| StoreError::ReadSchemaVersion { + path: db_path.to_path_buf(), + source: source.into(), + })?; + if found > migrations::CURRENT_SCHEMA_VERSION { + return Err(StoreError::FutureSchema { + path: db_path.to_path_buf(), + found, + supported: migrations::CURRENT_SCHEMA_VERSION, + discard_with_uninit: true, + }); + } + Ok(()) +} + +/// [`check_schema_version`] for a database whose write-ahead log may hold +/// committed frames: the main file plus those frames, read into memory without +/// creating or writing any file. +fn check_schema_version_with_log(db_path: &Path) -> Result<()> { + match crate::schema_probe::probe_schema_version(db_path) { + Ok(crate::schema_probe::ProbedSchema::Version(found)) + if found > migrations::CURRENT_SCHEMA_VERSION => + { + Err(StoreError::FutureSchema { + path: db_path.to_path_buf(), + found, + supported: migrations::CURRENT_SCHEMA_VERSION, + discard_with_uninit: true, + }) + } + Ok(crate::schema_probe::ProbedSchema::Version(_)) => Ok(()), + Ok(crate::schema_probe::ProbedSchema::Absent) => Err(StoreError::CurrentDatabaseMissing { + path: db_path.to_path_buf(), + }), + Ok(crate::schema_probe::ProbedSchema::NotSqlite) => Err(StoreError::ReadSchemaVersion { + path: db_path.to_path_buf(), + source: rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_NOTADB), + None, + ) + .into(), + }), + Err(source) => Err(StoreError::ReadSchemaVersion { + path: db_path.to_path_buf(), + source, + }), + } +} + +/// `discard_with_uninit` as [`StoreError::FutureSchema`] documents it: true +/// where the caller opened a `Current` namespace. +fn migration_error( + db_path: &Path, + error: migrations::MigrationError, + discard_with_uninit: bool, +) -> StoreError { + match error { + migrations::MigrationError::Sqlite(source) => StoreError::Migrate { + path: db_path.to_path_buf(), + source, + }, + migrations::MigrationError::NewerSchema { found, supported } => StoreError::FutureSchema { + path: db_path.to_path_buf(), + found, + supported, + discard_with_uninit, + }, + } +} + fn open_read_only_without_sidecars(db_path: &Path) -> Result<(Connection, Connection)> { // Opening an existing WAL-mode DB read-only is not enough to prevent SQLite // from creating `-wal`/`-shm` on the first query. Open the real path with @@ -954,12 +1084,17 @@ fn open_read_only_without_sidecars(db_path: &Path) -> Result<(Connection, Connec /// A `sqlite3_malloc64` buffer holding a database image, freed on drop until /// [`Self::into_raw`] hands it to SQLite. -struct SqliteImage { +pub(crate) struct SqliteImage { allocation: std::ptr::NonNull, len: usize, } impl SqliteImage { + /// A SQLite-owned copy of `bytes`, for an image assembled in memory. + pub(crate) fn copy_from(bytes: &[u8]) -> std::io::Result { + read_image_of_len(&mut &bytes[..], bytes.len()) + } + fn bytes(&self) -> &[u8] { // SAFETY: `allocation` is a live `len`-byte allocation this value owns. unsafe { std::slice::from_raw_parts(self.allocation.as_ptr(), self.len) } @@ -1008,7 +1143,10 @@ fn read_image_of_len(file: &mut impl std::io::Read, len: usize) -> std::io::Resu Ok(image) } -fn deserialize_read_only(conn: &mut Connection, image: SqliteImage) -> rusqlite::Result<()> { +pub(crate) fn deserialize_read_only( + conn: &mut Connection, + image: SqliteImage, +) -> rusqlite::Result<()> { if image.len == 0 { // An empty file fails as it did when its zero-byte copy could not be // allocated. diff --git a/crates/codegraph-store/src/lib.rs b/crates/codegraph-store/src/lib.rs index 785c1236..6b309287 100644 --- a/crates/codegraph-store/src/lib.rs +++ b/crates/codegraph-store/src/lib.rs @@ -7,6 +7,7 @@ pub mod migrations; pub mod queries; pub mod rebuild; pub mod schema; +mod schema_probe; #[cfg(feature = "test-hooks")] pub mod test_support; pub mod uninit; @@ -14,8 +15,8 @@ pub mod viewer; pub mod wsl_shared_index; pub use connection::{ - ExtractionStampIssue, Store, StoreError, StoreStatusOpen, StoreWriteAuthorization, - StoreWriteOpen, StoreWritePurpose, + ExtractionStampIssue, SchemaVersionReadError, Store, StoreError, StoreStatusOpen, + StoreWriteAuthorization, StoreWriteOpen, StoreWritePurpose, }; pub use file_identity::{PathIdentity, path_still_names_file}; pub use index_lease::{IndexLease, IndexLeaseError, IndexLeaseValidationError}; diff --git a/crates/codegraph-store/src/migrations.rs b/crates/codegraph-store/src/migrations.rs index ed55d621..5b872301 100644 --- a/crates/codegraph-store/src/migrations.rs +++ b/crates/codegraph-store/src/migrations.rs @@ -2,6 +2,17 @@ use rusqlite::{Connection, OptionalExtension, params}; use crate::schema::BASE_SCHEMA; +/// Why [`ensure_schema_and_migrations`] stopped. +#[derive(Debug, thiserror::Error)] +pub enum MigrationError { + #[error(transparent)] + Sqlite(#[from] rusqlite::Error), + /// The database records a schema newer than [`CURRENT_SCHEMA_VERSION`]: + /// a newer codegraph wrote it. Nothing was changed. + #[error("the database records schema {found}; this binary supports schema {supported}")] + NewerSchema { found: i64, supported: i64 }, +} + pub const CURRENT_SCHEMA_VERSION: i64 = 10; pub const FRESH_SCHEMA_DESCRIPTION: &str = "Initial schema includes all migrations"; @@ -143,8 +154,17 @@ struct Migration { adds_column: Option<(&'static str, &'static str)>, } -pub fn ensure_schema_and_migrations(conn: &mut Connection) -> rusqlite::Result<()> { - if get_current_version(conn)? == 0 { +pub fn ensure_schema_and_migrations(conn: &mut Connection) -> Result<(), MigrationError> { + // A newer codegraph's database is refused before anything below writes to + // it: no table, no migration, no ANALYZE (OD-19). + let current = get_current_version(conn)?; + if current > CURRENT_SCHEMA_VERSION { + return Err(MigrationError::NewerSchema { + found: current, + supported: CURRENT_SCHEMA_VERSION, + }); + } + if current == 0 { initialize_fresh_schema(conn)?; } @@ -314,6 +334,48 @@ mod tests { assert_eq!(get_current_version(&conn).unwrap(), CURRENT_SCHEMA_VERSION); } + /// OD-19: a database a newer codegraph wrote is refused before anything is + /// written: no table, no migration, no ANALYZE, the file's bytes unchanged. + #[test] + fn a_newer_schema_is_refused_without_writing() { + struct Dir(std::path::PathBuf); + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let dir = + Dir(std::env::temp_dir() + .join(format!("codegraph-migrations-newer-{}", std::process::id()))); + let _ = std::fs::remove_dir_all(&dir.0); + std::fs::create_dir_all(&dir.0).unwrap(); + let db = dir.0.join("newer.db"); + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE schema_versions (version INTEGER PRIMARY KEY, applied_at INTEGER, description TEXT);", + ) + .unwrap(); + conn.execute( + "INSERT INTO schema_versions VALUES (?1, 0, 'newer')", + [CURRENT_SCHEMA_VERSION + 1], + ) + .unwrap(); + drop(conn); + let before = std::fs::read(&db).unwrap(); + + let mut conn = Connection::open(&db).unwrap(); + match ensure_schema_and_migrations(&mut conn) { + Err(MigrationError::NewerSchema { found, supported }) => { + assert_eq!(found, CURRENT_SCHEMA_VERSION + 1); + assert_eq!(supported, CURRENT_SCHEMA_VERSION); + } + other => panic!("expected NewerSchema, got {other:?}"), + } + assert!(!has_sqlite_stat1(&conn).unwrap(), "no ANALYZE ran"); + drop(conn); + assert_eq!(std::fs::read(&db).unwrap(), before, "the file changed"); + } + #[test] fn analyze_runs_once_and_leaves_sqlite_stat1() { let mut conn = Connection::open_in_memory().unwrap(); diff --git a/crates/codegraph-store/src/rebuild.rs b/crates/codegraph-store/src/rebuild.rs index 7cd86b5c..ae30cfa1 100644 --- a/crates/codegraph-store/src/rebuild.rs +++ b/crates/codegraph-store/src/rebuild.rs @@ -34,6 +34,8 @@ use crate::connection::{ Store, StoreError, StoreWriteOpen, StoreWritePurpose, database_sidecar_path, first_existing_database_artifact, }; +use crate::migrations::CURRENT_SCHEMA_VERSION; +use crate::schema_probe::ProbedSchema; use crate::{ CorruptReason, IndexLease, IndexLeaseError, IndexLeaseValidationError, SlotOutcome, StatePhase, StatePublishError, classify, publish_index_state, @@ -273,6 +275,9 @@ fn begin_full_rebuild_with( fault: &mut impl RebuildFault, ) -> Result { let lease = acquire_outer_exclusive(paths, deadline, cancelled)?; + // Before any byte changes, including an explicit init's removal of a + // copied project's state slots (OD-19). + refuse_newer_schema_database(paths, &lease)?; checkpoint(fault, RebuildCheckpoint::BeforeWriteAuthorization)?; if kind == RebuildKind::ExplicitInit { remove_owner_mismatched_state_slots(paths, &lease)?; @@ -324,6 +329,43 @@ fn refuse_tombstoned_namespace(paths: &IndexPaths, lease: &IndexLease) -> Result } } +/// Refuse to replace a database a newer codegraph wrote (OD-19), deciding from +/// the version its next opener would read, its committed write-ahead log +/// included, without creating or writing any file. Runs under the retained +/// exclusive lease, before anything is removed or published. +fn refuse_newer_schema_database( + paths: &IndexPaths, + lease: &IndexLease, +) -> Result<(), RebuildError> { + lease.validate_exclusive(paths)?; + newer_schema_check(paths) +} + +/// The check itself. A file that is not a SQLite database is no index any +/// codegraph wrote, so a rebuild replaces it as before; a database whose version +/// cannot be read is refused. +fn newer_schema_check(paths: &IndexPaths) -> Result<(), RebuildError> { + let db = paths.current_db(); + match crate::schema_probe::probe_schema_version(&db) { + Ok(ProbedSchema::Absent | ProbedSchema::NotSqlite) => Ok(()), + Ok(ProbedSchema::Version(found)) if found > CURRENT_SCHEMA_VERSION => { + // `uninit --force` is offered only for the states it accepts. + let discard_with_uninit = crate::uninit::uninit_accepts(classify(paths).status()); + Err(RebuildError::Store(StoreError::FutureSchema { + path: db, + found, + supported: CURRENT_SCHEMA_VERSION, + discard_with_uninit, + })) + } + Ok(ProbedSchema::Version(_)) => Ok(()), + Err(source) => Err(RebuildError::Store(StoreError::ReadSchemaVersion { + path: db, + source, + })), + } +} + /// Normalize only the copied/moved-project shape: every PRESENT fixed slot must /// independently be an [`CorruptReason::OwnerMismatch`]. A mixed namespace is /// genuine damage and remains untouched for the ordinary state gate to refuse. @@ -428,6 +470,7 @@ fn resume_full_rebuild_with( } let lease = authorization.clone_lease(); refuse_tombstoned_namespace(paths, &lease)?; + refuse_newer_schema_database(paths, &lease)?; begin_from_authorization(paths, lease, RebuildKind::Reindex, authorization, fault) } @@ -801,6 +844,9 @@ fn acquire_outer_exclusive( Err(IndexLeaseError::LockNotFound { .. }) if Store::extraction_status(paths) == crate::ExtractionStatus::Missing => { + // A newer codegraph's database stops the rebuild before the lock + // exists; the check is repeated under the lease (OD-19). + newer_schema_check(paths)?; let lease = IndexLease::create_exclusive_in_existing_root(paths, deadline, cancelled)?; let status = Store::extraction_status(paths); if status != crate::ExtractionStatus::Missing { diff --git a/crates/codegraph-store/src/schema_probe.rs b/crates/codegraph-store/src/schema_probe.rs new file mode 100644 index 00000000..ec81897a --- /dev/null +++ b/crates/codegraph-store/src/schema_probe.rs @@ -0,0 +1,383 @@ +//! The schema version a database file holds, read without opening it (OD-19). +//! +//! A rebuild must not replace an index a newer codegraph wrote, and it decides +//! that before it changes a byte: before an explicit `init` removes a copied +//! project's state slots, before a lockless root gets its lock, before +//! `phase=building` is published. The version a newer binary recorded may still +//! sit in an un-checkpointed write-ahead log, and opening the database through +//! SQLite to read it would create `-wal`/`-shm` sidecars the newer binary's own +//! read gate refuses. So this reads the main file and the log into memory and +//! applies the log's committed frames the way SQLite's recovery does +//! (): the header's magic, +//! version and checksum; each frame's salts and cumulative checksum, stopping at +//! the first frame that fails them; every frame up to the last commit frame, +//! and the database size that frame records. The result is the database the +//! next opener would see, read through a private read-only in-memory handle. +//! Nothing on disk is created or written. + +use std::io; +use std::path::Path; + +use rusqlite::{Connection, OptionalExtension}; + +use crate::connection::{ + SchemaVersionReadError, SqliteImage, database_sidecar_path, deserialize_read_only, +}; + +/// What a database file holds, as its next opener would read it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProbedSchema { + /// There is no database file. + Absent, + /// The file is not a SQLite database, so no codegraph wrote it. + NotSqlite, + /// A SQLite database recording this schema version (0 without a + /// `schema_versions` table). + Version(i64), +} + +/// Read the schema version `db_path` holds, its committed write-ahead log +/// included, without creating or writing any file. Either way an error leaves +/// every file as it was. +pub(crate) fn probe_schema_version(db_path: &Path) -> Result { + let read = |path: &Path| match std::fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(source) => Err(SchemaVersionReadError::Io { + path: path.to_path_buf(), + source, + }), + }; + let Some(mut image) = read(db_path)? else { + return Ok(ProbedSchema::Absent); + }; + let wal_path = database_sidecar_path(db_path, "-wal"); + if let Some(wal) = read(&wal_path)? { + apply_committed_frames(&mut image, &wal); + } + if image.len() < 100 || !image.starts_with(b"SQLite format 3\0") { + return Ok(ProbedSchema::NotSqlite); + } + let mut conn = Connection::open_in_memory()?; + let image = SqliteImage::copy_from(&image).map_err(|source| SchemaVersionReadError::Io { + path: db_path.to_path_buf(), + source, + })?; + deserialize_read_only(&mut conn, image)?; + let has_table = conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_versions'", + [], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !has_table { + return Ok(ProbedSchema::Version(0)); + } + let version = conn.query_row("SELECT MAX(version) FROM schema_versions", [], |row| { + row.get::<_, Option>(0) + })?; + Ok(ProbedSchema::Version(version.unwrap_or(0))) +} + +const WAL_HEADER_LEN: usize = 32; +const WAL_FRAME_HEADER_LEN: usize = 24; +const WAL_MAGIC_LITTLE_ENDIAN: u32 = 0x377f_0682; +const WAL_MAGIC_BIG_ENDIAN: u32 = 0x377f_0683; +const WAL_FORMAT_VERSION: u32 = 3_007_000; + +/// Apply every frame of `wal` up to its last valid commit frame to `image`, +/// resizing it to the page count that frame records. A log whose header is +/// invalid holds no frames, as SQLite's recovery treats it. +fn apply_committed_frames(image: &mut Vec, wal: &[u8]) { + let Some(header) = wal.get(..WAL_HEADER_LEN) else { + return; + }; + let magic = be32(header, 0); + let big_endian = match magic { + WAL_MAGIC_BIG_ENDIAN => true, + WAL_MAGIC_LITTLE_ENDIAN => false, + _ => return, + }; + if be32(header, 4) != WAL_FORMAT_VERSION { + return; + } + let page_size = be32(header, 8) as usize; + if !(512..=65_536).contains(&page_size) || !page_size.is_power_of_two() { + return; + } + let salts = (be32(header, 16), be32(header, 20)); + let mut checksum = wal_checksum(&header[..24], (0, 0), big_endian); + if checksum != (be32(header, 24), be32(header, 28)) { + return; + } + + let frame_len = WAL_FRAME_HEADER_LEN + page_size; + let mut pending: Vec<(usize, &[u8])> = Vec::new(); + let mut committed: Vec<(usize, &[u8])> = Vec::new(); + let mut committed_pages: Option = None; + let mut offset = WAL_HEADER_LEN; + while let Some(frame) = wal.get(offset..offset + frame_len) { + let page_number = be32(frame, 0) as usize; + let pages_after_commit = be32(frame, 4) as usize; + if page_number == 0 || (be32(frame, 8), be32(frame, 12)) != salts { + break; + } + checksum = wal_checksum(&frame[..8], checksum, big_endian); + checksum = wal_checksum(&frame[WAL_FRAME_HEADER_LEN..], checksum, big_endian); + if checksum != (be32(frame, 16), be32(frame, 20)) { + break; + } + pending.push((page_number, &frame[WAL_FRAME_HEADER_LEN..])); + if pages_after_commit != 0 { + committed.append(&mut pending); + committed_pages = Some(pages_after_commit); + } + offset += frame_len; + } + + let Some(pages) = committed_pages else { + return; + }; + image.resize(pages * page_size, 0); + for (page_number, page) in committed { + let start = (page_number - 1) * page_size; + if let Some(slot) = image.get_mut(start..start + page_size) { + slot.copy_from_slice(page); + } + } +} + +/// SQLite's write-ahead-log checksum of `bytes` (a multiple of 8 bytes), +/// continuing from `seed`. +fn wal_checksum(bytes: &[u8], seed: (u32, u32), big_endian: bool) -> (u32, u32) { + let word = |raw: [u8; 4]| { + if big_endian { + u32::from_be_bytes(raw) + } else { + u32::from_le_bytes(raw) + } + }; + let (mut s0, mut s1) = seed; + let (pairs, _) = bytes.as_chunks::<8>(); + for [a, b, c, d, e, f, g, h] in pairs.iter().copied() { + s0 = s0.wrapping_add(word([a, b, c, d])).wrapping_add(s1); + s1 = s1.wrapping_add(word([e, f, g, h])).wrapping_add(s0); + } + (s0, s1) +} + +fn be32(bytes: &[u8], at: usize) -> u32 { + u32::from_be_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static NEXT: AtomicUsize = AtomicUsize::new(0); + + struct Dir(PathBuf); + + impl Dir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-schema-probe-{label}-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + } + + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// A database at `dir/codegraph.db` whose main file records schema 10 and + /// whose write-ahead log alone adds 11 (and one more row, so the log has + /// more than one transaction). Returns copies of both files taken while + /// the writer is still open, as a killed process leaves them. + fn wal_only_newer(dir: &Path) -> (Vec, Vec) { + let db = dir.join("source.db"); + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA wal_autocheckpoint = 0; + CREATE TABLE schema_versions (version INTEGER PRIMARY KEY, applied_at INTEGER, description TEXT); + INSERT INTO schema_versions VALUES (10, 0, 'ten'); + PRAGMA wal_checkpoint(TRUNCATE); + INSERT INTO schema_versions VALUES (11, 0, 'eleven'); + CREATE TABLE later (x); + INSERT INTO later VALUES (1);", + ) + .unwrap(); + let main = std::fs::read(&db).unwrap(); + let wal = std::fs::read(database_sidecar_path(&db, "-wal")).unwrap(); + drop(conn); + (main, wal) + } + + fn plant(dir: &Path, name: &str, main: &[u8], wal: Option<&[u8]>) -> PathBuf { + let db = dir.join(name); + std::fs::write(&db, main).unwrap(); + if let Some(wal) = wal { + std::fs::write(database_sidecar_path(&db, "-wal"), wal).unwrap(); + } + db + } + + /// What SQLite itself reads after recovering a copy of `main` + `wal`. + fn sqlite_recovers(dir: &Path, main: &[u8], wal: Option<&[u8]>) -> i64 { + let copy = plant( + dir, + &format!("oracle-{}.db", NEXT.fetch_add(1, Ordering::Relaxed)), + main, + wal, + ); + let conn = Connection::open(©).unwrap(); + conn.query_row("SELECT MAX(version) FROM schema_versions", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap() + } + + fn snapshot(dir: &Path) -> Vec<(String, Vec)> { + let mut entries = std::fs::read_dir(dir) + .unwrap() + .map(|entry| { + let entry = entry.unwrap(); + ( + entry.file_name().to_string_lossy().into_owned(), + std::fs::read(entry.path()).unwrap(), + ) + }) + .collect::>(); + entries.sort(); + entries + } + + fn frame_len(wal: &[u8]) -> usize { + WAL_FRAME_HEADER_LEN + be32(wal, 8) as usize + } + + #[test] + fn a_version_only_in_the_write_ahead_log_is_read_without_touching_a_file() { + let work = Dir::new("wal-only"); + let (main, wal) = wal_only_newer(&work.0); + let probe_dir = Dir::new("wal-only-probe"); + let db = plant(&probe_dir.0, "codegraph.db", &main, Some(&wal)); + let before = snapshot(&probe_dir.0); + + assert_eq!( + probe_schema_version(&db).unwrap(), + ProbedSchema::Version(11) + ); + assert_eq!(snapshot(&probe_dir.0), before, "the probe changed a file"); + + let main_only = Dir::new("wal-only-main"); + let db = plant(&main_only.0, "codegraph.db", &main, None); + assert_eq!( + probe_schema_version(&db).unwrap(), + ProbedSchema::Version(10) + ); + assert_eq!(sqlite_recovers(&work.0, &main, Some(&wal)), 11); + } + + #[test] + fn a_damaged_log_reads_as_sqlite_recovers_it() { + let work = Dir::new("damaged"); + let (main, wal) = wal_only_newer(&work.0); + let frame = frame_len(&wal); + let frames = (wal.len() - WAL_HEADER_LEN) / frame; + assert!(frames >= 2, "the fixture log has several frames"); + + // Each damage, and the version SQLite's recovery is expected to read: + // the log's later transactions go, the one recording 11 stays, unless + // the damage reaches its first frame or the header. + let mut cases: Vec<(&str, Vec, i64)> = Vec::new(); + // A torn tail: the last frame is half written. + cases.push(("torn", wal[..wal.len() - frame / 2].to_vec(), 11)); + // The last frame's page is damaged, so its checksum fails. + let mut bad_page = wal.clone(); + let last_page = wal.len() - 10; + bad_page[last_page] ^= 0xff; + cases.push(("checksum", bad_page, 11)); + // The first frame carries another log's salt. + let mut bad_salt = wal.clone(); + bad_salt[WAL_HEADER_LEN + 8] ^= 0x01; + cases.push(("salt", bad_salt, 10)); + // The header's checksum fails: the whole log is ignored. + let mut bad_header = wal.clone(); + bad_header[12] ^= 0x01; + cases.push(("header", bad_header, 10)); + + for (label, damaged, expected) in cases { + let dir = Dir::new(&format!("damaged-{label}")); + let db = plant(&dir.0, "codegraph.db", &main, Some(&damaged)); + let probed = probe_schema_version(&db).unwrap(); + let recovered = sqlite_recovers(&work.0, &main, Some(&damaged)); + assert_eq!(recovered, expected, "{label}: SQLite's own recovery"); + assert_eq!(probed, ProbedSchema::Version(recovered), "{label}"); + } + } + + #[test] + fn what_is_not_a_sqlite_database_reads_as_such() { + let dir = Dir::new("not-sqlite"); + assert_eq!( + probe_schema_version(&dir.0.join("absent.db")).unwrap(), + ProbedSchema::Absent + ); + let empty = plant(&dir.0, "empty.db", b"", None); + assert_eq!( + probe_schema_version(&empty).unwrap(), + ProbedSchema::NotSqlite + ); + let garbage = plant(&dir.0, "garbage.db", &[7u8; 4096], None); + assert_eq!( + probe_schema_version(&garbage).unwrap(), + ProbedSchema::NotSqlite + ); + } + + #[test] + fn a_database_without_schema_versions_is_version_zero() { + let dir = Dir::new("no-table"); + let db = dir.0.join("plain.db"); + let conn = Connection::open(&db).unwrap(); + conn.execute_batch("CREATE TABLE other (x); INSERT INTO other VALUES (1);") + .unwrap(); + drop(conn); + assert_eq!(probe_schema_version(&db).unwrap(), ProbedSchema::Version(0)); + } + + #[test] + fn a_damaged_sqlite_database_is_an_error() { + let dir = Dir::new("damaged-db"); + let db = dir.0.join("damaged.db"); + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE schema_versions (version INTEGER PRIMARY KEY, applied_at INTEGER, description TEXT); + INSERT INTO schema_versions VALUES (10, 0, 'ten');", + ) + .unwrap(); + drop(conn); + let mut bytes = std::fs::read(&db).unwrap(); + // Page 2 holds `schema_versions`; overwrite its b-tree header. + let page_size = usize::from(u16::from_be_bytes([bytes[16], bytes[17]])); + bytes[page_size..page_size + 8].fill(0xff); + std::fs::write(&db, &bytes).unwrap(); + assert!(matches!( + probe_schema_version(&db), + Err(SchemaVersionReadError::Sqlite(_)) + )); + } +} diff --git a/crates/codegraph-store/src/uninit.rs b/crates/codegraph-store/src/uninit.rs index 4a845347..2e3fc0ce 100644 --- a/crates/codegraph-store/src/uninit.rs +++ b/crates/codegraph-store/src/uninit.rs @@ -148,6 +148,19 @@ pub fn uninit_index_with_drain( uninit_index_with(paths, deadline, cancelled, drain, &mut NoFault) } +/// The states `uninit` removes. An index an older version built is removed as +/// it is: uninit reads no row of it, so it need not be rebuilt first. A +/// refusal elsewhere offers `uninit --force` only for these. +pub(crate) fn uninit_accepts(status: &ExtractionStatus) -> bool { + matches!( + status, + ExtractionStatus::Current + | ExtractionStatus::Building { .. } + | ExtractionStatus::Uninitialized + | ExtractionStatus::Outdated { .. } + ) +} + fn uninit_index_with( paths: &IndexPaths, deadline: Instant, @@ -155,16 +168,8 @@ fn uninit_index_with( drain: impl FnOnce() -> Result<(), String>, fault: &mut impl UninitFault, ) -> Result { - // An index an older version built is removed as it is: uninit reads no - // row of it, so it need not be rebuilt first. let visible = Store::extraction_status(paths); - if !matches!( - visible, - ExtractionStatus::Current - | ExtractionStatus::Building { .. } - | ExtractionStatus::Uninitialized - | ExtractionStatus::Outdated { .. } - ) { + if !uninit_accepts(&visible) { return Err(UninitError::StateRejected { status: visible }); } @@ -176,13 +181,7 @@ fn uninit_index_with( other => unreachable!("uninit purpose returned unexpected Store open: {other:?}"), }; if authorization.purpose() != StoreWritePurpose::UninitContinuation - || !matches!( - authorization.status(), - ExtractionStatus::Current - | ExtractionStatus::Building { .. } - | ExtractionStatus::Uninitialized - | ExtractionStatus::Outdated { .. } - ) + || !uninit_accepts(authorization.status()) { return Err(UninitError::StateRejected { status: authorization.status().clone(), diff --git a/crates/codegraph-store/tests/future_schema.rs b/crates/codegraph-store/tests/future_schema.rs new file mode 100644 index 00000000..ff13dadc --- /dev/null +++ b/crates/codegraph-store/tests/future_schema.rs @@ -0,0 +1,451 @@ +//! An index a newer codegraph wrote (OD-19): its `schema_versions` records a +//! schema above this binary's. Every open that would read, write or replace +//! the database refuses before it changes a byte, including a version that is +//! only in the write-ahead log; `uninit` still removes it. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use codegraph_core::IndexPaths; +use codegraph_store::migrations::CURRENT_SCHEMA_VERSION; +use codegraph_store::{ + CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, EXTRACTION_VERSION_KEY, ExtractionStatus, + IndexLease, RebuildError, RebuildKind, Store, StoreError, StoreWriteOpen, StoreWritePurpose, + begin_full_rebuild, checksum_hex, resume_full_rebuild, uninit_index, +}; +use rusqlite::Connection; +use serde_json::json; + +const NEWER: i64 = CURRENT_SCHEMA_VERSION + 1; +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + +struct TempProject(PathBuf); + +impl TempProject { + fn new(label: &str) -> Self { + let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "codegraph-future-schema-{label}-{}-{serial}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir(&path) + .unwrap_or_else(|error| panic!("create temp project {}: {error}", path.display())); + Self(path.canonicalize().expect("canonical temp project")) + } + + fn paths(&self) -> IndexPaths { + IndexPaths::resolve(&self.0, None).expect("resolve test paths") + } +} + +impl Drop for TempProject { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn deadline() -> Instant { + Instant::now() + Duration::from_secs(5) +} + +fn state_bytes(phase: &str, owner: &str) -> Vec { + serde_json::to_vec(&json!({ + "sequence": 1, + "storageProtocol": CURRENT_STORAGE_PROTOCOL, + "extractionVersion": CURRENT_EXTRACTION_VERSION, + "phase": phase, + "projectIdentity": owner, + "checksum": checksum_hex(1, CURRENT_STORAGE_PROTOCOL, CURRENT_EXTRACTION_VERSION, phase, owner), + })) + .expect("serialize state fixture") +} + +fn write_state(paths: &IndexPaths, phase: &str) { + std::fs::write( + &paths.state_slots()[0], + state_bytes(phase, paths.project_identity()), + ) + .expect("write state fixture"); +} + +fn sidecar(db: &Path, suffix: &str) -> PathBuf { + let mut path = db.as_os_str().to_os_string(); + path.push(suffix); + PathBuf::from(path) +} + +/// A current-format database at `db` (this binary's schema, the current +/// extraction stamp), closed and without sidecars. +fn create_database(db: &Path) { + let store = Store::open(db).expect("create database fixture"); + store + .set_project_metadata( + EXTRACTION_VERSION_KEY, + &CURRENT_EXTRACTION_VERSION.to_string(), + ) + .expect("stamp database fixture"); + store + .restore_default_pragmas() + .expect("checkpoint database fixture"); + drop(store); + for suffix in ["-wal", "-shm"] { + let _ = std::fs::remove_file(sidecar(db, suffix)); + } +} + +/// Record schema `NEWER` in `db`, checkpointed into the main file. +fn record_newer_schema(db: &Path) { + let conn = Connection::open(db).expect("open database fixture"); + conn.execute( + "INSERT INTO schema_versions (version, applied_at, description) VALUES (?1, 0, 'newer')", + [NEWER], + ) + .expect("record the newer schema"); + conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);") + .expect("checkpoint"); + drop(conn); + for suffix in ["-wal", "-shm"] { + let _ = std::fs::remove_file(sidecar(db, suffix)); + } +} + +/// Record schema `NEWER` in `db`'s write-ahead log only, as a newer binary +/// killed before its checkpoint leaves it: the main file still records this +/// binary's schema. +fn record_newer_schema_in_the_log_only(db: &Path) { + let conn = Connection::open(db).expect("open database fixture"); + conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0;") + .expect("keep the log"); + conn.execute( + "INSERT INTO schema_versions (version, applied_at, description) VALUES (?1, 0, 'newer')", + [NEWER], + ) + .expect("record the newer schema"); + let main = std::fs::read(db).expect("read main file"); + let wal = std::fs::read(sidecar(db, "-wal")).expect("read log"); + drop(conn); + std::fs::write(db, main).expect("restore main file"); + std::fs::write(sidecar(db, "-wal"), wal).expect("restore log"); + let _ = std::fs::remove_file(sidecar(db, "-shm")); +} + +/// Every file under the index root: its length and a digest of its bytes, so a +/// failed comparison names the changed files instead of printing them. +fn snapshot(paths: &IndexPaths) -> BTreeMap { + use std::hash::{Hash, Hasher}; + let mut files = BTreeMap::new(); + let mut pending = vec![paths.current_root().to_path_buf()]; + while let Some(dir) = pending.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries { + let entry = entry.expect("read index root entry"); + let path = entry.path(); + if entry.file_type().expect("entry type").is_dir() { + pending.push(path); + } else { + let bytes = std::fs::read(&path).expect("read index file"); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut hasher); + files.insert(path, (bytes.len(), hasher.finish())); + } + } + } + files +} + +fn stage_current(project: &TempProject) -> IndexPaths { + let paths = project.paths(); + let lease = + IndexLease::create_exclusive(&paths, deadline(), || false).expect("create namespace"); + create_database(&paths.current_db()); + write_state(&paths, "current"); + drop(lease); + paths +} + +/// A `FutureSchema` refusal for `NEWER`, offering `uninit --force` exactly +/// when `discard` (the states uninit accepts). +fn assert_future_schema(error: &StoreError, discard: bool) { + match error { + StoreError::FutureSchema { + found, + supported, + discard_with_uninit, + .. + } => { + assert_eq!(*found, NEWER); + assert_eq!(*supported, CURRENT_SCHEMA_VERSION); + assert_eq!(*discard_with_uninit, discard, "{error}"); + assert_eq!(error.to_string().contains("uninit"), discard, "{error}"); + } + other => panic!("expected FutureSchema, got {other:?}"), + } +} + +/// An unreadable version says nothing was changed and where to look next. +fn assert_unreadable_message(error: &StoreError) { + let text = error.to_string(); + assert!( + text.contains("nothing was changed") && text.contains("codegraph status"), + "{text}" + ); +} + +fn assert_rebuild_refused( + result: Result, + discard: bool, +) { + match result { + Err(RebuildError::Store(error)) => assert_future_schema(&error, discard), + Err(other) => panic!("expected a FutureSchema refusal, got {other:?}"), + Ok(_) => panic!("the rebuild replaced a newer codegraph's index"), + } +} + +#[test] +fn reads_and_writes_refuse_a_newer_schema_without_changing_a_byte() { + let project = TempProject::new("current"); + let paths = stage_current(&project); + record_newer_schema(&paths.current_db()); + let before = snapshot(&paths); + + let Err(error) = Store::open_for_read(&paths, deadline(), || false) else { + panic!("a read opened a newer codegraph's index"); + }; + assert_future_schema(&error, true); + let Err(error) = Store::open_for_status(&paths, deadline(), || false) else { + panic!("status opened a newer codegraph's index"); + }; + assert_future_schema(&error, true); + for purpose in [ + StoreWritePurpose::CurrentMutation, + StoreWritePurpose::IncrementalSync, + StoreWritePurpose::FullRebuild, + ] { + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("acquire the namespace"); + let error = Store::open_for_write(&paths, lease, purpose) + .err() + .unwrap_or_else(|| panic!("{purpose} opened a newer codegraph's index")); + assert_future_schema(&error, true); + } + assert_eq!(snapshot(&paths), before, "a refused open changed the index"); +} + +#[test] +fn an_unreadable_schema_version_fails_closed() { + let project = TempProject::new("unreadable"); + let paths = stage_current(&project); + let conn = Connection::open(paths.current_db()).unwrap(); + conn.execute_batch( + "DROP TABLE schema_versions; + CREATE TABLE schema_versions (version TEXT, applied_at INTEGER, description TEXT); + INSERT INTO schema_versions VALUES ('ten', 0, 'not a number'); + PRAGMA wal_checkpoint(TRUNCATE);", + ) + .unwrap(); + drop(conn); + for suffix in ["-wal", "-shm"] { + let _ = std::fs::remove_file(sidecar(&paths.current_db(), suffix)); + } + let before = snapshot(&paths); + match Store::open_for_read(&paths, deadline(), || false) { + Err(error @ StoreError::ReadSchemaVersion { .. }) => assert_unreadable_message(&error), + other => panic!("expected ReadSchemaVersion, got {:?}", other.map(|_| ())), + } + assert_eq!(snapshot(&paths), before); +} + +#[test] +fn an_explicit_init_refuses_a_newer_schema() { + let project = TempProject::new("init"); + let paths = stage_current(&project); + record_newer_schema(&paths.current_db()); + let before = snapshot(&paths); + assert_rebuild_refused( + begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false), + true, + ); + assert_eq!(snapshot(&paths), before); +} + +#[test] +fn a_copied_project_is_refused_before_its_state_slots_are_removed() { + let project = TempProject::new("copied"); + let paths = stage_current(&project); + record_newer_schema(&paths.current_db()); + let other = TempProject::new("copied-from"); + let owner = other.paths().project_identity().to_string(); + std::fs::write(&paths.state_slots()[0], state_bytes("current", &owner)).unwrap(); + assert!(matches!( + Store::extraction_status(&paths), + ExtractionStatus::Corrupt { .. } + )); + let before = snapshot(&paths); + assert_rebuild_refused( + begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false), + false, + ); + assert_eq!( + snapshot(&paths), + before, + "the copied project's slots must stay" + ); +} + +#[test] +fn a_lockless_root_is_refused_before_it_gets_a_lock() { + let project = TempProject::new("lockless"); + let paths = project.paths(); + std::fs::create_dir_all(paths.current_root()).unwrap(); + create_database(&paths.current_db()); + record_newer_schema(&paths.current_db()); + assert!(!paths.permanent_lock().exists()); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Missing); + let before = snapshot(&paths); + assert_rebuild_refused( + begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false), + false, + ); + assert!( + !paths.permanent_lock().exists(), + "the refusal must come before the lock is created" + ); + assert_eq!(snapshot(&paths), before); +} + +#[test] +fn a_newer_schema_only_in_the_log_is_refused_by_init_and_by_a_sync_migration() { + // Missing, with the lock: an explicit init. + let project = TempProject::new("log-missing"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + create_database(&paths.current_db()); + drop(lease); + record_newer_schema_in_the_log_only(&paths.current_db()); + let before = snapshot(&paths); + assert_rebuild_refused( + begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false), + false, + ); + assert_eq!(snapshot(&paths), before); + + // An interrupted build: the migration an incremental sync escalates to. + let project = TempProject::new("log-building"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + create_database(&paths.current_db()); + write_state(&paths, "building"); + drop(lease); + record_newer_schema_in_the_log_only(&paths.current_db()); + let before = snapshot(&paths); + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false).unwrap(); + let authorization = + match Store::open_for_write(&paths, lease, StoreWritePurpose::IncrementalSync) { + Ok(StoreWriteOpen::FullRebuildRequired(authorization)) => authorization, + Ok(other) => panic!("expected a migration, got {other:?}"), + Err(error) => panic!("expected a migration, got {error:?}"), + }; + assert_rebuild_refused(resume_full_rebuild(&paths, authorization), true); + assert_eq!( + snapshot(&paths), + before, + "`building` must not be republished" + ); +} + +#[test] +fn garbage_in_a_missing_namespace_is_still_replaced() { + let project = TempProject::new("garbage"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + std::fs::write(paths.current_db(), [7u8; 4096]).unwrap(); + drop(lease); + let rebuild = begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false) + .expect("a file no codegraph wrote is replaced as before"); + assert!(!paths.current_db().exists(), "the rebuild removed it"); + drop(rebuild); +} + +#[test] +fn a_damaged_database_in_a_missing_namespace_is_refused() { + let project = TempProject::new("damaged"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + create_database(&paths.current_db()); + drop(lease); + let db = paths.current_db(); + let mut bytes = std::fs::read(&db).unwrap(); + let page_size = usize::from(u16::from_be_bytes([bytes[16], bytes[17]])); + // Every page after the first: the schema survives, the tables do not. + for page in bytes.chunks_mut(page_size).skip(1) { + page[..8].fill(0xff); + } + std::fs::write(&db, &bytes).unwrap(); + let before = snapshot(&paths); + match begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false) { + Err(RebuildError::Store(error @ StoreError::ReadSchemaVersion { .. })) => { + assert_unreadable_message(&error) + } + Err(other) => panic!("expected ReadSchemaVersion, got {other:?}"), + Ok(_) => panic!("the rebuild replaced a database it could not read"), + } + assert_eq!(snapshot(&paths), before); +} + +#[test] +fn uninit_still_removes_a_newer_schema_index() { + for phase in ["current", "building"] { + let project = TempProject::new(&format!("uninit-{phase}")); + let paths = stage_current(&project); + record_newer_schema(&paths.current_db()); + write_state(&paths, phase); + uninit_index(&paths, deadline(), || false) + .unwrap_or_else(|error| panic!("uninit of a {phase} newer index: {error}")); + assert!( + !paths.current_db().exists(), + "uninit removed the {phase} database" + ); + } +} + +/// A newer schema only in the log of a `Current` index: the writers that +/// tolerate a live log (an incremental sync, the daemon's recovery of a dead +/// owner's log) refuse before their writable open, so no `-shm` appears and no +/// checkpoint folds the log; a read refuses the log as today. +#[test] +fn a_current_index_with_a_newer_schema_only_in_its_log_is_never_touched() { + let project = TempProject::new("current-log"); + let paths = stage_current(&project); + record_newer_schema_in_the_log_only(&paths.current_db()); + let before = snapshot(&paths); + assert!( + !sidecar(&paths.current_db(), "-shm").exists(), + "the fixture starts without -shm" + ); + + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false).unwrap(); + let Err(error) = Store::open_for_write(&paths, lease, StoreWritePurpose::IncrementalSync) + else { + panic!("an incremental sync opened a newer codegraph's index"); + }; + assert_future_schema(&error, true); + assert_eq!(snapshot(&paths), before, "the sync touched the index"); + + let Err(error) = Store::recover_stale_current_sidecars(&paths, deadline(), || false) else { + panic!("the recovery folded a newer codegraph's log"); + }; + assert_future_schema(&error, true); + assert_eq!(snapshot(&paths), before, "the recovery touched the index"); + + assert!(Store::open_for_read(&paths, deadline(), || false).is_err()); + assert_eq!(snapshot(&paths), before, "the read touched the index"); + + uninit_index(&paths, deadline(), || false).expect("uninit still removes it"); + assert!(!paths.current_db().exists()); +} diff --git a/docs/cli.md b/docs/cli.md index 9fb29bc5..120bc275 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1407,6 +1407,16 @@ the index, even one an interrupted `init` left `building`, because only `init` removes the tombstone; `status` and the read errors name `codegraph init` as the recovery then. +### An index a newer CodeGraph wrote + +A database recording a schema newer than this binary's is left alone: `search` +and the other reads, `sync`, `init`, `index --force`, MCP tools and the daemon +refuse it, change nothing, and name both schema versions. `status` reports it as +`current` with `initialized: false` and the versions in `extractionStatusDetail`. +Upgrade CodeGraph to use it again. To discard it instead, run `codegraph uninit +--force` and then `codegraph init`; the refusal offers that only where `uninit` +accepts the index's state. + ### Index diagnostics `init`, `index`, and `sync` share `--debug` and `--debug-log `. diff --git a/docs/data-model.md b/docs/data-model.md index b2560eba..5ac8ff9c 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -45,6 +45,18 @@ Schema version and extraction version are independent. A DDL change needs a schema migration; a change in graph meaning may need an extraction-version move even when DDL is unchanged. +A database whose `schema_versions` records a version above this binary's was +written by a newer codegraph, and this binary neither reads, writes nor replaces +it. Reads, `status`, a sync, an MCP session, a daemon, `init` and `index --force` +refuse it before they change a byte. The check sees what the database's next +opener would see, including a version that is only in an un-checkpointed +write-ahead log: where a log may exist, the main file and the log's committed +frames are read into memory and checked there, as SQLite's recovery would apply +them, so no `-wal` or `-shm` is created or folded. A database whose version cannot +be read is refused as well. A rebuild still replaces a file that is not a SQLite +database at all. `uninit --force` skips the schema check and removes such an +index from the states it accepts (current, building, outdated, uninitialized). + ## Application tables ### `schema_versions` diff --git a/docs/mcp.md b/docs/mcp.md index f3632c37..c13f29c4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -461,11 +461,15 @@ LLM a high-level structural summary of the entire codebase. ## Error Channels -Two distinct error channels: +Three error channels: - **Unknown tool name** — JSON-RPC error `-32602` (invalid params). - **Missing or invalid required argument** — tool result with `{content: ..., isError: true}` and `Error: ` body. +- **A project whose index cannot be opened** — the same `isError` tool result, + `Failed to open project at : `. An index a newer CodeGraph wrote + is one: the reason names both schema versions and nothing in the index changes + ([`cli.md`](cli.md#an-index-a-newer-codegraph-wrote)). --- From e5a8695cebb2228dd4231530b69f58ca5ccacf22 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 23 Oct 2026 13:54:01 +0800 Subject: [PATCH 03/19] test(mcp): give every engine test its own directory and remove it test_engine() returned an engine over /tmp/cg-mcp-engine-{pid}-{seq} and never removed the directory, so a reused pid opened a stale index and engine tests failed locally. TestEngine derefs to the engine, removes a stale directory before it creates one, and on drop closes the engine before removing its directory. --- crates/codegraph-mcp/src/engine.rs | 90 ++++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 10 deletions(-) diff --git a/crates/codegraph-mcp/src/engine.rs b/crates/codegraph-mcp/src/engine.rs index 6b6709de..a4d74992 100644 --- a/crates/codegraph-mcp/src/engine.rs +++ b/crates/codegraph-mcp/src/engine.rs @@ -5797,23 +5797,93 @@ mod tests { static TEMP_SEQ: AtomicUsize = AtomicUsize::new(0); - fn test_engine() -> CodeGraphEngine { + /// An engine over an empty index in its own directory, which is removed + /// when the engine is dropped. + struct TestEngine { + engine: Option, + root: PathBuf, + } + + impl std::ops::Deref for TestEngine { + type Target = CodeGraphEngine; + + fn deref(&self) -> &CodeGraphEngine { + self.engine.as_ref().expect("the engine lives until drop") + } + } + + impl std::ops::DerefMut for TestEngine { + fn deref_mut(&mut self) -> &mut CodeGraphEngine { + self.engine.as_mut().expect("the engine lives until drop") + } + } + + impl Drop for TestEngine { + fn drop(&mut self) { + // Close the index first: Windows cannot remove an open database. + drop(self.engine.take()); + let _ = std::fs::remove_dir_all(&self.root); + } + } + + fn test_engine() -> TestEngine { let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed); - let base = std::env::temp_dir().join(format!("cg-mcp-engine-{}-{seq}", std::process::id())); + test_engine_at( + std::env::temp_dir().join(format!("cg-mcp-engine-{}-{seq}", std::process::id())), + ) + } + + /// The directory is keyed by pid and sequence number, so a dead process + /// whose pid came back may have left one with its own index there: remove + /// it first, or the test reads that run's index instead of an empty one. + fn test_engine_at(base: PathBuf) -> TestEngine { + match std::fs::remove_dir_all(&base) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("remove the stale {}: {error}", base.display()), + } let db = base.join(".codegraph").join("codegraph.db"); std::fs::create_dir_all(db.parent().unwrap()).unwrap(); let store = Store::open(&db).unwrap(); - CodeGraphEngine { - store, - project_root: base, - config: Arc::new(Config::default()), - deprioritize: Arc::new(DeprioritizeMatcher::default()), - source_probes: RefCell::new(HashMap::new()), - source_citations: RefCell::new(HashSet::new()), - answer_files: RefCell::new(BTreeSet::new()), + TestEngine { + engine: Some(CodeGraphEngine { + store, + project_root: base.clone(), + config: Arc::new(Config::default()), + deprioritize: Arc::new(DeprioritizeMatcher::default()), + source_probes: RefCell::new(HashMap::new()), + source_citations: RefCell::new(HashSet::new()), + answer_files: RefCell::new(BTreeSet::new()), + }), + root: base, } } + #[test] + fn a_test_engine_never_reads_what_a_reused_directory_holds() { + let base = std::env::temp_dir().join(format!( + "cg-mcp-engine-stale-{}-{}", + std::process::id(), + TEMP_SEQ.fetch_add(1, Ordering::Relaxed) + )); + let db = base.join(".codegraph").join("codegraph.db"); + std::fs::create_dir_all(db.parent().unwrap()).unwrap(); + std::fs::write(&db, b"left by a dead process with the same pid").unwrap(); + std::fs::write(base.join("stale.txt"), b"stale").unwrap(); + + let engine = test_engine_at(base.clone()); + assert!( + !base.join("stale.txt").exists(), + "the stale directory stays" + ); + assert!( + engine.store.all_files().unwrap().is_empty(), + "a test engine starts over an empty index" + ); + drop(engine); + assert!(!base.exists(), "dropping the engine removes its directory"); + } + fn node(name: &str, file: &str, start: i64, end: i64, kind: NodeKind) -> Node { Node { id: format!("{kind:?}:{name}:{start}"), From 301a172b3d50bcb1a26d960c9913dc3c0f9d4414 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 23 Oct 2026 13:54:01 +0800 Subject: [PATCH 04/19] test(probes): std-typed Rust receivers stay unresolved The member-path std gate deferred on 2026-10-03 needs no code: the v0.55 port of upstream #2169 already keeps a receiver whose declared type the project does not declare (PathBuf, &Path, std::path::PathBuf) from binding a project method by name. The probe pins it: index_root.join, base.join, p.join and q.join stay unresolved and reach no LineIndex::join, while env.set() still reaches EnvGuard::set. --- crates/codegraph-cli/tests/probes/main.rs | 29 ++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/codegraph-cli/tests/probes/main.rs b/crates/codegraph-cli/tests/probes/main.rs index 2238cb2b..45e14e85 100644 --- a/crates/codegraph-cli/tests/probes/main.rs +++ b/crates/codegraph-cli/tests/probes/main.rs @@ -12818,7 +12818,7 @@ mod go { } mod rust { - use super::support::{Graph, Project}; + use super::support::{Graph, Project, assert_edge, assert_no_edge, assert_unresolved}; /// `kind:qualifiedName` of every `calls` / `references` / `instantiates` /// target out of the functions named `name` (upstream's `targets`). @@ -12852,6 +12852,33 @@ mod rust { .collect() } + /// The member-path std gate deferred on 2026-10-03 needs no change: since + /// the #2169 port, a receiver whose declared type the project does not + /// declare (`PathBuf`, `&Path`, `std::path::PathBuf`) binds no project + /// method by name, so `index_root.join(..)` no longer reaches the project's + /// `LineIndex::join`; a project-typed receiver keeps its method. + #[test] + fn pr2169_a_std_typed_rust_receiver_binds_no_project_method_by_name() { + let project = Project::new() + .file("Cargo.toml", "[package]\nname = \"app\"\nversion = \"0.1.0\"\n") + .file( + "src/lib.rs", + "use std::path::{Path, PathBuf};\n\npub struct LineIndex;\nimpl LineIndex {\n pub fn join(&self, _x: &str) -> usize { 0 }\n}\n\npub struct EnvGuard;\nimpl EnvGuard {\n pub fn set(&self) {}\n}\npub fn env_guard() -> EnvGuard { EnvGuard }\n\npub fn annotated(index_root: PathBuf) -> PathBuf {\n index_root.join(\"x\")\n}\n\npub fn let_typed() -> PathBuf {\n let base: PathBuf = PathBuf::from(\"a\");\n base.join(\"b\")\n}\n\npub fn path_typed(p: &Path) -> PathBuf {\n p.join(\"c\")\n}\n\npub fn std_path(q: std::path::PathBuf) -> PathBuf {\n q.join(\"d\")\n}\n\npub fn project_typed() {\n let mut env = env_guard();\n env.set();\n}\n", + ) + .index(); + + for (function, call) in [ + ("annotated", "index_root.join"), + ("let_typed", "base.join"), + ("path_typed", "p.join"), + ("std_path", "q.join"), + ] { + assert_no_edge!(project, function => calls => "method LineIndex::join"); + assert_unresolved!(project, function => calls => call); + } + assert_edge!(project, "project_typed" => calls => "method EnvGuard::set"); + } + /// Upstream #2124: a bare Rust name reaches only what is in scope — /// `Some(x)` / `Ok(x)` the prelude's, not a project variant or a struct /// another file declares; a variant through a `use` of its enum's `*`; a From 98b7052b35bd506907bc4dfcaacc1d814de8a512 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 23 Oct 2026 15:57:40 +0800 Subject: [PATCH 05/19] build(deps): bump the grammar crates held back from #334 Takes dependabot #334 except tree-sitter-swift: tree-sitter 0.27.1, tree-sitter-language 0.1.9, erlang 0.21.0, php 0.25.1, scala =0.26.3, cfml 0.26.43, and the windows-sys reselection. Every golden, the four precision corpora and this repository index identically before and after. tree-sitter-swift stays at =0.7.3 (OD-B4). 0.7.4 fixes the scanner line behind upstream #2683 (`1UL << 32`, which MSVC folds to 0, so the Windows build reads `try! Foo()` as a call of `! Foo`), but it also regenerates the grammar: a call after an infix or prefix operator (`a + b.c()`, `!a.b()`) comes out as another tree, which the walker does not read as a member call. Upgrading it lost an exact-match edge in the swift golden and turned two Alamofire callees into whole expressions; the upgrade waits for that walker change. The #2683 probe pins calls after try!, try? and try. It is ignored on Windows until the upgrade; with the scanner line changed to what MSVC compiles it to, it fails on Linux the way upstream observed on Windows. No real Windows host was used. The grammar manifest records the held version, and languages.md the Windows limitation. --- Cargo.lock | 38 +++++++------- crates/codegraph-cli/tests/probes/main.rs | 64 +++++++++++++++++++++++ crates/codegraph-extract/Cargo.toml | 11 ++-- docs/equivalence.md | 2 +- docs/grammar-manifest.md | 12 +++++ docs/languages.md | 4 +- 6 files changed, 105 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee81d1a9..faf3c5b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,7 +1143,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1820,7 +1820,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" dependencies = [ "io-lifetimes 3.0.1", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2384,7 +2384,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2702,7 +2702,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2771,7 +2771,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3217,7 +3217,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3572,9 +3572,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2038684e0058edba0d17302619f62eabce4a8e11c6ac59506996a8d79848851d" +checksum = "a5129de2ccc97808c4583d48f1fb44576d015cf241e432800572bda11ef30c3b" dependencies = [ "cc", "regex", @@ -3615,9 +3615,9 @@ dependencies = [ [[package]] name = "tree-sitter-cfml" -version = "0.26.42" +version = "0.26.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf1c57e5c21ce03a08338c67441b40125227066634297ea0c2c8621372541e95" +checksum = "68ebd7091acf7ec18caf8e99c6b95116bc03b04173244d8ef26c0c185631bb9a" dependencies = [ "cc", "tree-sitter-language", @@ -3655,9 +3655,9 @@ dependencies = [ [[package]] name = "tree-sitter-erlang" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3107efc581d335488dc38d2f51ca8b7a6a98e36f07f95a8e963f031edda6f568" +checksum = "8079a9cfe733c3b6749bb3c683c0e997ee68a2305f7a210800bbbc53e82d3f64" dependencies = [ "cc", "tree-sitter-language", @@ -3745,9 +3745,9 @@ dependencies = [ [[package]] name = "tree-sitter-language" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" +checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a" [[package]] name = "tree-sitter-lua" @@ -3801,9 +3801,9 @@ dependencies = [ [[package]] name = "tree-sitter-php" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" +checksum = "d9779e392d38af4254f48be0e57b7eed4d50ec7a6e55a7e5e2e74df8f8f032d3" dependencies = [ "cc", "tree-sitter-language", @@ -3861,9 +3861,9 @@ dependencies = [ [[package]] name = "tree-sitter-scala" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24e0ab4505990bfe30051761d40a7bf4033ce5a81c9eda9e20e987a5cdc84826" +checksum = "c554730d29306d9c7cdd26912638d2401378c26961e12d161a071df43f49b81b" dependencies = [ "cc", "tree-sitter-language", @@ -4233,7 +4233,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/codegraph-cli/tests/probes/main.rs b/crates/codegraph-cli/tests/probes/main.rs index 45e14e85..4cf91506 100644 --- a/crates/codegraph-cli/tests/probes/main.rs +++ b/crates/codegraph-cli/tests/probes/main.rs @@ -10547,6 +10547,70 @@ mod swift { "{report}" ); } + + /// Upstream #2683: a call written after `try!` links what it calls. Calls + /// after `try?` and `try` are the control. Upstream's TortureTry.swift, + /// reduced to calls whose callees the project declares. + /// + /// Not yet on Windows: the held tree-sitter-swift 0.7.3 builds the `try!` + /// entry of its scanner's 64-bit mask as `1UL << 32`, which MSVC folds to + /// 0, so there `try! Message2(…)` parses as a call of `! Message2` and + /// `try! Settings.Defaults.load()` loses its receiver. 0.7.4 fixes it but + /// regenerates the grammar, which needs a walker change first + /// (docs/grammar-manifest.md); drop the `ignore` with that upgrade. + #[test] + #[cfg_attr( + windows, + ignore = "tree-sitter-swift 0.7.3 misparses `try!` on MSVC (upstream #2683)" + )] + fn pr2683_a_call_after_try_bang_links_what_it_calls() { + let project = Project::new() + .file( + "App/Models.swift", + "import Foundation\n\nstruct Message2 {\n init(data: Data?) throws {}\n}\n\nfinal class Store {\n init(path: String) throws {}\n}\n\nenum Settings {\n enum Defaults {\n static func load(from name: String) throws -> Settings.Defaults { .init() }\n }\n}\n", + ) + .file( + "App/Repository.swift", + "import Foundation\n\nfinal class Repository {\n func load(data: Data?) throws -> Message2 {\n let msg2 = try! Message2(data: data!)\n let msg3 = try? Message2(data: data!)\n let msg4 = try Message2(data: data!)\n let store = try! Store(path: \"x\")\n let settings = try! Settings.Defaults.load(from: \"defaults\")\n try! validate(msg2)\n _ = (msg3, msg4, store, settings)\n return try! Message2(data: data!)\n }\n\n func validate(_ m: Message2) throws {}\n}\n", + ) + .index(); + let graph = project.graph(); + let lines = |kind: &str, target: &str| -> Vec { + let mut lines: Vec = graph + .edges_matching("method Repository::load", kind, target) + .iter() + .filter_map(|edge| edge.line) + .collect(); + lines.sort_unstable(); + lines + }; + let report = graph.report(); + assert_eq!( + lines("instantiates", "struct Message2"), + [5, 6, 7, 12], + "{report}" + ); + assert_eq!(lines("instantiates", "class Store"), [8], "{report}"); + assert_eq!( + lines("calls", "method Settings::Defaults::load"), + [9], + "{report}" + ); + assert_eq!( + lines("calls", "method Repository::validate"), + [10], + "{report}" + ); + // The misparse left references named after the `!`. + assert!( + graph + .unresolved + .iter() + .all(|reference| !reference.reference_name.starts_with('!')), + "{}", + graph.unresolved_list() + ); + } } mod objc { diff --git a/crates/codegraph-extract/Cargo.toml b/crates/codegraph-extract/Cargo.toml index 10b46f92..ff9d3920 100644 --- a/crates/codegraph-extract/Cargo.toml +++ b/crates/codegraph-extract/Cargo.toml @@ -32,7 +32,7 @@ tree-sitter-cfml = "0.26.30" tree-sitter-cpp = "0.23.4" tree-sitter-css = "0.25.0" tree-sitter-dart = "0.2.0" -tree-sitter-erlang = "0.20.0" +tree-sitter-erlang = "0.21.0" tree-sitter-go = "0.25.0" tree-sitter-gdscript = "6.1.0" tree-sitter-hcl = "1.1.0" @@ -46,15 +46,18 @@ tree-sitter-luau = "1.2.0" tree-sitter-nix = "0.3.0" tree-sitter-objc = "3.0.2" tree-sitter-pascal = "0.10.2" -tree-sitter-php = "0.24.2" +tree-sitter-php = "0.25.1" tree-sitter-properties = "0.3.0" tree-sitter-python = "0.25.0" tree-sitter-r = "1.2.0" tree-sitter-ruby = "0.23.1" tree-sitter-rust = "0.24.2" -tree-sitter-scala = "=0.26.2" +tree-sitter-scala = "=0.26.3" tree-sitter-solidity = "1.2.13" -tree-sitter-swift = "0.7.3" +# Held at 0.7.3: 0.7.4 regenerates the grammar, and its tree for a call after +# an infix or prefix operator (`a + b.c()`, `!a.b()`) needs a walker change +# first (docs/grammar-manifest.md). +tree-sitter-swift = "=0.7.3" tree-sitter-typescript = "0.23.2" tree-sitter-xml = "0.7.0" tree-sitter-yaml = "0.7.2" diff --git a/docs/equivalence.md b/docs/equivalence.md index 38d32118..a1d2249b 100644 --- a/docs/equivalence.md +++ b/docs/equivalence.md @@ -414,7 +414,7 @@ self-equivalence. fixes from upstream #1823 and #1824. Its two-file corpus under `crates/codegraph-bench/fixtures/scala/` pins all of these as one contract: -- the `tree-sitter-scala` 0.26.2 grammar accepts a class with multiple +- the `tree-sitter-scala` 0.26.3 grammar accepts a class with multiple constructor parameter lists whose parent constructor also has multiple lists; - one `extends A(...) with B with C` clause emits three separate, bare-name `Extends` edges; diff --git a/docs/grammar-manifest.md b/docs/grammar-manifest.md index 1c47a809..5540a16d 100644 --- a/docs/grammar-manifest.md +++ b/docs/grammar-manifest.md @@ -141,3 +141,15 @@ cargo test --locked -p codegraph-bench --test equivalence Review `cargo tree -p codegraph-extract` for duplicate/incompatible tree-sitter cores. Do not vendor a grammar or change a pin merely to match upstream's WASM packaging; the Rust crate ABI and actual fixtures decide compatibility. + +## Held grammar versions + +`tree-sitter-swift` is pinned exactly to 0.7.3. Its scanner builds the `try!` +entry of a 64-bit mask as `1UL << 32`, which a compiler with 32-bit `long` +(MSVC) folds to 0, so the Windows build reads `try! Foo()` as a call of `! Foo` +(upstream #2683). 0.7.4 fixes that line but regenerates the grammar: a call +after an infix or prefix operator (`a + b.c()`, `!a.b()`) comes out as another +tree, which the walker does not yet recognize as a member call. Upgrade it +together with that walker change; the probe +`pr2683_a_call_after_try_bang_links_what_it_calls`, ignored on Windows until +then, is the acceptance test. diff --git a/docs/languages.md b/docs/languages.md index 71749248..2ac56e03 100644 --- a/docs/languages.md +++ b/docs/languages.md @@ -107,10 +107,10 @@ manager call that returns one instance (`site = Site.objects.create(…)`, also | C# | `.cs` | Full tree-sitter | Every entry of a `base_list` is an `Extends` reference (type arguments and `global::` dropped); the resolver turns one that binds an interface into `Implements`. `new T(...)` is an `Instantiates` reference; a target-typed `new()` names no type, except as a field's or property's initializer, where it instantiates the declared type (`List _items = new();` instantiates `List`). A property's accessor bodies, expression body and `= initializer`, and each field declarator's initializer, are walked as that member: their calls, instantiations, static reads, lambdas and function values are its; attributes are not walked. Type positions (property, return, field and parameter types) are `References`; a qualified name by its last segment. A `const` or `static readonly` field is a constant; any other field stays a field. A block `namespace X { … }` qualifies only its body: a file's second block namespace, or one written inside another (`Outer.Inner`), is a namespace node of its own, and a `using` outside every block is the file's. A type position (`Type name`, `List`, `Type?`, `Type[]`, a generic argument, `new T`) names only a type, never a property, method (a constructor), enum case, field or constant of that name. A bare type name is one its file can see: in its namespace or one around it, or imported by the file's `using`, by the `global using` of any file of its project (the project is the outermost directory holding a `.csproj`; outside one, any file's), by the `` of a `.csproj` or `.props` file above it, by the SDK's implicit usings under `enable`, or by an alias naming it; a nested type is reached by bare name only from inside its owner or a type deriving from it, through any partial part. A `using X;` names the project's namespace `X` or stays unresolved, never another file's `using`. A bare name whose line shows no receiver (`TestContext.Current`, `Helper()`) is a member only of the types around it, of the bases their heads list (every partial declaration, by name, up to 40 types), or of a type a static using brings in: the file's `using static`, any file's `global using static`, or a `` of a `.csproj` or `.props` file above it; outside every type it is not judged. A call through a field or property (`_innerWriter.WriteValue(…)`, `writer.writeValue(…)`, `sink.write(…)`) is a call on the type the class around it declares the member with, or a class it inherits does (with the type arguments the subclass gives; a member typed by a type parameter takes the argument, else its bound), read from the classes' member-level lines; a member of a built-in type has no project method, and a type parameter with no bound reaches nothing. Without an access modifier an interface's member is `public`, a type in a namespace or the file `internal`, any other member `private`. A using alias names its type as a receiver (`Assert.AreEqual` under `using Assert = …XUnitAssert;`), a keyword type (`string.Equals`) is no project type, and a standard .NET method name (`table.Columns.Add`, `reader.Value.ToString()`) on an untyped receiver reaches a project method only when the receiver names its owner. A local, parameter or member of a keyword type (`this string format`) reaches only the project's extension method for it (KEEP-RUST), never a standard .NET name. | | PHP | `.php` `.module` `.install` `.theme` `.inc` | Full tree-sitter | `new T(...)` is an `Instantiates` reference named by the class name's last segment. Parameter, return and property type hints are `References` (primitives and `self`/`static`/`parent` are not); upstream reads no property type. `extends` and `implements` (and an interface's `extends` list) are supertypes by each name's last `\` segment. A file-scope `const` is a constant, as a class `const` is; a class property is a field, one per `$name`. A call through `$this->` (or `?->`), `self::`, `static::` or `parent::` reaches only a method of the class it is written in, of a class its `extends` chain names or a trait any of them `use`s (each name read through its file's `namespace` and `use` imports, at most 80 types), or, except through `parent::`, of a subclass; inside a trait it is not judged, and past an ancestor outside the repository a repository trait's method still counts. A call through a receiver nothing types takes the one project method of its name only when the receiver is named after its class (`$avatars->assignToUser()` → `UserAvatars`) or after a class whose ancestry has it (`$page->save()` → `Entity`, `Page` extending it). A call written without a receiver (`redirect(…)`, `'size' => filesize($zip)`, or `\helper()` from the global namespace) is a function call and reaches only a function. An unqualified class name is the class of the file's `namespace` or the one a `use` imports, never another namespace's; a class name written with a namespace (`new Field\FirstName()` through `use App\Fields as Field;`, `extends Sub\Base`, `\Ns\X::make()`) is read the way PHP reads it (a leading `\` is absolute, an imported first segment is replaced by what it imports, anything else is relative to the current namespace) and resolves to that one project class, or stays unresolved when the project has none, never to another node of its last segment's name; such a name, which the reference records by its last segment, is read back from the source at the reference's position. | | Ruby | `.rb` `.rake` | Full tree-sitter | A call with a receiver is named `receiver.method` (`self`/`super` leave the bare name; a constant receiver is also a `References`; `Foo.new` is an `Instantiates`). A constant assignment at the top level or in a class or module (`MAX = 3`) is a `variable` node. A bare call written inside a class is a call on `self`: it reaches only a method of the class, of its superclasses, or of a module any of them `include`s, `extend`s or `prepend`s, read from the declarations' source by qualified name (the superclass from a declaration's first line, the mixins from every line of its body; each constant resolved against the lexical nesting to a class or module the project declares, else taken as written; at most 60 classes and modules, declarations of one name in path order); a bare call in a module body, at the top of a file or in a script is not judged. A call through a receiver other than `self` / `self.class` takes the one method of its name only when the receiver is named after the method's owner (`web_push_request.legacy_encrypt` reaches `WebPushRequest`, `user_name.name` no lone `Person::name`). | -| Swift | `.swift` | Full tree-sitter | An `extension` is a class node, but a reference to a type never lands on one: it moves to the type's declaration (the one in the reference's file, else the nearest by directory, none on a tie) or, when the project only extends the type (SwiftUI's `View`), stays unresolved; a conformance to such a type is read from the conformer's head, so the extensions' members still reach it. A nested type is reached bare only from inside its parent, the type itself or a type whose head inherits from the parent, and a qualified name (`Build.Id`, read back from the line; generic arguments dropped) only on its path or under a module qualifier. A call through a type path (`API.PackageController.GetRoute.query(on:)`, two or more capitalized segments) keeps the path and reaches only the member on the type it names (an extension's written path counts, a path shortened inside its namespace or under a module qualifier fits; a TCA `@Reducer enum`'s `Path.State.case`), else nothing. A bare, `self.` or `super.` call reaches only a member of the types around it and what they inherit or conform to (their heads, UIKit / AppKit ancestry, the standard collection protocols), the nearest first (`super.` the inherited only); down a longer chain a standard-library name (`removeAll`, `contains`, …) needs a receiver naming the owner, an owner the project only extends, or a first argument label only the project's method takes; a member of what a constructor before the call builds, or of a property's declared type (`var cache: ImageCache!`, `self.x`, unless a function's own `let` shadows it), is that type's; a playground's or test target's globals are no other file's call target. Parameter and return types are `References`; a protocol composition continued on an `&` line inside a type body is joined before parsing. A stored property is a node: at the top level a `let` is a constant and a `var` a variable, in a type a `static let`/`static var` likewise and an instance one a field; a computed property is a property. | +| Swift | `.swift` | Full tree-sitter | An `extension` is a class node, but a reference to a type never lands on one: it moves to the type's declaration (the one in the reference's file, else the nearest by directory, none on a tie) or, when the project only extends the type (SwiftUI's `View`), stays unresolved; a conformance to such a type is read from the conformer's head, so the extensions' members still reach it. A nested type is reached bare only from inside its parent, the type itself or a type whose head inherits from the parent, and a qualified name (`Build.Id`, read back from the line; generic arguments dropped) only on its path or under a module qualifier. A call through a type path (`API.PackageController.GetRoute.query(on:)`, two or more capitalized segments) keeps the path and reaches only the member on the type it names (an extension's written path counts, a path shortened inside its namespace or under a module qualifier fits; a TCA `@Reducer enum`'s `Path.State.case`), else nothing. A bare, `self.` or `super.` call reaches only a member of the types around it and what they inherit or conform to (their heads, UIKit / AppKit ancestry, the standard collection protocols), the nearest first (`super.` the inherited only); down a longer chain a standard-library name (`removeAll`, `contains`, …) needs a receiver naming the owner, an owner the project only extends, or a first argument label only the project's method takes; a member of what a constructor before the call builds, or of a property's declared type (`var cache: ImageCache!`, `self.x`, unless a function's own `let` shadows it), is that type's; a playground's or test target's globals are no other file's call target. Parameter and return types are `References`; a protocol composition continued on an `&` line inside a type body is joined before parsing. A stored property is a node: at the top level a `let` is a constant and a `var` a variable, in a type a `static let`/`static var` likewise and an instance one a field; a computed property is a property. On Windows a call written after `try!` is misread (`try! Store(path: p)` is a call of `! Store`): the Swift grammar is held at 0.7.3, whose scanner loses `try!` when built with MSVC ([grammar manifest](grammar-manifest.md#held-grammar-versions)). | | Kotlin | `.kt` `.kts` | Full tree-sitter | Top-level functions, class methods, and extension functions include raw parameter-list signatures with optional declared return types; constructors, accessors, lambdas, and anonymous functions are not synthesized as callable nodes. Private definitions never become cross-file targets. Parameter and return types are `References` (an extension receiver is not). A property at the top level or in an `object` or companion object is a constant (`val`) or variable (`var`); a class property is a field; a local is none. A property's declared type is a `References` from it (an extension property's receiver is not); upstream reads no property type. An enum entry with a body of its own (`NewBuffer { override fun pipe() … }`) owns the methods and fields that body declares (`Factory::NewBuffer::pipe`). An infix call (`id eq 3`, `single { … } bind I::class`) is a call of the infix function, `id.eq` when the left operand is a plain name and `bind` otherwise; a literal left operand records none. A `when` guard (`is T if cond ->`) and a multi-dollar string (`$$"…"`), syntax newer than the grammar, are rewritten to same-length equivalents before the parse, so the class around them survives. Imports are read without a `;`, and `import a.b.C as D` binds `D` to `a.b.C` (`D.create()` reaches `C`'s member). A top-level declaration (a function, an extension function indexed under its receiver type, a property) is reached from its own package, an import of it, a star import of its package, or a package every Kotlin file imports. A bare call reaches a member of a type around it (a class, an anonymous `object : Base()`, an extension function's receiver, a lambda's receiver read from the last parameter type of the function it is passed to, through up to two typealiases), of what those inherit (class heads read from source by name, and Android's platform ancestry), of a type some project function type takes as a receiver or an outside type the project writes extensions on, or of an object the file imports; the code around the call wins over the project-wide receivers. `require(…)` / `check(…)` / `assert(…)` with a condition is Kotlin's precondition. A standard-library name on an untyped chain link (`.apply { }`, `.map { }`) or in an infix call on an expression (`alias(x) apply false`) reaches only a member its receiver is named after. A project's bitwise extension on a number type (`infix fun Byte.and(mask: Int)`) is never a call's target, by name, typed or guessed: `x and 0xff` is the standard library's. A Gradle script's bare call never reaches a member of a class the project declares. A name bound by an import from outside the project names nothing in it, unless the file declares it itself. A call through a field or property (`_innerWriter.WriteValue(…)`, `writer.writeValue(…)`, `sink.write(…)`) is a call on the type the class around it declares the member with, or a class it inherits does (with the type arguments the subclass gives; a member typed by a type parameter takes the argument, else its bound), read from the classes' member-level lines; a member of a built-in type has no project method, and a type parameter with no bound reaches nothing. | | Dart | `.dart` | Full tree-sitter | Dart 3 `extension type` declarations are class-like owners; getters and methods qualify under the extension type rather than leaking to file scope. A function's or method's signature types, the type an extension is `on`, a field's type (its class's), a top-level variable's (the file's) and every type a body or an initializer names (a local's, a generic argument, a cast, a type test, a catch clause) are `References`; a `new`/`const` constructor call's class, an import prefix and a lowercase name are not, and a class whose field holds its own type gets no self-edge (#2360). A class `extends` its base and `implements` each `with` mixin and `implements` interface (an enum's and a mixin's too; a mixin's `on` constraint is none); a mixin application `class A = B with M;` is named `A`. A member keeps the dartdoc written above its annotations, a member with no body included, and its annotations are `Decorates`. An annotation is a constant expression and links what it is written as, read from its `@` (#2390): a constant or, called, a class (no extension) its library can see, through the import prefix it is written with (`@riverpod`, `@Riverpod(keepAlive: true)`, `@meta.immutable`); a static constant, an enum value or, called, a named constructor of the type it is written through (`@Riverpod.scoped`, `@Retry.always`, `@Riverpod.forever()`); on a member, a static constant or enum value of the type around it, written bare; never a method, a getter or a function, so riverpod's `@riverpod` is riverpod_annotation's `const riverpod = Riverpod();`, not an extension getter of that name. KEEP-RUST: this grammar writes a dotted annotation's name as a `qualified` node, recorded by its last name as upstream records it. A generic call `ref.read()` calls `ref.read`. A top-level `const`/`final` or a class `static const`/`static final` is a constant, whose initializer is code it runs: its calls, instantiations, reads, types and function values are the constant's, and a field's or a `var`'s initializer is its class's or the file's (#2367). A named `const` constructor and a redirecting factory (`const BlocProvider.value(…)`, `const factory Foo.bar() = _Bar;`) are methods named by the constructor that return their class, with a parameters-only signature (an unnamed one is no node), and a redirecting factory references the class it constructs, an UpperCamel name of its target (#2380). A call through a type's name (`BlocProvider.value(…)`, `Provider.autoDispose(…)`, `BlocProvider.value(…)`) reaches that type's own constructor, static member or static constant (#2367), the call's own file's first, then the nearest, or nothing in the project; a named constructor is never reached by a bare name, by a function value (#2380) or through a value of its type: a typed parameter or local, or a chain's last link, calls an instance member or an extension's of that name, or nothing (KEEP-RUST: upstream's member lookup takes the constructor for an instance member), and by an annotation only as one that calls it through its type (#2390). KEEP-RUST: the annotations of a member with a body sit inside its node in this grammar, so whether it is a constructor, a getter or a setter is read from its signature past them. A member read through a plain name (`x.area`, `s?.label`, `Config.instance`) calls the getter its receiver's type reaches, declared on it or on a type it extends, mixes in or implements, else added by an extension `on` one of those (an unnamed one only in its own library), or links nothing: a field read, an outside type's getter, an untyped receiver; the receiver is a type named directly, a parameter or local the function declares, or a field of the class around it or of one it extends (#2360). A call on a typed receiver whose type declares no such member reaches the one an extension `on` it adds, an enum's too (#2360). A later link of a call chain (`r.load().save()`, `context.read().fetch()`, `BlocProvider.of(context).increment()`) calls the member of what the chain before it evaluates to (#2376, #2383): its head is what the local or parameter it names is declared as or initialized with, else a field or getter of the class around it, or a constant's declared type or initializer; what the function or method it calls is declared to return; the class a constructor call or a cast names; the type `this` or `super` is written in. Each link is its member's declared return type, read or called: a member returning its own type parameter returns the type argument the call gives, one returning its class's type parameter nothing; a lookup nothing in the project declares (`read()`, `watch()`, `get()`, `of()`, and the like) hands back its one type argument, and `toString()` a `String`. A static member is looked up on the type the head names, an instance member through the type's lineage, else on an extension `on` one of its types, an extension `on` any type ranking last and applying to a project type only; an initializer is read at most three deep, and a part nothing types links nothing. KEEP-RUST: the extractor records a later link where the chain starts, by its member alone after a call, a parenthesis, an index, `!`, `this` or `super`, or past the last segment of a member chain (`s.m`, #1496); the link is read back after that place in the same encoding. A function's or method's return type is the last name of the type its signature writes (`Future` is `Future`, `p.Todo` is `Todo`), a method is `static` as its signature says, `x?.m()` is recorded as `x.m()` is, and `self` and `cls` are plain names. `const Foo.named()` and `new Foo()` call the class and constructor they name (KEEP-RUST: this grammar writes them in the call's `type`, a named constructor as a second `type_identifier`, told from an import prefix by its case). The grammar ends an arrow function in front of the call or member access its body ends with (`(ref) => createDio()` reads as a call of `(ref) => createDio`); that call or access is the tail's, written where the tail starts (KEEP-RUST; upstream #2375 reads a receiver past the arrow the same way). A call written without a receiver (not a chain's later link, whose receiver the extractor drops) reaches a member of the class it is written in, of what that class extends, mixes in or implements, or of an extension `on` one of those types, the nearest one winning; outside any class, no member (upstream #2145). A function declared in a body is no member (KEEP-RUST: it is qualified by the function around it, `outer::make`), and a call in there reaches it. A call written without a receiver, or a function passed by name, whose name a parameter or local around it binds (a function's, method's, closure's, `catch` clause's or constructor's parameter, its initializer list included; a `for` loop's variable; a local a block declares, from there to the block's end: `final x = …`, `Foo? x;`, a destructuring, a pattern's `final x`) calls that parameter or local and links nothing, a local function declared nearer keeping its link (#2379). A String, List, Iterable, Map or Set method name on an untyped receiver reaches a project method only through a receiver named after its owner, an extension's owner being the type it is `on` (#2207). An unnamed `extension on X`, named ``, applies only in its own library, the file and its parts (#2237, #2386). A call, constructor call or type written without a receiver reaches only a top-level declaration its library can see: its own (the file and its `part`s; one of them shadows an imported namesake) or one a library it imports without a prefix exports, through `export` chains, `show`, `hide` and every alternative of a conditional import; a type written through an import prefix (`p.Report`) is what that import exports, and a bare call never reaches another type's enum constant or static constant (#2386). A call or constructor call through an import prefix (`fmt.describe(…)`, `http.get(…)`, `kit.Widget()`) reaches the one top-level declaration the libraries imported with that prefix export, `export` chains, `show` and `hide` followed and a part taking its library's prefixes, or nothing in the project: a package outside it, a name not shown or hidden, a deferred library's `loadLibrary`, two declarations of the name; never a member found by the receiver's name (#2389). A parameter, a local or a member of the type around it named like the prefix hides it, and a null-aware `p?.m()` is written on no prefix. A chain headed by an import prefix (`kit.Box.create()`, `fmt.make().save()`, `fmt.config.save()`) starts at what the prefix brings in: the type of that name, a function's declared return type, a constant's type. KEEP-RUST: a prefixed call is recorded at the prefix, where its chain starts, and a call through a type written behind a prefix (`p.Box.create()`, `p.Box.named()`) is that chain's link. A file with no `pubspec.yaml` at or above it, or whose `part of` names no library the index holds, sees every file. An `import`, `export` or `part` links the one indexed file its URI names: `package:/` is `/lib/` of the package whose `pubspec.yaml` says `name: ` (same-named packages told apart by the importer's own package, then the one enclosing it, then its `path:` dependency in `pubspec_overrides.yaml` or `pubspec.yaml`), any other URI a path from the directive's file; a `dart:` library, a package outside the project, a path out of it or a file the index lacks links nothing (#2388). A `part 'x.dart';` is an `import` node whose reference links the library to the part's file; `part of` records nothing (#2391). Only an indexed file's directives are read (KEEP-RUST). | -| Scala | `.scala` `.sc` | Full tree-sitter | Scala grammar 0.26.2; multiple constructor parameter lists and every `extends`/`with` parent are retained. Companion `object` nodes are Modules, while same-named class/trait types win inheritance resolution. `new T(...)` and `new T { ... }` are `Instantiates` references. Parameter types of every parameter list, the return type and type-parameter bounds are `References`. An `object`'s `val` is a constant and its `var` a variable, as at the top level; a class, trait, enum or given value is a field. A bare call reaches a member only where it is in reach: a later link of a chain (`List(1).map(…)`, and `a.b.c()`, recorded as `b.c`) only a member of what its receiver is named after, a type or object only through what holds it (its outer type, or for a top-level one its file's package: `app.client.Response(200)`), and a package object's function only when an `extension` line marks it; a name the enclosing definition binds only that local; otherwise a member of the types around the call, of an anonymous class's bases, of what those extend (heads read from source by name, at most 60 types), of an imported object or the package object of the file's package and what they extend, or anything when the file imports from a value (`import builder._`). A `val` written in a block of a class body (`test("a") { val f = … }`) is that block's, and a `val` or constant declared in any language's function body that body's. A member of `package object X` is in scope only in its package and those under it, or through an import naming it. A symbolic name in a type (`F ~> G`, a kind-projector `*`) is the one type or alias of that name, or nothing. | +| Scala | `.scala` `.sc` | Full tree-sitter | Scala grammar 0.26.3; multiple constructor parameter lists and every `extends`/`with` parent are retained. Companion `object` nodes are Modules, while same-named class/trait types win inheritance resolution. `new T(...)` and `new T { ... }` are `Instantiates` references. Parameter types of every parameter list, the return type and type-parameter bounds are `References`. An `object`'s `val` is a constant and its `var` a variable, as at the top level; a class, trait, enum or given value is a field. A bare call reaches a member only where it is in reach: a later link of a chain (`List(1).map(…)`, and `a.b.c()`, recorded as `b.c`) only a member of what its receiver is named after, a type or object only through what holds it (its outer type, or for a top-level one its file's package: `app.client.Response(200)`), and a package object's function only when an `extension` line marks it; a name the enclosing definition binds only that local; otherwise a member of the types around the call, of an anonymous class's bases, of what those extend (heads read from source by name, at most 60 types), of an imported object or the package object of the file's package and what they extend, or anything when the file imports from a value (`import builder._`). A `val` written in a block of a class body (`test("a") { val f = … }`) is that block's, and a `val` or constant declared in any language's function body that body's. A member of `package object X` is in scope only in its package and those under it, or through an import naming it. A symbolic name in a type (`F ~> G`, a kind-projector `*`) is the one type or alias of that name, or nothing. | | Lua | `.lua` | Full tree-sitter | `local f = function` emits one Function (not a duplicate Variable); `M.f = function`, string-key table assignments, and nested table constructor functions emit qualified Methods. Body calls belong to the synthesized callable; `f()`, `M.f()`, and `M:f()` resolve, while computed keys stay dynamic. A `require` is an `Imports` reference to the module file it names, or unresolved; it never binds the local it is assigned to. A `local` declaration is invisible outside its file, except to a bare call through a `require` alias that names it (upstream #2143). A bare call never exactly matches a table's method (`function M.x`, `function M:x`), and one named like a Lua global function (`ipairs`, `error`, busted's `it`, …) is the standard library's unless its file defines a function of that name (#2202). A bare call through a `local` alias follows it (#2188): `require "m"` and its members reach the module's function of the name, or what the module hands out under it (`return { check = check_phase }`, `M.x = y`), its own alias or its global, up to three modules deep; an alias of a global function (`local type = type`) or of an outside module's member is no edge; a library table's member (`ngx.sleep`) is the project's own patch of it when there is exactly one, and a host table's (`kong.response.exit`) the one Lua method of a table named after its holder. | | Luau | `.luau` | Full tree-sitter | Roblox Luau dialect | | Objective-C | `.m` `.mm` | Full tree-sitter | A message send is a call named by its full selector (`[c a:x b:y]` → `c.a:b:`, a `self`/`super` receiver leaves the bare selector); `@interface X : S

` extends `S` and implements `P`, and a class conforming to a protocol of its own name implements the protocol, its own header's first. A name in C call syntax (`completionBlock()`) calls a function, a block or a function pointer, never a method, property or field. A message to `self` reaches a member of the sender's hierarchy, read from the `@interface` heads it passes and, for a category on a system class, from UIKit and AppKit ancestry; one to `super` goes past the sender's own class. An untyped receiver reaches the one method of its name only when it is named after the method's owner, or names a class or ends in a property declared with a type that has the owner in its hierarchy; a local's or a parameter's declaration (`FMResultSet *rs`) types its messages. | From fc6b6d09a7f159e58758afb807550526cf3e8843 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 23 Oct 2026 17:27:28 +0800 Subject: [PATCH 06/19] fix(store): uninit discards a current index beside its tombstone A Current index beside the uninit tombstone (an init that published Current but could not remove it) refused uninit --force with CurrentTombstoned. With a schema a newer codegraph wrote, init refuses that index and names uninit --force, so the refusal pointed at a command that then failed. Uninit's corroboration now accepts the tombstone: it discards the index like any other Current one and keeps the tombstone, so only init rebuilds it. Covered with and without a newer schema, in the store and through the CLI. --- .../codegraph-cli/tests/future_schema_cli.rs | 30 +++++++++++++++++++ crates/codegraph-store/src/connection.rs | 10 ++++--- crates/codegraph-store/tests/future_schema.rs | 25 ++++++++++++++++ .../tests/store_state_gates.rs | 20 +++++++++++++ docs/cli.md | 3 +- 5 files changed, 83 insertions(+), 5 deletions(-) diff --git a/crates/codegraph-cli/tests/future_schema_cli.rs b/crates/codegraph-cli/tests/future_schema_cli.rs index cf922f5a..522d17ad 100644 --- a/crates/codegraph-cli/tests/future_schema_cli.rs +++ b/crates/codegraph-cli/tests/future_schema_cli.rs @@ -166,6 +166,36 @@ fn reads_sync_and_init_refuse_a_newer_schema_and_uninit_discards_it() { assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); } +/// The same index beside the tombstone a newer codegraph's `init` could not +/// remove: `init` refuses it unchanged and names `uninit --force`, which +/// discards it, and `init` then rebuilds it. +#[test] +fn a_tombstoned_index_a_newer_codegraph_wrote_is_discarded_by_uninit() { + let dir = TestDir::new("future-schema-tombstone"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema(&paths); + fs::write(paths.tombstone(), b"remove failed").unwrap(); + let before = index_bytes(&paths); + + let init = cli(&["init", path_str(&project)]); + assert!(!init.ok, "init replaced a newer codegraph's index"); + assert!( + init.stderr.contains("newer CodeGraph") && init.stderr.contains("uninit --force"), + "{}", + init.stderr + ); + assert_unchanged(&paths, &before, "init"); + + let uninit = cli(&["uninit", "--force", path_str(&project)]); + assert!(uninit.ok, "uninit failed: {}", uninit.stderr); + assert!(!paths.current_db().exists()); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init after uninit failed: {}", init.stderr); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + assert!(!paths.tombstone().exists(), "init removed the tombstone"); +} + #[test] fn sync_leaves_a_newer_schema_in_the_log_untouched() { let dir = TestDir::new("future-schema-log-sync"); diff --git a/crates/codegraph-store/src/connection.rs b/crates/codegraph-store/src/connection.rs index f78534a5..8ebf3de6 100644 --- a/crates/codegraph-store/src/connection.rs +++ b/crates/codegraph-store/src/connection.rs @@ -634,11 +634,13 @@ impl Store { // then publishes Uninitialized before deleting those // sidecars. Rejecting them here would make that required // cleanup path unreachable. + // OD-19: no schema check, so uninit stays the way to + // discard a newer codegraph's index; it still reads the + // extraction stamp row. A tombstone beside `Current` is an + // init that published but could not remove it: uninit + // discards that index too, and the tombstone stays. let (corroboration, source_conn) = - // OD-19: no schema check, so uninit stays the way to - // discard a newer codegraph's index. It still reads - // the extraction stamp row. - corroborate_current_database_options(paths, false, false, true)?; + corroborate_current_database_options(paths, false, true, true)?; drop(corroboration); drop(source_conn); } diff --git a/crates/codegraph-store/tests/future_schema.rs b/crates/codegraph-store/tests/future_schema.rs index ff13dadc..f0aa0979 100644 --- a/crates/codegraph-store/tests/future_schema.rs +++ b/crates/codegraph-store/tests/future_schema.rs @@ -398,6 +398,31 @@ fn a_damaged_database_in_a_missing_namespace_is_refused() { assert_eq!(snapshot(&paths), before); } +/// A `Current` index beside the tombstone an `init` could not remove, with a +/// newer schema: `init` refuses it unchanged and offers `uninit --force`, and +/// that command then discards it. +#[test] +fn a_tombstoned_newer_schema_index_is_refused_then_discarded_by_uninit() { + let project = TempProject::new("current-tombstone"); + let paths = stage_current(&project); + record_newer_schema(&paths.current_db()); + std::fs::write(paths.tombstone(), b"remove failed").expect("stage the tombstone"); + let before = snapshot(&paths); + + assert_rebuild_refused( + begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false), + true, + ); + assert_eq!(snapshot(&paths), before, "the refusal changed the index"); + + uninit_index(&paths, deadline(), || false).expect("uninit --force discards it"); + assert!(!paths.current_db().exists()); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Uninitialized + ); +} + #[test] fn uninit_still_removes_a_newer_schema_index() { for phase in ["current", "building"] { diff --git a/crates/codegraph-store/tests/store_state_gates.rs b/crates/codegraph-store/tests/store_state_gates.rs index 68951feb..d439d40f 100644 --- a/crates/codegraph-store/tests/store_state_gates.rs +++ b/crates/codegraph-store/tests/store_state_gates.rs @@ -651,6 +651,26 @@ fn current_read_and_status_reject_tombstone_without_mutating_any_artifact() { } } +/// The tombstone an `init` that published `Current` could not remove does not +/// stand between `uninit --force` and the index: uninit completes the discard +/// that tombstone records. +#[test] +fn uninit_discards_a_current_index_beside_its_tombstone() { + let project = TempProject::new("current-tombstoned-uninit"); + let paths = stage_current(&project, Some(&CURRENT_EXTRACTION_VERSION.to_string())); + std::fs::write(paths.tombstone(), b"remove failed") + .expect("stage Current+tombstone finalizer residue"); + + codegraph_store::uninit_index(&paths, deadline(), || false) + .expect("uninit --force discards Current+tombstone"); + assert!(!paths.current_db().exists(), "uninit removed the database"); + assert!(paths.tombstone().is_file(), "the tombstone stays for init"); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Uninitialized + ); +} + #[test] fn current_read_stamp_failures_are_typed_and_byte_nonmutating() { for (stamp, expected) in [ diff --git a/docs/cli.md b/docs/cli.md index 120bc275..47fc7df4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -992,7 +992,8 @@ A `Current` index beside the uninit tombstone, which an `init` that published `Current` but could not remove the tombstone leaves, is reported the same way, with `recoveryCommand` set to `codegraph init `; human output shows `current (blocked by the uninit tombstone)` and that recovery. Reads refuse such -an index until `init` rebuilds it. +an index until `init` rebuilds it; `uninit --force` discards it instead and keeps +the tombstone. When the WAL is larger than both the database and the configured WAL limit, `status` warns that live CodeGraph processes must be stopped before running: From b80a14f81250331db27490a9caf91be5a9e312b8 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 23 Oct 2026 17:53:05 +0800 Subject: [PATCH 07/19] fix(store): report a newer schema before the uninit tombstone A Current index beside the uninit tombstone was reported as tombstoned before its schema was read, so status named `codegraph init` as the recovery even when a newer codegraph wrote the database, and init then refused it. When the tombstone is present, the corroboration now reads the schema version first (the write-ahead-log-aware probe, which touches no file); a positively newer version is FutureSchema, which offers uninit --force. Any other outcome is still CurrentTombstoned, so the same-version contract and its init recovery are unchanged. --- .../codegraph-cli/tests/future_schema_cli.rs | 27 +++++++++++++++++++ crates/codegraph-store/src/connection.rs | 16 +++++++++++ crates/codegraph-store/tests/future_schema.rs | 10 +++++++ docs/cli.md | 3 ++- 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/codegraph-cli/tests/future_schema_cli.rs b/crates/codegraph-cli/tests/future_schema_cli.rs index 522d17ad..3f1d1fed 100644 --- a/crates/codegraph-cli/tests/future_schema_cli.rs +++ b/crates/codegraph-cli/tests/future_schema_cli.rs @@ -178,6 +178,33 @@ fn a_tombstoned_index_a_newer_codegraph_wrote_is_discarded_by_uninit() { fs::write(paths.tombstone(), b"remove failed").unwrap(); let before = index_bytes(&paths); + // `status` reports the newer schema, not the tombstone's `init` recovery. + let status = cli(&["status", path_str(&project), "--json"]); + assert!(status.ok, "status failed: {}", status.stderr); + let json: Value = serde_json::from_str(&status.stdout).expect("status JSON"); + assert_eq!(json["extractionStatus"], "current", "{}", status.stdout); + let detail = json["extractionStatusDetail"].as_str().unwrap_or(""); + assert!( + detail.contains(&format!("schema {NEWER}")) && detail.contains("uninit --force"), + "{detail}" + ); + assert_eq!(json["recoveryCommand"], Value::Null, "{}", status.stdout); + let human = cli(&["status", path_str(&project)]); + assert!( + human.stdout.contains(&format!("schema {NEWER} is newer")) + && human.stdout.contains("codegraph uninit --force") + && !human.stdout.contains("blocked by the uninit tombstone"), + "{}", + human.stdout + ); + let search = cli(&["search", "add", "-p", path_str(&project)]); + assert!( + !search.ok && search.stderr.contains("newer CodeGraph"), + "{}", + search.stderr + ); + assert_unchanged(&paths, &before, "status and search"); + let init = cli(&["init", path_str(&project)]); assert!(!init.ok, "init replaced a newer codegraph's index"); assert!( diff --git a/crates/codegraph-store/src/connection.rs b/crates/codegraph-store/src/connection.rs index 8ebf3de6..74a5e192 100644 --- a/crates/codegraph-store/src/connection.rs +++ b/crates/codegraph-store/src/connection.rs @@ -925,6 +925,22 @@ fn corroborate_current_database_options( ) -> Result<(Connection, Connection)> { let db_path = paths.current_db(); if !allow_tombstone && artifact_exists(&paths.tombstone())? { + // A database a newer codegraph wrote is reported as that before the + // tombstone, whose recovery (`init`) would refuse it (OD-19); uninit + // discards both. Read without touching a file, and only a version + // positively newer changes the answer. + if check_schema + && let Ok(crate::schema_probe::ProbedSchema::Version(found)) = + crate::schema_probe::probe_schema_version(&db_path) + && found > migrations::CURRENT_SCHEMA_VERSION + { + return Err(StoreError::FutureSchema { + path: db_path, + found, + supported: migrations::CURRENT_SCHEMA_VERSION, + discard_with_uninit: true, + }); + } return Err(StoreError::CurrentTombstoned { path: paths.tombstone(), }); diff --git a/crates/codegraph-store/tests/future_schema.rs b/crates/codegraph-store/tests/future_schema.rs index f0aa0979..cbab8129 100644 --- a/crates/codegraph-store/tests/future_schema.rs +++ b/crates/codegraph-store/tests/future_schema.rs @@ -409,6 +409,16 @@ fn a_tombstoned_newer_schema_index_is_refused_then_discarded_by_uninit() { std::fs::write(paths.tombstone(), b"remove failed").expect("stage the tombstone"); let before = snapshot(&paths); + // The newer schema takes precedence over the tombstone, whose own + // recovery (`init`) would refuse it. + let Err(error) = Store::open_for_read(&paths, deadline(), || false) else { + panic!("a read opened a tombstoned newer codegraph's index"); + }; + assert_future_schema(&error, true); + let Err(error) = Store::open_for_status(&paths, deadline(), || false) else { + panic!("status opened a tombstoned newer codegraph's index"); + }; + assert_future_schema(&error, true); assert_rebuild_refused( begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false), true, diff --git a/docs/cli.md b/docs/cli.md index 47fc7df4..3fd9d406 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1416,7 +1416,8 @@ refuse it, change nothing, and name both schema versions. `status` reports it as `current` with `initialized: false` and the versions in `extractionStatusDetail`. Upgrade CodeGraph to use it again. To discard it instead, run `codegraph uninit --force` and then `codegraph init`; the refusal offers that only where `uninit` -accepts the index's state. +accepts the index's state. The newer schema is reported before a leftover uninit +tombstone, whose `init` recovery would refuse such an index. ### Index diagnostics From 7160d9ae739118dccb44e56e20b6137eed00ec8a Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 23 Oct 2026 18:43:13 +0800 Subject: [PATCH 08/19] fix(store): offer uninit only where it would discard the index A refusal of a newer codegraph's index offered `uninit --force` for every state uninit accepts, but on a Current namespace uninit first corroborates the database's extraction stamp. With the stamp missing, malformed or not this binary's, the offered command then failed. The offer now runs the same read-only corroboration uninit runs before its first change, for the tombstone precedence in the read gate and for the rebuild prologue. A newer schema with a bad stamp, tombstoned or not, is refused without the offer. --- crates/codegraph-store/src/connection.rs | 29 +++++--- crates/codegraph-store/src/rebuild.rs | 5 +- crates/codegraph-store/tests/future_schema.rs | 70 +++++++++++++++++++ docs/cli.md | 3 +- 4 files changed, 96 insertions(+), 11 deletions(-) diff --git a/crates/codegraph-store/src/connection.rs b/crates/codegraph-store/src/connection.rs index 74a5e192..f03ae9e2 100644 --- a/crates/codegraph-store/src/connection.rs +++ b/crates/codegraph-store/src/connection.rs @@ -634,13 +634,7 @@ impl Store { // then publishes Uninitialized before deleting those // sidecars. Rejecting them here would make that required // cleanup path unreachable. - // OD-19: no schema check, so uninit stays the way to - // discard a newer codegraph's index; it still reads the - // extraction stamp row. A tombstone beside `Current` is an - // init that published but could not remove it: uninit - // discards that index too, and the tombstone stays. - let (corroboration, source_conn) = - corroborate_current_database_options(paths, false, true, true)?; + let (corroboration, source_conn) = corroborate_current_for_uninit(paths)?; drop(corroboration); drop(source_conn); } @@ -938,7 +932,7 @@ fn corroborate_current_database_options( path: db_path, found, supported: migrations::CURRENT_SCHEMA_VERSION, - discard_with_uninit: true, + discard_with_uninit: uninit_would_discard(paths, &ExtractionStatus::Current), }); } return Err(StoreError::CurrentTombstoned { @@ -979,6 +973,25 @@ fn corroborate_current_database_options( Ok((conn, source_conn)) } +/// The corroboration `uninit --force` runs on a `Current` namespace before its +/// first change. OD-19: no schema check, so uninit stays the way to discard a +/// newer codegraph's index; it still reads the extraction stamp row. A +/// tombstone beside `Current` is an init that published but could not remove +/// it: uninit discards that index too, and the tombstone stays. +fn corroborate_current_for_uninit(paths: &IndexPaths) -> Result<(Connection, Connection)> { + corroborate_current_database_options(paths, false, true, true) +} + +/// Whether `uninit --force` would discard the namespace in `status` now: a +/// state it accepts, and for `Current` its own read-only corroboration +/// (database present, main file readable, extraction stamp this binary's). +/// A refusal offers uninit only when this holds; it creates and writes no +/// file. +pub(crate) fn uninit_would_discard(paths: &IndexPaths, status: &ExtractionStatus) -> bool { + crate::uninit::uninit_accepts(status) + && (*status != ExtractionStatus::Current || corroborate_current_for_uninit(paths).is_ok()) +} + /// Refuse a database that records a schema newer than this binary's, read on /// the connection that will be used (OD-19). An unreadable version fails /// closed. diff --git a/crates/codegraph-store/src/rebuild.rs b/crates/codegraph-store/src/rebuild.rs index ae30cfa1..8342d078 100644 --- a/crates/codegraph-store/src/rebuild.rs +++ b/crates/codegraph-store/src/rebuild.rs @@ -349,8 +349,9 @@ fn newer_schema_check(paths: &IndexPaths) -> Result<(), RebuildError> { match crate::schema_probe::probe_schema_version(&db) { Ok(ProbedSchema::Absent | ProbedSchema::NotSqlite) => Ok(()), Ok(ProbedSchema::Version(found)) if found > CURRENT_SCHEMA_VERSION => { - // `uninit --force` is offered only for the states it accepts. - let discard_with_uninit = crate::uninit::uninit_accepts(classify(paths).status()); + // `uninit --force` is offered only where it would succeed. + let discard_with_uninit = + crate::connection::uninit_would_discard(paths, classify(paths).status()); Err(RebuildError::Store(StoreError::FutureSchema { path: db, found, diff --git a/crates/codegraph-store/tests/future_schema.rs b/crates/codegraph-store/tests/future_schema.rs index cbab8129..452b80af 100644 --- a/crates/codegraph-store/tests/future_schema.rs +++ b/crates/codegraph-store/tests/future_schema.rs @@ -112,6 +112,28 @@ fn record_newer_schema(db: &Path) { } } +/// Replace `db`'s extraction stamp (`None` deletes it), checkpointed. +fn set_stamp(db: &Path, stamp: Option<&str>) { + let conn = Connection::open(db).expect("open database fixture"); + match stamp { + Some(value) => conn.execute( + "UPDATE project_metadata SET value = ?1 WHERE key = ?2", + [value, EXTRACTION_VERSION_KEY], + ), + None => conn.execute( + "DELETE FROM project_metadata WHERE key = ?1", + [EXTRACTION_VERSION_KEY], + ), + } + .expect("rewrite the stamp"); + conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);") + .expect("checkpoint"); + drop(conn); + for suffix in ["-wal", "-shm"] { + let _ = std::fs::remove_file(sidecar(db, suffix)); + } +} + /// Record schema `NEWER` in `db`'s write-ahead log only, as a newer binary /// killed before its checkpoint leaves it: the main file still records this /// binary's schema. @@ -433,6 +455,54 @@ fn a_tombstoned_newer_schema_index_is_refused_then_discarded_by_uninit() { ); } +/// `uninit --force` refuses a `Current` database whose extraction stamp is +/// missing or not this binary's, so a refusal of such a newer-schema index, +/// tombstoned or not, does not offer it. +#[test] +fn a_newer_schema_with_a_bad_stamp_never_offers_uninit() { + for (label, stamp) in [ + ("missing", None), + ("malformed", Some("02")), + ("mismatched", Some("1")), + ] { + for tombstone in [false, true] { + let project = TempProject::new(&format!("bad-stamp-{label}-{tombstone}")); + let paths = stage_current(&project); + set_stamp(&paths.current_db(), stamp); + record_newer_schema(&paths.current_db()); + if tombstone { + std::fs::write(paths.tombstone(), b"remove failed").expect("stage the tombstone"); + } + let before = snapshot(&paths); + assert!( + uninit_index(&paths, deadline(), || false).is_err(), + "{label}/{tombstone}: uninit refuses a bad stamp" + ); + assert_eq!( + snapshot(&paths), + before, + "{label}/{tombstone}: uninit changed it" + ); + + assert_rebuild_refused( + begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false), + false, + ); + if tombstone { + let Err(error) = Store::open_for_read(&paths, deadline(), || false) else { + panic!("{label}: a read opened it"); + }; + assert_future_schema(&error, false); + } + assert_eq!( + snapshot(&paths), + before, + "{label}/{tombstone}: a refusal changed it" + ); + } + } +} + #[test] fn uninit_still_removes_a_newer_schema_index() { for phase in ["current", "building"] { diff --git a/docs/cli.md b/docs/cli.md index 3fd9d406..91100895 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1416,7 +1416,8 @@ refuse it, change nothing, and name both schema versions. `status` reports it as `current` with `initialized: false` and the versions in `extractionStatusDetail`. Upgrade CodeGraph to use it again. To discard it instead, run `codegraph uninit --force` and then `codegraph init`; the refusal offers that only where `uninit` -accepts the index's state. The newer schema is reported before a leftover uninit +would succeed: a state it accepts and, for a current index, a readable database +with this binary's extraction stamp. The newer schema is reported before a leftover uninit tombstone, whose `init` recovery would refuse such an index. ### Index diagnostics From 5cb5f32d2f0c19f24b77e1c1b4d6c3b1fbf44171 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 23 Oct 2026 18:51:22 +0800 Subject: [PATCH 09/19] fix(cli): status offers uninit for a newer schema only where it works status's human output for an index a newer codegraph wrote always named `uninit --force`, even when the refusal it reports does not offer it because uninit would refuse the index (a missing or foreign extraction stamp). It now follows the refusal: upgrade, plus the uninit recovery only where uninit would discard the index. --- crates/codegraph-cli/src/main.rs | 21 ++++++--- .../codegraph-cli/tests/future_schema_cli.rs | 43 +++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index 0de9d1da..871a6434 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -2157,7 +2157,10 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { // failing like a read. Err( error @ codegraph_store::StoreError::FutureSchema { - found, supported, .. + found, + supported, + discard_with_uninit, + .. }, ) => { if json_output { @@ -2198,11 +2201,17 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { " Daemon: {}", if daemon_running { "running" } else { "stopped" } ); - println!( - "\nRecovery: a newer CodeGraph wrote this index. Upgrade CodeGraph, or run `codegraph uninit --force {}` and then `codegraph init {}` to discard and rebuild it.", - project.display(), - project.display() - ); + if discard_with_uninit { + println!( + "\nRecovery: a newer CodeGraph wrote this index. Upgrade CodeGraph, or run `codegraph uninit --force {}` and then `codegraph init {}` to discard and rebuild it.", + project.display(), + project.display() + ); + } else { + println!( + "\nRecovery: a newer CodeGraph wrote this index. Upgrade CodeGraph to use it." + ); + } } return Ok(()); } diff --git a/crates/codegraph-cli/tests/future_schema_cli.rs b/crates/codegraph-cli/tests/future_schema_cli.rs index 3f1d1fed..d040caaf 100644 --- a/crates/codegraph-cli/tests/future_schema_cli.rs +++ b/crates/codegraph-cli/tests/future_schema_cli.rs @@ -223,6 +223,49 @@ fn a_tombstoned_index_a_newer_codegraph_wrote_is_discarded_by_uninit() { assert!(!paths.tombstone().exists(), "init removed the tombstone"); } +/// Where `uninit --force` would fail (a tombstoned newer-schema index whose +/// extraction stamp is missing), `status` reports the newer schema without +/// offering it, in JSON and in its human output. +#[test] +fn status_offers_no_uninit_where_uninit_would_fail() { + let dir = TestDir::new("future-schema-no-discard"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + { + let conn = rusqlite::Connection::open(paths.current_db()).unwrap(); + conn.execute( + "DELETE FROM project_metadata WHERE key = 'indexed_with_extraction_version'", + [], + ) + .unwrap(); + } + record_newer_schema(&paths); + fs::write(paths.tombstone(), b"remove failed").unwrap(); + let before = index_bytes(&paths); + + let status = cli(&["status", path_str(&project), "--json"]); + assert!(status.ok, "status failed: {}", status.stderr); + let json: Value = serde_json::from_str(&status.stdout).expect("status JSON"); + let detail = json["extractionStatusDetail"].as_str().unwrap_or(""); + assert!( + detail.contains(&format!("schema {NEWER}")) && !detail.contains("uninit"), + "{detail}" + ); + let human = cli(&["status", path_str(&project)]); + assert!(human.ok, "status failed: {}", human.stderr); + assert!( + human.stdout.contains(&format!("schema {NEWER} is newer")) + && !human.stdout.contains("uninit"), + "{}", + human.stdout + ); + assert!( + !cli(&["uninit", "--force", path_str(&project)]).ok, + "uninit refuses the missing stamp" + ); + assert_unchanged(&paths, &before, "status and uninit"); +} + #[test] fn sync_leaves_a_newer_schema_in_the_log_untouched() { let dir = TestDir::new("future-schema-log-sync"); From fda96ed1e2764b44276b7b940d8dba1ad1200160 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 23 Oct 2026 19:32:35 +0800 Subject: [PATCH 10/19] test(cli): import the unix-only upgrade test types on unix only Moving the shared helpers to tests/upgrade_support left upgrade_from_v054 and upgrade_from_v055 using ExtractionStatus and Store only in their unix-only tests, so the Windows Clippy job failed on unused imports. The import is now cfg(unix). --- crates/codegraph-cli/tests/upgrade_from_v054.rs | 4 +++- crates/codegraph-cli/tests/upgrade_from_v055.rs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/codegraph-cli/tests/upgrade_from_v054.rs b/crates/codegraph-cli/tests/upgrade_from_v054.rs index c67828ac..78c0036a 100644 --- a/crates/codegraph-cli/tests/upgrade_from_v054.rs +++ b/crates/codegraph-cli/tests/upgrade_from_v054.rs @@ -16,7 +16,9 @@ mod upgrade_support; use std::fs; use std::path::{Path, PathBuf}; -use codegraph_store::{CURRENT_STORAGE_PROTOCOL, ExtractionStatus, Store}; +use codegraph_store::CURRENT_STORAGE_PROTOCOL; +#[cfg(unix)] +use codegraph_store::{ExtractionStatus, Store}; use upgrade_support::*; /// Extraction version the v0.54.0 release published. diff --git a/crates/codegraph-cli/tests/upgrade_from_v055.rs b/crates/codegraph-cli/tests/upgrade_from_v055.rs index a08492b0..386c80d7 100644 --- a/crates/codegraph-cli/tests/upgrade_from_v055.rs +++ b/crates/codegraph-cli/tests/upgrade_from_v055.rs @@ -17,7 +17,9 @@ mod upgrade_support; use std::fs; use std::path::{Path, PathBuf}; -use codegraph_store::{CURRENT_STORAGE_PROTOCOL, ExtractionStatus, Store}; +use codegraph_store::CURRENT_STORAGE_PROTOCOL; +#[cfg(unix)] +use codegraph_store::{ExtractionStatus, Store}; use upgrade_support::*; /// Extraction version the v0.55.2 release published. From f699c78fe8098b48cd95749a8b2e321a0d945892 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 23 Oct 2026 20:48:37 +0800 Subject: [PATCH 11/19] feat(resolve): framework-edge infrastructure The groundwork the routers of the next commits draw their edges with (F1, upstream #2129 and #2395). No registered resolver uses the new pieces yet, so every golden and the express, typescript-large and self-index graphs are unchanged. - EdgeKind::Navigates, stored as text like every edge kind. Callers, callees, the named-symbol flow and a flow's boundary follow it, as upstream's traversal, FLOW_EDGE_KINDS and CONTINUATION_KINDS do. - A ResolvedRef can carry an EdgeShape (upstream edgeKind, metadata and alsoTargets): the edges' kind, the metadata on the target's edge, and more targets, each its own edge under the same kind and confidence. ResolvedRef::new builds the plain resolution every resolver returned before. - frameworks/package_deps reads the dependencies a project declares in its root package.json and up to 24 nested ones: one or two directories down, then deeper above JavaScript-family code, never inside node_modules. React and NestJS detect through it, so an app whose manifest is in frontend/, apps/web/ or src/Web/ClientApp/ is detected without its fallback evidence. Upstream package-deps.ts is unchanged between b635dd46 and e8ddc4e2. - A framework can name its app dependencies (appDependencies). When a manifest declares one, its extractor runs only on files whose nearest package.json naming such a framework names it. The frameworks row a sync compares adds a digest of where each gated framework runs, so a manifest edit that moves an app re-extracts every file, as a framework that came or went does. The JS control inputs digest every manifest detection may read, so a sync notices one appearing beside no script. - The trait gains cross_file_nodes and navigation (upstream #2400 and #2425), both None by default; nothing calls them until a router implements them. The sync matrix gains two cases: React declared in an app's manifest, and in a manifest one directory down above no script. The gating sync case waits for the first gated framework (W7-07). --- crates/codegraph-cli/src/main.rs | 2 +- .../tests/sync_convergence/cases.rs | 53 ++ .../tests/sync_convergence/main.rs | 2 + crates/codegraph-core/src/types.rs | 12 +- crates/codegraph-graph/src/flow_boundary.rs | 7 +- crates/codegraph-graph/src/graph/mod.rs | 2 + .../codegraph-graph/src/named_symbol_flow.rs | 5 +- crates/codegraph-graph/tests/traversal.rs | 104 +++ crates/codegraph-resolve/src/cfml_scope.rs | 24 +- crates/codegraph-resolve/src/context.rs | 12 + .../codegraph-resolve/src/cpp_namespaces.rs | 24 +- .../codegraph-resolve/src/cpp_supertypes.rs | 24 +- .../src/csharp_extensions.rs | 12 +- .../codegraph-resolve/src/dart_annotations.rs | 8 +- crates/codegraph-resolve/src/dart_chains.rs | 8 +- crates/codegraph-resolve/src/dart_members.rs | 24 +- crates/codegraph-resolve/src/dart_prefixes.rs | 12 +- .../src/destructured_calls.rs | 12 +- crates/codegraph-resolve/src/framework.rs | 66 +- .../codegraph-resolve/src/frameworks/godot.rs | 36 +- .../codegraph-resolve/src/frameworks/mod.rs | 1 + .../src/frameworks/nestjs.rs | 34 +- .../src/frameworks/package_deps.rs | 730 ++++++++++++++++++ .../codegraph-resolve/src/frameworks/react.rs | 28 +- .../codegraph-resolve/src/frameworks/tauri.rs | 12 +- .../codegraph-resolve/src/frameworks/vue.rs | 18 +- crates/codegraph-resolve/src/go_packages.rs | 8 +- .../codegraph-resolve/src/import_resolver.rs | 247 +++--- crates/codegraph-resolve/src/js_store.rs | 12 +- crates/codegraph-resolve/src/lib.rs | 4 +- crates/codegraph-resolve/src/lua_scope.rs | 7 +- crates/codegraph-resolve/src/member_value.rs | 12 +- crates/codegraph-resolve/src/name_matcher.rs | 390 +++++----- .../codegraph-resolve/src/object_literal.rs | 12 +- crates/codegraph-resolve/src/resolver.rs | 573 ++++++++++++-- crates/codegraph-resolve/src/scala_scope.rs | 12 +- .../codegraph-resolve/src/shopify_themes.rs | 12 +- .../src/swift_type_visibility.rs | 12 +- crates/codegraph-resolve/src/types.rs | 75 +- crates/codegraph-resolve/tests/frameworks.rs | 50 ++ crates/codegraph-store/src/queries.rs | 26 + crates/codegraph-watch/src/control_inputs.rs | 18 +- crates/codegraph-watch/src/migrate.rs | 2 +- crates/codegraph-watch/src/sync.rs | 2 +- docs/architecture.md | 19 +- 45 files changed, 2154 insertions(+), 611 deletions(-) create mode 100644 crates/codegraph-resolve/src/frameworks/package_deps.rs diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index 871a6434..027afa00 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -6569,7 +6569,7 @@ fn index_project_inner( codegraph_watch::control_inputs::write_index_rows( &store, project, - &resolver.framework_names(), + &resolver.framework_signature(), )?; if resolver.has_framework_resolvers() { let relative_files = store diff --git a/crates/codegraph-cli/tests/sync_convergence/cases.rs b/crates/codegraph-cli/tests/sync_convergence/cases.rs index 76124a08..3c3df96e 100644 --- a/crates/codegraph-cli/tests/sync_convergence/cases.rs +++ b/crates/codegraph-cli/tests/sync_convergence/cases.rs @@ -10138,6 +10138,59 @@ pub fn typescript_react_dependency_leaves_package_json() -> Case { .edge_after("function page", "calls", "function useCart") } +/// Upstream #2395: React coming into an app's manifest rather than the +/// root's detects React as the root's does +/// ([`typescript_react_dependency_appears_in_package_json`]). +pub fn typescript_react_dependency_appears_in_an_app_package_json() -> Case { + Case::new(Change::ControlFile, &[Language::TypeScript]) + .file("package.json", "{\"name\":\"mono\",\"private\":true}\n") + .file( + "apps/web/package.json", + "{\"name\":\"web\",\"dependencies\":{\"left-pad\":\"1.3.0\"}}\n", + ) + .file( + "apps/web/src/hooks.ts", + "export function useCart(): number {\n return 1;\n}\n", + ) + .file( + "apps/web/src/page.ts", + "export function page(): number {\n return useCart();\n}\n", + ) + .write( + "apps/web/package.json", + "{\"name\":\"web\",\"dependencies\":{\"left-pad\":\"1.3.0\",\"react\":\"18.2.0\"}}\n", + ) + .edge_before("function page", "calls", "function useCart") + .edge_after( + "function page", + "calls", + "function apps/web/src/hooks.ts::useCart", + ) +} + +/// Upstream #2395: detection reads a manifest one directory down whatever is +/// indexed below it, so React declared in `frontend/package.json` beside +/// only a Python build script detects React on the next sync too. +pub fn typescript_react_dependency_appears_above_no_script() -> Case { + Case::new(Change::ControlFile, &[Language::TypeScript]) + .file("package.json", "{\"name\":\"web\",\"private\":true}\n") + .file("frontend/build.py", "def build():\n return 1\n") + .file( + "src/hooks.ts", + "export function useCart(): number {\n return 1;\n}\n", + ) + .file( + "src/page.ts", + "export function page(): number {\n return useCart();\n}\n", + ) + .write( + "frontend/package.json", + "{\"name\":\"assets\",\"devDependencies\":{\"react\":\"18.2.0\"}}\n", + ) + .edge_before("function page", "calls", "function useCart") + .edge_after("function page", "calls", "function src/hooks.ts::useCart") +} + /// W5-06: a package the manifest comes to declare is outside the project, /// so the import of its binding no longer reaches a project class by name. pub fn typescript_declared_dependency_makes_a_bare_import_external() -> Case { diff --git a/crates/codegraph-cli/tests/sync_convergence/main.rs b/crates/codegraph-cli/tests/sync_convergence/main.rs index aeb3f4a9..6efa6133 100644 --- a/crates/codegraph-cli/tests/sync_convergence/main.rs +++ b/crates/codegraph-cli/tests/sync_convergence/main.rs @@ -1344,6 +1344,8 @@ matrix! { typescript_tsconfig_extends_target_appears, typescript_paths_retarget_moves_a_barrel_source, typescript_react_dependency_leaves_package_json, + typescript_react_dependency_appears_in_an_app_package_json, + typescript_react_dependency_appears_above_no_script, typescript_declared_dependency_makes_a_bare_import_external, typescript_workspace_member_manifest_renames_its_package, typescript_workspace_member_manifest_is_deleted, diff --git a/crates/codegraph-core/src/types.rs b/crates/codegraph-core/src/types.rs index 732f6531..c744186b 100644 --- a/crates/codegraph-core/src/types.rs +++ b/crates/codegraph-core/src/types.rs @@ -37,7 +37,7 @@ pub const NODE_KIND_STRINGS: [&str; 23] = [ "union", ]; -pub const EDGE_KIND_STRINGS: [&str; 12] = [ +pub const EDGE_KIND_STRINGS: [&str; 13] = [ "contains", "calls", "imports", @@ -50,6 +50,7 @@ pub const EDGE_KIND_STRINGS: [&str; 12] = [ "instantiates", "overrides", "decorates", + "navigates", ]; pub const LANGUAGE_STRINGS: [&str; 42] = [ @@ -236,10 +237,15 @@ pub enum EdgeKind { Overrides, #[serde(rename = "decorates")] Decorates, + /// A screen transition a router resolves: `router.push('/login')`, + /// ``, to the route it names. A framework resolver + /// gives it to a `calls` or `references` reference. + #[serde(rename = "navigates")] + Navigates, } impl EdgeKind { - pub const ALL: [Self; 12] = [ + pub const ALL: [Self; 13] = [ Self::Contains, Self::Calls, Self::Imports, @@ -252,6 +258,7 @@ impl EdgeKind { Self::Instantiates, Self::Overrides, Self::Decorates, + Self::Navigates, ]; pub const fn as_str(self) -> &'static str { @@ -268,6 +275,7 @@ impl EdgeKind { Self::Instantiates => "instantiates", Self::Overrides => "overrides", Self::Decorates => "decorates", + Self::Navigates => "navigates", } } } diff --git a/crates/codegraph-graph/src/flow_boundary.rs b/crates/codegraph-graph/src/flow_boundary.rs index aeb61a17..7c97abb5 100644 --- a/crates/codegraph-graph/src/flow_boundary.rs +++ b/crates/codegraph-graph/src/flow_boundary.rs @@ -28,9 +28,12 @@ pub struct BoundaryContinuations { pub uncertain: Vec, } -/// The kinds that continue a flow (`navigates` has no Rust edge kind yet). +/// The kinds that continue a flow (`CONTINUATION_KINDS`). fn is_continuation_kind(kind: EdgeKind) -> bool { - matches!(kind, EdgeKind::Calls | EdgeKind::Instantiates) + matches!( + kind, + EdgeKind::Calls | EdgeKind::Instantiates | EdgeKind::Navigates + ) } /// One row per target and bucket, in line order; `exclude` drops the symbols diff --git a/crates/codegraph-graph/src/graph/mod.rs b/crates/codegraph-graph/src/graph/mod.rs index 8b134126..f8645e72 100644 --- a/crates/codegraph-graph/src/graph/mod.rs +++ b/crates/codegraph-graph/src/graph/mod.rs @@ -408,6 +408,7 @@ impl<'store> GraphTraverser<'store> { EdgeKind::References, EdgeKind::Imports, EdgeKind::Instantiates, + EdgeKind::Navigates, ], )?; if incoming.is_empty() { @@ -543,6 +544,7 @@ impl<'store> GraphTraverser<'store> { EdgeKind::References, EdgeKind::Imports, EdgeKind::Instantiates, + EdgeKind::Navigates, ], )?; if outgoing.is_empty() { diff --git a/crates/codegraph-graph/src/named_symbol_flow.rs b/crates/codegraph-graph/src/named_symbol_flow.rs index a7ecd755..6f8205b8 100644 --- a/crates/codegraph-graph/src/named_symbol_flow.rs +++ b/crates/codegraph-graph/src/named_symbol_flow.rs @@ -54,9 +54,10 @@ pub fn is_flow_callable(kind: NodeKind) -> bool { ) } -/// Edge kinds a flow may ride (`navigates` has no Rust edge kind yet). +/// Edge kinds a flow may ride (`FLOW_EDGE_KINDS`): a call, and a +/// navigation to the screen a route renders. fn is_flow_edge(kind: EdgeKind) -> bool { - kind == EdgeKind::Calls + matches!(kind, EdgeKind::Calls | EdgeKind::Navigates) } /// Non-callable kinds a SYNTHESIZED edge may end at (an RTK thunk is a constant). diff --git a/crates/codegraph-graph/tests/traversal.rs b/crates/codegraph-graph/tests/traversal.rs index 157d253e..dbdc4a87 100644 --- a/crates/codegraph-graph/tests/traversal.rs +++ b/crates/codegraph-graph/tests/traversal.rs @@ -821,6 +821,110 @@ fn callers_keep_multiple_edge_kinds_between_same_pair() { assert_eq!(callers.len(), 2, "exactly two multi-kind caller edges"); } +/// A navigation (`navigate('/login')` resolved to the `/login` route, F1) is +/// a caller/callee edge both ways, as upstream's traversal lists it. +#[test] +fn a_navigation_is_a_caller_and_callee_edge() { + let mut store = Store::open(&temp_db_path("navigates")).expect("open store"); + let mut route = r2_node("route:login", "/login"); + route.kind = NodeKind::Route; + store + .upsert_nodes(&[r2_node("function:logout", "logout"), route]) + .expect("insert nodes"); + store + .insert_edges(&[edge( + "function:logout", + "route:login", + EdgeKind::Navigates, + Some(3), + Some(4), + )]) + .expect("insert edges"); + + let traverser = GraphTraverser::new(&store); + let callers = traverser.get_callers("route:login", 1).expect("callers"); + assert_eq!( + callers + .iter() + .map(|c| (c.node.id.as_str(), c.edge.kind)) + .collect::>(), + [("function:logout", EdgeKind::Navigates)] + ); + let callees = traverser + .get_callees("function:logout", 1) + .expect("callees"); + assert_eq!( + callees + .iter() + .map(|c| (c.node.id.as_str(), c.edge.kind)) + .collect::>(), + [("route:login", EdgeKind::Navigates)] + ); +} + +/// A named flow rides a navigation as it rides a call (upstream +/// `FLOW_EDGE_KINDS`), and a flow's boundary continues through one +/// (`CONTINUATION_KINDS`). +#[test] +fn a_flow_and_its_boundary_ride_a_navigation() { + let mut store = Store::open(&temp_db_path("navigates-flow")).expect("open store"); + let submit = r2_node("function:submit", "submitForm"); + let open = r2_node("function:open", "openSettings"); + let mut screen = r2_node("component:settings", "SettingsScreen"); + screen.kind = NodeKind::Component; + store + .upsert_nodes(&[submit, open.clone(), screen]) + .expect("insert nodes"); + store + .insert_edges(&[ + edge( + "function:submit", + "function:open", + EdgeKind::Calls, + Some(2), + Some(2), + ), + edge( + "function:open", + "component:settings", + EdgeKind::Navigates, + Some(5), + Some(2), + ), + ]) + .expect("insert edges"); + + let flow = codegraph_graph::named_symbol_flow::resolve_named_symbol_flow( + &store, + "submitForm SettingsScreen", + &codegraph_graph::named_symbol_flow::NamedSymbolFlowOptions::default(), + ); + let steps: Vec<&str> = flow + .chains + .first() + .map(|chain| chain.steps.iter().map(|s| s.node.id.as_str()).collect()) + .unwrap_or_default(); + assert_eq!( + steps, + ["function:submit", "function:open", "component:settings"] + ); + + let continuations = codegraph_graph::flow_boundary::continuations_from( + &store, + &open, + &std::collections::HashSet::new(), + ) + .expect("continuations"); + assert_eq!( + continuations + .resolved + .iter() + .map(|c| c.node.id.as_str()) + .collect::>(), + ["component:settings"] + ); +} + /// #1087 (mirror) — callees must likewise keep multi-kind edges to the same pair. #[test] fn callees_keep_multiple_edge_kinds_between_same_pair() { diff --git a/crates/codegraph-resolve/src/cfml_scope.rs b/crates/codegraph-resolve/src/cfml_scope.rs index 4732c331..de659dbb 100644 --- a/crates/codegraph-resolve/src/cfml_scope.rs +++ b/crates/codegraph-resolve/src/cfml_scope.rs @@ -265,12 +265,12 @@ pub(crate) fn resolve_cfml_component_path( .collect(); found.sort_by(|a, b| a.id.cmp(&b.id)); let target = found.into_iter().next()?; - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence: 0.95, - resolved_by: ResolvedBy::FilePath, - }); + return Some(ResolvedRef::new( + reference.clone(), + target.id.clone(), + 0.95, + ResolvedBy::FilePath, + )); } let segments: Vec<&str> = name .split('.') @@ -303,12 +303,12 @@ pub(crate) fn resolve_cfml_component_path( return None; } let best = best?; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: best.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::QualifiedName, - }) + Some(ResolvedRef::new( + reference.clone(), + best.id.clone(), + 0.9, + ResolvedBy::QualifiedName, + )) } /// How many of a dotted component path's directories `file_path`'s own diff --git a/crates/codegraph-resolve/src/context.rs b/crates/codegraph-resolve/src/context.rs index 43929e5f..d766e97d 100644 --- a/crates/codegraph-resolve/src/context.rs +++ b/crates/codegraph-resolve/src/context.rs @@ -65,6 +65,8 @@ struct Caches { /// Each directory's package.json / Deno import-map scope for the pass. manifest_scopes: HashMap>, all_files: Option>>, + /// The dependency names the project's manifests declare, for the pass. + declared_dependencies: Option>>, files_by_basename: Option>>>, /// Existence answers for the pass: import resolution probes every extension /// of a specifier from every importing file, and neither the borrowed store @@ -139,6 +141,7 @@ impl Caches { workspace_packages: None, manifest_scopes: HashMap::new(), all_files: None, + declared_dependencies: None, files_by_basename: None, file_exists: HashMap::new(), python_write_index: None, @@ -438,6 +441,15 @@ impl ResolutionContext for StoreResolutionContext<'_> { self.cached_all_files() } + fn declared_dependencies(&self) -> Arc> { + if let Some(names) = self.caches.borrow().declared_dependencies.clone() { + return names; + } + let names = Arc::new(crate::frameworks::package_deps::declared_dependencies(self)); + self.caches.borrow_mut().declared_dependencies = Some(names.clone()); + names + } + fn get_files_by_basename_shared(&self, basename: &str) -> Arc> { self.cached_files_by_basename(basename) } diff --git a/crates/codegraph-resolve/src/cpp_namespaces.rs b/crates/codegraph-resolve/src/cpp_namespaces.rs index 5ec7f242..5aa9bb6b 100644 --- a/crates/codegraph-resolve/src/cpp_namespaces.rs +++ b/crates/codegraph-resolve/src/cpp_namespaces.rs @@ -646,11 +646,13 @@ pub(crate) fn match_cpp_macro_namespaced( best = Some((node, score)); } } - best.map(|(node, _)| ResolvedRef { - original: reference.clone(), - target_node_id: node.id.clone(), - confidence: 0.8, - resolved_by: ResolvedBy::QualifiedName, + best.map(|(node, _)| { + ResolvedRef::new( + reference.clone(), + node.id.clone(), + 0.8, + ResolvedBy::QualifiedName, + ) }) } @@ -681,16 +683,16 @@ pub(crate) fn match_cpp_global_name( let chosen = prefer_call_site_file(global, &reference.file_path) .into_iter() .next()?; - Some(ResolvedRef { - original: reference.clone(), - confidence: if count == 1 || chosen.file_path == reference.file_path { + Some(ResolvedRef::new( + reference.clone(), + chosen.id.clone(), + if count == 1 || chosen.file_path == reference.file_path { 0.95 } else { 0.85 }, - target_node_id: chosen.id.clone(), - resolved_by: ResolvedBy::QualifiedName, - }) + ResolvedBy::QualifiedName, + )) } /// Split `a, f(b, c), d` at its top-level commas. diff --git a/crates/codegraph-resolve/src/cpp_supertypes.rs b/crates/codegraph-resolve/src/cpp_supertypes.rs index ef49b742..0ac08682 100644 --- a/crates/codegraph-resolve/src/cpp_supertypes.rs +++ b/crates/codegraph-resolve/src/cpp_supertypes.rs @@ -93,11 +93,13 @@ pub(crate) fn match_cpp_supertype( let visible = move |node: &Node| node.file_path == derived_file || !is_source_file(&node.file_path); let scopes = scopes_within(parent_scope(&derived.qualified_name)); - let resolved = |target: Arc| ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::QualifiedName, + let resolved = |target: Arc| { + ResolvedRef::new( + reference.clone(), + target.id.clone(), + 0.9, + ResolvedBy::QualifiedName, + ) }; let look_up = |spelled: &str, scopes: &[String]| { class_node( @@ -205,12 +207,12 @@ pub(crate) fn match_cpp_supertype( own }; match sole.as_slice() { - [only] => Some(ResolvedRef { - original: reference.clone(), - target_node_id: only.id.clone(), - confidence: 0.7, - resolved_by: ResolvedBy::ExactMatch, - }), + [only] => Some(ResolvedRef::new( + reference.clone(), + only.id.clone(), + 0.7, + ResolvedBy::ExactMatch, + )), _ => None, } } diff --git a/crates/codegraph-resolve/src/csharp_extensions.rs b/crates/codegraph-resolve/src/csharp_extensions.rs index e63f7f65..1055b3b9 100644 --- a/crates/codegraph-resolve/src/csharp_extensions.rs +++ b/crates/codegraph-resolve/src/csharp_extensions.rs @@ -80,12 +80,12 @@ pub(crate) fn extension_method_target( } _ => return None, }; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: chosen, - confidence: 0.9, - resolved_by: ResolvedBy::InstanceMethod, - }) + Some(ResolvedRef::new( + reference.clone(), + chosen, + 0.9, + ResolvedBy::InstanceMethod, + )) } /// What a qualified name is declared in: all but its last `::` segment. diff --git a/crates/codegraph-resolve/src/dart_annotations.rs b/crates/codegraph-resolve/src/dart_annotations.rs index 6baefaf6..0c874e58 100644 --- a/crates/codegraph-resolve/src/dart_annotations.rs +++ b/crates/codegraph-resolve/src/dart_annotations.rs @@ -128,12 +128,12 @@ fn resolved( confidence: f64, resolved_by: ResolvedBy, ) -> ResolvedRef { - ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), + ResolvedRef::new( + reference.clone(), + target.id.clone(), confidence, resolved_by, - } + ) } /// A class a Dart annotation can call a constructor of: not an extension, diff --git a/crates/codegraph-resolve/src/dart_chains.rs b/crates/codegraph-resolve/src/dart_chains.rs index b2c1d5ac..1724d434 100644 --- a/crates/codegraph-resolve/src/dart_chains.rs +++ b/crates/codegraph-resolve/src/dart_chains.rs @@ -1835,12 +1835,12 @@ fn match_link( } } let (target_node_id, resolved_by) = chosen.flatten()?; - Some(ResolvedRef { - original: reference.clone(), + Some(ResolvedRef::new( + reference.clone(), target_node_id, - confidence: 0.85, + 0.85, resolved_by, - }) + )) } /// Whether a constructor call written at `column` of 1-based `line` names diff --git a/crates/codegraph-resolve/src/dart_members.rs b/crates/codegraph-resolve/src/dart_members.rs index 5ad6eaf1..eb1eab3c 100644 --- a/crates/codegraph-resolve/src/dart_members.rs +++ b/crates/codegraph-resolve/src/dart_members.rs @@ -351,12 +351,12 @@ pub fn match_dart_member_read( &|node| is_dart_getter(node, context), 0, )?; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: getter.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::InstanceMethod, - }) + Some(ResolvedRef::new( + reference.clone(), + getter.id.clone(), + 0.9, + ResolvedBy::InstanceMethod, + )) } /// Whether a Dart method is a named constructor — `Foo.named(…)`, `factory @@ -518,11 +518,13 @@ pub(crate) fn match_dart_type_member_call( let own = dart_static_member(type_name, member, &reference.file_path, context, &|_| true); Some( own.filter(|own| own.kind != NodeKind::EnumMember) - .map(|own| ResolvedRef { - original: reference.clone(), - target_node_id: own.id.clone(), - confidence: 0.85, - resolved_by: ResolvedBy::QualifiedName, + .map(|own| { + ResolvedRef::new( + reference.clone(), + own.id.clone(), + 0.85, + ResolvedBy::QualifiedName, + ) }), ) } diff --git a/crates/codegraph-resolve/src/dart_prefixes.rs b/crates/codegraph-resolve/src/dart_prefixes.rs index 000d94ed..d3be2ef5 100644 --- a/crates/codegraph-resolve/src/dart_prefixes.rs +++ b/crates/codegraph-resolve/src/dart_prefixes.rs @@ -41,11 +41,13 @@ pub(crate) fn match_dart_prefixed_call( return None; } let found = dart_prefixed_decls(prefix, name, &reference.file_path, context); - Some((found.len() == 1).then(|| ResolvedRef { - original: reference.clone(), - target_node_id: found[0].id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, + Some((found.len() == 1).then(|| { + ResolvedRef::new( + reference.clone(), + found[0].id.clone(), + 0.9, + ResolvedBy::Import, + ) })) } diff --git a/crates/codegraph-resolve/src/destructured_calls.rs b/crates/codegraph-resolve/src/destructured_calls.rs index 4be574c2..1c7b96dc 100644 --- a/crates/codegraph-resolve/src/destructured_calls.rs +++ b/crates/codegraph-resolve/src/destructured_calls.rs @@ -274,12 +274,12 @@ fn destructured_call_target( .iter() .find(|n| n.kind == NodeKind::Function) .or_else(|| pool.first())?; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence: 0.85, - resolved_by: ResolvedBy::InstanceMethod, - }) + Some(ResolvedRef::new( + reference.clone(), + target.id.clone(), + 0.85, + ResolvedBy::InstanceMethod, + )) } #[cfg(test)] diff --git a/crates/codegraph-resolve/src/framework.rs b/crates/codegraph-resolve/src/framework.rs index 99c1ae47..f26543a6 100644 --- a/crates/codegraph-resolve/src/framework.rs +++ b/crates/codegraph-resolve/src/framework.rs @@ -25,7 +25,7 @@ use crate::frameworks::godot_dsl_config::GodotDslConfig; use crate::types::{FrameworkResolverExtractionResult, RefView, ResolutionContext, ResolvedRef}; -use codegraph_core::types::Language; +use codegraph_core::types::{Language, Node, NodeKind}; use std::sync::Arc; /// The per-project inputs a [`FrameworkResolver::extract`] pass may need beyond @@ -102,6 +102,18 @@ pub trait FrameworkResolver: Sync { self.languages() } + /// Packages an app declares when it is built on this framework + /// (`appDependencies?`, upstream #2129). When some manifest in the project + /// declares one of them, [`Self::extract`] runs only on files of an app + /// whose `package.json` (the file's directory's or an enclosing one's) + /// declares one: in a monorepo with an Expo app beside a Next.js app, Expo + /// Router must not read the Next app's `app/layout.tsx` as a screen. When + /// no manifest declares any, detection found the framework by other + /// evidence and the extractor runs on every file. + fn app_dependencies(&self) -> Option<&'static [&'static str]> { + None + } + /// Detect whether the project uses this `FrameworkResolver`'s target — called /// once at startup (`detect`, `types.ts:163`). fn detect(&self, context: &dyn ResolutionContext) -> bool; @@ -154,6 +166,58 @@ pub trait FrameworkResolver: Sync { ) -> Option> { None } + + /// Nodes no single file's [`Self::extract`] can decide on + /// (`crossFileNodes?`, upstream #2400): a route table written in one file + /// is a table of routes only because another file hands it to the + /// router. It returns the COMPLETE set the framework wants now, so a pass + /// after [`Self::post_extract`] can insert the new ones with their + /// references, remove the ones no longer wanted, rename the ones whose + /// name changed and leave the rest, whose edges then survive. `None` when + /// the resolver makes no such nodes, as every registered resolver does so + /// far: neither an index nor a sync calls it yet. + fn cross_file_nodes(&self, _context: &dyn ResolutionContext) -> Option { + None + } + + /// The navigation calls this resolver's [`Self::resolve`] binds to its + /// route nodes (`navigation?`, upstream #2425). A navigation call names + /// its route by path and its reference by the router's method, so a call + /// parked while its route was missing, or bound to the best route then, + /// is invisible to a sync's retry by name tail; this says which calls a + /// changed route may answer, so a sync can put them back. `None` when the + /// resolver binds no navigation, as every registered resolver does so + /// far: no sync calls it yet. + fn navigation(&self) -> Option<&dyn NavigationCalls> { + None + } +} + +/// The nodes a [`FrameworkResolver::cross_file_nodes`] pass wants now +/// (upstream `CrossFileNodes`). +pub struct CrossFileNodes { + /// The nodes and the references that link them, as an extraction returns + /// them. + pub extraction: FrameworkResolverExtractionResult, + /// The kind every one of these nodes has. + pub kind: NodeKind, + /// True for a node this pass produces, and for no node extraction does. + pub owns: fn(&Node) -> bool, +} + +/// The navigation calls a router binds to its routes — `navigate('/login')`, +/// `router.push('/x')` — described so a sync can find the ones a changed +/// route may answer (upstream `NavigationCalls`). +pub trait NavigationCalls: Sync { + /// The name tails of the calls the resolver accepts as navigation (`push` + /// for `history.push`), which find them among the parked references; the + /// resolver then decides on the whole name. + fn tails(&self) -> &[&str]; + + /// The files whose navigation calls can name `route`: the path prefixes + /// of the apps whose table it is in (`""` for every file), or `None` when + /// it is not one of this router's routes. + fn scope(&self, route: &Node, context: &dyn ResolutionContext) -> Option>; } #[cfg(test)] diff --git a/crates/codegraph-resolve/src/frameworks/godot.rs b/crates/codegraph-resolve/src/frameworks/godot.rs index abc4fc9b..73df58d0 100644 --- a/crates/codegraph-resolve/src/frameworks/godot.rs +++ b/crates/codegraph-resolve/src/frameworks/godot.rs @@ -123,12 +123,12 @@ impl FrameworkResolver for GodotResolver { { if reference.reference_kind == EdgeKind::Calls { if let Some(target) = resolve_autoload_func(context, access) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id, - confidence: 0.9, - resolved_by: ResolvedBy::Framework, - }); + return Some(ResolvedRef::new( + reference.clone(), + target.id, + 0.9, + ResolvedBy::Framework, + )); } } return None; @@ -164,24 +164,24 @@ impl FrameworkResolver for GodotResolver { if let Some(member) = member { if !member.is_empty() { if let Some(target) = resolve_class_member(context, receiver, member) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id, - confidence: 0.9, - resolved_by: ResolvedBy::Framework, - }); + return Some(ResolvedRef::new( + reference.clone(), + target.id, + 0.9, + ResolvedBy::Framework, + )); } } } } let singleton = find_autoload_singleton(context, receiver)?; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: singleton.id, - confidence: 0.9, - resolved_by: ResolvedBy::Framework, - }) + Some(ResolvedRef::new( + reference.clone(), + singleton.id, + 0.9, + ResolvedBy::Framework, + )) } /// Opt a `BuffManager.member` reference through the name-exists pre-filter. diff --git a/crates/codegraph-resolve/src/frameworks/mod.rs b/crates/codegraph-resolve/src/frameworks/mod.rs index 5dff73db..519918da 100644 --- a/crates/codegraph-resolve/src/frameworks/mod.rs +++ b/crates/codegraph-resolve/src/frameworks/mod.rs @@ -18,6 +18,7 @@ pub(crate) mod godot_resource; pub(crate) mod godot_scene; pub mod godot_script; pub mod nestjs; +pub mod package_deps; pub mod react; pub mod tauri; pub mod vue; diff --git a/crates/codegraph-resolve/src/frameworks/nestjs.rs b/crates/codegraph-resolve/src/frameworks/nestjs.rs index 2e24a23f..ea1fe5e8 100644 --- a/crates/codegraph-resolve/src/frameworks/nestjs.rs +++ b/crates/codegraph-resolve/src/frameworks/nestjs.rs @@ -39,12 +39,14 @@ impl FrameworkResolver for NestjsResolver { // Ports nestjsResolver.detect (nestjs.ts:49-89). fn detect(&self, context: &dyn ResolutionContext) -> bool { - if let Some(package_json) = context.read_file("package.json") { - if let Ok(pkg) = serde_json::from_str::(&package_json) { - if any_nestjs_dep(&pkg) { - return true; - } - } + // Any `@nestjs/*` dependency in a package.json: the root's, or an + // app's (`apps/api/`, `server/`). + if context + .declared_dependencies() + .iter() + .any(|name| name.starts_with("@nestjs/")) + { + return true; } for file in context.get_all_files_shared().iter() { @@ -88,12 +90,12 @@ impl FrameworkResolver for NestjsResolver { Some(node) => (node, 0.85), None => (&candidates[0], 0.7), }; - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), + return Some(ResolvedRef::new( + reference.clone(), + target.id.clone(), confidence, - resolved_by: ResolvedBy::Framework, - }); + ResolvedBy::Framework, + )); } None } @@ -302,16 +304,6 @@ impl FrameworkResolver for NestjsResolver { // Detection helpers // --------------------------------------------------------------------------- -/// Any `@nestjs/*` key in dependencies/devDependencies (nestjs.ts:54-56). -fn any_nestjs_dep(pkg: &serde_json::Value) -> bool { - let in_section = |section: &str| -> bool { - pkg.get(section) - .and_then(|d| d.as_object()) - .is_some_and(|deps| deps.keys().any(|k| k.starts_with("@nestjs/"))) - }; - in_section("dependencies") || in_section("devDependencies") -} - // --------------------------------------------------------------------------- // Provider resolution conventions (nestjs.ts:273-283) // --------------------------------------------------------------------------- diff --git a/crates/codegraph-resolve/src/frameworks/package_deps.rs b/crates/codegraph-resolve/src/frameworks/package_deps.rs new file mode 100644 index 00000000..7b04b51d --- /dev/null +++ b/crates/codegraph-resolve/src/frameworks/package_deps.rs @@ -0,0 +1,730 @@ +//! The dependencies a project declares (upstream `package-deps.ts`): in its +//! root `package.json`, in the ones one or two directories down +//! (`apps/web/package.json`, `frontend/package.json`), and in the ones deeper +//! still above JS/TS code (`src/Web/ClientApp/package.json`, upstream #2395). +//! A framework detector that reads only the root misses every monorepo: a +//! Turborepo keeps `next` in `apps/web/`, an ASP.NET solution its Angular app +//! in `src/Web/ClientApp/`, and that framework's resolver then never runs. +//! +//! The index lists source files, never manifests, so the candidates are the +//! directories of what is indexed, probed through the context: every path is +//! a prefix of an indexed project path, so it stays inside the project. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use crate::types::ResolutionContext; + +/// Nested manifests read per project, at most: a monorepo with hundreds of +/// packages is sampled, not scanned. +const MAX_MANIFESTS: usize = 24; + +/// Directories probed for a manifest below the first two levels, at most. +const MAX_DEEP_PROBES: usize = MAX_MANIFESTS * 8; + +/// A file a JavaScript framework's app is written in; its manifest is in a +/// directory above it. +fn is_script_file(path: &str) -> bool { + let Some((_, extension)) = path.rsplit_once('.') else { + return false; + }; + matches!( + extension, + "js" | "jsx" + | "ts" + | "tsx" + | "mjs" + | "mjsx" + | "mts" + | "mtsx" + | "cjs" + | "cjsx" + | "cts" + | "ctsx" + | "vue" + | "svelte" + | "astro" + ) +} + +/// The dependency names a `package.json` declares in `dependencies`, +/// `devDependencies` and `peerDependencies`; `None` when it is not JSON (a +/// template, a broken manifest). +pub(crate) fn manifest_dependency_names(content: &str) -> Option> { + let manifest: serde_json::Value = serde_json::from_str(content).ok()?; + let mut names = BTreeSet::new(); + for field in ["dependencies", "devDependencies", "peerDependencies"] { + if let Some(group) = manifest.get(field).and_then(serde_json::Value::as_object) { + names.extend(group.keys().cloned()); + } + } + Some(names) +} + +/// Every dependency name declared in the manifests read (see the module +/// comment), de-duplicated. +pub fn declared_dependencies(context: &C) -> BTreeSet { + let mut files: Vec = context.get_all_files_shared().as_ref().clone(); + // Path order, as the resolver lists them, so the sample does not depend + // on the order a caller's list comes in. + files.sort(); + let mut manifests = vec!["package.json".to_string()]; + for dir in shallow_directories(&files) { + if manifests.len() > MAX_MANIFESTS { + break; + } + let manifest = format!("{dir}package.json"); + if !dir.contains("node_modules") && context.file_exists(&manifest) { + manifests.push(manifest); + } + } + // An app can sit deeper: an ASP.NET solution's single-page app in + // `src/Web/ClientApp/`, or apps under a workspace root at `web/ui/`. The + // slots left go to the directories above JS/TS code, shallowest first. + if manifests.len() <= MAX_MANIFESTS { + for dir in deep_script_directories(&files) { + if manifests.len() > MAX_MANIFESTS { + break; + } + let manifest = format!("{dir}package.json"); + if context.file_exists(&manifest) { + manifests.push(manifest); + } + } + } + let mut names = BTreeSet::new(); + for manifest in &manifests { + if let Some(content) = context.read_file(manifest) + && let Some(declared) = manifest_dependency_names(&content) + { + names.extend(declared); + } + } + names +} + +/// Every manifest [`declared_dependencies`] may read for a project whose +/// indexed files are `files`, whether or not it exists: what a sync digests +/// to know when the dependencies a framework is detected by may have moved. +pub fn manifest_candidates(files: &[String]) -> Vec { + let mut files = files.to_vec(); + files.sort(); + let mut candidates = vec!["package.json".to_string()]; + candidates.extend( + shallow_directories(&files) + .into_iter() + .filter(|dir| !dir.contains("node_modules")) + .map(|dir| format!("{dir}package.json")), + ); + candidates.extend( + deep_script_directories(&files) + .into_iter() + .map(|dir| format!("{dir}package.json")), + ); + candidates +} + +/// The first one or two segments of what IS indexed (`apps/`, `apps/web/`), +/// in the order the sorted `files` reach them; the index lists source files, +/// never manifests, so these are where an app's manifest may sit. +fn shallow_directories(files: &[String]) -> Vec { + let mut dirs: Vec = Vec::new(); + let mut seen: BTreeSet = BTreeSet::new(); + for file in files { + let segments: Vec<&str> = file.split('/').collect(); + if segments.len() > 1 { + let dir = format!("{}/", segments[0]); + if seen.insert(dir.clone()) { + dirs.push(dir); + } + } + if segments.len() > 2 { + let dir = format!("{}/{}/", segments[0], segments[1]); + if seen.insert(dir.clone()) { + dirs.push(dir); + } + } + if dirs.len() > MAX_MANIFESTS * 8 { + break; + } + } + dirs +} + +/// The directories three or more levels down that hold indexed JS/TS code or +/// a directory that does, never inside `node_modules`: shallowest first, +/// sorted within a level, at most [`MAX_DEEP_PROBES`]. +fn deep_script_directories(files: &[String]) -> Vec { + let mut levels: BTreeMap> = BTreeMap::new(); + for file in files { + if !is_script_file(file) || file.contains("node_modules") { + continue; + } + let mut depth = 0; + for (slash, _) in file.match_indices('/') { + depth += 1; + if depth >= 3 { + levels + .entry(depth) + .or_default() + .insert(file[..=slash].to_string()); + } + } + } + levels + .into_values() + .flatten() + .take(MAX_DEEP_PROBES) + .collect() +} + +/// Whether any of `deps` is declared anywhere the project's manifests are +/// read. +pub fn depends_on(context: &C, deps: &[&str]) -> bool { + let names = context.declared_dependencies(); + deps.iter().any(|dep| names.contains(*dep)) +} + +/// Which detected frameworks' extractors apply to a file (upstream #2129 +/// `frameworksForFile`): every one, except a framework with +/// [`crate::framework::FrameworkResolver::app_dependencies`] that some +/// manifest in the project declares, which applies only inside an app whose +/// nearest manifest naming any gated framework names it. +#[derive(Debug, Default)] +pub struct AppGating { + /// Gated frameworks and the packages that mark one of their apps. + gated: BTreeMap, + /// Directory → the dependency names its `package.json` declares, `None` + /// without a readable one. + manifests: BTreeMap>>, + /// `(directory, framework)` → whether the framework applies there. + applies: BTreeMap<(String, String), bool>, + /// Largest manifest read, the project's `indexing.max_file_size`, as the + /// resolution context reads one. + max_file_size: u64, +} + +impl AppGating { + /// The gating for `frameworks` (name, app dependencies) on a project + /// whose manifests declare `declared`. A framework is gated only when some + /// manifest names one of its packages: otherwise detection found it by + /// other evidence and every file is its app's. + pub fn new<'a>( + frameworks: impl IntoIterator)>, + declared: &BTreeSet, + max_file_size: u64, + ) -> Self { + let gated = frameworks + .into_iter() + .filter_map(|(name, deps)| { + let deps = deps?; + deps.iter() + .any(|dep| declared.contains(*dep)) + .then(|| (name.to_string(), deps)) + }) + .collect(); + Self { + gated, + max_file_size, + ..Self::default() + } + } + + /// Whether no framework is gated, so every extractor applies everywhere. + pub fn is_empty(&self) -> bool { + self.gated.is_empty() + } + + /// Whether the framework `name` extracts `file_path` (project-relative): + /// [`Self::applies_in`] its directory. + pub fn applies(&mut self, name: &str, file_path: &str, project_root: &Path) -> bool { + let dir = file_path.rsplit_once('/').map_or("", |(dir, _)| dir); + self.applies_in(name, dir, project_root) + } + + /// Whether the framework `name` extracts the files of the directory `dir` + /// (project-relative, `""` for the root), reading the `package.json` of + /// `dir` and each enclosing one under `project_root` as needed. The + /// nearest manifest that names ANY gated framework decides: a root + /// `package.json` that declares `expo-router` for an example app does not + /// make a `docs/` Next.js app, whose own manifest declares `next`, an Expo + /// app. + pub fn applies_in(&mut self, name: &str, dir: &str, project_root: &Path) -> bool { + let Some(deps) = self.gated.get(name).copied() else { + return true; + }; + let key = (dir.to_string(), name.to_string()); + if let Some(&memo) = self.applies.get(&key) { + return memo; + } + let mut applies = false; + let mut current = Some(dir); + while let Some(d) = current { + current = if d.is_empty() { + None + } else { + Some(d.rsplit_once('/').map_or("", |(parent, _)| parent)) + }; + let Some(declared) = + declared_in(&mut self.manifests, d, project_root, self.max_file_size) + else { + continue; + }; + if deps.iter().any(|dep| declared.contains(*dep)) { + applies = true; + break; + } + let names_another = self.gated.iter().any(|(other, other_deps)| { + other != name && other_deps.iter().any(|dep| declared.contains(*dep)) + }); + if names_another { + break; + } + } + self.applies.insert(key, applies); + applies + } + + /// A digest of where each gated framework applies among the directories + /// of `files` (project-relative), or `None` when no framework is gated. A + /// sync compares it with the last index's: a manifest edit that moves an + /// app's frameworks changes which files make framework nodes, so every + /// file is extracted again, as a fresh index extracts it. + pub fn digest<'a>( + &mut self, + files: impl IntoIterator, + project_root: &Path, + ) -> Option { + if self.gated.is_empty() { + return None; + } + let dirs: BTreeSet<&str> = files + .into_iter() + .map(|file| file.rsplit_once('/').map_or("", |(dir, _)| dir)) + .collect(); + let names: Vec = self.gated.keys().cloned().collect(); + let mut decisions = String::new(); + for name in &names { + for dir in &dirs { + let applies = self.applies_in(name, dir, project_root); + decisions.push_str(&format!("{name}\t{dir}\t{}\n", u8::from(applies))); + } + } + Some(codegraph_core::node_id::hash_content(&decisions)) + } +} + +/// The dependency names `

/package.json` declares, read once into +/// `manifests`. +fn declared_in<'a>( + manifests: &'a mut BTreeMap>>, + dir: &str, + project_root: &Path, + max_file_size: u64, +) -> Option<&'a BTreeSet> { + if !manifests.contains_key(dir) { + let relative = if dir.is_empty() { + "package.json".to_string() + } else { + format!("{dir}/package.json") + }; + let declared = read_manifest(project_root, &relative, max_file_size) + .as_deref() + .and_then(manifest_dependency_names); + manifests.insert(dir.to_string(), declared); + } + manifests.get(dir).and_then(Option::as_ref) +} + +/// Read `relative` (a project path the index produced) under `project_root` +/// the way the resolution context reads a manifest, after proving it +/// lexically inside the root; `None` when it is absent, no regular file, +/// over `max_file_size` or unreadable. +fn read_manifest(project_root: &Path, relative: &str, max_file_size: u64) -> Option { + let full = + crate::pathutil::lexical_path_within_root(&project_root.to_string_lossy(), relative)?; + if !std::fs::metadata(&full).ok()?.is_file() { + return None; + } + crate::context::read_source_file(&full, relative, max_file_size) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{ImportMapping, ResolutionContext}; + use codegraph_core::types::{Language, Node, NodeKind}; + use std::cell::RefCell; + use std::collections::HashMap; + + /// A context over an indexed file list and the manifests "on disk", + /// recording every probe (upstream `package-deps.test.ts`'s `tree`). + struct Tree { + files: Vec, + manifests: HashMap, + probes: RefCell>, + } + + impl Tree { + fn new(files: &[&str], manifests: &[(&str, &str)]) -> Self { + Self { + files: files.iter().map(|f| f.to_string()).collect(), + manifests: manifests + .iter() + .map(|(path, content)| (path.to_string(), content.to_string())) + .collect(), + probes: RefCell::new(Vec::new()), + } + } + } + + impl ResolutionContext for Tree { + fn get_nodes_in_file(&self, _file_path: &str) -> Vec { + Vec::new() + } + fn get_nodes_by_name(&self, _name: &str) -> Vec { + Vec::new() + } + fn get_nodes_by_qualified_name(&self, _qualified_name: &str) -> Vec { + Vec::new() + } + fn get_nodes_by_kind(&self, _kind: NodeKind) -> Vec { + Vec::new() + } + fn file_exists(&self, file_path: &str) -> bool { + self.probes.borrow_mut().push(file_path.to_string()); + self.manifests.contains_key(file_path) + } + fn read_file(&self, file_path: &str) -> Option { + self.manifests.get(file_path).cloned() + } + fn get_project_root(&self) -> &str { + "/project" + } + fn get_all_files(&self) -> Vec { + self.files.clone() + } + fn get_nodes_by_lower_name(&self, _lower_name: &str) -> Vec { + Vec::new() + } + fn get_node_by_id(&self, _id: &str) -> Option { + None + } + fn get_import_mappings(&self, _file_path: &str, _language: Language) -> Vec { + Vec::new() + } + } + + #[test] + fn reads_a_single_page_app_three_or_four_levels_down() { + for app in ["src/Web/ClientApp/", "src/Presentation/Web/ClientApp/"] { + let files = [ + "src/Web/Program.cs".to_string(), + format!("{app}karma.conf.js"), + format!("{app}src/main.ts"), + format!("{app}src/app/app.module.ts"), + ]; + let files: Vec<&str> = files.iter().map(String::as_str).collect(); + let manifest = format!("{app}package.json"); + let tree = Tree::new( + &files, + &[( + manifest.as_str(), + r#"{"dependencies":{"@angular/core":"^21","@angular/router":"^21"}}"#, + )], + ); + assert!(depends_on(&tree, &["@angular/router"]), "{app}"); + } + } + + #[test] + fn reads_a_workspace_root_two_levels_down_and_its_apps() { + let tree = Tree::new( + &[ + "cmd/prometheus/main.go", + "web/ui/mantine-ui/src/main.tsx", + "web/ui/react-app/src/App.tsx", + "web/ui/module/lezer-promql/src/index.ts", + ], + &[ + ( + "web/ui/package.json", + r#"{"devDependencies":{"typescript":"*","vite":"*"}}"#, + ), + ( + "web/ui/mantine-ui/package.json", + r#"{"dependencies":{"react":"*","react-router-dom":"*"}}"#, + ), + ( + "web/ui/react-app/package.json", + r#"{"dependencies":{"react":"*","react-router":"*"}}"#, + ), + ], + ); + let names: Vec = declared_dependencies(&tree).into_iter().collect(); + assert_eq!( + names, + [ + "react", + "react-router", + "react-router-dom", + "typescript", + "vite" + ] + ); + } + + #[test] + fn never_reads_a_manifest_inside_node_modules() { + let tree = Tree::new( + &[ + "tools/site/node_modules/framework/dist/index.js", + "tools/site/src/index.ts", + ], + &[( + "tools/site/node_modules/framework/package.json", + r#"{"dependencies":{"next":"*"}}"#, + )], + ); + assert!(!depends_on(&tree, &["next"])); + assert!( + tree.probes + .borrow() + .iter() + .all(|probe| !probe.contains("node_modules")) + ); + // Nor one a level or two down that is a dependency's own. + let tree = Tree::new( + &["node_modules/next/dist/index.js", "src/index.ts"], + &[( + "node_modules/next/package.json", + r#"{"dependencies":{"react":"*"}}"#, + )], + ); + assert!(!depends_on(&tree, &["react"])); + assert!( + tree.probes + .borrow() + .iter() + .all(|probe| !probe.contains("node_modules")) + ); + } + + #[test] + fn probes_only_above_script_code_and_a_bounded_number_of_directories() { + let mut files = vec!["src/Api/Tools/Generator.cs".to_string()]; + for i in 0..3000 { + files.push(format!("src/app/feature{i}/parts/widget.component.ts")); + } + let files: Vec<&str> = files.iter().map(String::as_str).collect(); + let tree = Tree::new( + &files, + &[( + "src/Api/Tools/package.json", + r#"{"dependencies":{"express":"*"}}"#, + )], + ); + assert!(!depends_on(&tree, &["express"])); + // `src/`, `src/Api/`, `src/app/` from the first two levels; the rest + // below them. + assert!(tree.probes.borrow().len() <= 3 + MAX_DEEP_PROBES); + } + + #[test] + fn reads_the_same_manifests_whatever_order_the_files_come_in() { + let mut files = Vec::new(); + let mut manifests = Vec::new(); + for i in 0..40 { + files.push(format!("clients/web/app{i}/src/index.ts")); + manifests.push(( + format!("clients/web/app{i}/package.json"), + format!(r#"{{"dependencies":{{"dep-{i}":"*"}}}}"#), + )); + } + let manifests: Vec<(&str, &str)> = manifests + .iter() + .map(|(path, content)| (path.as_str(), content.as_str())) + .collect(); + let forward: Vec<&str> = files.iter().map(String::as_str).collect(); + let mut backward = forward.clone(); + backward.reverse(); + let scanned = declared_dependencies(&Tree::new(&forward, &manifests)); + let reversed = declared_dependencies(&Tree::new(&backward, &manifests)); + assert_eq!(scanned, reversed); + // A sample: the root and 24 more. + assert_eq!(scanned.len(), MAX_MANIFESTS); + } + + /// What a sync digests covers every manifest detection probes or reads, + /// so an edit to one of them reaches the next sync. + #[test] + fn the_candidates_cover_every_manifest_detection_reads() { + let mut files = vec![ + "src/Web/Program.cs".to_string(), + "src/Web/ClientApp/src/main.ts".to_string(), + "web/ui/mantine-ui/src/main.tsx".to_string(), + "tools/site/node_modules/framework/dist/index.js".to_string(), + "server/app.py".to_string(), + ]; + // More directories above script code than are probed deep down, + // fewer than the first two levels sample. + for i in 0..120 { + files.push(format!("clients/app{i}/src/deep/index.ts")); + } + let paths: Vec<&str> = files.iter().map(String::as_str).collect(); + let tree = Tree::new(&paths, &[]); + declared_dependencies(&tree); + let candidates: BTreeSet = manifest_candidates(&files).into_iter().collect(); + assert!(candidates.contains("package.json")); + assert!(candidates.contains("server/package.json")); + for probe in tree.probes.borrow().iter() { + assert!( + candidates.contains(probe), + "{probe} is probed but not a candidate" + ); + } + assert!(candidates.iter().all(|path| !path.contains("node_modules"))); + assert!(tree.probes.borrow().len() > MAX_DEEP_PROBES); + } + + /// The digest moves only when a manifest moves an app onto or off a + /// gated framework, and there is none when nothing is gated. + #[test] + fn the_digest_moves_with_an_app_and_not_with_a_version() { + let files = [ + "apps/mobile/app/index.tsx", + "apps/web/app/page.tsx", + "README.md", + ]; + let digest = |web: &str| { + let disk = Disk::new( + "digest", + &[ + ( + "apps/mobile/package.json", + r#"{"dependencies":{"expo-router":"*"}}"#, + ), + ("apps/web/package.json", web), + ], + ); + gating(&["expo-router", "next"]).digest(files, &disk.0) + }; + let next = digest(r#"{"dependencies":{"next":"14"}}"#); + assert!(next.is_some()); + assert_eq!(next, digest(r#"{"dependencies":{"next":"15"}}"#)); + assert_ne!(next, digest(r#"{"dependencies":{"expo-router":"*"}}"#)); + let disk = Disk::new("digest-none", &[("package.json", "{}")]); + assert_eq!(gating(&["react"]).digest(files, &disk.0), None); + } + + #[test] + fn peer_dependencies_count_and_a_broken_manifest_declares_nothing() { + assert_eq!( + manifest_dependency_names(r#"{"peerDependencies":{"react":"*"}}"#), + Some(BTreeSet::from(["react".to_string()])) + ); + assert_eq!(manifest_dependency_names("{{ template }}"), None); + } + + /// A temporary project tree on disk for [`AppGating`]. + struct Disk(std::path::PathBuf); + + impl Disk { + fn new(label: &str, files: &[(&str, &str)]) -> Self { + let root = std::env::temp_dir().join(format!( + "codegraph-app-gating-{label}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + for (path, content) in files { + let full = root.join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + Self(root) + } + } + + impl Drop for Disk { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + const EXPO: &[&str] = &["expo-router"]; + const NEXT: &[&str] = &["next"]; + + fn gating(declared: &[&str]) -> AppGating { + let declared = declared.iter().map(|d| d.to_string()).collect(); + AppGating::new( + [ + ("expo-router", Some(EXPO)), + ("nextjs", Some(NEXT)), + ("react", None), + ], + &declared, + codegraph_core::config::DEFAULT_MAX_FILE_SIZE, + ) + } + + /// Upstream #2129: an Expo app beside a Next.js app; each router reads + /// only its own app, and an ungated framework reads every file. + #[test] + fn a_file_based_router_reads_only_its_own_app() { + let disk = Disk::new( + "monorepo", + &[ + ("package.json", r#"{"name":"mono","workspaces":["apps/*"]}"#), + ( + "apps/mobile/package.json", + r#"{"dependencies":{"expo":"*","expo-router":"*","react":"*"}}"#, + ), + ( + "apps/web/package.json", + r#"{"dependencies":{"next":"*","react":"*"}}"#, + ), + ], + ); + let mut gating = gating(&["expo-router", "next", "react"]); + assert!(gating.applies("expo-router", "apps/mobile/app/index.tsx", &disk.0)); + assert!(!gating.applies("expo-router", "apps/web/app/page.tsx", &disk.0)); + assert!(gating.applies("nextjs", "apps/web/app/user/[id]/page.tsx", &disk.0)); + assert!(!gating.applies("nextjs", "apps/mobile/app/_layout.tsx", &disk.0)); + assert!(gating.applies("react", "apps/web/app/page.tsx", &disk.0)); + assert!(gating.applies("react", "apps/mobile/app/index.tsx", &disk.0)); + } + + /// The nearest manifest that names a gated framework decides: a Next.js + /// docs app under a root that declares Expo Router for its example app. + #[test] + fn the_nearest_manifest_naming_a_router_decides() { + let disk = Disk::new( + "nearest", + &[ + ( + "package.json", + r#"{"devDependencies":{"expo":"*","expo-router":"*","react":"*"}}"#, + ), + ( + "docs/package.json", + r#"{"dependencies":{"next":"*","react":"*"}}"#, + ), + ], + ); + let mut gating = gating(&["expo-router", "next", "react"]); + assert!(gating.applies("nextjs", "docs/app/layout.tsx", &disk.0)); + assert!(!gating.applies("expo-router", "docs/app/layout.tsx", &disk.0)); + assert!(gating.applies("expo-router", "example/app/sheet.tsx", &disk.0)); + assert!(!gating.applies("nextjs", "example/app/sheet.tsx", &disk.0)); + } + + /// A framework no manifest names was detected by other evidence: it is + /// not gated, so it reads every file. + #[test] + fn a_framework_no_manifest_names_reads_every_file() { + let disk = Disk::new("undeclared", &[("package.json", r#"{"name":"demo"}"#)]); + let mut gating = gating(&["react"]); + assert!(gating.is_empty()); + assert!(gating.applies("expo-router", "app/settings.tsx", &disk.0)); + } +} diff --git a/crates/codegraph-resolve/src/frameworks/react.rs b/crates/codegraph-resolve/src/frameworks/react.rs index 45fdb983..4b13f5bd 100644 --- a/crates/codegraph-resolve/src/frameworks/react.rs +++ b/crates/codegraph-resolve/src/frameworks/react.rs @@ -40,12 +40,10 @@ impl FrameworkResolver for ReactResolver { // Ports reactResolver.detect (react.ts:14-32). fn detect(&self, context: &dyn ResolutionContext) -> bool { - if let Some(package_json) = context.read_file("package.json") { - if let Ok(pkg) = serde_json::from_str::(&package_json) { - if dep_present(&pkg, &["react", "next", "react-native"]) { - return true; - } - } + // React in a package.json: the root's, or an app's (`frontend/`, + // `apps/web/`, `src/Web/ClientApp/`). + if super::package_deps::depends_on(context, &["react", "next", "react-native"]) { + return true; } context .get_all_files_shared() @@ -266,16 +264,6 @@ impl FrameworkResolver for ReactResolver { } } -/// `{ ...dependencies, ...devDependencies }` membership test (react.ts:20-21). -fn dep_present(pkg: &serde_json::Value, keys: &[&str]) -> bool { - let in_section = |section: &str| -> bool { - pkg.get(section) - .and_then(|d| d.as_object()) - .is_some_and(|deps| keys.iter().any(|k| deps.contains_key(*k))) - }; - in_section("dependencies") || in_section("devDependencies") -} - /// Build a `references` ref linking a route node to its handler component /// (react.ts:188-196 / 229-237). fn route_reference( @@ -300,12 +288,12 @@ fn route_reference( } fn framework_resolved(reference: &RefView, target_node_id: String, confidence: f64) -> ResolvedRef { - ResolvedRef { - original: reference.clone(), + ResolvedRef::new( + reference.clone(), target_node_id, confidence, - resolved_by: ResolvedBy::Framework, - } + ResolvedBy::Framework, + ) } /// `isPascalCase` (react.ts:274-276). diff --git a/crates/codegraph-resolve/src/frameworks/tauri.rs b/crates/codegraph-resolve/src/frameworks/tauri.rs index 420dcab3..739bde58 100644 --- a/crates/codegraph-resolve/src/frameworks/tauri.rs +++ b/crates/codegraph-resolve/src/frameworks/tauri.rs @@ -178,12 +178,12 @@ impl FrameworkResolver for TauriResolver { return None; } let target = self.roster(context).get(wire)?.as_ref()?; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Framework, - }) + Some(ResolvedRef::new( + reference.clone(), + target.id.clone(), + 0.9, + ResolvedBy::Framework, + )) } /// Opt the namespaced call-side references through the name-exists diff --git a/crates/codegraph-resolve/src/frameworks/vue.rs b/crates/codegraph-resolve/src/frameworks/vue.rs index 30df789b..6c8a4c8c 100644 --- a/crates/codegraph-resolve/src/frameworks/vue.rs +++ b/crates/codegraph-resolve/src/frameworks/vue.rs @@ -265,21 +265,21 @@ fn declares_own_function(reference: &RefView, context: &dyn ResolutionContext) - } fn self_resolved(reference: &RefView, confidence: f64) -> ResolvedRef { - ResolvedRef { - original: reference.clone(), - target_node_id: reference.from_node_id.clone(), + ResolvedRef::new( + reference.clone(), + reference.from_node_id.clone(), confidence, - resolved_by: ResolvedBy::Framework, - } + ResolvedBy::Framework, + ) } fn framework_resolved(reference: &RefView, target_node_id: String, confidence: f64) -> ResolvedRef { - ResolvedRef { - original: reference.clone(), + ResolvedRef::new( + reference.clone(), target_node_id, confidence, - resolved_by: ResolvedBy::Framework, - } + ResolvedBy::Framework, + ) } /// Resolve an `@/` or `~/` alias path through the extension list to a node in diff --git a/crates/codegraph-resolve/src/go_packages.rs b/crates/codegraph-resolve/src/go_packages.rs index 8a28e3a4..2a2e370e 100644 --- a/crates/codegraph-resolve/src/go_packages.rs +++ b/crates/codegraph-resolve/src/go_packages.rs @@ -920,12 +920,12 @@ pub(crate) fn resolve_go_method_in_package( return Some(None); } let ordered = crate::name_matcher::prefer_call_site_file(own, &reference.file_path); - return Some(Some(ResolvedRef { - original: reference.clone(), - target_node_id: ordered[0].id.clone(), + return Some(Some(ResolvedRef::new( + reference.clone(), + ordered[0].id.clone(), confidence, resolved_by, - })); + ))); } let types = package_types(type_name, pkg_dir, context); if types.is_empty() { diff --git a/crates/codegraph-resolve/src/import_resolver.rs b/crates/codegraph-resolve/src/import_resolver.rs index b0e86bc8..dbbb77ee 100644 --- a/crates/codegraph-resolve/src/import_resolver.rs +++ b/crates/codegraph-resolve/src/import_resolver.rs @@ -1671,12 +1671,12 @@ pub fn resolve_jvm_import( } else { pick_closest_jvm_candidate(&candidates, &reference.file_path) }; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: best.id, - confidence: 0.95, - resolved_by: ResolvedBy::Import, - }) + Some(ResolvedRef::new( + reference.clone(), + best.id, + 0.95, + ResolvedBy::Import, + )) } /// `pickClosestJvmCandidate` (`import-resolver.ts:1096-1119`). @@ -1818,12 +1818,12 @@ pub(crate) fn resolve_php_qualified_class_ref( .then_some(None); }; let Some(member) = member else { - return Some(Some(ResolvedRef { - original: reference.clone(), - target_node_id: owner.id.clone(), - confidence: 0.95, - resolved_by: ResolvedBy::Import, - })); + return Some(Some(ResolvedRef::new( + reference.clone(), + owner.id.clone(), + 0.95, + ResolvedBy::Import, + ))); }; let methods: Vec> = context .get_nodes_by_qualified_name_shared(&format!("{}::{member}", owner.qualified_name)) @@ -1839,12 +1839,12 @@ pub(crate) fn resolve_php_qualified_class_ref( let [method] = methods.as_slice() else { return None; }; - Some(Some(ResolvedRef { - original: reference.clone(), - target_node_id: method.id.clone(), - confidence: 0.95, - resolved_by: ResolvedBy::Import, - })) + Some(Some(ResolvedRef::new( + reference.clone(), + method.id.clone(), + 0.95, + ResolvedBy::Import, + ))) } /// Resolve a PHP scoped call through the exact class namespace named by its @@ -1927,12 +1927,12 @@ pub(crate) fn resolve_php_imported_static_call( return PhpImportedStaticCallResolution::Claimed(None); }; - PhpImportedStaticCallResolution::Claimed(Some(ResolvedRef { - original: reference.clone(), - target_node_id: method.id.clone(), - confidence: 0.95, - resolved_by: ResolvedBy::Import, - })) + PhpImportedStaticCallResolution::Claimed(Some(ResolvedRef::new( + reference.clone(), + method.id.clone(), + 0.95, + ResolvedBy::Import, + ))) } /// Resolve a reference using import mappings (`resolveViaImport`, @@ -1964,12 +1964,12 @@ pub fn resolve_via_import( .into_iter() .find(|n| n.kind == NodeKind::File && n.file_path == file) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: file_node.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }); + return Some(ResolvedRef::new( + reference.clone(), + file_node.id.clone(), + 0.9, + ResolvedBy::Import, + )); } // A Dart `import` / `export` URI names a library file, and a `part` URI // one of the library's own files: `package:app/x.dart` is app's @@ -1988,12 +1988,12 @@ pub fn resolve_via_import( .get_nodes_in_file_shared(&file) .into_iter() .find(|n| n.kind == NodeKind::File)?; - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: file_node.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }); + return Some(ResolvedRef::new( + reference.clone(), + file_node.id.clone(), + 0.9, + ResolvedBy::Import, + )); } // C/C++ #include → file→file edge (import-resolver.ts:1132-1164). if matches!(reference.language, Language::C | Language::Cpp) @@ -2011,12 +2011,12 @@ pub fn resolve_via_import( .into_iter() .find(|n| n.kind == NodeKind::File && n.file_path == sibling_path) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: sibling.id.clone(), - confidence: 0.92, - resolved_by: ResolvedBy::Import, - }); + return Some(ResolvedRef::new( + reference.clone(), + sibling.id.clone(), + 0.92, + ResolvedBy::Import, + )); } let resolved_path = resolve_import_path( &reference.reference_name, @@ -2030,12 +2030,12 @@ pub fn resolve_via_import( .into_iter() .find(|n| n.kind == NodeKind::File && n.file_path == resolved_path) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: file_node.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }); + return Some(ResolvedRef::new( + reference.clone(), + file_node.id.clone(), + 0.9, + ResolvedBy::Import, + )); } return None; } @@ -2051,12 +2051,12 @@ pub fn resolve_via_import( .into_iter() .find(|n| n.kind == NodeKind::File && n.file_path == resolved_path) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: file_node.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }); + return Some(ResolvedRef::new( + reference.clone(), + file_node.id.clone(), + 0.9, + ResolvedBy::Import, + )); } } return None; @@ -2292,12 +2292,12 @@ pub fn resolve_via_import( } else { target_node }; - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: resolved_target.id, - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }); + return Some(ResolvedRef::new( + reference.clone(), + resolved_target.id, + 0.9, + ResolvedBy::Import, + )); } } } @@ -2350,12 +2350,12 @@ fn resolve_module_import_to_file( .into_iter() .find(|n| n.kind == NodeKind::File) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: file_node.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }); + return Some(ResolvedRef::new( + reference.clone(), + file_node.id.clone(), + 0.9, + ResolvedBy::Import, + )); } } } @@ -2372,12 +2372,12 @@ fn resolve_module_import_to_file( .into_iter() .find(|n| n.kind == NodeKind::File) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: file_node.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }); + return Some(ResolvedRef::new( + reference.clone(), + file_node.id.clone(), + 0.9, + ResolvedBy::Import, + )); } } } @@ -2420,12 +2420,12 @@ fn resolve_jvm_alias( .into_iter() .next()?, }; - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: target_node.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }); + return Some(ResolvedRef::new( + reference.clone(), + target_node.id.clone(), + 0.9, + ResolvedBy::Import, + )); } None } @@ -2467,12 +2467,12 @@ fn resolve_java_imported_reference( } let fp = node.file_path.replace('\\', "/"); if fp.ends_with(&fqn_path) || fp.ends_with(&format!("/{fqn_path}")) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: node.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }); + return Some(ResolvedRef::new( + reference.clone(), + node.id.clone(), + 0.9, + ResolvedBy::Import, + )); } } @@ -2487,12 +2487,12 @@ fn resolve_java_imported_reference( } let fp = node.file_path.replace('\\', "/"); if fp.ends_with(&owner_path) || fp.ends_with(&format!("/{owner_path}")) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: node.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }); + return Some(ResolvedRef::new( + reference.clone(), + node.id.clone(), + 0.9, + ResolvedBy::Import, + )); } } } @@ -3122,12 +3122,7 @@ fn find_go_package_member( && node.is_exported && crate::go_packages::package_dir_of(&node.file_path) == pkg_dir }) - .map(|node| ResolvedRef { - original: reference.clone(), - target_node_id: node.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }) + .map(|node| ResolvedRef::new(reference.clone(), node.id.clone(), 0.9, ResolvedBy::Import)) } #[derive(Default)] @@ -3243,12 +3238,12 @@ fn lookup_python_module_member( PythonModuleMemberLookup { claimed: true, - resolved: Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence: 0.85, - resolved_by: ResolvedBy::Import, - }), + resolved: Some(ResolvedRef::new( + reference.clone(), + target.id.clone(), + 0.85, + ResolvedBy::Import, + )), } } @@ -3345,12 +3340,12 @@ fn resolve_python_from_import_member( if targets.len() != 1 { return None; } - Some(ResolvedRef { - original: reference.clone(), - target_node_id: targets[0].id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }) + Some(ResolvedRef::new( + reference.clone(), + targets[0].id.clone(), + 0.9, + ResolvedBy::Import, + )) } /// Python absolute dotted module import `import a.b.c` → its file (the Django @@ -3374,12 +3369,12 @@ fn resolve_python_absolute_module( .find(|n| n.kind == NodeKind::File) .map(|n| n.id.clone()) })?; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: hit_id, - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }) + Some(ResolvedRef::new( + reference.clone(), + hit_id, + 0.9, + ResolvedBy::Import, + )) } /// How many modules deep a Python re-export walk goes (upstream #2247). @@ -3673,12 +3668,12 @@ fn resolve_rust_path_reference( | NodeKind::Interface ) })?; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }) + Some(ResolvedRef::new( + reference.clone(), + target.id.clone(), + 0.9, + ResolvedBy::Import, + )) } /// The crate-root directory (holds `lib.rs`/`main.rs`), walking up from a file. @@ -3827,12 +3822,12 @@ pub(crate) fn resolve_lua_require( .into_iter() .find(|n| n.kind == NodeKind::File) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: file_node.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }); + return Some(ResolvedRef::new( + reference.clone(), + file_node.id.clone(), + 0.9, + ResolvedBy::Import, + )); } } None diff --git a/crates/codegraph-resolve/src/js_store.rs b/crates/codegraph-resolve/src/js_store.rs index e25e56cf..88ad9fcb 100644 --- a/crates/codegraph-resolve/src/js_store.rs +++ b/crates/codegraph-resolve/src/js_store.rs @@ -61,12 +61,12 @@ pub(crate) fn match_store_accessor_chain( let [only] = callables.as_slice() else { return None; }; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: only.id.clone(), - confidence: 0.6, - resolved_by: ResolvedBy::ExactMatch, - }) + Some(ResolvedRef::new( + reference.clone(), + only.id.clone(), + 0.6, + ResolvedBy::ExactMatch, + )) } /// A bare JS/TS call bound to a store action by a destructuring of diff --git a/crates/codegraph-resolve/src/lib.rs b/crates/codegraph-resolve/src/lib.rs index 9fb5f6ae..2bc21ff1 100644 --- a/crates/codegraph-resolve/src/lib.rs +++ b/crates/codegraph-resolve/src/lib.rs @@ -88,6 +88,6 @@ pub use name_matcher::CASE_INSENSITIVE_LANGUAGES; pub use resolver::{ReferenceResolver, ResolutionObservation}; pub use snapshot_context::{EdgeAdjacency, SnapshotResolutionContext, build_edge_adjacency}; pub use types::{ - FrameworkResolverExtractionResult, ImportMapping, ReExport, RefView, ResolutionContext, - ResolutionResult, ResolutionStats, ResolvedBy, ResolvedRef, + AlsoTarget, EdgeShape, FrameworkResolverExtractionResult, ImportMapping, ReExport, RefView, + ResolutionContext, ResolutionResult, ResolutionStats, ResolvedBy, ResolvedRef, }; diff --git a/crates/codegraph-resolve/src/lua_scope.rs b/crates/codegraph-resolve/src/lua_scope.rs index be2cc7d1..5788d378 100644 --- a/crates/codegraph-resolve/src/lua_scope.rs +++ b/crates/codegraph-resolve/src/lua_scope.rs @@ -707,11 +707,8 @@ pub(crate) fn lua_alias_target( } }; target.map(|target| { - target.map(|target_node_id| ResolvedRef { - original: reference.clone(), - target_node_id, - confidence: 0.9, - resolved_by: ResolvedBy::Import, + target.map(|target_node_id| { + ResolvedRef::new(reference.clone(), target_node_id, 0.9, ResolvedBy::Import) }) }) } diff --git a/crates/codegraph-resolve/src/member_value.rs b/crates/codegraph-resolve/src/member_value.rs index 3375064d..9312f892 100644 --- a/crates/codegraph-resolve/src/member_value.rs +++ b/crates/codegraph-resolve/src/member_value.rs @@ -210,11 +210,13 @@ fn unique_callable( (matches!(target.kind, NodeKind::Function | NodeKind::Method) && target.id != reference.from_node_id && !is_python_property(target, context)) - .then(|| ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence, - resolved_by: ResolvedBy::FunctionRef, + .then(|| { + ResolvedRef::new( + reference.clone(), + target.id.clone(), + confidence, + ResolvedBy::FunctionRef, + ) }) } diff --git a/crates/codegraph-resolve/src/name_matcher.rs b/crates/codegraph-resolve/src/name_matcher.rs index 4632ee7b..ce6307d6 100644 --- a/crates/codegraph-resolve/src/name_matcher.rs +++ b/crates/codegraph-resolve/src/name_matcher.rs @@ -70,12 +70,12 @@ pub fn match_by_file_path( if let Some(exact) = file_nodes.iter().find(|n| { n.qualified_name == reference.reference_name || n.file_path == reference.reference_name }) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: exact.id.clone(), - confidence: 0.95, - resolved_by: ResolvedBy::FilePath, - }); + return Some(ResolvedRef::new( + reference.clone(), + exact.id.clone(), + 0.95, + ResolvedBy::FilePath, + )); } // Suffix match, picking the closest file node (name-matcher.ts:54-64). @@ -89,22 +89,22 @@ pub fn match_by_file_path( .collect(); if !suffix_matches.is_empty() { let chosen = pick_closest_file_node(&suffix_matches, reference); - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: chosen.id.clone(), - confidence: 0.85, - resolved_by: ResolvedBy::FilePath, - }); + return Some(ResolvedRef::new( + reference.clone(), + chosen.id.clone(), + 0.85, + ResolvedBy::FilePath, + )); } // Single same-named file node, lower confidence (name-matcher.ts:67-74). if file_nodes.len() == 1 { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: file_nodes[0].id.clone(), - confidence: 0.7, - resolved_by: ResolvedBy::FilePath, - }); + return Some(ResolvedRef::new( + reference.clone(), + file_nodes[0].id.clone(), + 0.7, + ResolvedBy::FilePath, + )); } None @@ -3250,12 +3250,12 @@ pub fn match_by_exact_name( // reference actually was, and only the CANDIDATES are narrowed. if reachable.len() == 1 { let is_cross_language = candidates[0].language != reference.language; - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: candidates[0].id.clone(), - confidence: if is_cross_language { 0.5 } else { 0.9 }, - resolved_by: ResolvedBy::ExactMatch, - }); + return Some(ResolvedRef::new( + reference.clone(), + candidates[0].id.clone(), + if is_cross_language { 0.5 } else { 0.9 }, + ResolvedBy::ExactMatch, + )); } // Multiple matches — narrow down (name-matcher.ts:194-206). Decline past the @@ -3266,12 +3266,12 @@ pub fn match_by_exact_name( if let Some(best) = find_best_match(reference, &candidates) { let proximity = compute_path_proximity(&reference.file_path, &best.file_path); let confidence = if proximity >= 30.0 { 0.7 } else { 0.4 }; - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: best.id.clone(), + return Some(ResolvedRef::new( + reference.clone(), + best.id.clone(), confidence, - resolved_by: ResolvedBy::ExactMatch, - }); + ResolvedBy::ExactMatch, + )); } None @@ -3319,12 +3319,12 @@ pub fn match_by_qualified_name( }; } if candidates.len() == 1 { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: candidates[0].id.clone(), - confidence: 0.95, - resolved_by: ResolvedBy::QualifiedName, - }); + return Some(ResolvedRef::new( + reference.clone(), + candidates[0].id.clone(), + 0.95, + ResolvedBy::QualifiedName, + )); } // Several symbols share this exact qualified name (an ODR clash / separate @@ -3333,12 +3333,12 @@ pub fn match_by_qualified_name( if candidates.len() > 1 { let ordered = prefer_call_site_file(candidates, &reference.file_path); if ordered[0].file_path == reference.file_path { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: ordered[0].id.clone(), - confidence: 0.95, - resolved_by: ResolvedBy::QualifiedName, - }); + return Some(ResolvedRef::new( + reference.clone(), + ordered[0].id.clone(), + 0.95, + ResolvedBy::QualifiedName, + )); } } @@ -3365,12 +3365,12 @@ pub fn match_by_qualified_name( }) .collect(); if arity_candidates.len() == 1 { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: arity_candidates[0].id.clone(), - confidence: 0.85, - resolved_by: ResolvedBy::QualifiedName, - }); + return Some(ResolvedRef::new( + reference.clone(), + arity_candidates[0].id.clone(), + 0.85, + ResolvedBy::QualifiedName, + )); } return None; } @@ -3387,12 +3387,12 @@ pub fn match_by_qualified_name( .collect(); if !partial.is_empty() { let ordered = prefer_call_site_file(partial, &reference.file_path); - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: ordered[0].id.clone(), - confidence: 0.85, - resolved_by: ResolvedBy::QualifiedName, - }); + return Some(ResolvedRef::new( + reference.clone(), + ordered[0].id.clone(), + 0.85, + ResolvedBy::QualifiedName, + )); } } @@ -3555,12 +3555,12 @@ pub(crate) fn resolve_object_literal_member( return None; } - Some(ResolvedRef { - original: reference.clone(), - target_node_id: first.id.clone(), + Some(ResolvedRef::new( + reference.clone(), + first.id.clone(), confidence, resolved_by, - }) + )) } /// Resolve a method on a type, walking supertypes (`resolveMethodOnType`, @@ -3642,12 +3642,12 @@ pub(crate) fn resolve_method_on_type( context, ) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: member.id.clone(), + return Some(ResolvedRef::new( + reference.clone(), + member.id.clone(), confidence, resolved_by, - }); + )); } return None; } @@ -3664,12 +3664,12 @@ pub(crate) fn resolve_method_on_type( let fp = m.file_path.replace('\\', "/"); fp.ends_with(&fqn_path) || fp.ends_with(&format!("/{fqn_path}")) }) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: chosen.id.clone(), + return Some(ResolvedRef::new( + reference.clone(), + chosen.id.clone(), confidence, resolved_by, - }); + )); } } } @@ -3679,12 +3679,12 @@ pub(crate) fn resolve_method_on_type( // Runs AFTER the preferred_fqn block, so Java/Kotlin import disambiguation // (#314), whose target is intentionally in another file, is unaffected. let ordered = prefer_call_site_file(matches, &reference.file_path); - Some(ResolvedRef { - original: reference.clone(), - target_node_id: ordered[0].id.clone(), + Some(ResolvedRef::new( + reference.clone(), + ordered[0].id.clone(), confidence, resolved_by, - }) + )) } /// Last `::`-separated segment of a C++ name (`cppLastSegment`, @@ -5857,11 +5857,13 @@ pub fn match_method_call( } else { None }; - return target.map(|target| ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence: 0.95, - resolved_by: ResolvedBy::InstanceMethod, + return target.map(|target| { + ResolvedRef::new( + reference.clone(), + target.id.clone(), + 0.95, + ResolvedBy::InstanceMethod, + ) }); } } @@ -6042,12 +6044,12 @@ pub fn match_method_call( reference, context, ) { - crate::cfml_scope::TypedReceiverCall::Method(method) => Some(ResolvedRef { - original: reference.clone(), - target_node_id: method.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::InstanceMethod, - }), + crate::cfml_scope::TypedReceiverCall::Method(method) => Some(ResolvedRef::new( + reference.clone(), + method.id.clone(), + 0.9, + ResolvedBy::InstanceMethod, + )), // The receiver is one of the namesakes and nothing says // which: a method picked by name below is a guess. crate::cfml_scope::TypedReceiverCall::Ambiguous => return None, @@ -6265,12 +6267,12 @@ pub fn match_method_call( if let Some(method_node) = find_method_in_class(&class_node, &method_name, context, &|_| true) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: method_node.id, - confidence: 0.85, - resolved_by: ResolvedBy::QualifiedName, - }); + return Some(ResolvedRef::new( + reference.clone(), + method_node.id, + 0.85, + ResolvedBy::QualifiedName, + )); } } } @@ -6315,12 +6317,12 @@ pub fn match_method_call( inherited_class_method(&walk, &method_name, context) }); if let Some(inherited) = inherited { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: inherited.id.clone(), - confidence: 0.8, - resolved_by: ResolvedBy::QualifiedName, - }); + return Some(ResolvedRef::new( + reference.clone(), + inherited.id.clone(), + 0.8, + ResolvedBy::QualifiedName, + )); } // Built-in method names need a validated receiver (#1987). Typed, imported, @@ -6450,12 +6452,12 @@ pub fn match_method_call( callable_on_a_value(method, reference, context) }) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: method_node.id, - confidence: 0.8, - resolved_by: ResolvedBy::InstanceMethod, - }); + return Some(ResolvedRef::new( + reference.clone(), + method_node.id, + 0.8, + ResolvedBy::InstanceMethod, + )); } } } @@ -6593,12 +6595,12 @@ pub fn match_method_call( && !crate::ruby_scope::is_self_receiver(&object_or_class) && !names_owner(&target_methods[0])) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: target_methods[0].id.clone(), - confidence: 0.7, - resolved_by: ResolvedBy::InstanceMethod, - }); + return Some(ResolvedRef::new( + reference.clone(), + target_methods[0].id.clone(), + 0.7, + ResolvedBy::InstanceMethod, + )); } if target_methods.len() > 1 { @@ -6654,12 +6656,12 @@ pub fn match_method_call( // (upstream #2221). if let Some(best) = best_match { if best_score >= 2 && best.id != reference.from_node_id { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: best.id.clone(), - confidence: 0.65, - resolved_by: ResolvedBy::InstanceMethod, - }); + return Some(ResolvedRef::new( + reference.clone(), + best.id.clone(), + 0.65, + ResolvedBy::InstanceMethod, + )); } } } @@ -6824,12 +6826,12 @@ fn match_rust_self_call( let [target] = owned.as_slice() else { return None; }; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::QualifiedName, - }) + Some(ResolvedRef::new( + reference.clone(), + target.id.clone(), + 0.9, + ResolvedBy::QualifiedName, + )) } /// Resolve `self.field.method()` through the field type declared on the @@ -7596,12 +7598,12 @@ pub fn match_fuzzy(reference: &RefView, context: &dyn ResolutionContext) -> Opti return None; } let is_cross_language = candidate.language != reference.language; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: candidate.id.clone(), - confidence: if is_cross_language { 0.3 } else { 0.5 }, - resolved_by: ResolvedBy::Fuzzy, - }) + Some(ResolvedRef::new( + reference.clone(), + candidate.id.clone(), + if is_cross_language { 0.3 } else { 0.5 }, + ResolvedBy::Fuzzy, + )) } /// Resolve a `::`-scoped factory chain (`matchScopedCallChain`, @@ -8063,12 +8065,12 @@ fn other_supertype_named( let [candidate] = pool.as_slice() else { return None; }; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: candidate.id.clone(), - confidence: 0.8, - resolved_by: ResolvedBy::QualifiedName, - }) + Some(ResolvedRef::new( + reference.clone(), + candidate.id.clone(), + 0.8, + ResolvedBy::QualifiedName, + )) } /// Reference kinds a bare JS/TS local can be: a call, a value (function refs @@ -8562,12 +8564,12 @@ pub fn match_reference( .iter() .find(|candidate| candidate.file_path == reference.file_path) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: same_file.id.clone(), - confidence: 0.95, - resolved_by: ResolvedBy::ExactMatch, - }); + return Some(ResolvedRef::new( + reference.clone(), + same_file.id.clone(), + 0.95, + ResolvedBy::ExactMatch, + )); } // Another module's function is called bare only through `-import(Mod, // [f/N])`, or from a `.hrl` a module includes (upstream #2178): @@ -8582,11 +8584,13 @@ pub fn match_reference( return candidates .iter() .find(|candidate| candidate.qualified_name.starts_with(&prefix)) - .map(|chosen| ResolvedRef { - original: reference.clone(), - target_node_id: chosen.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::ExactMatch, + .map(|chosen| { + ResolvedRef::new( + reference.clone(), + chosen.id.clone(), + 0.9, + ResolvedBy::ExactMatch, + ) }); } let candidates: Vec> = @@ -8601,21 +8605,21 @@ pub fn match_reference( .collect() }; if candidates.len() == 1 { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: candidates[0].id.clone(), - confidence: 0.8, - resolved_by: ResolvedBy::ExactMatch, - }); + return Some(ResolvedRef::new( + reference.clone(), + candidates[0].id.clone(), + 0.8, + ResolvedBy::ExactMatch, + )); } if let Some(best) = find_best_match(reference, &candidates) { let proximity = compute_path_proximity(&reference.file_path, &best.file_path); - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: best.id.clone(), - confidence: if proximity >= 30.0 { 0.7 } else { 0.4 }, - resolved_by: ResolvedBy::ExactMatch, - }); + return Some(ResolvedRef::new( + reference.clone(), + best.id.clone(), + if proximity >= 30.0 { 0.7 } else { 0.4 }, + ResolvedBy::ExactMatch, + )); } return None; } @@ -8794,12 +8798,12 @@ pub fn match_function_ref( .iter() .copied() .reduce(|a, b| if a.start_line <= b.start_line { a } else { b })?; - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::FunctionRef, - }); + return Some(ResolvedRef::new( + reference.clone(), + target.id.clone(), + 0.9, + ResolvedBy::FunctionRef, + )); } let named: Vec> = context @@ -8916,12 +8920,12 @@ pub fn match_function_ref( .iter() .copied() .reduce(|a, b| if a.start_line <= b.start_line { a } else { b })?; - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence: if same_file.len() == 1 { 0.95 } else { 0.9 }, - resolved_by: ResolvedBy::FunctionRef, - }); + return Some(ResolvedRef::new( + reference.clone(), + target.id.clone(), + if same_file.len() == 1 { 0.95 } else { 0.9 }, + ResolvedBy::FunctionRef, + )); } // Cross-file: only an unambiguous match resolves, or, in Python, the one @@ -8936,12 +8940,12 @@ pub fn match_function_ref( context, ))) { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: candidates[0].id.clone(), - confidence: 0.8, - resolved_by: ResolvedBy::FunctionRef, - }); + return Some(ResolvedRef::new( + reference.clone(), + candidates[0].id.clone(), + 0.8, + ResolvedBy::FunctionRef, + )); } None } @@ -9191,12 +9195,12 @@ mod self_veto_tests { is_function_ref: false, reference_subkind: None, }; - let result = ResolvedRef { - original: reference.clone(), - target_node_id: node.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::ExactMatch, - }; + let result = ResolvedRef::new( + reference.clone(), + node.id.clone(), + 0.9, + ResolvedBy::ExactMatch, + ); (reference, Some(result)) } @@ -9630,12 +9634,12 @@ mod tests { let rust_call = refv("map", EdgeKind::Calls, "src/lib.rs", Language::Rust, 1); let ctx = Ctx::default().node_by_id(ts_method.clone()); let chosen = || { - Some(ResolvedRef { - original: rust_call.clone(), - target_node_id: ts_method.id.clone(), - confidence: 0.5, - resolved_by: ResolvedBy::ExactMatch, - }) + Some(ResolvedRef::new( + rust_call.clone(), + ts_method.id.clone(), + 0.5, + ResolvedBy::ExactMatch, + )) }; assert!( gate_language_match(chosen(), &rust_call, &ctx).is_none(), @@ -9659,12 +9663,12 @@ mod tests { let cgo = Ctx::default() .node_by_id(exported.clone()) .file("go/lib.go", "package main\nimport \"C\"\n//export add\nfunc add(a, b C.int) C.int { return a + b }\n"); - let via_cgo = Some(ResolvedRef { - original: c_call.clone(), - target_node_id: exported.id.clone(), - confidence: 0.5, - resolved_by: ResolvedBy::ExactMatch, - }); + let via_cgo = Some(ResolvedRef::new( + c_call.clone(), + exported.id.clone(), + 0.5, + ResolvedBy::ExactMatch, + )); assert!( gate_language_match(via_cgo, &c_call, &cgo).is_some(), "a cgo //export is a C ABI bridge" @@ -16113,11 +16117,13 @@ mod tests { "src/util.rs", Language::Rust, ); - let chosen = |reference: &RefView| ResolvedRef { - original: reference.clone(), - target_node_id: "function:helper".to_string(), - confidence: 0.9, - resolved_by: ResolvedBy::QualifiedName, + let chosen = |reference: &RefView| { + ResolvedRef::new( + reference.clone(), + "function:helper".to_string(), + 0.9, + ResolvedBy::QualifiedName, + ) }; let gate = |source: &str, reference: &RefView| { let ctx = Ctx::default() diff --git a/crates/codegraph-resolve/src/object_literal.rs b/crates/codegraph-resolve/src/object_literal.rs index bbedcbf6..b341ef40 100644 --- a/crates/codegraph-resolve/src/object_literal.rs +++ b/crates/codegraph-resolve/src/object_literal.rs @@ -101,12 +101,12 @@ pub(crate) fn resolve_object_literal_binding( let target = facts.literal_binding_target(&container.id, &key, |facts| { binding_target(facts, container, &binding, calls, reference, context) })?; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: target, - confidence: 0.85, - resolved_by: ResolvedBy::InstanceMethod, - }) + Some(ResolvedRef::new( + reference.clone(), + target, + 0.85, + ResolvedBy::InstanceMethod, + )) } fn accepts(node: &Node, calls: bool) -> bool { diff --git a/crates/codegraph-resolve/src/resolver.rs b/crates/codegraph-resolve/src/resolver.rs index 30c44e0e..7991d68b 100644 --- a/crates/codegraph-resolve/src/resolver.rs +++ b/crates/codegraph-resolve/src/resolver.rs @@ -1396,6 +1396,14 @@ pub struct ReferenceResolver { /// Detected `FrameworkResolver` implementations (`frameworks` field, /// `index.ts:264`). Empty until [`Self::initialize`] detects react/vue/nestjs. framework_resolver_extensions: Vec>, + /// Which detected frameworks extract which files (upstream #2129 + /// `frameworksForFile`), set by [`Self::initialize`]; a `Mutex` so its + /// manifest reads are memoized behind `&self`. + app_gating: std::sync::Mutex, + /// [`crate::frameworks::package_deps::AppGating::digest`] over the + /// indexed files when [`Self::initialize`] ran; `None` when no detected + /// framework is gated. + framework_gating: Option, /// Distinct symbol names known to the graph, for the fast pre-filter /// (`knownNames`, `index.ts:224`). Populated by `warm_caches`. known_names: Option>, @@ -1441,6 +1449,8 @@ impl ReferenceResolver { max_file_size: codegraph_core::config::DEFAULT_MAX_FILE_SIZE, recorded_heads_stale: None, framework_resolver_extensions: Vec::new(), + app_gating: std::sync::Mutex::default(), + framework_gating: None, known_names: None, known_lower_names: std::sync::OnceLock::new(), macro_visibility: std::sync::Mutex::default(), @@ -1483,7 +1493,32 @@ impl ReferenceResolver { /// `FrameworkResolver` list (`initialize`, `index.ts:263-266`). Must run /// before `resolve_all` so Strategy-1 dispatch sees the detected resolvers. pub fn initialize(&mut self, context: &dyn ResolutionContext) { - self.framework_resolver_extensions = crate::frameworks::detect_frameworks(context); + self.install_frameworks(crate::frameworks::detect_frameworks(context), context); + } + + /// Run `frameworks`, which `context`'s project uses, and decide which of + /// their extractors run on which files. + fn install_frameworks( + &mut self, + frameworks: Vec>, + context: &dyn ResolutionContext, + ) { + self.framework_resolver_extensions = frameworks; + let mut gating = crate::frameworks::package_deps::AppGating::new( + self.framework_resolver_extensions + .iter() + .map(|resolver| (resolver.name(), resolver.app_dependencies())), + &context.declared_dependencies(), + self.max_file_size, + ); + self.framework_gating = gating.digest( + context.get_all_files_shared().iter().map(String::as_str), + std::path::Path::new(&self.project_root), + ); + *self + .app_gating + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) = gating; } /// Has at least one `FrameworkResolver` been detected? @@ -1504,6 +1539,18 @@ impl ReferenceResolver { names } + /// [`Self::framework_names`], then `gating:` and the digest of which + /// files the gated frameworks extract when any is gated: the frameworks + /// row a sync compares, so a manifest edit that moves an app's + /// frameworks re-extracts every file like a framework that came or went. + pub fn framework_signature(&self) -> Vec { + let mut signature = self.framework_names(); + if let Some(digest) = &self.framework_gating { + signature.push(format!("gating:{digest}")); + } + signature + } + /// Run each detected resolver's `post_extract` finalization and persist the /// returned node updates (`runPostExtract`, `index.ts:276-295`). Idempotent — /// safe after every index and every incremental sync. Returns the number of @@ -1604,6 +1651,11 @@ impl ReferenceResolver { mut read_file: impl FnMut(&str) -> Option, ) -> FrameworkExtractionCollection { let mut collection = FrameworkExtractionCollection::default(); + let mut gating = self + .app_gating + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let project_root = std::path::Path::new(&self.project_root); for relative in relative_files { let detected = codegraph_extract::detect_language_with(relative, extensions); // A `.h` header may turn out C++ or Objective-C once read, so it @@ -1628,7 +1680,11 @@ impl ReferenceResolver { let language = codegraph_extract::detect_language_for_source(relative, &content, extensions); for resolver in &self.framework_resolver_extensions { - if !applies_to_language(resolver.as_ref(), language) { + // A framework that runs only in its own apps (#2129) + // extracts only the files of one of them. + if !applies_to_language(resolver.as_ref(), language) + || !gating.applies(resolver.name(), relative, project_root) + { continue; } if let Some(result) = resolver.extract(relative, &content, context) { @@ -2712,12 +2768,12 @@ impl ReferenceResolver { let [target] = targets.as_slice() else { return None; }; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence: 0.98, - resolved_by: ResolvedBy::QualifiedName, - }) + Some(ResolvedRef::new( + reference.clone(), + target.id.clone(), + 0.98, + ResolvedBy::QualifiedName, + )) } /// Resolve a `this.` function_ref against the enclosing class's own @@ -2774,12 +2830,12 @@ impl ReferenceResolver { .reduce(|a, b| if a.start_line <= b.start_line { a } else { b }); match target { Some(target) => ( - Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence: 0.95, - resolved_by: ResolvedBy::FunctionRef, - }), + Some(ResolvedRef::new( + reference.clone(), + target.id.clone(), + 0.95, + ResolvedBy::FunctionRef, + )), None, ), // Not on the class itself — possibly INHERITED. Retry in the @@ -2834,12 +2890,12 @@ impl ReferenceResolver { }; if let Some(target) = find_inherited_member(store, &class_name, member, reference) { - resolved.push(ResolvedRef { - original: reference.clone(), - target_node_id: target, - confidence: 0.85, - resolved_by: ResolvedBy::FunctionRef, - }); + resolved.push(ResolvedRef::new( + reference.clone(), + target, + 0.85, + ResolvedBy::FunctionRef, + )); } } drop(context); @@ -2908,19 +2964,25 @@ impl ReferenceResolver { Ok(count) } - /// Create edges from resolved references (`createEdges`, `index.ts:751-790`). + /// Create edges from resolved references (`createEdges`, `index.ts:1546-1614`). pub fn create_edges(&self, resolved: &[ResolvedRef], store: &Store) -> Vec { resolved .iter() - .map(|reference| { - let mut kind = reference.original.reference_kind; - - // A Dart member read runs the getter it links (upstream #2338's - // `edgeKind: 'calls'`): `calls` is what callers, impact and - // flows follow. - if crate::dart_members::is_dart_member_read(&reference.original) { - kind = EdgeKind::Calls; - } + .flat_map(|reference| { + let shape = reference.edge.as_deref(); + let mut kind = match shape.and_then(|shape| shape.edge_kind) { + // The kind a framework resolution gives its edges when it + // is not the reference's own (`edgeKind`): a router's + // `navigates`. + Some(kind) => kind, + // A Dart member read runs the getter it links (upstream + // #2338's `edgeKind: 'calls'`): `calls` is what callers, + // impact and flows follow. + None if crate::dart_members::is_dart_member_read(&reference.original) => { + EdgeKind::Calls + } + None => reference.original.reference_kind, + }; // Promote extends → implements (index.ts:756-764). if kind == EdgeKind::Extends { @@ -2952,16 +3014,35 @@ impl ReferenceResolver { } } - Edge { - id: None, - source: reference.original.from_node_id.clone(), - target: reference.target_node_id.clone(), - kind, - metadata: Some(build_edge_metadata(reference)), - line: Some(reference.original.line), - col: Some(reference.original.column), - provenance: None, + // One reference can name several targets — a navigation whose + // destination is a conditional reaches every arm. Each becomes + // its own edge, sharing this resolution's kind and confidence. + let empty = serde_json::Map::new(); + let mut targets = vec![( + reference.target_node_id.as_str(), + shape.map_or(&empty, |shape| &shape.metadata), + )]; + if let Some(shape) = shape { + targets.extend( + shape + .also_targets + .iter() + .map(|also| (also.target_node_id.as_str(), &also.metadata)), + ); } + targets + .into_iter() + .map(|(target, extra)| Edge { + id: None, + source: reference.original.from_node_id.clone(), + target: target.to_string(), + kind, + metadata: Some(build_edge_metadata(reference, extra)), + line: Some(reference.original.line), + col: Some(reference.original.column), + provenance: None, + }) + .collect::>() }) .collect() } @@ -3713,13 +3794,17 @@ fn ref_view_to_unresolved(reference: &RefView) -> UnresolvedRef { } } -/// Build a resolved edge's `metadata` JSON: `confidence`+`resolvedBy`, plus +/// Build a resolved edge's `metadata` JSON: the target's own metadata from a +/// framework resolution (`href`, …), then `confidence`+`resolvedBy`, plus /// `fnRef: true` for function-as-value edges (#756, index.ts:824-827) and /// `subkind` for Godot refs carrying a `reference_subkind`. The base shape (no /// `fnRef`, no `subkind`) stays byte-identical to the prior output, keeping /// existing resolved-edge goldens stable. -fn build_edge_metadata(reference: &ResolvedRef) -> serde_json::Value { - let mut map = serde_json::Map::new(); +fn build_edge_metadata( + reference: &ResolvedRef, + extra: &serde_json::Map, +) -> serde_json::Value { + let mut map = extra.clone(); map.insert( "confidence".to_string(), serde_json::json!(reference.confidence), @@ -4088,12 +4173,12 @@ mod tests { #[test] fn build_edge_metadata_base_and_fn_ref() { let mut r = resolved_ref("t", 0.75); - let base = build_edge_metadata(&r); + let base = build_edge_metadata(&r, &serde_json::Map::new()); assert_eq!(base["confidence"].as_f64(), Some(0.75)); assert_eq!(base["resolvedBy"].as_str(), Some("import")); assert!(base.get("fnRef").is_none()); r.original.is_function_ref = true; - let with_fn = build_edge_metadata(&r); + let with_fn = build_edge_metadata(&r, &serde_json::Map::new()); assert_eq!(with_fn["fnRef"].as_bool(), Some(true)); } @@ -4101,7 +4186,7 @@ mod tests { fn build_edge_metadata_includes_subkind() { let mut r = resolved_ref("t", 0.9); r.original.reference_subkind = Some(codegraph_core::types::ReferenceSubkind::ScriptAttach); - let meta = build_edge_metadata(&r); + let meta = build_edge_metadata(&r, &serde_json::Map::new()); assert_eq!(meta["subkind"].as_str(), Some("script_attach")); } @@ -4374,8 +4459,8 @@ mod tests { let resolver = ReferenceResolver::new("/root"); - let extends = ResolvedRef { - original: RefView { + let extends = ResolvedRef::new( + RefView { row_id: None, from_node_id: cls.id.clone(), reference_name: "I".to_string(), @@ -4387,12 +4472,12 @@ mod tests { is_function_ref: false, reference_subkind: None, }, - target_node_id: iface.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }; - let calls = ResolvedRef { - original: RefView { + iface.id.clone(), + 0.9, + ResolvedBy::Import, + ); + let calls = ResolvedRef::new( + RefView { row_id: None, from_node_id: caller.id.clone(), reference_name: "D".to_string(), @@ -4404,16 +4489,85 @@ mod tests { is_function_ref: false, reference_subkind: None, }, - target_node_id: target_cls.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }; + target_cls.id.clone(), + 0.9, + ResolvedBy::Import, + ); let edges = resolver.create_edges(&[extends, calls], &store); assert_eq!(edges.len(), 2); assert!(edges.iter().any(|e| e.kind == EdgeKind::Implements)); assert!(edges.iter().any(|e| e.kind == EdgeKind::Instantiates)); } + /// A framework resolution's edge shape (upstream `edgeKind`, `metadata`, + /// `alsoTargets`): the edges take its kind, never promoted, one per + /// target, each with that target's metadata under the resolution's + /// `confidence` and `resolvedBy`. + #[test] + fn create_edges_draws_a_framework_edge_shape() { + let mut store = Store::open(&temp_db("edge-shape")).expect("open"); + let caller = mk_node("function:go", NodeKind::Function, "go", "a.tsx"); + // Class-kind targets: a plain `calls` edge to one would be promoted. + let login = mk_node("class:Login", NodeKind::Class, "Login", "login.tsx"); + let search = mk_node("class:Search", NodeKind::Class, "Search", "search.tsx"); + store + .upsert_nodes(&[caller.clone(), login.clone(), search.clone()]) + .expect("upsert"); + let resolver = ReferenceResolver::new("/root"); + let href = |value: &str| { + let mut map = serde_json::Map::new(); + map.insert("href".to_string(), serde_json::json!(value)); + // A key the resolution's own metadata overrides. + map.insert("confidence".to_string(), serde_json::json!(0.1)); + map + }; + let mut original = mk_ref("router.push", EdgeKind::Calls, Language::Tsx); + original.from_node_id = caller.id.clone(); + original.line = 7; + original.column = 4; + let navigation = ResolvedRef::new(original, login.id.clone(), 0.9, ResolvedBy::Framework) + .with_edge(crate::types::EdgeShape { + edge_kind: Some(EdgeKind::Navigates), + metadata: href("/login"), + also_targets: vec![crate::types::AlsoTarget { + target_node_id: search.id.clone(), + metadata: href("/search"), + }], + }); + + let edges = resolver.create_edges(&[navigation], &store); + let drawn: Vec = edges + .iter() + .map(|edge| { + let metadata = edge.metadata.as_ref().expect("metadata"); + format!( + "{} {} {} {} {} {:?}:{:?}", + edge.target, + edge.kind.as_str(), + metadata["href"].as_str().unwrap(), + metadata["confidence"], + metadata["resolvedBy"].as_str().unwrap(), + edge.line, + edge.col, + ) + }) + .collect(); + assert_eq!( + drawn, + [ + format!( + "{} navigates /login 0.9 framework Some(7):Some(4)", + login.id + ), + format!( + "{} navigates /search 0.9 framework Some(7):Some(4)", + search.id + ), + ] + ); + assert!(edges.iter().all(|edge| edge.source == caller.id)); + } + #[test] fn extends_ref_judged_before_implements_promotion() { // `create_edges` promotes `Extends` → `Implements` when the target is an @@ -4441,18 +4595,13 @@ mod tests { is_function_ref: false, reference_subkind: None, }; - let to_member = ResolvedRef { - original: extends_ref.clone(), - target_node_id: member.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::ExactMatch, - }; - let to_iface = ResolvedRef { - original: extends_ref, - target_node_id: iface.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::ExactMatch, - }; + let to_member = ResolvedRef::new( + extends_ref.clone(), + member.id.clone(), + 0.9, + ResolvedBy::ExactMatch, + ); + let to_iface = ResolvedRef::new(extends_ref, iface.id.clone(), 0.9, ResolvedBy::ExactMatch); assert!( resolver @@ -5121,12 +5270,12 @@ mod tests { .is_none() ); // References ref to a Python target from a TS source -> dropped (different family). - let candidate = ResolvedRef { - original: mk_ref("py", EdgeKind::References, Language::TypeScript), - target_node_id: py_node.id.clone(), - confidence: 0.8, - resolved_by: ResolvedBy::Import, - }; + let candidate = ResolvedRef::new( + mk_ref("py", EdgeKind::References, Language::TypeScript), + py_node.id.clone(), + 0.8, + ResolvedBy::Import, + ); assert!( resolver .gate_language( @@ -5842,6 +5991,270 @@ mod tests { } } + /// A file-based router that runs only in its own apps (upstream #2129): + /// one route node per file it extracts. + struct AppRouter; + impl FrameworkResolver for AppRouter { + fn name(&self) -> &str { + "app-router" + } + fn languages(&self) -> Option<&[Language]> { + const L: [Language; 1] = [Language::TypeScript]; + Some(&L) + } + fn app_dependencies(&self) -> Option<&'static [&'static str]> { + Some(&["app-router"]) + } + fn detect(&self, _c: &dyn ResolutionContext) -> bool { + true + } + fn resolve(&self, _r: &RefView, _c: &dyn ResolutionContext) -> Option { + None + } + fn extract( + &self, + file_path: &str, + _content: &str, + _context: &crate::framework::FrameworkExtractionContext, + ) -> Option { + let mut route = mk_node( + &format!("route:{file_path}"), + NodeKind::Route, + file_path, + file_path, + ); + route.language = Language::TypeScript; + Some(crate::types::FrameworkResolverExtractionResult { + nodes: vec![route], + references: Vec::new(), + }) + } + } + + /// The same extractor, run on every file of its language. + struct EveryFileRouter; + impl FrameworkResolver for EveryFileRouter { + fn name(&self) -> &str { + "every-file" + } + fn languages(&self) -> Option<&[Language]> { + AppRouter.languages() + } + fn detect(&self, _c: &dyn ResolutionContext) -> bool { + true + } + fn resolve(&self, _r: &RefView, _c: &dyn ResolutionContext) -> Option { + None + } + fn extract( + &self, + file_path: &str, + content: &str, + context: &crate::framework::FrameworkExtractionContext, + ) -> Option { + let mut result = AppRouter.extract(file_path, content, context)?; + for node in &mut result.nodes { + node.id = format!("every:{file_path}"); + } + Some(result) + } + } + + /// A context over files on disk under `root`, all of them indexed. + struct DiskCtx { + root: std::path::PathBuf, + files: Vec, + } + impl ResolutionContext for DiskCtx { + fn get_nodes_in_file(&self, _f: &str) -> Vec { + Vec::new() + } + fn get_nodes_by_name(&self, _n: &str) -> Vec { + Vec::new() + } + fn get_nodes_by_qualified_name(&self, _q: &str) -> Vec { + Vec::new() + } + fn get_nodes_by_kind(&self, _k: NodeKind) -> Vec { + Vec::new() + } + fn file_exists(&self, f: &str) -> bool { + self.root.join(f).is_file() + } + fn read_file(&self, f: &str) -> Option { + std::fs::read_to_string(self.root.join(f)).ok() + } + fn get_project_root(&self) -> &str { + self.root.to_str().expect("utf-8 root") + } + fn get_all_files(&self) -> Vec { + self.files.clone() + } + fn get_nodes_by_lower_name(&self, _n: &str) -> Vec { + Vec::new() + } + fn get_node_by_id(&self, _id: &str) -> Option { + None + } + fn get_import_mappings(&self, _f: &str, _l: Language) -> Vec { + Vec::new() + } + } + + /// A temporary project tree, removed on drop. + struct TempTree(std::path::PathBuf); + impl TempTree { + fn new(slug: &str, files: &[(&str, &str)]) -> Self { + let root = temp_db(slug).with_extension("tree"); + let _ = std::fs::remove_dir_all(&root); + let tree = Self(root); + for (path, content) in files { + tree.write(path, content); + } + tree + } + fn write(&self, path: &str, content: &str) { + let full = self.0.join(path); + std::fs::create_dir_all(full.parent().expect("parent")).expect("mkdir"); + std::fs::write(full, content).expect("write"); + } + } + impl Drop for TempTree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// Upstream #2129 through the extraction seam: a framework that names + /// its app dependencies extracts only the files of an app whose nearest + /// manifest declares one, an ungated framework every file, and the + /// frameworks signature moves when a manifest moves an app. + #[test] + fn a_gated_framework_extracts_only_the_files_of_its_apps() { + let tree = TempTree::new( + "app-gating", + &[ + ("package.json", r#"{"name":"mono","private":true}"#), + ( + "apps/web/package.json", + r#"{"dependencies":{"app-router":"*"}}"#, + ), + ("apps/web/app/page.ts", "export const page = 1;\n"), + ( + "apps/admin/package.json", + r#"{"dependencies":{"left-pad":"*"}}"#, + ), + ("apps/admin/app/page.ts", "export const page = 2;\n"), + ], + ); + let files = vec![ + "apps/admin/app/page.ts".to_string(), + "apps/web/app/page.ts".to_string(), + ]; + let context = DiskCtx { + root: tree.0.clone(), + files: files.clone(), + }; + let root = tree.0.to_string_lossy().to_string(); + let extract = |resolver: &ReferenceResolver| -> Vec { + let mut ids: Vec = resolver + .collect_framework_extraction_with( + &files, + &crate::framework::FrameworkExtractionContext::without_config(&root), + &codegraph_extract::ext_config::ExtensionOverrides::default(), + ) + .nodes + .into_iter() + .map(|node| node.id) + .collect(); + ids.sort(); + ids + }; + + let mut resolver = ReferenceResolver::new(&root); + resolver.install_frameworks( + vec![Box::new(AppRouter), Box::new(EveryFileRouter)], + &context, + ); + assert_eq!( + extract(&resolver), + [ + "every:apps/admin/app/page.ts", + "every:apps/web/app/page.ts", + "route:apps/web/app/page.ts", + ] + ); + let signature = resolver.framework_signature(); + assert_eq!(signature[..2], ["app-router", "every-file"]); + assert!(signature[2].starts_with("gating:"), "{signature:?}"); + + // The admin app moves onto the router: its file is extracted too, and + // the signature a sync compares moves with it. + tree.write( + "apps/admin/package.json", + r#"{"dependencies":{"app-router":"*"}}"#, + ); + let mut moved = ReferenceResolver::new(&root); + moved.install_frameworks( + vec![Box::new(AppRouter), Box::new(EveryFileRouter)], + &context, + ); + assert_eq!( + extract(&moved), + [ + "every:apps/admin/app/page.ts", + "every:apps/web/app/page.ts", + "route:apps/admin/app/page.ts", + "route:apps/web/app/page.ts", + ] + ); + assert_ne!(moved.framework_signature(), signature); + + // A manifest edit that moves no app leaves the signature alone. + tree.write( + "apps/admin/package.json", + r#"{"dependencies":{"app-router":"^2"}}"#, + ); + let mut again = ReferenceResolver::new(&root); + again.install_frameworks( + vec![Box::new(AppRouter), Box::new(EveryFileRouter)], + &context, + ); + assert_eq!(again.framework_signature(), moved.framework_signature()); + } + + /// When no manifest names a gated framework's packages, detection found + /// it by other evidence: it extracts every file, and the signature is + /// the framework names alone, as before gating. + #[test] + fn an_undeclared_app_framework_extracts_every_file() { + let tree = TempTree::new( + "app-gating-undeclared", + &[ + ("package.json", r#"{"name":"demo"}"#), + ("app/page.ts", "export const page = 1;\n"), + ], + ); + let files = vec!["app/page.ts".to_string()]; + let context = DiskCtx { + root: tree.0.clone(), + files: files.clone(), + }; + let root = tree.0.to_string_lossy().to_string(); + let mut resolver = ReferenceResolver::new(&root); + resolver.install_frameworks(vec![Box::new(AppRouter)], &context); + let nodes = resolver + .collect_framework_extraction_with( + &files, + &crate::framework::FrameworkExtractionContext::without_config(&root), + &codegraph_extract::ext_config::ExtensionOverrides::default(), + ) + .nodes; + assert_eq!(nodes.len(), 1); + assert_eq!(resolver.framework_signature(), ["app-router"]); + assert_eq!(resolver.framework_signature(), resolver.framework_names()); + } + struct MinimalCtx { files: HashMap, } @@ -5897,12 +6310,12 @@ mod tests { } fn resolved_ref(target: &str, confidence: f64) -> ResolvedRef { - ResolvedRef { - original: mk_ref("x", EdgeKind::Calls, Language::TypeScript), - target_node_id: target.to_string(), + ResolvedRef::new( + mk_ref("x", EdgeKind::Calls, Language::TypeScript), + target.to_string(), confidence, - resolved_by: ResolvedBy::Import, - } + ResolvedBy::Import, + ) } fn mk_node(id: &str, kind: NodeKind, name: &str, file: &str) -> Node { diff --git a/crates/codegraph-resolve/src/scala_scope.rs b/crates/codegraph-resolve/src/scala_scope.rs index 9251f4a8..6a1ee474 100644 --- a/crates/codegraph-resolve/src/scala_scope.rs +++ b/crates/codegraph-resolve/src/scala_scope.rs @@ -863,12 +863,12 @@ pub(crate) fn match_scala_symbolic_type( types }; Some(match chosen.as_slice() { - [only] => Some(ResolvedRef { - original: reference.clone(), - target_node_id: only.id.clone(), - confidence: 0.8, - resolved_by: ResolvedBy::ExactMatch, - }), + [only] => Some(ResolvedRef::new( + reference.clone(), + only.id.clone(), + 0.8, + ResolvedBy::ExactMatch, + )), _ => None, }) } diff --git a/crates/codegraph-resolve/src/shopify_themes.rs b/crates/codegraph-resolve/src/shopify_themes.rs index 25194ef1..4874b0da 100644 --- a/crates/codegraph-resolve/src/shopify_themes.rs +++ b/crates/codegraph-resolve/src/shopify_themes.rs @@ -53,10 +53,12 @@ pub(crate) fn match_shopify_theme_file( .find(|node| node.kind == NodeKind::File && node.file_path == target); // The confidence the path matcher gives the same link: an exact path for // a theme at the project root, a path tail for one in a folder. - Some(found.map(|file| ResolvedRef { - original: reference.clone(), - target_node_id: file.id.clone(), - confidence: if theme.is_empty() { 0.95 } else { 0.85 }, - resolved_by: ResolvedBy::FilePath, + Some(found.map(|file| { + ResolvedRef::new( + reference.clone(), + file.id.clone(), + if theme.is_empty() { 0.95 } else { 0.85 }, + ResolvedBy::FilePath, + ) })) } diff --git a/crates/codegraph-resolve/src/swift_type_visibility.rs b/crates/codegraph-resolve/src/swift_type_visibility.rs index 013f0c08..d3ffed5d 100644 --- a/crates/codegraph-resolve/src/swift_type_visibility.rs +++ b/crates/codegraph-resolve/src/swift_type_visibility.rs @@ -769,12 +769,12 @@ pub(crate) fn resolve_swift_type_path_call( let target = fit .into_iter() .min_by(|a, b| (&a.file_path, a.start_line).cmp(&(&b.file_path, b.start_line)))?; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: target.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::QualifiedName, - }) + Some(ResolvedRef::new( + reference.clone(), + target.id.clone(), + 0.9, + ResolvedBy::QualifiedName, + )) } /// Is this case declared in a `@Reducer enum`? (upstream `isReducerEnum`.) diff --git a/crates/codegraph-resolve/src/types.rs b/crates/codegraph-resolve/src/types.rs index 1a2a6883..90c04f92 100644 --- a/crates/codegraph-resolve/src/types.rs +++ b/crates/codegraph-resolve/src/types.rs @@ -207,7 +207,7 @@ impl ResolvedBy { /// A resolved reference. /// -/// Ports the upstream `ResolvedRef` interface (`types.ts:34-43`). +/// Ports the upstream `ResolvedRef` interface (`types.ts:39-71`). #[derive(Debug, Clone, PartialEq)] pub struct ResolvedRef { /// Original unresolved reference. @@ -218,6 +218,61 @@ pub struct ResolvedRef { pub confidence: f64, /// How it was resolved. pub resolved_by: ResolvedBy, + /// What a framework resolver adds to the edges this resolution draws; + /// `None` for a plain edge of the reference's own kind. + pub edge: Option>, +} + +impl ResolvedRef { + /// A resolution drawing one plain edge of the reference's own kind. + #[must_use] + pub fn new( + original: RefView, + target_node_id: String, + confidence: f64, + resolved_by: ResolvedBy, + ) -> Self { + Self { + original, + target_node_id, + confidence, + resolved_by, + edge: None, + } + } + + /// This resolution drawing the edges `edge` describes. + #[must_use] + pub fn with_edge(mut self, edge: EdgeShape) -> Self { + self.edge = Some(Box::new(edge)); + self + } +} + +/// The edges a framework resolution draws when they are not one plain edge +/// of the reference's own kind (upstream `ResolvedRef.edgeKind`, `metadata` +/// and `alsoTargets`). +#[derive(Debug, Clone, PartialEq, Default)] +pub struct EdgeShape { + /// The edges' kind when it is not the reference's own: a router turns a + /// `calls` reference into a `navigates` edge. + pub edge_kind: Option, + /// Metadata persisted on the edge to the resolution's target (`href`, …). + pub metadata: serde_json::Map, + /// The other targets, when one reference names several: a navigation + /// whose destination is a conditional reaches every arm. Each is an edge + /// of its own with its own metadata, sharing the kind and confidence; the + /// reference still resolves once. + pub also_targets: Vec, +} + +/// One more target of a resolution ([`EdgeShape::also_targets`]). +#[derive(Debug, Clone, PartialEq, Default)] +pub struct AlsoTarget { + /// ID of the target node. + pub target_node_id: String, + /// Metadata persisted on this target's edge. + pub metadata: serde_json::Map, } /// Aggregate resolution statistics. @@ -424,6 +479,12 @@ pub trait ResolutionContext { .iter() .any(|node| node.is_exported) } + /// The dependency names the project's `package.json` files declare + /// ([`crate::frameworks::package_deps::declared_dependencies`]). Contexts + /// that live for one pass compute it once; the default recomputes. + fn declared_dependencies(&self) -> Arc> { + Arc::new(crate::frameworks::package_deps::declared_dependencies(self)) + } /// Get project root (`getProjectRoot`). fn get_project_root(&self) -> &str; /// Get all files (`getAllFiles`). @@ -892,12 +953,12 @@ mod tests { #[test] fn resolved_ref_constructs() { - let rr = ResolvedRef { - original: sample_ref(), - target_node_id: "fn:2".to_string(), - confidence: 0.75, - resolved_by: ResolvedBy::ExactMatch, - }; + let rr = ResolvedRef::new( + sample_ref(), + "fn:2".to_string(), + 0.75, + ResolvedBy::ExactMatch, + ); assert_eq!(rr.clone(), rr); assert!((rr.confidence - 0.75).abs() < f64::EPSILON); assert_eq!(rr.resolved_by, ResolvedBy::ExactMatch); diff --git a/crates/codegraph-resolve/tests/frameworks.rs b/crates/codegraph-resolve/tests/frameworks.rs index f9636904..9e25c37c 100644 --- a/crates/codegraph-resolve/tests/frameworks.rs +++ b/crates/codegraph-resolve/tests/frameworks.rs @@ -159,6 +159,39 @@ fn react_detects_via_tsx_file() { assert!(react::ReactResolver.detect(&ctx)); } +/// React in an app's manifest rather than the root's: proshop keeps `react` +/// in `frontend/`, an ASP.NET solution its app in `src/Web/ClientApp/` +/// (upstream #2395). Neither app has a `.jsx` or `.tsx` file. +#[test] +fn react_detects_via_an_app_package_json() { + for app in ["frontend/", "apps/web/", "src/Web/ClientApp/"] { + let ctx = MockContext::default() + .with_file("package.json", r#"{"name":"shop","private":true}"#) + .with_file( + &format!("{app}package.json"), + r#"{"peerDependencies":{"react":"18"}}"#, + ) + .with_file(&format!("{app}src/index.js"), "export const x = 1;"); + assert!(react::ReactResolver.detect(&ctx), "{app}"); + } +} + +/// A dependency's own manifest inside `node_modules` is not the project's. +#[test] +fn react_does_not_detect_a_manifest_inside_node_modules() { + let ctx = MockContext::default() + .with_file( + "tools/node_modules/react-ui/package.json", + r#"{"dependencies":{"react":"18"}}"#, + ) + .with_file( + "tools/node_modules/react-ui/index.js", + "module.exports = 1;", + ) + .with_file("tools/src/index.js", "export const x = 1;"); + assert!(!react::ReactResolver.detect(&ctx)); +} + #[test] fn react_does_not_detect_plain_ts_project() { let ctx = MockContext::default() @@ -772,6 +805,23 @@ fn nestjs_detects_via_package_json() { assert!(nestjs::NestjsResolver.detect(&ctx)); } +/// `@nestjs/*` in a workspace app's manifest, with no conventionally named +/// controller or module file to fall back on. +#[test] +fn nestjs_detects_via_an_app_package_json() { + let ctx = MockContext::default() + .with_file( + "package.json", + r#"{"private":true,"workspaces":["apps/*"]}"#, + ) + .with_file( + "apps/api/package.json", + r#"{"dependencies":{"@nestjs/core":"10"}}"#, + ) + .with_file("apps/api/src/main.ts", "bootstrap();"); + assert!(nestjs::NestjsResolver.detect(&ctx)); +} + #[test] fn nestjs_does_not_detect_plain_project() { let ctx = MockContext::default() diff --git a/crates/codegraph-store/src/queries.rs b/crates/codegraph-store/src/queries.rs index cdfe40b5..f8f8486f 100644 --- a/crates/codegraph-store/src/queries.rs +++ b/crates/codegraph-store/src/queries.rs @@ -2940,6 +2940,7 @@ fn edge_kind_named(value: &str) -> Option { "instantiates" => EdgeKind::Instantiates, "overrides" => EdgeKind::Overrides, "decorates" => EdgeKind::Decorates, + "navigates" => EdgeKind::Navigates, _ => return None, }; Some(kind) @@ -3886,6 +3887,31 @@ mod tests { } } + /// Every edge kind, `navigates` included, reads back as the kind written: + /// the parser and `EdgeKind::ALL` stay in step. + #[test] + fn every_edge_kind_reads_back_as_written() { + let mut store = store("edge-kinds"); + let a = node("function:a", "alpha", "src/a.rs"); + let b = node("function:b", "beta", "src/b.rs"); + store.upsert_nodes(&[a, b]).unwrap(); + let written: Vec = EdgeKind::ALL + .into_iter() + .map(|kind| edge("function:a", "function:b", kind)) + .collect(); + store.insert_edges(&written).unwrap(); + let mut read: Vec<&'static str> = store + .all_edges() + .unwrap() + .into_iter() + .map(|edge| edge.kind.as_str()) + .collect(); + read.sort_unstable(); + let mut expected = EdgeKind::ALL.map(EdgeKind::as_str).to_vec(); + expected.sort_unstable(); + assert_eq!(read, expected); + } + #[test] fn node_lookups_by_kind_qualified_name_and_ids() { let mut store = store("node-lookups"); diff --git a/crates/codegraph-watch/src/control_inputs.rs b/crates/codegraph-watch/src/control_inputs.rs index 624e20cf..f063f6a4 100644 --- a/crates/codegraph-watch/src/control_inputs.rs +++ b/crates/codegraph-watch/src/control_inputs.rs @@ -16,8 +16,9 @@ //! names a control file, is a folder coming into a workspace glob's listing, //! or changes which files the kind's readers start from) and compares: a kind //! that moved re-resolves every file of its languages whole; a framework that -//! came or went re-extracts every file, since framework nodes are made at -//! extraction. The rows are written last; +//! came or went, or a manifest edit that moves which files a framework that +//! runs only in its own apps extracts, re-extracts every file, since +//! framework nodes are made at extraction. The rows are written last; //! `control_inputs_version` is withdrawn and restored with the other //! markers, and `frameworks_version` only by a sync that re-extracts for a //! framework change. @@ -39,7 +40,9 @@ pub const VERSION_KEY: &str = "control_inputs_version"; pub const VERSION: &str = "1"; /// Metadata key prefix of a kind's row. pub const ROW_KEY_PREFIX: &str = "control_inputs:"; -/// Metadata key of the detected frameworks, sorted, as JSON. +/// Metadata key of the detected frameworks, sorted, as JSON, then the digest +/// of which files the frameworks that run only in their own apps extract +/// ([`codegraph_resolve::ReferenceResolver::framework_signature`]). pub const FRAMEWORKS_KEY: &str = "frameworks_detected"; /// Metadata key present when no sync left a framework change unextracted. pub const FRAMEWORKS_VERSION_KEY: &str = "frameworks_version"; @@ -214,6 +217,15 @@ pub fn digests_of(kind: ControlKind, project_root: &Path, files: &[FileRecord]) let digest = file_digest(&project_root.join(&relative)); out.insert(relative, digest); } + // The manifests framework detection reads (upstream #2395): the + // root's, the ones one or two directories down from any indexed + // file, and the ones deeper above JavaScript-family code. + let paths: Vec = files.iter().map(|file| file.path.clone()).collect(); + for relative in codegraph_resolve::frameworks::package_deps::manifest_candidates(&paths) + { + let digest = file_digest(&project_root.join(&relative)); + out.insert(relative, digest); + } } ControlKind::Csharp => { let cs: Vec = files diff --git a/crates/codegraph-watch/src/migrate.rs b/crates/codegraph-watch/src/migrate.rs index 6dcec795..227b2e18 100644 --- a/crates/codegraph-watch/src/migrate.rs +++ b/crates/codegraph-watch/src/migrate.rs @@ -313,7 +313,7 @@ pub(crate) fn migrate_project( crate::control_inputs::write_index_rows( rebuild.store(), project_root, - &resolver.framework_names(), + &resolver.framework_signature(), )?; if resolver.has_framework_resolvers() { let relative_files = rebuild diff --git a/crates/codegraph-watch/src/sync.rs b/crates/codegraph-watch/src/sync.rs index a24d197d..7f6e9800 100644 --- a/crates/codegraph-watch/src/sync.rs +++ b/crates/codegraph-watch/src/sync.rs @@ -863,7 +863,7 @@ fn sync_paths_with_store( let context = resolver.store_context(store); resolver.initialize(&context); } - frameworks = resolver.framework_names(); + frameworks = resolver.framework_signature(); frameworks_changed = crate::control_inputs::frameworks_changed(store, &frameworks)?; } if frameworks_changed { diff --git a/docs/architecture.md b/docs/architecture.md index 6752c7c5..5756fe73 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -220,7 +220,15 @@ The registered framework candidates are NestJS, React/Next.js, Vue/Nuxt, Godot, and Tauri, in a deliberate order. Each activates only when its detection guard matches, and resolves only references written in its own languages (React and Vue their scripts', NestJS TypeScript and JavaScript, Tauri the JS-family -`invoke` sites and Rust; Godot stays universal). Godot also extracts project/scene/resource relationships; Tauri bridges +`invoke` sites and Rust; Godot stays universal). React and NestJS detect a +dependency in any `package.json` detection reads (upstream #2395): the root's +and up to 24 nested ones, first those one or two directories down on the path +to an indexed file, then those deeper above JavaScript-family code +(`src/Web/ClientApp/`), shallowest first, never one inside `node_modules`. A +framework may name the packages its apps declare (upstream #2129); when some +manifest declares one, its extractor runs only on files whose nearest +`package.json` naming any such framework names it, so a router reads its own +app in a monorepo. No registered framework names any yet. Godot also extracts project/scene/resource relationships; Tauri bridges literal JS-family `invoke` calls to uniquely registered Rust commands. Ambiguous or dynamic targets remain unresolved rather than falling through to a global same-name guess. @@ -594,19 +602,22 @@ that file's row rather than its source; the `class_heads_version` marker is withdrawn and restored with the others. Resolution also reads control files, which are no source files: the JS/TS path-alias configs along their `extends` chains, the workspace manifests and the folders a workspace glob lists, the `package.json` -and Deno manifests above each JavaScript-family file, the C# project files +and Deno manifests above each JavaScript-family file and the `package.json` +files framework detection reads, the C# project files above each C# file, and the `Cargo.toml` files above each Rust file; and the frameworks it detects. Each reader reports what it read or probed in the same traversal, and every index and sync keeps a `control_inputs:` row per kind (`js`, `csharp`, `rust`) holding each input's digest by path (the sha256 of its text, or of a listed folder's names; `null` for a missing one; one outside the project by the absolute path its reader used), and a `frameworks_detected` -row. A sync re-reads a kind's inputs (a CLI sync always; a watcher's when a +row (the detected frameworks, sorted, and, when a framework runs only in its +own apps, a digest of where it runs). A sync re-reads a kind's inputs (a CLI sync always; a watcher's when a path it was handed is a recorded input, a control file of the kind, or a folder coming into a workspace glob's listing, or when a file of the kind came, went or changed language), whole, with no size or time shortcut; a kind whose inputs moved re-resolves every file of its languages whole, and a framework that came -or went re-extracts every file, since framework nodes are made at extraction. +or went, or an app that moved onto or off a framework that runs only in its +apps, re-extracts every file, since framework nodes are made at extraction. The `control_inputs_version` marker is withdrawn and restored with the others, and `frameworks_version` only by a sync re-extracting for a framework change. The watcher hands a control file's event to the sync, ignored or not, and an From 9ef9f69079b8a9ff02570f008a52a84d0e376c45 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 23 Oct 2026 21:33:01 +0800 Subject: [PATCH 12/19] feat(resolve): framework edges name their resolver An edge a framework resolver made now names that resolver in metadata.framework (upstream #2159), as a synthesized edge names its pass in synthesizedBy: a Tauri invoke('greet') says `tauri`, a Godot autoload call `godot`. The orchestrator adds it to every result a framework's resolve returns, before the confidence race, so a lower confidence framework result that wins later carries it too. When a resolution reaches several targets, only the edge to the first carries it, as upstream's does; the others keep the metadata the resolver gave each. Goldens: godot's four autoload calls and routers' one Vue-resolved call gain the key; no edge, node or reference comes or goes. The same holds for this repository (2 edges) and typescript-large (590 edges, all `react`). --- crates/codegraph-cli/tests/probes/main.rs | 38 +++++++ crates/codegraph-resolve/src/resolver.rs | 122 ++++++++++++++++++++-- crates/codegraph-resolve/src/types.rs | 14 +++ docs/data-model.md | 6 ++ docs/equivalence.md | 8 ++ reference/golden/godot/colby.db | Bin 180224 -> 180224 bytes reference/golden/godot/edges.json | 4 + reference/golden/routers/colby.db | Bin 278528 -> 278528 bytes reference/golden/routers/edges.json | 1 + 9 files changed, 187 insertions(+), 6 deletions(-) diff --git a/crates/codegraph-cli/tests/probes/main.rs b/crates/codegraph-cli/tests/probes/main.rs index 4cf91506..b0ff7e50 100644 --- a/crates/codegraph-cli/tests/probes/main.rs +++ b/crates/codegraph-cli/tests/probes/main.rs @@ -18761,3 +18761,41 @@ mod pascal_includes { ); } } + +/// What framework resolvers record on the edges they make (W7). +mod frameworks { + use super::support::Project; + + /// Upstream #2159: a link a framework or bridge resolver made names that + /// resolver in `metadata.framework`, as a synthesized channel names + /// itself with `synthesizedBy`: a Tauri `invoke('greet')` names `tauri`. + #[test] + fn pr2159_a_framework_resolved_edge_names_its_resolver() { + let project = Project::new() + .file("src-tauri/tauri.conf.json", "{}\n") + .file( + "src/main.ts", + "import { invoke } from '@tauri-apps/api/core';\n\nexport function run(): Promise {\n return invoke('greet');\n}\n", + ) + .file( + "src-tauri/src/main.rs", + "#[tauri::command]\npub fn greet() -> String {\n String::new()\n}\n\nfn main() {\n greet();\n}\n", + ) + .index(); + let graph = project.graph(); + let bridged = graph.edges_matching("file:src/main.ts", "calls", "function greet"); + assert_eq!(bridged.len(), 1, "{}", graph.report()); + assert_eq!(bridged[0].resolved_by(), Some("framework")); + assert_eq!( + bridged[0] + .metadata + .get("framework") + .and_then(|v| v.as_str()), + Some("tauri") + ); + // A call no framework resolved names none. + let direct = graph.edges_matching("function main", "calls", "function greet"); + assert_eq!(direct.len(), 1, "{}", graph.report()); + assert_eq!(direct[0].metadata.get("framework"), None); + } +} diff --git a/crates/codegraph-resolve/src/resolver.rs b/crates/codegraph-resolve/src/resolver.rs index 7991d68b..0c8972ee 100644 --- a/crates/codegraph-resolve/src/resolver.rs +++ b/crates/codegraph-resolve/src/resolver.rs @@ -2439,8 +2439,12 @@ impl ReferenceResolver { .iter() .filter(|resolver| resolves_language(resolver.as_ref(), reference.language)) { + // The resolver's name rides on the edges it makes (upstream + // #2159). if let Some(result) = self.gate_framework_resolver_extension_language( - resolver.resolve(reference, context), + resolver + .resolve(reference, context) + .map(|result| result.named_by(resolver.name())), reference, context, ) { @@ -3795,11 +3799,12 @@ fn ref_view_to_unresolved(reference: &RefView) -> UnresolvedRef { } /// Build a resolved edge's `metadata` JSON: the target's own metadata from a -/// framework resolution (`href`, …), then `confidence`+`resolvedBy`, plus -/// `fnRef: true` for function-as-value edges (#756, index.ts:824-827) and -/// `subkind` for Godot refs carrying a `reference_subkind`. The base shape (no -/// `fnRef`, no `subkind`) stays byte-identical to the prior output, keeping -/// existing resolved-edge goldens stable. +/// framework resolution (`href`, the `framework` that made it, …), then +/// `confidence`+`resolvedBy`, plus `fnRef: true` for function-as-value edges +/// (#756, index.ts:824-827) and `subkind` for Godot refs carrying a +/// `reference_subkind`. The base shape (no `fnRef`, no `subkind`) stays +/// byte-identical to the prior output, keeping existing resolved-edge goldens +/// stable. fn build_edge_metadata( reference: &ResolvedRef, extra: &serde_json::Map, @@ -4680,6 +4685,111 @@ mod tests { assert!(serial > 0, "expected at least one resolved edge"); } + /// A bridge that resolves `bridge.call` to two targets, the way a router + /// resolves a conditional navigation. + struct Bridge; + impl FrameworkResolver for Bridge { + fn name(&self) -> &str { + "bridge" + } + fn detect(&self, _c: &dyn ResolutionContext) -> bool { + true + } + fn claims_reference(&self, name: &str) -> bool { + name == "bridge.call" + } + fn resolve(&self, r: &RefView, _c: &dyn ResolutionContext) -> Option { + (r.reference_name == "bridge.call").then(|| { + ResolvedRef::new( + r.clone(), + "function:native".to_string(), + 0.95, + ResolvedBy::Framework, + ) + .with_edge(crate::types::EdgeShape { + also_targets: vec![crate::types::AlsoTarget { + target_node_id: "function:fallback".to_string(), + metadata: serde_json::Map::new(), + }], + ..Default::default() + }) + }) + } + } + + /// Upstream #2159: the edge a framework resolver made to its target names + /// that resolver in `metadata.framework`, on the serial and the batched + /// path; an also-target's edge keeps its own metadata, and a resolution no + /// framework made names none. + #[test] + fn a_framework_resolved_edge_names_its_resolver() { + for batched in [false, true] { + let mut store = Store::open(&temp_db("framework-name")).expect("open"); + let caller = mk_node("function:run", NodeKind::Function, "run", "app.ts"); + let native = mk_node("function:native", NodeKind::Function, "native", "n.ts"); + let fallback = mk_node("function:fallback", NodeKind::Function, "fallback", "f.ts"); + let add = mk_node("function:add", NodeKind::Function, "add", "math.ts"); + for file in ["app.ts", "n.ts", "f.ts", "math.ts"] { + store.upsert_file(&file_rec(file)).expect("file"); + } + store + .upsert_nodes(&[caller.clone(), native, fallback, add]) + .expect("nodes"); + let reference = |name: &str, line: i64| UnresolvedRef { + id: None, + from_node_id: caller.id.clone(), + reference_name: name.to_string(), + reference_kind: EdgeKind::Calls, + line, + col: 0, + candidates: None, + file_path: "app.ts".to_string(), + language: Language::TypeScript, + is_function_ref: false, + reference_subkind: None, + }; + store + .insert_unresolved_refs(&[reference("bridge.call", 1), reference("add", 2)]) + .expect("refs"); + let mut resolver = ReferenceResolver::new("/root"); + resolver.framework_resolver_extensions = vec![Box::new(Bridge)]; + if batched { + resolver + .resolve_and_persist_batched(&mut store, 10) + .expect("batched"); + } else { + resolver.resolve_and_persist(&mut store).expect("serial"); + } + let mut named: Vec = store + .edges_by_source_kind(&caller.id, Some(EdgeKind::Calls)) + .expect("edges") + .into_iter() + .map(|edge| { + let metadata = edge.metadata.expect("metadata"); + format!( + "{} {} {}", + edge.target, + metadata["resolvedBy"].as_str().unwrap_or("-"), + metadata + .get("framework") + .and_then(serde_json::Value::as_str) + .unwrap_or("-"), + ) + }) + .collect(); + named.sort(); + assert_eq!( + named, + [ + "function:add exact-match -", + "function:fallback framework -", + "function:native framework bridge", + ], + "batched: {batched}" + ); + } + } + fn run_persist(batched: bool) -> usize { let slug = if batched { "batched" } else { "serial" }; let mut store = Store::open(&temp_db(slug)).expect("open"); diff --git a/crates/codegraph-resolve/src/types.rs b/crates/codegraph-resolve/src/types.rs index 90c04f92..c9d7b454 100644 --- a/crates/codegraph-resolve/src/types.rs +++ b/crates/codegraph-resolve/src/types.rs @@ -247,6 +247,20 @@ impl ResolvedRef { self.edge = Some(Box::new(edge)); self } + + /// This resolution as the framework resolver `framework` made it: the + /// edge to its target names that resolver in `metadata.framework` + /// (upstream #2159), so a bridge or router hop says how it got into the + /// graph, as a synthesized edge's `synthesizedBy` does. The edges to + /// [`EdgeShape::also_targets`] keep their own metadata, as upstream's do. + #[must_use] + pub fn named_by(mut self, framework: &str) -> Self { + self.edge + .get_or_insert_with(Box::default) + .metadata + .insert("framework".to_string(), serde_json::json!(framework)); + self + } } /// The edges a framework resolution draws when they are not one plain edge diff --git a/docs/data-model.md b/docs/data-model.md index 5ac8ff9c..908d3b72 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -109,6 +109,12 @@ that made it) and, when the wiring happens elsewhere, `registeredAt` (`path:line` of the registration). Consumers such as the type hierarchy and the named-symbol flow recognize synthesized edges by that provenance value. +An edge a framework resolver made (`resolvedBy: "framework"`) names that +resolver in `metadata.framework` (`react`, `vue`, `nestjs`, `godot`, `tauri`), +as a synthesized edge names its pass. When one reference reaches several +targets, only the edge to the first carries it; the others carry the metadata +the resolver gave each. + A value reference is a `references` edge the extractor emits from a symbol to a constant or variable of the same file that the symbol reads (upstream #895, #897), so `impact` on a shared value reaches its readers. It carries `metadata` diff --git a/docs/equivalence.md b/docs/equivalence.md index a1d2249b..d84aceb1 100644 --- a/docs/equivalence.md +++ b/docs/equivalence.md @@ -240,6 +240,14 @@ that regenerated it. Older regenerations are described in each corpus section. | `feat(resolve): Dart getter reads, type positions and initializer calls` | mobile | `edges.json`, `colby.db` | a `const` constructor call calls the class it names (W4-DART-03; KEEP-RUST: this grammar writes the class in the call's `type`, where the call's name was never read): the Flutter `main`'s `runApp(const HelloApp())` instantiates `HelloApp` at the `const`, where it referenced `HelloApp` as a type at the name; nodes, refs and files are unchanged, and the dart corpus, whose extension type has no getter read, initializer or body type, is identical | `scripts/regen-goldens.sh --write mobile` | | `feat(resolve): type the later links of Dart call chains` | dart, mobile | `nodes.json`, `colby.db` | a Dart function's or method's return type is the last name of the type its signature writes (W4-DART-03b; KEEP-RUST: this grammar holds it in the signature's `return_type` field, where the extractor never looked, so every Dart return type was empty): the dart corpus's extension type getters `MetersT::km` and `Widget::half` return `double`, mobile's `greeting` returns `String` and `HelloApp::build` returns `Widget`; edges, refs and files are unchanged, as neither corpus writes a later link of a call chain | `scripts/regen-goldens.sh --write dart mobile` | +#### v0.56.0 regeneration log + +The golden changes of the v0.56.0 follow-up program, in commit order. + +| Commit | Corpus | Artifacts | Intent | Command | +| ---------------------------------------------------- | -------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| `feat(resolve): framework edges name their resolver` | godot, routers | `edges.json`, `colby.db` | an edge a framework resolver made names it in `metadata.framework` (upstream #2159): godot's four autoload calls name `godot`, and routers' one Vue-resolved call (the Next.js `Dashboard`'s `useRouter()`, read as a Nuxt auto-import of its own) names `vue`; no edge, node or reference comes or goes | `scripts/regen-goldens.sh --write godot routers` | + The canonicalizer strips inherently unstable timestamp columns (`nodes.updated_at`, `files.modified_at`, `files.indexed_at`), parses JSON text columns before re-serializing them with deterministic key order, asserts all diff --git a/reference/golden/godot/colby.db b/reference/golden/godot/colby.db index 13db26e7eb4ce672918b16b249c2e83e7489ee46..3325dff1d5d9ba6c1ec770441d7183ba4e84774f 100644 GIT binary patch delta 1011 zcmZo@;BIK(-mpiXCF9BNZIch^>wy>&n?LFA;sCMsP2OP93S#WtY-uUO2oZ|3Dgg^k zzG&49k&CciCnCefz`u?^kzassHD44TC+`kkb)IS5pSbxiWN@zhGPl41-;hkJaWNb8YHU zk-4kuQjocl^;yW=-ug@=Zd1dy2F9cM)*!>#xR_ZPfi?h{tOzCxg2{|vGI27qazfN@ z+rZd!O4%6fv_nz~ESiF>Y56%h`Q@o8@j01!*~QVZV7dL%*HnM6Gdt;|zRlTs}#OiV3}EiDZ#EX>VQlFf`P&67+G%*@P^lM)jXEt1SqEDa5k zO-u|d3=EQ!jEsy^%uOtk&C?7`)1Y=(Rx?R+0Rw4|q6~{AKkIadUM4YiL$KtY>DARt zDxj3IXZqr5CU6qlGyQ5clRQ{#`;TfSMsU*FGyQK3RB*aNEt51vi+wGVf)+@#oCu3C zKWk2DVtj6XN@-4NaeP^7QE_H|9@H8YVHSI!&5C_Yl5m?>)-uT`fi%kqvgq@(7GxB} zXQU>kplhA}yp~Bm0i;fvpGB9SH9a4zGFd(rLw?r$q^xAD(sH~Y2NsuSrj?+|LqdMf z^t3vd_d4OMHFYqLo`$nt!DV^tVLA-zndBe=%nAj4>y12%us-^BqES~huuMJtH0bhD+U3?oD+(y9b3 zH2I=cGej=JdYy<&6a)V{{zQHOzSVqDe4M;Hc-48P@vw7OaJ}S;+Wb(!f{U|}hnJHf zK3+U|vOrYsP-%dfyo1P zb0K6WYs5IhG;W?6qprvm$Z^P;aUszCizgqe%|Yhc)TJVGSJ$N=b0zDukh#6}nMmBG zhHVXuNA;~ihO==ovobO+1Tk3=Ocn%_8Np=YWM<`rsNJ@KvFDVs5!h*4r4(2+1zFSb zb8_;_Q&Zw|GV`*FqhrBx%crleVv-dw(KCQ>ljK;<8wFXX^Y=1|dKg-nnOhi{n;9FL zq@`G}iZF{k&}PLxCP}ExOQx@^Ws*?>X_gUW(dTC^$S8=~B~b;4O|>R=u{ z4QIWA%ktL4bQsh#$$K6K-I?pik?<4kFTNeSdrw{ z;EhpyP@ct+-z2wbk@9Ag+@ILh!ohJJ=882T7`8G0DH87i_dxY@Yx!bn?ZSGr_fznZGY%B8&iII429uPoltGx%7lYVA8I04| zQJLQ2BMv7Tqp`;-V1m-~mA*@>(yzNVppTsBxbCX~{p3Wwu6;ytp=sIbC3FI%6rPxG z0>9XA^t{%$+tk2u%O7iChF-htLT^wyr=S2OvkJme*UsCXcJeQfyEQOQUSEPPVs3(A za;FN`Xg1{F*9O^9(yDH<%)a_5R_o#23i7O)x=% zI*_q@&qcNxeS?1*;5}-!t%Ge!k2b-(lcIz8GJIc|tNxeub`*?llW#5@eRSBlv}zIvhfV)%4etK`F-U><(c zo3j7%U3kg5<=)P9hu|as!<{3UBS?4qQ}m7~#(U36kX#k%uP;b$i6|%~=3>W*0})M~ zT~HU)ST31L7 yquJ$nj6MZH^4tcn{@CwrpEZR4Zl4wNk}ZWkw0?3^q0g(Ie665Uy|G<)qsYIbM`30F delta 2316 zcmeH|&us}3CdM0Ap@1UM8&akII8>^g;7uD;r)m7# zYrO0JkP0GF%18_dBFLew5Nb))sy?)p0(UMPgNm9Xq)4<c3EKpYMBb zzVr6^dDk>UrWsoQQ9`d>{c0cHkj9#DGy3iCQhM6^s^`uT*IF}i5T~+>OpfBsY}wz* zikuO~Te%S||H0$s+$*e5#1C>sr%dKoobp-TVDcdTu;9p!;(=nBWkr0gs0Y}cu-+BK z(c%ku{iz4PSvujY{IZmF%8~MtC8z918O7Jj3j&j{;Ojn)L;4;pSKeS{B3`LDPKns3 zpLS+VJ>!&r>UpOuRCTBPtGeKn*)#7s<-;=-r_9tc!A^T3tP7y~6s{UWB)SARY`zr2 zL&izxfr4QVkv^XI(T>sBFUdXvzYZmr0+|Mw~a38}ShI<+AVYr)Nh+&YS z;5FJePX#*RDL+FW!)}INhCD+LLpQ@+3^|6Vg}u#R9I`OrA;;U3nEHO*b#}wrcZDo3 zf{0Cx^+oV8JkSivgCj^0xb)0YF8QuDlg=g!+ERA@{RPF|ADJBYp{R96CVCDGP`Z)> zqD2z9-HJ(MD+l^$Y$r!=k%-TadTH!v9`sXsJ5OCCQp>mN);jdHAGPZ4cIa0>vb&au z5I_;Hb#HVpQU#8f=RuE~HY$mQ&unV+ya<1bxgHaD3da05 zP#As!9)N`FLf2y)ihkbMOu?U^$3M+?C6nP(!d7_ynG{Y@RQbwLs*5 zxKt#y1L^X9hc3C@ Date: Fri, 23 Oct 2026 23:20:26 +0800 Subject: [PATCH 13/19] fix(watch): any file that comes or goes re-reads the JS detection inputs Framework detection reads the manifests one or two directories down from every indexed file, whatever its language (upstream #2395), and the JS control-input row digests every such candidate. The watcher re-read that row only when a JS-family file came or went, so a file of another language arriving in a new directory left the row behind a fresh index: 39 cases of the sync matrix (CFML, C, C++, C#, Dart, Go, Java, Kotlin, Liquid, Lua, Objective-C, PHP, Python, Rust and Solidity edits) failed under the watch driver. The graph itself converged, since a sync with changes re-detects the frameworks anyway. The control-input check now counts any file that comes or goes as touching the JS kind. typescript_react_dependency_beside_an_added_script covers it directly: React declared in frontend/package.json is detected once frontend/build.py is indexed, and the sync records that manifest as a fresh index does. --- .../tests/sync_convergence/cases.rs | 25 +++++++++++++++++++ .../tests/sync_convergence/main.rs | 1 + crates/codegraph-watch/src/control_inputs.rs | 19 ++++++++------ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/crates/codegraph-cli/tests/sync_convergence/cases.rs b/crates/codegraph-cli/tests/sync_convergence/cases.rs index 3c3df96e..deaa7418 100644 --- a/crates/codegraph-cli/tests/sync_convergence/cases.rs +++ b/crates/codegraph-cli/tests/sync_convergence/cases.rs @@ -10191,6 +10191,31 @@ pub fn typescript_react_dependency_appears_above_no_script() -> Case { .edge_after("function page", "calls", "function src/hooks.ts::useCart") } +/// Upstream #2395: a file of any language arriving one directory down brings +/// the manifest beside it into detection: React declared in +/// `frontend/package.json` is detected once `frontend/build.py` is indexed, +/// and the sync records that manifest among the JS inputs, as a fresh index +/// does. +pub fn typescript_react_dependency_beside_an_added_script() -> Case { + Case::new(Change::ControlFile, &[Language::TypeScript]) + .file("package.json", "{\"name\":\"web\",\"private\":true}\n") + .file( + "frontend/package.json", + "{\"name\":\"assets\",\"devDependencies\":{\"react\":\"18.2.0\"}}\n", + ) + .file( + "src/hooks.ts", + "export function useCart(): number {\n return 1;\n}\n", + ) + .file( + "src/page.ts", + "export function page(): number {\n return useCart();\n}\n", + ) + .write("frontend/build.py", "def build():\n return 1\n") + .edge_before("function page", "calls", "function useCart") + .edge_after("function page", "calls", "function src/hooks.ts::useCart") +} + /// W5-06: a package the manifest comes to declare is outside the project, /// so the import of its binding no longer reaches a project class by name. pub fn typescript_declared_dependency_makes_a_bare_import_external() -> Case { diff --git a/crates/codegraph-cli/tests/sync_convergence/main.rs b/crates/codegraph-cli/tests/sync_convergence/main.rs index 6efa6133..47abbc6b 100644 --- a/crates/codegraph-cli/tests/sync_convergence/main.rs +++ b/crates/codegraph-cli/tests/sync_convergence/main.rs @@ -1346,6 +1346,7 @@ matrix! { typescript_react_dependency_leaves_package_json, typescript_react_dependency_appears_in_an_app_package_json, typescript_react_dependency_appears_above_no_script, + typescript_react_dependency_beside_an_added_script, typescript_declared_dependency_makes_a_bare_import_external, typescript_workspace_member_manifest_renames_its_package, typescript_workspace_member_manifest_is_deleted, diff --git a/crates/codegraph-watch/src/control_inputs.rs b/crates/codegraph-watch/src/control_inputs.rs index f063f6a4..da255f18 100644 --- a/crates/codegraph-watch/src/control_inputs.rs +++ b/crates/codegraph-watch/src/control_inputs.rs @@ -391,9 +391,9 @@ impl ControlCheck { /// kind; otherwise a kind is re-read when a `requested` path is one of its /// recorded inputs, is a listed directory or one directly in a workspace /// glob's listing, or names one of its control files, or a `membership` -/// change touches a file its readers start from. A `bootstrap` (the marker -/// was missing when the sync began) re-reads every kind and counts each as -/// moved. +/// change touches a file its readers start from (for the JS kind, any file +/// that comes or goes). A `bootstrap` (the marker was missing when the sync +/// began) re-reads every kind and counts each as moved. pub(crate) fn check( store: &Store, project_root: &Path, @@ -424,10 +424,15 @@ pub(crate) fn check( || (kind == ControlKind::Js && row.contains_key(&format!("dir:{}", parent_of(path)))) }) || membership.iter().any(|(path, before, after)| { - before - .iter() - .chain(after) - .any(|language| kind.reads_from(path, *language)) + // Framework detection reads the manifests one or two directories + // down from every indexed file, whatever its language (upstream + // #2395), so a file of any language that comes or goes may move + // the JS kind's inputs. + (kind == ControlKind::Js && (before.is_none() || after.is_none())) + || before + .iter() + .chain(after) + .any(|language| kind.reads_from(path, *language)) }) }; let mut out = ControlCheck::default(); From 80a5384304d2ef65a516c034dfcd45b35ce816ff Mon Sep 17 00:00:00 2001 From: sunerpy Date: Sat, 24 Oct 2026 01:31:32 +0800 Subject: [PATCH 14/19] fix(react): hooks and contexts prefer own-file declarations and imports; typed styled components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React resolution now follows upstream b635dd46 (#2120's React half, #2240, #2429, #841, #2475's static half and #2420's wrapper half): - A component, hook or context the file imports is the import's: a UI kit's `