Skip to content

Commit 6865c00

Browse files
authored
Merge pull request #1101 from Wibias/codex/1098-issue-quality-image-section
fix(issue-quality): treat media-only sections as empty so image-only goals cannot hide repeated prose
2 parents 9795aeb + f9a1172 commit 6865c00

2 files changed

Lines changed: 500 additions & 0 deletions

File tree

.github/scripts/issue-quality.cjs

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,341 @@ function isPlaceholderOnlyValue(raw) {
5151
return PLACEHOLDER_ONLY_RE.test(value);
5252
}
5353

54+
/**
55+
* Strip image/media-only content from a markdown or HTML fragment so that a
56+
* section whose only content is a screenshot or media embed is treated as
57+
* empty by the validators.
58+
*
59+
* Handles:
60+
* - Markdown images: `![alt](url)`, `![alt](url "title")`
61+
* - HTML <img ...> and <picture> ... </picture> blocks
62+
* - Common media embeds (video/audio) when they are the only content
63+
*
64+
* Text mixed with media (for example a caption or repro steps around an
65+
* image) is preserved; only the media tokens themselves are removed.
66+
*/
67+
function stripMediaTokens(text) {
68+
if (typeof text !== "string") return "";
69+
// Indented code lines render as literal code in GitHub Markdown. Protect
70+
// them first so neither the HTML nor the Markdown media stripper can
71+
// remove example syntax; restore the lines afterwards.
72+
const protectedText = protectIndentedCodeLines(text);
73+
const markdownStripped = stripMarkdownImages(stripHtmlMedia(protectedText.text));
74+
const referenceStripped = stripReferenceImages(markdownStripped);
75+
return restoreIndentedCodeLines(referenceStripped, protectedText.lines);
76+
}
77+
78+
/**
79+
* Replace every indented code line (4+ leading spaces or a tab) with a
80+
* placeholder of equal length so media stripping cannot touch it. Returns the
81+
* masked text plus the original lines for restoration.
82+
*/
83+
function protectIndentedCodeLines(text) {
84+
const lines = [];
85+
const masked = text.split("\n").map((line) => {
86+
if (/^(?: {4,}|\t)/.test(line)) {
87+
lines.push(line);
88+
return "\u0000" + line.replace(/[^\n]/g, " ").slice(1);
89+
}
90+
lines.push(null);
91+
return line;
92+
});
93+
return { text: masked.join("\n"), lines };
94+
}
95+
96+
/**
97+
* Restore masked indented-code lines from their original content. Placeholder
98+
* lines are identified by the leading \u0000 marker and matched positionally.
99+
*/
100+
function restoreIndentedCodeLines(text, lines) {
101+
const out = text.split("\n").map((line, i) => {
102+
if (lines[i] !== null && line.startsWith("\u0000")) {
103+
return lines[i];
104+
}
105+
return line;
106+
});
107+
return out.join("\n");
108+
}
109+
110+
/**
111+
* Strip HTML media blocks whose entire inner content is media markup (no
112+
* substantive text). A block that contains fallback/caption prose — for
113+
* example `<video controls>Route requests through the fallback provider.</video>`
114+
* — is left untouched so the prose survives the empty-section check.
115+
*
116+
* Handles <img ...>, <picture>...</picture>, <video>...</video>, and
117+
* <audio>...</audio>.
118+
*/
119+
function stripHtmlMedia(text) {
120+
if (typeof text !== "string") return "";
121+
let s = text
122+
.replace(/<img\b[^>]*>/gi, " ")
123+
.replace(/<!--[\s\S]*?-->/g, " ");
124+
125+
// Whole media blocks: replace only when the inner content is not
126+
// substantive text (no word characters outside tags).
127+
s = s.replace(
128+
/<(picture|video|audio)\b[^>]*>([\s\S]*?)<\/\1>/gi,
129+
(match, tag, inner) => {
130+
const innerStripped = inner
131+
.replace(/<[^>]+>/g, " ")
132+
.replace(/[\s_*~`]+/g, " ")
133+
.trim();
134+
return innerStripped.length === 0 ? " " : match;
135+
},
136+
);
137+
return s;
138+
}
139+
140+
/**
141+
* Remove Markdown image tokens `![alt](dest "title")` using a small
142+
* balanced scanner instead of a regex, because destinations may contain
143+
* balanced parentheses (for example `image_(final).png`) and alt text may
144+
* contain balanced brackets (`![Image [screenshot]](url)`).
145+
*
146+
* A token is matched only when:
147+
* - it starts with `![` (not escaped);
148+
* - the alt text is balanced with respect to `[` / `]`;
149+
* - the destination is balanced with respect to `(`, `)` and `"` (an
150+
* optional title may follow); and
151+
* - the token closes with a `)`.
152+
*
153+
* Malformed tokens (unbalanced destination, e.g. `a)b.png)`) are left in
154+
* place — they are not valid Markdown images and must not be silently
155+
* dropped.
156+
*/
157+
function stripMarkdownImages(text) {
158+
if (typeof text !== "string") return "";
159+
const out = [];
160+
let i = 0;
161+
while (i < text.length) {
162+
// Inside an indented code block (4+ leading spaces or a tab), image
163+
// syntax is literal code, not a rendered image. Leave it untouched so a
164+
// section that documents example syntax is not emptied.
165+
if (isInsideIndentedCode(text, i)) {
166+
out.push(text[i]);
167+
i += 1;
168+
continue;
169+
}
170+
// A backslash-escaped or code-fenced `![` is not an image token. We only
171+
// guard the common `\!` escape here; fenced blocks are handled by the
172+
// section extractor upstream, which does not include them in sections.
173+
if (text[i] === "!" && text[i + 1] === "[") {
174+
const end = scanMarkdownImage(text, i);
175+
if (end !== -1) {
176+
out.push(" ");
177+
i = end;
178+
continue;
179+
}
180+
}
181+
out.push(text[i]);
182+
i += 1;
183+
}
184+
return out.join("");
185+
}
186+
187+
/**
188+
* True when `index` sits inside an indented code block, i.e. on a line that
189+
* starts with four or more spaces or a tab. Such lines render as literal
190+
* code in GitHub Markdown.
191+
*/
192+
function isInsideIndentedCode(text, index) {
193+
const lineStart = text.lastIndexOf("\n", index - 1) + 1;
194+
const prefix = text.slice(lineStart, index);
195+
return /^(?: {4,}|\t)/.test(prefix);
196+
}
197+
198+
/**
199+
* Strip reference-style Markdown images: inline references `![alt][ref]`
200+
* and the reference definitions `[ref]: https://...` they point at. These
201+
* are valid image syntax that a media-only section may use to embed a
202+
* screenshot.
203+
*/
204+
function stripReferenceImages(text) {
205+
if (typeof text !== "string") return "";
206+
// Inline reference: ![alt][ref] or ![alt][] (implicit). Alt may contain
207+
// balanced brackets, so a balanced scan is used for the label part.
208+
let s = stripInlineReferences(text);
209+
// Reference definitions: [ref]: url "title" — only when the reference is
210+
// actually used by an image in the same text. A definition alone (or one
211+
// used by a text link) is not media and must stay.
212+
const refs = new Set();
213+
for (const ref of collectInlineReferenceLabels(text)) {
214+
refs.add(ref.toLowerCase());
215+
}
216+
if (refs.size > 0) {
217+
s = s.replace(
218+
/^\s*\[([^\]]+)\]:\s*\S+(?:\s+["'(][^"')]*["')])?\s*$/gm,
219+
(line, ref) => (refs.has(ref.toLowerCase()) ? " " : line),
220+
);
221+
}
222+
return s;
223+
}
224+
225+
/**
226+
* Strip inline reference-style image tokens `![alt][ref]` / `![alt][]`
227+
* using a balanced scan for the alt text (which may contain nested brackets).
228+
*/
229+
function stripInlineReferences(text) {
230+
const out = [];
231+
let i = 0;
232+
while (i < text.length) {
233+
if (text[i] === "!" && text[i + 1] === "[") {
234+
const end = scanReferenceImage(text, i);
235+
if (end !== -1) {
236+
out.push(" ");
237+
i = end;
238+
continue;
239+
}
240+
}
241+
out.push(text[i]);
242+
i += 1;
243+
}
244+
return out.join("");
245+
}
246+
247+
/**
248+
* Scan an inline reference-style image `![alt][ref]` or `![alt][]` starting
249+
* at `start`. Returns the index just past the closing `]` on success, or -1.
250+
*/
251+
function scanReferenceImage(text, start) {
252+
const altEnd = scanBalancedBrackets(text, start + 2);
253+
if (altEnd === -1 || text[altEnd] !== "]") return -1;
254+
if (text[altEnd + 1] !== "[") return -1;
255+
const refEnd = scanBalancedBrackets(text, altEnd + 2);
256+
if (refEnd === -1 || text[refEnd] !== "]") return -1;
257+
return refEnd + 1;
258+
}
259+
260+
/**
261+
* Scan balanced bracket content starting at `start` (inside the opening `[`).
262+
* Returns the index of the matching closing `]`, or -1 when unbalanced.
263+
*/
264+
function scanBalancedBrackets(text, start) {
265+
let depth = 0;
266+
for (let i = start; i < text.length; i += 1) {
267+
const ch = text[i];
268+
if (ch === "\\") {
269+
i += 1;
270+
continue;
271+
}
272+
if (ch === "[") {
273+
depth += 1;
274+
} else if (ch === "]") {
275+
if (depth === 0) return i;
276+
depth -= 1;
277+
}
278+
}
279+
return -1;
280+
}
281+
282+
/**
283+
* Collect the reference labels used by inline reference-style images. For an
284+
* explicit `![alt][ref]` the label is `ref`; for an implicit `![alt][]` the
285+
* label is the alt text.
286+
*/
287+
function collectInlineReferenceLabels(text) {
288+
const labels = [];
289+
let i = 0;
290+
while (i < text.length) {
291+
if (text[i] === "!" && text[i + 1] === "[") {
292+
const altStart = i + 2;
293+
const altEnd = scanBalancedBrackets(text, altStart);
294+
if (altEnd !== -1 && text[altEnd] === "]") {
295+
const alt = text.slice(altStart, altEnd);
296+
if (text[altEnd + 1] === "[") {
297+
const refStart = altEnd + 2;
298+
const refEnd = scanBalancedBrackets(text, refStart);
299+
if (refEnd !== -1 && text[refEnd] === "]") {
300+
const ref = text.slice(refStart, refEnd);
301+
labels.push(ref ? ref : alt);
302+
i = refEnd + 1;
303+
continue;
304+
}
305+
}
306+
}
307+
}
308+
i += 1;
309+
}
310+
return labels;
311+
}
312+
313+
/**
314+
* Scan a Markdown image token starting at `start` (which points at `!`).
315+
* Returns the index just past the closing `)` on success, or -1 when the
316+
* token is malformed.
317+
*/
318+
function scanMarkdownImage(text, start) {
319+
// Alt text: `![` ... `]` with balanced nested brackets.
320+
let i = start + 2;
321+
let bracketDepth = 0;
322+
for (; i < text.length; i += 1) {
323+
const ch = text[i];
324+
if (ch === "\\") {
325+
i += 1; // skip escaped character
326+
continue;
327+
}
328+
if (ch === "[") {
329+
bracketDepth += 1;
330+
} else if (ch === "]") {
331+
if (bracketDepth === 0) break;
332+
bracketDepth -= 1;
333+
}
334+
}
335+
if (i >= text.length || text[i] !== "]") return -1;
336+
337+
// Destination: `(` ... `)` with balanced parentheses. An optional
338+
// whitespace-separated `"title"` may follow the destination.
339+
if (text[i + 1] !== "(") return -1;
340+
i += 2;
341+
let parenDepth = 1;
342+
let inQuotes = false;
343+
for (; i < text.length; i += 1) {
344+
const ch = text[i];
345+
if (ch === "\\") {
346+
i += 1; // skip escaped character
347+
continue;
348+
}
349+
if (ch === '"') {
350+
inQuotes = !inQuotes;
351+
continue;
352+
}
353+
if (inQuotes) continue;
354+
if (ch === "(") {
355+
parenDepth += 1;
356+
} else if (ch === ")") {
357+
parenDepth -= 1;
358+
if (parenDepth === 0) return i + 1;
359+
}
360+
}
361+
return -1;
362+
}
363+
364+
/**
365+
* True when a section contains no substantive text after removing media
366+
* tokens and whitespace. Used to decide whether a media-only section should
367+
* count as empty for quality validation.
368+
*/
369+
function isMediaOnly(text) {
370+
if (typeof text !== "string") return false;
371+
const stripped = stripMediaTokens(text);
372+
return stripped.replace(/\s+/g, "").length === 0;
373+
}
374+
54375
/**
55376
* Strip HTML comments, placeholder-only values, and trim whitespace.
56377
*/
57378
function clean(raw) {
58379
if (typeof raw !== "string") return "";
59380
let s = raw.replace(/<!--[\s\S]*?-->/g, "");
381+
// Media-only sections (a lone screenshot or embed) carry no reportable
382+
// text. Strip the media tokens so the section participates in emptiness and
383+
// duplicate detection like any other blank section. This closes the
384+
// image-only-section bypass (see #1098: an `<img>`-only goal hid repeated
385+
// prose in the other sections from duplicate detection).
386+
if (isMediaOnly(s)) {
387+
s = stripMediaTokens(s).replace(/\s+/g, " ").trim();
388+
}
60389
// Whole-value placeholders first (including a single enclosing fence), so
61390
// line-by-line stripping cannot leave bare fence markers behind.
62391
if (isPlaceholderOnlyValue(s)) return "";
@@ -1290,6 +1619,8 @@ module.exports = {
12901619
clean,
12911620
normalise,
12921621
canonicalise,
1622+
stripMediaTokens,
1623+
isMediaOnly,
12931624
extractSection,
12941625
resolveSection,
12951626
detectIssueKind,

0 commit comments

Comments
 (0)