Skip to content
Merged
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
38 changes: 38 additions & 0 deletions packages/loopover-engine/src/goal-model.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
/**
* Pure lane-fit scorer: the compiled form of a repo's `MinerGoalSpec` path/label preferences. Given a
* candidate opportunity's paths and labels plus the operator's goal spec, it returns a single `[0, 1]`
* lane-fit score with no IO, network, clock, or random input. It is the shared primitive that
* `miner-goal-lane-fit.ts` and `opportunity-metadata.ts`'s `computeMetadataLaneFit` both consume; see
* {@link computeLaneFit} for the exact precedence and scoring rules.
*/
import type { MinerGoalSpec } from "./miner-goal-spec.js";

/** The inputs to {@link computeLaneFit}: one candidate opportunity scored against a goal spec. */
export type GoalModelInput = {
/** The candidate's changed/relevant file paths, matched against the spec's path globs (case-insensitive). */
candidatePaths: string[];
/** The candidate's labels, matched (trimmed + lowercased, exact) against the spec's label preferences. */
candidateLabels: string[];
/**
* The operator's goal spec supplying `blockedPaths`/`blockedLabels` (hard vetoes) and
* `wantedPaths`/`preferredLabels` (preferences).
*/
goalSpec: MinerGoalSpec;
};

Expand All @@ -17,6 +31,14 @@ function normalizePathForMatch(path: string): string {
return String(path ?? "").replace(/\\/g, "/").toLowerCase();
}

/**
* Compile one glob pattern into a case-insensitive whole-path matcher. Supports `*` (any run of
* non-`/` chars, within a single segment), a bare `**` (any run of chars including `/`), a `**` that is
* immediately followed by a slash (an optional directory prefix — zero or more leading segments), and
* `?` (a single non-`/` char). It does NOT
* support character classes (`[abc]`) or brace expansion (`{a,b}`) — those metacharacters are escaped and
* matched literally. Backslashes are normalized to `/` before matching, so patterns are OS-agnostic.
*/
function compileGlobMatcher(pattern: string): (path: string) => boolean {
const normalizedPattern = normalizePathForMatch(pattern);
if (!normalizedPattern) return () => false;
Expand Down Expand Up @@ -67,6 +89,22 @@ function matchesAnyPath(candidatePaths: readonly string[], goalPaths: readonly s
});
}

/**
* Score how well a candidate fits the goal spec's lane, in `[0, 1]`. Rules, in strict precedence:
*
* 1. **Hard veto.** If any candidate path matches `blockedPaths`, or any candidate label matches
* `blockedLabels`, the result is `0` immediately — before any preference is considered.
* 2. **Neutral default.** If the spec configures neither `wantedPaths` nor `preferredLabels`, the result
* is a fixed `0.5` (unopinionated), never `0` or `1`.
* 3. **No match.** If at least one preference dimension is configured but none of the configured ones
* actually match, the result is `0`.
* 4. **Partial credit.** Otherwise the result is `matchedDimensions / activeDimensions`, where a dimension
* (paths, labels) is "active" when configured and "matched" when it hit. So one active dimension that
* matches scores `1`; with both configured, matching only one scores `0.5` and matching both scores `1`.
* (Note `0.5` is thus reachable two ways — the neutral default of rule 2, and a one-of-two match here.)
*
* Pure: reads only its inputs, with no IO, network, clock, or randomness.
*/
export function computeLaneFit(input: GoalModelInput): number {
const { candidatePaths, candidateLabels, goalSpec } = input;
if (matchesAnyPath(candidatePaths, goalSpec.blockedPaths)) {
Expand Down