diff --git a/packages/loopover-engine/src/review/content-lane/content-repo-spec.ts b/packages/loopover-engine/src/review/content-lane/content-repo-spec.ts index a2998f7d96..eae52a3d3e 100644 --- a/packages/loopover-engine/src/review/content-lane/content-repo-spec.ts +++ b/packages/loopover-engine/src/review/content-lane/content-repo-spec.ts @@ -143,7 +143,10 @@ export const AWESOME_CLAUDE_CONTENT_SPEC: ContentRepoSpec = { "website_url", ], sourceUrlListFields: new Set(["sourceUrls", "retrievalSources"]), - distributionSourceFields: new Set(["downloadUrl", "packageUrl"]), + // snake_case aliases match urlFields / sourceUrlFields (#7446): without them, a site-relative + // `download_url`/`package_url` is misclassified as canonical (spurious invalid_url), and a + // retryable `github_url`/`repo_url`/`repository_url`/`source_url` is silently downgradable. + distributionSourceFields: new Set(["downloadUrl", "packageUrl", "download_url", "package_url"]), distributionSourceHosts: new Set([ "crates.io", "files.pythonhosted.org", @@ -160,5 +163,14 @@ export const AWESOME_CLAUDE_CONTENT_SPEC: ContentRepoSpec = { "rubygems.org", "www.npmjs.com", ]), - primaryCanonicalSourceFields: new Set(["githubUrl", "repoUrl", "repositoryUrl", "sourceUrl"]), + primaryCanonicalSourceFields: new Set([ + "githubUrl", + "repoUrl", + "repositoryUrl", + "sourceUrl", + "github_url", + "repo_url", + "repository_url", + "source_url", + ]), }; diff --git a/test/unit/content-lane-source-evidence.test.ts b/test/unit/content-lane-source-evidence.test.ts index eb577a0277..91c55d36dc 100644 --- a/test/unit/content-lane-source-evidence.test.ts +++ b/test/unit/content-lane-source-evidence.test.ts @@ -52,6 +52,16 @@ describe("extractSubmittedSourceUrls", () => { expect(urls).toHaveLength(0); }); + it("drops a site-relative snake_case download_url the same way as downloadUrl (#7446)", () => { + // Before #7446, distributionSourceFields was camelCase-only, so download_url:/… stayed in the + // extracted set, was classified as canonical, and produced a spurious invalid_url hard failure. + expect(extractSubmittedSourceUrls(mdx({ download_url: "/downloads/skills/foo.zip" }))).toHaveLength(0); + expect(extractSubmittedSourceUrls(mdx({ package_url: "/packages/foo.tgz" }))).toHaveLength(0); + for (const field of ["download_url", "package_url"]) { + expect(AWESOME_CLAUDE_CONTENT_SPEC.distributionSourceFields.has(field)).toBe(true); + } + }); + it("reads snake_case source fields (e.g. the canonical source_url), matching urlFields (#7250)", () => { // Before #7250, sourceUrlFields listed only the camelCase names, so a legitimately-aliased snake_case field // was invisible to the source-evidence gate even though duplicates.ts (which reads urlFields) saw it. @@ -816,6 +826,25 @@ describe("downgrade rules (hasVerifiableCanonicalSource / isDowngradableInconclu expect(primary?.blocking).toBe(true); expect(report.status).toBe("retryable"); }); + + it("does NOT downgrade a snake_case PRIMARY-field retryable either (#7446)", async () => { + // Before #7446, primaryCanonicalSourceFields was camelCase-only, so a retryable source_url was + // treated as non-primary and silently downgraded whenever another canonical was reachable. + const src = mdx({ + githubUrl: "https://github.com/acme/anchor", + source_url: "https://flaky.example/primary-snake", + }); + const report = await checkSubmittedSourceEvidence( + src, + fakeFetch({ "https://github.com/acme/anchor": 200, "https://flaky.example/primary-snake": 503 }), + ); + const primary = report.urls.find((u) => u.field === "source_url"); + expect(primary?.blocking).toBe(true); + expect(report.status).toBe("retryable"); + for (const field of ["github_url", "repo_url", "repository_url", "source_url"]) { + expect(AWESOME_CLAUDE_CONTENT_SPEC.primaryCanonicalSourceFields.has(field)).toBe(true); + } + }); }); // ── summary / decision string branches ───────────────────────────────────────────────────────────