From ee48b7e0a1c05a828ad8b8f020f43c75baaefab1 Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:29:30 +0900 Subject: [PATCH] fix(engine): count fnmatch label-pattern wildcard groups per raw star, not by path-glob rules labelPatternToRegExp reused change-guardrail's path-glob wildcard-group counter to guard against catastrophic backtracking, but that counter treats a ** pair as ONE group (the path compiler collapses ** into a single .*). The fnmatch compiler here has no ** concept and emits one .* per *, so the count and the compiled regex disagreed for any ** pattern: *a**b counted 2 but compiled 3 .* groups and was wrongly accepted, admitting a pattern this compiler builds into a catastrophic- backtracking RegExp on an adversarial near-miss label. Count one group per raw * (no ** pairing; ? and [..] are not counted) and compare against the shared MAX_GLOB_WILDCARD_GROUPS, now exported from change-guardrail rather than redeclared. An over-complex registry key degrades to the existing LABEL_PATTERN_NEVER_MATCHES and is still cached. The path-glob counter and every path consumer keep their **-is-one-group semantics unchanged. Closes #9994 --- .../public/downloads/loopover-extension.zip | Bin 0 -> 25897 bytes .../src/scoring/label-match.ts | 31 +++++++++++----- .../src/signals/change-guardrail.ts | 5 ++- .../loopover-engine/test/label-match.test.ts | 34 ++++++++++++++++++ test/unit/scoring.test.ts | 33 +++++++++++++++-- 5 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 apps/loopover-ui/public/downloads/loopover-extension.zip create mode 100644 packages/loopover-engine/test/label-match.test.ts diff --git a/apps/loopover-ui/public/downloads/loopover-extension.zip b/apps/loopover-ui/public/downloads/loopover-extension.zip new file mode 100644 index 0000000000000000000000000000000000000000..1c64990b9737d6bfe1864123e177f0dc2aacbc73 GIT binary patch literal 25897 zcmb_lOLH7Ya>lM78$sLQi^JgvZ`h?4Ee*7o9)JX25FSBMkVFe4VGiV22bMc*%=8Re z=;@yA?g5B}5We~1@L#YGzWC(u#r_LE?8yiJ2mc0N>@PE`s;eI}09Q*1BD(99mG8>T zY8`(4Tfcp0MgDxSvi7Uw{NMg`WrcozgqOj*IQ75H!*{bZE7UMe@5mtn$FVmF!O`R`}9s_@cvJa_Fwj&zj`_7@Avud z*ZY6<&GXm$pATN(#nUhMX|jG;s31|{yCO{TC{0uz=KOb&o`s2u5_NzjUSSERPdY2m zHTQ2`a{s4?^%eIUO9AXC3r8x=R2H6N-I1@JWnoZ+Dp2Dr%uk)Q8*mX6)t&c`UcJWl z24C<0O&@!_x}y|u^(u=dQL?2x`@6S>7QNpi+AbR1N4I^SVa%TvWgq*>?RSvMB6=Qv zbldyGbQb32B=XEX;vg@cf(yc=h=$nwh!<;li{97Rv+&5lt7lISEQ$4&kFhZq3%3;1hy-dO0re17!RH=hlTUVXj)a&Y*1|BL589UMMAI@*6t ztls;1XL36GZgQI9XO5p7KiPMas5qUU3}!*GI_O28T131$eD&A+uPrXzc_u|ZI1akw z_3o25R}VL@@6tHKHRE|QB!(`ztY4&AFbO-u(=45a2hmvwXeM!b62wQRQSQt8Rc5Vd ztU4c*9ryfP2Jb;Qhe52q`%bkt=a*2TCR{vM8B&>Z6b3t&itvXI1T0AGcZ*9&N73 zE*aBGtHpU1V;Mr$H?I#mSd>wq6j1_g{wzz2beP8K16o|vB`;2mr)gd!!8DXcMisqU z(7ti+k-v_AHatTqi|3|tCRl|OwiB(Gvf^PXDaiHkRBuMpt9_p9HX1Xg{8( z*_XS%VLlRQX7g+k9)y!%c-beog~vP29`v+4Mp#|r{D@sAs|Z=g@LnrsxT+fW9F$bM z!B_A|LSzZ?mEebzinckwwd6OiQFg7aqqibf9cw+!vf$E>a{e1)2Fq62l5|{Ue7EgI z+Wm197h%@v9+$DQJ6)0iD7Ih_a@hj3r11>*gtmCSa@ zRdro4RCS24cnPwWV;0elrpIkyuG+HYCQwhi=Zg|Ag*O34fEQun>SpyAh70@Uk-{OZ z{e*B(1JqaiExW2EW}QQH58rk$eptyHt&1_&w=BfcvS<-*yyXI-9I!%F%h(;Ua>zv? zC`g>yh`fJ)rJ@w6l{WOYNDjavSMzh&tqvTc^m+f4c4dsP;CRUf%y2X-1Ss*MLj-qh zis`^CpM_JL+&e9%$kBH+!4i8sjKd(?w_;{&)O$*xz`Got>JD$*KndXIOx2ULnel&hd~xhbCyCc zy-30gV`Sq#Kk~Be-Lr6(mc8j|NoNA<<$QX=3uwil*D0G;c>?WZk3*aR_YE5V5=S^X zwU#^5J7ZI@hT}YZTTO@3P%X81P`&BAuq*VnTKJ8Vn>X@&I-}FL;gs?nc!eJpv&XHZ z*3j9-3I!5AFby@~TgXWiVC@NN7P61g9lovzLG4GJ!j7q3+^MUTHgU~ry=^$}}IudRLX+jGVD zu~KYyE$r!8)7on1RRiaT6#cBbl+{Ma$K5MCr{LonvQm3>6uv92-@1BNPs6s-Ml@Q^ zKx;TuQXG>!J|Dk!G|G50)^}bt@z!w==V3|WB0_9Y51s{C9y*6B>ZOHDOa>0rUqnd+ z&yt3%l3wHS+X!!_7fnc8=qv~*$;+yTK-HW=G0@HR`%Q2)AjjT`gqjCh$qcmB9zjcb zK%!Xt)>cHe=HqcRjF7Szurp3$5S`ZgEF5`n{3sd5^HG?aIHM-Ro~E?~96=|zj5S4b z`w)!Xbkm#mb42hvowM+gwWAFV9OKO!4g!}7bG7Ek-@?s3tR(fm{O6CmpZsuTg?|13 zNxhR`cs9vWtN;m*rkvWN_n<9E_o};)76v)xxZc7L8)5UsK#?Oa4N#6f&0#Dfz)!9PEPIeS_XD z69m^&YXz^7MHUxgfIU>1>d~M7d-ShAURj|Z${({2oD^UN@pZ)8B)n2`L8k`54oB?f zbF7Y>4j=ilQ_6f`fjdNX8Mo)3MF}htrAmn2EdDq!=!C}<%v(_UHx4fK2$5B*LEBfg zi?L`5pxhI1YH!MPF*yfr?(}~CvtH-;=e;*SS#^IQ(7ucE9A_DN|Fh9gRy%uJKkI#W zceTe<7d|EoSnf+Orc}(wIPlOpfaGkJLK^1TI2a-pov`;#acG8sMzH>r_7XF|h^Ew) zBAmget01g4KedUL-%|2-M^-|-$}t3XF5VpUX9Y-pxp)@n%qkrYvFK}2BbxjM%kBa1 z29B{q3@R#|!b!Et#w}h9!Y!J3FYqOq6Xo0`=(q2L7A~%I=kk^ILd%V|u%tBrqjWf* z!W{WfrP*aajKd+$sU7ba8LOac^h|e_nWN+z&#J4ES4hMH_|R}gl$~+NB?$iG4vJs#5kC2qD7&*+I$ypizc%*t@S=de0{i6ii|LW+) z0Re~>rM5@Wxnj6?8Ve5w;zWD9+!dYk8Oll)_MQgWnYa7pc8|JM#_S&GdXtv6#q-Uw z=p30EZ}*UXDZ<*|lX+1fzfZ<#$CC$d@nl)ZMJFDzeGzyjI0@q&@3p)v6Kq6FxJTZu zZrSe1#Il?19CdFM4tu{q00dPf|?88Ya|4za!rr&S2L% zmb;kOu2l%flm$bTOiarmW zOZLwpG}bxshBykJLD|WXmCi&Z^d?RtrysWF3p!LCd|QDV<;Fcs2@Ya+%(od2rdz_F+*-KpbbKR2fR1^ z=mpqgynwirI0jd|C#9Khnapf?>CxB$QY6f0Bp=U6OcGLz-eM*6Dc3RO z9h!v~LP+gCHiSVl)W%9V}S?QO4 z93~UgBJ8SlLkBIUW|%(>X4o%ntuKRC{cqDK>7Yi%U`!8@UXrjs3=-%*>-bmIL}fap z02x5nJEG%oW0$($o=>f`L;ZrEIh{oCh&16mKV32dH@j0ve}f^JLsM!o9EOw*C}vD` zCKx_)GLF4Tn(~?cBFGSYR9+I!i!6xgS8aCO)4JU-jd!8t`5<#G=L=wbs4 zn;*tF2Fo$Cv>yDv+r4{5Yh0JU#8Lw)gUJ1`1IYa-vNV}g1=MbdO*$QRJ?0daNId|X zkw5IfEEs6nrn3Tm=F1c% zh{ID9Th}~IDl4@`rD}{IY=J^oMW-;Vz;P+< zh&0)>qWXy(F z3`dUAN0i^{*YXZV8|Px>edKLfSoH$lN5ORVDf^}bu=Lw*>lYXGZo6&WCiQL~dmp!~ z`ipt$bVN2Un8!s+zYp&}G0PI(bxyeE);$#mOuK|yIlAr+>G6h}Vf*bNfhm5UtB7b! zs~ReuAh5s-_%a%lUo)J+i&RyI@hLP$!O-GkXtoiD z!6r;xmq1J~_gk*81lL^&B;=m9-5AJ9x8fPTOh@F_Sv23`a9*3eJiKWV)0D=Z5cbFl z(6Jo+Aw3i#+eCWft&>fXmOURaca9;W)bVmKo1fr-Kgfe|h-(_dvry{mb+XS4p&-x* zAwn4lY*O6H%x#=$9lBW@htuI4-VKSVJaZxf_%YEQ+K<~>J()fO(6Ds`beDj}`GtY5 zjanSXkj#}tGp_Vf?!^UJT+-3Fyb(2ynRK_jo%)W+uex&z#GoKy_ z6_}0TkaIeXc9^mfuoC*(rAYvygfb^INTzAy%kwC_7--!vI6=wV845M{)<@OQ8s;=z zY`)wu1+EK1?6OYHi3eXt5h##D#jW?#JdA49L-&MbT9p z8i+BSYh;6gy#!KL{vGZ2j|Kx#gO$GKy!=ceYR?f@%W(ULIPr>^zcmvlHA39vWF?NI z1dxZx0mg|i$&gB%#C_$)Slstd#Ziz~rRPS^Tv1-lYX0h9{`XJ*9%*v=p;Y-aNTM-< zr(6m{`|-@10g@tgCDq$f_h~YZl9+fvJW=0FeSut!b~bdXl8dW6R2>Ynh%c*BebTE~MwzTEBEU*QXG#@)xB>bZ@?=Q|!5E-D;B!b+&N<6T6f z;&f*e5({+sVGYFx5vMK?kB-BgjdCj9na!%HeN)l1-IEq3JbYVV-JC&5jagnmts|5L z-JD+kl=~93N^aXyAFhu#HZ~rWjktQ5W_Ul|96uUADBq6({}vpF*}L9`|3G!AA~Nje zm#A2ouBp%BC^>r(4Ey~21^PlZ`r#xE)i=)}CwY+MUGRN0HZvMUK85&Kz_k@1AH+!ekkqdxJsrISX-KjL~qU02q#?=ENHvJLWHC|iHJpsHMEr=6u&Do$ZlSatn3Z#1L zE}PwQV;?>W!$&8>5?sTAV9fg!)|}(YEYLec5&`D396vvFF&){pn%nLfRlH5w z&Zu2z5~BpSPd7@f3CBR{ATe&k_L%J+;`$!GQsI0$z+6m_D_l~(hdQ*fZU9=(SAAqK z;X9byx!A`geQQIPVj{RfUk=bd#hTnY4&jLK&HZqMHz?T>FJ;+HKl zk}qJ#((%|DCDJI!1yE&0xma^?YpbeAZHL6IkvG6p-;r(J@n$Hzf!j1fdB^vMk3L+2 zzu*6_K0ld8Nc!|~3A9@7LIf>)6JwZ4PMxx`+R>|4;XuSD3IU3nrsLaG z2P77@aA)C^&*)a1$WU`yCl*IMNYY?fa9bmcOYA+BrEsL+*i*s1gSPD+`5Q83TO4SW zXG1W!{wfZ?KzH%m4f6Z~yc0@2#xR57l~>bDHX5Ows&e z1UdDs_syt*%0H|mX~iS&+bV3SrTvmGx8Ru5xV9%ZmF~3HDs%Xdb&eDFsMpt*Y<--8}AT>Tk5>D#(=@1T6)!i5law0#x*6f z+5AN1YxMSW$#Y1dWckS#iJ7)@WXF=z=PZ!9ZjAJIQ$_$K=_nyOU(><9U7}Xzkj@mZ z;jU#=TEnwY4yY)JzW;#QaHICO%rchpm8EdB(ccF+F7I1#CDQ=Fa*g2|PVNx+;`#^> zEp(tR`DuF5XWt3y)Q8v}4&ulY4MOS0l&;pB8ZV$dTd2{YeeCw;H{ySI}nB zujNZn_0r@UI$hyt#5>lt(zK2RTJ~r!^)FvR6QsEcYH0LCIP?*X%oh#C0wKf7$k1wp zydHegjc`;c-xhAwSZ^l;s>%@Clk*5&9M-7UW1be*zEphC+?F5P@%47UuFd!;3Wi&B zMDLa89t) z#Hh+JsSc3d=K$&E_ll4r+0@vPy*t8NviUtYJh{r*oe|xodDn;$a6cpI<=hQMgua2~2}`Hts{cIq>WBKCSdFH)Z0$C+WLx zei|TYjsO@#sWw493BDqsss9x^x8c@YfZ_c&Vq^GX-X!T#782pvA(XO&9^IIjW*v)| zo8rIsnBi=2l47rFLq{ho!OVvb5X@``nbYAzvH!Q_^!u%x{^SV)z{Ey|smp*Y1C!&! zk;H4#sREx4wj%l;S`u#1RCaHW2NP5$#opzhC>=Pq)EqZiGAhhkv_-J>S(PSH!b_R3 zrDm~v8g({!sGk>5?pu6rFB)IUB{IrtltiuR03GwM=ztPA23=*Gx`La>6o_u8opx(@ zZ(4Taa12OFmcC^sLE@@6gI$Y7YtWr2w23b}!F8ATNNF_4%BhO>4ca7>Hu2j)a=y&rwZ2y+b z#r1VO>b4rQA$LfQAP0I978fBIC#77STC-`l1BWX49c)2sRaJ3=q?>J~|NRtV$!_06 z)k(=>m-%NAi3H=dEkcWNLa9s-Hdw4w?Bnst=;4#4EYijGTE2z3kW+33qrsQj!!io< z$;QcpF*3nz+@(e?D{|g?Q8&6-LR{P6zgsfeVwI#ulGd%@wvp2k9mHC4989Aa`L}u0 zou*03jJ>A%U%bFW_jNd##{urHzX+2Um)C8NMHmo`Bm$r__e&vp!c6?V073MHtTS6o ze58RKJ`zadZ0W3EkX&9Mx4h6`Qw7#q({~*Vbb7*3GFysVv~{UV&?3|oBe=0d)@cQl zi7j0uj(DA=D0?J^(^YsVZD=P*3I1(nCStU4teM3W2v=Fxlp>TG@K$q3hGz*j>}tB^ zjaOY@WgB99ztzgNI7MPE>(*#paBQT$epIr~$9wEl2-~a!usRqnfYRQq%h1GFw; zsOGe{f;HF0qNJP_`BkP|VTp$?LSre2`&=}kQ41i$`-@L)%G>bsV_||@N{WOudE=&# zXne96JYIzSkghffRb4w)*R_ub2#N>%;D>+2XMY_3SDODH4tiVufiq2C-Kh_%f9Izh z@Zb2~d6d3>%h3AQT*{%J{F(c0m->?RddYI!%BQvJ$@RgP@prygc0$DQ}xpv&Rczw^7)_9OiB4?kF0`MV)K{XcmLnL_{o literal 0 HcmV?d00001 diff --git a/packages/loopover-engine/src/scoring/label-match.ts b/packages/loopover-engine/src/scoring/label-match.ts index 1237563cbb..fe18c2350a 100644 --- a/packages/loopover-engine/src/scoring/label-match.ts +++ b/packages/loopover-engine/src/scoring/label-match.ts @@ -1,4 +1,4 @@ -import { hasUnsafeWildcardCount } from "../signals/change-guardrail.js"; +import { MAX_GLOB_WILDCARD_GROUPS } from "../signals/change-guardrail.js"; export function labelMatchesPattern(label: string, pattern: string): boolean { return labelPatternToRegExp(pattern.toLowerCase()).test(label.toLowerCase()); @@ -42,6 +42,19 @@ const LABEL_PATTERN_NEVER_MATCHES = /^(?!)$/; // change-guardrail.ts (there `*` stops at `/` and `?` is literal): labels are flat strings, so `*` matches any // run, `?` any single character, and `[seq]`/`[!seq]` a character class. Literal keys are unaffected — for a // pattern with no glob metacharacter the RegExp is an exact match, so existing configs score identically. + +/** Count the backtracking-capable wildcard GROUPS this fnmatch compiler will emit: one per raw `*` (each + * compiles to a `.*` below), with NO `**`-is-one-group rule — unlike change-guardrail's path-glob counter, + * this compiler has no globstar concept, so `**` is two `.*` groups, not one (#9994). `?` is not counted (it + * compiles to a single `.`, which cannot backtrack ambiguously), and neither are `[…]` classes. */ +function fnmatchWildcardGroups(pattern: string): number { + let count = 0; + for (let i = 0; i < pattern.length; i += 1) { + if (pattern.charAt(i) === "*") count += 1; + } + return count; +} + function labelPatternToRegExp(pattern: string): RegExp { const cached = labelPatternRegExpCache.get(pattern); if (cached !== undefined) { @@ -51,13 +64,15 @@ function labelPatternToRegExp(pattern: string): RegExp { labelPatternRegExpCache.set(pattern, cached); return cached; } - // Reuses change-guardrail.ts's wildcard-GROUP counting (a `*` here matches the same "any run of chars" - // semantics as that glob compiler's `*`, so the same catastrophic-backtracking risk and the same empirically- - // safe threshold apply) — an over-complex registry-sourced label_multipliers key degrades to a safe never-match - // instead of hanging RegExp.test() on an adversarial near-miss label (#2456). Reachable via the public - // score-preview API, the MCP tool, and the per-PR label-audit signal, so one bad registry entry could otherwise - // hang scoring for every PR on that repo. - if (hasUnsafeWildcardCount(pattern)) { + // Reject an over-complex registry-sourced label_multipliers key so it degrades to a safe never-match instead + // of hanging RegExp.test() on an adversarial near-miss label (#2456). Reachable via the public score-preview + // API, the MCP tool, and the per-PR label-audit signal, so one bad registry entry could otherwise hang + // scoring for every PR on that repo. Counting is fnmatch-specific, NOT change-guardrail's path-glob count: + // this compiler emits one `.*` per `*` with no `**` pairing (see below), so `**` is TWO backtracking groups + // here, not the single `.*` the path compiler collapses it into (#9994) — counting via that predicate would + // undercount `**` and admit a glob this compiler builds into a catastrophic-backtracking RegExp. The threshold + // itself is the shared MAX_GLOB_WILDCARD_GROUPS, so the two surfaces stay on one empirically-safe boundary. + if (fnmatchWildcardGroups(pattern) > MAX_GLOB_WILDCARD_GROUPS) { setLabelPatternRegExpCacheEntry(pattern, LABEL_PATTERN_NEVER_MATCHES); return LABEL_PATTERN_NEVER_MATCHES; } diff --git a/packages/loopover-engine/src/signals/change-guardrail.ts b/packages/loopover-engine/src/signals/change-guardrail.ts index e6a14969fd..1f79acd933 100644 --- a/packages/loopover-engine/src/signals/change-guardrail.ts +++ b/packages/loopover-engine/src/signals/change-guardrail.ts @@ -34,7 +34,10 @@ export function canonicalize(value: string): string { // protected automatically rather than needing to separately remember the risk. The boundary is set at the // highest GROUP count proven safe by the benchmark above (2) — a boundary that itself sits inside the // empirically dangerous range would defeat the point of a cap. -const MAX_GLOB_WILDCARD_GROUPS = 2; +// Exported (#9994) so label-match.ts's fnmatch compiler applies the SAME empirically-safe threshold rather +// than redeclaring its own literal — a second, independently-chosen cap is exactly the drift the +// hasUnsafeWildcardCount export below warns about. +export const MAX_GLOB_WILDCARD_GROUPS = 2; /** Count `*` GROUPS in `glob` — a `**` pair is ONE group (it compiles to a single `.*`, see globToRegExp), not * two. Mirrors globToRegExp's own tokenization exactly (including consuming a `**`'s trailing `/`) so the count diff --git a/packages/loopover-engine/test/label-match.test.ts b/packages/loopover-engine/test/label-match.test.ts new file mode 100644 index 0000000000..a7e13c4698 --- /dev/null +++ b/packages/loopover-engine/test/label-match.test.ts @@ -0,0 +1,34 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + clearLabelPatternRegExpCacheForTest, + labelMatchesPattern, + labelPatternRegExpCacheKeysForTest, +} from "../dist/scoring/label-match.js"; + +// #9994: the fnmatch compiler emits one `.*` per `*` and has no `**` concept, so `*a**b` compiles to THREE +// `.*` groups. The old guard reused change-guardrail's path-glob counter, which scores a `**` pair as ONE +// group, undercounting `**` and admitting a pattern this compiler builds into a catastrophic-backtracking +// RegExp. Counting is now fnmatch-specific (one per raw `*`), so `**`-containing patterns over the cap are +// rejected — they fail SAFE toward no-multiplier (never match). +test("#9994: a pattern whose COMPILED groups exceed the cap via `**` is rejected (never matches)", () => { + clearLabelPatternRegExpCacheForTest(); + assert.equal(labelMatchesPattern("anything", "*a**b"), false); // 3 stars → 3 groups → rejected + assert.equal(labelMatchesPattern("x/y", "**/**"), false); // 4 stars → 4 groups → rejected +}); + +test("#9994: the preserved 2-group and non-`*` cases still match exactly", () => { + assert.equal(labelMatchesPattern("type:bug-fix", "type:*"), true); + assert.equal(labelMatchesPattern("priority:1", "priority:?"), true); // `?` is not a counted group + assert.equal(labelMatchesPattern("a-b-c", "a*b*c"), true); // 2 stars, at the cap + assert.equal(labelMatchesPattern("kind:bug", "kind:[bc]ug"), true); // classes are not counted groups +}); + +test("#9994: a rejected over-complex pattern is still cached (repeated read served from cache)", () => { + clearLabelPatternRegExpCacheForTest(); + assert.equal(labelMatchesPattern("anything", "*a**b"), false); + assert.ok(labelPatternRegExpCacheKeysForTest().includes("*a**b")); + assert.equal(labelMatchesPattern("something-else", "*a**b"), false); // cache-hit arm, still false + assert.equal(labelPatternRegExpCacheKeysForTest().filter((k) => k === "*a**b").length, 1); +}); diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index c13475d69d..a61be9df40 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -960,9 +960,12 @@ NOVELTY_BONUS_SCALAR = 3 // Regex metacharacters in a literal key stay literal: `.` matches only a dot, not any char. expect(labelMultiplierFor({ "v1.0": 1.1 }, ["v1.0"])).toBe(1.1); expect(labelMultiplierFor({ "v1.0": 1.1 }, ["v1x0"])).toBe(1); - // `**/` counts as one wildcard group and matches across path-like label segments. + // #9994: this fnmatch compiler counts one wildcard group per raw `*` (it has no `**`-is-one-group rule — + // that is the PATH compiler's semantics), so `**/bug` and `**bug` are 2 groups (at the cap → still + // compile and match), but `public/**/*.json` is THREE (`**` + `*`) and is now rejected as over-complex, + // failing SAFE toward no multiplier — where the old path-glob count wrongly scored it as 2 and matched it. expect(labelMultiplierFor({ "**/bug": 1.45 }, ["feature/bug"])).toBe(1.45); - expect(labelMultiplierFor({ "public/**/*.json": 1.2 }, ["public/release/config.json"])).toBe(1.2); + expect(labelMultiplierFor({ "public/**/*.json": 1.2 }, ["public/release/config.json"])).toBe(1); expect(labelMultiplierFor({ "**bug": 1.35 }, ["feature-bug"])).toBe(1.35); // When several patterns match, the highest multiplier wins (mirrors upstream `max(...)`). expect(labelMultiplierFor({ "kind/*": 1.1, "*/bug": 1.6 }, ["kind/bug"])).toBe(1.6); @@ -2150,4 +2153,30 @@ describe("label pattern matcher memoization (#2106)", () => { expect(labelMatchesPattern("type-bug-fix", "type-*-*")).toBe(true); expect(labelMatchesPattern("type-bug", "type-*-*")).toBe(false); }); + + it("#9994: a `**`-containing pattern is counted by its COMPILED groups (one per raw *), not the path-glob `**`-is-one rule, so it is rejected", () => { + // The fnmatch compiler emits one `.*` per `*` and has no `**` concept, so `*a**b` compiles to THREE `.*` + // groups. change-guardrail's path counter scored it as 2 (a `**` pair = one group) and wrongly ACCEPTED it, + // admitting a pattern this compiler builds into a catastrophic-backtracking RegExp. All three fail SAFE + // toward no-multiplier (never matches). + clearLabelPatternRegExpCacheForTest(); + expect(labelMatchesPattern("anything", "*a**b")).toBe(false); // 3 stars → 3 compiled groups → rejected + expect(labelMatchesPattern("x/y", "**/**")).toBe(false); // 4 stars → 4 compiled groups → rejected + expect(labelMatchesPattern("abc", "a**b**c")).toBe(false); // 4 stars → rejected + + // The preserved 2-group cases and non-`*` metacharacters still compile and match exactly as before. + expect(labelMatchesPattern("type:bug-fix", "type:*")).toBe(true); + expect(labelMatchesPattern("priority:1", "priority:?")).toBe(true); // `?` is not a counted group + expect(labelMatchesPattern("a-b-c", "a*b*c")).toBe(true); + expect(labelMatchesPattern("kind:bug", "kind:[bc]ug")).toBe(true); // classes are not counted groups + }); + + it("#9994: a rejected over-complex pattern is still cached, so a repeated read is served from the cache", () => { + clearLabelPatternRegExpCacheForTest(); + expect(labelMatchesPattern("anything", "*a**b")).toBe(false); + expect(labelPatternRegExpCacheKeysForTest()).toContain("*a**b"); + // Second read of the same over-complex pattern is served from the cache (cache-hit arm), still false. + expect(labelMatchesPattern("something-else", "*a**b")).toBe(false); + expect(labelPatternRegExpCacheKeysForTest().filter((k) => k === "*a**b")).toHaveLength(1); + }); });