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
91 changes: 91 additions & 0 deletions packages/gittensory-engine/src/goal-model.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
175 changes: 175 additions & 0 deletions packages/gittensory-engine/test/goal-model.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): MinerGoalSpec {
return { ...DEFAULT_MINER_GOAL_SPEC, ...overrides };
}

function input(overrides: Partial<GoalModelInput> = {}): 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);
});