diff --git a/packages/loopover-miner/lib/idea-feasibility.d.ts b/packages/loopover-miner/lib/idea-feasibility.d.ts new file mode 100644 index 0000000000..9f468155d0 --- /dev/null +++ b/packages/loopover-miner/lib/idea-feasibility.d.ts @@ -0,0 +1,51 @@ +import type { + FeasibilityClaimStatus, + FeasibilityDuplicateClusterRisk, + FeasibilityGateInput, + FeasibilityGateResult, + FeasibilityIssueStatus, + FeasibilityVerdict, +} from "@loopover/engine"; + +/** A schema-validated idea submission (#4779). This structural gate only reads `acceptanceHints`, but accepts + * the full submission so callers can pass the idea through unchanged. */ +export type IdeaFeasibilityInput = { + title?: string | undefined; + body?: string | undefined; + targetRepo?: string | undefined; + constraints?: readonly string[] | undefined; + acceptanceHints?: readonly string[] | undefined; + priority?: "normal" | "high" | undefined; +}; + +/** Objectively-resolved intake signals for the idea (resolved by the caller, never guessed from prose). */ +export type ResolvedIdeaSignals = { + targetResolvable: boolean; + claimStatus: FeasibilityClaimStatus; + duplicateClusterRisk: FeasibilityDuplicateClusterRisk; +}; + +export type AssessIdeaFeasibilityOptions = { + buildFeasibilityVerdict?: (input: FeasibilityGateInput) => FeasibilityGateResult; +}; + +export type IdeaFeasibilityDisposition = "proceed" | "flag" | "reject"; + +export type IdeaFeasibilityResult = { + disposition: IdeaFeasibilityDisposition; + verdict: FeasibilityVerdict; + issueStatus: FeasibilityIssueStatus; + reasons: string[]; + summary: string; +}; + +export function deriveIdeaIssueStatus( + idea: IdeaFeasibilityInput, + resolved: Pick, +): FeasibilityIssueStatus; + +export function assessIdeaFeasibility( + idea: IdeaFeasibilityInput, + resolved: ResolvedIdeaSignals, + options?: AssessIdeaFeasibilityOptions, +): IdeaFeasibilityResult; diff --git a/packages/loopover-miner/lib/idea-feasibility.js b/packages/loopover-miner/lib/idea-feasibility.js new file mode 100644 index 0000000000..0c6d24263f --- /dev/null +++ b/packages/loopover-miner/lib/idea-feasibility.js @@ -0,0 +1,68 @@ +/** Pre-execution feasibility check for a freeform Rent-a-Loop idea (#5671). + * + * Runs post-schema-validation and pre-compute-allocation on an idea submission (the intake shape defined in + * #4779), so a customer can no longer burn paid or free-trial compute on an idea that was never going to + * succeed. It is the freeform-text counterpart to the metadata `feasibility` CLI (`feasibility-cli.js`, #4270). + * + * REUSED from feasibility-cli.js AS-IS: + * - the engine's pure `buildFeasibilityVerdict` composer and its `avoid > raise > go` precedence — an idea + * inherits exactly the same verdict machinery a metadata-resolved issue does, so there is no second, + * divergent decision surface; + * - the injectable-verdict test seam (`options.buildFeasibilityVerdict`), matching the CLI's convention. + * + * NEW for freeform text (#5671, per the #4779 rubric): + * - `deriveIdeaIssueStatus`, which computes the `issueStatus` discriminant from the idea's OWN structure + * instead of a resolved GitHub issue. An idea with no objective success signal is `invalid` (impossible to + * evaluate objectively) and is rejected before compute; an unresolvable target repo is `missing` (out of the + * loop's scope) and is flagged. + * + * OUT OF SCOPE (stays with #5136): judging abusive/illegal or semantically off-topic intent from prose — that is + * a content-moderation policy call, not this deterministic structural gate. + */ +import { buildFeasibilityVerdict } from "@loopover/engine"; + +/** Verdict → caller-facing disposition. `go` proceeds to compute; `raise`/`avoid` gate it. */ +const DISPOSITION_BY_VERDICT = { go: "proceed", raise: "flag", avoid: "reject" }; + +/** + * Derive the feasibility `issueStatus` for a freeform idea from objective, structural signals only — never from + * a semantic read of the prose. + * + * @param {{ acceptanceHints?: readonly string[] }} idea schema-validated idea submission (#4779) + * @param {{ targetResolvable: boolean }} resolved objectively-resolved intake signals + * @returns {"missing" | "invalid" | "ready"} + */ +export function deriveIdeaIssueStatus(idea, resolved) { + // Out of the loop's scope: the idea does not resolve to a repo the loop can act on. + if (!resolved.targetResolvable) return "missing"; + // Impossible to evaluate objectively: no declared success signal, so the loop could never test its own output. + const objectiveSignals = idea.acceptanceHints?.length ?? 0; + if (objectiveSignals === 0) return "invalid"; + return "ready"; +} + +/** + * Assess a schema-validated idea's feasibility before compute is allocated. + * + * @param {{ acceptanceHints?: readonly string[] }} idea + * @param {{ targetResolvable: boolean, claimStatus: string, duplicateClusterRisk: string }} resolved + * @param {{ buildFeasibilityVerdict?: Function }} [options] test seam; defaults to the engine composer + * @returns {{ disposition: "proceed"|"flag"|"reject", verdict: string, issueStatus: string, reasons: string[], summary: string }} + */ +export function assessIdeaFeasibility(idea, resolved, options = {}) { + const buildVerdict = options.buildFeasibilityVerdict ?? buildFeasibilityVerdict; + const issueStatus = deriveIdeaIssueStatus(idea, resolved); + const verdict = buildVerdict({ + found: resolved.targetResolvable, + claimStatus: resolved.claimStatus, + duplicateClusterRisk: resolved.duplicateClusterRisk, + issueStatus, + }); + return { + disposition: DISPOSITION_BY_VERDICT[verdict.verdict], + verdict: verdict.verdict, + issueStatus, + reasons: [...verdict.avoidReasons, ...verdict.raiseReasons], + summary: verdict.summary, + }; +} diff --git a/test/unit/miner-idea-feasibility.test.ts b/test/unit/miner-idea-feasibility.test.ts new file mode 100644 index 0000000000..3eaaa352ea --- /dev/null +++ b/test/unit/miner-idea-feasibility.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@loopover/engine", async () => { + return import("../../packages/loopover-engine/src/index"); +}); + +import { + assessIdeaFeasibility, + deriveIdeaIssueStatus, +} from "../../packages/loopover-miner/lib/idea-feasibility.js"; +import type { ResolvedIdeaSignals } from "../../packages/loopover-miner/lib/idea-feasibility.js"; +import type { FeasibilityGateResult } from "@loopover/engine"; + +function cleanSignals(overrides: Partial = {}): ResolvedIdeaSignals { + return { targetResolvable: true, claimStatus: "unclaimed", duplicateClusterRisk: "none", ...overrides }; +} + +describe("deriveIdeaIssueStatus (#5671)", () => { + it("returns 'missing' when the idea's target repo does not resolve (out of the loop's scope)", () => { + expect(deriveIdeaIssueStatus({ acceptanceHints: ["retries on 5xx"] }, { targetResolvable: false })).toBe("missing"); + }); + + it("returns 'invalid' when the idea declares NO objective success signal (acceptanceHints absent)", () => { + expect(deriveIdeaIssueStatus({}, { targetResolvable: true })).toBe("invalid"); + }); + + it("returns 'invalid' when acceptanceHints is present but empty (still nothing testable)", () => { + expect(deriveIdeaIssueStatus({ acceptanceHints: [] }, { targetResolvable: true })).toBe("invalid"); + }); + + it("returns 'ready' when the idea resolves and carries at least one objective success signal", () => { + expect(deriveIdeaIssueStatus({ acceptanceHints: ["uploads retry on 5xx"] }, { targetResolvable: true })).toBe("ready"); + }); +}); + +describe("assessIdeaFeasibility (#5671)", () => { + it("a feasible idea (resolvable target, objective signal, clean metadata) proceeds to compute", () => { + const result = assessIdeaFeasibility({ acceptanceHints: ["uploads retry on 5xx"] }, cleanSignals()); + expect(result.disposition).toBe("proceed"); + expect(result.verdict).toBe("go"); + expect(result.issueStatus).toBe("ready"); + expect(result.reasons).toEqual([]); + }); + + it("a well-formed but impossible-to-evaluate idea (no objective signal) is REJECTED before compute", () => { + const result = assessIdeaFeasibility({ title: "make it better", acceptanceHints: [] }, cleanSignals()); + expect(result.disposition).toBe("reject"); + expect(result.verdict).toBe("avoid"); + expect(result.issueStatus).toBe("invalid"); + expect(result.reasons).toContain("issue_lifecycle_invalid"); + }); + + it("an ambiguous idea (evaluable but overlaps existing work) is FLAGGED, not auto-proceeded or rejected", () => { + const result = assessIdeaFeasibility( + { acceptanceHints: ["adds an API key store"] }, + cleanSignals({ duplicateClusterRisk: "medium" }), + ); + expect(result.disposition).toBe("flag"); + expect(result.verdict).toBe("raise"); + expect(result.reasons).toContain("duplicate_cluster_medium"); + }); + + it("an out-of-scope idea whose target repo does not resolve is flagged (target_not_found)", () => { + const result = assessIdeaFeasibility( + { acceptanceHints: ["do a thing"] }, + cleanSignals({ targetResolvable: false }), + ); + expect(result.disposition).toBe("flag"); + expect(result.verdict).toBe("raise"); + expect(result.issueStatus).toBe("missing"); + expect(result.reasons).toEqual(expect.arrayContaining(["target_not_found"])); + }); + + it("surfaces multiple avoid reasons together (already-solved AND duplicate cluster)", () => { + const result = assessIdeaFeasibility( + { acceptanceHints: ["x"] }, + cleanSignals({ claimStatus: "solved", duplicateClusterRisk: "high" }), + ); + expect(result.disposition).toBe("reject"); + expect(result.reasons).toEqual(expect.arrayContaining(["claim_status_solved", "duplicate_cluster_high"])); + }); + + it("uses an injected verdict composer when provided (test seam), not the engine default", () => { + const spy = vi.fn((): FeasibilityGateResult => ({ verdict: "go", avoidReasons: [], raiseReasons: [], summary: "injected" })); + const result = assessIdeaFeasibility( + { acceptanceHints: ["x"] }, + cleanSignals(), + { buildFeasibilityVerdict: spy }, + ); + expect(spy).toHaveBeenCalledWith({ + found: true, + claimStatus: "unclaimed", + duplicateClusterRisk: "none", + issueStatus: "ready", + }); + expect(result.disposition).toBe("proceed"); + expect(result.summary).toBe("injected"); + }); +});