diff --git a/CHANGELOG.md b/CHANGELOG.md
index cd69259..ac8c200 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,17 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo
### Fixed
+- Variant J READMEs generate the GitHub-profile type badge from standard data and the known license in the brand palette. Digest-verified generated badge rows refresh with metadata and badge output mode changes; the previous generator's remote and local row formats migrate independently of current metadata. Edited and custom authored blocks remain protected by the shrink guard, including unmarked license claims when current license metadata is unavailable. The inherited template badge is replaced even after its body or marker changes, and R7 counts local, reference-style and nested-alt Markdown images plus HTML images with unquoted or case-insensitive attributes. Missing licenses remain explicit instead of being invented.
+- README image validation retains angle-delimited Markdown destinations and literal backticks in HTML attributes, while excluding Markdown code spans and fences; Markdown image alt text keeps literal backticks, and palette checks read `img.shields.io` badge URLs written as `` without the closing angle bracket.
+- README lint admits a reference definition only with a balanced bare destination (unescaped parentheses must pair; escaped ones do not count) and treats live definitions as inert when scanning link openers, so a malformed definition or a backtick in a definition can no longer hide a rendered image or satisfy the R7 badge count.
+- README lint rejects an unescaped opening bracket in a reference-definition label (the line is then ordinary Markdown, so its image is checked; an escaped bracket stays an inert definition), recognises raw HTML indented into a list item after removing the item's content column (literal backticks cannot hide its image, and terminators are found after the indent), and keeps list items inside block quotes so a fence opened on a quoted item's continuation line ends where the line leaves the item. Regressions cover each failing case with its control.
+- Raw HTML blocks (CommonMark types 1-7, including inside quotes and list items) are literal for inline-code detection: a backtick inside one never opens a code span, so the block's real `` stays visible to R7, while Markdown after the block's end condition still pairs backticks.
+- README lint treats the destination and title of a resolved inline link as literal text (an image-looking title is neither counted as a badge nor validated, while a linked image in the link text still is) and reads a comment opener inside a complete HTML tag as attribute text, so it can no longer blank a live reference definition. The generated-badge digest (`badge_sha`) preserves Markdown-significant leading and trailing spaces and normalizes only line endings and the marker's separator newlines, so an indented generated row becomes authored and survives header refresh and upgrade; CRLF-converted untouched blocks still count as generated. Template-body hashing is unchanged.
+- Variant J template recognition normalizes only line endings and separator newlines (it no longer strips spaces and tabs), so an indented, tab-prefixed or trailing-space copy of the inherited type row inside the badges markers stays authored and is preserved by header refresh and upgrade without a `replaces=` digest, while the exact, CRLF-terminated and newline-padded template (remote and local SVG) is still refreshed.
+- README lint reads a reference definition on a list-item continuation line (including nested items and quote containers) as a definition and still treats a four-space indented definition with no open item as code, so its missing image is reported; a comment opener inside a resolved inline link title or destination no longer blanks later definitions; and a reference image whose alt text or label crosses a paragraph boundary is no longer accepted or counted as a header badge.
+- A committed regression covers badge digest ownership for tab and 4-space indentation, untouched-row refresh and CRLF normalization.
+- The `replaces=` digest a Variant J migration records is the whitespace-preserving badge digest, matched the same way by the shrink guard, so an authored indented badge block (four leading spaces are an indented code block) is no longer exempted because its stripped text equals a migrated unindented row. `--write` keeps that block or refuses with `readme-shrink`; the template-body digest is unchanged (#7).
+- README lint ends an inline link destination or title at a paragraph boundary: a blank line before the destination, around the title or before `)` makes the link invalid, so `[Docs](https://example.test "unfinished` + blank line + `` + blank line + `")` leaves Shot as a rendered image whose missing file and off-palette badge colour are still reported, while a title with a single newline stays a link title. The definition prepass keeps a stack of link openers and each `](` consumes one, so a completed link's opener cannot back an orphan destination. An inline HTML comment opened mid-line is a comment only if its `-->` closes in the same paragraph, so an orphan `](x "` closes in the same paragraph, so an orphan `](x "")),
+ (re.compile(r"^ {0,3}<\?"), re.compile(r"\?>")),
+ (re.compile(r"^ {0,3}")),
+ (re.compile(r"^ {0,3}")),
+)
+_HTML_TYPE_6 = re.compile(r"^ {0,3}?(?:" + "|".join(_TYPE_6) + r")(?:[ \t]|/?>|$)", re.I)
+_HTML_TYPE_7 = re.compile(
+ r"^ {0,3}(?:<[A-Za-z][A-Za-z0-9-]*(?:[ \t]+[A-Za-z_:][\w:.-]*(?:[ \t]*=[ \t]*(?:[^\s\"'=<>`]+|'[^']*'|\"[^\"]*\"))?)*"
+ r"[ \t]*/?>|[A-Za-z][A-Za-z0-9-]*[ \t]*>)[ \t]*$")
+
+
+def _html_block(line: str):
+ """None when `line` (container markers removed) does not start an HTML block; otherwise the end-condition regex of
+ a type 1-5 block, or True for a type 6 or 7 block, which ends at the next blank line."""
+ for opener, end in _HTML_OPEN_1_5:
+ if opener.match(line):
+ return end
+ return True if _HTML_TYPE_6.match(line) or _HTML_TYPE_7.match(line) else None
+
+
+def _run_end(text: str, i: int) -> int:
+ """Index just past the backtick run that starts at `i`."""
+ j = i
+ while j < len(text) and text[j] == "`":
+ j += 1
+ return j
+
+
+def _quotes(body: str) -> tuple[int, str]:
+ """(block-quote depth, remainder) of a line: the `>` markers are container syntax, not paragraph content."""
+ depth = 0
+ while m := _QUOTE.match(body):
+ depth += 1
+ body = body[m.end():]
+ return depth, body
+
+
+def _strip_quotes(body: str, depth: int) -> str:
+ """`body` with at most `depth` leading block-quote markers removed: deeper `>` characters stay content."""
+ for _ in range(depth):
+ m = _QUOTE.match(body)
+ if not m:
+ break
+ body = body[m.end():]
+ return body
+
+
+def _containers(body: str) -> tuple[int, int, str]:
+ """(block-quote depth, list-item content column, remainder): quote and item markers are peeled in any order, so
+ `- ```x`, `1. ```x`, `- > ```x` and `> - ```x` all reach the same remainder. The column is the summed width of the
+ item prefixes, measured on the quote-stripped line; it is 0 when the line has no item marker."""
+ depth, col, rest = 0, 0, body
+ while True:
+ if m := _QUOTE.match(rest):
+ depth += 1
+ elif m := _ITEM_PREFIX.match(rest):
+ col += m.end()
else:
- close = re.match(r"^ {0,3}(`{3,}|~{3,})[ \t]*$", line.rstrip("\n"))
- if close and close.group(1)[0] == fence[0] and len(close.group(1)) >= len(fence):
- spans.append((fstart, pos + len(line)))
+ return depth, col, rest
+ rest = rest[m.end():]
+
+
+def _unescaped_pipe(row: str, i: int) -> bool:
+ slashes = 0
+ while i - slashes > 0 and row[i - slashes - 1] == "\\":
+ slashes += 1
+ return not slashes % 2
+
+
+def _cell_bounds(row: str, base: int = 0) -> list[tuple[int, int]]:
+ """(start, end) of each table cell of `row`, split at pipes that are not backslash-escaped."""
+ out, last = [], 0
+ for i, ch in enumerate(row):
+ if ch == "|" and _unescaped_pipe(row, i):
+ out.append((base + last, base + i))
+ last = i + 1
+ out.append((base + last, base + len(row)))
+ return out
+
+
+def _cell_count(rest: str) -> int:
+ row = rest.strip()
+ row = row[1:] if row.startswith("|") else row
+ if row.endswith("|") and _unescaped_pipe(row, len(row) - 1):
+ row = row[:-1]
+ return len(_cell_bounds(row))
+
+
+def _is_delimiter_row(rest: str) -> bool:
+ row = rest.strip()
+ if "|" not in row:
+ return False
+ row = row[1:] if row.startswith("|") else row
+ row = row[:-1] if row.endswith("|") else row
+ return bool(_DELIMITER_CELLS.fullmatch(row))
+
+
+def _interrupts(rest: str, depth: int, para_depth: int, base: int) -> bool:
+ """True when a line (block-quote markers removed) starts a block that ends the open paragraph. A continued quote
+ line (same depth) and a lazy continuation (lower depth) stay in the paragraph, as GitHub renders them; a deeper
+ quote, a fence, an ATX heading, a thematic break, a list item and an HTML block of type 1-6 end it. `base` is the
+ content column of the list item holding the paragraph, so an item nested under it is still seen."""
+ if depth > para_depth:
+ return True
+ lead = len(rest) - len(rest.lstrip(" "))
+ line = rest[min(lead, base):]
+ if FENCE_OPEN.match(line) or _ATX_HEADING.match(line) or _THEMATIC.match(line) or _HTML_BLOCK.match(line):
+ return True
+ if _BULLET_ITEM.match(line):
+ return True
+ ordered = _ORDERED_ITEM.match(line)
+ # only "1." may interrupt a paragraph, but a sibling item of the enclosing list always ends the item
+ return bool(ordered and (ordered.group(1) == "1" or (base and lead < base)))
+
+
+@functools.lru_cache(maxsize=16)
+def _layout(text: str) -> tuple[tuple[tuple[int, int], ...], tuple[tuple[int, int], ...], tuple[int, ...],
+ tuple[tuple[int, int], ...]]:
+ """(inline regions, fenced spans, continuation starts, raw HTML block spans). An inline region is a stretch in which
+ one inline code span can live: a paragraph, a heading line, an HTML block start line or one table cell. A
+ continuation start is the source offset of a line that continues an open paragraph or table (not the first line of
+ a block, not a heading, not a line inside a fence). An HTML block span runs from its first line to just past its
+ last line, excluding a terminating blank line or the line that ended its container."""
+ WORK['layout_builds'] += 1
+ regions: list[tuple[int, int]] = []
+ fences: list[tuple[int, int]] = []
+ html_blocks: list[tuple[int, int]] = []
+ hstart = 0
+ conts: set[int] = set()
+ seg: list[tuple[int, str]] = []
+ # an open fence: (fence run, block-quote depth, list-item content column (0 outside an item), item-before-quote flag)
+ fence, fstart, table, para_depth, base, pos = None, 0, False, 0, 0, 0
+ # open list items as (block-quote depth, content column): the depth of the quote the item sits in and the column its
+ # content starts at, measured on the quote-stripped line. They persist across blank lines (see the list update below)
+ lists: list[tuple[int, int]] = []
+ # an open raw HTML block: (end regex, or None for a block that ends at a blank line; the same container fields)
+ html = None
+
+ def flush(rows):
+ if rows:
+ regions.append((rows[0][0], rows[-1][0] + len(rows[-1][1])))
+
+ def row_cells(at, raw):
+ regions.extend((a, b) for a, b in _cell_bounds(raw.rstrip("\r\n"), at) if a < b)
+
+ for raw in re.findall(r"[^\n]*\n|[^\n]+", text):
+ at, pos = pos, pos + len(raw)
+ body = raw.rstrip("\r\n")
+ depth, rest = _quotes(body)
+ if fence is not None:
+ run, fdepth, col, outer = fence
+ # an item whose marker precedes its quote marker (`- > ```x`) is indented before the quote marker, so its
+ # indentation is the raw one; otherwise it is the indentation left after the quote markers (`> - ```x`)
+ # only the fence's own quote depth is container syntax: extra `>` characters inside it are code content
+ inner = _strip_quotes(body, fdepth)
+ lead = len(body) - len(body.lstrip(" ")) if outer else len(inner) - len(inner.lstrip(" "))
+ if depth < fdepth or (col and depth >= fdepth and rest.strip(" \t") and lead < col):
+ # the quote container of an open fence ended, or a non-blank line at the fence's quote depth OR DEEPER
+ # (a new quote opened by the line) that is indented below the item content column ends the fence
+ # unclosed; the line is then processed as an ordinary line (a dedented `>` begins a real quote)
+ fences.append((fstart, at))
fence = None
- pos += len(line)
+ else:
+ # a depth-0 fence is closed by its raw line, as before; a quoted fence by the line inside its quote; an
+ # item fence by the line after its content column is removed
+ closer = inner if fdepth else body
+ if col:
+ closer = closer[min(len(closer) - len(closer.lstrip(" ")), col):]
+ close = _FENCE_CLOSE.match(closer)
+ if close and close.group(1)[0] == run[0] and len(close.group(1)) >= len(run):
+ fences.append((fstart, pos))
+ fence = None
+ continue
+ if html is not None:
+ end, hdepth, hcol, houter = html
+ inner = _strip_quotes(body, hdepth)
+ lead = len(body) - len(body.lstrip(" ")) if houter else len(inner) - len(inner.lstrip(" "))
+ if depth < hdepth or (hcol and depth >= hdepth and rest.strip(" \t") and lead < hcol):
+ # its quote or list item ended (a dedented line may open a new, deeper quote): no lazy continuation
+ html = None
+ html_blocks.append((hstart, at))
+ elif end is None:
+ if rest.strip(" \t"):
+ continue
+ html = None # a type 6 or 7 block ends at the blank line, which is then handled as usual
+ html_blocks.append((hstart, at))
+ else:
+ # an item's indentation is container syntax, so a terminator (``, `-->`) is found after it is removed
+ if end.search(inner[min(lead, hcol):] if hcol else rest):
+ html = None
+ html_blocks.append((hstart, pos))
+ continue
+ if not rest.strip(" \t"):
+ flush(seg)
+ seg, table, base = [], False, 0
+ continue
+ if table:
+ if not _interrupts(rest, depth, para_depth, 0):
+ row_cells(at, raw)
+ conts.add(at)
+ continue
+ table = False
+ elif seg:
+ lead = len(rest) - len(rest.lstrip(" "))
+ if _is_delimiter_row(rest) and _cell_count(_quotes(seg[-1][1].rstrip("\r\n"))[1]) == _cell_count(rest):
+ flush(seg[:-1])
+ # the header row (the last paragraph line) starts the table, so it is not a continuation
+ conts.discard(seg[-1][0])
+ row_cells(*seg[-1])
+ conts.add(at)
+ seg, table = [], True
+ continue
+ if _SETEXT.match(rest[min(lead, base):]):
+ flush(seg)
+ seg, base = [], 0
+ continue
+ if not _interrupts(rest, depth, para_depth, base):
+ seg.append((at, raw))
+ conts.add(at)
+ continue
+ flush(seg)
+ seg, base = [], 0
+ # the opener may sit behind quote and list-item markers in any order, e.g. `- ```x` or `> - ```x`
+ # CommonMark list items: a blank line keeps the item open and a non-blank line left of an open item's content
+ # column ends it, within the item's own quote. A line of lower quote depth pops every item it leaves; an item
+ # marker pushes its (quote depth, content column). Blank lines never reach this update, so items persist.
+ # `depth` is the leading quote depth, the quote the item marker sits in: `> - x` is an item of quote depth 1, and
+ # `- > x` an item of depth 0 whose quote is item content (measured on the raw line, so its quote markers are not
+ # container syntax for the item's indentation)
+ cdepth, col, crest = _containers(body)
+ inner = _strip_quotes(body, depth)
+ ilead = len(inner) - len(inner.lstrip(" "))
+ while lists and (depth < lists[-1][0] or (depth == lists[-1][0] and ilead < lists[-1][1])):
+ lists.pop()
+ if col:
+ lists.append((depth, col))
+ opener = FENCE_OPEN.match(crest)
+ # an unmarked line indented into an open item may open a fence or raw HTML block at that item's content column
+ in_item = col == 0 and bool(lists) and depth == lists[-1][0] and ilead >= lists[-1][1]
+ item_opener = FENCE_OPEN.match(inner[lists[-1][1]:]) if in_item else None
+ item_html = _html_block(inner[lists[-1][1]:]) if in_item and not item_opener else None
+ if item_opener:
+ fence, fstart = (item_opener.group(1), cdepth, lists[-1][1], not cdepth), at
+ elif opener:
+ fence, fstart = (opener.group(1), cdepth, col, bool(col) and not _QUOTE.match(body)), at
+ elif item_html is not None:
+ # raw HTML indented into an item: its content column is removed before the block's own tests and
+ # terminators, and it ends when a line leaves the item (a dedented line starts Markdown again)
+ hstart, icol = at, lists[-1][1]
+ stripped = inner[icol:]
+ if item_html is not True and item_html.search(stripped):
+ html_blocks.append((at, pos)) # the block closes on its own opening line
+ else:
+ html = (None if item_html is True else item_html, cdepth, icol, not cdepth)
+ elif _ATX_HEADING.match(rest):
+ regions.append((at, pos))
+ elif (kind := _html_block(crest)) is not None:
+ # raw HTML: no inline region, so a backtick on these lines never pairs and the block's images stay visible
+ hstart = at
+ if kind is not True and kind.search(crest):
+ html_blocks.append((at, pos)) # the block closes on its own opening line
+ else:
+ html = (None if kind is True else kind, cdepth, col, bool(col) and not _QUOTE.match(body))
+ elif not _THEMATIC.match(rest):
+ item = _ITEM_PREFIX.match(rest)
+ seg, para_depth, base = [(at, raw)], depth, (item.end() if item else 0)
if fence is not None:
- spans.append((fstart, len(text)))
- return spans
+ fences.append((fstart, len(text)))
+ else:
+ flush(seg)
+ if html is not None:
+ html_blocks.append((hstart, len(text)))
+ return tuple(regions), tuple(fences), tuple(sorted(conts)), tuple(html_blocks)
+
+
+@functools.lru_cache(maxsize=16)
+def continuation_starts(text: str) -> frozenset[int]:
+ """Source offsets of the lines that continue an open paragraph or table (every such line after the first line of
+ its block, and each table row after the header). Headings, block starts and lines inside fences are not included."""
+ return frozenset(_layout(text)[2])
+
+
+def _closing_run(text: str, pos: int, k: int, limit: int) -> tuple[int, int] | None:
+ """First maximal backtick run of exactly `k` backticks at or after `pos` and before `limit`."""
+ while True:
+ p = text.find("`", pos, limit)
+ if p < 0:
+ return None
+ q = _run_end(text, p)
+ if q - p == k:
+ return p, q
+ pos = q
+
+
+@functools.lru_cache(maxsize=16)
+def _region_bounds(text: str) -> tuple[tuple[int, ...], tuple[int, ...]]:
+ """(starts, ends) of the inline regions that hold a backtick, as parallel tuples in document order. Regions are
+ sorted and non-overlapping, so the first region that ends after an offset is found by bisection; a region with no
+ backtick has no code span and is left out."""
+ kept = [(lo, hi) for lo, hi in _layout(text)[0] if "`" in text[lo:hi]]
+ return tuple(lo for lo, _ in kept), tuple(hi for _, hi in kept)
+
+
+def find_inline_code(text: str, start: int = 0) -> tuple[int, int] | None:
+ """(start, end) of the first CommonMark inline code span whose opening run begins at or after `start`. The span
+ runs from the opening backtick run to the end of its closing run; escaping backslashes are not part of it. A
+ backtick preceded by an odd number of backslashes is escaped, so its run starts one character later. The
+ content must be non-empty. It may span single newlines within one paragraph, but the closing run must lie in the
+ same inline region: no blank line, fence, heading, list item, new block quote, thematic break, setext underline,
+ HTML block start (types 1-6) or table cell boundary in between. Fenced text holds no inline span."""
+ los, his = _region_bounds(text)
+ for n in range(bisect.bisect_right(his, start), len(los)):
+ WORK['regions'] += 1
+ lo, hi = los[n], his[n]
+ i = max(start, lo)
+ while (i := text.find("`", i, hi)) >= 0:
+ if i == start and i > 0 and text[i - 1] == "`":
+ # `start` landed inside a run that began earlier, so that run is not an opener at or after `start`
+ i = _run_end(text, i)
+ continue
+ j = _run_end(text, i)
+ backslashes = 0
+ while i - backslashes > 0 and text[i - backslashes - 1] == "\\":
+ backslashes += 1
+ if backslashes % 2:
+ if i + 1 == j:
+ i = j
+ continue
+ run_start = i + 1
+ else:
+ run_start = i
+ closer = _closing_run(text, j, j - run_start, hi)
+ if closer is not None:
+ return run_start, closer[1]
+ i = j
+ return None
+
+
+@functools.lru_cache(maxsize=16)
+def fence_spans(text: str) -> tuple[tuple[int, int], ...]:
+ """Sorted (start, end) of every fenced block, including fences inside block quotes (the cached `_layout` fences)."""
+ return tuple(sorted(_layout(text)[1]))
+
+
+@functools.lru_cache(maxsize=16)
+def html_block_spans(text: str) -> tuple[tuple[int, int], ...]:
+ """Sorted, non-overlapping (start, end) of every raw HTML block (the cached `_layout` HTML spans)."""
+ return tuple(sorted(_layout(text)[3]))
+
+
+@functools.lru_cache(maxsize=16)
+def inline_code_spans(text: str) -> tuple[tuple[int, int], ...]:
+ """Sorted, non-overlapping inline code spans: `find_inline_code` repeated from offset 0, each call resuming at the
+ end of the previous span."""
+ spans: list[tuple[int, int]] = []
+ at = 0
+ while (span := find_inline_code(text, at)) is not None:
+ spans.append(span)
+ at = span[1]
+ return tuple(sorted(spans))
+
+
+def code_spans(text: str) -> list[tuple[int, int]]:
+ """(start, end) offsets of fenced code (``` and ~~~, an unclosed fence runs to the end) and inline code spans."""
+ return sorted(fence_spans(text) + inline_code_spans(text))
def masked(text: str) -> str:
diff --git a/scripts/ctshowcase/readme.py b/scripts/ctshowcase/readme.py
index 2167ddb..8841121 100644
--- a/scripts/ctshowcase/readme.py
+++ b/scripts/ctshowcase/readme.py
@@ -18,6 +18,8 @@
MARK = re.compile(r"\n?(.*?)\n?", re.S)
EMOJI = re.compile("[\U0001F000-\U0001FAFF☀-➿⬀-⯿️]")
SHIELDS = "https://img.shields.io/badge/"
+GENERATED_BADGES = re.compile(r"\r?\n(.*)", re.S)
BOM = "\ufeff"
@@ -175,8 +177,10 @@ def badges(ctx: RepoContext) -> list[tuple[str, str, str | None]]:
topics = set(ctx.meta.get("topics") or [])
rt = [n for t, n in (("claude-skill", "Claude"), ("codex-skill", "Codex")) if t in topics] or ["Claude", "Codex"]
out.append(("Agent skill", f"{SHIELDS}agent%20skill-{shield(' | '.join(rt))}-{b['brand']}", None))
- elif ctx.det.get("category"):
- out.append((f"Type {ctx.det['category']}", f"{SHIELDS}type-{shield(ctx.det['category'])}-{b['brand']}{extra}", None))
+ else:
+ category = standard("types")["types"][ctx.det["type"]].get("badge_category", ctx.det.get("category"))
+ if category:
+ out.append((f"Type {category}", f"{SHIELDS}type-{shield(category)}-{b['brand']}{extra}", None))
lic = ctx.meta.get("license")
if not ctx.public:
out.append(("License proprietary", f"{SHIELDS}license-proprietary-{b['neutral']}", None))
@@ -229,6 +233,110 @@ def badges_html(ctx) -> str:
return '
\n' + "\n".join(parts) + "\n
" +def _eol_normalized(body: str) -> str: + """Line endings unified to LF and only the surrounding newline separators removed; spaces and tabs are kept + because they are significant (an indented row is Markdown code, not the inherited template).""" + return body.replace("\r\n", "\n").replace("\r", "\n").strip("\n") + + +def badge_body(ctx: RepoContext, existing: str | None = None) -> str: + """Refresh owned rows; a missing or changed digest makes a J block authored.""" + if ctx.det["variant"] != "J": + return badges_html(ctx) if ctx.det["variant"] == "F" else badges_md(ctx) + is_legacy = (legacy_profile_badges(ctx.brand, existing or "", ctx.style) + or _eol_normalized(existing or "") in template_badge_bodies(ctx.brand)) + has_license_row = "![License " in (existing or "") or "![AGPL-3.0 or commercial license]" in (existing or "") + if is_legacy and has_license_row and ctx.public and (ctx.meta.get("license") or "").upper() in ("", "NOASSERTION", "OTHER"): + return existing # missing metadata cannot disprove an authored license claim + if existing and existing.strip() and not generated_badges(existing) and not is_legacy: + return existing + body = badges_md(ctx) + previous = GENERATED_BADGES.fullmatch(existing or "") + # The replaces digest must preserve significant whitespace (badge_sha), matching preservation(). + replaced_digest = badge_sha(existing) if is_legacy else previous.group(2) if previous else None + replaces = f" replaces={replaced_digest}" if replaced_digest else "" + return f"\n{body}" + + +def template_badge_body(brand) -> str: + """The exact inherited type row from the repository template, independent of body state.""" + category = standard("types")["types"]["template"]["category"] + return f"}-{brand.raw['badges']['brand']})" + + +def template_badge_bodies(brand) -> tuple[str, str]: + """The inherited type row in its remote form and its exact `--badges local` equivalent.""" + category = standard("types")["types"]["template"]["category"] + slug = re.sub(r"[^a-z0-9]+", "-", f"type-{category}".lower()).strip("-") + return template_badge_body(brand), f"" + + +def legacy_profile_badges(brand, body: str, style: dict | None = None) -> bool: + """Recognize the old generator's ordered language/license/CI rows without today's metadata. + + A type row, custom markup, altered alt/URL pair or duplicate kind makes the block authored. + Both historical remote and local badge paths are accepted. + """ + lines = _eol_normalized(body).split("\n") + if not 1 <= len(lines) <= 3: + return False + kinds = [] + colors = dict(brand.raw["badges"]) + family = {k: (style or {}).get(f"badge_{k}") for k in ("brand", "accent")} + for line in lines: + linked = re.fullmatch(r"\[!\[([^\]]+)\]\((.+)\)\]\((.+)\)", line) + plain = re.fullmatch(r"!\[([^\]]+)\]\((.+)\)", line) + if linked: + alt, url, link = linked.groups() + elif plain: + alt, url = plain.groups() + link = None + else: + return False + suffixes = ["", "?labelColor=1E1E1E"] + links = {None} + if alt.startswith("License ") and alt[8:]: + kind, label, message, color = 1, "license", alt[8:], colors["neutral"] + elif alt == "AGPL-3.0 or commercial license": + kind, label, message, color = 1, "license", "AGPL-3.0 | commercial", colors["neutral"] + links.add("LICENSING.md") + suffixes = [""] + elif alt.startswith("Language ") and alt[9:]: + kind, label, message, color = 0, "language", alt[9:], colors["accent"] + elif alt.startswith("Python ") and alt.endswith("+"): + kind, label, message, color = 0, "python", alt[7:], colors["accent"] + suffixes = ["?logo=python&logoColor=white"] + elif alt.startswith("Node ") and alt.endswith("+"): + kind, label, message, color = 0, "node", alt[5:], colors["accent"] + suffixes = ["?logo=nodedotjs&logoColor=white"] + elif alt in ("CI", "Tests"): + ci = re.fullmatch(r"https://github\.com/([A-Za-z0-9-]+)/(\.github|[A-Za-z0-9-]+)/actions/workflows/([^/?#]+\.ya?ml)/badge\.svg", url) + if not ci or link != url.removesuffix("/badge.svg") or ci.group(2).lower() not in (".github", ci.group(1).lower()): + return False + name = ci.group(3) + expected = "Tests" if "test" in name else "CI" if "ci" in name else None + if alt != expected: + return False + kinds.append(2) + continue + else: + return False + shades = {color, family["accent"]} - {None} if kind == 0 else {color} # a family palette recolours the language row + urls = {f"{SHIELDS}{label}-{shield(message)}-{c}{suffix}" for suffix in suffixes for c in shades} + slug = re.sub(r"[^a-z0-9]+", "-", f"{label}-{message}".lower()).strip("-") + urls.add(f"docs/images/badges/{slug}.svg") + if url not in urls or link not in links: + return False + kinds.append(kind) + return kinds == sorted(set(kinds)) + + +def generated_badges(body: str) -> bool: + """The digest proves that the generated badge rows have not been edited.""" + match = GENERATED_BADGES.fullmatch(body) + return bool(match and match.group(1) == badge_sha(match.group(3))) + + def nav_first(ctx: RepoContext) -> bool: """§7.1 order: hero banner, then the nav line (review q57 `nav` default); brand.yaml `readme.nav_first` restores the nav-first order only if the user answers "Nav line first".""" @@ -266,16 +374,16 @@ def tagline_body(ctx: RepoContext, existing: str | None = None, text: str = "") def header(ctx: RepoContext, keep_banner: str | None = None, title: str | None = None, - tagline: str | None = None) -> str: + tagline: str | None = None, existing_badges: str | None = None) -> str: """The §7.2 header. `tagline` is the tagline block body a caller already decided with tagline_body() (the body it carries over from the header it replaces); None writes the description's tagline, or an empty block.""" tag = wrap("tagline", tagline_body(ctx) if tagline is None else tagline) banner = keep_banner if keep_banner is not None else wrap("banner", banner_html(ctx)) if ctx.det["variant"] == "F": - parts = [*lead(ctx, banner, wrap("nav", nav_line(ctx))), wrap("badges", badges_html(ctx)), tag, "---"] + parts = [*lead(ctx, banner, wrap("nav", nav_line(ctx))), wrap("badges", badge_body(ctx, existing_badges)), tag, "---"] return "\n\n".join(p for p in parts if p) parts = [*lead(ctx, banner, wrap("nav", nav_line(ctx))), '', "
", ""}
+
+
+@pytest.mark.parametrize("boundary", ["***", "---", "___", "- - -", "", "", "", "",
+ "
", "", "## Heading", "===", "-"])
+def test_boundary_lines_stop_the_span(boundary):
+ t = "a ` b\n" + boundary + "\nc " + IMAGE + " `z`\n"
+ # An opener of a raw HTML block (CommonMark 4.6 types 1-6) keeps the next line inside that block, where Markdown
+ # image syntax is literal text; every other boundary leaves the line to the paragraph, so the image renders.
+ in_raw_block = any(a <= t.index("![x]") < b for a, b in mdcode.html_block_spans(t))
+ assert in_raw_block == (boundary in RAW_BLOCK_OPENERS)
+ assert images(t) == ([] if in_raw_block else [("x", "docs/images/missing.png")])
+ assert not covers(t, "![x]")
+
+
+@pytest.mark.parametrize("line", ["b` here", "b` here", "b` here", " "}.get(kind)
+ if prefix:
+ return a + "\n" + prefix + payload + "\n"
+ middle = {"blank": ["", ""], "thematic": ["***"], "setext": ["==="], "html6": [""], "comment": [""],
+ "heading": ["## H"], "fence": ["```", "code", "```"]}[kind]
+ return "\n".join([a, *middle, payload]) + "\n"
+
+
+KINDS = ["blank", "list", "ordered", "quote", "thematic", "setext", "html6", "comment", "heading", "fence"]
+
+
+@pytest.mark.parametrize("kind", KINDS)
+@pytest.mark.parametrize("above", ["Price is 5` per unit", "Run `make"])
+def test_matrix_marker_block_survives(kind, above):
+ t = _stack(kind, above, MARKER)
+ assert [m.group(1) for m in mdcode.find_blocks(readme.MARK, t)] == ["tagline"], kind
+ assert not covers(t, "", True, id="comment"),
+ pytest.param("", True, id="pi"),
+ pytest.param("", True, id="cdata"),
+]
+
+
+@pytest.mark.parametrize("opener,closer,blank", RAW_HTML_PAIRS)
+def test_raw_html_block_keeps_the_image_out_of_code(opener, closer, blank):
+ lead = "\n" if blank else ""
+ t = opener + "\n" + lead + "Use a literal ` character.\n" + RAW_IMG + "\nAnother ` character.\n" + closer + "\n"
+ assert not covers(t, RAW_IMG)
+
+
+# --- controls that must keep working ------------------------------------------------------------------------------
+def test_paragraph_after_a_type_6_block_ends_at_a_blank_line_keeps_its_code():
+ t = "\nhtml\n\n\nRun `npx` now\n"
+ assert [t[a:b] for a, b in mdcode.inline_code_spans(t)] == ["`npx`"]
+
+
+def test_type_7_paragraph_still_pairs_its_backticks():
+ t = "a` b and `c` d\n"
+ assert [t[a:b] for a, b in mdcode.inline_code_spans(t)] == ["` b and `"]
+
+
+def test_type_1_block_closed_on_its_own_line_leaves_the_next_line_as_markdown():
+ t = "x
\nRun `npx` now\n"
+ assert [t[a:b] for a, b in mdcode.inline_code_spans(t)] == ["`npx`"]
+
+
+# --- container-aware and boundary behaviour of the raw HTML block --------------------------------------------------
+def test_quoted_html_block_keeps_the_image_visible_and_ends_with_the_quote():
+ t = "> \n> Use a literal ` character.\n> " + RAW_IMG + "\n> Another ` character.\n> \n"
+ assert mdcode.inline_code_spans(t) == ()
+ lazy = "> \nlazy ` x\n" + IMAGE + "\ny `\n" # an HTML block has no lazy continuation: this is a paragraph
+ assert covers(lazy, IMAGE)
+
+
+def test_list_item_html_block_keeps_the_image_visible_and_ends_left_of_the_item():
+ t = "- \n Use a literal ` character.\n " + RAW_IMG + "\n Another ` character.\n \n"
+ assert mdcode.inline_code_spans(t) == ()
+ left = "- \n x\nUse ` here " + IMAGE + " there `\n"
+ assert covers(left, IMAGE)
+
+
+def test_blank_line_ends_a_type_6_block_and_markdown_resumes_after_it():
+ t = "\nraw\n\n\nUse ` here " + IMAGE + " there `\n"
+ assert covers(t, IMAGE)
+
+
+def test_type_7_tag_alone_on_a_line_is_raw_but_cannot_interrupt_a_paragraph():
+ raw = "\nUse ` here\n" + IMAGE + "\nthere `\n"
+ assert mdcode.inline_code_spans(raw) == ()
+ para = "Run `a\n\nb` c\n"
+ assert [para[a:b] for a, b in mdcode.inline_code_spans(para)] == ["`a\n\nb`"]
+
+
+def test_unclosed_type_2_block_runs_to_the_end_and_masks_nothing():
+ t = "\ntext after\n\npara\n") == ["\n"]
+
+
+def test_unterminated_block_runs_to_end_of_file():
+ text = "intro\n\n\nraw\nmore"
+ assert pieces(text) == ["\nraw\nmore"]
+
+
+def test_block_inside_a_blockquote_ends_with_the_quote():
+ text = "> \n> raw\n\nplain\n"
+ spans = pieces(text)
+ assert len(spans) == 1 and spans[0].startswith("> ") and "plain" not in spans[0]
+
+
+def test_no_block_inside_a_fence():
+ assert pieces("```html\n\nraw\n\n```\n") == []
+
+
+def test_no_block_for_plain_markdown():
+ assert mdcode.html_block_spans("just text\n\n- item\n") == ()
diff --git a/tests/test_mdcode_multiline.py b/tests/test_mdcode_multiline.py
new file mode 100644
index 0000000..6f85839
--- /dev/null
+++ b/tests/test_mdcode_multiline.py
@@ -0,0 +1,55 @@
+"""Inline code spans may cross soft line breaks inside one paragraph, never a blank line, fence or ATX heading."""
+import pytest
+
+from ctshowcase import mdcode
+
+SAMPLE = "Example ```sample\nTODO``` end."
+
+
+def test_multiline_span_covers_both_lines_and_masks_content():
+ spans = mdcode.code_spans(SAMPLE)
+ assert [SAMPLE[a:b] for a, b in spans] == ["```sample\nTODO```"]
+ assert "TODO" not in mdcode.masked(SAMPLE)
+ assert "end." in mdcode.masked(SAMPLE)
+
+
+@pytest.mark.parametrize("between", ["\n\n", "\n# Heading\n", "\n ## Heading\n"])
+def test_blocks_between_opener_and_closer_end_the_search(between):
+ text = "Example ```sample" + between + "TODO``` end."
+ assert mdcode.find_inline_code(text) is None
+
+
+@pytest.mark.parametrize("between", ["\n\n", "\n# Heading\n"])
+def test_no_span_and_content_stays_visible(between):
+ text = "Example ```sample" + between + "TODO``` end."
+ assert mdcode.code_spans(text) == []
+ assert "TODO" in mdcode.masked(text)
+
+
+@pytest.mark.parametrize("fence", ["```", "~~~"])
+def test_fence_line_between_yields_no_inline_span(fence):
+ text = "Example ```sample\n" + fence + "\nTODO``` end."
+ assert mdcode.find_inline_code("Example ```sample\n") is None
+ assert all(a != 8 for a, _ in mdcode.code_spans(text))
+ assert mdcode.code_spans(text) == [(18, len(text))]
+
+
+def test_offsets_absolute_around_fenced_block():
+ before = "Lead ```a\nb``` mid.\n"
+ fence = "\n```\nraw ```x\n```\n\n"
+ after = "Tail ```c\nd``` end."
+ text = before + fence + after
+ spans = mdcode.code_spans(text)
+ assert [text[a:b] for a, b in spans] == ["```a\nb```", "```\nraw ```x\n```\n", "```c\nd```"] or len(spans) == 3
+ assert text[spans[0][0]:spans[0][1]] == "```a\nb```"
+ assert text[spans[-1][0]:spans[-1][1]] == "```c\nd```"
+ fenced = [text[a:b] for a, b in spans[1:-1]]
+ assert fenced == ["```\nraw ```x\n```\n"]
+ masked = mdcode.masked(text)
+ assert "raw" not in masked and "a\nb" not in masked.replace("\x00", "") and "end." in masked
+
+
+@pytest.mark.parametrize("count", range(6))
+def test_parity_still_holds_in_multiline_text(count):
+ text = "x\n" + "\\" * count + "`code`"
+ assert (mdcode.code_spans(text) == []) == bool(count % 2)
diff --git a/tests/test_mdcode_parity.py b/tests/test_mdcode_parity.py
new file mode 100644
index 0000000..b2ced31
--- /dev/null
+++ b/tests/test_mdcode_parity.py
@@ -0,0 +1,33 @@
+"""Inline-code detection uses arbitrary-length backslash parity before every opening backtick."""
+import pytest
+
+from ctshowcase import mdcode
+
+
+@pytest.mark.parametrize("count", range(8))
+def test_find_inline_code_backslash_parity(count):
+ text = "\\" * count + "`code`"
+ found = mdcode.find_inline_code(text)
+ if count % 2:
+ assert found is None
+ else:
+ assert found is not None and text[found[0]:found[1]].endswith("`code`")
+ assert (mdcode.code_spans(text) == []) == bool(count % 2)
+
+
+def test_escaped_backtick_does_not_stop_next_backtick_opening_a_span():
+ text = "\\``code` tail"
+ spans = mdcode.code_spans(text)
+ assert [text[a:b] for a, b in spans] == ["`code`"]
+
+
+@pytest.mark.parametrize("count", range(8))
+def test_masked_hides_only_even_runs(count):
+ text = "\\" * count + "`secret`"
+ assert ("secret" in mdcode.masked(text)) == bool(count % 2)
+
+
+def test_fenced_blocks_unchanged_by_parity():
+ text = "```\n\\``\n```\nafter"
+ assert "y.png" not in mdcode.masked(text)
+ assert "after" in mdcode.masked(text)
diff --git a/tests/test_mdcode_scaling.py b/tests/test_mdcode_scaling.py
new file mode 100644
index 0000000..71a27e5
--- /dev/null
+++ b/tests/test_mdcode_scaling.py
@@ -0,0 +1,114 @@
+"""Deterministic scaling regression for the per-document span caches: the work done by the reference-definition scan
+grows near-linearly with the document, checked by counting layout builds and inline regions visited (mdcode.WORK),
+never by seconds. Documents are synthetic."""
+import pytest
+
+from ctshowcase import mdcode, readmelint
+
+N = 60
+
+
+def reference_doc(n, fenced=False):
+ parts = ["# Synthetic\n"]
+ for i in range(n):
+ parts.append(f"Paragraph {i} with `code {i}` and ![x{i}][ref{i}] and tag em  `more`.\n")
+ if fenced:
+ parts.append(f"\n```text\nblock {i} `tick`\n```\n")
+ parts.append("")
+ parts.append("")
+ parts += [f"[ref{i}]: https://example.test/img{i}.png \"Title {i}\"" for i in range(n)]
+ return "\n".join(parts) + "\n"
+
+
+def reset():
+ mdcode._layout.cache_clear()
+ mdcode.inline_code_spans.cache_clear()
+ mdcode.fence_spans.cache_clear()
+ mdcode.continuation_starts.cache_clear()
+ readmelint._definitions.cache_clear()
+ mdcode._region_bounds.cache_clear()
+ readmelint._definition_spans.cache_clear()
+ readmelint._outside_code.cache_clear()
+ mdcode.WORK.clear()
+
+
+def measure(doc, action):
+ reset()
+ action(doc)
+ return dict(mdcode.WORK)
+
+
+def _spans(doc):
+ assert len(readmelint._definition_spans(doc)) > 0
+
+
+def _markdown(doc):
+ assert readmelint._markdown_images(readmelint._outside_code(doc), doc)
+
+
+def _images(doc):
+ found = readmelint._images(readmelint._outside_code(doc), doc)
+ assert len(found) >= doc.count("![x")
+
+
+ACTIONS = {"definition_spans": _spans, "markdown_images": _markdown, "images": _images}
+
+
+@pytest.mark.parametrize("fenced", [False, True], ids=["prose", "fence-heavy"])
+@pytest.mark.parametrize("name", sorted(ACTIONS))
+def test_work_grows_near_linearly(name, fenced):
+ small = measure(reference_doc(N, fenced), ACTIONS[name])
+ large = measure(reference_doc(2 * N, fenced), ACTIONS[name])
+ # the tokenizer builds the document layout once, however many definitions there are
+ assert small["layout_builds"] == large["layout_builds"] <= 2, (small, large)
+ # a quadratic walk from offset 0 per definition would roughly quadruple the regions visited
+ assert large.get("regions", 0) <= 2.5 * small.get("regions", 0), (small, large)
+
+
+def test_image_and_tag_comparisons_grow_near_linearly():
+ """`_images` matches every HTML tag and Markdown image against the other by interval lookup (bisection), so the
+ comparisons it makes grow with the number of tags and images, not with their product. Counted, never timed."""
+ small = measure(reference_doc(N), _images)
+ large = measure(reference_doc(2 * N), _images)
+ assert small["span_checks"] > 0 and large["span_checks"] > 0, (small, large)
+ assert large["span_checks"] <= 2.5 * small["span_checks"], (small, large)
+
+
+def test_layout_is_built_once_per_document_across_the_whole_path():
+ doc = reference_doc(N, fenced=True)
+ reset()
+ for _ in range(3):
+ readmelint._images(readmelint._outside_code(doc), doc)
+ readmelint._definition_spans(doc)
+ assert mdcode.WORK["layout_builds"] == 1
+
+
+def test_link_title_and_list_continuation_passes_grow_linearly():
+ """The link-destination pass and the list-continuation peeling of `_definition_candidates` run once per document:
+ the layout is built once and the regions visited and span lookups grow with the document. Counted, never timed."""
+ def doc(n):
+ links = "".join(f'[Docs{i}](https://example.test/{i} "\n"
+ assert len(readmelint._definitions(doc(N))) == N
+ small = measure(doc(N), lambda d: readmelint._images(readmelint._outside_code(d), d))
+ large = measure(doc(2 * N), lambda d: readmelint._images(readmelint._outside_code(d), d))
+ assert small["layout_builds"] == large["layout_builds"] <= 2, (small, large)
+ assert large.get("regions", 0) <= 2.5 * small.get("regions", 0), (small, large)
+ assert large["span_checks"] <= 2.5 * small["span_checks"], (small, large)
+
+
+def test_container_definitions_add_no_layout_work_and_grow_linearly():
+ """Definitions inside quote and list containers are read by one pass over the lines (no per-definition rescan):
+ the layout is still built once and the regions visited grow with the document. Counted, never timed."""
+ def doc(n):
+ prefixes = ["> ", "- ", "1. ", "> - "]
+ body = "".join(f"![x{i}][ref{i}] text `c{i}`\n\n" for i in range(n))
+ return body + "".join(f"{prefixes[i % 4]}[ref{i}]: https://example.test/img{i}.png\n\n" for i in range(n))
+ small = measure(doc(N), lambda d: readmelint._images(readmelint._outside_code(d), d))
+ large = measure(doc(2 * N), lambda d: readmelint._images(readmelint._outside_code(d), d))
+ assert len(readmelint._definitions(doc(N))) == N
+ assert small["layout_builds"] == large["layout_builds"] == 1, (small, large)
+ assert large.get("regions", 0) <= 2.5 * small.get("regions", 0), (small, large)
+ assert large["span_checks"] <= 2.5 * small["span_checks"], (small, large)
diff --git a/tests/test_readmelint.py b/tests/test_readmelint.py
index 8401ef6..6844943 100644
--- a/tests/test_readmelint.py
+++ b/tests/test_readmelint.py
@@ -45,3 +45,1381 @@ def test_incomplete_only_needs_input(make_ctx, tmp_path, brand):
r = readmelint.lint(ctx, p)
assert r["result"] == "incomplete" and "needs-input:C:Usage" in r["logs"]
assert footer.check(brand, p.read_text(encoding="utf-8"))[0] == "pass"
+
+
+def _insert_usage(tmp_path, extra):
+ p = tmp_path / "README.md"
+ p.write_text(p.read_text(encoding="utf-8").replace("## License", extra + "\n\n## License", 1), encoding="utf-8")
+ return p
+
+
+def test_image_destination_with_backticks_is_read_unmasked(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ (tmp_path / "docs/images/build`draft`.png").write_bytes(b"x")
+ p = _insert_usage(tmp_path, "![Shot]()")
+ res = readmelint.lint(ctx, p)
+ assert not any("local image" in f for f in res["fails"]), res["fails"]
+
+
+def test_missing_image_with_backticks_is_reported_under_its_real_name(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "![Shot]()")
+ res = readmelint.lint(ctx, p)
+ assert "R7 local image does not resolve: docs/images/build`draft`.png" in res["fails"], res["fails"]
+
+
+def test_multiline_inline_code_placeholder_is_not_flagged(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "Example ```sample\nTODO``` end.")
+ res = readmelint.lint(ctx, p)
+ assert not any("placeholder" in f.lower() for f in res["fails"]), res["fails"]
+ assert not any("TODO" in f for f in res["fails"]), res["fails"]
+
+
+def test_list_item_backtick_does_not_hide_a_missing_image_in_the_next_item(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "- Install `foo\n- See  and `bar`")
+ assert "R7 local image does not resolve: docs/images/missing.png" in readmelint.lint(ctx, p)["fails"]
+
+
+def test_ordered_list_backtick_does_not_hide_a_missing_image_in_the_next_item(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "1. Install `foo\n2. See  and `bar`")
+ assert "R7 local image does not resolve: docs/images/missing.png" in readmelint.lint(ctx, p)["fails"]
+
+
+def test_ordered_list_starting_at_one_second_item(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "1. Install `foo\n1. See  and `bar`")
+ assert "R7 local image does not resolve: docs/images/missing.png" in readmelint.lint(ctx, p)["fails"]
+
+
+def test_angle_destination_backtick_pairs_with_nothing():
+ text = "![A]() ![B]() `code`"
+ found = readmelint._images(readmelint._outside_code(text), text)
+ assert [source for _, source in found] == ["https://example.test/a`b.png", "docs/images/missing.png"]
+
+
+def test_angle_destination_backtick_does_not_hide_the_missing_file(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "![A]() ![B]() `code`")
+ assert "R7 local image does not resolve: docs/images/missing.png" in readmelint.lint(ctx, p)["fails"]
+
+
+def test_plain_destination_backtick_does_not_hide_the_missing_file(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "  `c`")
+ assert "R7 local image does not resolve: missing.png" in readmelint.lint(ctx, p)["fails"]
+
+
+def test_backtick_opener_before_the_closing_bracket_still_wins():
+ text = " y` tail"
+ assert readmelint._images(readmelint._outside_code(text), text) == []
+
+
+def test_reference_definition_inside_multiline_code_is_not_a_definition(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "![Shot][example]\n\nExample `syntax\n[example]: docs/images/missing.png\nend`")
+ res = readmelint.lint(ctx, p)
+ assert not any("local image" in f for f in res["fails"]), res["fails"]
+ text = p.read_text(encoding="utf-8")
+ assert readmelint._images(readmelint._outside_code(text), text) == [] or all(
+ "missing.png" not in source for _, source in readmelint._images(readmelint._outside_code(text), text))
+
+
+def test_reference_definition_with_backtick_label_still_defines_the_image(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "![Shot][`a`]\n\n[`a`]: docs/images/missing.png")
+ assert "R7 local image does not resolve: docs/images/missing.png" in readmelint.lint(ctx, p)["fails"]
+
+
+def test_off_palette_badge_colour_is_reported_once(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = tmp_path / "README.md"
+ p.write_text("# X\n\n\n", encoding="utf-8")
+ assert readmelint.lint(ctx, p)["fails"].count("R7 off-palette badge colour FF0000") == 1
+
+
+# --- Shields badge colour is the last structural segment of the complete destination ------------------------------
+import pytest # noqa: E402
+
+
+def _badge_fails(make_ctx, tmp_path, brand, body):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = tmp_path / "README.md"
+ p.write_text("# X\n\n" + body + "\n", encoding="utf-8")
+ return readmelint.lint(ctx, p)["fails"]
+
+
+@pytest.mark.parametrize("body", [
+ "",
+ "",
+ "",
+ "",
+ "",
+ '
',
+], ids=["apostrophe-probe", "hex-message-before-apostrophe", "escaped-dashes-label", "escaped-underscores-label",
+ "apostrophe-with-query", "html-img-apostrophe"])
+def test_shields_colour_is_the_last_segment(make_ctx, tmp_path, brand, body):
+ assert not any("off-palette" in f for f in _badge_fails(make_ctx, tmp_path, brand, body))
+
+
+@pytest.mark.parametrize("body", [
+ "",
+ "",
+ "",
+ '
',
+])
+def test_genuinely_off_palette_colour_still_fails(make_ctx, tmp_path, brand, body):
+ assert "R7 off-palette badge colour 123ABC" in _badge_fails(make_ctx, tmp_path, brand, body)
+
+
+def test_hex_shaped_message_is_not_taken_for_the_colour():
+ assert readmelint._badge_colour("type-DEADBE's-005ADD") == "005ADD"
+ assert readmelint._badge_colour("a--b-c__d-64748B?logo=x#frag") == "64748B"
+
+
+# --- round-7 container, definition-context, encoded-badge and nested-image regressions ----------------------------
+MISSING = "R7 local image does not resolve: docs/images/missing.png"
+OFF_ABC = "R7 off-palette badge colour ABCDEF"
+
+
+def _usage_fails(make_ctx, tmp_path, brand, snippet):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ return readmelint.lint(ctx, _insert_usage(tmp_path, snippet))["fails"]
+
+
+@pytest.mark.parametrize("snippet", [
+ "See the screenshot below.\n[shot]: ",
+ "Text\n[a]: https://example.test/a\nmore text\n[shot]: ",
+], ids=["probe", "after-a-definition-and-text"])
+def test_definition_like_line_inside_a_paragraph_is_text(make_ctx, tmp_path, brand, snippet):
+ assert MISSING in _usage_fails(make_ctx, tmp_path, brand, snippet)
+
+
+@pytest.mark.parametrize("snippet", [
+ "[shot]: ",
+ "Intro.\n\n[shot]: ",
+ "[a]: https://example.test/a\n[shot]: ",
+], ids=["block-start", "after-blank-line", "after-another-definition"])
+def test_real_block_start_definition_is_not_an_image(make_ctx, tmp_path, brand, snippet):
+ assert MISSING not in _usage_fails(make_ctx, tmp_path, brand, snippet)
+
+
+@pytest.mark.parametrize("label", ["C%23", "C%3F", "C%23%3F"])
+def test_encoded_hash_and_question_mark_in_a_label_do_not_hide_the_colour(make_ctx, tmp_path, brand, label):
+ body = f""
+ assert "R7 off-palette badge colour FF0000" in _badge_fails(make_ctx, tmp_path, brand, body)
+
+
+def test_literal_query_after_the_colour_still_fails(make_ctx, tmp_path, brand):
+ bad = ""
+ assert "R7 off-palette badge colour FF0000" in _badge_fails(make_ctx, tmp_path, brand, bad)
+
+
+def test_encoded_label_with_on_palette_colour_passes(make_ctx, tmp_path, brand):
+ good = ""
+ assert not any("off-palette" in f for f in _badge_fails(make_ctx, tmp_path, brand, good))
+
+
+def test_badge_colour_decodes_only_after_removing_literal_delimiters():
+ assert readmelint._badge_colour("language-C%23-FF0000?x=1#f") == "FF0000"
+ assert readmelint._badge_colour("language-C%3F-005ADD") == "005ADD"
+
+
+def test_backtick_in_a_definition_destination_does_not_hide_the_next_image(make_ctx, tmp_path, brand):
+ snippet = "[unused]: https://example.test/a`b.png\n![Shot]() `tail`"
+ assert MISSING in _usage_fails(make_ctx, tmp_path, brand, snippet)
+
+
+def test_definition_in_fence_stays_literal(make_ctx, tmp_path, brand):
+ fenced = "```text\n[unused]: https://example.test/a`b.png\n```\n\n`tail` "
+ assert MISSING in _usage_fails(make_ctx, tmp_path, brand, fenced)
+
+
+def test_definition_like_line_in_a_code_span_stays_literal(make_ctx, tmp_path, brand):
+ spanned = "Use `a\n[x]: ` end"
+ assert MISSING not in _usage_fails(make_ctx, tmp_path, brand, spanned)
+
+
+def test_failed_outer_image_leaves_its_nested_image(make_ctx, tmp_path, brand):
+ assert MISSING in _usage_fails(make_ctx, tmp_path, brand, "]")
+
+
+def test_resolved_outer_reference_image_still_consumes_its_nested_text(make_ctx, tmp_path, brand):
+ doc = "][ref]\n\n[ref]: https://example.test/ok.png\n"
+ found = readmelint._images(readmelint._outside_code(doc), doc)
+ assert [src for _, src in found] == ["https://example.test/ok.png"]
+
+
+FENCE_TAIL = ["", ""]
+
+
+@pytest.mark.parametrize("opener,body,closer", [
+ ("1. ```bash", " npm test", " ```"), ("- ```text", " npm test", " ```"),
+ ("1. ~~~bash", " npm test", " ~~~"), ("- ~~~text", " npm test", " ~~~"),
+ ("- - ```text", " npm test", " ```"), ("10. ```text", " npm test", " ```"),
+], ids=["ordered", "bullet", "ordered-tilde", "bullet-tilde", "nested-item", "wide-marker"])
+@pytest.mark.parametrize("tail", FENCE_TAIL, ids=["missing-image", "off-palette-badge"])
+def test_item_fence_does_not_hide_what_follows(make_ctx, tmp_path, brand, opener, body, closer, tail):
+ fails = _usage_fails(make_ctx, tmp_path, brand, f"{opener}\n{body}\n{closer}\n\n{tail}")
+ assert MISSING in fails or OFF_ABC in fails
+
+
+@pytest.mark.parametrize("snippet", [
+ "- ```text\n TODO\n ```", "1. ```text\n TODO\n ```", "- > ```text\n > TODO\n > ```",
+ "> - ```text\n> TODO\n> ```", "- ~~~text\n TODO\n ~~~", "- - ```text\n TODO\n ```",
+], ids=["bullet", "ordered", "item-quote", "quote-item", "tilde", "nested-item"])
+def test_placeholder_inside_an_item_fence_is_code(make_ctx, tmp_path, brand, snippet):
+ assert not any("placeholder" in f for f in _usage_fails(make_ctx, tmp_path, brand, snippet))
+
+
+def test_genuine_placeholder_outside_code_still_fails(make_ctx, tmp_path, brand):
+ snippet = "- ```text\n fine\n ```\n\nTODO"
+ assert any("placeholder" in f for f in _usage_fails(make_ctx, tmp_path, brand, snippet))
+
+
+@pytest.mark.parametrize("path,colour", [
+ ("type-example---FF0000", "FF0000"),
+ ("x-a---005ADD", "005ADD"),
+ ("type-example-005ADD", "005ADD"),
+ ("my--label-a--b-64748B", "64748B"),
+], ids=["trailing-dash-off-palette", "trailing-dash-on-palette", "plain", "escaped-dashes"])
+def test_badge_colour_keeps_the_structural_separator_after_a_trailing_dash(path, colour):
+ assert readmelint._badge_colour(path) == colour
+
+
+def test_trailing_dash_message_with_off_palette_colour_is_reported(make_ctx, tmp_path, brand):
+ body = "![Type]()"
+ assert "R7 off-palette badge colour FF0000" in _badge_fails(make_ctx, tmp_path, brand, body)
+
+
+def test_trailing_dash_message_with_palette_colour_passes(make_ctx, tmp_path, brand):
+ body = "![Type]()"
+ assert not any("off-palette" in f for f in _badge_fails(make_ctx, tmp_path, brand, body))
+
+
+def test_extra_quote_markers_in_a_quoted_fence_do_not_hide_a_missing_image(make_ctx, tmp_path, brand):
+ snippet = "> ```text\n> > ```\n> code\n> ```\n> ![Shot]()"
+ assert MISSING in _usage_fails(make_ctx, tmp_path, brand, snippet)
+
+
+# --- a resolved full-reference label is literal text: its backticks never open inline code ---------------------------
+REF_LABEL_DEFINITION = "[docs`ref]: https://example.test/docs"
+REF_LABEL_LAYOUT = ("[Docs][docs`ref] ![Type]() "
+ "![License]() `help`")
+
+
+def test_backtick_in_a_resolved_reference_label_does_not_pair_with_later_code(make_ctx, tmp_path, brand):
+ licence_off = REF_LABEL_LAYOUT.format(type="005ADD", license="FF0000") + "\n\n" + REF_LABEL_DEFINITION
+ assert "R7 off-palette badge colour FF0000" in _badge_fails(make_ctx, tmp_path, brand, licence_off)
+ type_off = REF_LABEL_LAYOUT.format(type="FF0000", license="64748B") + "\n\n" + REF_LABEL_DEFINITION
+ second = tmp_path / "second" # good_readme builds a fresh tree, so the second body gets its own directory
+ second.mkdir()
+ assert "R7 off-palette badge colour FF0000" in _badge_fails(make_ctx, second, brand, type_off)
+
+
+# GFM expectation (test data): orphan junction renders 1 image, raw comment boundary 1, and the inline-comment,
+# valid-reference and consecutive-reference controls (same label, distinct labels, three same label) render 0 images,
+# matching GFM per the 6dcc adjudication.
+IMAGE_IN_CODE = "`![Shot]()`"
+REF_DEF = "\n\n[ref`x]: https://example.test/"
+ORPHAN_JUNCTION_CASES = {
+ "orphan_reference_junction": ("][ref`x] " + IMAGE_IN_CODE + REF_DEF, True),
+ "raw_comment_boundary": ("See [open\n\n](a`b) " + IMAGE_IN_CODE, True),
+ "inline_comment_control": ("[open ](a`b) " + IMAGE_IN_CODE, False),
+ "valid_reference_control": ("[text][ref`x] " + IMAGE_IN_CODE + REF_DEF, False),
+ "consecutive_reference_control": ("[a][ref`x] [b][ref`x] " + IMAGE_IN_CODE + REF_DEF, False),
+ "consecutive_distinct_label_control": (
+ "[a][one`x] [b][two`y] " + IMAGE_IN_CODE + "\n\n[one`x]: https://example.test/\n[two`y]: https://example.test/",
+ False,
+ ),
+ "consecutive_three_same_label_control": ("[a][ref`x] [b][ref`x] [c][ref`x] " + IMAGE_IN_CODE + REF_DEF, False),
+}
+
+
+@pytest.mark.parametrize("extra,reported", list(ORPHAN_JUNCTION_CASES.values()), ids=list(ORPHAN_JUNCTION_CASES))
+def test_orphan_reference_junction_and_comment_controls(make_ctx, tmp_path, brand, extra, reported):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ readme = tmp_path / "README.md"
+ readme.write_text(readme.read_text(encoding="utf8").replace("## Usage", extra + "\n\n## Usage"), encoding="utf8")
+ fails = readmelint.lint(ctx, readme)["fails"]
+ if reported:
+ assert MISSING in fails
+ else:
+ assert fails == []
+
+
+def test_backtick_in_an_unresolved_reference_label_still_opens_code(make_ctx, tmp_path, brand):
+ # no `[docs`ref]:` definition: the label is not a reference, so its backtick pairs with the one before `help`
+ body = REF_LABEL_LAYOUT.format(type="005ADD", license="FF0000")
+ assert "R7 off-palette badge colour FF0000" not in _badge_fails(make_ctx, tmp_path, brand, body)
+
+
+def test_code_before_the_label_keeps_precedence(make_ctx, tmp_path, brand):
+ # the code span opened before the label ends at the label's backtick (CommonMark), so the badge inside it is code
+ body = ("![License]() `x ![Type]()"
+ " [a][docs`ref] y`\n\n" + REF_LABEL_DEFINITION)
+ fails = _badge_fails(make_ctx, tmp_path, brand, body)
+ assert "R7 off-palette badge colour FF0000" in fails
+ assert "R7 off-palette badge colour ABCDEF" not in fails
+
+
+def test_resolved_reference_label_scan_stays_linear():
+ text = "[a][docs`ref] " * 2000 + "\n\n" + REF_LABEL_DEFINITION + "\n"
+ assert len(readmelint._outside_code(text)) == len(text)
+
+
+# --- round-7 reference definitions inside blockquote and list containers --------------------------------------------
+CONTAINER_DEFINITIONS = [
+ "> [shot]: docs/images/missing.png",
+ "- [shot]: docs/images/missing.png",
+ "1. [shot]: docs/images/missing.png",
+ "> - [shot]: docs/images/missing.png",
+ "- > [shot]: docs/images/missing.png",
+ "> > [shot]: docs/images/missing.png",
+ "> [shot]: ",
+]
+CONTAINER_IDS = ["quote", "bullet", "ordered", "quote-bullet", "bullet-quote", "nested-quote", "quote-angle"]
+
+
+@pytest.mark.parametrize("definition", CONTAINER_DEFINITIONS, ids=CONTAINER_IDS)
+def test_container_reference_definition_reports_missing_local_image(make_ctx, tmp_path, brand, definition):
+ assert MISSING in _usage_fails(make_ctx, tmp_path, brand, f"![Screenshot][shot]\n\n{definition}")
+
+
+CONTAINER_CONTROLS = [
+ ("fenced", "```text\n[shot]: docs/images/missing.png\n```"),
+ ("inline-code", "Use `[shot]: docs/images/missing.png` here."),
+ ("indented-code", " [shot]: docs/images/missing.png"),
+ ("paragraph-continuation", "> text\n> [shot]: docs/images/missing.png"),
+]
+
+
+@pytest.mark.parametrize("control", [c for _, c in CONTAINER_CONTROLS], ids=[i for i, _ in CONTAINER_CONTROLS])
+def test_container_control_is_not_a_definition_and_renders_no_image(make_ctx, tmp_path, brand, control):
+ fails = _usage_fails(make_ctx, tmp_path, brand, f"![Screenshot][shot]\n\n{control}")
+ assert MISSING not in fails, fails
+ text = (tmp_path / "README.md").read_text(encoding="utf-8")
+ assert not any("missing.png" in source for _, source in readmelint._images(readmelint._outside_code(text), text))
+
+
+BADGE_TYPE_LINE = "![Type]()"
+BADGE_LICENSE_LINE = "![License][lic]"
+LICENSE_DEFINITION = ""
+
+
+def _variant_j_badge_fails(make_ctx, tmp_path, definition):
+ # Inlined from the Variant J fixture in test_variant_j_badges.py: a profile repo with the header badges replaced
+ # by the reference-style pair under test, and the definition placed after the header and before the footer.
+ (tmp_path / "docs/images").mkdir(parents=True)
+ (tmp_path / "docs/images/banner.png").write_bytes(b"banner")
+ ctx = make_ctx(repo_dir=tmp_path, repo=".github", language=None,
+ description="Community health files for Northwind Trading Co.")
+ text = readme.scaffold(ctx)
+ original = next(m.group(2) for m in readme.MARK.finditer(text) if m.group(1) == "badges")
+ text = text.replace(original, BADGE_TYPE_LINE + "\n" + BADGE_LICENSE_LINE + "\n")
+ text = text.replace("", definition + "\n\n")
+ path = tmp_path / "README.md"
+ path.write_text(text, encoding="utf-8")
+ return readmelint.lint(ctx, path)["fails"]
+
+
+@pytest.mark.parametrize("definition", [
+ f"> [lic]: {LICENSE_DEFINITION}",
+ f"- [lic]: {LICENSE_DEFINITION}",
+], ids=["quote", "bullet"])
+def test_container_definition_counts_its_header_badge(make_ctx, tmp_path, definition):
+ fails = _variant_j_badge_fails(make_ctx, tmp_path, definition)
+ assert not any("2-4 header badges required" in f for f in fails), fails
+
+
+def test_missing_container_definition_still_reports_one_header_badge(make_ctx, tmp_path):
+ fails = _variant_j_badge_fails(make_ctx, tmp_path, "")
+ assert "R7 2-4 header badges required (found 1)" in fails, fails
+
+
+RAW_HTML_IMG = '
'
+
+
+def test_literal_backticks_in_a_raw_html_block_do_not_hide_a_missing_image(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "\nUse a literal ` character.\n" + RAW_HTML_IMG + "\nAnother ` character.\n")
+ assert "R7 local image does not resolve: docs/images/missing.png" in readmelint.lint(ctx, p)["fails"]
+
+
+def test_a_real_img_inside_an_html_block_is_still_counted_for_r7(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "\n" + RAW_HTML_IMG + "\n")
+ assert "R7 local image does not resolve: docs/images/missing.png" in readmelint.lint(ctx, p)["fails"]
+
+
+def test_backticks_after_the_html_block_ends_at_a_blank_line_still_hide_an_image(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "\nraw\n\n\nUse ` and " + RAW_HTML_IMG + " and `")
+ assert not any("local image" in f for f in readmelint.lint(ctx, p)["fails"])
+
+
+# Raw HTML blocks are literal HTML: no live reference definitions, and backslashes are not escapes there.
+def test_definition_inside_a_raw_html_block_is_not_live(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "![Shot][shot]\n\n\n[shot]: docs/images/missing.png\n")
+ assert not any("local image" in f for f in readmelint.lint(ctx, p)["fails"])
+
+
+def test_definition_after_the_html_block_ends_is_live(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "![Shot][shot]\n\n\nraw\n\n\n[shot]: docs/images/missing.png")
+ assert MISSING in readmelint.lint(ctx, p)["fails"]
+
+
+def test_escaped_img_inside_a_raw_html_block_is_still_checked(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "\n\\" + RAW_HTML_IMG + "\n")
+ assert MISSING in readmelint.lint(ctx, p)["fails"]
+
+
+def test_escaped_img_in_an_ordinary_paragraph_is_not_checked(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "\\" + RAW_HTML_IMG)
+ assert not any("local image" in f for f in readmelint.lint(ctx, p)["fails"])
+
+
+DEDENTED_QUOTE_TAIL = "\n\nOrdinary paragraph."
+
+
+def test_dedented_quote_after_a_list_item_fence_is_scanned_for_images(make_ctx, tmp_path, brand):
+ # the unindented `>` cannot continue the list item, so the item and its unclosed fence end and a real quote begins
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "- ```text\n example\n> ![Shot]()" + DEDENTED_QUOTE_TAIL)
+ assert "R7 local image does not resolve: docs/images/missing.png" in readmelint.lint(ctx, p)["fails"]
+
+
+def test_indented_quote_inside_a_list_item_fence_stays_code(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "- ```text\n example\n > ![Shot]()\n ```")
+ assert not any("local image" in f for f in readmelint.lint(ctx, p)["fails"])
+
+
+def test_dedented_quote_badges_after_a_list_item_fence_are_counted(make_ctx, tmp_path, brand):
+ # the header badges become a dedented quote after an unclosed list-item fence: CommonMark renders both images
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = tmp_path / "README.md"
+ text = p.read_text(encoding="utf-8")
+ lines = text.split(chr(10))
+ at = next(i for i, ln in enumerate(lines) if "img.shields.io/badge/" in ln)
+ lines[at:at + 3] = [ # the three derived badges are replaced, so only the quoted pair can be counted
+ "- ```text", " example",
+ "> ![A]()"
+ " ![B]()",
+ "", "Ordinary paragraph.",
+ ]
+ p.write_text(chr(10).join(lines), encoding="utf-8")
+ fails = readmelint.lint(ctx, p)["fails"]
+ assert not any(f.startswith("R7 2-4 header badges") for f in fails), fails
+
+
+# CommonMark 4.6 / GFM: the content of a raw HTML block is literal HTML, so Markdown image syntax there is plain text.
+def _image_fails(make_ctx, tmp_path, brand, snippet):
+ return [f for f in _usage_fails(make_ctx, tmp_path, brand, snippet) if "local image" in f]
+
+
+@pytest.mark.parametrize("snippet", [
+ "\n![Example][shot]\n
\n\n[shot]: docs/images/missing.png",
+ "\n\n
",
+ "\n\n",
+ "\n![Example][shot]\n\n\n[shot]: docs/images/missing.png",
+ "\n\n![Example]()\n \n
",
+], ids=["pre-reference", "pre-inline", "div-inline", "div-reference", "table-angle"])
+def test_markdown_image_syntax_inside_a_raw_html_block_is_literal_text(make_ctx, tmp_path, brand, snippet):
+ assert _image_fails(make_ctx, tmp_path, brand, snippet) == []
+
+
+@pytest.mark.parametrize("snippet", [
+ "\n" + RAW_HTML_IMG + "\n
",
+ "\n" + RAW_HTML_IMG + "\n",
+], ids=["pre", "div"])
+def test_a_real_html_img_inside_a_raw_html_block_is_still_reported(make_ctx, tmp_path, brand, snippet):
+ assert _image_fails(make_ctx, tmp_path, brand, snippet) == [MISSING]
+
+
+@pytest.mark.parametrize("snippet", [
+ "\nraw\n\n",
+ "\nraw\n\n\n![Example][shot]\n\n[shot]: docs/images/missing.png",
+ "\nraw\n
\n",
+ "\nraw
\n\n",
+], ids=["after-blank-in-div", "reference-after-div", "line-after-pre-end", "after-pre-end-line"])
+def test_markdown_image_after_a_raw_html_block_ends_is_still_reported(make_ctx, tmp_path, brand, snippet):
+ assert _image_fails(make_ctx, tmp_path, brand, snippet) == [MISSING]
+
+
+def test_markdown_image_inside_a_fence_stays_literal(make_ctx, tmp_path, brand):
+ assert _image_fails(make_ctx, tmp_path, brand, "```text\n\n```") == []
+
+
+def test_many_images_and_html_blocks_are_scanned_in_linear_work():
+ from ctshowcase import mdcode
+
+ def work(n):
+ doc = "".join(f"\n\n\n\n\n\n" for i in range(n))
+ before = mdcode.WORK["span_checks"]
+ found = readmelint._images(doc, doc)
+ assert [src for _, src in found] == [f"docs/images/y{i}.png" for i in range(n)]
+ return mdcode.WORK["span_checks"] - before
+
+ small, large = work(200), work(1600)
+ assert large <= small * 8 * 2, (small, large)
+
+
+# GFM evidence: gfm-a358-raw_comment_one.html, gfm-a358-raw_comment_three.html, gfm-a358-escaped_comment_outside.html, gfm-a358-bare_destination.html, gfm-a358-valid_destination_control.html; review-cases-a358707026b1de858620e75ee652612c54ae507f-vm.json
+@pytest.mark.parametrize("backslashes", [1, 3])
+def test_raw_html_block_backslash_comment_hides_image(make_ctx, tmp_path, brand, backslashes):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ usage = "\n" + "\\" * backslashes + '\n'
+ p = _insert_usage(tmp_path, usage)
+ fails = readmelint.lint(ctx, p)["fails"]
+ assert "R7 local image does not resolve: docs/images/missing.png" not in fails, fails
+ assert not any(f.startswith("R7") for f in fails), fails
+
+
+def test_backslash_comment_outside_raw_html_still_fails(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, '\\')
+ assert "R7 local image does not resolve: docs/images/missing.png" in readmelint.lint(ctx, p)["fails"]
+
+
+def test_bare_link_destination_backtick_does_not_hide_the_missing_image(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "See ](a`b) `![Shot]()`")
+ res = readmelint.lint(ctx, p)
+ assert "R7 local image does not resolve: docs/images/missing.png" in res["fails"], res["fails"]
+ assert res["result"] == "fail", res
+
+
+def test_valid_link_destination_backtick_still_hides_the_image(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "[See](a`b) `![Shot]()`")
+ fails = readmelint.lint(ctx, p)["fails"]
+ assert "R7 local image does not resolve: docs/images/missing.png" not in fails, fails
+
+# --- round-11 active link openers --------------------------------------------------------------------------------------
+# GFM evidence: gfm-3e-code_opener.html, gfm-3e-attribute_opener.html (real missing image renders); VM-confirmed at 3e85537.
+@pytest.mark.parametrize("body,expect_missing", [
+ ("See `[x` ](a`b) `![Shot]()`", True),
+ ('See x ](a`b) `![Shot]()`', True),
+ ("See [x](ok) ](a`b) `![Shot]()`", True),
+ ("[x[y]](a`b) `![Shot]()`", False),
+], ids=["code_opener", "attribute_opener", "closed_link_control", "nested_valid_control"])
+def test_round11_active_link_opener(make_ctx, tmp_path, brand, body, expect_missing):
+ fails = _usage_fails(make_ctx, tmp_path, brand, body)
+ if expect_missing:
+ assert MISSING in fails, fails
+ else:
+ assert MISSING not in fails, fails
+
+
+@pytest.mark.parametrize("text,expected", [
+ ("`[a](b`", False), # opener inside inline code
+ ('x ](b)', False), # opener inside a tag attribute
+ ("[a]", True), # plain active opener
+ ("\\[a]", False), # escaped opener
+ ("[a\n\nb]", False), # blank line separates the opener from the closer
+], ids=["code-opener", "attribute-opener", "plain", "escaped", "blank-line"])
+def test_has_link_opener_direct(text, expected):
+ assert readmelint._has_link_opener(text, text.index("]")) is expected
+
+
+def test_link_opener_lookup_work_is_linear():
+ def work(n):
+ text = "word ](d) " * n
+ readmelint._active_openers.cache_clear()
+ readmelint._outside_code.cache_clear()
+ readmelint._OPENER_STEPS = 0
+ readmelint._outside_code(text)
+ return readmelint._OPENER_STEPS, len(text)
+
+ w50, len50 = work(50)
+ w100, _ = work(100)
+ w200, len200 = work(200)
+ assert w100 <= 2.5 * w50, (w50, w100)
+ assert w200 <= 2.5 * w100, (w100, w200)
+ assert w50 <= 10 * len50 and w100 <= 10 * len(("word ](d) " * 100)) and w200 <= 10 * len200, (w50, w100, w200)
+
+# --- active link openers reset at real block boundaries ----------------------------------------------------------------
+# GFM oracle (rendered by GitHub): gfm-4f-opener_across_quote.html, gfm-4f-opener_across_list.html,
+# gfm-4f-opener_across_fence.html, gfm-4f-opener_across_heading.html, gfm-4f-opener_across_heading-h2.html and
+# gfm-4f-opener_blank_control.html (showcase-orchestration 20261031 directory). The unmatched `[open` ends its paragraph
+# at each block start, so the orphan `](a`b)` is not a link closer and the following real image renders.
+# Adjudication: t7-adjudication-opener-block-boundary-4f32c0a.md.
+@pytest.mark.parametrize("block", [
+ "> quote",
+ "- item",
+ "~~~text\nfenced\n~~~",
+ "### heading",
+ "## heading",
+ "1. item",
+ "***",
+ "",
+], ids=["quote", "list", "fence", "heading", "heading-h2", "ordered-list", "thematic-break", "blank-control"])
+def test_opener_resets_at_block_boundary(make_ctx, tmp_path, brand, block):
+ readmelint._active_openers.cache_clear()
+ text = f"See [open\n{block}\n](a`b) ![Shot]()"
+ assert readmelint._has_link_opener(text, text.index("](a")) is False
+ fails = _usage_fails(make_ctx, tmp_path, brand, text)
+ assert MISSING in fails, fails
+
+
+# A `[` closed inside its own list item or blockquote is still an active opener there (the reset only cuts a stack
+# across a real block boundary). GFM: the closed link renders inside the block. Adjudication:
+# t7-adjudication-opener-block-boundary-4f32c0a.md.
+@pytest.mark.parametrize("text", [
+ "- [a](b)",
+ "> [a](b)",
+ "> [a\n> ](b)",
+ "- item [x\n continued ](b)",
+ "[a\nb](c)",
+], ids=["list-closed", "quote-closed", "quote-continued", "list-continued", "paragraph-control"])
+def test_opener_closed_inside_block_stays_active(text):
+ readmelint._active_openers.cache_clear()
+ assert readmelint._has_link_opener(text, text.index("]", text.index("["))) is True
+
+
+# A heading line that carries its own unmatched opener does not leak it into the following paragraph line.
+def test_heading_opener_does_not_leak_into_following_paragraph():
+ readmelint._active_openers.cache_clear()
+ text = "## [open\nfoo ](x)"
+ assert readmelint._has_link_opener(text, text.index("]")) is False
+
+
+# --- round-1 (fix plan) raw HTML block openers and nested links ---
+# GFM renders (GitHub, showcase-orchestration 20261031 directory), adjudication
+# t7-adjudication-raw-pre-nested-link-2d3a866.md:
+# gfm-2d3-raw_pre_opener.html -> test_raw_html_block_opener_does_not_leak[pre-no-blank]
+# gfm-2d3-nested_link_opener.html -> test_resolved_inner_link_deactivates_enclosing_link_opener[nested-link]
+# gfm-2d3-raw_pre_inert_control.html -> test_raw_pre_inert_control_has_no_missing_image
+# gfm-2d3-valid_link_control.html -> test_valid_link_control_hides_image_in_code_span
+# Cases without a GFM file yet are marked 'GFM render pending (not yet executed)': they are CommonMark derivations,
+# not verified renders. Other adjudications: t7-adjudication-link-opener-assess-3e855.md and
+# t7-adjudication-opener-block-boundary-4f32c0a.md.
+RAW_TAIL = "](a`b) `![Shot]()`"
+
+
+# A `[` inside a raw HTML block is literal text, so it must not stay active for an orphan `](a`b)` after the block.
+# The `` case has no blank line before the orphan, so only the raw-block boundary separates them. The `` and
+# `` cases end with a blank line, which already clears the stack; they guard the same rendered image.
+@pytest.mark.parametrize("body", [
+ "\n[open\n
\n" + RAW_TAIL,
+ "\n[open\n\n\n" + RAW_TAIL,
+ "\n[open \n
\n\n" + RAW_TAIL,
+], ids=["pre-no-blank", "div-blank-after", "table-blank-after"])
+def test_raw_html_block_opener_does_not_leak(make_ctx, tmp_path, brand, body):
+ readmelint._active_openers.cache_clear()
+ fails = _usage_fails(make_ctx, tmp_path, brand, body)
+ assert MISSING in fails, fails
+
+
+def test_raw_pre_opener_is_not_an_active_opener():
+ readmelint._active_openers.cache_clear()
+ text = "\n[open\n
\n" + RAW_TAIL
+ assert readmelint._has_link_opener(text, text.index("](a")) is False
+
+
+# A balanced bracket pair inside a raw HTML block is inert, and a valid link after the block still hides the image.
+def test_bracket_pair_inside_raw_html_block_does_not_change_later_markdown(make_ctx, tmp_path, brand):
+ readmelint._active_openers.cache_clear()
+ body = "\n[a](b)\n
\n\n[See](a`b) `![Shot]()`"
+ fails = _usage_fails(make_ctx, tmp_path, brand, body)
+ assert MISSING not in fails, fails
+
+
+# Once an inner link forms, CommonMark deactivates every earlier link opener (links may not contain links), so the
+# enclosing `](a`b)` is literal and its backtick pairs normally. An enclosing IMAGE opener stays active. Each body
+# is a static CommonMark trace of the reviewer finding, not a VM run.
+@pytest.mark.parametrize("body,expect_missing", [
+ ("[outer [inner](ok)](a`b) `![Shot]()`", True),
+ ("[a [b [c](x)](y)](a`b) `![Shot]()`", True),
+ ("](a`b) `![Shot]()`", False),
+ ("[x[y]](a`b) `![Shot]()`", False),
+], ids=["nested-link", "three-deep", "image-encloses-link", "bracket-without-destination"])
+def test_resolved_inner_link_deactivates_enclosing_link_opener(make_ctx, tmp_path, brand, body, expect_missing):
+ readmelint._active_openers.cache_clear()
+ fails = _usage_fails(make_ctx, tmp_path, brand, body)
+ if expect_missing:
+ assert MISSING in fails, fails
+ else:
+ assert MISSING not in fails, fails
+
+
+def test_inner_link_closer_active_and_enclosing_link_closer_inactive():
+ readmelint._active_openers.cache_clear()
+ text = "[outer [inner](ok)](z)"
+ active = readmelint._active_openers(text)
+ assert text.rindex("]") not in active
+ assert text.index("]") in active
+
+
+def test_inner_link_does_not_deactivate_enclosing_image_opener():
+ readmelint._active_openers.cache_clear()
+ text = "](z)"
+ active = readmelint._active_openers(text)
+ assert text.index("]") in active
+ assert text.rindex("]") in active
+
+
+# Deterministic counter (not wall-clock): steps per input character must not grow with the nesting depth k.
+def test_nested_link_deactivation_work_is_linear():
+ def ratio(k):
+ text = "[a " * k + "[b](c)" + "](d) " * k
+ readmelint._active_openers.cache_clear()
+ readmelint._OPENER_STEPS = 0
+ readmelint._active_openers(text)
+ return readmelint._OPENER_STEPS / len(text)
+
+ ratios = [ratio(k) for k in (100, 200, 400)]
+ assert max(ratios) <= 1.5 * min(ratios), ratios
+
+
+# --- round-2 invariant table (2d3 fix2): raw block types, reference links, adjacent links, linear reference work ----
+# Invariants covered: (1) raw HTML block text creates no openers, with the block-end conditions; (2) a completed link
+# deactivates earlier LINK openers, including reference-style links, while IMAGE openers stay; (3) unmatched brackets and
+# adjacent links behave as in CommonMark. GFM citations: the raw-pre inert control and the valid-link control above
+# (gfm-2d3-raw_pre_inert_control.html, gfm-2d3-valid_link_control.html). Every other case is a CommonMark 4.6 / 6.3
+# derivation (GFM render pending (not yet executed) unless a gfm--* file is cited). The nine exact oracle cases of
+# t7-adjudication-reference-links-e90d748.md are pinned, with their GFM files, in test_e90_gfm_oracle_cases.
+
+
+# Raw HTML block types (CommonMark 4.6). Type 1 (script/pre/style/textarea) ends at its end tag, which may be on the start
+# line, and it does NOT end at a blank line; type 7 ends at a blank line. The `[open` is raw text, so the orphan `](a`b)`
+# after the block is not a closer and the following image renders (MISSING reported).
+# GFM renders exist for script/style/textarea/type-7 (gfm-e90-raw_{script,style,textarea,type7}_opener.html, pinned in
+# test_e90_gfm_oracle_cases); the other ids here are derivations: GFM render pending (not yet executed).
+@pytest.mark.parametrize("body", [
+ "\n" + RAW_TAIL, # type 1 script, end tag on its own later line
+ "\n" + RAW_TAIL, # type 1 style, end tag on its own later line
+ "\n" + RAW_TAIL, # type 1 textarea, end tag on its own later line
+ "[open
\n" + RAW_TAIL, # type 1 pre, end condition met on the start line
+ "\n[open\n\nstill raw\n
\n" + RAW_TAIL, # type 1 pre, a blank line inside does NOT end the block
+ "\n[open\n\n" + RAW_TAIL, # type 7, ends at the blank line
+], ids=[
+ "type1-script-end-tag-later-line",
+ "type1-style-end-tag-later-line",
+ "type1-textarea-end-tag-later-line",
+ "type1-pre-end-tag-on-start-line",
+ "type1-pre-blank-line-inside-not-an-end",
+ "type7-custom-tag-ends-at-blank-line",
+])
+def test_raw_html_block_types_do_not_leak_openers(make_ctx, tmp_path, brand, body):
+ readmelint._active_openers.cache_clear()
+ fails = _usage_fails(make_ctx, tmp_path, brand, body)
+ assert MISSING in fails, fails
+
+
+# GFM render: gfm-2d3-raw_pre_inert_control.html (no image). Everything inside a raw block is literal text.
+def test_raw_pre_inert_control_has_no_missing_image(make_ctx, tmp_path, brand):
+ readmelint._active_openers.cache_clear()
+ body = "\n[open\n![Shot]()\n
"
+ fails = _usage_fails(make_ctx, tmp_path, brand, body)
+ assert MISSING not in fails, fails
+
+
+# GFM render: gfm-2d3-valid_link_control.html (no image). The valid link's destination holds a backtick, and the next
+# backtick pair is a real code span that hides the image.
+def test_valid_link_control_hides_image_in_code_span(make_ctx, tmp_path, brand):
+ readmelint._active_openers.cache_clear()
+ body = "[outer](a`b) `![Shot]()`"
+ fails = _usage_fails(make_ctx, tmp_path, brand, body)
+ assert MISSING not in fails, fails
+
+
+REF_DEFINITION = "\n\n[ref]: https://example.test/r"
+
+
+# A completed reference link (full, collapsed or shortcut, with a live definition) deactivates the enclosing link
+# opener, so the orphan `](a`b)` is literal and the image renders. The exact GFM-rendered cases are in
+# test_e90_gfm_oracle_cases below (gfm-e90-inner_*_reference.html).
+@pytest.mark.parametrize("usage", [
+ "[outer [x][ref]](a`b) `![Shot]()",
+ "[outer [ref][]](a`b) `![Shot]()",
+ "[outer [ref]](a`b) `![Shot]()",
+], ids=["full", "collapsed", "shortcut"])
+def test_reference_style_inner_link_deactivates_enclosing_link_opener(make_ctx, tmp_path, brand, usage):
+ readmelint._active_openers.cache_clear()
+ fails = _usage_fails(make_ctx, tmp_path, brand, usage + REF_DEFINITION)
+ assert MISSING in fails, fails
+
+
+# GFM render pending (not yet executed). Controls that must NOT report the image: an image opener inside a reference
+# link keeps its own closer active, and an unresolved reference label completes no link, so the enclosing `](a`b)` is
+# a link and the code span hides the image.
+@pytest.mark.parametrize("usage", [
+ "![alt [x][ref]](a`b) `![Shot]()`",
+ "[outer [x][nope]](a`b) `![Shot]()`",
+], ids=["image-opener-control", "unresolved-label-control"])
+def test_reference_style_controls_do_not_report_the_image(make_ctx, tmp_path, brand, usage):
+ readmelint._active_openers.cache_clear()
+ fails = _usage_fails(make_ctx, tmp_path, brand, usage + REF_DEFINITION)
+ assert MISSING not in fails, fails
+
+
+# Direct opener state for the reference-style nested link (derived from the CommonMark rule that links may not contain
+# links, matching gfm-e90-inner_full_reference.html): the inner closer stays active and the enclosing link closer is
+# inactive.
+def test_reference_inner_link_closer_active_and_enclosing_link_closer_inactive():
+ readmelint._active_openers.cache_clear()
+ text = "[outer [x][ref]](z)" + REF_DEFINITION
+ active = readmelint._active_openers(text)
+ assert text.index("]") in active
+ assert text.index("]](z)") + 1 not in active
+
+
+# GFM render pending (not yet executed). Adjacent links `[a](b)[c](d)` both complete, and a code span after them hides
+# the image. An unmatched `[c` is closed by `](d`e)` (a valid link whose destination holds a backtick), so the code span
+# after it still hides the image. Direct `_has_link_opener` assertions pin the closers.
+@pytest.mark.parametrize("body,expect_missing", [
+ ("[a](b)[c](d) `![Shot]()`", False),
+ ("[a](b)[c](d`e) `![Shot]()`", False),
+ ("[open text] more ![Shot]()", True), # unmatched bracket pair, no link: image renders
+], ids=["adjacent-links", "adjacent-link-with-backtick-destination", "unmatched-bracket-pair"])
+def test_adjacent_links_and_unmatched_brackets(make_ctx, tmp_path, brand, body, expect_missing):
+ readmelint._active_openers.cache_clear()
+ fails = _usage_fails(make_ctx, tmp_path, brand, body)
+ if expect_missing:
+ assert MISSING in fails, fails
+ else:
+ assert MISSING not in fails, fails
+
+
+def test_adjacent_links_direct_openers():
+ readmelint._active_openers.cache_clear()
+ text = "[a](b)[c](d`e) `![Shot]()`"
+ assert readmelint._has_link_opener(text, text.index("]")) is True # closes [a
+ assert readmelint._has_link_opener(text, text.index("](d")) is True # closes [c
+
+
+# Deterministic counter (not wall-clock): reference-link work per character must not grow with the nesting depth k.
+# The `[ref]: x` definition is appended so the reference labels are resolvable in the same text.
+def test_reference_link_work_is_linear():
+ def ratio(k):
+ text = "[a " * k + "[b][ref]" + "](d) " * k + "\n\n[ref]: x"
+ readmelint._active_openers.cache_clear()
+ readmelint._OPENER_STEPS = 0
+ readmelint._active_openers(text)
+ return readmelint._OPENER_STEPS / len(text)
+
+ ratios = [ratio(k) for k in (100, 200, 400)]
+ assert max(ratios) <= 1.5 * min(ratios), ratios
+
+
+# The nine cases of t7-adjudication-reference-links-e90d748.md. Each has a live GitHub GFM render in the
+# showcase-orchestration 20261031 directory (gfm-e90-.html): the eight True cases render one real image (so R7 must
+# report it missing) and the undefined-reference control renders none (an unresolved reference is plain text, not a link,
+# so it must NOT deactivate the enclosing opener).
+_IMG = "`![Shot]()`"
+E90_CASES = [
+ ("inner_full_reference", "[outer [inner][ref]](a`b) " + _IMG + "\n\n[ref]: ok", True),
+ ("inner_collapsed_reference", "[outer [inner][]](a`b) " + _IMG + "\n\n[inner]: ok", True),
+ ("inner_shortcut_reference", "[outer [ref]](a`b) " + _IMG + "\n\n[ref]: ok", True),
+ ("undefined_reference_control", "[outer [inner][ref]](a`b) " + _IMG, False),
+ ("raw_script_opener", "\n](a`b) " + _IMG, True),
+ ("raw_style_opener", "\n](a`b) " + _IMG, True),
+ ("raw_textarea_opener", "\n](a`b) " + _IMG, True),
+ ("adjacent_links_opener", "[a](b)[c](d) ](a`b) " + _IMG, True),
+ ("raw_type7_opener", "\n[open\n \n\n](a`b) " + _IMG, True),
+]
+
+
+@pytest.mark.parametrize("body,expect_missing", [(b, m) for _, b, m in E90_CASES], ids=[c[0] for c in E90_CASES])
+def test_e90_gfm_oracle_cases(make_ctx, tmp_path, brand, body, expect_missing):
+ readmelint._active_openers.cache_clear()
+ fails = _usage_fails(make_ctx, tmp_path, brand, body)
+ assert (MISSING in fails) is expect_missing, fails
+
+
+# --- r4: list-continuation fences, setext boundaries, failed-destination work (#7) ---------------------------------------
+# GFM oracle: gfm-77004-list_continuation_fence.html, gfm-77004-setext_boundary.html (each renders 1 image) and
+# gfm-77004-list_fence_inert_control.html (0 images). Adjudication: t7-adjudication-blocks-failed-target-77004e6.md.
+@pytest.mark.parametrize("snippet", [
+ "- Example\n\n ```text\n code\n\n![Shot]()",
+ "1. x\n\n ```text\n code\n\n![Shot]()",
+ "[open\n--\n](a`b) `![Shot]()`",
+ "[open\n==\n](a`b) `![Shot]()`",
+], ids=["list-fence", "ordered-list-fence", "setext-dashes", "setext-equals"])
+def test_r4_missing_image_is_reported(make_ctx, tmp_path, brand, snippet):
+ assert MISSING in _usage_fails(make_ctx, tmp_path, brand, snippet)
+
+
+def test_r4_list_fence_inert_control_reports_nothing(make_ctx, tmp_path, brand):
+ snippet = "- Example\n\n ```text\n ![Shot]()\n ```"
+ assert MISSING not in _usage_fails(make_ctx, tmp_path, brand, snippet)
+
+
+@pytest.mark.parametrize("underline", ["--", "=="])
+def test_r4_setext_underline_leaves_no_opener_for_the_orphan_bracket(underline):
+ text = f"[open\n{underline}\n](a`b) `![Shot]()`"
+ readmelint._active_openers.cache_clear()
+ assert text.index("]") not in readmelint._active_openers(text)
+
+
+def test_r4_dashes_after_a_blank_line_are_not_a_setext_underline():
+ text = "[open\n\n--\n[x](y)"
+ assert readmelint._block_boundary(text, text.index("\n--"), ()) is False
+
+
+def _failed_destination_steps(n, unit):
+ text = unit * n
+ readmelint._active_openers.cache_clear()
+ readmelint._outside_code.cache_clear()
+ readmelint._title_ends.cache_clear()
+ readmelint._DEST_STEPS = 0
+ readmelint._active_openers(text)
+ openers = readmelint._DEST_STEPS
+ readmelint._active_openers.cache_clear()
+ readmelint._outside_code.cache_clear()
+ readmelint._DEST_STEPS = 0
+ readmelint._outside_code(text)
+ return openers, readmelint._DEST_STEPS
+
+
+@pytest.mark.parametrize("unit", ["[x](", "[x](a ("], ids=["unmatched-destination", "title-scan"])
+def test_r4_failed_destination_work_per_candidate_is_constant(unit):
+ steps = {n: _failed_destination_steps(n, unit) for n in (50, 100, 200, 400)}
+ for index in (0, 1): # _active_openers, then _outside_code (the next_destination path)
+ assert steps[400][index] <= 2.5 * steps[200][index], steps
+ assert steps[200][index] <= 2.5 * steps[100][index], steps
+ assert steps[400][index] <= 300 * 400, steps # a bounded number of reads per candidate (nesting limit 32)
+
+
+def test_r4_opener_steps_stay_linear_for_unmatched_candidates():
+ seen = []
+ for n in (50, 100, 200):
+ readmelint._active_openers.cache_clear()
+ readmelint._outside_code.cache_clear()
+ readmelint._OPENER_STEPS = 0
+ readmelint._outside_code("[x](" * n + "x")
+ seen.append(readmelint._OPENER_STEPS)
+ assert seen == [251, 501, 1001], seen
+
+
+def test_r4_destination_nesting_deeper_than_32_is_no_link():
+ def target(depth):
+ text = "x(" + "(" * depth + "a" + ")" * depth + ")"
+ return readmelint._inline_image_target(text, 1)
+
+ assert target(32) is not None # 32 nested parentheses still form a destination
+ assert target(33) is None # 33 is beyond CommonMark's nesting limit: not a link
+
+
+# --- r3 (8783e0e): nested containers, invalid reference labels, list-continuation HTML (#7) -------------------------------
+# GFM oracle: gfm-878-nested-containers-summary.json (an image renders in each failing input; none in each control).
+# Adjudication: t7-adjudication-nested-containers-8783.md.
+_HTML_IMG = '
'
+NESTED_CASES = [
+ # 1. an unescaped opening bracket makes the label invalid, so the line is ordinary Markdown and the image renders
+ ("invalid_label", "Intro.\n\n[bad[label]: ", True),
+ ("escaped_bracket_control", "Intro.\n\n[ok\\[label]: ", False),
+ # 2. raw HTML indented into a list item: the item's four spaces are container syntax, the backticks are literal
+ ("item_html_backticks", "- Example\n\n \n One ` character.\n " + _HTML_IMG + "\n Another ` character.\n ", True),
+ ("item_html_real_code_control", "- Example\n\n \n One `code` here.\n " + _HTML_IMG + "\n ", True),
+ ("item_inline_code_control", "- Example\n\n Text `" + _HTML_IMG + "` more", False),
+ ("item_fenced_control", "- Example\n\n ```html\n " + _HTML_IMG + "\n ```", False),
+ # 3. a fence opened on a list continuation line inside a quote ends where the line dedents out of the quote item
+ ("quoted_list_fence", "> - Example\n>\n> ```text\n> code\n\n> ", True),
+ ("quoted_list_fence_quote_blank", "> - Example\n>\n> ```text\n> code\n>\n> ", True),
+ ("quoted_list_closed_fence_control", "> - Example\n>\n> ```text\n> \n> ```", False),
+]
+
+
+@pytest.mark.parametrize("body,expect_missing", [(b, m) for _, b, m in NESTED_CASES], ids=[c[0] for c in NESTED_CASES])
+def test_r3_nested_container_cases(make_ctx, tmp_path, brand, body, expect_missing):
+ readmelint._active_openers.cache_clear()
+ fails = _usage_fails(make_ctx, tmp_path, brand, body)
+ assert (MISSING in fails) is expect_missing, fails
+
+
+# --- Link destinations and titles are literal; comment openers inside complete tags are text ---------------------
+def _profile_readme(make_ctx, tmp_path):
+ (tmp_path / "docs/images").mkdir(parents=True)
+ (tmp_path / "docs/images/banner.png").write_bytes(b"banner")
+ ctx = make_ctx(repo_dir=tmp_path, repo=".github", language=None,
+ description="Community health files for Northwind Trading Co.")
+ return ctx, readme.scaffold(ctx)
+
+
+def test_image_text_in_a_link_title_is_not_a_badge(make_ctx, tmp_path):
+ ctx, text = _profile_readme(make_ctx, tmp_path)
+ license_row = next(line for line in text.splitlines() if "license-MIT-" in line)
+ path = tmp_path / "README.md"
+ path.write_text(text, encoding="utf-8")
+ assert not any("badge" in f for f in readmelint.lint(ctx, path)["fails"])
+ title = '[Docs](https://example.test "")'
+ path.write_text(text.replace(license_row, title), encoding="utf-8")
+ assert "R7 2-4 header badges required (found 1)" in readmelint.lint(ctx, path)["fails"]
+
+
+def test_images_inside_a_link_title_are_neither_validated_nor_counted(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, '[Docs](https://example.test "'
+ ''
+ '
")')
+ fails = readmelint.lint(ctx, p)["fails"]
+ assert not any("missing.png" in f or "off-palette" in f for f in fails), fails
+
+
+def test_linked_image_in_link_text_is_still_validated(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, "[](https://example.test)")
+ assert "R7 local image does not resolve: docs/images/missing.png" in readmelint.lint(ctx, p)["fails"]
+
+
+def test_comment_opener_inside_an_html_attribute_does_not_blank_a_definition(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, 'Read note.\n\n![Shot][shot]\n\n'
+ '[shot]: docs/images/missing.png\n\n')
+ assert "R7 local image does not resolve: docs/images/missing.png" in readmelint.lint(ctx, p)["fails"]
+
+
+def test_plain_comment_before_a_definition_still_blanks_properly(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, '\n\n![Shot][shot]\n\n'
+ '[shot]: docs/images/banner.png')
+ fails = readmelint.lint(ctx, p)["fails"]
+ assert not any("missing.png" in f for f in fails), fails
+
+
+def test_real_comment_with_tag_like_text_still_hides_a_definition(make_ctx, tmp_path, brand):
+ ctx = good_readme(make_ctx, tmp_path, brand)
+ p = _insert_usage(tmp_path, '\n\n'
+ '![Shot][shot]\n\n[shot]: docs/images/banner.png')
+ fails = readmelint.lint(ctx, p)["fails"]
+ assert not any("missing.png" in f for f in fails), fails
+
+
+# --- Round 6: list-continuation definitions, link-title comment openers ---------------------------------------------
+LIST_CONTINUATION = [
+ "1. Entry\n\n [shot]: docs/images/missing.png",
+ "- Entry\n\n [shot]: docs/images/missing.png",
+ "- Outer\n - Inner\n\n [shot]: docs/images/missing.png",
+ "1. Outer\n\n 1. Inner\n\n [shot]: docs/images/missing.png",
+ "> 1. Entry\n>\n> [shot]: docs/images/missing.png",
+ "- > Entry\n >\n > [shot]: docs/images/missing.png",
+ "1. Entry\n\n\t[shot]: docs/images/missing.png",
+]
+LIST_CONTINUATION_IDS = ["ordered", "bullet", "nested-bullet", "nested-ordered", "quote-list",
+ "list-quote", "tab"]
+
+
+@pytest.mark.parametrize("snippet", LIST_CONTINUATION, ids=LIST_CONTINUATION_IDS)
+def test_list_continuation_definition_reports_the_missing_file(make_ctx, tmp_path, brand, snippet):
+ assert MISSING in _usage_fails(make_ctx, tmp_path, brand, f"![Shot][shot]\n\n{snippet}")
+
+
+@pytest.mark.parametrize("control", [
+ " [shot]: docs/images/missing.png",
+ "Paragraph.\n\n [shot]: docs/images/missing.png",
+ "1. Entry\n\nParagraph closes the list.\n\n [shot]: docs/images/missing.png",
+ "- Entry\n\n Still in the item.\n\ntext\n\n [shot]: docs/images/missing.png",
+ "1. Entry\n [shot]: docs/images/missing.png",
+], ids=["no-list", "after-paragraph", "list-closed", "list-closed-bullet", "lazy-continuation-text"])
+def test_indented_definition_without_an_open_list_item_is_code(make_ctx, tmp_path, brand, control):
+ fails = _usage_fails(make_ctx, tmp_path, brand, f"![Shot][shot]\n\n{control}")
+ assert MISSING not in fails, fails
+
+
+LINK_TITLE_DOC = '[Docs](https://example.test ""))
+
+
+def test_comment_opener_in_a_single_quoted_link_title_does_not_hide_a_definition(make_ctx, tmp_path, brand):
+ doc = LINK_TITLE_DOC.replace('"")
+ assert MISSING in _usage_fails(make_ctx, tmp_path, brand, doc)
+
+
+def test_real_comment_still_blanks_a_definition_it_hides(make_ctx, tmp_path, brand):
+ doc = '[Docs](https://example.test "title")\n\n\n\n![Shot][shot]'
+ fails = _usage_fails(make_ctx, tmp_path, brand, doc)
+ assert MISSING not in fails, fails
+
+
+def test_real_comment_after_a_link_title_opener_still_blanks_a_definition(make_ctx, tmp_path, brand):
+ doc = ('[Docs](https://example.test "\n\n![Shot][shot]\n\n'
+ '[shot]: docs/images/banner.png')
+ fails = _usage_fails(make_ctx, tmp_path, brand, doc)
+ assert MISSING not in fails, fails
+
+
+def test_unterminated_inline_comment_opener_is_unchanged(make_ctx, tmp_path, brand):
+ doc = "text ` is an HTML block running to the end of the document: nothing after it is live
+ doc = "\n\n" + BADGE_LICENSE_LINE + "\n"
+ path = tmp_path / "README.md"
+ path.write_text(text.replace(original, body), encoding="utf-8")
+ return readmelint.lint(ctx, path)["fails"]
+
+
+def test_completed_link_opener_is_not_reused_for_an_orphan_destination(make_ctx, tmp_path):
+ # GitHub GFM (gfm-f774-orphan_comment_badge.html) renders BOTH badges: the second `](` has no active opener, and an inline ``, so it is literal text and `[lic]` stays a live definition.
+ fails = _header_with(make_ctx, tmp_path, COMPLETED_LINK)
+ assert not any('2-4 header badges required' in f for f in fails), fails
+
+
+def test_genuine_link_title_still_protects_its_comment_opener(make_ctx, tmp_path):
+ fails = _header_with(make_ctx, tmp_path, '[a](u "'
+ assert MISSING in _usage_fails(make_ctx, tmp_path, brand, doc)
+
+
+# --- parenthesized link titles and ordered-list paragraph interruption (#7 f774 static findings) ---------------------
+
+def test_unescaped_paren_in_a_parenthesized_title_does_not_hide_a_missing_image(make_ctx, tmp_path, brand):
+ # The unescaped `(` in `](docs/...` makes the parenthesized title invalid, so GFM renders the Shot image and its missing file must be reported.
+ doc = '[Docs](https://example.test (see ))'
+ assert MISSING in _usage_fails(make_ctx, tmp_path, brand, doc)
+
+
+def test_unescaped_paren_in_a_parenthesized_title_does_not_hide_an_off_palette_badge(make_ctx, tmp_path, brand):
+ # Same invalid title shape: the off-palette badge renders, so its colour must be reported.
+ doc = '[Docs](https://example.test (see ))'
+ assert OFF_ABC in _usage_fails(make_ctx, tmp_path, brand, doc)
+
+
+def test_escaped_parens_in_a_parenthesized_title_keep_the_image_inert(make_ctx, tmp_path, brand):
+ # Every paren inside the title is backslash-escaped, so the title is valid and the text inside it renders no image.
+ doc = '[Docs](https://example.test (see ![Shot]\\(docs/images/missing.png\\)))'
+ assert MISSING not in _usage_fails(make_ctx, tmp_path, brand, doc)
+
+
+def test_plain_parenthesized_title_still_reports_a_later_missing_image(make_ctx, tmp_path, brand):
+ # A valid title closes on its own line, and the image on the following paragraph renders, so it must be reported.
+ doc = '[Docs](https://example.test (just a title))\n\n'
+ assert MISSING in _usage_fails(make_ctx, tmp_path, brand, doc)
+
+
+GITHUB_PROFILE_BADGE = "https://img.shields.io/badge/type-GitHub%20profile-005ADD"
+
+
+def test_ordered_marker_two_cannot_interrupt_a_multiline_badge_alt(make_ctx, tmp_path):
+ # `2.` cannot interrupt a paragraph, so the multiline badge is one valid image and the header holds two badges.
+ fails = _header_with(make_ctx, tmp_path, "", type_line=f"")
+ assert not any("2-4 header badges required" in f for f in fails), fails
+
+
+def test_ordered_marker_one_interrupts_a_multiline_badge_alt(make_ctx, tmp_path):
+ # `1.` interrupts the paragraph, so the badge is not an image and the header holds only one.
+ fails = _header_with(make_ctx, tmp_path, "", type_line=f"")
+ assert any("2-4 header badges required" in f for f in fails), fails
+
+
+def test_bullet_marker_interrupts_a_multiline_badge_alt(make_ctx, tmp_path):
+ # A bullet always interrupts the paragraph, so the badge is not an image and the header holds only one.
+ fails = _header_with(make_ctx, tmp_path, "", type_line=f"")
+ assert any("2-4 header badges required" in f for f in fails), fails
+
+
+def test_ordered_marker_two_inside_a_list_item_does_not_render_a_badge(make_ctx, tmp_path):
+ # Control: the `2.` at indent 0 starts a new ordered list outside the bullet item and cannot interrupt the item's paragraph, so no badge renders.
+ fails = _header_with(make_ctx, tmp_path, "", type_line=f"- ")
+ assert any("2-4 header badges required" in f for f in fails), fails
+
+
+def test_block_comment_across_a_blank_line_still_hides_an_image(make_ctx, tmp_path, brand):
+ doc = ''
+ assert MISSING not in _usage_fails(make_ctx, tmp_path, brand, doc)
+
+
+def test_single_paragraph_inline_comment_still_hides_an_image(make_ctx, tmp_path, brand):
+ doc = 'text end'
+ assert MISSING not in _usage_fails(make_ctx, tmp_path, brand, doc)
+
+
+def test_inline_comment_closed_in_its_paragraph_does_not_fail_the_badge_count(make_ctx, tmp_path):
+ fails = _header_with(make_ctx, tmp_path, 'text [Docs](https://example.test)')
+ assert not any('2-4 header badges required' in f for f in fails), fails
+
+
+# --- round-5 (#7 f774 / 26c4933 findings): title crossing a block boundary, multiline labels, tab columns ----------
+
+HEADING_TITLE = '[Docs](https://example.test "unfinished\n## Usage\n{image}\n")'
+
+
+def test_title_crossing_a_heading_does_not_hide_a_missing_image(make_ctx, tmp_path, brand):
+ # The `## Usage` heading ends the paragraph, so the local image after it renders and must be reported.
+ doc = HEADING_TITLE.format(image="")
+ assert MISSING in _usage_fails(make_ctx, tmp_path, brand, doc)
+
+
+def test_title_crossing_a_heading_does_not_hide_an_off_palette_badge(make_ctx, tmp_path, brand):
+ # The heading ends the paragraph, so the off-palette badge after it renders and must be reported.
+ doc = HEADING_TITLE.format(image="")
+ assert OFF_ABC in _usage_fails(make_ctx, tmp_path, brand, doc)
+
+
+def test_soft_break_title_with_image_on_the_next_line_stays_inert(make_ctx, tmp_path, brand):
+ # One paragraph: the image sits inside the link title, so GFM renders no image and nothing is reported.
+ doc = '[Docs](https://example.test "unfinished\n\n")'
+ assert MISSING not in _usage_fails(make_ctx, tmp_path, brand, doc)
+
+
+def test_soft_break_title_with_prose_before_the_image_stays_inert(make_ctx, tmp_path, brand):
+ # One paragraph: the title continues through the prose line and the image line, so no image renders.
+ doc = '[Docs](https://example.test "unfinished\nstill the title\n\n")'
+ assert MISSING not in _usage_fails(make_ctx, tmp_path, brand, doc)
+
+
+def test_title_crossing_a_list_item_does_not_hide_a_missing_image(make_ctx, tmp_path, brand):
+ # The list item interrupts the paragraph, and the following line is part of that item, so the image renders.
+ doc = '[Docs](https://example.test "unfinished\n- item\n\n")'
+ assert MISSING in _usage_fails(make_ctx, tmp_path, brand, doc)
+
+
+def _header_with_definition(make_ctx, tmp_path, header_line, definition):
+ """Variant J header with the type badge then `header_line` in the badges block, and `definition` after the header."""
+ (tmp_path / "docs/images").mkdir(parents=True)
+ (tmp_path / "docs/images/banner.png").write_bytes(b"banner")
+ ctx = make_ctx(repo_dir=tmp_path, repo=".github", language=None,
+ description="Community health files for Northwind Trading Co.")
+ text = readme.scaffold(ctx)
+ original = next(m.group(2) for m in readme.MARK.finditer(text) if m.group(1) == "badges")
+ text = text.replace(original, BADGE_TYPE_LINE + "\n" + header_line + "\n")
+ text = text.replace("", definition + "\n\n")
+ path = tmp_path / "README.md"
+ path.write_text(text, encoding="utf-8")
+ return readmelint.lint(ctx, path)["fails"]
+
+
+def test_multiline_label_definition_counts_its_header_badge(make_ctx, tmp_path):
+ # GFM normalizes the soft line break inside `lic\nense` to a space, so the definition resolves: two badges.
+ fails = _header_with_definition(make_ctx, tmp_path, "![License][lic ense]",
+ "[lic\nense]: https://img.shields.io/badge/license-MIT-64748B")
+ assert not any("2-4 header badges required" in f for f in fails), fails
+
+
+def test_label_split_by_a_paragraph_break_is_not_a_definition(make_ctx, tmp_path):
+ # A blank line inside the label ends the paragraph, so the line is text and only the type badge counts.
+ fails = _header_with_definition(make_ctx, tmp_path, "![License][lic ense]",
+ "[lic\n\nense]: https://img.shields.io/badge/license-MIT-64748B")
+ assert "R7 2-4 header badges required (found 1)" in fails, fails
+
+
+def test_label_with_an_unescaped_bracket_is_not_a_definition(make_ctx, tmp_path):
+ # An unescaped `[` inside the label makes the line invalid as a definition, so only the type badge counts.
+ fails = _header_with_definition(make_ctx, tmp_path, "![License][lic ense]",
+ "[li[c]\nense]: https://img.shields.io/badge/license-MIT-64748B")
+ assert "R7 2-4 header badges required (found 1)" in fails, fails
+
+
+def test_tab_indented_definition_under_a_list_item_is_live(make_ctx, tmp_path):
+ # A tab is four columns: the item content column is two, so two residual columns remain and this is a definition.
+ fails = _header_with_definition(make_ctx, tmp_path, BADGE_LICENSE_LINE,
+ "- Example\n\n\t[lic]: https://img.shields.io/badge/license-MIT-64748B")
+ assert not any("2-4 header badges required" in f for f in fails), fails
+
+
+def test_tab_plus_two_spaces_under_a_list_item_is_indented_code(make_ctx, tmp_path):
+ # Six visual columns leave four beyond the item content column, so this is code, not a definition.
+ fails = _header_with_definition(make_ctx, tmp_path, BADGE_LICENSE_LINE,
+ "- Example\n\n\t [lic]: https://img.shields.io/badge/license-MIT-64748B")
+ assert "R7 2-4 header badges required (found 1)" in fails, fails
+
+
+def test_six_spaces_under_a_list_item_is_indented_code(make_ctx, tmp_path):
+ # Six spaces leave four beyond the content column of `- `, so this is code, not a definition.
+ fails = _header_with_definition(make_ctx, tmp_path, BADGE_LICENSE_LINE,
+ "- Example\n\n [lic]: https://img.shields.io/badge/license-MIT-64748B")
+ assert "R7 2-4 header badges required (found 1)" in fails, fails
+
+
+def test_two_spaces_under_a_list_item_is_live(make_ctx, tmp_path):
+ # Two spaces equal the content column of `- `, so this line is a continuation definition.
+ fails = _header_with_definition(make_ctx, tmp_path, BADGE_LICENSE_LINE,
+ "- Example\n\n [lic]: https://img.shields.io/badge/license-MIT-64748B")
+ assert not any("2-4 header badges required" in f for f in fails), fails
diff --git a/tests/test_readmelint_html_tokens.py b/tests/test_readmelint_html_tokens.py
new file mode 100644
index 0000000..2c28a26
--- /dev/null
+++ b/tests/test_readmelint_html_tokens.py
@@ -0,0 +1,180 @@
+"""README image tokenizing never raises and follows CommonMark for raw HTML, comments, code and titles."""
+import pytest
+
+from ctshowcase import readmelint
+from test_reference_badge_count import lint_reference
+
+
+def images(document):
+ return readmelint._images(readmelint._outside_code(document), document)
+
+
+@pytest.mark.parametrize("document", [
+ "Wrap here",
+ "Inline ` ",
+])
+def test_marked_section_lookalikes_do_not_raise(document):
+ images(document)
+
+
+def test_marked_section_lookalike_keeps_image():
+ assert images("Wrap here") == [("CI", "https://x/badge.svg")]
+
+
+@pytest.mark.parametrize("document", [
+ "Retry while attempts Note: callout\n",
+ "Use `Listb` ok",
+ "Compare a quote\n",
+])
+def test_stray_angle_bracket_is_not_a_tag(document):
+ assert images(document) == [("Diagram", "docs/missing.png")]
+
+
+def test_comment_opener_inside_code_does_not_hide_images():
+ document = "Use `\n"
+ assert images(document) == [("Screenshot", "docs/missing.png")]
+ fenced = "```\n\n"
+ assert images(fenced) == [("Screenshot", "docs/missing.png")]
+ assert images("\n") == [("Shown", "s.png")]
+
+
+def test_reference_title_with_escaped_quote_still_defines_image():
+ document = '![Screenshot][shot]\n\n[shot]: "The \\"before\\" screenshot"\n'
+ assert images(document) == [("Screenshot", "docs/missing.png")]
+ assert images("![S][s]\n\n[s]: a.png 'it\\'s'\n") == [("S", "a.png")]
+
+
+def test_inline_code_angle_bracket_does_not_hide_html_images_from_lint(make_ctx, tmp_path):
+ usage = 'Loop while `i'
+ result = lint_reference(make_ctx, tmp_path, usage, "")
+ assert any("without alt" in f for f in result["fails"])
+ assert "R7 local image does not resolve: docs/missing.png" in result["fails"]
+ prose = lint_reference(make_ctx, tmp_path / "second", 'Compare a ', "")
+ assert "R7 local image does not resolve: docs/missing.png" in prose["fails"]
+
+
+@pytest.mark.parametrize("document,expected", [
+ ('
see ``',
+ [("a`b", "https://img.shields.io/badge/a-b-005ADD")]),
+ ("A ``", []),
+ ("A `` ", [("G", "g.png")]),
+])
+# the comment sits inside a paragraph: a line that starts with a comment is a raw HTML block (CommonMark type 2), not Markdown
+def test_code_span_after_a_winning_tag_or_comment_is_still_code(document, expected):
+ assert images(document) == expected
+
+
+def test_code_example_after_attribute_backtick_is_not_a_badge_or_local_file(make_ctx, tmp_path):
+ usage = 'A ``'
+ result = lint_reference(make_ctx, tmp_path, usage, "")
+ assert "R7 local image does not resolve: y.png" not in result["fails"]
+ assert "R7 2-4 header badges required (found 1)" in result["fails"]
+
+
+def test_multiline_reference_title_keeps_the_definition(make_ctx, tmp_path):
+ from test_reference_badge_count import URL
+ assert images('![A][a]\n\n[a]: x.png "Build\nstatus"\n') == [("A", "x.png")]
+ assert images("![A][a]\n\n[a]: x.png (Build\nstatus)\n") == [("A", "x.png")]
+ assert images('![A][a]\n\n[a]: x.png "Build\n\nstatus"\n') == [] # a blank line ends the paragraph: not a definition
+ ok = lint_reference(make_ctx, tmp_path / "ok", "![CI][ci]", f'[ci]: {URL} "Build\nstatus"')
+ assert not any("badge" in failure for failure in ok["fails"]), ok
+ missing = lint_reference(make_ctx, tmp_path / "missing", "![CI][ci]", '[ci]: docs/images/missing.svg "Build\nstatus"')
+ assert "R7 local image does not resolve: docs/images/missing.svg" in missing["fails"]
+
+
+def test_reference_labels_keep_literal_backticks(make_ctx, tmp_path):
+ from test_reference_badge_count import URL
+ document = "![CI][ci`current`]\n\n[ci`old`]: docs/images/missing.svg\n[ci`current`]: https://example.test/b.svg\n"
+ assert images(document) == [("CI", "https://example.test/b.svg")]
+ shortcut = "![ci`x`]\n\n[ci`y`]: a.png\n[ci`x`]: b.png\n"
+ assert images(shortcut) == [("ci`x`", "b.png")]
+ result = lint_reference(make_ctx, tmp_path, "![CI][ci`current`]",
+ f"[ci`old`]: docs/images/missing.svg\n[ci`current`]: {URL}")
+ assert not any("missing.svg" in failure for failure in result["fails"]), result
+ assert not any("badge" in failure for failure in result["fails"]), result
+ assert images("`[ci]: a.png`\n\n![C][ci]") == []
+
+
+def test_apostrophe_in_markdown_badge_url_cannot_hide_an_off_palette_colour(make_ctx, tmp_path):
+ result = lint_reference(make_ctx, tmp_path, "", "")
+ assert "R7 off-palette badge colour FF0000" in result["fails"]
+ quoted = lint_reference(make_ctx, tmp_path / "q", "![Type]()", "")
+ assert "R7 off-palette badge colour FF0000" in quoted["fails"]
+
+
+def test_escaped_backticks_do_not_hide_angle_image_from_lint(make_ctx, tmp_path):
+ usage = r"\`![Screenshot]()\`"
+ result = lint_reference(make_ctx, tmp_path, usage, "")
+ assert "R7 local image does not resolve: docs/images/missing.png" in result["fails"]
+
+
+def test_real_inline_code_still_hides_angle_image_from_lint(make_ctx, tmp_path):
+ usage = "`![Screenshot]()`"
+ result = lint_reference(make_ctx, tmp_path, usage, "")
+ assert "R7 local image does not resolve: docs/images/missing.png" not in result["fails"]
+
+
+def test_even_backslash_run_before_backtick_is_real_code(make_ctx, tmp_path):
+ usage = r"\\`![Screenshot]()`"
+ result = lint_reference(make_ctx, tmp_path, usage, "")
+ assert "R7 local image does not resolve: docs/images/missing.png" not in result["fails"]
+
+
+MISSING = ""
+
+
+@pytest.mark.parametrize("count", range(8))
+def test_backslash_parity_before_backtick_controls_code_masking(count):
+ document = "\\" * count + "`![Shot]()`"
+ expected = [("Shot", "docs/images/missing.png")] if count % 2 else []
+ assert images(document) == expected
+
+
+def test_escaped_backtick_then_real_span_masks_image():
+ assert images("\\``" + MISSING + "` y") == []
+ assert images("\\``code` " + MISSING) == [("Shot", "docs/images/missing.png")]
+
+
+@pytest.mark.parametrize("count", range(8))
+def test_backslash_parity_before_html_comment_opener(count):
+ document = "\\" * count + ""
+ expected = [("Shot", "docs/images/missing.png")] if count % 2 else []
+ assert images(document) == expected
+
+
+@pytest.mark.parametrize("count", range(8))
+def test_backslash_parity_before_html_img_open_tag(count):
+ document = "\\" * count + '
'
+ found = images(document)
+ # An escaped "<" is literal text, so odd runs render no HTML image.
+ assert found == ([] if count % 2 else [("Shot", "docs/images/missing.png")])
+
+
+def test_reference_definition_title_is_not_rendered_markdown():
+ document = ('[unused]: https://example.test/page "![Ghost][ghost]"\n'
+ "[ghost]: docs/images/missing.png\n")
+ assert images(document) == []
+ with_real = document + "\n![Real][ghost]\n"
+ assert images(with_real) == [("Real", "docs/images/missing.png")]
+
+
+def test_html_img_inside_reference_definition_title_is_not_an_image():
+ document = '[unused]: https://example.test/page "
"\n'
+ assert images(document) == []
+
+
+def test_http_shields_badges_count_and_pass_r7(make_ctx, tmp_path):
+ usage = ("\n"
+ "")
+ result = lint_reference(make_ctx, tmp_path, usage, "")
+ assert not any("badge" in failure for failure in result["fails"]), result
+ assert images(usage) == [
+ ("Type", "http://img.shields.io/badge/type-GitHub%20profile-005ADD"),
+ ("License", "http://img.shields.io/badge/license-MIT-64748B"),
+ ]
+
+
+def test_off_palette_http_badge_still_fails(make_ctx, tmp_path):
+ result = lint_reference(make_ctx, tmp_path, "", "")
+ assert "R7 off-palette badge colour FF0000" in result["fails"]
diff --git a/tests/test_reference_badge_count.py b/tests/test_reference_badge_count.py
new file mode 100644
index 0000000..032918a
--- /dev/null
+++ b/tests/test_reference_badge_count.py
@@ -0,0 +1,318 @@
+"""Badge counts follow rendered reference images, not unused URL definitions."""
+import pytest
+
+from ctshowcase import readme, readmelint
+
+URL = "https://github.com/Northwind-Trading-Co/sample-library/actions/workflows/ci.yml/badge.svg"
+
+
+def lint_reference(make_ctx, tmp_path, usage, definitions, files=None):
+ (tmp_path / ".github/workflows").mkdir(parents=True)
+ (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n", encoding="utf-8")
+ (tmp_path / "docs/images").mkdir(parents=True)
+ (tmp_path / "docs/images/banner.png").write_bytes(b"banner")
+ for name in files or []:
+ (tmp_path / name).parent.mkdir(parents=True, exist_ok=True)
+ (tmp_path / name).write_bytes(b"image")
+ ctx = make_ctx(repo_dir=tmp_path, repo="sample-library", language=None, license=None)
+ ctx.det.update(type="library", variant="C", category="Library")
+ text = readme.scaffold(ctx)
+ original = next(m.group(2) for m in readme.MARK.finditer(text) if m.group(1) == "badges")
+ type_badge = next(line for line in original.splitlines() if "badge/type-" in line)
+ text = text.replace(original, type_badge + "\n" + usage)
+ # References may be defined outside the header, before the required final footer.
+ text = text.replace("", definitions + "\n\n")
+ path = tmp_path / "README.md"
+ path.write_text(text, encoding="utf-8")
+ return readmelint.lint(ctx, path)
+
+
+@pytest.mark.parametrize("usage,definitions", [
+ ("![CI][ci-status]", f"[ci-status]: {URL}"),
+ ("![CI][]", f"[CI]: <{URL}>"),
+ ("![CI]", f'[ci]: {URL} "Build status"'),
+ ("![CI][ Build STATUS ]", f"[build status]:\n {URL}"),
+])
+def test_reference_ci_images_count(make_ctx, tmp_path, usage, definitions):
+ result = lint_reference(make_ctx, tmp_path, usage, definitions)
+ assert not any("badge" in failure for failure in result["fails"]), result
+
+
+@pytest.mark.parametrize("usage,definitions", [
+ ("[CI][ci-status]", f"[ci-status]: {URL}"),
+ (r"\![CI][ci-status]", f"[ci-status]: {URL}"),
+ ("`![CI][ci-status]`", f"[ci-status]: {URL}"),
+ ("![CI][missing]", f"[ci-status]: {URL}"),
+ ("![CI][ci-status]", f"```markdown\n[ci-status]: {URL}\n```"),
+ ("![CI][ci-status]", f"[ci-status]: https://example.test/not-a-badge.png\n[ci-status]: {URL}"),
+])
+def test_non_images_and_unresolved_references_do_not_count(make_ctx, tmp_path, usage, definitions):
+ result = lint_reference(make_ctx, tmp_path, usage, definitions)
+ assert "R7 2-4 header badges required (found 1)" in result["fails"]
+
+
+@pytest.mark.parametrize("count", [3, 4])
+def test_each_rendered_reference_image_counts_once(make_ctx, tmp_path, count):
+ result = lint_reference(make_ctx, tmp_path, "\n".join(["![CI][ci-status]"] * count), f"[ci-status]: {URL}")
+ if count == 3:
+ assert not any("badge" in failure for failure in result["fails"]), result
+ else:
+ assert "R7 2-4 header badges required (found 5)" in result["fails"]
+
+
+def test_reference_images_still_require_alt_and_existing_local_files(make_ctx, tmp_path):
+ result = lint_reference(make_ctx, tmp_path, "![][ci-status]",
+ "[ci-status]: docs/images/badges/missing.svg")
+ assert any(failure.startswith("R7 image without alt text") for failure in result["fails"])
+ assert "R7 local image does not resolve: docs/images/badges/missing.svg" in result["fails"]
+
+
+@pytest.mark.parametrize("usage", [
+ f'
',
+ f'
',
+ f"
",
+ f'
build"\nsrc="{URL}">',
+ f'![CI [build]]({URL} "Build status")',
+ f'![CI [build [latest]]]({URL})',
+ "![CI [build]][ci]",
+ "![CI [build [latest]]][ci]",
+])
+def test_valid_html_and_balanced_markdown_badges_count(make_ctx, tmp_path, usage):
+ result = lint_reference(make_ctx, tmp_path, usage, f"[ci]: {URL}")
+ assert not any("badge" in failure or "without alt" in failure for failure in result["fails"]), result
+
+
+@pytest.mark.parametrize("usage", [
+ f'<img src="{URL}" alt="CI">',
+ f'```html\n
\n```',
+ '
Build status',
+ f'\\![CI [build]]({URL})',
+])
+def test_nonrendered_html_and_markdown_badges_do_not_count(make_ctx, tmp_path, usage):
+ result = lint_reference(make_ctx, tmp_path, usage, f"[ci]: {URL}")
+ assert "R7 2-4 header badges required (found 1)" in result["fails"]
+
+
+def test_unfinished_html_at_end_has_no_image():
+ assert readmelint._images(f'
)', f"[ci]: {URL}")
+ assert "R7 local image does not resolve: docs/images/missing.png" in result["fails"]
+
+
+def test_angle_markdown_destination_existing_file_passes(make_ctx, tmp_path):
+ result = lint_reference(make_ctx, tmp_path,
+ '![CI][ci]\n![Screenshot]()', f"[ci]: {URL}",
+ files=["docs/images/good shot.png"])
+ assert result["fails"] == [], result
+
+
+@pytest.mark.parametrize("attribute", ['alt="`showcase.py --help`"', "ALT='`showcase.py --help`'"])
+def test_html_attribute_backticks_are_literal_text(make_ctx, tmp_path, attribute):
+ result = lint_reference(make_ctx, tmp_path, f'
', "")
+ assert result["fails"] == [], result
+
+
+@pytest.mark.parametrize("usage", [
+ '`![Screenshot]()`',
+ f'``
``',
+])
+def test_angle_destinations_and_html_inside_code_are_excluded(make_ctx, tmp_path, usage):
+ result = lint_reference(make_ctx, tmp_path, usage, "")
+ assert "R7 2-4 header badges required (found 1)" in result["fails"]
+ assert not any("local image" in failure or "without alt" in failure for failure in result["fails"])
+
+
+@pytest.mark.parametrize("usage", [f"", f"[](https://example.test/ci)"])
+def test_angle_bracketed_inline_destinations_count(make_ctx, tmp_path, usage):
+ result = lint_reference(make_ctx, tmp_path, usage, "")
+ assert not any("badge" in failure for failure in result["fails"]), result
+
+
+COMMENT = ""
+
+
+@pytest.mark.parametrize("document, alts", [
+ (COMMENT + "\n\n", ["`gh` screenshot"]),
+ ("\n" + " " * 20 + "\n\n", ["Logo"]),
+ ("\n\n", [" "]),
+ ("\nprose\n\n", [" "]),
+])
+def test_alt_text_is_read_at_source_offsets_after_html_comments(document, alts):
+ assert len(readmelint._outside_code(document)) == len(document)
+ assert [alt for alt, _ in readmelint._images(readmelint._outside_code(document), document)] == alts
+
+
+def test_comment_before_blank_alt_image_is_still_flagged(make_ctx, tmp_path):
+ result = lint_reference(make_ctx, tmp_path, COMMENT + "\n", "[ci]: " + URL,
+ files=["docs/images/shot.png"])
+ assert any(f.startswith("R7 image without alt text") for f in result["fails"]), result
+
+
+def test_reference_destination_unescapes_markdown_punctuation(make_ctx, tmp_path):
+ result = lint_reference(make_ctx, tmp_path, "![Shot][shot]", r"[shot]: docs/images/shot\(1\).png",
+ files=["docs/images/shot(1).png"])
+ assert not any("does not resolve" in f for f in result["fails"]), result
+
+
+@pytest.mark.parametrize("document, alts", [
+ ("Use `
` for breaks.\n\n\n", [" "]),
+ ("Use `
`, `
` or `` tags.\n\n\n" + "\n" * 60, ["Architecture"]),
+ ("`
`\n\n", ["Logo"]),
+ ("Use `
` here\n\n\n", [" "]),
+ ("```html\n\n
\n```\n\n\n", ["Valid alt"]),
+ ("```html\n
\n", ["Screenshot", "Shot"]),
+])
+def test_html_inside_code_keeps_source_offsets(document, alts):
+ outside = readmelint._outside_code(document)
+ assert len(outside) == len(document)
+ assert [alt for alt, _ in readmelint._images(outside, document)] == alts
+
+
+def test_failed_inline_destination_falls_back_to_shortcut_reference(make_ctx, tmp_path):
+ result = lint_reference(make_ctx, tmp_path, "", f"[CI]: {URL}")
+ assert not any("badge" in failure for failure in result["fails"]), result
+
+
+def test_fallback_reference_with_local_definition_is_checked_for_existence(make_ctx, tmp_path):
+ missing = lint_reference(make_ctx, tmp_path, "", "[CI]: docs/images/badges/ci.svg")
+ assert "R7 local image does not resolve: docs/images/badges/ci.svg" in missing["fails"], missing
+ assert not any("header badges" in failure for failure in missing["fails"]), missing
+
+
+def test_fallback_reference_with_existing_local_definition_passes(make_ctx, tmp_path):
+ result = lint_reference(make_ctx, tmp_path, "", "[CI]: docs/images/badges/ci.svg",
+ files=["docs/images/badges/ci.svg"])
+ assert not any("local image" in failure or "header badges" in failure for failure in result["fails"]), result
+
+
+LIC = "[lic]: https://img.shields.io/badge/license-MIT-64748B"
+
+
+def _comment_definition_doc(body):
+ return f"{body}\n\n{LIC}\n\n"
+
+
+def _count(make_ctx, tmp_path, body):
+ result = lint_reference(make_ctx, tmp_path, "![License][lic]", _comment_definition_doc(body))
+ return result
+
+
+def test_code_span_comment_opener_does_not_hide_a_later_definition(make_ctx, tmp_path):
+ result = _count(make_ctx, tmp_path, "Use `")
+ assert "R7 2-4 header badges required (found 1)" in result["fails"]
+
+
+def test_escaped_comment_opener_does_not_start_a_comment(make_ctx, tmp_path):
+ result = _count(make_ctx, tmp_path, r"Write \",
+ "A user-written body note.\n")
+ if state == "unmarked":
+ text = readme.drop_pristine(text).replace("", "")
+ text = text.replace("", "")
+ if mode == "upgrade":
+ # The base also rejects unrelated template identity changes through its shrink guard.
+ # Align those generated fields to isolate the badge migration in a real CLI upgrade.
+ identity = {"banner": readme.banner_html(ctx), "nav": readme.nav_line(ctx),
+ "tagline": readme.tagline_body(ctx)}
+ text = readme._sub_blocks(text, lambda m: readme.wrap(m.group(1), identity[m.group(1)])
+ if m.group(1) in identity else m.group(0))
+ path = tmp_path / "README.md"
+ path.write_text(text, encoding="utf-8")
+ options = ["--bootstrap"] if mode == "bootstrap" else ["--mode", mode]
+ profile_cli(ctx, tmp_path, "readme", *options, "--write")
+ actual = path.read_text(encoding="utf-8")
+ if custom:
+ assert badge_block(actual) == authored
+ else:
+ assert "type-Template-" not in badge_block(actual)
+ assert "type-GitHub%20profile-" in badge_block(actual)
+ assert "license-MIT-" in badge_block(actual)
+ assert not any("badge" in f for f in readmelint.lint(ctx, path)["fails"])
+ if state != "pristine":
+ assert "A user-written body note." in actual
+
+
+@pytest.mark.parametrize("exists", [False, True])
+def test_cli_angle_image_destinations_check_file_existence(make_ctx, tmp_path, exists):
+ ctx = profile_context(make_ctx, tmp_path)
+ if exists:
+ (tmp_path / "docs/images/screenshot.png").write_bytes(b"image")
+ text = readme.scaffold(ctx).replace("\n---\n", "\n---\n\n![Screenshot]()\n")
+ (tmp_path / "README.md").write_text(text, encoding="utf-8")
+ result = profile_cli(ctx, tmp_path, "lint", "readme", expected_code=0 if exists else 1)
+ assert any("local image does not resolve" in failure for failure in result["fails"]) is not exists
+
+
+def test_cli_html_alt_backticks_remain_literal(make_ctx, tmp_path):
+ ctx = profile_context(make_ctx, tmp_path)
+ text = readme.scaffold(ctx)
+ body = ('
\n'
+ '
')
+ (tmp_path / "README.md").write_text(text.replace(badge_block(text), body), encoding="utf-8")
+ result = profile_cli(ctx, tmp_path, "lint", "readme")
+ assert not any("without alt" in failure for failure in result["fails"])
+
+
+@pytest.mark.parametrize("repo, migrates", [("Old-Northwind", True), (".github", True), ("Elsewhere", False)])
+def test_legacy_ci_badge_recognizes_personal_profile_repository(make_ctx, tmp_path, repo, migrates):
+ ctx = profile_context(make_ctx, tmp_path)
+ url = f"https://github.com/Old-Northwind/{repo}/actions/workflows/ci.yml"
+ authored = f"\n[]({url})"
+ before = readme.wrap("badges", authored) + "\n\nAuthored paragraph to retain.\n"
+ after, removed = readme.upgrade(ctx, before)
+ assert (authored not in badge_block(after)) is migrates
+ assert "Authored paragraph to retain." in after
+ assert readme.preservation(before, after, removed) == []
+
+
+def test_cli_markdown_alt_backticks_remain_literal(make_ctx, tmp_path):
+ ctx = profile_context(make_ctx, tmp_path)
+ text = readme.scaffold(ctx)
+ body = ("\n"
+ "")
+ (tmp_path / "README.md").write_text(text.replace(badge_block(text), body), encoding="utf-8")
+ result = profile_cli(ctx, tmp_path, "lint", "readme")
+ assert not any("without alt" in failure for failure in result["fails"])
+
+
+def test_cli_angle_bracketed_shield_destinations_use_palette_tokens(make_ctx, tmp_path):
+ ctx = profile_context(make_ctx, tmp_path)
+ text = readme.scaffold(ctx)
+ body = ("![Type]()\n"
+ "![License]()")
+ (tmp_path / "README.md").write_text(text.replace(badge_block(text), body), encoding="utf-8")
+ result = profile_cli(ctx, tmp_path, "lint", "readme")
+ assert not any("palette" in failure.lower() or "badge" in failure.lower() for failure in result["fails"])
+ bad = body.replace("64748B>", "FF0000>")
+ (tmp_path / "README.md").write_text(text.replace(badge_block(text), bad), encoding="utf-8")
+ result = profile_cli(ctx, tmp_path, "lint", "readme", expected_code=1)
+ assert any("palette" in failure.lower() for failure in result["fails"])
+
+
+def test_legacy_ci_badge_profile_repository_matches_owner_case_insensitively(make_ctx, tmp_path):
+ ctx = profile_context(make_ctx, tmp_path)
+ url = "https://github.com/Old-Northwind/old-northwind/actions/workflows/ci.yml"
+ authored = f"\n[]({url})"
+ before = readme.wrap("badges", authored) + "\n\nAuthored paragraph to retain.\n"
+ after, removed = readme.upgrade(ctx, before)
+ assert authored not in badge_block(after)
+ assert readme.preservation(before, after, removed) == []
+
+
+def test_legacy_language_badge_with_family_palette_migrates(make_ctx, tmp_path):
+ ctx = profile_context(make_ctx, tmp_path)
+ ctx.style = dict(ctx.style, badge_accent="115E59")
+ authored = ("\n"
+ "")
+ assert readme.legacy_profile_badges(ctx.brand, authored, ctx.style)
+ assert not readme.legacy_profile_badges(ctx.brand, authored)
+ body = readme.badge_body(ctx, authored)
+ assert body != authored and "type-GitHub%20profile-" in body
+
+
+def test_local_inherited_template_badge_migrates_like_remote(make_ctx, tmp_path):
+ ctx = profile_context(make_ctx, tmp_path)
+ remote, local = readme.template_badge_bodies(ctx.brand)
+ assert local == ""
+ for existing in (remote, local):
+ body = readme.badge_body(ctx, existing)
+ assert body != existing and readme.generated_badges(body)
+ assert "type-GitHub%20profile-" in body and "license-MIT-" in body
+
+
+# --- The generated-badge digest preserves Markdown-significant whitespace ---------------------------------------
+def _indented_first_row(text):
+ body = badge_block(text)
+ comment, rows = body.strip("\n").split("\n", 1)
+ indented = f"{comment}\n {rows}"
+ return text.replace(body, indented), indented
+
+
+def test_indenting_a_generated_badge_row_makes_the_block_authored(make_ctx, tmp_path):
+ ctx = profile_context(make_ctx, tmp_path)
+ before = readme.scaffold(ctx)
+ assert readme.generated_badges(badge_block(before))
+ edited, indented = _indented_first_row(before)
+ assert badge_block(edited) == indented
+ assert not readme.generated_badges(badge_block(edited))
+ after_ctx = make_ctx(repo_dir=tmp_path, **(ctx.meta | {"license": "Apache-2.0"}))
+ header = readme.header_only(after_ctx, edited)
+ assert badge_block(header) == indented
+ upgraded, removed = readme.upgrade(after_ctx, edited)
+ assert badge_block(upgraded) == indented
+ assert readme.preservation(edited, upgraded, removed) == []
+
+
+def test_crlf_converted_untouched_block_still_counts_as_generated(make_ctx, tmp_path):
+ ctx = profile_context(make_ctx, tmp_path)
+ block = badge_block(readme.scaffold(ctx))
+ assert readme.generated_badges(block.replace("\n", "\r\n"))
+
+
+def _tab_indented_first_row(text):
+ body = badge_block(text)
+ comment, rows = body.strip("\n").split("\n", 1)
+ indented = f"{comment}\n\t{rows}"
+ return text.replace(body, indented), indented
+
+
+def test_tab_indenting_a_generated_badge_row_makes_the_block_authored(make_ctx, tmp_path):
+ ctx = profile_context(make_ctx, tmp_path)
+ before = readme.scaffold(ctx)
+ assert readme.generated_badges(badge_block(before))
+ edited, indented = _tab_indented_first_row(before)
+ assert badge_block(edited) == indented
+ assert not readme.generated_badges(badge_block(edited))
+ after_ctx = make_ctx(repo_dir=tmp_path, **(ctx.meta | {"license": "Apache-2.0"}))
+ header = readme.header_only(after_ctx, edited)
+ assert badge_block(header) == indented
+ upgraded, removed = readme.upgrade(after_ctx, edited)
+ assert badge_block(upgraded) == indented
+ assert readme.preservation(edited, upgraded, removed) == []
+
+
+def test_untouched_generated_badge_row_refreshes_on_license_change(make_ctx, tmp_path):
+ ctx = profile_context(make_ctx, tmp_path)
+ before = readme.scaffold(ctx)
+ assert readme.generated_badges(badge_block(before))
+ after_ctx = make_ctx(repo_dir=tmp_path, **(ctx.meta | {"license": "Apache-2.0"}))
+ header = readme.header_only(after_ctx, before)
+ assert badge_block(header) != badge_block(before)
+ assert readme.generated_badges(badge_block(header))
+ assert "license-MIT-" not in badge_block(header)
+ assert "Apache" in badge_block(header)
+
+
+@pytest.mark.parametrize("variant", ["no-lead", "extra-lead", "extra-trail", "no-trail"])
+def test_marker_separator_newline_variations_still_validate(make_ctx, tmp_path, variant):
+ ctx = profile_context(make_ctx, tmp_path)
+ block = badge_block(readme.scaffold(ctx))
+ comment, body = block.strip("\n").split("\n", 1)
+ shaped = {"no-lead": f"{comment}\n{body}", "extra-lead": f"{comment}\n\n{body}",
+ "extra-trail": f"{comment}\n{body}\n\n", "no-trail": f"{comment}\n{body}"}[variant]
+ assert readme.generated_badges(shaped)
+
+
+def test_badge_digest_is_not_the_template_body_digest(make_ctx, tmp_path):
+ body = badge_block(readme.scaffold(profile_context(make_ctx, tmp_path))).split("\n", 1)[1]
+ assert readme.badge_sha(body) != readme.badge_sha(" " + body)
+ assert readme.badge_sha(body) != readme.badge_sha("\t" + body)
+ assert readme.badge_sha(body) == readme.badge_sha(body.replace("\n", "\r\n"))
+
+
+# --- Round 6: only line endings and separator newlines are normalized when recognizing the inherited template -----
+def _template_text(ctx, row):
+ text = readme.scaffold(ctx)
+ return text.replace(badge_block(text), row)
+
+
+def _refresh(ctx, text, mode):
+ if mode == "header":
+ return readme.header_only(ctx, text), []
+ return readme.upgrade(ctx, text)
+
+
+@pytest.mark.parametrize("which", [0, 1], ids=["remote", "local-svg"])
+@pytest.mark.parametrize("edit", ["indent4", "tab", "trailing-space", "indent1"])
+@pytest.mark.parametrize("mode", ["header", "upgrade"])
+def test_edited_template_row_without_digest_survives_refresh_byte_for_byte(make_ctx, tmp_path, mode, edit, which):
+ ctx = profile_context(make_ctx, tmp_path)
+ template = readme.template_badge_bodies(ctx.brand)[which]
+ row = {"indent4": " " + template, "tab": "\t" + template, "trailing-space": template + " ",
+ "indent1": " " + template}[edit]
+ text = _template_text(ctx, row)
+ assert badge_block(text) == row
+ after, removed = _refresh(ctx, text, mode)
+ assert badge_block(after) == row
+ assert "replaces=" not in after
+ assert readme.preservation(text, after, removed) == []
+ again, _ = _refresh(ctx, after, mode)
+ assert badge_block(again) == row
+
+
+@pytest.mark.parametrize("which", [0, 1], ids=["remote", "local-svg"])
+@pytest.mark.parametrize("shape", ["exact", "crlf", "padded", "crlf-padded"])
+@pytest.mark.parametrize("mode", ["header", "upgrade"])
+def test_exact_template_row_is_still_recognized_and_refreshed(make_ctx, tmp_path, mode, shape, which):
+ ctx = profile_context(make_ctx, tmp_path)
+ template = readme.template_badge_bodies(ctx.brand)[which]
+ row = {"exact": template, "crlf": template + "\r\n", "padded": "\n\n" + template + "\n\n",
+ "crlf-padded": "\r\n" + template + "\r\n"}[shape]
+ text = _template_text(ctx, row)
+ body = readme.badge_body(ctx, row)
+ assert body != row and readme.generated_badges(body)
+ after, _ = _refresh(ctx, text, mode)
+ assert "type-Template" not in badge_block(after) and "type-template" not in badge_block(after)
+ assert readme.generated_badges(badge_block(after))
+
+
+INDENTED_MIT = " "
+PLAIN_MIT = INDENTED_MIT.strip()
+
+
+def two_block_changed_license(make_ctx, tmp_path):
+ """Variant J with current license Apache-2.0: an authored indented MIT row, then the same row unindented."""
+ ctx = profile_context(make_ctx, tmp_path, license="Apache-2.0")
+ text = readme.scaffold(ctx)
+ marked = readme.wrap("badges", INDENTED_MIT) + "\n\n" + readme.wrap("badges", PLAIN_MIT)
+ return ctx, text.replace(readme.wrap("badges", badge_block(text)), marked)
+
+
+def test_indented_authored_badge_block_is_not_silently_removed(make_ctx, tmp_path):
+ ctx, before = two_block_changed_license(make_ctx, tmp_path)
+ after, removed = readme.upgrade(ctx, before)
+ problems = readme.preservation(before, after, removed)
+ # The authored indented MIT row is kept, or the shrink guard rejects its removal; never a silent drop.
+ assert INDENTED_MIT in after or any("License MIT" in problem for problem in problems), (problems, after)
+ # The unindented duplicate is a legacy generated row and migrates to the current license.
+ assert "license-Apache--2.0-64748B" in after
+ assert not re.search(r"^" + re.escape(PLAIN_MIT) + r"$", after, re.M)
+ assert after.count("