From 8a1162b088d24178ae388b308217e17c9b9e0510 Mon Sep 17 00:00:00 2001 From: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com> Date: Fri, 3 Jul 2026 04:31:01 -1000 Subject: [PATCH 1/3] =?UTF-8?q?feat(miner-discovery):=20goal=20model=20?= =?UTF-8?q?=E2=80=94=20translate=20MinerGoalSpec=20into=20ranker=20weights?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GoalModelInput and computeLaneFit to packages/gittensory-engine/src/goal-model.ts. Given a candidate issue's paths/labels and a parsed MinerGoalSpec, returns a [0, 1] lane-fit score: - 0 immediately if any candidatePath matches a blockedPath glob (short-circuit, mirrors focus-manifest blockedPaths-precedence convention) - 0 immediately if any candidateLabel matches a blockedLabel (case-insensitive) - 0 if wantedPaths/preferredLabels are set but neither matches - 0.5 when wantedPaths and preferredLabels are both empty (neutral default) - matched-criteria / total-criteria fractional ratio otherwise Includes a minimal scoped glob helper for path matching — the existing matchesManifestPath in src/signals/focus-manifest.ts lives outside the engine package's rootDir, so a self-contained glob matcher is defined here per the issue's note about the import gap. Re-exports from packages/gittensory-engine/src/index.ts so the ranker's laneFit parameter can consume the score via the shared package. Adds 10 unit tests covering: blockedPath short-circuit, blockedLabel hit, empty-preferences neutral default, no-match zero, full-match one, partial-match fraction, path-only and label-only match, case-insensitive label matching. Closes #2304 --- packages/gittensory-engine/src/goal-model.ts | 88 +++++++++++++ packages/gittensory-engine/src/index.ts | 4 + .../gittensory-engine/test/goal-model.test.ts | 119 ++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 packages/gittensory-engine/src/goal-model.ts create mode 100644 packages/gittensory-engine/test/goal-model.test.ts diff --git a/packages/gittensory-engine/src/goal-model.ts b/packages/gittensory-engine/src/goal-model.ts new file mode 100644 index 0000000000..086bb2d6e2 --- /dev/null +++ b/packages/gittensory-engine/src/goal-model.ts @@ -0,0 +1,88 @@ +import type { MinerGoalSpec } from "./miner-goal-spec.js"; + +export type GoalModelInput = { + candidatePaths: string[]; + candidateLabels: string[]; + goalSpec: MinerGoalSpec; +}; + +function normalizeLabels(labels: readonly string[]): string[] { + return labels + .filter((label): label is string => typeof label === "string") + .map((label) => label.trim().toLowerCase()) + .filter(Boolean); +} + +function normalizePathForMatch(path: string): string { + return String(path ?? "").replace(/\\/g, "/").toLowerCase(); +} + +function compileGlobMatcher(pattern: string): (path: string) => boolean { + const normalizedPattern = normalizePathForMatch(pattern); + if (!normalizedPattern) return () => false; + let regex = "^"; + for (let i = 0; i < normalizedPattern.length; i++) { + const ch = normalizedPattern[i]; + if (ch === "*") { + const next = normalizedPattern[i + 1]; + if (next === "*") { + regex += ".*"; + i++; + } else { + regex += "[^/]*"; + } + } else if (ch === "?") { + regex += "[^/]"; + } else if (/[.+^$(){}|[\]\\]/.test(ch ?? "")) { + regex += "\\" + ch; + } else { + regex += ch; + } + } + regex += "$"; + const compiled = new RegExp(regex); + return (path: string) => { + const normalized = normalizePathForMatch(path); + if (!normalized) return false; + return compiled.test(normalized); + }; +} + +function matchesAnyLabel(candidateLabels: readonly string[], goalLabels: readonly string[]): boolean { + if (goalLabels.length === 0) return false; + const normalizedCandidate = normalizeLabels(candidateLabels); + const normalizedGoal = normalizeLabels(goalLabels); + return normalizedGoal.some((label) => normalizedCandidate.includes(label)); +} + +function matchesAnyPath(candidatePaths: readonly string[], goalPaths: readonly string[]): boolean { + if (goalPaths.length === 0) return false; + return goalPaths.some((pattern) => { + const matcher = compileGlobMatcher(pattern); + return candidatePaths.some((path) => matcher(path)); + }); +} + +export function computeLaneFit(input: GoalModelInput): number { + const { candidatePaths, candidateLabels, goalSpec } = input; + if (matchesAnyPath(candidatePaths, goalSpec.blockedPaths)) { + return 0; + } + if (matchesAnyLabel(candidateLabels, goalSpec.blockedLabels)) { + return 0; + } + const pathMatches = matchesAnyPath(candidatePaths, goalSpec.wantedPaths); + const labelMatches = matchesAnyLabel(candidateLabels, goalSpec.preferredLabels); + if (!pathMatches && !labelMatches) { + if (goalSpec.wantedPaths.length === 0 && goalSpec.preferredLabels.length === 0) { + return 0.5; + } + return 0; + } + const totalCriteria = goalSpec.wantedPaths.length + goalSpec.preferredLabels.length; + const matchedCriteria = (pathMatches ? 1 : 0) + (labelMatches ? 1 : 0); + if (totalCriteria === 0) { + return 0.5; + } + return matchedCriteria / totalCriteria; +} \ No newline at end of file diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 538d5a64d5..73dd877d59 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -34,3 +34,7 @@ export { isMinerRepoTargetable, } from "./miner-goal-lane-fit.js"; export { computeOpportunityCompetition } from "./opportunity-competition.js"; +export { + computeLaneFit, + type GoalModelInput, +} from "./goal-model.js"; diff --git a/packages/gittensory-engine/test/goal-model.test.ts b/packages/gittensory-engine/test/goal-model.test.ts new file mode 100644 index 0000000000..0ddac6dfc1 --- /dev/null +++ b/packages/gittensory-engine/test/goal-model.test.ts @@ -0,0 +1,119 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { DEFAULT_MINER_GOAL_SPEC, type MinerGoalSpec } from "../dist/miner-goal-spec.js"; +import { computeLaneFit, type GoalModelInput } from "../dist/goal-model.js"; + +function baseSpec(overrides: Partial = {}): MinerGoalSpec { + return { ...DEFAULT_MINER_GOAL_SPEC, ...overrides }; +} + +function input(overrides: Partial = {}): GoalModelInput { + return { + candidatePaths: ["src/app.ts"], + candidateLabels: ["bug"], + goalSpec: baseSpec(), + ...overrides, + }; +} + +test("computeLaneFit returns 0 when a candidate path matches a blockedPath (short-circuit, ignores preferredLanes)", () => { + const result = computeLaneFit( + input({ + candidatePaths: ["secrets/api-keys.ts"], + goalSpec: baseSpec({ blockedPaths: ["secrets/**"], wantedPaths: ["src/**"], preferredLabels: ["bug"] }), + }), + ); + assert.equal(result, 0); +}); + +test("computeLaneFit returns 0 when a candidate label matches a blockedLabel", () => { + const result = computeLaneFit( + input({ + candidateLabels: ["do-not-pick"], + goalSpec: baseSpec({ blockedLabels: ["do-not-pick"], preferredLabels: ["bug"] }), + }), + ); + assert.equal(result, 0); +}); + +test("computeLaneFit returns 0.5 when wantedPaths and preferredLabels are both empty (neutral default)", () => { + const result = computeLaneFit(input()); + assert.equal(result, 0.5); +}); + +test("computeLaneFit returns 0 when wantedPaths/preferredLabels are set but no match", () => { + const result = computeLaneFit( + input({ + candidatePaths: ["docs/readme.md"], + candidateLabels: [], + goalSpec: baseSpec({ wantedPaths: ["src/**"], preferredLabels: ["bug"] }), + }), + ); + assert.equal(result, 0); +}); + +test("computeLaneFit returns 1.0 when both wantedPaths and preferredLabels fully match", () => { + const result = computeLaneFit( + input({ + candidatePaths: ["src/app.ts"], + candidateLabels: ["bug"], + goalSpec: baseSpec({ wantedPaths: ["src/**"], preferredLabels: ["bug"] }), + }), + ); + assert.equal(result, 1); +}); + +test("computeLaneFit returns 0.5 when only one of two preferred criteria matches (path match, label miss)", () => { + const result = computeLaneFit( + input({ + candidatePaths: ["src/app.ts"], + candidateLabels: ["unrelated"], + goalSpec: baseSpec({ wantedPaths: ["src/**"], preferredLabels: ["bug"] }), + }), + ); + assert.equal(result, 0.5); +}); + +test("computeLaneFit returns 0.5 when only one of two preferred criteria matches (label match, path miss)", () => { + const result = computeLaneFit( + input({ + candidatePaths: ["docs/readme.md"], + candidateLabels: ["bug"], + goalSpec: baseSpec({ wantedPaths: ["src/**"], preferredLabels: ["bug"] }), + }), + ); + assert.equal(result, 0.5); +}); + +test("computeLaneFit returns 1.0 when only wantedPaths is set and matches", () => { + const result = computeLaneFit( + input({ + candidatePaths: ["src/app.ts"], + candidateLabels: [], + goalSpec: baseSpec({ wantedPaths: ["src/**"] }), + }), + ); + assert.equal(result, 1); +}); + +test("computeLaneFit returns 1.0 when only preferredLabels is set and matches", () => { + const result = computeLaneFit( + input({ + candidatePaths: [], + candidateLabels: ["bug"], + goalSpec: baseSpec({ preferredLabels: ["bug"] }), + }), + ); + assert.equal(result, 1); +}); + +test("computeLaneFit treats label matching case-insensitively", () => { + const result = computeLaneFit( + input({ + candidateLabels: ["BUG"], + goalSpec: baseSpec({ preferredLabels: ["bug"] }), + }), + ); + assert.equal(result, 1); +}); \ No newline at end of file From a8c28b9767964c2944ff0b4bce122e12455824c3 Mon Sep 17 00:00:00 2001 From: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com> Date: Fri, 3 Jul 2026 04:48:01 -1000 Subject: [PATCH 2/3] fix(miner-discovery): count active dimensions in computeLaneFit Switch the denominator from total list entries to active dimensions (one for wantedPaths, one for preferredLabels). A spec with wantedPaths containing multiple globs and a candidate matching one of them now returns 1.0 instead of 0.5, matching the boolean matching semantics. Add 3 regression tests for multi-entry wantedPaths, multi-entry preferredLabels, and both-multi-entry-with-match cases. Resolves the Gittensory Orb Review blocker on PR #2787. --- packages/gittensory-engine/src/goal-model.ts | 21 ++++++----- .../gittensory-engine/test/goal-model.test.ts | 36 +++++++++++++++++++ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/packages/gittensory-engine/src/goal-model.ts b/packages/gittensory-engine/src/goal-model.ts index 086bb2d6e2..bed0e986a0 100644 --- a/packages/gittensory-engine/src/goal-model.ts +++ b/packages/gittensory-engine/src/goal-model.ts @@ -71,18 +71,17 @@ export function computeLaneFit(input: GoalModelInput): number { if (matchesAnyLabel(candidateLabels, goalSpec.blockedLabels)) { return 0; } - const pathMatches = matchesAnyPath(candidatePaths, goalSpec.wantedPaths); - const labelMatches = matchesAnyLabel(candidateLabels, goalSpec.preferredLabels); + const hasPathCriteria = goalSpec.wantedPaths.length > 0; + const hasLabelCriteria = goalSpec.preferredLabels.length > 0; + if (!hasPathCriteria && !hasLabelCriteria) { + return 0.5; + } + const pathMatches = hasPathCriteria && matchesAnyPath(candidatePaths, goalSpec.wantedPaths); + const labelMatches = hasLabelCriteria && matchesAnyLabel(candidateLabels, goalSpec.preferredLabels); if (!pathMatches && !labelMatches) { - if (goalSpec.wantedPaths.length === 0 && goalSpec.preferredLabels.length === 0) { - return 0.5; - } return 0; } - const totalCriteria = goalSpec.wantedPaths.length + goalSpec.preferredLabels.length; - const matchedCriteria = (pathMatches ? 1 : 0) + (labelMatches ? 1 : 0); - if (totalCriteria === 0) { - return 0.5; - } - return matchedCriteria / totalCriteria; + const activeDimensions = (hasPathCriteria ? 1 : 0) + (hasLabelCriteria ? 1 : 0); + const matchedDimensions = (pathMatches ? 1 : 0) + (labelMatches ? 1 : 0); + return matchedDimensions / activeDimensions; } \ No newline at end of file diff --git a/packages/gittensory-engine/test/goal-model.test.ts b/packages/gittensory-engine/test/goal-model.test.ts index 0ddac6dfc1..2dfd004299 100644 --- a/packages/gittensory-engine/test/goal-model.test.ts +++ b/packages/gittensory-engine/test/goal-model.test.ts @@ -86,6 +86,42 @@ test("computeLaneFit returns 0.5 when only one of two preferred criteria matches assert.equal(result, 0.5); }); +test("computeLaneFit returns 1.0 when multiple wantedPaths are set and one matches", () => { + const result = computeLaneFit( + input({ + candidatePaths: ["src/app.ts"], + candidateLabels: [], + goalSpec: baseSpec({ wantedPaths: ["src/**", "lib/**", "packages/**"] }), + }), + ); + assert.equal(result, 1); +}); + +test("computeLaneFit returns 1.0 when multiple preferredLabels are set and one matches", () => { + const result = computeLaneFit( + input({ + candidatePaths: [], + candidateLabels: ["bug"], + goalSpec: baseSpec({ preferredLabels: ["bug", "feature", "enhancement"] }), + }), + ); + assert.equal(result, 1); +}); + +test("computeLaneFit returns 1.0 when both multi-entry lists have at least one match", () => { + const result = computeLaneFit( + input({ + candidatePaths: ["lib/utils.ts"], + candidateLabels: ["enhancement"], + goalSpec: baseSpec({ + wantedPaths: ["src/**", "lib/**", "packages/**"], + preferredLabels: ["bug", "feature", "enhancement"], + }), + }), + ); + assert.equal(result, 1); +}); + test("computeLaneFit returns 1.0 when only wantedPaths is set and matches", () => { const result = computeLaneFit( input({ From c793f40f45b9f8f4fe3230c19ad6c640cf11d4f1 Mon Sep 17 00:00:00 2001 From: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:12:18 -1000 Subject: [PATCH 3/3] fix: ** glob matches zero or more path segments --- packages/gittensory-engine/src/goal-model.ts | 14 ++++++++----- .../gittensory-engine/test/goal-model.test.ts | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/gittensory-engine/src/goal-model.ts b/packages/gittensory-engine/src/goal-model.ts index bed0e986a0..1747546c1b 100644 --- a/packages/gittensory-engine/src/goal-model.ts +++ b/packages/gittensory-engine/src/goal-model.ts @@ -23,14 +23,18 @@ function compileGlobMatcher(pattern: string): (path: string) => boolean { let regex = "^"; for (let i = 0; i < normalizedPattern.length; i++) { const ch = normalizedPattern[i]; - if (ch === "*") { - const next = normalizedPattern[i + 1]; - if (next === "*") { + const next = normalizedPattern[i + 1]; + if (ch === "*" && next === "*") { + const afterDoubleStar = normalizedPattern[i + 2]; + if (afterDoubleStar === "/") { + regex += "(?:.*/)?"; + i += 2; + } else { regex += ".*"; i++; - } else { - regex += "[^/]*"; } + } else if (ch === "*") { + regex += "[^/]*"; } else if (ch === "?") { regex += "[^/]"; } else if (/[.+^$(){}|[\]\\]/.test(ch ?? "")) { diff --git a/packages/gittensory-engine/test/goal-model.test.ts b/packages/gittensory-engine/test/goal-model.test.ts index 2dfd004299..3deb4a18a7 100644 --- a/packages/gittensory-engine/test/goal-model.test.ts +++ b/packages/gittensory-engine/test/goal-model.test.ts @@ -152,4 +152,24 @@ test("computeLaneFit treats label matching case-insensitively", () => { }), ); assert.equal(result, 1); +}); + +test("computeLaneFit ** glob matches both top-level and nested paths", () => { + const topLevel = computeLaneFit( + input({ + candidatePaths: ["src/app.ts"], + candidateLabels: [], + goalSpec: baseSpec({ wantedPaths: ["src/**/*.ts"] }), + }), + ); + assert.equal(topLevel, 1); + + const nested = computeLaneFit( + input({ + candidatePaths: ["src/nested/app.ts"], + candidateLabels: [], + goalSpec: baseSpec({ wantedPaths: ["src/**/*.ts"] }), + }), + ); + assert.equal(nested, 1); }); \ No newline at end of file