From 324490b6ad19eb548c48b476f4f09e9ad48aa7a1 Mon Sep 17 00:00:00 2001 From: luciferlive112116 <291889058+luciferlive112116@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:26:55 +0800 Subject: [PATCH] feat(miner): wire ContributionProfile eligibility filtering into discover (#6798) Closes the AMS contribution-profile loop: discover now filters candidate issues through each target repo's ContributionProfile before ranking/enqueueing, so it no longer surfaces work a repo's own conventions would reject. The decision is a pure module (contribution-profile-filter.js): partitions candidates into kept + excluded-with-reason (exclusion_label / missing_eligibility_label / conflicting_signals). Conflicting signals resolve conservatively -- exclusion wins. SAFE DEFAULT: filtering activates for a repo only when its profile has an explicit eligibility signal; a low-confidence/empty/absent profile keeps every candidate, so real work is never silently skipped on a repo AMS couldn't read. The default resolver does no profile work at all without a github token, keeping the unauthenticated path byte-identical. The excluded set (repo/issue/reason) is surfaced in --json and plain-text output. Assignee exclusion is a documented follow-up (candidates don't carry assignees yet). Doc updated. Closes #6798 --- .../docs/contribution-profile.md | 40 +- .../lib/contribution-profile-filter.d.ts | 20 + .../lib/contribution-profile-filter.js | 89 +++ packages/loopover-miner/lib/discover-cli.d.ts | 33 +- packages/loopover-miner/lib/discover-cli.js | 77 ++- packages/loopover-miner/package.json | 2 +- .../miner-contribution-profile-filter.test.ts | 202 ++++++ test/unit/miner-discover-cli.test.ts | 599 +++++++++++++++--- 8 files changed, 969 insertions(+), 93 deletions(-) create mode 100644 packages/loopover-miner/lib/contribution-profile-filter.d.ts create mode 100644 packages/loopover-miner/lib/contribution-profile-filter.js create mode 100644 test/unit/miner-contribution-profile-filter.test.ts diff --git a/packages/loopover-miner/docs/contribution-profile.md b/packages/loopover-miner/docs/contribution-profile.md index a6dadef340..c036fd9085 100644 --- a/packages/loopover-miner/docs/contribution-profile.md +++ b/packages/loopover-miner/docs/contribution-profile.md @@ -108,5 +108,41 @@ The profile is cached in a local SQLite store keyed by repo, mirroring the miner - **#6796 (extraction):** populates each `SignalRule` from labels + `CONTRIBUTING.md` (root and `.github/`) + PR template + agent docs, setting `confidence`/`provenance` per the findings above. - **#6797 (cache + doctor):** the `miner_contribution_profile` SQLite store with the TTL above. -- **#6798 (`discover` wiring):** reads the profile's eligibility/exclusion rules and the runtime assignee check - to filter candidate issues, weighting each by its `confidence`. +- **#6798 (`discover` wiring):** reads the profile's eligibility/exclusion rules to filter candidate issues — + see the section below for the landed behavior. + +## Discover eligibility filtering (#6798) + +`loopover-miner discover` now filters candidate issues through each target repo's `ContributionProfile` before +ranking and enqueueing, so it no longer surfaces work a repo's own conventions would reject. The decision logic +is `filterCandidatesByProfiles` (`contribution-profile-filter.js`), a pure partition of candidates into `kept` +and `excluded` (each excluded entry carries a `reason`). + +**Safe-default posture — the load-bearing rule.** Filtering activates for a repo **only** when its profile has +a trustworthy eligibility signal (`eligibilityLabels.confidence === "explicit"`). A repo with no profile, or a +low-confidence / empty one — a repo whose conventions AMS simply couldn't read — keeps **every** candidate. A +weak profile can never cause AMS to silently skip real, eligible work. On top of that, the default resolver +does no profile work at all without a GitHub token (it can't read a taxonomy reliably unauthenticated), so the +unauthenticated CLI path is byte-identical to before. + +**What gets excluded, once a repo is trusted** (matched against the candidate's own labels, by the eligibility/ +exclusion label _names_ the profile recorded in `provenance`): + +| Reason | Meaning | +| --------------------------- | ------------------------------------------------------------------------------------------- | +| `exclusion_label` | the issue carries a label the profile marks maintainer-only / off-limits | +| `missing_eligibility_label` | the repo has an eligibility convention and the issue carries none of its eligibility labels | +| `conflicting_signals` | the issue carries **both** an eligibility and an exclusion label — **exclusion wins** | + +**Conflicting signals resolve conservatively:** an issue that is both eligibility-labelled and exclusion-labelled +is excluded, because a maintainer marking it off-limits outranks its also being help-wanted — better to skip +than to attempt work the repo's own gate would reject. + +**Assignee exclusion is not yet applied here.** The candidate objects that flow through `discover` carry label +names but not assignees (`opportunity-fanout.js`'s `normalizeIssue` drops them), and `ContributionAssigneeRuntimeCheck` +is deliberately a runtime concern rather than a profile field. Threading assignees through the fan-out is a +follow-up; this PR scopes filtering to labels, which is the primary eligibility signal the #6794 inventory found. + +The excluded set (repo + issue + reason) is surfaced in both the `--json` output (`result.excluded`) and the +plain-text summary (an `excluded (eligibility): N` block), so a human running `discover` sees exactly what AMS +inferred and why each candidate was skipped. diff --git a/packages/loopover-miner/lib/contribution-profile-filter.d.ts b/packages/loopover-miner/lib/contribution-profile-filter.d.ts new file mode 100644 index 0000000000..f5d63c7788 --- /dev/null +++ b/packages/loopover-miner/lib/contribution-profile-filter.d.ts @@ -0,0 +1,20 @@ +import type { ContributionProfile } from "./contribution-profile.js"; + +export const ELIGIBILITY_EXCLUSION_REASONS: { + readonly EXCLUSION_LABEL: "exclusion_label"; + readonly MISSING_ELIGIBILITY_LABEL: "missing_eligibility_label"; + readonly CONFLICTING_SIGNALS: "conflicting_signals"; +}; + +export type EligibilityExclusion = { + candidate: T; + reason: + "exclusion_label" | "missing_eligibility_label" | "conflicting_signals"; +}; + +export function filterCandidatesByProfiles< + T extends { repoFullName: string; labels?: string[] }, +>( + candidates: T[], + profilesByRepo: Map, +): { kept: T[]; excluded: EligibilityExclusion[] }; diff --git a/packages/loopover-miner/lib/contribution-profile-filter.js b/packages/loopover-miner/lib/contribution-profile-filter.js new file mode 100644 index 0000000000..07a44e2dbd --- /dev/null +++ b/packages/loopover-miner/lib/contribution-profile-filter.js @@ -0,0 +1,89 @@ +// Eligibility filtering of discover candidates against a ContributionProfile (#6798). Pure: given the candidate +// list and a per-repo profile map, it partitions candidates into kept + excluded-with-reason. No fetching, no +// side effects — discover-cli.js resolves the profiles and renders the result; this owns only the decision. +// +// SAFE-DEFAULT POSTURE (the load-bearing requirement): filtering activates ONLY when a repo's profile has a +// trustworthy eligibility signal (eligibilityLabels.confidence === "explicit"). A repo with no profile, or a +// low-confidence/empty one — a repo whose conventions AMS simply couldn't read — has EVERY candidate kept, so a +// weak profile can never cause AMS to silently skip real, eligible work. + +/** Why a candidate was excluded. */ +export const ELIGIBILITY_EXCLUSION_REASONS = Object.freeze({ + /** The issue carries a label the profile identified as maintainer-only / off-limits. */ + EXCLUSION_LABEL: "exclusion_label", + /** The repo has a trustworthy eligibility convention, and the issue carries none of its eligibility labels. */ + MISSING_ELIGIBILITY_LABEL: "missing_eligibility_label", + /** The issue carries BOTH an eligibility and an exclusion label — conflicting signals; exclusion wins. */ + CONFLICTING_SIGNALS: "conflicting_signals", +}); + +/** The actual repo label names a signal rule was derived from (its provenance details), lowercased for match. */ +function labelNamesFromRule(rule) { + const names = new Set(); + for (const entry of rule?.provenance ?? []) { + if (typeof entry?.detail === "string") + names.add(entry.detail.toLowerCase()); + } + return names; +} + +/** Does the candidate carry any label whose name is in `names`? Case-insensitive. */ +function candidateHasAnyLabel(candidate, names) { + if (names.size === 0) return false; + for (const label of candidate?.labels ?? []) { + if (typeof label === "string" && names.has(label.toLowerCase())) + return true; + } + return false; +} + +/** + * Partition candidates into kept + excluded against per-repo ContributionProfiles. + * + * @param {Array<{ repoFullName: string, labels?: string[] }>} candidates the fanned-out discover candidates + * @param {Map} profilesByRepo profile per repoFullName + * @returns {{ kept: object[], excluded: Array<{ candidate: object, reason: string }> }} + */ +export function filterCandidatesByProfiles(candidates, profilesByRepo) { + const kept = []; + const excluded = []; + for (const candidate of candidates) { + const profile = profilesByRepo?.get(candidate.repoFullName); + // Trust gate: only an EXPLICIT eligibility signal is trustworthy enough to filter on. Anything weaker + // (absent/inferred/unknown, or no profile at all) keeps every candidate — the safe default. + if (profile?.eligibilityLabels?.confidence !== "explicit") { + kept.push(candidate); + continue; + } + const eligibilityNames = labelNamesFromRule(profile.eligibilityLabels); + const exclusionNames = labelNamesFromRule(profile.exclusionLabels); + const hasEligibility = candidateHasAnyLabel(candidate, eligibilityNames); + const hasExclusion = candidateHasAnyLabel(candidate, exclusionNames); + if (hasExclusion && hasEligibility) { + // Conservative resolution for conflicting signals: exclusion wins. A maintainer marking an issue + // off-limits outranks its also carrying an eligibility label — better to skip than to attempt work the + // repo's own gate would reject. + excluded.push({ + candidate, + reason: ELIGIBILITY_EXCLUSION_REASONS.CONFLICTING_SIGNALS, + }); + continue; + } + if (hasExclusion) { + excluded.push({ + candidate, + reason: ELIGIBILITY_EXCLUSION_REASONS.EXCLUSION_LABEL, + }); + continue; + } + if (!hasEligibility) { + excluded.push({ + candidate, + reason: ELIGIBILITY_EXCLUSION_REASONS.MISSING_ELIGIBILITY_LABEL, + }); + continue; + } + kept.push(candidate); + } + return { kept, excluded }; +} diff --git a/packages/loopover-miner/lib/discover-cli.d.ts b/packages/loopover-miner/lib/discover-cli.d.ts index 4d065c56d1..22530331ad 100644 --- a/packages/loopover-miner/lib/discover-cli.d.ts +++ b/packages/loopover-miner/lib/discover-cli.d.ts @@ -39,7 +39,10 @@ export type DiscoverFanOutSummary = { }; /** The subset of a ranked entry that `renderDiscoverSummary` reads for its top-candidates listing. */ -export type DiscoverRankedEntry = Pick; +export type DiscoverRankedEntry = Pick< + RankedCandidateIssue, + "repoFullName" | "issueNumber" | "title" | "rankScore" +>; export type DiscoverResult = { fanOutCount: number; @@ -47,6 +50,12 @@ export type DiscoverResult = { rateLimitRemaining: number | null; rateLimitResetAt: string | null; ranked: DiscoverRankedEntry[]; + /** Candidates the eligibility filter dropped, each with the repo/issue and the reason (#6798). */ + excluded?: Array<{ + repoFullName: string; + issueNumber: number; + reason: string; + }>; /** True when ranking fell back to the built-in default goal spec because no per-tenant spec was supplied (#4784). */ usedDefaultGoalSpec?: boolean; enqueueSummary: EnqueueRankedDiscoverySummary; @@ -89,12 +98,32 @@ export type RunDiscoverOptions = { * to (never instead of) the plain exit-code return -- mirrors `RunAttemptOptions.onResult`. Never fires on a * parse-error/unexpected-error `reportCliFailure` branch, matching runAttempt's own asymmetry (#6522). */ onResult?: (result: DiscoverResult) => void; + /** Resolve each candidate repo's ContributionProfile for eligibility filtering (#6798). Defaults to + * resolveContributionProfilesForDiscover; injectable so tests avoid the network. */ + resolveContributionProfiles?: ( + repoFullNames: string[], + ctx: { githubToken?: string; apiBaseUrl?: string; nowMs?: number }, + ) => Promise>; }; +export function resolveContributionProfilesForDiscover( + repoFullNames: string[], + ctx?: { + githubToken?: string; + apiBaseUrl?: string; + nowMs?: number; + initCache?: unknown; + extract?: unknown; + }, +): Promise>; + export function parseDiscoverArgs(args: string[]): ParsedDiscoverArgs; export function sanitizeDiscoverDisplayText(value: unknown): string; export function renderDiscoverSummary(result: DiscoverResult): string; -export function runDiscover(args: string[], options?: RunDiscoverOptions): Promise; +export function runDiscover( + args: string[], + options?: RunDiscoverOptions, +): Promise; diff --git a/packages/loopover-miner/lib/discover-cli.js b/packages/loopover-miner/lib/discover-cli.js index 2ddcd6f4fa..0ac65f0ee0 100644 --- a/packages/loopover-miner/lib/discover-cli.js +++ b/packages/loopover-miner/lib/discover-cli.js @@ -11,6 +11,9 @@ import { initPolicyVerdictCacheStore } from "./policy-verdict-cache.js"; import { enqueueRankedDiscovery } from "./portfolio-discovery.js"; import { initPortfolioQueueStore } from "./portfolio-queue.js"; import { initRankedCandidatesStore } from "./ranked-candidates.js"; +import { extractContributionProfile } from "./contribution-profile-extract.js"; +import { initContributionProfileCache } from "./contribution-profile-cache.js"; +import { filterCandidatesByProfiles } from "./contribution-profile-filter.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; const DISCOVER_USAGE = @@ -124,6 +127,14 @@ export function renderDiscoverSummary(result) { if (result.enqueueSummary.skippedBelowMinRank > 0) { lines.push(`skipped (below min rank): ${result.enqueueSummary.skippedBelowMinRank}`); } + // #6798: surface what the eligibility filter dropped and why, so a human sees AMS's inference. + const excluded = result.excluded ?? []; + if (excluded.length > 0) { + lines.push(`excluded (eligibility): ${excluded.length}`); + for (const entry of excluded.slice(0, 10)) { + lines.push(` ${entry.repoFullName}#${entry.issueNumber} ${entry.reason}`); + } + } // Make the fall-back to loopover's built-in rubric explicit instead of silent (#4784): when no per-tenant goal // spec is supplied, lane fit reflects loopover's defaults, not the target repo's own conventions. if (result.usedDefaultGoalSpec) { @@ -143,6 +154,41 @@ export function renderDiscoverSummary(result) { return lines.join("\n"); } +/** + * Default per-repo ContributionProfile resolver (#6798): reads the local cache and, on a miss/stale entry, + * extracts a fresh profile and caches it. Returns a Map keyed by repoFullName. + * + * WITHOUT a github token this returns an empty map and does no network work at all — AMS can't reliably read a + * repo's label taxonomy/docs unauthenticated (rate limits), so it safe-defaults to no eligibility filtering. + * That also keeps callers that don't supply a token (the common CLI path, and every test) hermetic. + * + * @param {string[]} repoFullNames unique repos among the fanned-out candidates + * @param {{ githubToken?: string, apiBaseUrl?: string, nowMs?: number, initCache?: typeof initContributionProfileCache, extract?: typeof extractContributionProfile }} ctx + * @returns {Promise>} + */ +export async function resolveContributionProfilesForDiscover(repoFullNames, ctx = {}) { + const profiles = new Map(); + if (!ctx.githubToken) return profiles; + const initCache = ctx.initCache ?? initContributionProfileCache; + const extract = ctx.extract ?? extractContributionProfile; + const cache = initCache(); + try { + for (const repoFullName of repoFullNames) { + const cached = cache.get(repoFullName, ctx.nowMs); + if (cached && !cached.stale) { + profiles.set(repoFullName, cached.profile); + continue; + } + const profile = await extract(repoFullName, { githubToken: ctx.githubToken, apiBaseUrl: ctx.apiBaseUrl }); + cache.put(profile, ctx.nowMs); + profiles.set(repoFullName, profile); + } + } finally { + cache.close(); + } + return profiles; +} + export async function runDiscover(args, options = {}) { const parsed = parseDiscoverArgs(args); if ("error" in parsed) { @@ -162,6 +208,9 @@ export async function runDiscover(args, options = {}) { const searchTargets = options.searchCandidateIssuesWithSummary ?? searchCandidateIssuesWithSummary; const rankIssues = options.rankCandidateIssuesWithSummary ?? rankCandidateIssuesWithSummary; const enqueue = options.enqueueRankedDiscovery ?? enqueueRankedDiscovery; + // Eligibility filtering (#6798): resolve each candidate repo's ContributionProfile and drop candidates the + // repo's own conventions would reject, BEFORE ranking. Safe by default -- see resolveContributionProfilesForDiscover. + const resolveProfiles = options.resolveContributionProfiles ?? resolveContributionProfilesForDiscover; // #4847: fetch + rank are read-only GitHub GETs and pure local computation, so a dry run still does them for // real (that's the useful "what would this discover?" output) -- but it never opens any local store (portfolio @@ -175,7 +224,12 @@ export async function runDiscover(args, options = {}) { parsed.search !== null ? await searchTargets(parsed.search, githubToken, fanOutOptions) : await fetchTargets(parsed.targets, githubToken, fanOutOptions); - const rankedSummary = rankIssues(fanOut.issues, { + // #6798: same eligibility filter as the real path, so a dry run shows the exact candidate set a real run + // would enqueue (and the same excluded set), rather than an unfiltered preview. + const repoFullNames = [...new Set(fanOut.issues.map((issue) => issue.repoFullName))]; + const profilesByRepo = await resolveProfiles(repoFullNames, { githubToken, apiBaseUrl, nowMs: options.nowMs }); + const { kept, excluded } = filterCandidatesByProfiles(fanOut.issues, profilesByRepo); + const rankedSummary = rankIssues(kept, { nowMs: options.nowMs, goalSpecsByRepo: options.goalSpecsByRepo, goalSpecContentByRepo: options.goalSpecContentByRepo, @@ -189,6 +243,11 @@ export async function runDiscover(args, options = {}) { rateLimitRemaining: fanOut.rateLimitRemaining, rateLimitResetAt: fanOut.rateLimitResetAt, ranked: rankedSummary.issues, + excluded: excluded.map((entry) => ({ + repoFullName: entry.candidate.repoFullName, + issueNumber: entry.candidate.issueNumber, + reason: entry.reason, + })), usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, enqueueSummary, }; @@ -268,10 +327,17 @@ export async function runDiscover(args, options = {}) { ? await searchTargets(parsed.search, githubToken, fanOutOptions) : await fetchTargets(parsed.targets, githubToken, fanOutOptions); + // Eligibility filter (#6798): drop candidates a target repo's own conventions would reject, before ranking. + // A repo with no trustworthy eligibility profile keeps every candidate (filterCandidatesByProfiles' safe + // default), so this never silently skips real work on a repo whose conventions AMS couldn't read. + const repoFullNames = [...new Set(fanOut.issues.map((issue) => issue.repoFullName))]; + const profilesByRepo = await resolveProfiles(repoFullNames, { githubToken, apiBaseUrl, nowMs: options.nowMs }); + const { kept, excluded } = filterCandidatesByProfiles(fanOut.issues, profilesByRepo); + // Pass any caller-supplied per-tenant goal specs through to the ranker so lane fit uses the tenant's // conventions instead of silently falling back to loopover's defaults (#4784); the fallback is surfaced via // `usedDefaultGoalSpec` below rather than hidden. - const rankedSummary = rankIssues(fanOut.issues, { + const rankedSummary = rankIssues(kept, { nowMs: options.nowMs, goalSpecsByRepo: options.goalSpecsByRepo, goalSpecContentByRepo: options.goalSpecContentByRepo, @@ -294,6 +360,13 @@ export async function runDiscover(args, options = {}) { rateLimitRemaining: fanOut.rateLimitRemaining, rateLimitResetAt: fanOut.rateLimitResetAt, ranked: rankedSummary.issues, + // #6798: candidates the eligibility filter dropped, each with the repo + issue + reason, so a human sees + // what AMS inferred and why a candidate was skipped. Empty when no profile was trustworthy enough to filter. + excluded: excluded.map((entry) => ({ + repoFullName: entry.candidate.repoFullName, + issueNumber: entry.candidate.issueNumber, + reason: entry.reason, + })), usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, enqueueSummary, }; diff --git a/packages/loopover-miner/package.json b/packages/loopover-miner/package.json index 6792bcff47..eb06c98e1b 100644 --- a/packages/loopover-miner/package.json +++ b/packages/loopover-miner/package.json @@ -38,7 +38,7 @@ "scripts": { "benchmark": "node scripts/benchmark.mjs", "cross-repo-eval": "node scripts/cross-repo-evaluation.mjs", - "build": "node --check bin/loopover-miner.js && node --check bin/loopover-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/chat-action-dispatch.js && node --check lib/chat-action-registry.js && node --check lib/chat-discover-attempt-actions.js && node --check lib/chat-governor-actions.js && node --check lib/chat-portfolio-actions.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/contribution-profile.js && node --check lib/contribution-profile-cache.js && node --check lib/contribution-profile-extract.js && node --check lib/cross-repo-evaluation.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-metrics-cli.js && node --check lib/governor-pause-cli.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/harness-submission-trigger.js && node --check lib/init-wizard.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/logger.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/purge-cli.js && node --check lib/ranked-candidates.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-bridge.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/sentry.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/loopover-miner.js && node --check bin/loopover-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/chat-action-dispatch.js && node --check lib/chat-action-registry.js && node --check lib/chat-discover-attempt-actions.js && node --check lib/chat-governor-actions.js && node --check lib/chat-portfolio-actions.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/contribution-profile.js && node --check lib/contribution-profile-cache.js && node --check lib/contribution-profile-extract.js && node --check lib/contribution-profile-filter.js && node --check lib/cross-repo-evaluation.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-metrics-cli.js && node --check lib/governor-pause-cli.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/harness-submission-trigger.js && node --check lib/init-wizard.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/logger.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/purge-cli.js && node --check lib/ranked-candidates.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-bridge.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/sentry.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@loopover/engine": "^3.0.0", diff --git a/test/unit/miner-contribution-profile-filter.test.ts b/test/unit/miner-contribution-profile-filter.test.ts new file mode 100644 index 0000000000..86e9cc919b --- /dev/null +++ b/test/unit/miner-contribution-profile-filter.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vitest"; + +import type { ContributionProfile } from "../../packages/loopover-miner/lib/contribution-profile.js"; +import { + ELIGIBILITY_EXCLUSION_REASONS, + filterCandidatesByProfiles, +} from "../../packages/loopover-miner/lib/contribution-profile-filter.js"; + +type Candidate = { + repoFullName: string; + issueNumber: number; + labels?: string[]; +}; + +const candidate = (issueNumber: number, labels: string[]): Candidate => ({ + repoFullName: "acme/widgets", + issueNumber, + labels, +}); + +/** A trustworthy (explicit-eligibility) profile whose eligibility label is `good first issue` and whose + * exclusion label is `blocked` — provenance details carry the real repo label names the filter matches on. */ +function trustworthyProfile( + over: Partial = {}, +): ContributionProfile { + return { + repoFullName: "acme/widgets", + schemaVersion: 1, + generatedAt: "2026-07-18T00:00:00.000Z", + eligibilityLabels: { + value: [{ field: "name", contains: "good first issue" }], + confidence: "explicit", + provenance: [{ source: "labels", detail: "good first issue" }], + }, + exclusionLabels: { + value: [{ field: "name", contains: "blocked" }], + confidence: "inferred", + provenance: [{ source: "labels", detail: "blocked" }], + }, + prBody: { value: null, confidence: "absent", provenance: [] }, + completeness: "inferred", + ...over, + }; +} + +const profilesFor = (profile: ContributionProfile) => + new Map([["acme/widgets", profile]]); + +describe("filterCandidatesByProfiles (#6798)", () => { + it("keeps candidates carrying an eligibility label", () => { + const { kept, excluded } = filterCandidatesByProfiles( + [candidate(1, ["good first issue"])], + profilesFor(trustworthyProfile()), + ); + expect(kept.map((c) => c.issueNumber)).toEqual([1]); + expect(excluded).toEqual([]); + }); + + it("excludes a candidate that carries an exclusion label", () => { + const { kept, excluded } = filterCandidatesByProfiles( + [candidate(1, ["good first issue", "blocked"])], + profilesFor(trustworthyProfile()), + ); + // Conflicting signals (both eligibility + exclusion) — exclusion wins, conservatively. + expect(kept).toEqual([]); + expect(excluded).toEqual([ + { + candidate: candidate(1, ["good first issue", "blocked"]), + reason: ELIGIBILITY_EXCLUSION_REASONS.CONFLICTING_SIGNALS, + }, + ]); + }); + + it("excludes an exclusion-only candidate as exclusion_label", () => { + const { excluded } = filterCandidatesByProfiles( + [candidate(2, ["blocked"])], + profilesFor(trustworthyProfile()), + ); + expect(excluded).toEqual([ + { + candidate: candidate(2, ["blocked"]), + reason: ELIGIBILITY_EXCLUSION_REASONS.EXCLUSION_LABEL, + }, + ]); + }); + + it("excludes a candidate with neither an eligibility nor exclusion label as missing_eligibility_label", () => { + const { kept, excluded } = filterCandidatesByProfiles( + [candidate(3, ["bug"])], + profilesFor(trustworthyProfile()), + ); + expect(kept).toEqual([]); + expect(excluded).toEqual([ + { + candidate: candidate(3, ["bug"]), + reason: ELIGIBILITY_EXCLUSION_REASONS.MISSING_ELIGIBILITY_LABEL, + }, + ]); + }); + + it("matches labels case-insensitively", () => { + const { kept } = filterCandidatesByProfiles( + [candidate(1, ["GOOD First Issue"])], + profilesFor(trustworthyProfile()), + ); + expect(kept.map((c) => c.issueNumber)).toEqual([1]); + }); + + it("SAFE DEFAULT: keeps everything when the profile's eligibility confidence is not explicit", () => { + // A low-confidence/absent eligibility signal must never cause a candidate to be skipped. + const absent = trustworthyProfile({ + eligibilityLabels: { value: null, confidence: "absent", provenance: [] }, + }); + const { kept, excluded } = filterCandidatesByProfiles( + [candidate(1, ["bug"]), candidate(2, ["blocked"])], + profilesFor(absent), + ); + expect(kept.map((c) => c.issueNumber)).toEqual([1, 2]); + expect(excluded).toEqual([]); + }); + + it("SAFE DEFAULT: keeps a candidate whose repo has no profile in the map", () => { + const { kept, excluded } = filterCandidatesByProfiles( + [{ repoFullName: "other/repo", issueNumber: 9, labels: ["bug"] }], + profilesFor(trustworthyProfile()), + ); + expect(kept).toHaveLength(1); + expect(excluded).toEqual([]); + }); + + it("handles a candidate with no labels field (treated as no labels ⇒ missing eligibility)", () => { + const { excluded } = filterCandidatesByProfiles( + [{ repoFullName: "acme/widgets", issueNumber: 4 }], + profilesFor(trustworthyProfile()), + ); + expect(excluded[0]?.reason).toBe( + ELIGIBILITY_EXCLUSION_REASONS.MISSING_ELIGIBILITY_LABEL, + ); + }); + + it("keeps eligible when the profile has no exclusion labels at all", () => { + const noExclusion = trustworthyProfile({ + exclusionLabels: { value: null, confidence: "absent", provenance: [] }, + }); + const { kept, excluded } = filterCandidatesByProfiles( + [candidate(1, ["good first issue"]), candidate(2, ["bug"])], + profilesFor(noExclusion), + ); + expect(kept.map((c) => c.issueNumber)).toEqual([1]); + expect(excluded.map((e) => e.reason)).toEqual([ + ELIGIBILITY_EXCLUSION_REASONS.MISSING_ELIGIBILITY_LABEL, + ]); + }); + + it("ignores a non-string label entry without matching or throwing", () => { + const { excluded } = filterCandidatesByProfiles( + [ + { + repoFullName: "acme/widgets", + issueNumber: 5, + labels: [42 as unknown as string], + }, + ], + profilesFor(trustworthyProfile()), + ); + expect(excluded[0]?.reason).toBe( + ELIGIBILITY_EXCLUSION_REASONS.MISSING_ELIGIBILITY_LABEL, + ); + }); + + it("tolerates malformed provenance (missing field / non-string detail) without throwing", () => { + // Defensive: an explicit rule with no provenance yields no label names, and a non-string detail entry is + // skipped — a corrupted/older-extractor profile degrades to "no match", never a crash. + const malformed = trustworthyProfile({ + eligibilityLabels: { + value: [{ field: "name", contains: "x" }], + confidence: "explicit", + } as never, + exclusionLabels: { + value: null, + confidence: "inferred", + provenance: [{ source: "labels", detail: 42 }], + } as never, + }); + const { excluded } = filterCandidatesByProfiles( + [candidate(1, ["good first issue"])], + profilesFor(malformed), + ); + // eligibilityNames is empty (no provenance) ⇒ nothing matches ⇒ missing_eligibility_label. + expect(excluded[0]?.reason).toBe( + ELIGIBILITY_EXCLUSION_REASONS.MISSING_ELIGIBILITY_LABEL, + ); + }); + + it("tolerates a null profilesByRepo map (keeps everything)", () => { + const { kept } = filterCandidatesByProfiles( + [candidate(1, ["bug"])], + null as never, + ); + expect(kept).toHaveLength(1); + }); +}); diff --git a/test/unit/miner-discover-cli.test.ts b/test/unit/miner-discover-cli.test.ts index 86c4261984..cbc92240e5 100644 --- a/test/unit/miner-discover-cli.test.ts +++ b/test/unit/miner-discover-cli.test.ts @@ -45,7 +45,9 @@ function tempPolicyDocCacheStore() { function tempPolicyVerdictCacheStore() { const root = mkdtempSync(join(tmpdir(), "loopover-miner-discover-cli-pvc-")); roots.push(root); - const store = initPolicyVerdictCacheStore(join(root, "policy-verdict-cache.sqlite3")); + const store = initPolicyVerdictCacheStore( + join(root, "policy-verdict-cache.sqlite3"), + ); stores.push(store); return store; } @@ -54,7 +56,9 @@ function tempPolicyVerdictCacheStore() { function tempRankedCandidatesStore() { const root = mkdtempSync(join(tmpdir(), "loopover-miner-discover-cli-rc-")); roots.push(root); - const store = initRankedCandidatesStore(join(root, "ranked-candidates.sqlite3")); + const store = initRankedCandidatesStore( + join(root, "ranked-candidates.sqlite3"), + ); stores.push(store); return store; } @@ -81,7 +85,8 @@ afterEach(() => { for (const store of stores.splice(0)) store.close(); closeDefaultPortfolioQueueStore(); vi.restoreAllMocks(); - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }); }); describe("parseDiscoverArgs (#4247)", () => { @@ -92,7 +97,9 @@ describe("parseDiscoverArgs (#4247)", () => { }); it("parses one or more owner/repo targets plus --json", () => { - expect(parseDiscoverArgs(["acme/widgets", "acme/gadgets", "--json"])).toEqual({ + expect( + parseDiscoverArgs(["acme/widgets", "acme/gadgets", "--json"]), + ).toEqual({ targets: [ { owner: "acme", repo: "widgets" }, { owner: "acme", repo: "gadgets" }, @@ -177,13 +184,17 @@ describe("parseDiscoverArgs (#4247)", () => { expect(parseDiscoverArgs(["acme/widgets", "--api-base-url"])).toEqual({ error: expect.stringContaining("Usage: loopover-miner discover"), }); - expect(parseDiscoverArgs(["acme/widgets", "--api-base-url", "--json"])).toEqual({ + expect( + parseDiscoverArgs(["acme/widgets", "--api-base-url", "--json"]), + ).toEqual({ error: expect.stringContaining("Usage: loopover-miner discover"), }); expect(parseDiscoverArgs(["acme/widgets", "--token-env"])).toEqual({ error: expect.stringContaining("Usage: loopover-miner discover"), }); - expect(parseDiscoverArgs(["acme/widgets", "--token-env", "--json"])).toEqual({ + expect( + parseDiscoverArgs(["acme/widgets", "--token-env", "--json"]), + ).toEqual({ error: expect.stringContaining("Usage: loopover-miner discover"), }); }); @@ -193,20 +204,43 @@ describe("renderDiscoverSummary (#4247)", () => { it("summarizes fan-out, ranking, and enqueue counts with top candidates", () => { const text = renderDiscoverSummary({ fanOutCount: 2, - warnings: [{ repoFullName: "acme/banned", stage: "policy:AI-USAGE.md", message: "denied" }], + warnings: [ + { + repoFullName: "acme/banned", + stage: "policy:AI-USAGE.md", + message: "denied", + }, + ], rateLimitRemaining: 4993, rateLimitResetAt: "2026-07-09T13:00:00.000Z", ranked: [ - { repoFullName: "acme/widgets", issueNumber: 1, title: "Add retry helper", rankScore: 0.8 }, - { repoFullName: "acme/widgets", issueNumber: 2, title: "Fix flaky test", rankScore: 0.4 }, + { + repoFullName: "acme/widgets", + issueNumber: 1, + title: "Add retry helper", + rankScore: 0.8, + }, + { + repoFullName: "acme/widgets", + issueNumber: 2, + title: "Fix flaky test", + rankScore: 0.4, + }, ], - enqueueSummary: { enqueued: 2, skippedBelowMinRank: 0, skippedInvalid: 0, eventsAppended: 0 }, + enqueueSummary: { + enqueued: 2, + skippedBelowMinRank: 0, + skippedInvalid: 0, + eventsAppended: 0, + }, }); expect(text).toContain("fanned out: 2 candidate issue(s)"); expect(text).toContain("ai-policy warnings: 1"); expect(text).toContain("ranked: 2"); expect(text).toContain("enqueued: 2"); - expect(text).toContain("rate-limit remaining: 4993 (resets 2026-07-09T13:00:00.000Z)"); + expect(text).toContain( + "rate-limit remaining: 4993 (resets 2026-07-09T13:00:00.000Z)", + ); expect(text).toContain("acme/widgets#1 score=0.8000 Add retry helper"); expect(text).not.toContain("skipped (below min rank)"); }); @@ -226,19 +260,28 @@ describe("renderDiscoverSummary (#4247)", () => { rankScore: 0.8, }, ], - enqueueSummary: { enqueued: 1, skippedBelowMinRank: 0, skippedInvalid: 0, eventsAppended: 0 }, + enqueueSummary: { + enqueued: 1, + skippedBelowMinRank: 0, + skippedInvalid: 0, + eventsAppended: 0, + }, }); expect(text).toContain("normal SPOOFED: enqueued: 999 red CLICK codexe"); expect(text).not.toContain("\u001b"); expect(text).not.toContain("\u0007"); expect(text).not.toContain("\u202e"); - expect(text.split("\n").filter((line) => line.includes("SPOOFED"))).toHaveLength(1); + expect( + text.split("\n").filter((line) => line.includes("SPOOFED")), + ).toHaveLength(1); }); it("bounds sanitized title display text and handles nullish values", () => { expect(sanitizeDiscoverDisplayText(null)).toBe(""); - expect(sanitizeDiscoverDisplayText(`safe ${"x".repeat(300)}`)).toHaveLength(240); + expect(sanitizeDiscoverDisplayText(`safe ${"x".repeat(300)}`)).toHaveLength( + 240, + ); }); it("reports skipped-below-min-rank counts and an empty-result message", () => { @@ -247,8 +290,20 @@ describe("renderDiscoverSummary (#4247)", () => { warnings: [], rateLimitRemaining: null, rateLimitResetAt: null, - ranked: [{ repoFullName: "acme/widgets", issueNumber: 1, title: "x", rankScore: 0.1 }], - enqueueSummary: { enqueued: 0, skippedBelowMinRank: 1, skippedInvalid: 0, eventsAppended: 0 }, + ranked: [ + { + repoFullName: "acme/widgets", + issueNumber: 1, + title: "x", + rankScore: 0.1, + }, + ], + enqueueSummary: { + enqueued: 0, + skippedBelowMinRank: 1, + skippedInvalid: 0, + eventsAppended: 0, + }, }); expect(withSkips).toContain("skipped (below min rank): 1"); @@ -258,7 +313,12 @@ describe("renderDiscoverSummary (#4247)", () => { rateLimitRemaining: null, rateLimitResetAt: null, ranked: [], - enqueueSummary: { enqueued: 0, skippedBelowMinRank: 0, skippedInvalid: 0, eventsAppended: 0 }, + enqueueSummary: { + enqueued: 0, + skippedBelowMinRank: 0, + skippedInvalid: 0, + eventsAppended: 0, + }, }); expect(empty).toContain("no candidates found."); // Without the flag the fall-back note is absent (the default-goal-spec branch is opt-in on the result). @@ -271,9 +331,21 @@ describe("renderDiscoverSummary (#4247)", () => { warnings: [], rateLimitRemaining: null, rateLimitResetAt: null, - ranked: [{ repoFullName: "acme/widgets", issueNumber: 1, title: "x", rankScore: 0.8 }], + ranked: [ + { + repoFullName: "acme/widgets", + issueNumber: 1, + title: "x", + rankScore: 0.8, + }, + ], usedDefaultGoalSpec: true, - enqueueSummary: { enqueued: 1, skippedBelowMinRank: 0, skippedInvalid: 0, eventsAppended: 0 }, + enqueueSummary: { + enqueued: 1, + skippedBelowMinRank: 0, + skippedInvalid: 0, + eventsAppended: 0, + }, }); expect(text).toContain("ranked with the built-in default goal spec"); }); @@ -285,9 +357,16 @@ describe("renderDiscoverSummary (#4247)", () => { rateLimitRemaining: 12, rateLimitResetAt: "2026-07-09T13:30:00.000Z", ranked: [], - enqueueSummary: { enqueued: 0, skippedBelowMinRank: 0, skippedInvalid: 0, eventsAppended: 0 }, + enqueueSummary: { + enqueued: 0, + skippedBelowMinRank: 0, + skippedInvalid: 0, + eventsAppended: 0, + }, }); - expect(withTelemetry).toContain("rate-limit remaining: 12 (resets 2026-07-09T13:30:00.000Z)"); + expect(withTelemetry).toContain( + "rate-limit remaining: 12 (resets 2026-07-09T13:30:00.000Z)", + ); // A remaining count of zero must still print the number, not fall through to "unknown". const throttled = renderDiscoverSummary({ @@ -296,7 +375,12 @@ describe("renderDiscoverSummary (#4247)", () => { rateLimitRemaining: 0, rateLimitResetAt: null, ranked: [], - enqueueSummary: { enqueued: 0, skippedBelowMinRank: 0, skippedInvalid: 0, eventsAppended: 0 }, + enqueueSummary: { + enqueued: 0, + skippedBelowMinRank: 0, + skippedInvalid: 0, + eventsAppended: 0, + }, }); expect(throttled).toContain("rate-limit remaining: 0"); expect(throttled).not.toContain("resets"); @@ -307,7 +391,12 @@ describe("renderDiscoverSummary (#4247)", () => { rateLimitRemaining: null, rateLimitResetAt: null, ranked: [], - enqueueSummary: { enqueued: 0, skippedBelowMinRank: 0, skippedInvalid: 0, eventsAppended: 0 }, + enqueueSummary: { + enqueued: 0, + skippedBelowMinRank: 0, + skippedInvalid: 0, + eventsAppended: 0, + }, }); expect(noTelemetry).toContain("rate-limit remaining: unknown"); }); @@ -318,8 +407,16 @@ describe("runDiscover (#4247)", () => { const portfolioQueue = tempQueueStore(); const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ issues: [ - fanOutIssue({ issueNumber: 1, title: "Add retry helper", labels: ["help wanted", "feature"] }), - fanOutIssue({ issueNumber: 2, title: "Fix flaky test", labels: ["help wanted"] }), + fanOutIssue({ + issueNumber: 1, + title: "Add retry helper", + labels: ["help wanted", "feature"], + }), + fanOutIssue({ + issueNumber: 2, + title: "Fix flaky test", + labels: ["help wanted"], + }), ], warnings: [], rateLimitRemaining: 4987, @@ -351,13 +448,18 @@ describe("runDiscover (#4247)", () => { const payload = JSON.parse(String(log.mock.calls[0]?.[0])); expect(payload.fanOutCount).toBe(2); expect(payload.enqueueSummary.enqueued).toBe(2); - expect(payload.ranked.map((entry: { issueNumber: number }) => entry.issueNumber)).toEqual([1, 2]); + expect( + payload.ranked.map((entry: { issueNumber: number }) => entry.issueNumber), + ).toEqual([1, 2]); // The fanout's rate-limit telemetry is surfaced verbatim in --json output (#4837). expect(payload.rateLimitRemaining).toBe(4987); expect(payload.rateLimitResetAt).toBe("2026-07-09T13:00:00.000Z"); const queued = portfolioQueue.listQueue("acme/widgets"); - expect(queued.map((entry) => entry.identifier).sort()).toEqual(["issue:1", "issue:2"]); + expect(queued.map((entry) => entry.identifier).sort()).toEqual([ + "issue:1", + "issue:2", + ]); }); it("#4847: --dry-run performs the real fan-out/rank but never opens any local store", async () => { @@ -365,25 +467,33 @@ describe("runDiscover (#4247)", () => { const initPolicyDocCache = vi.fn(); const initPolicyVerdictCache = vi.fn(); const initRankedCandidatesStore = vi.fn(); - const fetchCandidateIssuesWithSummary = vi.fn(async (targets, token, fanOutOptions) => { - expect(fanOutOptions).toMatchObject({ policyDocCache: null, policyVerdictCache: null }); - return { - issues: [fanOutIssue({ issueNumber: 1, title: "Add retry helper" })], - warnings: [], - rateLimitRemaining: 4990, - rateLimitResetAt: "2026-07-09T13:00:00.000Z", - }; - }); + const fetchCandidateIssuesWithSummary = vi.fn( + async (targets, token, fanOutOptions) => { + expect(fanOutOptions).toMatchObject({ + policyDocCache: null, + policyVerdictCache: null, + }); + return { + issues: [fanOutIssue({ issueNumber: 1, title: "Add retry helper" })], + warnings: [], + rateLimitRemaining: 4990, + rateLimitResetAt: "2026-07-09T13:00:00.000Z", + }; + }, + ); const log = vi.spyOn(console, "log").mockImplementation(() => undefined); - const exitCode = await runDiscover(["acme/widgets", "--dry-run", "--json"], { - nowMs: NOW, - initPortfolioQueue, - initPolicyDocCache, - initPolicyVerdictCache, - initRankedCandidatesStore, - fetchCandidateIssuesWithSummary, - }); + const exitCode = await runDiscover( + ["acme/widgets", "--dry-run", "--json"], + { + nowMs: NOW, + initPortfolioQueue, + initPolicyDocCache, + initPolicyVerdictCache, + initRankedCandidatesStore, + fetchCandidateIssuesWithSummary, + }, + ); expect(exitCode).toBe(0); expect(initPortfolioQueue).not.toHaveBeenCalled(); @@ -394,7 +504,9 @@ describe("runDiscover (#4247)", () => { expect(payload.outcome).toBe("dry_run"); expect(payload.fanOutCount).toBe(1); expect(payload.enqueueSummary.enqueued).toBe(1); - expect(payload.ranked.map((entry: { issueNumber: number }) => entry.issueNumber)).toEqual([1]); + expect( + payload.ranked.map((entry: { issueNumber: number }) => entry.issueNumber), + ).toEqual([1]); log.mockClear(); const textExitCode = await runDiscover(["acme/widgets", "--dry-run"], { @@ -406,7 +518,9 @@ describe("runDiscover (#4247)", () => { fetchCandidateIssuesWithSummary, }); expect(textExitCode).toBe(0); - expect(String(log.mock.calls[1]?.[0])).toContain("DRY RUN: no portfolio-queue write was made."); + expect(String(log.mock.calls[1]?.[0])).toContain( + "DRY RUN: no portfolio-queue write was made.", + ); }); it("#4847: --dry-run reports fan-out failures and exits non-zero without opening any local store", async () => { @@ -414,7 +528,9 @@ describe("runDiscover (#4247)", () => { const fetchCandidateIssuesWithSummary = vi.fn(async () => { throw new Error("github_unreachable"); }); - const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const error = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); const exitCode = await runDiscover(["acme/widgets", "--dry-run"], { nowMs: NOW, @@ -434,11 +550,14 @@ describe("runDiscover (#4247)", () => { }); const log = vi.spyOn(console, "log").mockImplementation(() => undefined); - const exitCode = await runDiscover(["acme/widgets", "--dry-run", "--json"], { - nowMs: NOW, - initPortfolioQueue, - fetchCandidateIssuesWithSummary, - }); + const exitCode = await runDiscover( + ["acme/widgets", "--dry-run", "--json"], + { + nowMs: NOW, + initPortfolioQueue, + fetchCandidateIssuesWithSummary, + }, + ); expect(exitCode).toBe(2); expect(initPortfolioQueue).not.toHaveBeenCalled(); @@ -450,9 +569,14 @@ describe("runDiscover (#4247)", () => { const fetchCandidateIssuesWithSummary = vi.fn(async () => { throw "raw_string_fault"; }); - const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const error = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); - const exitCode = await runDiscover(["acme/widgets", "--dry-run"], { nowMs: NOW, fetchCandidateIssuesWithSummary }); + const exitCode = await runDiscover(["acme/widgets", "--dry-run"], { + nowMs: NOW, + fetchCandidateIssuesWithSummary, + }); expect(exitCode).toBe(2); expect(error).toHaveBeenCalledWith("raw_string_fault"); @@ -471,12 +595,15 @@ describe("runDiscover (#4247)", () => { }); const log = vi.spyOn(console, "log").mockImplementation(() => undefined); - const exitCode = await runDiscover(["--search", "label:bug", "--dry-run", "--json"], { - nowMs: NOW, - initPortfolioQueue, - searchCandidateIssuesWithSummary, - fetchCandidateIssuesWithSummary, - }); + const exitCode = await runDiscover( + ["--search", "label:bug", "--dry-run", "--json"], + { + nowMs: NOW, + initPortfolioQueue, + searchCandidateIssuesWithSummary, + fetchCandidateIssuesWithSummary, + }, + ); expect(exitCode).toBe(0); expect(initPortfolioQueue).not.toHaveBeenCalled(); @@ -510,7 +637,11 @@ describe("runDiscover (#4247)", () => { }); expect(exitCode).toBe(0); - expect(searchCandidateIssuesWithSummary).toHaveBeenCalledWith("label:bug", "", expect.objectContaining({})); + expect(searchCandidateIssuesWithSummary).toHaveBeenCalledWith( + "label:bug", + "", + expect.objectContaining({}), + ); expect(fetchCandidateIssuesWithSummary).not.toHaveBeenCalled(); expect(String(log.mock.calls[0]?.[0])).toContain("Result for label:bug"); }); @@ -537,7 +668,9 @@ describe("runDiscover (#4247)", () => { expect(exitCode).toBe(0); const text = String(log.mock.calls[0]?.[0]); expect(text).toContain("fanned out: 1 candidate issue(s)"); - expect(text).toContain("rate-limit remaining: 3200 (resets 2026-07-09T13:00:00.000Z)"); + expect(text).toContain( + "rate-limit remaining: 3200 (resets 2026-07-09T13:00:00.000Z)", + ); expect(text).toContain("top candidates:"); }); @@ -545,17 +678,23 @@ describe("runDiscover (#4247)", () => { const initPortfolioQueue = vi.fn(() => { throw new Error("must not open the queue on a parse error"); }); - const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const error = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); const exitCode = await runDiscover(["not-a-repo"], { initPortfolioQueue }); expect(exitCode).toBe(2); expect(initPortfolioQueue).not.toHaveBeenCalled(); - expect(error).toHaveBeenCalledWith("Repository must be in owner/repo form: not-a-repo"); + expect(error).toHaveBeenCalledWith( + "Repository must be in owner/repo form: not-a-repo", + ); }); it("emits JSON when portfolio queue open fails with --json (#4836)", async () => { const log = vi.spyOn(console, "log").mockImplementation(() => undefined); - const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const error = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); const exitCode = await runDiscover(["acme/widgets", "--json"], { initPortfolioQueue: () => { throw new Error("portfolio_db_locked"); @@ -574,7 +713,9 @@ describe("runDiscover (#4247)", () => { const fetchCandidateIssuesWithSummary = vi.fn(async () => { throw new Error("github_unreachable"); }); - const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const error = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); const exitCode = await runDiscover(["acme/widgets"], { initPortfolioQueue: () => portfolioQueue, @@ -589,7 +730,9 @@ describe("runDiscover (#4247)", () => { }); it("opens and closes the default on-disk portfolio queue when no override is supplied", async () => { - const root = mkdtempSync(join(tmpdir(), "loopover-miner-discover-cli-default-")); + const root = mkdtempSync( + join(tmpdir(), "loopover-miner-discover-cli-default-"), + ); roots.push(root); const dbPath = join(root, "portfolio-queue.sqlite3"); const previousDbPath = process.env.LOOPOVER_MINER_PORTFOLIO_QUEUE_DB; @@ -616,9 +759,12 @@ describe("runDiscover (#4247)", () => { // the same file confirms the enqueue was actually persisted through the default code path. const reopened = initPortfolioQueueStore(dbPath); stores.push(reopened); - expect(reopened.listQueue().map((entry) => entry.identifier)).toEqual(["issue:5"]); + expect(reopened.listQueue().map((entry) => entry.identifier)).toEqual([ + "issue:5", + ]); } finally { - if (previousDbPath === undefined) delete process.env.LOOPOVER_MINER_PORTFOLIO_QUEUE_DB; + if (previousDbPath === undefined) + delete process.env.LOOPOVER_MINER_PORTFOLIO_QUEUE_DB; else process.env.LOOPOVER_MINER_PORTFOLIO_QUEUE_DB = previousDbPath; } }); @@ -637,7 +783,13 @@ describe("runDiscover (#4247)", () => { vi.spyOn(console, "log").mockImplementation(() => undefined); const exitCode = await runDiscover( - ["acme/widgets", "--api-base-url", "https://ghe.example.com/api/v3", "--token-env", "FORGE_PAT"], + [ + "acme/widgets", + "--api-base-url", + "https://ghe.example.com/api/v3", + "--token-env", + "FORGE_PAT", + ], { nowMs: NOW, initPortfolioQueue: () => portfolioQueue, @@ -652,12 +804,16 @@ describe("runDiscover (#4247)", () => { expect(fetchCandidateIssuesWithSummary).toHaveBeenCalledWith( [{ owner: "acme", repo: "widgets" }], "tenant-secret", - expect.objectContaining({ apiBaseUrl: "https://ghe.example.com/api/v3" }), + expect.objectContaining({ + apiBaseUrl: "https://ghe.example.com/api/v3", + }), ); // REGRESSION (#5563): the enqueued portfolio-queue row itself carries the resolved forge host, not just // the fan-out call — otherwise a same-named repo on github.com would collide with this GHE tenant's row. expect(portfolioQueue.listQueue()).toEqual([ - expect.objectContaining({ apiBaseUrl: "https://ghe.example.com/api/v3" }), + expect.objectContaining({ + apiBaseUrl: "https://ghe.example.com/api/v3", + }), ]); } finally { if (previous === undefined) delete process.env.FORGE_PAT; @@ -720,13 +876,17 @@ describe("runDiscover (#4247)", () => { githubToken: "explicit-token", apiBaseUrl: "https://programmatic.example.com", tokenEnv: "IGNORED_BECAUSE_TOKEN_IS_EXPLICIT", + // A token is set, so the default profile resolver would otherwise reach the network — no-op it (#6798). + resolveContributionProfiles: async () => new Map(), }); expect(exitCode).toBe(0); expect(fetchCandidateIssuesWithSummary).toHaveBeenCalledWith( [{ owner: "acme", repo: "widgets" }], "explicit-token", - expect.objectContaining({ apiBaseUrl: "https://programmatic.example.com" }), + expect.objectContaining({ + apiBaseUrl: "https://programmatic.example.com", + }), ); }); @@ -778,7 +938,9 @@ describe("runDiscover (#4247)", () => { }); it("opens and closes the default on-disk policy-doc cache when no override is supplied", async () => { - const root = mkdtempSync(join(tmpdir(), "loopover-miner-discover-cli-pdc-default-")); + const root = mkdtempSync( + join(tmpdir(), "loopover-miner-discover-cli-pdc-default-"), + ); roots.push(root); const cacheDbPath = join(root, "policy-doc-cache.sqlite3"); const previousCacheDbPath = process.env.LOOPOVER_MINER_POLICY_DOC_CACHE_DB; @@ -807,9 +969,14 @@ describe("runDiscover (#4247)", () => { const reopened = initPolicyDocCacheStore(cacheDbPath); stores.push(reopened); - expect(reopened.get("https://api.github.com/repos/acme/widgets/contents/AI-USAGE.md")).toBeNull(); + expect( + reopened.get( + "https://api.github.com/repos/acme/widgets/contents/AI-USAGE.md", + ), + ).toBeNull(); } finally { - if (previousCacheDbPath === undefined) delete process.env.LOOPOVER_MINER_POLICY_DOC_CACHE_DB; + if (previousCacheDbPath === undefined) + delete process.env.LOOPOVER_MINER_POLICY_DOC_CACHE_DB; else process.env.LOOPOVER_MINER_POLICY_DOC_CACHE_DB = previousCacheDbPath; } }); @@ -848,10 +1015,13 @@ describe("runDiscover (#4247)", () => { }); it("opens and closes the default on-disk policy-verdict cache when no override is supplied", async () => { - const root = mkdtempSync(join(tmpdir(), "loopover-miner-discover-cli-pvc-default-")); + const root = mkdtempSync( + join(tmpdir(), "loopover-miner-discover-cli-pvc-default-"), + ); roots.push(root); const cacheDbPath = join(root, "policy-verdict-cache.sqlite3"); - const previousCacheDbPath = process.env.LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB; + const previousCacheDbPath = + process.env.LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB; process.env.LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB = cacheDbPath; try { const portfolioQueue = tempQueueStore(); @@ -879,8 +1049,11 @@ describe("runDiscover (#4247)", () => { stores.push(reopened); expect(reopened.get("acme/widgets")).toBeNull(); } finally { - if (previousCacheDbPath === undefined) delete process.env.LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB; - else process.env.LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB = previousCacheDbPath; + if (previousCacheDbPath === undefined) + delete process.env.LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB; + else + process.env.LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB = + previousCacheDbPath; } }); @@ -943,7 +1116,9 @@ describe("runDiscover (#4247)", () => { expect(exitCode).toBe(0); const snapshot = rankedCandidatesStore.listRankedCandidates(); expect(snapshot.map((entry) => entry.issueNumber).sort()).toEqual([1, 2]); - expect(snapshot.every((entry) => entry.rankedAt === new Date(NOW).toISOString())).toBe(true); + expect( + snapshot.every((entry) => entry.rankedAt === new Date(NOW).toISOString()), + ).toBe(true); // Every field opportunity-badge.js's badge needs must survive the round trip, not just rankScore. expect(snapshot[0]).toMatchObject({ repoFullName: "acme/widgets", @@ -958,7 +1133,9 @@ describe("runDiscover (#4247)", () => { }); it("opens and closes the default on-disk ranked-candidates store when no override is supplied", async () => { - const root = mkdtempSync(join(tmpdir(), "loopover-miner-discover-cli-rc-default-")); + const root = mkdtempSync( + join(tmpdir(), "loopover-miner-discover-cli-rc-default-"), + ); roots.push(root); const rankedCandidatesDbPath = join(root, "ranked-candidates.sqlite3"); const previousDbPath = process.env.LOOPOVER_MINER_RANKED_CANDIDATES_DB; @@ -989,7 +1166,8 @@ describe("runDiscover (#4247)", () => { stores.push(reopened); expect(reopened.listRankedCandidates()).toHaveLength(1); } finally { - if (previousDbPath === undefined) delete process.env.LOOPOVER_MINER_RANKED_CANDIDATES_DB; + if (previousDbPath === undefined) + delete process.env.LOOPOVER_MINER_RANKED_CANDIDATES_DB; else process.env.LOOPOVER_MINER_RANKED_CANDIDATES_DB = previousDbPath; } }); @@ -1070,7 +1248,13 @@ describe("runDiscover onResult hook (#6522)", () => { it("fires options.onResult with the structured result at the full-run success point, alongside exit 0", async () => { const portfolioQueue = tempQueueStore(); const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ - issues: [fanOutIssue({ issueNumber: 1, title: "Add retry helper", labels: ["help wanted", "feature"] })], + issues: [ + fanOutIssue({ + issueNumber: 1, + title: "Add retry helper", + labels: ["help wanted", "feature"], + }), + ], warnings: [], rateLimitRemaining: 4987, rateLimitResetAt: "2026-07-09T13:00:00.000Z", @@ -1091,7 +1275,10 @@ describe("runDiscover onResult hook (#6522)", () => { expect(exitCode).toBe(0); // additive: the exit code is unchanged by the hook expect(onResult).toHaveBeenCalledTimes(1); expect(onResult).toHaveBeenCalledWith( - expect.objectContaining({ fanOutCount: 1, enqueueSummary: expect.objectContaining({ enqueued: 1 }) }), + expect.objectContaining({ + fanOutCount: 1, + enqueueSummary: expect.objectContaining({ enqueued: 1 }), + }), ); }); @@ -1113,7 +1300,9 @@ describe("runDiscover onResult hook (#6522)", () => { expect(exitCode).toBe(0); expect(onResult).toHaveBeenCalledTimes(1); - expect(onResult).toHaveBeenCalledWith(expect.objectContaining({ outcome: "dry_run", fanOutCount: 1 })); + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ outcome: "dry_run", fanOutCount: 1 }), + ); }); it("REGRESSION: onResult never fires on the parse-error reportCliFailure branch, and the non-zero exit is unchanged", async () => { @@ -1125,4 +1314,242 @@ describe("runDiscover onResult hook (#6522)", () => { expect(exitCode).not.toBe(0); expect(onResult).not.toHaveBeenCalled(); }); + + describe("eligibility filtering (#6798)", () => { + const trustworthyProfile = { + repoFullName: "acme/widgets", + schemaVersion: 1, + generatedAt: "2026-07-18T00:00:00.000Z", + eligibilityLabels: { + value: [{ field: "name", contains: "help wanted" }], + confidence: "explicit", + provenance: [{ source: "labels", detail: "help wanted" }], + }, + exclusionLabels: { + value: [{ field: "name", contains: "blocked" }], + confidence: "inferred", + provenance: [{ source: "labels", detail: "blocked" }], + }, + prBody: { value: null, confidence: "absent", provenance: [] }, + completeness: "inferred", + }; + + function discoverWith( + issues: ReturnType[], + profilesByRepo: Map | null, + ) { + const portfolioQueue = tempQueueStore(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues, + warnings: [], + rateLimitRemaining: null, + rateLimitResetAt: null, + })); + return { + portfolioQueue, + fetchCandidateIssuesWithSummary, + opts: { + nowMs: NOW, + initPortfolioQueue: () => portfolioQueue, + initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), + fetchCandidateIssuesWithSummary, + resolveContributionProfiles: async () => profilesByRepo ?? new Map(), + }, + }; + } + + it("excludes maintainer-only-equivalent issues, enqueuing only the eligible ones", async () => { + const issues = [ + fanOutIssue({ issueNumber: 1, labels: ["help wanted"] }), // eligible + fanOutIssue({ issueNumber: 2, labels: ["blocked"] }), // exclusion label + fanOutIssue({ issueNumber: 3, labels: ["bug"] }), // missing eligibility + ]; + const { portfolioQueue, opts } = discoverWith( + issues, + new Map([["acme/widgets", trustworthyProfile]]), + ); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const exitCode = await runDiscover(["acme/widgets", "--json"], opts); + expect(exitCode).toBe(0); + + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + expect( + payload.ranked.map((e: { issueNumber: number }) => e.issueNumber), + ).toEqual([1]); + expect(payload.excluded).toEqual([ + { + repoFullName: "acme/widgets", + issueNumber: 2, + reason: "exclusion_label", + }, + { + repoFullName: "acme/widgets", + issueNumber: 3, + reason: "missing_eligibility_label", + }, + ]); + // Only the eligible issue is actually enqueued. + expect( + portfolioQueue.listQueue("acme/widgets").map((e) => e.identifier), + ).toEqual(["issue:1"]); + }); + + it("SAFE DEFAULT: filters nothing for a low-confidence/empty profile", async () => { + const emptyProfile = { + ...trustworthyProfile, + eligibilityLabels: { + value: null, + confidence: "absent", + provenance: [], + }, + }; + const issues = [ + fanOutIssue({ issueNumber: 1, labels: ["bug"] }), + fanOutIssue({ issueNumber: 2, labels: ["blocked"] }), + ]; + const { opts } = discoverWith( + issues, + new Map([["acme/widgets", emptyProfile]]), + ); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + await runDiscover(["acme/widgets", "--json"], opts); + + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + // Nothing excluded — a repo whose conventions AMS couldn't read never has real work silently skipped. + expect(payload.excluded).toEqual([]); + expect( + payload.ranked + .map((e: { issueNumber: number }) => e.issueNumber) + .sort(), + ).toEqual([1, 2]); + }); + + it("resolves conflicting eligibility+exclusion signals conservatively (exclusion wins)", async () => { + const issues = [ + fanOutIssue({ issueNumber: 1, labels: ["help wanted", "blocked"] }), + ]; + const { opts } = discoverWith( + issues, + new Map([["acme/widgets", trustworthyProfile]]), + ); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + await runDiscover(["acme/widgets", "--json"], opts); + + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(payload.ranked).toEqual([]); + expect(payload.excluded).toEqual([ + { + repoFullName: "acme/widgets", + issueNumber: 1, + reason: "conflicting_signals", + }, + ]); + }); + + it("surfaces the excluded set in plain-text output too", async () => { + const issues = [ + fanOutIssue({ issueNumber: 1, labels: ["help wanted"] }), + fanOutIssue({ issueNumber: 2, labels: ["blocked"] }), + ]; + const { opts } = discoverWith( + issues, + new Map([["acme/widgets", trustworthyProfile]]), + ); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + await runDiscover(["acme/widgets"], opts); + expect(String(log.mock.calls[0]?.[0])).toMatch( + /excluded \(eligibility\): 1\n {2}acme\/widgets#2 {2}exclusion_label/, + ); + }); + + it("applies the same eligibility filter on a --dry-run, surfacing the excluded set without enqueueing", async () => { + const issues = [ + fanOutIssue({ issueNumber: 1, labels: ["help wanted"] }), + fanOutIssue({ issueNumber: 2, labels: ["blocked"] }), + ]; + const { portfolioQueue, opts } = discoverWith( + issues, + new Map([["acme/widgets", trustworthyProfile]]), + ); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + await runDiscover(["acme/widgets", "--dry-run", "--json"], opts); + + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(payload.outcome).toBe("dry_run"); + expect( + payload.ranked.map((e: { issueNumber: number }) => e.issueNumber), + ).toEqual([1]); + expect(payload.excluded).toEqual([ + { + repoFullName: "acme/widgets", + issueNumber: 2, + reason: "exclusion_label", + }, + ]); + // Dry run writes nothing to the real queue. + expect(portfolioQueue.listQueue("acme/widgets")).toEqual([]); + }); + + it("the default resolver returns an empty map (no filtering, no network) when no token is set", async () => { + const { resolveContributionProfilesForDiscover } = + await import("../../packages/loopover-miner/lib/discover-cli.js"); + const fetchImpl = vi.fn(); + const profiles = await resolveContributionProfilesForDiscover( + ["acme/widgets"], + { githubToken: "", extract: fetchImpl as never }, + ); + expect(profiles.size).toBe(0); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("the default resolver reads the cache and extracts on a miss when a token is present", async () => { + const { resolveContributionProfilesForDiscover } = + await import("../../packages/loopover-miner/lib/discover-cli.js"); + const cache = { get: vi.fn(() => null), put: vi.fn(), close: vi.fn() }; + const extract = vi.fn(async (repoFullName: string) => ({ + ...trustworthyProfile, + repoFullName, + })); + const profiles = await resolveContributionProfilesForDiscover( + ["acme/widgets"], + { + githubToken: "tok", + initCache: (() => cache) as never, + extract: extract as never, + }, + ); + expect(extract).toHaveBeenCalledWith( + "acme/widgets", + expect.objectContaining({ githubToken: "tok" }), + ); + expect(cache.put).toHaveBeenCalled(); + expect(cache.close).toHaveBeenCalled(); + expect(profiles.get("acme/widgets")).toMatchObject({ + repoFullName: "acme/widgets", + }); + }); + + it("the default resolver serves a fresh cached profile without re-extracting", async () => { + const { resolveContributionProfilesForDiscover } = + await import("../../packages/loopover-miner/lib/discover-cli.js"); + const cache = { + get: vi.fn(() => ({ + profile: { ...trustworthyProfile }, + fetchedAt: "x", + stale: false, + })), + put: vi.fn(), + close: vi.fn(), + }; + const extract = vi.fn(); + await resolveContributionProfilesForDiscover(["acme/widgets"], { + githubToken: "tok", + initCache: (() => cache) as never, + extract: extract as never, + }); + expect(extract).not.toHaveBeenCalled(); + }); + }); });