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
51 changes: 51 additions & 0 deletions packages/loopover-miner/lib/idea-feasibility.d.ts
Original file line number Diff line number Diff line change
@@ -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<ResolvedIdeaSignals, "targetResolvable">,
): FeasibilityIssueStatus;

export function assessIdeaFeasibility(
idea: IdeaFeasibilityInput,
resolved: ResolvedIdeaSignals,
options?: AssessIdeaFeasibilityOptions,
): IdeaFeasibilityResult;
68 changes: 68 additions & 0 deletions packages/loopover-miner/lib/idea-feasibility.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
99 changes: 99 additions & 0 deletions test/unit/miner-idea-feasibility.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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");
});
});