diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index 7e495c32f2..444d7b0ad8 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -756,13 +756,55 @@ function deltaExplanationFor(core: ScoreCore, blockedBy: ScoreGateBlocker[]): st } function selectLabelMultiplier(labels: string[], multipliers: Record, fallback: number): number { - const normalized = new Set(labels.map((label) => label.toLowerCase())); - const matched = Object.entries(multipliers).flatMap(([label, multiplier]) => - normalized.has(label.toLowerCase()) ? [multiplier] : [], - ); + const normalized = labels.map((label) => label.toLowerCase()); + const matched = Object.entries(multipliers).flatMap(([pattern, multiplier]) => { + const matcher = labelPatternToRegExp(pattern.toLowerCase()); + return normalized.some((label) => matcher.test(label)) ? [multiplier] : []; + }); return matched.length > 0 ? Math.max(...matched) : fallback || 1; } +// Upstream resolves label multipliers by matching each configured key as a Python `fnmatch` GLOB, not a +// literal string: `fnmatch(label.lower(), pattern.lower())` in +// gittensor/validator/oss_contributions/label_resolution.py, so a repo can configure `type:*`, `kind/*`, or +// `priority:?` and have it match `type:bug-fix`, `kind/bug`, `priority:1` (#1244-class scoring parity). The +// preview previously did exact equality, so it silently scored every wildcard-configured trusted label at the +// neutral default — under-/over-estimating the score for any repo using glob keys. Translate one fnmatch +// pattern to an anchored, case-insensitive RegExp. fnmatch semantics differ from the path-glob in +// 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. +function labelPatternToRegExp(pattern: string): RegExp { + let regex = ""; + let i = 0; + while (i < pattern.length) { + const char = pattern.charAt(i); + i += 1; + if (char === "*") { + regex += ".*"; + } else if (char === "?") { + regex += "."; + } else if (char === "[") { + const close = pattern.indexOf("]", i); + if (close === -1) { + // 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}]`; + i = close + 1; + } + } else if (/[.+^${}()|\]\\]/.test(char)) { + regex += `\\${char}`; + } else { + regex += char; + } + } + return new RegExp(`^${regex}$`, "i"); +} + function decideLinkedIssueMultiplier( mode: "none" | "standard" | "maintainer", context: LinkedIssueMultiplierContext | undefined, diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index d25ecc7bf1..4df2e1b1b3 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -803,6 +803,44 @@ NOVELTY_BONUS_SCALAR = 3 ); }); + it("matches configured label keys as fnmatch globs, mirroring the upstream validator", () => { + const baseInput: ScorePreviewInput = { + repoFullName: repo.fullName, + sourceTokenScore: 60, + totalTokenScore: 90, + sourceLines: 50, + openPrCount: 0, + credibility: 1, + linkedIssueMode: "none", + }; + const labelMultiplierFor = (labelMultipliers: Record, labels: string[], defaultLabelMultiplier = 1): number => + buildScorePreview({ + repo: { ...repo, registryConfig: { ...repo.registryConfig!, defaultLabelMultiplier, labelMultipliers } }, + snapshot, + input: { ...baseInput, labels }, + }).scoreEstimate.labelMultiplier; + + // `*` spans any run of characters (including `/` and `:` — labels are flat strings, not paths). + expect(labelMultiplierFor({ "kind/*": 1.5 }, ["kind/bug"])).toBe(1.5); + expect(labelMultiplierFor({ "type:*": 1.1 }, ["type:bug-fix"])).toBe(1.1); + // `?` matches exactly one character: it matches `priority:1` but not the two-digit `priority:10`. + expect(labelMultiplierFor({ "priority:?": 2 }, ["priority:1"])).toBe(2); + expect(labelMultiplierFor({ "priority:?": 2 }, ["priority:10"])).toBe(1); + // `[seq]` / `[!seq]` character classes. + expect(labelMultiplierFor({ "[bf]ug": 1.4 }, ["bug"])).toBe(1.4); + expect(labelMultiplierFor({ "[!x]ug": 1.3 }, ["bug"])).toBe(1.3); + // 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. + expect(labelMultiplierFor({ "v1.0": 1.1 }, ["v1.0"])).toBe(1.1); + expect(labelMultiplierFor({ "v1.0": 1.1 }, ["v1x0"])).toBe(1); + // When several patterns match, the highest multiplier wins (mirrors upstream `max(...)`). + expect(labelMultiplierFor({ "kind/*": 1.1, "*/bug": 1.6 }, ["kind/bug"])).toBe(1.6); + // Literal keys are unchanged — exact match, parity-preserving for every existing config. + expect(labelMultiplierFor({ bug: 1.2 }, ["bug"])).toBe(1.2); + expect(labelMultiplierFor({ bug: 1.2 }, ["feature"])).toBe(1); + }); + it("gates linked-issue assumptions with branch eligibility evidence", () => { const baseInput = { repoFullName: repo.fullName,