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..198dc428bb 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -829,6 +829,16 @@ 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); + // 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.