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 eae52a3d3e..853f384509 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 @@ -102,6 +102,7 @@ export const AWESOME_CLAUDE_CONTENT_SPEC: ContentRepoSpec = { "sourceUrl", "websiteUrl", "docs_url", + "documentation_url", "download_url", "github_url", "package_url", @@ -141,6 +142,7 @@ export const AWESOME_CLAUDE_CONTENT_SPEC: ContentRepoSpec = { "repository_url", "source_url", "website_url", + "documentation_url", ], sourceUrlListFields: new Set(["sourceUrls", "retrievalSources"]), // snake_case aliases match urlFields / sourceUrlFields (#7446): without them, a site-relative diff --git a/packages/loopover-engine/test/content-repo-spec.test.ts b/packages/loopover-engine/test/content-repo-spec.test.ts new file mode 100644 index 0000000000..0e7c195bb6 --- /dev/null +++ b/packages/loopover-engine/test/content-repo-spec.test.ts @@ -0,0 +1,22 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { AWESOME_CLAUDE_CONTENT_SPEC } from "../dist/review/content-lane/content-repo-spec.js"; + +// Mirrors the derived pairing assertion at test/unit/content-lane-duplicates.test.ts (protectedFrontmatterFields) +// and test/unit/content-lane-source-evidence.test.ts (urlFields): a hand-enumerated alias list can't catch the +// next camelCase URL field added to the spec without a matching snake_case alias — a derived walk can (#9992). +test("every camelCase urlFields member has its snake_case alias present in both urlFields and sourceUrlFields", () => { + const toSnakeCase = (field: string): string => field.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); + for (const field of AWESOME_CLAUDE_CONTENT_SPEC.urlFields) { + if (!/[A-Z]/.test(field)) continue; + const snakeCase = toSnakeCase(field); + assert.equal(AWESOME_CLAUDE_CONTENT_SPEC.urlFields.has(snakeCase), true); + assert.equal(AWESOME_CLAUDE_CONTENT_SPEC.sourceUrlFields.includes(snakeCase), true); + } +}); + +test("documentation_url is present in both urlFields and sourceUrlFields (#9992)", () => { + assert.equal(AWESOME_CLAUDE_CONTENT_SPEC.urlFields.has("documentation_url"), true); + assert.equal(AWESOME_CLAUDE_CONTENT_SPEC.sourceUrlFields.includes("documentation_url"), true); +}); diff --git a/test/unit/content-lane-duplicates.test.ts b/test/unit/content-lane-duplicates.test.ts index fdb70b7c15..f5bac1b100 100644 --- a/test/unit/content-lane-duplicates.test.ts +++ b/test/unit/content-lane-duplicates.test.ts @@ -214,6 +214,22 @@ describe("extractContentDuplicateSignals + strict match", () => { const m = findStrictContentDuplicateMatch(a, [b]); expect(m?.reasons.some((r) => r.includes("same normalized description"))).toBe(true); }); + + it("REGRESSION (#9992): an entry whose only URL frontmatter key is documentation_url still yields a non-empty urls signal", () => { + const sig = extractContentDuplicateSignals({ + filePath: "content/skills/doc-only.mdx", + content: mdx({ title: "Doc Only", slug: "doc-only", documentation_url: "https://docs.acme.example/guide" }), + }); + expect(sig.urls).toContain("https://docs.acme.example/guide"); + }); + + it("does not pick up an unrelated non-URL frontmatter key into the urls signal", () => { + const sig = extractContentDuplicateSignals({ + filePath: "content/skills/no-url.mdx", + content: mdx({ title: "No Url", slug: "no-url", pricing_model: "https://example.com/pricing-not-a-url-field" }), + }); + expect(sig.urls).toEqual([]); + }); }); describe("normalizeUrl — RFC 3986 percent-encoding canonicalization for duplicate detection", () => { diff --git a/test/unit/content-lane-source-evidence.test.ts b/test/unit/content-lane-source-evidence.test.ts index acd762ed8a..50823a2962 100644 --- a/test/unit/content-lane-source-evidence.test.ts +++ b/test/unit/content-lane-source-evidence.test.ts @@ -67,12 +67,24 @@ describe("extractSubmittedSourceUrls", () => { // was invisible to the source-evidence gate even though duplicates.ts (which reads urlFields) saw it. const pairs = extractSubmittedSourceUrls(mdx({ source_url: "https://github.com/acme/y" })).map((u) => `${u.field}:${u.url}`); expect(pairs).toContain("source_url:https://github.com/acme/y"); - // Every snake_case alias urlFields carries is now recognized here too. - for (const field of ["docs_url", "download_url", "github_url", "package_url", "repo_url", "repository_url", "source_url", "website_url"]) { - expect(AWESOME_CLAUDE_CONTENT_SPEC.sourceUrlFields).toContain(field); - expect(AWESOME_CLAUDE_CONTENT_SPEC.urlFields.has(field)).toBe(true); + }); + + it("every camelCase urlFields member has its snake_case alias present in both urlFields and sourceUrlFields (mirrors #7250/#7445's pairing assertion; #9992)", () => { + // Derived, not hand-enumerated: a hard-coded list here (as before #9992) cannot catch the next URL field + // that gets added to urlFields without a matching snake_case alias — it would silently pass regardless. + const toSnakeCase = (field: string): string => field.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); + for (const field of AWESOME_CLAUDE_CONTENT_SPEC.urlFields) { + if (!/[A-Z]/.test(field)) continue; + const snakeCase = toSnakeCase(field); + expect(AWESOME_CLAUDE_CONTENT_SPEC.urlFields.has(snakeCase)).toBe(true); + expect(AWESOME_CLAUDE_CONTENT_SPEC.sourceUrlFields).toContain(snakeCase); } }); + + it("REGRESSION (#9992): extracts documentation_url the same way as documentationUrl", () => { + const urls = extractSubmittedSourceUrls(mdx({ documentation_url: "https://docs.acme.example/guide" })); + expect(urls).toEqual([{ field: "documentation_url", url: "https://docs.acme.example/guide" }]); + }); }); describe("checkSubmittedSourceEvidence", () => {