From 8a0cf283743ff8d85f65b3af185496156f259343 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Fri, 26 Jun 2026 02:47:38 -0700 Subject: [PATCH 1/3] fix(scoring): harden label glob translation --- src/scoring/preview.ts | 30 ++++++++++++++++++++++++++---- test/unit/scoring.test.ts | 5 +++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index f6d8ca6281..b6baa350bd 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -921,10 +921,20 @@ function labelPatternToRegExp(pattern: string): RegExp { // No closing bracket: fnmatch treats the `[` as a literal character. regex += "\\["; } else { - let body = pattern.slice(i, close).replace(/\\/g, "\\\\"); - // `[!seq]` is fnmatch's negated class; RegExp spells negation as `[^seq]`. - if (body.startsWith("!")) body = `^${body.slice(1)}`; - regex += `[${body}]`; + const rawBody = pattern.slice(i, close); + if (rawBody === "" || rawBody === "!") { + // Empty classes and bare `[!]` stay literal in Python fnmatch instead of compiling as classes. + regex += `\\[${escapeRegExpLiteral(rawBody)}\\]`; + } else if (hasDescendingCharacterRange(rawBody)) { + // Python fnmatch treats invalid ranges like `[z-a]` as a never-match pattern; RegExp throws. + regex += "(?!)"; + } else { + let body = rawBody.replace(/\\/g, "\\\\"); + // `[!seq]` is fnmatch's negated class; RegExp spells negation as `[^seq]`. + if (body.startsWith("!")) body = `^${body.slice(1)}`; + else if (body.startsWith("^")) body = `\\${body}`; + regex += `[${body}]`; + } i = close + 1; } } else if (/[.+^${}()|\]\\]/.test(char)) { @@ -936,6 +946,18 @@ function labelPatternToRegExp(pattern: string): RegExp { return new RegExp(`^${regex}$`, "i"); } +function escapeRegExpLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function hasDescendingCharacterRange(body: string): boolean { + const start = body.startsWith("!") ? 1 : 0; + for (let i = start + 1; i < body.length - 1; i += 1) { + if (body.charAt(i) === "-" && body.charCodeAt(i - 1) > body.charCodeAt(i + 1)) return true; + } + return false; +} + function decideLinkedIssueMultiplier( mode: "none" | "standard" | "maintainer", context: LinkedIssueMultiplierContext | undefined, diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index 7166626a11..f43330152f 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -829,6 +829,11 @@ NOVELTY_BONUS_SCALAR = 3 // `[seq]` / `[!seq]` character classes. expect(labelMultiplierFor({ "[bf]ug": 1.4 }, ["bug"])).toBe(1.4); expect(labelMultiplierFor({ "[!x]ug": 1.3 }, ["bug"])).toBe(1.3); + expect(labelMultiplierFor({ "[^x]ug": 1.3 }, ["bug"])).toBe(1); + expect(labelMultiplierFor({ "[^x]ug": 1.3 }, ["^ug"])).toBe(1.3); + // Malformed or empty bracket classes mirror Python fnmatch: they never throw or over-match. + expect(labelMultiplierFor({ "[z-a]": 2 }, ["a"])).toBe(1); + expect(labelMultiplierFor({ "[!]": 2 }, ["!"])).toBe(1); // A `[` with no closing bracket is a literal, not a class. expect(labelMultiplierFor({ "a[b": 0.7 }, ["a[b"])).toBe(0.7); // Regex metacharacters in a literal key stay literal: `.` matches only a dot, not any char. From 9a3712ce687e2abf5c5cc8f5e645ce64c10ce1cc Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:45:10 -0700 Subject: [PATCH 2/3] test(scoring): cover the empty bracket-class arm of the glob translator --- test/unit/scoring.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index f43330152f..3d5bd50106 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -834,6 +834,8 @@ NOVELTY_BONUS_SCALAR = 3 // Malformed or empty bracket classes mirror Python fnmatch: they never throw or over-match. expect(labelMultiplierFor({ "[z-a]": 2 }, ["a"])).toBe(1); expect(labelMultiplierFor({ "[!]": 2 }, ["!"])).toBe(1); + // An empty `[]` class stays literal too (the `rawBody === ""` arm): pattern `[]` matches only the label `[]`. + expect(labelMultiplierFor({ "[]": 2 }, ["[]"])).toBe(2); // A `[` with no closing bracket is a literal, not a class. expect(labelMultiplierFor({ "a[b": 0.7 }, ["a[b"])).toBe(0.7); // Regex metacharacters in a literal key stay literal: `.` matches only a dot, not any char. From 06044d4e573769682b252b156a1a178e5bca1f09 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:57:11 -0700 Subject: [PATCH 3/3] test(scoring): cover the ascending-range arm of the glob descending-range check --- test/unit/scoring.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index 3d5bd50106..198dc428bb 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -836,6 +836,9 @@ NOVELTY_BONUS_SCALAR = 3 expect(labelMultiplierFor({ "[!]": 2 }, ["!"])).toBe(1); // An empty `[]` class stays literal too (the `rawBody === ""` arm): pattern `[]` matches only the label `[]`. expect(labelMultiplierFor({ "[]": 2 }, ["[]"])).toBe(2); + // An ASCENDING range (`[a-c]`) has a `-` but is NOT descending, so it compiles as a real class (the other + // arm of the descending-range check): `b` is in `[a-c]`, so `[a-c]ug` matches `bug`. + expect(labelMultiplierFor({ "[a-c]ug": 1.5 }, ["bug"])).toBe(1.5); // A `[` with no closing bracket is a literal, not a class. expect(labelMultiplierFor({ "a[b": 0.7 }, ["a[b"])).toBe(0.7); // Regex metacharacters in a literal key stay literal: `.` matches only a dot, not any char.