diff --git a/packages/gittensory-engine/src/goal-model.ts b/packages/gittensory-engine/src/goal-model.ts new file mode 100644 index 0000000000..1747546c1b --- /dev/null +++ b/packages/gittensory-engine/src/goal-model.ts @@ -0,0 +1,91 @@ +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]; + const next = normalizedPattern[i + 1]; + if (ch === "*" && next === "*") { + const afterDoubleStar = normalizedPattern[i + 2]; + if (afterDoubleStar === "/") { + regex += "(?:.*/)?"; + i += 2; + } else { + regex += ".*"; + i++; + } + } else if (ch === "*") { + 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 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) { + return 0; + } + 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/src/index.ts b/packages/gittensory-engine/src/index.ts index 342f9dd36d..e0e45be9d3 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -35,6 +35,9 @@ export { } from "./miner-goal-lane-fit.js"; export { computeOpportunityCompetition } from "./opportunity-competition.js"; export { + computeLaneFit, + type GoalModelInput, +} from "./goal-model.js"; classifyContributorFit, type ContributorFit, type ContributorFitCheck, 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..3deb4a18a7 --- /dev/null +++ b/packages/gittensory-engine/test/goal-model.test.ts @@ -0,0 +1,175 @@ +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 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({ + 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); +}); + +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