From ddae60cb4806e9ead2eb60dbab57e82ee1dae062 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:05:39 -0400 Subject: [PATCH] Implement character-class matching in web model-scope globs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review on #15 (web/server/modelScope.ts:61): patterns like zai/glm-5.[23] were classified as globs (hasGlobCharacter includes `[`) but globToRegExp escaped the brackets into literals, so the pattern matched the literal text "[23]" instead of 2-or-3 — hiding valid models with no fallback, since the glob path bypasses partial matching. parseCharClass now translates classes with minimatch's semantics: - member sets ([23]), ranges ([2-3]), ! and ^ negation - a ] directly after [ (or the negation) is a literal member - backslash escapes a class member - an unterminated [ falls back to a literal bracket - classes never match the path separator: / members are unreachable (dropped) and negated classes exclude / alongside their members Verified against real minimatch (nocase) across 216 pattern/model combinations; the only intentional divergences are the documented :thinking suffix stripping and the non-glob partial-match fallback. New unit tests cover matching and non-matching classes, negation, ranges, slash exclusion, unterminated classes, and leading-] members. --- tests/web-model-scope.test.ts | 62 +++++++++++++++++++++++++++++++ web/server/modelScope.ts | 69 ++++++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/tests/web-model-scope.test.ts b/tests/web-model-scope.test.ts index 0f819c5..cada37b 100644 --- a/tests/web-model-scope.test.ts +++ b/tests/web-model-scope.test.ts @@ -93,6 +93,68 @@ test("non-glob patterns fall back to partial id and name containment", () => { ).toBe(true); }); +test("character classes match one member, like minimatch", () => { + expect( + modelMatchesScopePattern("zai/glm-5.[23]", "zai", { id: "glm-5.2" }), + ).toBe(true); + expect( + modelMatchesScopePattern("zai/glm-5.[23]", "zai", { id: "glm-5.3" }), + ).toBe(true); + expect( + modelMatchesScopePattern("zai/glm-5.[23]", "zai", { id: "glm-5.4" }), + ).toBe(false); + expect( + modelMatchesScopePattern("zai/glm-5.[2-3]", "zai", { id: "glm-5.3" }), + ).toBe(true); + expect( + modelMatchesScopePattern("zai/glm-5.[2-3]", "zai", { id: "glm-5.4" }), + ).toBe(false); +}); + +test("negated character classes exclude members and the path separator", () => { + expect( + modelMatchesScopePattern("zai/glm-5.[!23]", "zai", { id: "glm-5.4" }), + ).toBe(true); + expect( + modelMatchesScopePattern("zai/glm-5.[!23]", "zai", { id: "glm-5.2" }), + ).toBe(false); + expect( + modelMatchesScopePattern("zai/glm-5.[^23]", "zai", { id: "glm-5.x" }), + ).toBe(true); + expect( + modelMatchesScopePattern("zai/glm-5.[^23]", "zai", { id: "glm-5.2" }), + ).toBe(false); + // A negated class never matches "/", so it cannot span the provider boundary. + expect(modelMatchesScopePattern("zai[!z]*", "zai", { id: "glm-5.4" })).toBe( + false, + ); + // A class whose only member is "/" can never match anything. + expect(modelMatchesScopePattern("zai[/]*", "zai", { id: "glm-5.4" })).toBe( + false, + ); +}); + +test("unterminated character classes fall back to a literal bracket", () => { + expect( + modelMatchesScopePattern("zai/glm-5.[2", "zai", { id: "glm-5.2" }), + ).toBe(false); + expect( + modelMatchesScopePattern("zai/glm-5.[2", "zai", { id: "glm-5.[2" }), + ).toBe(true); +}); + +test("a leading bracket inside a class is a literal member", () => { + expect( + modelMatchesScopePattern("zai/glm-5.[]23]", "zai", { id: "glm-5.]" }), + ).toBe(true); + expect( + modelMatchesScopePattern("zai/glm-5.[]23]", "zai", { id: "glm-5.2" }), + ).toBe(true); + expect( + modelMatchesScopePattern("zai/glm-5.[]23]", "zai", { id: "glm-5.4" }), + ).toBe(false); +}); + test("readEnabledModelPatterns reads enabledModels from a settings file", async () => { directory = await mkdtemp(join(tmpdir(), "pi-model-scope-")); const settingsPath = join(directory, "settings.json"); diff --git a/web/server/modelScope.ts b/web/server/modelScope.ts index 03f4c90..e78e0da 100644 --- a/web/server/modelScope.ts +++ b/web/server/modelScope.ts @@ -47,7 +47,9 @@ export async function readEnabledModelPatterns( /** * Convert a glob to a RegExp with minimatch's default semantics for the * characters we support: `*` and `?` never cross `/`, and `[...]` character - * classes pass through. Everything else is literal. + * classes (sets, ranges, `!`/`^` negation, a leading `]` as a literal member, + * `\`-escaped members, and unterminated `[` treated as a literal) translate + * directly. Everything else is literal. */ function globToRegExp(pattern: string): RegExp { let source = ""; @@ -56,11 +58,74 @@ function globToRegExp(pattern: string): RegExp { if (char === "*") source += "[^/]*"; else if (char === "?") source += "[^/]"; else if (char === "\\") source += "\\\\"; - else source += char.replace(/[.+^${}()|[\]]/g, "\\$&"); + else if (char === "[") { + const charClass = parseCharClass(pattern, index); + if (charClass) { + source += charClass.source; + index = charClass.nextIndex - 1; + } else { + // Unterminated class: minimatch treats the `[` as a literal. + source += "\\["; + } + } else source += char.replace(/[.+^${}()|[\]]/g, "\\$&"); } return new RegExp(`^${source}$`, "i"); } +/** Escape a character so it is a literal member of a RegExp character class. */ +function escapeClassMember(char: string): string { + return char === "\\" || char === "]" || char === "^" ? `\\${char}` : char; +} + +/** + * Translate the `[...]` character class starting at `start` into a RegExp + * class source with minimatch's semantics, returning the source and the index + * just past the closing `]`. `undefined` when the class is unterminated. + */ +function parseCharClass( + pattern: string, + start: number, +): { source: string; nextIndex: number } | undefined { + let index = start + 1; + let negate = false; + if (pattern[index] === "!" || pattern[index] === "^") { + negate = true; + index += 1; + } + const members: string[] = []; + // A `]` immediately after `[` (or the negation) is a literal member. + if (pattern[index] === "]") { + members.push("\\]"); + index += 1; + } + let closed = false; + while (index < pattern.length) { + const char = pattern[index]; + if (char === "\\") { + const next = pattern[index + 1]; + if (next === undefined) return undefined; + members.push(escapeClassMember(next)); + index += 2; + continue; + } + if (char === "]") { + closed = true; + index += 1; + break; + } + members.push(escapeClassMember(char)); + index += 1; + } + if (!closed) return undefined; + // Minimatch classes never match the path separator: a `/` member can never + // match (dropped), and a negated class excludes `/` alongside its members. + const literalMembers = members.filter((member) => member !== "/"); + return { + source: `[${negate ? "^/" : ""}${literalMembers.join("")}]`, + nextIndex: index, + }; +} + function hasGlobCharacter(pattern: string): boolean { return /[*?[]/.test(pattern); }