diff --git a/packages/loopover-engine/src/idea-intake.ts b/packages/loopover-engine/src/idea-intake.ts index 644b902b6..ba0e89820 100644 --- a/packages/loopover-engine/src/idea-intake.ts +++ b/packages/loopover-engine/src/idea-intake.ts @@ -85,6 +85,10 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } +// The "owner/name" slug check shared by both existing-repo input shapes (bare string and +// `{ kind: "existing", repo }`): each segment must be a GitHub-legal slug. +const TARGET_REPO_SLUG_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; + /** Validate + normalize a raw renter submission (spec §1). Returns every failure at once (never folds with * `??`/`||`) so a caller can surface all problems in one pass rather than one-at-a-time. */ export function validateIdeaSubmission(raw: unknown): IdeaValidationResult { @@ -98,10 +102,17 @@ export function validateIdeaSubmission(raw: unknown): IdeaValidationResult { else if (input.body.length > IDEA_BODY_MAX_CHARS) errors.push("body_too_long"); // Back-compat wire form: a bare "owner/name" string means an existing repo (each segment a GitHub-legal // slug -- an uninstallable/malformed repo is rejected at intake, never scored, since it can never produce a - // `go`). A `{ kind: "provision" }` object requests a not-yet-created repo (#7589). Anything else is missing. + // `go`). A canonical `{ kind: "existing", repo }` object — the exact shape this validator itself returns, + // and what a TypeScript caller building against the exported `IdeaTarget` union constructs — resolves the + // same way, with the same slug check, so `validateIdeaSubmission` round-trips its own output (#9609). A + // `{ kind: "provision" }` object requests a not-yet-created repo (#7589). Anything else is missing. let resolvedTarget: IdeaTarget | undefined; if (isNonEmptyString(input.targetRepo)) { - if (/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input.targetRepo)) resolvedTarget = { kind: "existing", repo: input.targetRepo }; + if (TARGET_REPO_SLUG_PATTERN.test(input.targetRepo)) resolvedTarget = { kind: "existing", repo: input.targetRepo }; + else errors.push("target_repo_malformed"); + } else if (typeof input.targetRepo === "object" && input.targetRepo !== null && (input.targetRepo as Record).kind === "existing") { + const repo = (input.targetRepo as Record).repo; + if (typeof repo === "string" && TARGET_REPO_SLUG_PATTERN.test(repo)) resolvedTarget = { kind: "existing", repo }; else errors.push("target_repo_malformed"); } else if (typeof input.targetRepo === "object" && input.targetRepo !== null && (input.targetRepo as Record).kind === "provision") { resolvedTarget = { kind: "provision" }; diff --git a/test/unit/idea-intake-bridge.test.ts b/test/unit/idea-intake-bridge.test.ts index 4d5d84f37..75f9fcfda 100644 --- a/test/unit/idea-intake-bridge.test.ts +++ b/test/unit/idea-intake-bridge.test.ts @@ -74,11 +74,42 @@ describe("validateIdeaSubmission", () => { if (provision.ok) expect(provision.idea.targetRepo).toEqual({ kind: "provision" }); }); - it("rejects an object targetRepo that is not a provision request", () => { - for (const targetRepo of [{}, { kind: "existing" }]) { + it("rejects an object targetRepo whose kind is neither existing nor provision", () => { + for (const targetRepo of [{}, { kind: "bogus" }]) { const r = validateIdeaSubmission(rawIdea({ targetRepo })); expect(r.ok).toBe(false); - if (!r.ok) expect(r.errors).toContain("target_repo_required"); + if (!r.ok) expect(r.errors).toEqual(["target_repo_required"]); + } + }); + + it("accepts the canonical { kind: 'existing', repo } object the validator itself returns (#9609)", () => { + const r = validateIdeaSubmission(rawIdea({ targetRepo: { kind: "existing", repo: "acme/widgets" } })); + expect(r.ok).toBe(true); + if (r.ok) expect(r.idea.targetRepo).toEqual({ kind: "existing", repo: "acme/widgets" }); + }); + + it("round-trips its own output: re-validating a validated idea succeeds with targetRepo unchanged (#9609)", () => { + for (const targetRepo of ["acme/widgets", { kind: "provision" }]) { + const first = validateIdeaSubmission(rawIdea({ targetRepo })); + expect(first.ok).toBe(true); + if (!first.ok) continue; + const second = validateIdeaSubmission(first.idea); + expect(second.ok).toBe(true); + if (second.ok) expect(second.idea.targetRepo).toEqual(first.idea.targetRepo); + } + }); + + it("flags a recognised-but-malformed { kind: 'existing' } repo as target_repo_malformed (#9609)", () => { + const r = validateIdeaSubmission(rawIdea({ targetRepo: { kind: "existing", repo: "not-a-slug" } })); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors).toEqual(["target_repo_malformed"]); + }); + + it("flags an { kind: 'existing' } target whose repo is missing, empty, or not a string as target_repo_malformed (#9609)", () => { + for (const targetRepo of [{ kind: "existing" }, { kind: "existing", repo: "" }, { kind: "existing", repo: " " }, { kind: "existing", repo: 42 }]) { + const r = validateIdeaSubmission(rawIdea({ targetRepo })); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors).toEqual(["target_repo_malformed"]); } });