diff --git a/.github/scripts/enforce-pr-target.test.cjs b/.github/scripts/enforce-pr-target.test.cjs index 33bfda42c..bf5497d7f 100644 --- a/.github/scripts/enforce-pr-target.test.cjs +++ b/.github/scripts/enforce-pr-target.test.cjs @@ -49,7 +49,7 @@ describe("enforce-pr-target workflow", () => { assert.match(workflow, /synchronize/); }); - it("uses label events for GUI waivers and a trusted CodeRabbit status signal", () => { + it("uses label events for GUI waivers, hygiene sponsorship, and a trusted CodeRabbit status signal", () => { assert.doesNotMatch(workflow, /^ issue_comment:/m); assert.match(workflow, /- labeled/); assert.match(workflow, /- unlabeled/); @@ -57,6 +57,8 @@ describe("enforce-pr-target workflow", () => { assert.match(workflow, /github\.event\.context == 'CodeRabbit'/); assert.match(workflow, /github\.event\.state == 'success'/); assert.match(workflow, /github\.event\.label\.name == 'gui-screenshot-waived'/); + assert.match(workflow, /github\.event\.label\.name == 'intake: hygiene-blocked'/); + assert.match(workflow, /github\.event\.label\.name == 'maintainer-sponsored'/); assert.match(workflow, /listPullRequestsAssociatedWithCommit/); assert.match(workflow, /candidate\.head\?\.sha === statusSha/); assert.match(workflow, /candidates\.length !== 1/); @@ -180,6 +182,9 @@ describe("enforce-pr-target workflow", () => { it("loads pr-quality via require from the checked-out scripts", () => { assert.match(workflow, /pr-quality\.cjs/); assert.match(workflow, /collectPrQualityFailures/); + assert.match(workflow, /pr-hygiene\.cjs/); + assert.match(workflow, /collectDeterministicHygieneFailures/); + assert.match(workflow, /pulls\.listFiles/); }); it("checks stacked bases via open PR heads before wrong_base enforcement", () => { diff --git a/.github/scripts/issue-quality-core.cjs b/.github/scripts/issue-quality-core.cjs index 817624f46..c6abfacf1 100644 --- a/.github/scripts/issue-quality-core.cjs +++ b/.github/scripts/issue-quality-core.cjs @@ -66,45 +66,104 @@ function isPlaceholderOnlyValue(raw) { */ function stripMediaTokens(text) { if (typeof text !== "string") return ""; - // Indented code lines render as literal code in GitHub Markdown. Protect - // them first so neither the HTML nor the Markdown media stripper can - // remove example syntax; restore the lines afterwards. + // Fenced and indented code render literally in GitHub Markdown. Protect + // them first so neither media stripper can remove example syntax. The + // protector deliberately leaves indented children of an unindented HTML + // media block visible: those lines are HTML children, not Markdown code. const protectedText = protectIndentedCodeLines(text); const markdownStripped = stripMarkdownImages(stripHtmlMedia(protectedText.text)); const referenceStripped = stripReferenceImages(markdownStripped); - return restoreIndentedCodeLines(referenceStripped, protectedText.lines); + return restoreIndentedCodeLines(referenceStripped, protectedText); } /** - * Replace every indented code line (4+ leading spaces or a tab) with a - * placeholder of equal length so media stripping cannot touch it. Returns the - * masked text plus the original lines for restoration. + * Replace fenced code and indented code outside HTML media blocks with opaque + * tokens. Restoration is token-based rather than line-position-based because + * stripping a multiline media block may collapse or remove lines. */ function protectIndentedCodeLines(text) { const lines = []; + let markerPrefix = "\u0000OCX_ISSUE_CODE_"; + while (text.includes(markerPrefix)) markerPrefix += "_"; + let mediaDepth = 0; + let pendingMediaTag = null; + let fence = null; + + const mask = (line) => { + const index = lines.push(line) - 1; + return `${markerPrefix}${index}\u0000`; + }; + const masked = text.split("\n").map((line) => { - if (/^(?: {4,}|\t)/.test(line)) { - lines.push(line); - return "\u0000" + line.replace(/[^\n]/g, " ").slice(1); + if (fence) { + const closing = new RegExp(`^ {0,3}${fence.char}{${fence.length},}[ \\t]*$`); + if (closing.test(line)) fence = null; + return mask(line); } - lines.push(null); + + const fenceStart = line.match(/^ {0,3}(`{3,}|~{3,})/); + if (fenceStart) { + fence = { char: fenceStart[1][0], length: fenceStart[1].length }; + return mask(line); + } + + // Four-space/tab lines inside an active unindented HTML media block are + // child markup or fallback text. Treating them as code would keep an + // otherwise media-only /", + ].join("\n")), + true, + ); assert.equal(isMediaOnly(''), true); assert.equal(isMediaOnly('\nCaption text'), false); assert.equal(isMediaOnly("Some real description."), false); assert.equal(stripMediaTokens('').trim(), ""); assert.equal(stripMediaTokens('![alt](url "title")').trim(), ""); assert.equal(stripMediaTokens('before ![alt](url) after').replace(/\s+/g, " ").trim(), "before after"); + + const fencedMediaExample = [ + "```html", + "", + "```", + ].join("\n"); + assert.equal(stripMediaTokens(fencedMediaExample), fencedMediaExample); + assert.equal(isMediaOnly(fencedMediaExample), false); + + const protectedAroundMedia = [ + " ![before](url)", + "", + " ![after](url)", + ].join("\n"); + const strippedAroundMedia = stripMediaTokens(protectedAroundMedia); + assert.ok(strippedAroundMedia.includes(" ![before](url)")); + assert.ok(strippedAroundMedia.includes(" ![after](url)")); + assert.equal(strippedAroundMedia.includes("