Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions src/scoring/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions test/unit/scoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading