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 `![x](<...>)` 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 + `![Shot](...)` + 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}|$)", 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]*/?>|)[ \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"![Type {category}]({SHIELDS}type-{shield(category)}-{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"![Type {category}](docs/images/badges/{slug}.svg)" + + +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))), '
', f"# {title or product_name(ctx)}", - wrap("badges", badges_md(ctx)), tag, "
", "---"] + wrap("badges", badge_body(ctx, existing_badges)), tag, "", "---"] return "\n\n".join(p for p in parts if p) @@ -828,12 +936,17 @@ def upgrade(ctx: RepoContext, text: str) -> tuple[str, list[str]]: ctx.log("exempt:preserve-banner" if ctx.preserve_unchanged() else "preserve-banner:kept-below-hero") desc = (ctx.meta.get("description") or "").strip() old_tag = blocks.get("tagline") - if preserve and old_tag is not None: + if preserve and (old_tag is not None or (variant == "J" and "badges" in blocks)): # additive only, so the tagline block stays where it is, its body decided by the header rule (tagline_body) - tag = tagline_body(ctx, old_tag, text) - if old_tag.strip() and not tag: # an inherited tagline, emptied on purpose (not a lost line) - removed += [ln for ln in old_tag.splitlines() if ln.strip()] - body, _ = _strip_markers(text, keep={"tagline": tag}) + keep = {} + if old_tag is not None: + tag = tagline_body(ctx, old_tag, text) + if old_tag.strip() and not tag: # an inherited tagline, emptied on purpose (not a lost line) + removed += [ln for ln in old_tag.splitlines() if ln.strip()] + keep["tagline"] = tag + if variant == "J" and "badges" in blocks: + keep["badges"] = badge_body(ctx, blocks["badges"]) + body, _ = _strip_markers(text, keep=keep) if want_footer: body, _ = drop_old_footer(body, ctx.brand, own_site=True) # its lines are already in `removed` pre, sections = split_sections(body) @@ -949,7 +1062,7 @@ def is_header_line(ln: str) -> bool: # an inherited tagline is emptied (header mode does the same): its lines are dropped on purpose, so the # never-delete check must not report them as lost removed += [ln for ln in old_tag.splitlines() if ln.strip()] - out.append(header(ctx, tagline=new_tag)) + out.append(header(ctx, tagline=new_tag, existing_badges=blocks.get("badges"))) co = callout(ctx) if co and co not in intro: out.append(co) @@ -994,10 +1107,11 @@ def header_only(ctx: RepoContext, text: str) -> str: if ctx.repo_dir is not None and not (ctx.repo_dir / "docs" / "images" / "banner.png").is_file(): gen["banner"] = "" # no image yet (for example no description): an empty block, never a broken image ctx.log("needs-input:banner") - gen["badges"] = badges_html(ctx) if ctx.det["variant"] == "F" else badges_md(ctx) def sub(m): b, body = m.group(1), m.group(2) + if b == "badges": + return wrap(b, badge_body(ctx, body)) if b == "checklist": if re.search(r"- \[ \]", body): return m.group(0) @@ -1074,6 +1188,14 @@ def body_sha(body: str) -> str: return hashlib.sha256(body.replace("\r\n", "\n").strip().encode("utf-8")).hexdigest() +def badge_sha(body: str) -> str: + """Digest of generated badge rows. Preserves Markdown-significant leading and trailing whitespace on rows + (four leading spaces make an indented code block) and normalises only line endings and the marker's + separator newlines.""" + import hashlib + return hashlib.sha256(body.replace("\r\n", "\n").strip("\n").encode("utf-8")).hexdigest() + + def pristine_block(body: str) -> str: """A template-body block whose marker carries the sha256 of the generated sections.""" return wrap("template-body", f"\n{body}") @@ -1140,8 +1262,10 @@ def bootstrap(ctx: RepoContext, text: str) -> str: if not h1 or h1.group(1).strip() in (TEMPLATE_H1, auto, slug_title(ctx.repo)): # the tagline block carries over (tagline_body), so a line the owner wrote survives the rebuild and only an # inherited one is emptied, exactly as header_only decides it - old = next((m.group(2) for m in find_blocks(MARK, hm.group(0)) if m.group(1) == "tagline"), None) - new_header = header(ctx, title=auto, tagline=tagline_body(ctx, old, text)) + blocks = {m.group(1): m.group(2) for m in find_blocks(MARK, hm.group(0))} + old = blocks.get("tagline") + new_header = header(ctx, title=auto, tagline=tagline_body(ctx, old, text), + existing_badges=blocks.get("badges")) text = new_header + "\n\n" + text[hm.end():].lstrip("\n") return header_only(ctx, text) @@ -1171,6 +1295,21 @@ def nonws(s: str) -> int: def preservation(before: str, after: str, removed: list[str]) -> list[str]: """Every original non-header line survives (heading lines may be renamed); size drop <= 10%.""" + # A new generated block can explicitly identify the legacy body it replaced. This binds the + # one-time migration exemption to exact old content, not arbitrary authored rows elsewhere. + replacements = set() + for block in find_blocks(MARK, after): + if block.group(1) == "badges" and generated_badges(block.group(2)): + match = GENERATED_BADGES.fullmatch(block.group(2)) + if match.group(2): + replacements.add(match.group(2)) + + def omit_owned_badges(m): + # The digest must preserve significant whitespace (badge_sha), matching badge_body's replaces=. + owned = generated_badges(m.group(2)) or badge_sha(m.group(2)) in replacements + return wrap("badges", "") if m.group(1) == "badges" and owned else m.group(0) + + before = _sub_blocks(before, omit_owned_badges) problems = [] b_lines = Counter(ln.strip() for ln in before.splitlines() if ln.strip() and not ln.strip().startswith("", re.S) +SHIELDS_SRC = re.compile(r"https?://img\.shields\.io/badge/", re.I) +SHIELDS_BADGE = re.compile(r"https?://img\.shields\.io/badge/(.+)", re.I) -def _outside_code(text: str) -> str: - """The text without fenced code (``` and ~~~), inline code and HTML comments.""" - text = re.sub(r"(?ms)^ {0,3}(`{3,}(?=[^`\n]*$)|~{3,}).*?^ {0,3}\1", "", text) # no backtick in a ``` info string - text = re.sub(r"```.*?```", "", text, flags=re.S) - text = re.sub(r"`[^`\n]*`", "", text) - return re.sub(r"", "", text, flags=re.S) +def _escaped(text: str, i: int) -> bool: + """True when an odd number of consecutive backslashes immediately precedes index `i` (CommonMark escape).""" + count, before = 0, i - 1 + while before >= 0 and text[before] == "\\": + count += 1 + before -= 1 + return count % 2 == 1 + + +def _search_unescaped(pattern, text: str, pos: int, html_spans=None): + """First match at or after `pos` whose opening character is not backslash-escaped. A match starting inside one of + `html_spans` (raw HTML block spans) is never escaped: backslashes are literal there.""" + def escaped(start): + return _escaped(text, start) and not (html_spans is not None and _span_at(html_spans, start) is not None) + + match = pattern.search(text, pos) + while match and escaped(match.start()): + match = pattern.search(text, match.start() + 1) + return match + + +def _search_comment(text: str, pos: int, html_spans): + """First valid HTML comment match at or after `pos` whose opener is not backslash-escaped. + + An inline (mid-line) HTML comment cannot span a paragraph boundary (CommonMark/GFM), while a comment opening at the + start of an HTML block (inside `html_spans`) may span blank lines. An invalid match leaves `` + if _escaped(document, start) or _in_inline_code(document, start) or _span_at(fences, start) is not None: + pos = start + 1 + continue + for i in range(*nxt.span()): + if out[i] != "\n": + out[i] = " " + pos = nxt.end() + # Quote and list-item markers are container syntax: a definition inside `> ` or `- ` is still a definition and is + # global to the document. Each peeled prefix becomes NUL (same length), so offsets are unchanged and the pattern + # sees an ordinary line start. Fenced lines are already blanked; blanked comment text has no prefix to peel. + # A list item's content continues on the lines after its marker: a non-blank line indented at least to the content + # column of the deepest open item (within the same block quote) is inside that item, so that many columns are peeled. + # A blank line keeps the items open; a line indented less closes the items it does not reach. + pos = 0 + items: list[tuple[int, int]] = [] # open list items, outermost first: (block-quote depth, content column) + for raw in re.findall(r"[^\n]*\n|[^\n]+", document): + body = raw.rstrip("\r\n") + end = pos + len(body) + line = "".join(out[pos:end]) + doc_depth, doc_rest = mdcode._quotes(document[pos:end]) # structure is read from the source, not the blanking + blank = not doc_rest.strip() + quoted = len(line) - len(mdcode._quotes(line)[1]) # the block-quote markers are peeled first, as before + _, col, rest = mdcode._containers(line[quoted:]) + indent = _whitespace_columns(doc_rest)[1] + if col or not blank: + while items and (items[-1][0] > doc_depth or items[-1][1] > indent): + items.pop() + if col: # an item marker: its content column is the new innermost item + prefix = len(line) - len(rest) + items.append((doc_depth, col)) + else: + prefix = quoted + if items and not blank: + chars, cols = _whitespace_columns(line[quoted:], items[-1][1]) + # a tab that crosses the content column keeps its residual columns: with the spaces that follow, four + # or more make indented code, so that tab is left unpeeled and the line cannot match as a definition + if cols > items[-1][1] and line[quoted + chars - 1] == "\t" and ( + cols - items[-1][1] + _whitespace_columns(line[quoted + chars:])[1] >= 4): + chars -= 1 + prefix += chars + out[pos:pos + prefix] = ["\x00"] * prefix + pos += len(raw) + return "".join(out) + + +def _whitespace_columns(text: str, target: int | None = None) -> tuple[int, int]: + """(characters, columns) of the leading spaces and tabs of `text`, a tab advancing to the next multiple of four. The + scan stops once `target` columns are covered (None: all leading whitespace); a tab that crosses the target is + counted whole, so the characters can be peeled as one unit.""" + col = i = 0 + while i < len(text) and text[i] in " \t" and (target is None or col < target): + col = col + 1 if text[i] == " " else (col // 4 + 1) * 4 + i += 1 + return i, col + + +def _inline_link_spans(document: str, fences: tuple[tuple[int, int], ...]) -> tuple[tuple[int, int], ...]: + """Spans of resolved inline link destinations and titles, from `(` through the closing `)`, for the definition scan: + an HTML comment or tag opener inside one is link text, not markup. Link-text openers are kept on a stack, and each + `](` consumes one of them, whether or not its destination resolves, so a completed link's opener is never reused by + a later `](`: an orphan destination opens no span. A blank line clears the stack (a paragraph boundary). Escaped and + inline-code brackets are text and neither open nor consume. Computed in one pass; the spans are sorted and disjoint.""" + spans: list[tuple[int, int]] = [] + html_spans = mdcode.html_block_spans(document) # raw HTML and fenced code are not link syntax + openers: list[int] = [] # unconsumed unescaped `[` of the current paragraph, innermost last + skip_until = 0 # text inside an accepted span is destination or title text: its brackets open nothing + for token in re.finditer(r"\[|\]\(|\]|\n[ \t\x00\r]*\n", document): + at = token.start() + if at < skip_until: + continue + if token.group().startswith("\n"): + openers.clear() # a blank line ends the paragraph + continue + if _span_at(fences, at) is not None or _span_at(html_spans, at) is not None: + continue + if _escaped(document, at) or _in_inline_code(document, at): + continue # an escaped or code-span bracket is literal text + if token.group() == "[": + openers.append(at) + elif token.group() == "]": + if openers: + openers.pop() + elif openers: # `](` with an active opener: consume it, then read the destination + openers.pop() + target = _inline_image_target(document, at + 1) + if target is not None: + spans.append((at + 1, target[1])) + skip_until = target[1] + return tuple(spans) + + +def _balanced_destination(dest: str) -> bool: + """CommonMark bare link destination: a backslash escapes the character after it (an escaped parenthesis does not + count), and every unescaped `(` is closed by an unescaped `)` without the depth going below zero.""" + depth, i = 0, 0 + while i < len(dest): + char = dest[i] + if char == "\\": + i += 2 + continue + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth < 0: + return False + i += 1 + return depth == 0 + + +@functools.lru_cache(maxsize=16) +def _definitions(document: str) -> tuple: + """Live reference definitions, as regex matches over the document-length candidate text. A definition cannot + interrupt a paragraph (CommonMark), so a definition-like line that continues a paragraph is text; only a line that + starts a block counts, plus a definition directly after another live definition. Definitions are decided before + inline code, so a backtick in a definition destination pairs with nothing. A bare destination must have balanced + unescaped parentheses (CommonMark); otherwise the line is ordinary Markdown. Computed once per document.""" + continuation = mdcode.continuation_starts(document) + live: list = [] + for match in REF_DEFINITION.finditer(_definition_candidates(document)): + if match.group(3) is not None and not _balanced_destination(match.group(3)): + continue # a malformed bare destination is not a definition: the line stays ordinary Markdown + if match.start() in continuation and not (live and live[-1].end() + 1 == match.start()): + continue + live.append(match) + return tuple(live) + + +@functools.lru_cache(maxsize=16) +def _definition_spans(document: str) -> tuple[tuple[int, int], ...]: + """Spans of complete reference definitions (label, destination and title): their text is not Markdown. Computed + once per document; the spans are sorted and never overlap.""" + return tuple((m.start(), m.end()) for m in _definitions(document)) + + +class _HTMLTokens: + """Strict CommonMark open tags (never raises): spans let Markdown parsing respect attribute boundaries.""" + def __init__(self, text): + self.text = text + self.tags = [] + self.images = [] + for match in HTML_OPEN_TAG.finditer(text): + start, end = match.span() + if _escaped(text, start) and not _in_html_block(text, start): # raw HTML: a backslash is literal + continue + self.tags.append((start, end)) + if match.group(1).lower() == "img": + first = {} + for name, value in HTML_ATTR.findall(match.group(2)): + value = value[1:-1] if value[:1] in ("'", '"') and value else value + first.setdefault(name.lower(), html.unescape(value)) + self.images.append((first.get("alt") or "", first.get("src") or "", start, end)) + + +def _fence_spans(text: str) -> tuple[tuple[int, int], ...]: + """Fenced code blocks, including fences inside block quotes (cached per document by `mdcode`).""" + return mdcode.fence_spans(text) + + +@functools.lru_cache(maxsize=16) +def _outside_code(text: str, mask_inline: bool = True) -> str: + """Mask Markdown code and comments without reading literal backticks in HTML attributes as code. + Constructs are taken left to right, and inline code is looked for again after every HTML tag or comment, so a + backtick inside an attribute or comment is literal and the next real code span still opens (CommonMark). + `mask_inline=False` still lets inline code win precedence but leaves its characters readable (reference labels). + The result has the same length (and newline positions) as `text`.""" + fences = _fence_spans(text) + definitions = _definition_spans(text) # decided before inline code: a definition destination's backtick is literal + labels = _reference_label_spans(text) # resolved full-reference labels: their backticks are literal too + html_spans = mdcode.html_block_spans(text) # raw HTML blocks: tags there are never escaped by a backslash + out, pos = list(text), 0 + tag = comment = inline = dest = None + + def blank(a, b): + for i in range(a, b): + if out[i] != "\n": + out[i] = " " + + def next_inline(start): + while True: + found = find_inline_code(text, start) # (start, end) or None + if not found: + return False + inside = _span_at(fences, found[0]) + if inside is None: + return found + start = inside[1] + + def next_destination(start): + """(start, end) of the first inline link or image destination `](...)` from `start`: its text is literal, so a + backtick inside it opens no code span (CommonMark parses the destination before looking for later code).""" + while match := _search_unescaped(re.compile(r"\]\("), text, start): + if not _has_link_opener(text, match.start()): + start = match.start() + 1 + continue + target = _inline_image_target(text, match.end() - 1) + if target: + return match.start(), target[1] + start = match.start() + 1 + return False + + while pos < len(text): + if inline is not False and (inline is None or inline[0] < pos): + inline = next_inline(pos) + if dest is not False and (dest is None or dest[0] < pos): + dest = next_destination(pos) + if tag is not False and (tag is None or tag.start() < pos): + tag = _search_unescaped(HTML_OPEN_TAG, text, pos, html_spans) or False + if comment is not False and (comment is None or comment.start() < pos): + comment = _search_comment(text, pos, html_spans) or False + n = bisect.bisect_left(fences, (pos, -1)) + fence = fences[n] if n < len(fences) else None + d = bisect.bisect_left(definitions, (pos, -1)) + definition = definitions[d] if d < len(definitions) else None + lb = bisect.bisect_left(labels, (pos, -1)) + label_span = labels[lb] if lb < len(labels) else None + code = min([c for c in ((inline if inline else None), fence) if c], default=None) + found = [m for m in (tag, comment) if m] + nxt = min(found, key=lambda m: m.start()) if found else None + if definition and all(definition[0] <= other for other in [c[0] for c in (code, dest) if c] + + [m.start() for m in found]): + pos = definition[1] + elif label_span and all(label_span[0] <= other for other in [c[0] for c in (code, dest) if c] + + [m.start() for m in found]): + pos = label_span[1] # a resolved reference label is literal text: its backticks open no code span + elif dest and all(dest[0] < other for other in [c[0] for c in (code,) if c] + [m.start() for m in found]): + pos = dest[1] + elif code and (nxt is None or code[0] <= nxt.start()): + if mask_inline or code is fence: + blank(*code) + pos = code[1] + elif nxt is not None: + if nxt is comment: + blank(*nxt.span()) + pos = nxt.end() + else: + break + return "".join(out) + + +_BLANK_LINE_AT = re.compile(r"\n[ \t\x00]*(?:\n|\Z)") # a newline that starts a blank line (or ends the text) + + +def _closing_bracket(text: str, start: int) -> int | None: + """Find a balanced closing bracket, respecting escaped and nested brackets. None when the scan crosses a blank line + first: a link text or label cannot span a paragraph boundary (CommonMark), so the bracket is not closed there.""" + depth, pos = 1, start + 1 + while pos < len(text): + char = text[pos] + # the newline that a backslash escapes is still a line end: a blank line after it is a boundary too + newline = pos if char == "\n" else pos + 1 if char == "\\" else -1 + if newline >= 0 and text[newline:newline + 1] == "\n" and _BLANK_LINE_AT.match(text, newline): + return None + if char == "\\": + pos += 2 + continue + if char == "[": + depth += 1 + elif char == "]": + depth -= 1 + if not depth: + return pos + pos += 1 + return None + + +# work counter of the opener pass: one per position advanced (an inert-region skip counts one) and one per call of +# `_has_link_opener`; the regression test reads and resets it +_OPENER_STEPS = 0 +# work counter of destination parsing (`_inline_image_target`): one per raw or angle destination character read, per +# whitespace character skipped and per title lookup; the regression test reads and resets it +_DEST_STEPS = 0 + +# block starts at a line start after up to three spaces of indent (CommonMark 4.1 ATX headings, 4.2 thematic breaks, +# 5.2 list items, which may interrupt a paragraph); blockquotes are tested apart because a quote line continues a quote +_BLOCK_START_LINE = re.compile( + r" {0,3}(?:#{1,6}(?:[ \t]|$)|[-+*](?:[ \t]|$)" + r"|(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$)", re.M) +# single-line blocks (headings and thematic breaks): the line itself is the whole block +_ATX_OR_THEMATIC_LINE = re.compile( + r" {0,3}(?:#{1,6}(?:[ \t]|$)|(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$)", re.M) +_BLOCKQUOTE_LINE = re.compile(r" {0,3}>") +# an ordered list item start: only `1.` may interrupt a paragraph, but a sibling item of the enclosing list (a marker +# indented less than the item's content column) always ends the item (CommonMark 5.2, as `mdcode._interrupts`) +_ORDERED_START_LINE = re.compile(r"( {0,3})(\d{1,9})[.)](?:[ \t]|$)") +_ITEM_CONTENT = re.compile(r" {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+(?=\S)") + + +def _list_base(text: str, pos: int) -> int: + """Content column of the list item whose marker starts the line holding `pos`, or 0 outside a list item line.""" + item = _ITEM_CONTENT.match(text, text.rfind("\n", 0, pos) + 1) + return item.end() - item.start() if item else 0 +# the underline of a setext heading: `===` or `---` after paragraph text (a bare `---` line is a thematic break above) +_SETEXT_UNDERLINE = re.compile(r" {0,3}(?:=+|-+)[ \t]*$", re.M) + + +def _block_boundary(text: str, newline: int, fences: tuple[tuple[int, int], ...], base: int = 0) -> bool: + """True when the newline at `newline` (plain text, outside every inert region) ends a link-opener span even though + no blank line follows: the line that just ended is a heading, a thematic break or the underline of a setext heading, + or the next line starts a heading, list item, thematic break, fence, or a blockquote that does not continue a + blockquote line. An ordered item interrupts only as `1.` or, when `base` (the content column of the list item that + holds the paragraph) is set, as a sibling indented less than `base`; `2.` and `0.` stay paragraph text. Each check reads at most the two adjacent lines (the previous line is found backwards only as far + as its own start), so the cost is linear in the length of the lines involved.""" + ended = text.rfind("\n", 0, newline) + 1 # start of the line that just ended + if _ATX_OR_THEMATIC_LINE.match(text, ended): + return True + if ended > 0 and _SETEXT_UNDERLINE.match(text, ended): # an underline makes a heading only after paragraph text + previous = text.rfind("\n", 0, ended - 1) + 1 + if text[previous:ended - 1].strip(): + return True + nxt = newline + 1 + if _BLOCK_START_LINE.match(text, nxt): + return True + ordered = _ORDERED_START_LINE.match(text, nxt) + if ordered and (int(ordered.group(2)) == 1 or (base and len(ordered.group(1)) < base)): + return True + k = nxt # a fence opener is found at its line start after up to three spaces of indent + while k < len(text) and k - nxt < 3 and text[k] == " ": + k += 1 + at = bisect.bisect_left(fences, (k, -1)) + if at < len(fences) and fences[at][0] == k: + return True + return bool(_BLOCKQUOTE_LINE.match(text, nxt)) and not _BLOCKQUOTE_LINE.match(text, ended) + + +def _crosses_block(text: str, start: int, end: int, fences: tuple[tuple[int, int], ...]) -> bool: + """True when a newline in text[start:end] is a block boundary (see `_block_boundary`). A plain soft line break is + not one: alt text and labels may continue on the next line of their paragraph.""" + newline = text.find("\n", start, end) + base = _list_base(text, start) if newline != -1 else 0 + while newline != -1: + if _block_boundary(text, newline, fences, base): + return True + newline = text.find("\n", newline + 1, end) + return False + + +_BRACKET = re.compile(r"[][]") + + +def _next_unescaped_bracket(text: str, start: int, end: int) -> int: + """Index of the first unescaped `[` or `]` in text[start:end], or -1. The scan stops at that bracket, so a label + scan costs the length of the text it reads, and the scans of distinct closers do not overlap.""" + for match in _BRACKET.finditer(text, start, end): + if not _escaped(text, match.start()): + return match.start() + return -1 + + +def _reference_form(text: str, open_idx: int, close: int, limit: int, keys: set) -> tuple[int | None, int | None] | None: + """Reference-style link after the `]` at `close` whose `[` is at `open_idx`, or None. Returns (jump, label_close): + `jump` is the index past the label's closing bracket (None for a shortcut, which does not jump) and `label_close` + is that bracket's index (None for a shortcut). The label must be plain text before + `limit`, the next inert region, so a jump never skips into one. Link text holding an unescaped bracket is no label. + An inline `(dest)` is decided by the caller first, so a `(` here means the closer is not a shortcut.""" + if _next_unescaped_bracket(text, open_idx + 1, close) != -1: + return None + if text[close + 1:close + 2] == "[": + label_end = _next_unescaped_bracket(text, close + 2, limit) + if label_end == -1: + return None + if label_end == close + 2 and text[label_end] == "]": # collapsed `[text][]` + if _label_key(text[open_idx + 1:close]) in keys: + return label_end + 1, label_end + return None + if text[label_end] == "]" and _label_key(text[close + 2:label_end]) in keys: # full `[text][label]` + return label_end + 1, label_end + return None + if text[close + 1:close + 2] == "(": # not a shortcut when a destination follows + return None + if _label_key(text[open_idx + 1:close]) in keys: # shortcut `[text]` + return None, None + return None + + +@functools.lru_cache(maxsize=16) +def _active_openers(text: str) -> frozenset[int]: + """Indices of every `]` that closes a link text, i.e. has a matching ACTIVE `[` before it in the same paragraph. + One left-to-right pass keeps a stack of active `[` indices. Fenced code, inline code, HTML comments and HTML open + tags (attribute values included) are inert, so a bracket inside them is text: it neither opens nor closes a link. + An unescaped `]` that pops an opener and is directly followed by a destination `(...)` skips that destination, + which is literal (a backtick in it opens no code span). A blank line clears the stack, and so does a block + boundary: a heading, list item, thematic break, fence or blockquote start (see `_block_boundary`), and the line + after a heading or thematic break, and the underline of a setext heading. A quote line continues an open quote, + so a `[` and its `](` in one blockquote still pair. Raw HTML blocks (`mdcode.html_block_spans`) are inert and are block boundaries: no `[` inside one opens + a link, and the stack is cleared entering and leaving it. Completing an inline link deactivates every earlier LINK + opener in the stack (CommonMark: links may not contain links), while earlier IMAGE openers stay active; a `barrier` + marks the stack depth below which link openers are inactive, so that deactivation is O(1). Reference-style links + `[x][ref]`, `[x][]` and `[x]` deactivate earlier link openers too when the label resolves to a live definition + (`_reference_form`); an inline `(dest)` is tried first. The label of a full reference is read up to the next + non-inline inert region, so a backtick in a resolved label is literal and an unresolved label is not consumed. Inert regions are found + by cached lookups as in `_outside_code`, so the pass is linear. Offsets are the source offsets. It never calls + `_outside_code`. Live reference definitions (`_definition_spans`) are inert block regions like raw HTML blocks: their brackets and backticks are literal, and the opener stack is cleared entering and leaving one.""" + global _OPENER_STEPS + fences = _fence_spans(text) + html_spans = mdcode.html_block_spans(text) + defs = _definition_spans(text) # live reference definitions: inert block regions, cached per document + keys = {_label_key(m.group(1)) for m in _definitions(text)} # live definition labels, computed once per call + opened: set[int] = set() + stack: list[tuple[int, bool]] = [] # (index of `[`, whether it opens an image `![`) + barrier = 0 # stack positions below this hold inactive link openers (a link was completed after them) + inline = tag = comment = None # cached next starts: None = not searched yet, False = none left + n, pos = len(text), 0 + + def next_inline(start): + while True: + found = find_inline_code(text, start) # (start, end) or None + if not found: + return False + inside = _span_at(fences, found[0]) + if inside is not None: + start = inside[1] + continue + inside = _span_at(defs, found[0]) # a backtick inside a live definition is literal, not code + if inside is not None: + start = inside[1] + continue + return found + + while pos < n: + fence = _span_at(fences, pos) + if fence is not None: # fenced code is inert and a block boundary: no opener before it can match a closer after it + _OPENER_STEPS += 1 + pos = fence[1] + stack.clear() + barrier = 0 + continue + html_block = _span_at(html_spans, pos) + if html_block is not None: # a raw HTML block is inert text and a block boundary, like fenced code + _OPENER_STEPS += 1 + pos = html_block[1] + stack.clear() + barrier = 0 + continue + definition = _span_at(defs, pos) + if definition is not None: # a live definition is not paragraph text: a block boundary, like raw HTML + _OPENER_STEPS += 1 + pos = definition[1] + stack.clear() + barrier = 0 + continue + if inline is not False and (inline is None or inline[0] < pos): + inline = next_inline(pos) + if tag is not False and (tag is None or tag.start() < pos): + tag = _search_unescaped(HTML_OPEN_TAG, text, pos, html_spans) or False + if comment is not False and (comment is None or comment.start() < pos): + comment = _search_comment(text, pos, html_spans) or False + k = bisect.bisect_left(fences, (pos, -1)) + regions = [] + if inline: + regions.append(inline) + if tag: + regions.append(tag.span()) + if comment: + regions.append(comment.span()) + if k < len(fences): + regions.append(fences[k]) + h = bisect.bisect_left(html_spans, (pos, -1)) + if h < len(html_spans): + regions.append(html_spans[h]) + d = bisect.bisect_left(defs, (pos, -1)) # the next live definition at or after `pos` (`pos` is outside all of them) + if d < len(defs): + regions.append(defs[d]) + region = min(regions) if regions else None # the next inert region at or after `pos` + limit = region[0] if region else n + # the next inert region other than inline code: a backtick inside a reference label is literal, so the start of + # an inline code span must not truncate the label scan of `_reference_form` + ref_limit = n + if tag: + ref_limit = min(ref_limit, tag.start()) + if comment: + ref_limit = min(ref_limit, comment.start()) + if k < len(fences): + ref_limit = min(ref_limit, fences[k][0]) + if h < len(html_spans): + ref_limit = min(ref_limit, html_spans[h][0]) + if d < len(defs): + ref_limit = min(ref_limit, defs[d][0]) + jump = None + i = pos + while i < limit: # plain text between inert regions: brackets are read one position at a time + _OPENER_STEPS += 1 + char = text[i] + if char == "[" and not _escaped(text, i): + stack.append((i, i > 0 and text[i - 1] == "!" and not _escaped(text, i - 1))) + elif char == "]" and stack and not _escaped(text, i): + # the top opener is active unless a link was completed after it (images stay active) + active = stack[-1][1] or len(stack) - 1 >= barrier + open_idx, is_image = stack.pop() + if len(stack) < barrier: + barrier = len(stack) + if active: + opened.add(i) + if text[i + 1:i + 2] == "(" and (target := _inline_image_target(text, i + 1)): + jump = target[1] # the destination of a completed link is literal text + if not is_image: # completing a link deactivates every earlier link opener + barrier = len(stack) + break + # a backtick inside a reference label is literal, so the inline-code start must not truncate the label scan + form = _reference_form(text, open_idx, i, ref_limit, keys) + if form is not None: # a reference-style link completes like an inline one + jump_to, label_close = form + if label_close is not None: # the label's `]` is marked as the scan marks any active `]` + opened.add(label_close) + if not is_image: + barrier = len(stack) + if jump_to is not None: # skip the label so its `[` never opens a link + jump = jump_to + break + elif char == "\n": + j = i + 1 + while j < n and text[j] in " \t\r": + j += 1 + if j < n and text[j] == "\n": # a blank line is a paragraph boundary + stack.clear() + barrier = 0 + elif _block_boundary(text, i, fences, _list_base(text, stack[0][0]) if stack else 0): # so is the start of a block without a blank line + stack.clear() + barrier = 0 + i += 1 + if jump is not None: + pos = jump + elif region is None: + break + else: + _OPENER_STEPS += 1 + if k < len(fences) and region == fences[k]: # a fence skipped as a region is a block boundary too + stack.clear() + barrier = 0 + elif h < len(html_spans) and region == html_spans[h]: # so is a raw HTML block skipped as a region + stack.clear() + barrier = 0 + elif d < len(defs) and region == defs[d]: # and so is a live definition (its own block boundary) + stack.clear() + barrier = 0 + pos = region[1] + return frozenset(opened) + + +def _has_link_opener(text: str, close: int) -> bool: + """True when the `]` at index `close` closes a link text: it is in the set of active closing brackets of `text` + (see `_active_openers`). After the cached pass each call is one set lookup.""" + global _OPENER_STEPS + _OPENER_STEPS += 1 + return close in _active_openers(text) + + +def _label_key(value: str) -> str: + """Normalised reference label: backslash escapes and entities resolved, whitespace collapsed, case folded.""" + value = html.unescape(re.sub(r"\\([^\w\s])", r"\1", value)) + return " ".join(value.split()).casefold() + + +@functools.lru_cache(maxsize=16) +def _reference_label_spans(document: str) -> tuple[tuple[int, int], ...]: + """Label text of each resolved full reference (`[text][label]`): the characters between the brackets of the second + pair. Only a label that names a live definition is a reference, so its backticks are literal; an unresolved label + stays readable for inline code. Junctions inside fences or definitions are text. Computed once per document; + the spans are sorted and never overlap.""" + keys = {_label_key(m.group(1)) for m in _definitions(document)} + fences = mdcode.fence_spans(document) + definitions = _definition_spans(document) + spans = [] + for junction in re.finditer(r"\]\[", document): + at = junction.start() + if _escaped(document, at) or _span_at(fences, at) is not None or _span_at(definitions, at) is not None: + continue + if at not in _active_openers(document): + continue # the first `]` closes no link text (an orphan junction): `[label]` is a shortcut, not a full reference + open_ = at + 1 + close = _closing_bracket(document, open_) + if close is None: + continue + label = document[open_ + 1:close] + if not label.strip() or re.search(r"\n[ \t]*\n", label): + continue # an empty label or one spanning a blank line is not a reference label + first_fence = bisect.bisect_left(fences, (open_ + 1, -1)) + if first_fence < len(fences) and fences[first_fence][0] < close: + continue # a fenced block cannot sit inside a link label + if _label_key(label) in keys: + spans.append((open_ + 1, close)) + return tuple(spans) + + +@functools.lru_cache(maxsize=16) +def _blank_line_starts(text: str) -> tuple[int, ...]: + """Sorted start offsets of every paragraph boundary (a newline that starts a blank line), from one regex pass.""" + return tuple(match.start() for match in re.finditer(r"\n[ \t\r\x00]*\n", text)) + + +def _blank_between(text: str, a: int, b: int) -> bool: + """True when a paragraph boundary starts at an offset in [a, b): a bisection over the cached boundary list.""" + starts = _blank_line_starts(text) + at = bisect.bisect_left(starts, a) + return at < len(starts) and starts[at] < b + + +@functools.lru_cache(maxsize=16) +def _title_ends(text: str) -> dict[str, tuple[int, ...]]: + """Sorted positions of the unescaped `"`, `'`, `)` and `(` in `text`, from one linear pass. An occurrence is escaped when + an odd number of backslashes directly precedes it: the parity the destination scanner's skip-two loop produces.""" + found: dict[str, list[int]] = {'"': [], "'": [], ")": [], "(": []} + backslashes = 0 # length of the run of backslashes directly before the current position + for index, char in enumerate(text): + if char == "\\": + backslashes += 1 + continue + if char in found and backslashes % 2 == 0: + found[char].append(index) + backslashes = 0 + return {char: tuple(positions) for char, positions in found.items()} + + +def _inline_image_target(text: str, start: int) -> tuple[str, int] | None: + """Read a Markdown destination and optional title after its opening parenthesis. Destination nesting deeper than + 32 parentheses is no link (CommonMark's limit), which bounds each call and keeps the opener pass linear.""" + global _DEST_STEPS + pos = start + 1 + while pos < len(text) and text[pos].isspace(): + _DEST_STEPS += 1 + pos += 1 + if _blank_between(text, start + 1, pos): # (a) a paragraph boundary before the destination + return None + begin = pos + if pos < len(text) and text[pos] == "<": + begin = pos = pos + 1 + while pos < len(text) and text[pos] != ">": + _DEST_STEPS += 1 + if text[pos] in "\n<": + return None + pos += 2 if text[pos] == "\\" else 1 + if pos >= len(text): + return None + target = text[begin:pos] + pos += 1 + else: + depth = 0 + while pos < len(text): + _DEST_STEPS += 1 + char = text[pos] + if char == "\\": + pos += 2 + continue + if char.isspace() or char == ")" and depth == 0: + break + if char == "(": + depth += 1 + if depth > 32: # CommonMark's nesting limit: a deeper destination is no link + return None + elif char == ")": + depth -= 1 + pos += 1 + if depth: + return None + target = text[begin:pos] + dest_end = pos + while pos < len(text) and text[pos].isspace(): + _DEST_STEPS += 1 + pos += 1 + if _blank_between(text, dest_end, pos): # (b) a boundary before the title, or before `)` when there is no title + return None + if pos < len(text) and text[pos] in "\"'(": + end = ")" if text[pos] == "(" else text[pos] + pos += 1 + _DEST_STEPS += 1 # one title lookup, not one step per character it passes over + ends = _title_ends(text)[end] + at = bisect.bisect_left(ends, pos) # the first unescaped end character at or after the title start + if at == len(ends): + return None + if end == ")": # a parenthesized title may not hold an unescaped `(`: the link is then no link (CommonMark 6.3) + opens = _title_ends(text)["("] + first_open = bisect.bisect_left(opens, pos) + if first_open < len(opens) and opens[first_open] < ends[at]: + return None + if _blank_between(text, pos, ends[at]) or _crosses_block(text, pos, ends[at], _fence_spans(text)): + return None # (c) a title may span soft line breaks, never a paragraph or block boundary (heading, list, fence) + title_close = pos = ends[at] + 1 + while pos < len(text) and text[pos].isspace(): + _DEST_STEPS += 1 + pos += 1 + if _blank_between(text, title_close, pos): # (d) the whitespace after the title, up to `)` + return None + if pos >= len(text) or text[pos] != ")": + return None + return html.unescape(re.sub(r"\\([^\w\s])", r"\1", target)), pos + 1 + + +@functools.lru_cache(maxsize=16) +def _link_literal_spans(document: str) -> tuple[tuple[int, int], ...]: + """Spans of resolved inline link destinations and titles, from `(` through the closing `)`. This text is literal: a + `![...](...)` inside a link title renders no image, while a linked image in the link text is not covered. The spans + are sorted and never overlap, because `_active_openers` skips a completed destination.""" + spans = [] + for i in sorted(_active_openers(document)): + if document[i + 1:i + 2] != "(": + continue + target = _inline_image_target(document, i + 1) + if target is not None: + spans.append((i + 1, target[1])) + return tuple(sorted(spans)) + + +def _markdown_images(text: str, document: str) -> list[tuple[str, str, int, int]]: + """Read inline and reference images with balanced alt text; definitions use first-wins labels.""" + def label(value): + value = html.unescape(re.sub(r"\\([^\w\s])", r"\1", value)) + return " ".join(value.split()).casefold() + + definitions = {} + for match in _definitions(document): + target = html.unescape(re.sub(r"\\([^\w\s])", r"\1", match.group(2) or match.group(3))) + definitions.setdefault(label(match.group(1)), target) + spans = _definition_spans(document) # definition titles are never rendered as images + html_spans = mdcode.html_block_spans(document) # raw HTML blocks are literal: Markdown image syntax there is text + link_spans = _link_literal_spans(document) # a link destination or title is literal text, never a rendered image + # once per call: block boundaries inside alt text and labels. Fences come from the written document (the masked copy + # blanks fenced lines, and `_layout` is then built once per document, not once per copy) + fences = _fence_spans(document if len(document) >= len(text) else text) + sources = [] + consumed = 0 + for match in re.finditer(r"!\[", text): + if match.start() < consumed: + continue + if _span_at(spans, match.start()) or _span_at(html_spans, match.start()): + continue + if _span_at(link_spans, match.start()) is not None: + continue + pos = match.start() - 1 + while pos >= 0 and text[pos] == "\\": + pos -= 1 + if (match.start() - pos - 1) % 2: + continue # an escaped exclamation mark is text, not an image + close = _closing_bracket(text, match.start() + 1) + if close is None: + continue + if _crosses_block(text, match.end(), close, fences): + continue # alt text cannot hold a heading, list item, fence or quote start: no image forms there + alt = text[match.end():close] + after = close + 1 # tentative: `consumed` moves only once the image resolves, so a failed outer keeps its nested images + if after < len(text) and text[after] == "(": + # Destinations are read from the written document: the masked copy blanks literal backticks in filenames. + target = _inline_image_target(document if len(document) >= len(text) else text, after) + if target: + source, end = target + sources.append((alt, source, match.start(), end)) + consumed = end + continue + # not an inline destination (for example, a space-containing label): try a shortcut reference + # `text` masks inline code to spaces (a prefix-aligned copy): labels are matched on the written source, literal backticks included + key = label(document[match.end():close] if len(document) >= len(text) else alt) + if after < len(text) and text[after] == "[": + ref_close = _closing_bracket(text, after) + # a label holding a block boundary is no label: the shortcut form below still applies, as CommonMark has it + if ref_close is not None and not _crosses_block(text, after + 1, ref_close, fences): + key = label((document if len(document) >= len(text) else text)[after + 1:ref_close]) or key + after = ref_close + 1 + if key in definitions: + sources.append((alt, definitions[key], match.start(), after)) + consumed = after + return sources + + +def _images(text: str, document: str) -> list[tuple[str, str]]: + """Read HTML and Markdown images, excluding Markdown-looking text in HTML attributes.""" + parser = _HTMLTokens(text) + markdown = _markdown_images(text, document) + # Angle-delimited destinations (and HTML in alt text) belong to their Markdown image, + # while a Markdown-looking string wholly inside a real HTML tag is attribute text. + # Both comparisons are interval lookups over sorted, non-overlapping spans (bisection), never a cross product. + image_spans = [(mstart, mend) for _, _, mstart, mend in markdown] + + def inside(spans, start, end): + span = _span_at(spans, start) + return span is not None and end <= span[1] + + link_spans = _link_literal_spans(document) # tags and images inside a link destination or title are literal text + tags = [(start, end) for start, end in parser.tags if not inside(image_spans, start, end) + and _span_at(link_spans, start) is None] + def written_alt(alt, start): + # `text` has Markdown code masked to spaces (same length): read code-formatted alt text from the document + begin = start + 2 + return document[begin:begin + len(alt)] if len(document) >= begin + len(alt) else alt + + spans = _definition_spans(document) + def in_definition(start): + return _span_at(spans, start) is not None + def in_link_literal(start): + return _span_at(link_spans, start) is not None + result = [(written_alt(alt, start), source) for alt, source, start, end in markdown + if not in_definition(start) and not inside(tags, start, end) and not in_link_literal(start)] + kept = set(tags) + result += [(alt, source) for alt, source, start, end in parser.images + if (start, end) in kept and not in_definition(start) and not in_link_literal(start)] + return result def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: @@ -93,10 +1022,14 @@ def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: elif len(h1s) != 1: fails.append(f"R7 exactly one H1 required (found {len(h1s)})") # badges 2-4, palette tokens only - shields = re.findall(r"img\.shields\.io/badge/([^)\"\s]+)", plain) + shields: list[str] = [] header_part = plain.split("\n---\n", 1)[0] - hb = re.findall(r"img\.shields\.io/badge/([^)\"\s]+)", header_part) - nbadges = len(hb) + len(re.findall(r"badge\.svg", header_part)) + # masked over the whole document, then cut to the header: a reference label is resolved by a definition below it + badge_header = _outside_code(plain)[:len(header_part)] + image_sources = [source for _, source in _images(badge_header, plain)] + nbadges = sum(bool(SHIELDS_SRC.match(src) + or re.fullmatch(r"(?:\./)?docs/images/badges/[^/?#]+\.svg(?:[?#].*)?", unquote(src)) + or re.search(r"(?:^|/)badge\.svg(?:[?#].*)?$", src)) for src in image_sources) if not preserve: from .readme import badges as derivable floor = min(2, len(derivable(ctx))) # a public repo with no licence and no language derives fewer than two @@ -104,8 +1037,12 @@ def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: fails.append(f"R7 2-4 header badges required (found {nbadges})") elif nbadges < 2: warns.append(f"badges-short: {nbadges} badge (no licence or language to show yet)") + # colours come only from complete parsed image destinations, so an apostrophe or other HTML delimiter inside a Markdown URL hides nothing + shields += [m.group(1) for _, src in _images(_outside_code(plain), plain) + if (m := SHIELDS_BADGE.match(src))] + shields = list(dict.fromkeys(shields)) for s in shields: - col = re.split(r"[?)]", s.rsplit("-", 1)[-1])[0].upper() + col = _badge_colour(s) family_cols = {str(ctx.style.get(k) or "").upper() for k in ("badge_brand", "badge_accent")} if re.fullmatch(r"[0-9A-F]{6}", col) and col not in PALETTE and col not in family_cols: fails.append(f"R7 off-palette badge colour {col}") @@ -155,17 +1092,12 @@ def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: elif _extra_footer(text): fails.append("D18 footer fail:duplicate") # alt text, local images - for m in IMG_MD.finditer(plain): - if not m.group(1).strip(): - fails.append(f"R7 image without alt text: {m.group(2)[:40]}") - _local(path, m.group(2), fails) - for m in IMG_HTML.finditer(plain): - a = re.search(r'alt="([^"]*)"', m.group(1)) - if not a or not a.group(1).strip(): - fails.append("R7 without alt text") - src = re.search(r'src="([^"]+)"', m.group(1)) - if src: - _local(path, src.group(1), fails, example="examples" in src.group(1)) + visible = _outside_code(plain) + for alt, source in _images(visible, plain): + if not alt.strip(): + fails.append(f"R7 image without alt text: {source[:40]}") + if source: + _local(path, source, fails, example="examples" in source) body = _outside_code(plain) ph = re.search(rv["placeholder_regex"], body) if ph or re.search(r"<…>|<\.\.\.>", body): diff --git a/tests/test_mdcode_blocks.py b/tests/test_mdcode_blocks.py new file mode 100644 index 0000000..607f116 --- /dev/null +++ b/tests/test_mdcode_blocks.py @@ -0,0 +1,493 @@ +"""Block structure is decided before inline code: a span never pairs across a block boundary. Expected renders follow +the GitHub Markdown API (gfm mode) and CommonMark 0.31.2 HTML blocks 1-6 (type 7 cannot interrupt a paragraph).""" +import re + +import pytest + +from ctshowcase import mdcode, readme, readmelint + +IMAGE = "![x](docs/images/missing.png)" +MARKER = "\n**Use `npx` now**\n" + + +def images(text): + return readmelint._images(readmelint._outside_code(text), text) + + +def covers(text, needle): + at = text.index(needle) + return any(a <= at < b for a, b in mdcode.code_spans(text)) + + +def test_stray_backtick_above_marker_keeps_the_block(): + t = "Price is 5` per unit\n" + MARKER + "\n" + assert [m.group(1) for m in mdcode.find_blocks(readme.MARK, t)] == ["tagline"] + assert not covers(t, "\nfooter\n\n- then `done`\n" + assert [m.group(1) for m in mdcode.find_blocks(readme.MARK, t)] == ["footer"] + + +@pytest.mark.parametrize("first,second", [ + ("- Install `foo", "- See " + IMAGE + " and `bar`"), + ("* Install `foo", "* See " + IMAGE + " and `bar`"), + ("1. Install `foo", "2. See " + IMAGE + " and `bar`"), + ("1. Install `foo", "1. See " + IMAGE + " and `bar`"), + ("10. Install `foo", "11. See " + IMAGE + " and `bar`"), + ("- Install `foo", " - See " + IMAGE + " and `bar`"), +]) +def test_list_items_do_not_share_a_code_span(first, second): + assert images(first + "\n" + second + "\n") == [("x", "docs/images/missing.png")] + + +def test_continued_quote_lines_stay_one_paragraph_as_on_github(): + t = "> quote with ` tick\n> and ![x](y.png) then `z`\n" + assert images(t) == [] + assert [t[a:b] for a, b in mdcode.code_spans(t)] == ["` tick\n> and ![x](y.png) then `"] + + +def test_lazy_quote_continuation_stays_one_paragraph(): + t = "> quote with ` tick\nand ![x](y.png) then `z`\n" + assert images(t) == [] + + +@pytest.mark.parametrize("first,second", [ + ("plain ` tick", "> and ![x](y.png) then `z`"), + ("> outer ` tick", "> > and ![x](y.png) then `z`"), +]) +def test_new_quote_breaks_a_paragraph(first, second): + assert images(first + "\n" + second + "\n") == [("x", "y.png")] + + +RAW_BLOCK_OPENERS = {"
", "
", '

', "

", ""}
+
+
+@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\\`![x](y.png)`\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 ![in{i}](docs/images/in{i}.png) `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 ![Logo](docs/images/missing.png) 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 ![Logo](docs/images/missing.png) 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 ![Logo](docs/images/missing.png) 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, "![A](a`b.png) ![B](missing.png) `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 = "![A `x](docs/images/missing.png) 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![Type](https://img.shields.io/badge/type-x-FF0000)\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", [ + "![Type](https://img.shields.io/badge/type-facade's-005ADD)", + "![Type](https://img.shields.io/badge/type-DEADBE's-005ADD)", + "![Type](https://img.shields.io/badge/my--label-a__b-005ADD)", + "![Type](https://img.shields.io/badge/my__label-x--y-64748B)", + "![Type](https://img.shields.io/badge/type-facade's-005ADD?style=flat)", + 'Type', +], 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", [ + "![Type](https://img.shields.io/badge/type-facade's-123ABC)", + "![Type](https://img.shields.io/badge/type-x-123ABC)", + "![Type](https://img.shields.io/badge/my--label-x--y-123ABC)", + 'Type', +]) +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]: ![Screenshot](docs/images/missing.png)", + "Text\n[a]: https://example.test/a\nmore text\n[shot]: ![Screenshot](docs/images/missing.png)", +], 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]: ![Screenshot](docs/images/missing.png)", + "Intro.\n\n[shot]: ![Screenshot](docs/images/missing.png)", + "[a]: https://example.test/a\n[shot]: ![Screenshot](docs/images/missing.png)", +], 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"![L](https://img.shields.io/badge/language-{label}-FF0000)" + 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 = "![L](https://img.shields.io/badge/language-C-FF0000?style=flat)" + 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 = "![L](https://img.shields.io/badge/language-C%23-005ADD)" + 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` ![Shot](docs/images/missing.png)" + 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]: ![S](docs/images/missing.png)` 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, "![outer ![inner](docs/images/missing.png)]") + + +def test_resolved_outer_reference_image_still_consumes_its_nested_text(make_ctx, tmp_path, brand): + doc = "![outer ![inner](docs/images/missing.png)][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 = ["![x](docs/images/missing.png)", "![x](https://img.shields.io/badge/a-b-ABCDEF)"] + + +@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 = 'Shot' + + +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![Example](docs/images/missing.png)\n
", + "
\n![Example](docs/images/missing.png)\n
", + "
\n![Example][shot]\n
\n\n[shot]: docs/images/missing.png", + "\n\n
\n![Example]()\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![Example](docs/images/missing.png)", + "
\nraw\n
\n\n![Example][shot]\n\n[shot]: docs/images/missing.png", + "
\nraw\n
\n![Example](docs/images/missing.png)", + "
\nraw 
\n\n![Example](docs/images/missing.png)", +], 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![Example](docs/images/missing.png)\n```") == [] + + +def test_many_images_and_html_blocks_are_scanned_in_linear_work(): + from ctshowcase import mdcode + + def work(n): + doc = "".join(f"
\n![a{i}](docs/images/x{i}.png)\n
\n\n![b{i}](docs/images/y{i}.png)\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\n
[open
\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), + ("![alt [inner](ok)](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 = "![alt [inner](ok)](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 = 'Shot' +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]: ![Shot](docs/images/missing.png)", True), + ("escaped_bracket_control", "Intro.\n\n[ok\\[label]: ![Shot](docs/images/missing.png)", 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> ![Shot](docs/images/missing.png)", True), + ("quoted_list_fence_quote_blank", "> - Example\n>\n> ```text\n> code\n>\n> ![Shot](docs/images/missing.png)", True), + ("quoted_list_closed_fence_control", "> - Example\n>\n> ```text\n> ![Shot](docs/images/missing.png)\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 "![License](https://img.shields.io/badge/license-MIT-64748B)")' + 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 "![Shot](docs/images/missing.png)' + '![Type](https://img.shields.io/badge/type-x-FF0000)' + '\'x\'")') + 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, "[![Shot](docs/images/missing.png)](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 ![Shot](docs/images/missing.png)))' + 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 ![Type](https://img.shields.io/badge/type-x-ABCDEF)))' + 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![Shot](docs/images/missing.png)' + 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"![Type\n2. GitHub profile]({GITHUB_PROFILE_BADGE})") + 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"![Type\n1. GitHub profile]({GITHUB_PROFILE_BADGE})") + 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"![Type\n- GitHub profile]({GITHUB_PROFILE_BADGE})") + 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"- ![Type\n2. GitHub profile]({GITHUB_PROFILE_BADGE})") + 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="![Shot](docs/images/missing.png)") + 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="![Type](https://img.shields.io/badge/type-x-ABCDEF)") + 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![Shot](docs/images/missing.png)\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![Shot](docs/images/missing.png)\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![Shot](docs/images/missing.png)\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 ` ![A](a.png)", +]) +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)") == [("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", [ + ('a`b see `![F](https://img.shields.io/badge/c-d-005ADD)`', + [("a`b", "https://img.shields.io/badge/a-b-005ADD")]), + ("A `![F](y.png)`", []), + ("A `![F](y.png)` ![G](g.png)", [("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 `![F](y.png)`' + 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, "![Type](https://img.shields.io/badge/type-it's-FF0000)", "") + 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 = "![Shot](docs/images/missing.png)" + + +@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 + 'Shot' + 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 "\'Ghost\'"\n' + assert images(document) == [] + + +def test_http_shields_badges_count_and_pass_r7(make_ctx, tmp_path): + usage = ("![Type](http://img.shields.io/badge/type-GitHub%20profile-005ADD)\n" + "![License](http://img.shields.io/badge/license-MIT-64748B)") + 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, "![Type](http://img.shields.io/badge/type-x-FF0000)", "") + 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'CI', + f'CI', + f"CI", + 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\nCI\n```', + 'CIBuild 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'```showcase.py --help```', +]) +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"![CI](<{URL}>)", f"[![CI](<{URL}>)](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![`gh` screenshot](docs/images/s.png)\n", ["`gh` screenshot"]), + ("\n" + " " * 20 + "\n![Logo](docs/images/logo.png)\n", ["Logo"]), + ("\n![ ](docs/images/shot.png)\n", [" "]), + ("\nprose\n![ ](https://example.com/b.png)\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![ ](docs/images/shot.png)", "[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![ ](docs/images/screenshot.png)\n", [" "]), + ("Use `
`, `
` or `` tags.\n\n![Architecture](docs/x.png)\n" + "\n" * 60, ["Architecture"]), + ("`
`\n![Logo](docs/images/logo.png)\n", ["Logo"]), + ("Use `\"example\"` here\n\n![ ](docs/images/shot.png)\n", [" "]), + ("```html\n

\n\n```\n\n![Valid alt](docs/images/v.png)\n", ["Valid alt"]), + ("```html\n\"Shot\"\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, "![CI](current build)", 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](current build)", "[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](current build)", "[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 = ('`showcase.py --help`\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("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"![License MIT](https://img.shields.io/badge/license-MIT-64748B)\n[![CI]({url}/badge.svg)]({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 = ("![`showcase.py --help`](https://img.shields.io/badge/type-GitHub%20profile-005ADD)\n" + "![License](https://img.shields.io/badge/license-MIT-64748B)") + (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"![License MIT](https://img.shields.io/badge/license-MIT-64748B)\n[![CI]({url}/badge.svg)]({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 = ("![Language Python](https://img.shields.io/badge/language-Python-115E59)\n" + "![License MIT](https://img.shields.io/badge/license-MIT-64748B)") + 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 == "![Type Template](docs/images/badges/type-template.svg)" + 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 = " ![License MIT](https://img.shields.io/badge/license-MIT-64748B)" +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("