From b6d790db0b06733b6a28bd7e2cb3ec038a091b19 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 29 Oct 2026 22:16:11 -0400 Subject: [PATCH 01/98] fix: generate and preserve variant J README badges Co-Authored-By: Claude --- CHANGELOG.md | 1 + SKILL.md | 2 +- references/readme-variants.md | 2 + references/standard/standard.lock.json | 1 + references/standard/types.yaml | 2 +- scripts/ctshowcase/readme.py | 29 +++++++--- tests/test_variant_j_badges.py | 78 ++++++++++++++++++++++++++ 7 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 tests/test_variant_j_badges.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 26f7337..e9bf3a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ 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. Upgrade and header refresh preserve nonempty authored badge blocks, preventing `readme-shrink`; missing licenses remain explicit instead of being invented. - `doctor` reports disabled-Issues label checks, inapplicable social previews and archived community health as `n/a`, while preserving label coverage during approved archived polishing and unknown captured values on applicable surfaces. Social upload plans share the R10 applicability rule and include the personal-profile capstone. - `doctor` reports R11 as `n/a` for archived repositories and outside T1/T2 organizations (personal-account, utility and co-owned repositories), matching the definition of done. - R5 preserves upstream defaults on forks (forks in the watching and archive orgs still need Issues off, as `adopt` plans) and accepts a captured user keep decision for other default branches without relaxing the merge or Issues checks. `adopt` and the `doctor --estate` proposals honour the same keep decision: `adopt` logs `default-branch kept (user)` instead of asking for a `rename-default-branch` proposal, and neither asks for one on a fork. diff --git a/SKILL.md b/SKILL.md index b326dfd..0b74083 100644 --- a/SKILL.md +++ b/SKILL.md @@ -78,7 +78,7 @@ Run every command **from inside the target repository's checkout**, with the ski | 3 | `topics`, `labels --plan` | Proposals only. Add `--records FILE` to emit change records, `--gh` to print the exact `gh` commands. | | 4 | Apply the approved description and topics on GitHub (the writer), or write them into `meta.json` | Before any art: the banner subtitle, the card and the README tagline come from the description, so rendering first breaks R2 (tagline equals description) the moment the writer applies the new one. | | 5 | `banner`, then `card` (and `content social`) | `art-fail` (exit 3) writes nothing. `needs-input` (exit 4) names the missing fact, including fewer than two fact chips (`needs-input:banner-chips`; add `chips:` to `.github/showcase.yml` or pass `--key-chip`/`--chip`). | -| 6 | `readme`, then `readme --write` | Dry run prints a unified diff first. `readme-shrink` (exit 1) writes nothing. | +| 6 | `readme`, then `readme --write` | Dry run prints a unified diff first. `readme-shrink` (exit 1) writes nothing. Variant J generates a GitHub-profile type badge and a license badge when the license is known; subsequent refreshes preserve authored nonempty badge blocks for linting. | | 7 | `footer --check README.md`, `lint all` | Every applicable check passes; `incomplete` is allowed for logged `needs-input` omissions. | | 8 | `doctor --header` | One coverage row. `done` only when every applicable cell is `pass`, `warn`, `incomplete` or `n/a`, and the banner has two to four chips. | | 9 | `contact-sheet` | A human (or an independent verifier) views the set on both GitHub page colours before rollout. Pass PNGs: `card --png`, `org-tile --png` previews, or install the optional `resvg_py` (`requirements-preview.txt`) so SVG inputs (the intro's static frame) render too; otherwise an SVG input exits 2. | diff --git a/references/readme-variants.md b/references/readme-variants.md index 837129f..80f0802 100644 --- a/references/readme-variants.md +++ b/references/readme-variants.md @@ -64,6 +64,8 @@ BADGE 1 BADGE 2 [BADGE 3] [BADGE 4] 3. License: `license-SPDX-64748B`, or `license-proprietary-64748B` for private repositories; omitted while a public repository has no license. 4. CI, public repositories only: the Actions badge of a workflow whose file name contains `test`, else `ci`, using the current owner. +Variant J (`.github`) uses the `GitHub profile` type badge from `references/standard/types.yaml`, even though the profile has no card category. With a known license this produces two badges without requiring a language. A public profile without a known license gets only its type badge and the existing `badges-short` warning; no license is invented. Nonempty authored badge blocks in variant J survive both upgrade and header refresh unchanged, including HTML badge rows. `lint readme` still checks their count and palette; an empty block is regenerated. + Each label and message segment doubles `-` and `_` (shields.io's escapes) and is then percent-encoded, so `C#` reads `language-C%23-1E3A8A` and `#`, `?`, `/` or `%` never end the path early. | Token | Hex | Use | diff --git a/references/standard/standard.lock.json b/references/standard/standard.lock.json index 5da772f..e1c01ab 100644 --- a/references/standard/standard.lock.json +++ b/references/standard/standard.lock.json @@ -841,6 +841,7 @@ "variant": "E" }, "github-profile": { + "badge_category": "GitHub profile", "category": null, "variant": "J" }, diff --git a/references/standard/types.yaml b/references/standard/types.yaml index 30b444e..deca37c 100644 --- a/references/standard/types.yaml +++ b/references/standard/types.yaml @@ -15,7 +15,7 @@ deps: types: fork: {variant: G, category: null} backup: {variant: H, category: null} - github-profile: {variant: J, category: null} + github-profile: {variant: J, category: null, badge_category: GitHub profile} template: {variant: C, category: Template} agent-skills: {variant: A, variant_elsewhere: C, category: Agent skill} security-check: {variant: F, category: CVE checker} diff --git a/scripts/ctshowcase/readme.py b/scripts/ctshowcase/readme.py index 509d7bc..f4ac53e 100644 --- a/scripts/ctshowcase/readme.py +++ b/scripts/ctshowcase/readme.py @@ -172,8 +172,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)) @@ -226,6 +228,13 @@ def badges_html(ctx) -> str: return '

\n' + "\n".join(parts) + "\n

" +def badge_body(ctx: RepoContext, existing: str | None = None) -> str: + """Variant J keeps authored badge rows; lint still checks their count and palette.""" + if ctx.det["variant"] == "J" and existing and existing.strip(): + return existing + return badges_html(ctx) if ctx.det["variant"] == "F" else badges_md(ctx) + + 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".""" @@ -263,16 +272,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) @@ -615,7 +624,10 @@ def upgrade(ctx: RepoContext, text: str) -> tuple[str, list[str]]: 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 = {"tagline": tag} + if variant == "J" and blocks.get("badges", "").strip(): + keep["badges"] = blocks["badges"] + body, _ = _strip_markers(text, keep=keep) if want_footer: body, _ = drop_old_footer(body, ctx.brand) # its lines are already in `removed` pre, sections = split_sections(body) @@ -731,7 +743,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) @@ -776,10 +788,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) diff --git a/tests/test_variant_j_badges.py b/tests/test_variant_j_badges.py new file mode 100644 index 0000000..d07c655 --- /dev/null +++ b/tests/test_variant_j_badges.py @@ -0,0 +1,78 @@ +"""Variant J must derive badges and retain authored badges through refreshes.""" +import re + +import pytest + +from ctshowcase import readme, readmelint + + +def profile_context(make_ctx, tmp_path, **kwargs): + (tmp_path / "docs/images").mkdir(parents=True) + (tmp_path / "docs/images/banner.png").write_bytes(b"banner") + return make_ctx(repo_dir=tmp_path, repo=".github", language=None, + description="Community health files for Northwind Trading Co.", **kwargs) + + +def badge_block(text): + return next(m.group(2) for m in readme.MARK.finditer(text) if m.group(1) == "badges") + + +def test_variant_j_generates_type_and_license_badges(make_ctx, tmp_path): + ctx = profile_context(make_ctx, tmp_path) + assert ctx.det["variant"] == "J" + text = readme.scaffold(ctx) + badges = badge_block(text) + assert "type-GitHub%20profile-" + ctx.brand.raw["badges"]["brand"] in badges + assert "license-MIT-" + ctx.brand.raw["badges"]["neutral"] in badges + assert badges.count("img.shields.io/badge/") == 2 + path = tmp_path / "README.md" + path.write_text(text, encoding="utf-8") + assert not any("badge" in failure for failure in readmelint.lint(ctx, path)["fails"]) + + +@pytest.mark.parametrize("mode", ["upgrade", "header"]) +def test_variant_j_keeps_authored_badges_on_refresh(make_ctx, tmp_path, mode): + ctx = profile_context(make_ctx, tmp_path) + text = readme.scaffold(ctx) + colors = ctx.brand.raw["badges"] + authored = (f'Community\n' + f'MIT') + text = re.sub(re.escape(readme.wrap("badges", badge_block(text))), + lambda _: readme.wrap("badges", authored), text) + if mode == "upgrade": + after, removed = readme.upgrade(ctx, text) + assert readme.preservation(text, after, removed) == [] + again, _ = readme.upgrade(ctx, after) + else: + after = readme.header_only(ctx, text) + assert readme.preservation(text, after, []) == [] + again = readme.header_only(ctx, after) + assert badge_block(after) == authored + assert again == after + + +def test_variant_j_without_license_does_not_invent_one(make_ctx, tmp_path): + ctx = profile_context(make_ctx, tmp_path, license=None) + text = readme.scaffold(ctx) + badges = badge_block(text) + assert badges.count("img.shields.io/badge/") == 1 + assert "license-" not in badges + path = tmp_path / "README.md" + path.write_text(text, encoding="utf-8") + result = readmelint.lint(ctx, path) + assert not any("badge" in failure for failure in result["fails"]) + assert any("badges-short" in warning for warning in result["warns"]) + + +def test_variant_j_preserved_badges_still_obey_lint(make_ctx, tmp_path): + ctx = profile_context(make_ctx, tmp_path) + text = readme.scaffold(ctx) + bad = 'Custom' + text = text.replace(badge_block(text), bad) + after, _ = readme.upgrade(ctx, text) + assert badge_block(after) == bad + path = tmp_path / "README.md" + path.write_text(after, encoding="utf-8") + result = readmelint.lint(ctx, path) + assert "R7 off-palette badge colour ABCDEF" in result["fails"] + assert "R7 2-4 header badges required (found 1)" in result["fails"] From e93ce687e509f2a413baebcd5eb74656b5aa2738 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 29 Oct 2026 22:27:28 -0400 Subject: [PATCH 02/98] fix: refresh owned variant J badge rows after metadata changes Co-Authored-By: Claude --- CHANGELOG.md | 2 +- SKILL.md | 2 +- references/readme-variants.md | 2 +- scripts/ctshowcase/readme.py | 25 ++++++++++--- tests/test_variant_j_badges.py | 64 ++++++++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9bf3a4..c1579c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ 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. Upgrade and header refresh preserve nonempty authored badge blocks, preventing `readme-shrink`; missing licenses remain explicit instead of being invented. +- 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; edited or unmarked authored blocks remain protected by the shrink guard. Missing licenses remain explicit instead of being invented. - `doctor` reports disabled-Issues label checks, inapplicable social previews and archived community health as `n/a`, while preserving label coverage during approved archived polishing and unknown captured values on applicable surfaces. Social upload plans share the R10 applicability rule and include the personal-profile capstone. - `doctor` reports R11 as `n/a` for archived repositories and outside T1/T2 organizations (personal-account, utility and co-owned repositories), matching the definition of done. - R5 preserves upstream defaults on forks (forks in the watching and archive orgs still need Issues off, as `adopt` plans) and accepts a captured user keep decision for other default branches without relaxing the merge or Issues checks. `adopt` and the `doctor --estate` proposals honour the same keep decision: `adopt` logs `default-branch kept (user)` instead of asking for a `rename-default-branch` proposal, and neither asks for one on a fork. diff --git a/SKILL.md b/SKILL.md index 0b74083..c241259 100644 --- a/SKILL.md +++ b/SKILL.md @@ -78,7 +78,7 @@ Run every command **from inside the target repository's checkout**, with the ski | 3 | `topics`, `labels --plan` | Proposals only. Add `--records FILE` to emit change records, `--gh` to print the exact `gh` commands. | | 4 | Apply the approved description and topics on GitHub (the writer), or write them into `meta.json` | Before any art: the banner subtitle, the card and the README tagline come from the description, so rendering first breaks R2 (tagline equals description) the moment the writer applies the new one. | | 5 | `banner`, then `card` (and `content social`) | `art-fail` (exit 3) writes nothing. `needs-input` (exit 4) names the missing fact, including fewer than two fact chips (`needs-input:banner-chips`; add `chips:` to `.github/showcase.yml` or pass `--key-chip`/`--chip`). | -| 6 | `readme`, then `readme --write` | Dry run prints a unified diff first. `readme-shrink` (exit 1) writes nothing. Variant J generates a GitHub-profile type badge and a license badge when the license is known; subsequent refreshes preserve authored nonempty badge blocks for linting. | +| 6 | `readme`, then `readme --write` | Dry run prints a unified diff first. `readme-shrink` (exit 1) writes nothing. Variant J generates a GitHub-profile type badge and a license badge when the license is known; a content digest distinguishes generated rows that refresh from authored badge blocks that remain for linting. | | 7 | `footer --check README.md`, `lint all` | Every applicable check passes; `incomplete` is allowed for logged `needs-input` omissions. | | 8 | `doctor --header` | One coverage row. `done` only when every applicable cell is `pass`, `warn`, `incomplete` or `n/a`, and the banner has two to four chips. | | 9 | `contact-sheet` | A human (or an independent verifier) views the set on both GitHub page colours before rollout. Pass PNGs: `card --png`, `org-tile --png` previews, or install the optional `resvg_py` (`requirements-preview.txt`) so SVG inputs (the intro's static frame) render too; otherwise an SVG input exits 2. | diff --git a/references/readme-variants.md b/references/readme-variants.md index 80f0802..71820fa 100644 --- a/references/readme-variants.md +++ b/references/readme-variants.md @@ -64,7 +64,7 @@ BADGE 1 BADGE 2 [BADGE 3] [BADGE 4] 3. License: `license-SPDX-64748B`, or `license-proprietary-64748B` for private repositories; omitted while a public repository has no license. 4. CI, public repositories only: the Actions badge of a workflow whose file name contains `test`, else `ci`, using the current owner. -Variant J (`.github`) uses the `GitHub profile` type badge from `references/standard/types.yaml`, even though the profile has no card category. With a known license this produces two badges without requiring a language. A public profile without a known license gets only its type badge and the existing `badges-short` warning; no license is invented. Nonempty authored badge blocks in variant J survive both upgrade and header refresh unchanged, including HTML badge rows. `lint readme` still checks their count and palette; an empty block is regenerated. +Variant J (`.github`) uses the `GitHub profile` type badge from `references/standard/types.yaml`, even though the profile has no card category. With a known license this produces two badges without requiring a language. A public profile without a known license gets only its type badge and the existing `badges-short` warning; no license is invented. Generated rows carry a content digest so unchanged generated blocks refresh when metadata or `--badges local` changes. Editing a row invalidates that digest and makes the block authored. Nonempty authored badge blocks survive both upgrade and header refresh unchanged, including HTML badge rows; unmarked blocks are also authored. `lint readme` still checks their count and palette; an empty block is regenerated. Only digest-verified generated badge rows are excluded from the shrink guard; authored text remains protected. Each label and message segment doubles `-` and `_` (shields.io's escapes) and is then percent-encoded, so `C#` reads `language-C%23-1E3A8A` and `#`, `?`, `/` or `%` never end the path early. diff --git a/scripts/ctshowcase/readme.py b/scripts/ctshowcase/readme.py index f4ac53e..34b6cb6 100644 --- a/scripts/ctshowcase/readme.py +++ b/scripts/ctshowcase/readme.py @@ -18,6 +18,7 @@ MARK = re.compile(r"\n?(.*?)\n?", re.S) EMOJI = re.compile("[\U0001F000-\U0001FAFF☀-➿⬀-⯿️‍]") SHIELDS = "https://img.shields.io/badge/" +GENERATED_BADGES = re.compile(r"\n(.*)", re.S) BOM = "\ufeff" @@ -229,10 +230,19 @@ def badges_html(ctx) -> str: def badge_body(ctx: RepoContext, existing: str | None = None) -> str: - """Variant J keeps authored badge rows; lint still checks their count and palette.""" - if ctx.det["variant"] == "J" and existing and existing.strip(): + """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) + if existing and existing.strip() and not generated_badges(existing): return existing - return badges_html(ctx) if ctx.det["variant"] == "F" else badges_md(ctx) + body = badges_md(ctx) + return f"\n{body}" + + +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) == body_sha(match.group(2))) def nav_first(ctx: RepoContext) -> bool: @@ -626,7 +636,7 @@ def upgrade(ctx: RepoContext, text: str) -> tuple[str, list[str]]: removed += [ln for ln in old_tag.splitlines() if ln.strip()] keep = {"tagline": tag} if variant == "J" and blocks.get("badges", "").strip(): - keep["badges"] = blocks["badges"] + keep["badges"] = badge_body(ctx, blocks["badges"]) body, _ = _strip_markers(text, keep=keep) if want_footer: body, _ = drop_old_footer(body, ctx.brand) # its lines are already in `removed` @@ -966,6 +976,13 @@ 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%.""" + # Digest-verified generated badge rows are owned output, like their markers. Exclude them on both + # sides, so a metadata-driven refresh/removal does not hide loss of unrelated authored content. + def omit_owned_badges(m): + return wrap("badges", "") if m.group(1) == "badges" and generated_badges(m.group(2)) else m.group(0) + + before = _sub_blocks(before, omit_owned_badges) + after = _sub_blocks(after, omit_owned_badges) problems = [] b_lines = Counter(ln.strip() for ln in before.splitlines() if ln.strip() and not ln.strip().startswith("\n{body}" @@ -629,13 +639,15 @@ 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()] - keep = {"tagline": tag} - if variant == "J" and blocks.get("badges", "").strip(): + 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: @@ -945,8 +957,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) @@ -976,13 +990,12 @@ 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%.""" - # Digest-verified generated badge rows are owned output, like their markers. Exclude them on both - # sides, so a metadata-driven refresh/removal does not hide loss of unrelated authored content. + # Digest-verified generated badge rows are owned output, like their markers. They may be replaced; + # unmarked legacy rows remain protected and must actually survive in the refreshed output. def omit_owned_badges(m): return wrap("badges", "") if m.group(1) == "badges" and generated_badges(m.group(2)) else m.group(0) before = _sub_blocks(before, omit_owned_badges) - after = _sub_blocks(after, omit_owned_badges) problems = [] b_lines = Counter(ln.strip() for ln in before.splitlines() if ln.strip() and not ln.strip().startswith("\n?(.*?)\n?", re.S) EMOJI = re.compile("[\U0001F000-\U0001FAFF☀-➿⬀-⯿️‍]") SHIELDS = "https://img.shields.io/badge/" -GENERATED_BADGES = re.compile(r"\n(.*)", re.S) +GENERATED_BADGES = re.compile(r"\n(.*)", re.S) BOM = "\ufeff" @@ -233,26 +234,78 @@ 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) - # Before the profile type existed, the generator emitted only the remaining derived rows. - # Recognize that exact output so an old one-badge profile can acquire its missing type badge. - profile_type = standard("types")["types"]["github-profile"]["badge_category"] - legacy = [] - for alt, url, link in badges(ctx): - if alt == f"Type {profile_type}": - continue - img = f"![{alt}]({url})" - legacy.append(f"[{img}]({link})" if link else img) - is_legacy = bool(legacy) and existing == "\n".join(legacy) + is_legacy = legacy_profile_badges(ctx.brand, existing or "") if existing and existing.strip() and not generated_badges(existing) and not is_legacy: return existing body = badges_md(ctx) - return f"\n{body}" + previous = GENERATED_BADGES.fullmatch(existing or "") + replaced_digest = body_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 legacy_profile_badges(brand, body: str) -> 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 = body.splitlines() + if not 1 <= len(lines) <= 3: + return False + kinds = [] + colors = brand.raw["badges"] + 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/actions/workflows/([^/?#]+\.ya?ml)/badge\.svg", url) + if not ci or link != url.removesuffix("/badge.svg"): + return False + name = ci.group(1) + 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 + urls = {f"{SHIELDS}{label}-{shield(message)}-{color}{suffix}" for suffix in suffixes} + 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) == body_sha(match.group(2))) + return bool(match and match.group(1) == body_sha(match.group(3))) def nav_first(ctx: RepoContext) -> bool: @@ -997,10 +1050,18 @@ 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%.""" - # Digest-verified generated badge rows are owned output, like their markers. They may be replaced; - # unmarked legacy rows remain protected and must actually survive in the refreshed output. + # 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): - return wrap("badges", "") if m.group(1) == "badges" and generated_badges(m.group(2)) else m.group(0) + owned = generated_badges(m.group(2)) or body_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 = [] diff --git a/tests/test_variant_j_badges.py b/tests/test_variant_j_badges.py index 78e090e..1ece1ef 100644 --- a/tests/test_variant_j_badges.py +++ b/tests/test_variant_j_badges.py @@ -238,3 +238,50 @@ def test_local_badge_prose_and_code_examples_do_not_count(make_ctx, tmp_path, me path = tmp_path / "README.md" path.write_text(text, encoding="utf-8") assert "R7 2-4 header badges required (found 1)" in readmelint.lint(ctx, path)["fails"] + + +@pytest.mark.parametrize("mode", ["upgrade", "header"]) +@pytest.mark.parametrize("scenario", ["local", "local-to-remote", "license", "local-license", "owner", "language"]) +def test_legacy_badges_migrate_independently_of_current_metadata(make_ctx, tmp_path, mode, scenario): + ctx = profile_context(make_ctx, tmp_path, license="Apache-2.0" if "license" in scenario else "MIT") + if scenario in ("local", "local-to-remote"): + legacy = "![License MIT](docs/images/badges/license-mit.svg)" + elif scenario == "local-license": + legacy = "![License MIT](docs/images/badges/license-mit.svg)" + elif scenario == "owner": + (tmp_path / ".github/workflows").mkdir(parents=True) + (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n", encoding="utf-8") + legacy = ("![License MIT](https://img.shields.io/badge/license-MIT-64748B)\n" + "[![CI](https://github.com/Old-Northwind/.github/actions/workflows/ci.yml/badge.svg)]" + "(https://github.com/Old-Northwind/.github/actions/workflows/ci.yml)") + elif scenario == "language": + legacy = ("![Language JavaScript](https://img.shields.io/badge/language-JavaScript-1E3A8A)\n" + "![License MIT](https://img.shields.io/badge/license-MIT-64748B)") + else: + legacy = "![License MIT](https://img.shields.io/badge/license-MIT-64748B)" + ctx.local_badges = scenario.startswith("local") and scenario != "local-to-remote" + before = readme.wrap("badges", legacy) + "\n\nAuthored paragraph to retain.\n" + if mode == "upgrade": + after, removed = readme.upgrade(ctx, before) + else: + after, removed = readme.header_only(ctx, before), [] + body = badge_block(after) + assert "type-github-profile.svg" in body if ctx.local_badges else "type-GitHub%20profile-" in body + if "license" in scenario: + assert "apache" in body.lower() + assert "license-MIT-" not in body and "license-mit.svg" not in body + assert "Old-Northwind" not in body and "JavaScript" not in body + assert "Authored paragraph to retain." in after + assert readme.preservation(before, after, removed) == [] + again = readme.upgrade(ctx, after)[0] if mode == "upgrade" else readme.header_only(ctx, after) + assert again == after + + +def test_legacy_like_authored_custom_badge_block_stays_owned_by_author(make_ctx, tmp_path): + ctx = profile_context(make_ctx, tmp_path) + authored = ("![Type Community](https://img.shields.io/badge/type-Community-005ADD)\n" + "![License MIT](https://img.shields.io/badge/license-MIT-64748B)") + before = readme.wrap("badges", authored) + after, removed = readme.upgrade(ctx, before) + assert badge_block(after) == authored + assert readme.preservation(before, after, removed) == [] From 51b3e7040f0e4a4c13bf60f46bf9c2a4965f4895 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 29 Oct 2026 22:57:37 -0400 Subject: [PATCH 06/98] fix: count rendered Markdown reference badge images Co-Authored-By: Claude --- CHANGELOG.md | 2 +- references/readme-variants.md | 2 +- scripts/ctshowcase/readmelint.py | 43 +++++++++++++++++-- tests/test_reference_badge_count.py | 64 +++++++++++++++++++++++++++++ tests/test_variant_j_badges.py | 14 +++++++ 5 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 tests/test_reference_badge_count.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a810ab1..3a2f52f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ 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. Pristine template bootstrap replaces the inherited template badge, and R7 counts locally rendered badge images. Missing licenses remain explicit instead of being invented. +- 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. Pristine template bootstrap replaces the inherited template badge, and R7 counts locally rendered and Markdown reference badge images. Missing licenses remain explicit instead of being invented. - `doctor` reports disabled-Issues label checks, inapplicable social previews and archived community health as `n/a`, while preserving label coverage during approved archived polishing and unknown captured values on applicable surfaces. Social upload plans share the R10 applicability rule and include the personal-profile capstone. - `doctor` reports R11 as `n/a` for archived repositories and outside T1/T2 organizations (personal-account, utility and co-owned repositories), matching the definition of done. - R5 preserves upstream defaults on forks (forks in the watching and archive orgs still need Issues off, as `adopt` plans) and accepts a captured user keep decision for other default branches without relaxing the merge or Issues checks. `adopt` and the `doctor --estate` proposals honour the same keep decision: `adopt` logs `default-branch kept (user)` instead of asking for a `rename-default-branch` proposal, and neither asks for one on a fork. diff --git a/references/readme-variants.md b/references/readme-variants.md index 26cf711..493a601 100644 --- a/references/readme-variants.md +++ b/references/readme-variants.md @@ -66,7 +66,7 @@ BADGE 1 BADGE 2 [BADGE 3] [BADGE 4] Variant J (`.github`) uses the `GitHub profile` type badge from `references/standard/types.yaml`, even though the profile has no card category. With a known license this produces two badges without requiring a language. A public profile without a known license gets only its type badge and the existing `badges-short` warning; no license is invented. Generated rows carry a content digest so unchanged generated blocks refresh when metadata or `--badges local` changes. Editing a row invalidates that digest and makes the block authored. Nonempty authored badge blocks survive upgrade, header refresh and template bootstrap unchanged, including HTML badge rows and large preserve-list READMEs without a tagline block. Unmarked blocks are also authored, except the previous generator's exact Markdown language/license/CI row format, order and palette (remote or local SVG): those migrate using current facts, replacing obsolete licenses and CI owners and adding the missing type badge. The migration records the exact old block's digest so the shrink guard permits only that generated replacement; unrelated authored text remains protected. `lint readme` still checks badge count and palette; an empty block is regenerated. -During pristine template bootstrap, the source template's exact type badge under its unchanged template H1 is replaced with the target repository's badges. Authored replacements remain protected. Locally rendered badges under `docs/images/badges/*.svg` count toward R7 when referenced as Markdown or HTML images; merely mentioning an SVG path does not count. +During pristine template bootstrap, the source template's exact type badge under its unchanged template H1 is replaced with the target repository's badges. Authored replacements remain protected. Locally rendered badges under `docs/images/badges/*.svg` count toward R7 when referenced as Markdown or HTML images; merely mentioning an SVG path does not count. Markdown reference images (full, collapsed and shortcut) resolve their definitions anywhere in the document, with case-insensitive labels and the first definition winning. Only rendered header images count, not unused definitions, links, escaped syntax or code examples. Reference images also retain the alt-text and local-file checks. Each label and message segment doubles `-` and `_` (shields.io's escapes) and is then percent-encoded, so `C#` reads `language-C%23-1E3A8A` and `#`, `?`, `/` or `%` never end the path early. diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index 4073182..d186413 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -1,6 +1,7 @@ """README lint: R7 + decision 13 (banner first, plain ) + decision 18 (footer last, exact) (spec A6.11).""" from __future__ import annotations +import html import re from pathlib import Path from urllib.parse import unquote @@ -13,6 +14,10 @@ IMG_MD = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)[^)]*\)") IMG_HTML = re.compile(r"]*)>", re.I) PALETTE = {"1E3A8A", "005ADD", "067647", "64748B", "E551BA", "F386A1", "1E1E1E"} +REF_DEFINITION = re.compile( + r"^ {0,3}\[((?:\\.|[^\]\\\n])+)\]:[ \t]*(?:\n[ \t]*)?(?:<([^<>\n]+)>|(\S+?))" + r"(?:[ \t]+(?:\"[^\"\n]*\"|'[^'\n]*'|\([^\)\n]*\)))?[ \t]*$", re.M) +REF_IMAGE = re.compile(r"!\[((?:\\.|[^\]\\])*)\](?:\[((?:\\.|[^\]\\])*)\])?(?!\()") def _outside_code(text: str) -> str: @@ -23,6 +28,32 @@ def _outside_code(text: str) -> str: return re.sub(r"", "", text, flags=re.S) +def _reference_images(text: str, document: str) -> list[tuple[str, str]]: + """Resolve full, collapsed and shortcut image references used in the header. + + Definitions may occur anywhere outside code; the first definition wins, and labels are + case-insensitive with collapsed whitespace. Unused definitions are not images. + """ + def label(value): + value = html.unescape(re.sub(r"\\([^\w\s])", r"\1", value)) + return " ".join(value.split()).casefold() + + definitions = {} + for match in REF_DEFINITION.finditer(_outside_code(document)): + definitions.setdefault(label(match.group(1)), html.unescape(match.group(2) or match.group(3))) + sources = [] + for match in REF_IMAGE.finditer(text): + 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 + key = label(match.group(2) or match.group(1)) + if key in definitions: + sources.append((match.group(1), definitions[key])) + return sources + + def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: fails, warns = [], [] incomplete: list[str] = [] @@ -95,16 +126,16 @@ def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: # badges 2-4, palette tokens only shields = re.findall(r"img\.shields\.io/badge/([^)\"\s]+)", plain) header_part = plain.split("\n---\n", 1)[0] - hb = re.findall(r"img\.shields\.io/badge/([^)\"\s]+)", header_part) badge_header = _outside_code(header_part) image_sources = [m.group(2) for m in IMG_MD.finditer(badge_header)] + image_sources += [source for _, source in _reference_images(badge_header, plain)] for image in IMG_HTML.finditer(badge_header): source = re.search(r"\bsrc\s*=\s*(['\"])(.*?)\1", image.group(1)) if source: image_sources.append(source.group(2)) - svg_badges = sum(bool(re.fullmatch(r"(?:\./)?docs/images/badges/[^/?#]+\.svg(?:[?#].*)?", unquote(src)) - or re.search(r"(?:^|/)badge\.svg(?:[?#].*)?$", src)) for src in image_sources) - nbadges = len(hb) + svg_badges + nbadges = sum(bool(src.startswith("https://img.shields.io/badge/") + 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 @@ -173,6 +204,10 @@ def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: src = re.search(r'src="([^"]+)"', m.group(1)) if src: _local(path, src.group(1), fails, example="examples" in src.group(1)) + for alt, source in _reference_images(_outside_code(plain), plain): + if not alt.strip(): + fails.append(f"R7 image without alt text: {source[:40]}") + _local(path, source, fails) body = _outside_code(plain) ph = re.search(rv["placeholder_regex"], body) if ph or re.search(r"<…>|<\.\.\.>", body): diff --git a/tests/test_reference_badge_count.py b/tests/test_reference_badge_count.py new file mode 100644 index 0000000..d7decd1 --- /dev/null +++ b/tests/test_reference_badge_count.py @@ -0,0 +1,64 @@ +"""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): + (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") + 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"] diff --git a/tests/test_variant_j_badges.py b/tests/test_variant_j_badges.py index 1ece1ef..15085bd 100644 --- a/tests/test_variant_j_badges.py +++ b/tests/test_variant_j_badges.py @@ -285,3 +285,17 @@ def test_legacy_like_authored_custom_badge_block_stays_owned_by_author(make_ctx, after, removed = readme.upgrade(ctx, before) assert badge_block(after) == authored assert readme.preservation(before, after, removed) == [] + + +@pytest.mark.parametrize("mode", ["upgrade", "header"]) +def test_cli_legacy_remote_license_migrates_to_local_without_shrink(make_ctx, tmp_path, mode): + ctx = profile_context(make_ctx, tmp_path) + text = readme.scaffold(ctx) + legacy = "![License MIT](https://img.shields.io/badge/license-MIT-64748B)" + (tmp_path / "README.md").write_text(text.replace(badge_block(text), legacy), encoding="utf-8") + result = profile_cli(ctx, tmp_path, "readme", "--mode", mode, "--badges", "local", "--write") + assert "readme-shrink" not in result.get("logs", []) + actual = (tmp_path / "README.md").read_text(encoding="utf-8") + assert "docs/images/badges/type-github-profile.svg" in actual + assert "docs/images/badges/license-mit.svg" in actual + assert not any("badge" in f for f in profile_cli(ctx, tmp_path, "lint", "readme")["fails"]) From 19113431ae6f2a2ff6610ef876e2c63c3ed95d85 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 29 Oct 2026 23:08:50 -0400 Subject: [PATCH 07/98] fix: parse valid HTML and nested Markdown badge images Co-Authored-By: Claude --- CHANGELOG.md | 2 +- references/readme-variants.md | 2 +- scripts/ctshowcase/readmelint.py | 155 +++++++++++++++++++++++----- tests/test_reference_badge_count.py | 31 ++++++ 4 files changed, 160 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a2f52f..e3d7a41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ 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. Pristine template bootstrap replaces the inherited template badge, and R7 counts locally rendered and Markdown reference badge images. Missing licenses remain explicit instead of being invented. +- 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. Pristine template bootstrap replaces the inherited template badge, 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. - `doctor` reports disabled-Issues label checks, inapplicable social previews and archived community health as `n/a`, while preserving label coverage during approved archived polishing and unknown captured values on applicable surfaces. Social upload plans share the R10 applicability rule and include the personal-profile capstone. - `doctor` reports R11 as `n/a` for archived repositories and outside T1/T2 organizations (personal-account, utility and co-owned repositories), matching the definition of done. - R5 preserves upstream defaults on forks (forks in the watching and archive orgs still need Issues off, as `adopt` plans) and accepts a captured user keep decision for other default branches without relaxing the merge or Issues checks. `adopt` and the `doctor --estate` proposals honour the same keep decision: `adopt` logs `default-branch kept (user)` instead of asking for a `rename-default-branch` proposal, and neither asks for one on a fork. diff --git a/references/readme-variants.md b/references/readme-variants.md index 493a601..80c5377 100644 --- a/references/readme-variants.md +++ b/references/readme-variants.md @@ -66,7 +66,7 @@ BADGE 1 BADGE 2 [BADGE 3] [BADGE 4] Variant J (`.github`) uses the `GitHub profile` type badge from `references/standard/types.yaml`, even though the profile has no card category. With a known license this produces two badges without requiring a language. A public profile without a known license gets only its type badge and the existing `badges-short` warning; no license is invented. Generated rows carry a content digest so unchanged generated blocks refresh when metadata or `--badges local` changes. Editing a row invalidates that digest and makes the block authored. Nonempty authored badge blocks survive upgrade, header refresh and template bootstrap unchanged, including HTML badge rows and large preserve-list READMEs without a tagline block. Unmarked blocks are also authored, except the previous generator's exact Markdown language/license/CI row format, order and palette (remote or local SVG): those migrate using current facts, replacing obsolete licenses and CI owners and adding the missing type badge. The migration records the exact old block's digest so the shrink guard permits only that generated replacement; unrelated authored text remains protected. `lint readme` still checks badge count and palette; an empty block is regenerated. -During pristine template bootstrap, the source template's exact type badge under its unchanged template H1 is replaced with the target repository's badges. Authored replacements remain protected. Locally rendered badges under `docs/images/badges/*.svg` count toward R7 when referenced as Markdown or HTML images; merely mentioning an SVG path does not count. Markdown reference images (full, collapsed and shortcut) resolve their definitions anywhere in the document, with case-insensitive labels and the first definition winning. Only rendered header images count, not unused definitions, links, escaped syntax or code examples. Reference images also retain the alt-text and local-file checks. +During pristine template bootstrap, the source template's exact type badge under its unchanged template H1 is replaced with the target repository's badges. Authored replacements remain protected. Locally rendered badges under `docs/images/badges/*.svg` count toward R7 when referenced as Markdown or HTML images; merely mentioning an SVG path does not count. Markdown reference images (full, collapsed and shortcut) resolve their definitions anywhere in the document, with case-insensitive labels and the first definition winning. Inline and reference image alt text may contain nested or escaped brackets. HTML images accept quoted or unquoted attributes and case-insensitive attribute names. Only rendered header images count, not unused definitions, links, escaped syntax, HTML attribute text or code examples. All recognized images retain the alt-text and local-file checks. Each label and message segment doubles `-` and `_` (shields.io's escapes) and is then percent-encoded, so `C#` reads `language-C%23-1E3A8A` and `#`, `?`, `/` or `%` never end the path early. diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index d186413..2c0f4e2 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -3,6 +3,7 @@ import html import re +from html.parser import HTMLParser from pathlib import Path from urllib.parse import unquote @@ -17,7 +18,6 @@ REF_DEFINITION = re.compile( r"^ {0,3}\[((?:\\.|[^\]\\\n])+)\]:[ \t]*(?:\n[ \t]*)?(?:<([^<>\n]+)>|(\S+?))" r"(?:[ \t]+(?:\"[^\"\n]*\"|'[^'\n]*'|\([^\)\n]*\)))?[ \t]*$", re.M) -REF_IMAGE = re.compile(r"!\[((?:\\.|[^\]\\])*)\](?:\[((?:\\.|[^\]\\])*)\])?(?!\()") def _outside_code(text: str) -> str: @@ -28,12 +28,76 @@ def _outside_code(text: str) -> str: return re.sub(r"", "", text, flags=re.S) -def _reference_images(text: str, document: str) -> list[tuple[str, str]]: - """Resolve full, collapsed and shortcut image references used in the header. +def _closing_bracket(text: str, start: int) -> int | None: + """Find a balanced closing bracket, respecting escaped and nested brackets.""" + depth, pos = 1, start + 1 + while pos < len(text): + char = text[pos] + if char == "\\": + pos += 2 + continue + if char == "[": + depth += 1 + elif char == "]": + depth -= 1 + if not depth: + return pos + pos += 1 + return None + + +def _inline_image_target(text: str, start: int) -> tuple[str, int] | None: + """Read a Markdown destination and optional title after its opening parenthesis.""" + pos = start + 1 + while pos < len(text) and text[pos].isspace(): + pos += 1 + begin = pos + if pos < len(text) and text[pos] == "<": + begin = pos = pos + 1 + while pos < len(text) and text[pos] != ">": + 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): + char = text[pos] + if char == "\\": + pos += 2 + continue + if char.isspace() or char == ")" and depth == 0: + break + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + pos += 1 + if depth: + return None + target = text[begin:pos] + while pos < len(text) and text[pos].isspace(): + pos += 1 + if pos < len(text) and text[pos] in "\"'(": + end = ")" if text[pos] == "(" else text[pos] + pos += 1 + while pos < len(text) and text[pos] != end: + pos += 2 if text[pos] == "\\" else 1 + if pos >= len(text): + return None + pos += 1 + while pos < len(text) and text[pos].isspace(): + pos += 1 + if pos >= len(text) or text[pos] != ")": + return None + return html.unescape(re.sub(r"\\([^\w\s])", r"\1", target)), pos + 1 - Definitions may occur anywhere outside code; the first definition wins, and labels are - case-insensitive with collapsed whitespace. Unused definitions are not images. - """ + +def _markdown_images(text: str, document: str) -> list[tuple[str, str]]: + """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() @@ -42,18 +106,67 @@ def label(value): for match in REF_DEFINITION.finditer(_outside_code(document)): definitions.setdefault(label(match.group(1)), html.unescape(match.group(2) or match.group(3))) sources = [] - for match in REF_IMAGE.finditer(text): + consumed = 0 + for match in re.finditer(r"!\[", text): + if match.start() < consumed: + 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 - key = label(match.group(2) or match.group(1)) + close = _closing_bracket(text, match.start() + 1) + if close is None: + continue + alt = text[match.end():close] + consumed = close + 1 + if consumed < len(text) and text[consumed] == "(": + target = _inline_image_target(text, consumed) + if target: + source, consumed = target + sources.append((alt, source)) + continue + key = label(alt) + if consumed < len(text) and text[consumed] == "[": + ref_close = _closing_bracket(text, consumed) + if ref_close is not None: + key = label(text[consumed + 1:ref_close]) or key + consumed = ref_close + 1 if key in definitions: - sources.append((match.group(1), definitions[key])) + sources.append((alt, definitions[key])) return sources +def _images(text: str, document: str) -> list[tuple[str, str]]: + """Read HTML and Markdown images, excluding Markdown-looking text in HTML attributes.""" + class Images(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.images = [] + self.tags = [] + self.line_starts = [0, *(m.end() for m in re.finditer("\n", text))] + + def handle_starttag(self, tag, attrs): + line, column = self.getpos() + start = self.line_starts[line - 1] + column + self.tags.append((start, start + len(self.get_starttag_text()))) + if tag == "img": + first = {} + for name, value in attrs: + first.setdefault(name, value) + self.images.append((first.get("alt") or "", first.get("src") or "")) + + parser = Images() + parser.feed(text) + parser.close() + outside_tags = list(text) + for start, end in parser.tags: + for pos in range(start, end): + if outside_tags[pos] != "\n": + outside_tags[pos] = " " + return parser.images + _markdown_images("".join(outside_tags), document) + + def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: fails, warns = [], [] incomplete: list[str] = [] @@ -127,12 +240,7 @@ def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: shields = re.findall(r"img\.shields\.io/badge/([^)\"\s]+)", plain) header_part = plain.split("\n---\n", 1)[0] badge_header = _outside_code(header_part) - image_sources = [m.group(2) for m in IMG_MD.finditer(badge_header)] - image_sources += [source for _, source in _reference_images(badge_header, plain)] - for image in IMG_HTML.finditer(badge_header): - source = re.search(r"\bsrc\s*=\s*(['\"])(.*?)\1", image.group(1)) - if source: - image_sources.append(source.group(2)) + image_sources = [source for _, source in _images(badge_header, plain)] nbadges = sum(bool(src.startswith("https://img.shields.io/badge/") or re.fullmatch(r"(?:\./)?docs/images/badges/[^/?#]+\.svg(?:[?#].*)?", unquote(src)) or re.search(r"(?:^|/)badge\.svg(?:[?#].*)?$", src)) for src in image_sources) @@ -193,21 +301,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)) - for alt, source in _reference_images(_outside_code(plain), plain): + 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]}") - _local(path, source, fails) + 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_reference_badge_count.py b/tests/test_reference_badge_count.py index d7decd1..8fd9572 100644 --- a/tests/test_reference_badge_count.py +++ b/tests/test_reference_badge_count.py @@ -62,3 +62,34 @@ def test_reference_images_still_require_alt_and_existing_local_files(make_ctx, t "[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' Date: Thu, 29 Oct 2026 23:10:13 -0400 Subject: [PATCH 08/98] fix: preserve unmarked license claims when metadata is unknown Co-Authored-By: Claude --- CHANGELOG.md | 2 +- references/readme-variants.md | 2 +- scripts/ctshowcase/readme.py | 3 +++ tests/test_variant_j_badges.py | 25 +++++++++++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3d7a41..c0c64d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ 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. Pristine template bootstrap replaces the inherited template badge, 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. +- 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. Pristine template bootstrap replaces the inherited template badge, 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. - `doctor` reports disabled-Issues label checks, inapplicable social previews and archived community health as `n/a`, while preserving label coverage during approved archived polishing and unknown captured values on applicable surfaces. Social upload plans share the R10 applicability rule and include the personal-profile capstone. - `doctor` reports R11 as `n/a` for archived repositories and outside T1/T2 organizations (personal-account, utility and co-owned repositories), matching the definition of done. - R5 preserves upstream defaults on forks (forks in the watching and archive orgs still need Issues off, as `adopt` plans) and accepts a captured user keep decision for other default branches without relaxing the merge or Issues checks. `adopt` and the `doctor --estate` proposals honour the same keep decision: `adopt` logs `default-branch kept (user)` instead of asking for a `rename-default-branch` proposal, and neither asks for one on a fork. diff --git a/references/readme-variants.md b/references/readme-variants.md index 80c5377..3277fb7 100644 --- a/references/readme-variants.md +++ b/references/readme-variants.md @@ -64,7 +64,7 @@ BADGE 1 BADGE 2 [BADGE 3] [BADGE 4] 3. License: `license-SPDX-64748B`, or `license-proprietary-64748B` for private repositories; omitted while a public repository has no license. 4. CI, public repositories only: the Actions badge of a workflow whose file name contains `test`, else `ci`, using the current owner. -Variant J (`.github`) uses the `GitHub profile` type badge from `references/standard/types.yaml`, even though the profile has no card category. With a known license this produces two badges without requiring a language. A public profile without a known license gets only its type badge and the existing `badges-short` warning; no license is invented. Generated rows carry a content digest so unchanged generated blocks refresh when metadata or `--badges local` changes. Editing a row invalidates that digest and makes the block authored. Nonempty authored badge blocks survive upgrade, header refresh and template bootstrap unchanged, including HTML badge rows and large preserve-list READMEs without a tagline block. Unmarked blocks are also authored, except the previous generator's exact Markdown language/license/CI row format, order and palette (remote or local SVG): those migrate using current facts, replacing obsolete licenses and CI owners and adding the missing type badge. The migration records the exact old block's digest so the shrink guard permits only that generated replacement; unrelated authored text remains protected. `lint readme` still checks badge count and palette; an empty block is regenerated. +Variant J (`.github`) uses the `GitHub profile` type badge from `references/standard/types.yaml`, even though the profile has no card category. With a known license this produces two badges without requiring a language. A public profile without a known license gets only its type badge and the existing `badges-short` warning; no license is invented. Generated rows carry a content digest so unchanged generated blocks refresh when metadata or `--badges local` changes. Editing a row invalidates that digest and makes the block authored. Nonempty authored badge blocks survive upgrade, header refresh and template bootstrap unchanged, including HTML badge rows and large preserve-list READMEs without a tagline block. Unmarked blocks are also authored, except the previous generator's exact Markdown language/license/CI row format, order and palette (remote or local SVG): those migrate using current facts, replacing obsolete licenses and CI owners and adding the missing type badge. A public unmarked license block is preserved when the current license is absent, `NOASSERTION` or `other`; missing metadata cannot disprove its authored claim. The migration records the exact old block's digest so the shrink guard permits only that generated replacement; unrelated authored text remains protected. `lint readme` still checks badge count and palette; an empty block is regenerated. During pristine template bootstrap, the source template's exact type badge under its unchanged template H1 is replaced with the target repository's badges. Authored replacements remain protected. Locally rendered badges under `docs/images/badges/*.svg` count toward R7 when referenced as Markdown or HTML images; merely mentioning an SVG path does not count. Markdown reference images (full, collapsed and shortcut) resolve their definitions anywhere in the document, with case-insensitive labels and the first definition winning. Inline and reference image alt text may contain nested or escaped brackets. HTML images accept quoted or unquoted attributes and case-insensitive attribute names. Only rendered header images count, not unused definitions, links, escaped syntax, HTML attribute text or code examples. All recognized images retain the alt-text and local-file checks. diff --git a/scripts/ctshowcase/readme.py b/scripts/ctshowcase/readme.py index e444727..78eed51 100644 --- a/scripts/ctshowcase/readme.py +++ b/scripts/ctshowcase/readme.py @@ -235,6 +235,9 @@ def badge_body(ctx: RepoContext, existing: str | None = None) -> str: 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 "") + 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) diff --git a/tests/test_variant_j_badges.py b/tests/test_variant_j_badges.py index 15085bd..8e4981e 100644 --- a/tests/test_variant_j_badges.py +++ b/tests/test_variant_j_badges.py @@ -299,3 +299,28 @@ def test_cli_legacy_remote_license_migrates_to_local_without_shrink(make_ctx, tm assert "docs/images/badges/type-github-profile.svg" in actual assert "docs/images/badges/license-mit.svg" in actual assert not any("badge" in f for f in profile_cli(ctx, tmp_path, "lint", "readme")["fails"]) + + +@pytest.mark.parametrize("license", [None, "NOASSERTION", "other"]) +@pytest.mark.parametrize("mode", ["upgrade", "header"]) +def test_unknown_license_keeps_authored_legacy_shaped_badge(make_ctx, tmp_path, license, mode): + ctx = profile_context(make_ctx, tmp_path, license=license) + authored = "![License MIT](https://img.shields.io/badge/license-MIT-64748B)" + text = readme.scaffold(ctx) + text = text.replace(badge_block(text), authored) + if mode == "upgrade": + after, removed = readme.upgrade(ctx, text) + else: + after, removed = readme.header_only(ctx, text), [] + assert badge_block(after) == authored + assert readme.preservation(text, after, removed) == [] + + +@pytest.mark.parametrize("license", [None, "NOASSERTION", "other"]) +def test_cli_unknown_license_does_not_erase_authored_badge(make_ctx, tmp_path, license): + ctx = profile_context(make_ctx, tmp_path, license=license) + authored = "![License MIT](https://img.shields.io/badge/license-MIT-64748B)" + text = readme.scaffold(ctx) + (tmp_path / "README.md").write_text(text.replace(badge_block(text), authored), encoding="utf-8") + profile_cli(ctx, tmp_path, "readme", "--write") + assert badge_block((tmp_path / "README.md").read_text(encoding="utf-8")) == authored From f0a7909080c5a0b3aa256009947704feece44cb0 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 29 Oct 2026 23:18:09 -0400 Subject: [PATCH 09/98] fix: migrate inherited badges after template body edits Co-Authored-By: Claude --- CHANGELOG.md | 2 +- references/readme-variants.md | 2 +- scripts/ctshowcase/readme.py | 18 ++++++++-------- tests/test_variant_j_badges.py | 39 ++++++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c64d0..3ce476f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ 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. Pristine template bootstrap replaces the inherited template badge, 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. +- 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. - `doctor` reports disabled-Issues label checks, inapplicable social previews and archived community health as `n/a`, while preserving label coverage during approved archived polishing and unknown captured values on applicable surfaces. Social upload plans share the R10 applicability rule and include the personal-profile capstone. - `doctor` reports R11 as `n/a` for archived repositories and outside T1/T2 organizations (personal-account, utility and co-owned repositories), matching the definition of done. - R5 preserves upstream defaults on forks (forks in the watching and archive orgs still need Issues off, as `adopt` plans) and accepts a captured user keep decision for other default branches without relaxing the merge or Issues checks. `adopt` and the `doctor --estate` proposals honour the same keep decision: `adopt` logs `default-branch kept (user)` instead of asking for a `rename-default-branch` proposal, and neither asks for one on a fork. diff --git a/references/readme-variants.md b/references/readme-variants.md index 3277fb7..822de52 100644 --- a/references/readme-variants.md +++ b/references/readme-variants.md @@ -66,7 +66,7 @@ BADGE 1 BADGE 2 [BADGE 3] [BADGE 4] Variant J (`.github`) uses the `GitHub profile` type badge from `references/standard/types.yaml`, even though the profile has no card category. With a known license this produces two badges without requiring a language. A public profile without a known license gets only its type badge and the existing `badges-short` warning; no license is invented. Generated rows carry a content digest so unchanged generated blocks refresh when metadata or `--badges local` changes. Editing a row invalidates that digest and makes the block authored. Nonempty authored badge blocks survive upgrade, header refresh and template bootstrap unchanged, including HTML badge rows and large preserve-list READMEs without a tagline block. Unmarked blocks are also authored, except the previous generator's exact Markdown language/license/CI row format, order and palette (remote or local SVG): those migrate using current facts, replacing obsolete licenses and CI owners and adding the missing type badge. A public unmarked license block is preserved when the current license is absent, `NOASSERTION` or `other`; missing metadata cannot disprove its authored claim. The migration records the exact old block's digest so the shrink guard permits only that generated replacement; unrelated authored text remains protected. `lint readme` still checks badge count and palette; an empty block is regenerated. -During pristine template bootstrap, the source template's exact type badge under its unchanged template H1 is replaced with the target repository's badges. Authored replacements remain protected. Locally rendered badges under `docs/images/badges/*.svg` count toward R7 when referenced as Markdown or HTML images; merely mentioning an SVG path does not count. Markdown reference images (full, collapsed and shortcut) resolve their definitions anywhere in the document, with case-insensitive labels and the first definition winning. Inline and reference image alt text may contain nested or escaped brackets. HTML images accept quoted or unquoted attributes and case-insensitive attribute names. Only rendered header images count, not unused definitions, links, escaped syntax, HTML attribute text or code examples. All recognized images retain the alt-text and local-file checks. +The source template's exact type badge is replaced with the target repository's badges during upgrade, header refresh and bootstrap, even after its body is edited or its template marker is removed. Custom authored replacements remain protected. Locally rendered badges under `docs/images/badges/*.svg` count toward R7 when referenced as Markdown or HTML images; merely mentioning an SVG path does not count. Markdown reference images (full, collapsed and shortcut) resolve their definitions anywhere in the document, with case-insensitive labels and the first definition winning. Inline and reference image alt text may contain nested or escaped brackets. HTML images accept quoted or unquoted attributes and case-insensitive attribute names. Only rendered header images count, not unused definitions, links, escaped syntax, HTML attribute text or code examples. All recognized images retain the alt-text and local-file checks. Each label and message segment doubles `-` and `_` (shields.io's escapes) and is then percent-encoded, so `C#` reads `language-C%23-1E3A8A` and `#`, `?`, `/` or `%` never end the path early. diff --git a/scripts/ctshowcase/readme.py b/scripts/ctshowcase/readme.py index 78eed51..bcf4027 100644 --- a/scripts/ctshowcase/readme.py +++ b/scripts/ctshowcase/readme.py @@ -234,7 +234,8 @@ 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 "") + is_legacy = (legacy_profile_badges(ctx.brand, existing or "") + or (existing or "").strip() == template_badge_body(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 @@ -247,6 +248,12 @@ def badge_body(ctx: RepoContext, existing: str | None = None) -> str: 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 legacy_profile_badges(brand, body: str) -> bool: """Recognize the old generator's ordered language/license/CI rows without today's metadata. @@ -1015,15 +1022,8 @@ def bootstrap(ctx: RepoContext, text: str) -> str: # inherited one is emptied, exactly as header_only decides it blocks = {m.group(1): m.group(2) for m in find_blocks(MARK, hm.group(0))} old = blocks.get("tagline") - existing_badges = blocks.get("badges") - category = standard("types")["types"]["template"]["category"] - template_badge = (f"![Type {category}]({SHIELDS}type-{shield(category)}-" - f"{ctx.brand.raw['badges']['brand']})") - if (h1 and h1.group(1).strip() == TEMPLATE_H1 - and (existing_badges or "").strip() == template_badge): - existing_badges = None # this exact badge belongs to the pristine source template new_header = header(ctx, title=auto, tagline=tagline_body(ctx, old, text), - existing_badges=existing_badges) + existing_badges=blocks.get("badges")) text = new_header + "\n\n" + text[hm.end():].lstrip("\n") return header_only(ctx, text) diff --git a/tests/test_variant_j_badges.py b/tests/test_variant_j_badges.py index 8e4981e..bd1f86a 100644 --- a/tests/test_variant_j_badges.py +++ b/tests/test_variant_j_badges.py @@ -324,3 +324,42 @@ def test_cli_unknown_license_does_not_erase_authored_badge(make_ctx, tmp_path, l (tmp_path / "README.md").write_text(text.replace(badge_block(text), authored), encoding="utf-8") profile_cli(ctx, tmp_path, "readme", "--write") assert badge_block((tmp_path / "README.md").read_text(encoding="utf-8")) == authored + + +@pytest.mark.parametrize("state", ["pristine", "edited", "unmarked"]) +@pytest.mark.parametrize("mode", ["bootstrap", "upgrade", "header"]) +@pytest.mark.parametrize("custom", [False, True]) +def test_template_badges_across_body_states_and_cli_modes(make_ctx, tmp_path, state, mode, custom): + ctx = profile_context(make_ctx, tmp_path) + text = (ROOT / "tests/fixtures/template-README.md").read_text(encoding="utf-8") + authored = ('Community\n' + 'MIT') + if custom: + text = text.replace(badge_block(text), authored) + if state != "pristine": + text = text.replace("", + "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 From d4133bf555a0ada9903041cb306d5d356e44f23b Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 29 Oct 2026 23:24:36 -0400 Subject: [PATCH 10/98] fix: preserve image syntax context during README validation Co-Authored-By: Claude --- CHANGELOG.md | 1 + references/readme-variants.md | 2 + scripts/ctshowcase/readmelint.py | 92 ++++++++++++++++++----------- tests/test_reference_badge_count.py | 33 ++++++++++- tests/test_variant_j_badges.py | 25 +++++++- 5 files changed, 116 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ce476f..c4c9832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ 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. - `doctor` reports disabled-Issues label checks, inapplicable social previews and archived community health as `n/a`, while preserving label coverage during approved archived polishing and unknown captured values on applicable surfaces. Social upload plans share the R10 applicability rule and include the personal-profile capstone. - `doctor` reports R11 as `n/a` for archived repositories and outside T1/T2 organizations (personal-account, utility and co-owned repositories), matching the definition of done. - R5 preserves upstream defaults on forks (forks in the watching and archive orgs still need Issues off, as `adopt` plans) and accepts a captured user keep decision for other default branches without relaxing the merge or Issues checks. `adopt` and the `doctor --estate` proposals honour the same keep decision: `adopt` logs `default-branch kept (user)` instead of asking for a `rename-default-branch` proposal, and neither asks for one on a fork. diff --git a/references/readme-variants.md b/references/readme-variants.md index 822de52..ba7695a 100644 --- a/references/readme-variants.md +++ b/references/readme-variants.md @@ -68,6 +68,8 @@ Variant J (`.github`) uses the `GitHub profile` type badge from `references/stan The source template's exact type badge is replaced with the target repository's badges during upgrade, header refresh and bootstrap, even after its body is edited or its template marker is removed. Custom authored replacements remain protected. Locally rendered badges under `docs/images/badges/*.svg` count toward R7 when referenced as Markdown or HTML images; merely mentioning an SVG path does not count. Markdown reference images (full, collapsed and shortcut) resolve their definitions anywhere in the document, with case-insensitive labels and the first definition winning. Inline and reference image alt text may contain nested or escaped brackets. HTML images accept quoted or unquoted attributes and case-insensitive attribute names. Only rendered header images count, not unused definitions, links, escaped syntax, HTML attribute text or code examples. All recognized images retain the alt-text and local-file checks. +Markdown angle-delimited destinations such as `![Screenshot]()` retain their local-file check. Backticks inside HTML attribute values are literal text, while Markdown code spans and fences remain excluded from image counting and validation. + Each label and message segment doubles `-` and `_` (shields.io's escapes) and is then percent-encoded, so `C#` reads `language-C%23-1E3A8A` and `#`, `?`, `/` or `%` never end the path early. | Token | Hex | Use | diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index 2c0f4e2..99c9309 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -20,12 +20,52 @@ r"(?:[ \t]+(?:\"[^\"\n]*\"|'[^'\n]*'|\([^\)\n]*\)))?[ \t]*$", re.M) +class _HTMLTokens(HTMLParser): + """HTML spans let Markdown parsing respect attribute and destination boundaries.""" + def __init__(self, text): + super().__init__(convert_charrefs=True) + self.text = text + self.tags = [] + self.images = [] + self.line_starts = [0, *(m.end() for m in re.finditer("\n", text))] + self.feed(text) + self.close() + + def handle_starttag(self, tag, attrs): + line, column = self.getpos() + start = self.line_starts[line - 1] + column + before = start - 1 + while before >= 0 and self.text[before] == "\\": + before -= 1 + if (start - before - 1) % 2: + return + end = start + len(self.get_starttag_text()) + self.tags.append((start, end)) + if tag == "img": + first = {} + for name, value in attrs: + first.setdefault(name, value) + self.images.append((first.get("alt") or "", first.get("src") or "", start, end)) + + 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) + """Mask Markdown code without interpreting literal backticks in HTML attributes as code.""" + out, replacements, pos = [], [], 0 + for index, (start, end) in enumerate(_HTMLTokens(text).tags): + raw = text[start:end] + key = f"\x01showcase-html-{index}\x02" + while key in text: + key += "x" + key += "\n" * raw.count("\n") + out.extend((text[pos:start], key)) + replacements.append((key, raw)) + pos = end + out.append(text[pos:]) + protected = re.sub(r"", "", "".join(out), flags=re.S) + protected = masked(protected) + for key, raw in replacements: + protected = protected.replace(key, raw) + return protected.replace("\x00", " ") def _closing_bracket(text: str, start: int) -> int | None: @@ -96,7 +136,7 @@ def _inline_image_target(text: str, start: int) -> tuple[str, int] | None: return html.unescape(re.sub(r"\\([^\w\s])", r"\1", target)), pos + 1 -def _markdown_images(text: str, document: str) -> list[tuple[str, str]]: +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)) @@ -124,7 +164,7 @@ def label(value): target = _inline_image_target(text, consumed) if target: source, consumed = target - sources.append((alt, source)) + sources.append((alt, source, match.start(), consumed)) continue key = label(alt) if consumed < len(text) and text[consumed] == "[": @@ -133,38 +173,22 @@ def label(value): key = label(text[consumed + 1:ref_close]) or key consumed = ref_close + 1 if key in definitions: - sources.append((alt, definitions[key])) + sources.append((alt, definitions[key], match.start(), consumed)) return sources def _images(text: str, document: str) -> list[tuple[str, str]]: """Read HTML and Markdown images, excluding Markdown-looking text in HTML attributes.""" - class Images(HTMLParser): - def __init__(self): - super().__init__(convert_charrefs=True) - self.images = [] - self.tags = [] - self.line_starts = [0, *(m.end() for m in re.finditer("\n", text))] - - def handle_starttag(self, tag, attrs): - line, column = self.getpos() - start = self.line_starts[line - 1] + column - self.tags.append((start, start + len(self.get_starttag_text()))) - if tag == "img": - first = {} - for name, value in attrs: - first.setdefault(name, value) - self.images.append((first.get("alt") or "", first.get("src") or "")) - - parser = Images() - parser.feed(text) - parser.close() - outside_tags = list(text) - for start, end in parser.tags: - for pos in range(start, end): - if outside_tags[pos] != "\n": - outside_tags[pos] = " " - return parser.images + _markdown_images("".join(outside_tags), document) + 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. + tags = [(start, end) for start, end in parser.tags + if not any(mstart <= start and end <= mend for _, _, mstart, mend in markdown)] + result = [(alt, source) for alt, source, start, end in markdown + if not any(tstart <= start and end <= tend for tstart, tend in tags)] + result += [(alt, source) for alt, source, start, end in parser.images if (start, end) in tags] + return result def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: diff --git a/tests/test_reference_badge_count.py b/tests/test_reference_badge_count.py index 8fd9572..ebb236b 100644 --- a/tests/test_reference_badge_count.py +++ b/tests/test_reference_badge_count.py @@ -6,11 +6,13 @@ URL = "https://github.com/Northwind-Trading-Co/sample-library/actions/workflows/ci.yml/badge.svg" -def lint_reference(make_ctx, tmp_path, usage, definitions): +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).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) @@ -93,3 +95,32 @@ def test_nonrendered_html_and_markdown_badges_do_not_count(make_ctx, tmp_path, u 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"]) diff --git a/tests/test_variant_j_badges.py b/tests/test_variant_j_badges.py index bd1f86a..5090881 100644 --- a/tests/test_variant_j_badges.py +++ b/tests/test_variant_j_badges.py @@ -187,14 +187,14 @@ def test_bootstrap_keeps_authored_badges_outside_pristine_body(make_ctx, tmp_pat assert badge_block(after) == authored -def profile_cli(ctx, tmp_path, *args): +def profile_cli(ctx, tmp_path, *args, expected_code=0): meta = tmp_path / "meta.json" meta.write_text(json.dumps(ctx.meta), encoding="utf-8") # Isolate these regressions from the separately tracked Windows stdout-encoding defect. env = dict(os.environ, PYTHONIOENCODING="utf-8") result = subprocess.run([sys.executable, str(ROOT / "scripts/showcase.py"), "--meta", str(meta), "--repo-dir", str(tmp_path), *args], capture_output=True, encoding="utf-8", env=env) - assert result.returncode == 0, result.stdout + result.stderr + assert result.returncode == expected_code, result.stdout + result.stderr return json.loads(result.stdout) @@ -363,3 +363,24 @@ def test_template_badges_across_body_states_and_cli_modes(make_ctx, tmp_path, st 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"]) From 4695a5fb144f9aa9f47d08170550b9ff9f88fce4 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 12:28:46 -0400 Subject: [PATCH 11/98] fix: recognize personal-profile CI badges in legacy migration Co-Authored-By: Claude Sonnet 5.5 --- scripts/ctshowcase/readme.py | 2198 +++++++++++++-------------- tests/test_reference_badge_count.py | 6 + tests/test_variant_j_badges.py | 12 + 3 files changed, 1117 insertions(+), 1099 deletions(-) diff --git a/scripts/ctshowcase/readme.py b/scripts/ctshowcase/readme.py index 9d521ba..4989404 100644 --- a/scripts/ctshowcase/readme.py +++ b/scripts/ctshowcase/readme.py @@ -1,1099 +1,1099 @@ -"""README scaffold / upgrade / header / bootstrap per STANDARD §7 with decision 13 header order (spec A6.6). - -Generated blocks sit between `` / `:end -->` markers so reruns replace only -those blocks. Content is never invented (§1.5 rule 2) and never lost (§7.1 restructuring, multiset check). -""" -from __future__ import annotations - -import re -from collections import Counter -from pathlib import Path -from urllib.parse import quote - -from . import footer as footermod -from .context import RepoContext -from .detect import standard -from .mdcode import code_spans, find_blocks, masked # noqa: F401 (code_spans: re-exported for callers) - -MARK = re.compile(r"\n?(.*?)\n?", re.S) -EMOJI = re.compile("[\U0001F000-\U0001FAFF☀-➿⬀-⯿️‍]") -SHIELDS = "https://img.shields.io/badge/" -GENERATED_BADGES = re.compile(r"\n(.*)", re.S) -BOM = "\ufeff" - - -def normalize_text(text: str) -> str: - """README text as the tools read it: LF line endings, no leading UTF-8 byte order mark (a BOM would hide the H1 - from every `^#` match).""" - text = text.replace("\r\n", "\n") - return text[1:] if text.startswith(BOM) else text - - -def start(b): - return f"" - - -def end(b): - return f"" - - -def wrap(b, body): - return f"{start(b)}\n{body}\n{end(b)}" - - -# ------------------------------------------------------------------------------------------------ -# Small helpers -# ------------------------------------------------------------------------------------------------ -def shield(s: str) -> str: - """A shields.io path segment: `-`/`_` doubled, then percent-encoded, so `#`, `?`, `/` and `%` (C#, F#) stay in - the segment instead of ending the path.""" - return quote(s.replace("-", "--").replace("_", "__"), safe="()!*'.,;:@&=$~") - - -def title_case(s: str) -> str: - rv = standard("readme-variants") - small, keep = set(rv["title_small_words"]), {k.lower(): k for k in rv["title_keep_case"]} - out = [] - for i, w in enumerate(re.split(r"(\s+)", s)): - if not w.strip(): - out.append(w) - continue - low = w.lower() - if low in keep: - out.append(keep[low]) - elif i > 0 and low in small: - out.append(low) - else: - out.append(w[:1].upper() + w[1:]) - return "".join(out) - - -def slug_title(slug: str) -> str: - """Title from a slug; CVE ids keep their form ("acme-cve-2026-12345-check" -> "Acme CVE-2026-12345 Check").""" - cves: list[str] = [] - - def keep(m): - cves.append(m.group(0).upper()) - return f"\x00{len(cves) - 1}\x00" - s = re.sub(r"cve-\d{4}-\d+", keep, slug, flags=re.I) - t = title_case(s.replace("-", " ").replace("_", " ")) - return re.sub(r"\x00(\d+)\x00", lambda m: cves[int(m.group(1))], t) - - -def cased_title(slug: str, description: str = "") -> str: - """slug_title, with each word spelled the way the repository's own description spells it when that spelling is - a deliberate one (mixed case, such as PHP2Shell or OpenClaw), so a brand name is never re-cased to "Php2shell".""" - spelled = {} - for tok in re.findall(r"[A-Za-z0-9][A-Za-z0-9+#]*", description or ""): - if tok != tok.lower() and tok != tok[:1].upper() + tok[1:].lower(): - spelled.setdefault(tok.lower(), tok) - return re.sub(r"[A-Za-z0-9][A-Za-z0-9+#]*", lambda m: spelled.get(m.group(0).lower(), m.group(0)), slug_title(slug)) - - -def product_name(ctx: RepoContext) -> str: - return ctx.facts.h1() or cased_title(ctx.repo, ctx.meta.get("description") or "") - - -def split_sections(text: str) -> tuple[str, list[tuple[str, str]]]: - """(preamble, [(heading text, body)]) split at H2 outside code fences.""" - pre, sections, cur_h, buf = [], [], None, [] - fence = None - for line in text.splitlines(): - m = re.match(r"^(```+|~~~+)", line) - if m: - fence = None if fence and line.startswith(fence) else (fence or m.group(1)) - if fence is None and re.match(r"^## (?!#)", line): - if cur_h is None: - pre = buf - else: - sections.append((cur_h, "\n".join(buf))) - cur_h, buf = line[3:].strip(), [] - continue - buf.append(line) - if cur_h is None: - return "\n".join(buf), [] - sections.append((cur_h, "\n".join(buf))) - return "\n".join(pre), sections - - -# ------------------------------------------------------------------------------------------------ -# Header pieces -# ------------------------------------------------------------------------------------------------ -def nav_line(ctx: RepoContext) -> str: - org, owner = ctx.org, ctx.brand.resolver().canonical(ctx.owner) # links name the org's current login (CQ-03) - if ctx.same_owner("CompleteTech-Skills") and ctx.det["type"] == "agent-skills": - line = ("**CompleteTech LLC Skills** · [Start here](ONBOARDING.md) · [Agent instructions](SKILL.md) · " - "[Skill library](https://github.com/CompleteTech-Skills/agentic-services-orchestrator-skill/blob/main/" - "references/skill-family.md) · [Contributing](CONTRIBUTING.md)") - return line + (" · Internal" if not ctx.public else "") - f = ctx.facts - contrib = "CONTRIBUTING.md" if f.exists("CONTRIBUTING.md") else f"https://github.com/{owner}/.github/blob/main/CONTRIBUTING.md" - sec = "SECURITY.md" if f.exists("SECURITY.md") else f"https://github.com/{owner}/.github/blob/main/SECURITY.md" - if ctx.det["variant"] == "F": - items = [f"**{org.get('nav_name') or org.get('display_name')}**", f"[All checkers](https://github.com/{owner})"] - if f.exists("docs/REFERENCE.md"): - items.append("[Reference](docs/REFERENCE.md)") - if f.exists("LICENSING.md"): - items.append("[Licensing](LICENSING.md)") - items.append(f"[Security]({sec})") - return " · ".join(items) - items = [f"**{org.get('nav_name') or org.get('display_name') or owner}**", f"[All repositories](https://github.com/{owner})"] - hp = ctx.meta.get("homepage") or "" - if ctx.det["variant"] == "B" and hp and ctx.meta.get("homepage_status") == 200: - items.append(f"[Live site]({hp})") - if ctx.det["variant"] == "E" and hp and ctx.meta.get("homepage_status") == 200: - items.append(f"[Play]({hp})") - docs = [x for x in f.files if x.startswith("docs/") and not re.search(r"\.(png|jpe?g|gif|webp|svg)$|/\.", x)] if f.known else [] - if docs: - items.append("[Docs](docs/)") - items += [f"[Contributing]({contrib})", f"[Security]({sec})"] - line = " · ".join(items) - return line + (" · Internal" if not ctx.public else "") - - -def banner_html(ctx: RepoContext) -> str: - return (f'

\n {ctx.repo} by {ctx.company}\n

') - - -def badges(ctx: RepoContext) -> list[tuple[str, str, str | None]]: - """[(alt, image url, link)] per §7.2: language, type, license, CI. 2-4.""" - b = dict(ctx.brand.raw["badges"]) - for k in ("brand", "accent"): # a family outside CompleteTech carries its own badge colours (brand.yaml badge_brand, badge_accent) - if ctx.style.get(f"badge_{k}"): - b[k] = ctx.style[f"badge_{k}"] - jev = ctx.style["name"] == "jevplay-games" - extra = "?labelColor=1E1E1E" if jev else "" - out = [] - f, lang = ctx.facts, ctx.meta.get("language") - py, node = f.requires_python(), f.engines_node() - if lang == "Python" and py: - out.append((f"Python {py}+", f"{SHIELDS}python-{shield(py + '+')}-{b['accent']}?logo=python&logoColor=white", None)) - elif lang in ("JavaScript", "TypeScript") and node: - out.append((f"Node {node}+", f"{SHIELDS}node-{shield(node + '+')}-{b['accent']}?logo=nodedotjs&logoColor=white", None)) - elif lang: - out.append((f"Language {lang}", f"{SHIELDS}language-{shield(lang)}-{b['accent']}{extra}", None)) - if ctx.det["type"] == "agent-skills": - 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)) - 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)) - elif lic and lic not in ("NOASSERTION", "other"): - if ctx.org.get("license_chip") and lic.upper().startswith("AGPL"): - out.append(("AGPL-3.0 or commercial license", f"{SHIELDS}license-{shield('AGPL-3.0 | commercial')}-{b['neutral']}", - "LICENSING.md" if f.exists("LICENSING.md") else None)) - else: - out.append((f"License {lic}", f"{SHIELDS}license-{shield(lic)}-{b['neutral']}{extra}", None)) - if ctx.public and f.known: - wf = sorted(x for x in f.files if x.startswith(".github/workflows/") and x.endswith((".yml", ".yaml"))) - pick = [x for x in wf if "test" in x.rsplit("/", 1)[-1]] or [x for x in wf if "ci" in x.rsplit("/", 1)[-1]] - if pick: - name = pick[0].rsplit("/", 1)[-1] - base = f"https://github.com/{ctx.brand.resolver().canonical(ctx.owner)}/{ctx.repo}/actions/workflows/{name}" - out.append(("Tests" if "test" in name else "CI", f"{base}/badge.svg", base)) - return out[:4] - - -def _local(ctx, url: str) -> str: - """--badges local: render a shields.io badge URL to docs/images/badges/.svg (outlined text, no server).""" - if not getattr(ctx, "local_badges", False) or not url.startswith(SHIELDS): - return url - from urllib.parse import unquote - path = url[len(SHIELDS):].split("?", 1)[0] - parts = re.split(r"(? str: - lines = [] - for alt, url, link in badges(ctx): - url = _local(ctx, url) - img = f"![{alt}]({url})" - lines.append(f"[{img}]({link})" if link else img) - return "\n".join(lines) - - -def badges_html(ctx) -> str: - parts = [] - for alt, url, link in badges(ctx): - url = _local(ctx, url) - img = f'{alt}' - parts.append(f' {img}' if link else f" {img}") - return '

\n' + "\n".join(parts) + "\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 "") - or (existing or "").strip() == template_badge_body(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 "") - replaced_digest = body_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 legacy_profile_badges(brand, body: str) -> 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 = body.splitlines() - if not 1 <= len(lines) <= 3: - return False - kinds = [] - colors = brand.raw["badges"] - 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/actions/workflows/([^/?#]+\.ya?ml)/badge\.svg", url) - if not ci or link != url.removesuffix("/badge.svg"): - return False - name = ci.group(1) - 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 - urls = {f"{SHIELDS}{label}-{shield(message)}-{color}{suffix}" for suffix in suffixes} - 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) == body_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".""" - return bool(ctx.brand.readme_opts.get("nav_first")) - - -def lead(ctx: RepoContext, banner: str, nav: str) -> list[str]: - """The banner and nav blocks in the configured order.""" - return [nav, banner] if nav_first(ctx) else [banner, nav] - - -def inherited_tagline(ctx: RepoContext, body: str, text: str) -> bool: - """True when the tagline block `body` of README `text` came from the template this repository was generated from - (never another repository's line is kept): it is one of meta template_taglines (the template's tagline when this - repository was generated, and today); only when those are unknown, an untouched (pristine) template body stands in - as the proof. The template repository's own tagline is never inherited.""" - if ctx.meta.get("is_template"): - return False - known = [x.strip() for x in ctx.meta.get("template_taglines") or [] if isinstance(x, str) and x.strip()] - return body.strip() in known if known else bootstrap_state(text) == "pristine" - - -def tagline_body(ctx: RepoContext, existing: str | None = None, text: str = "") -> str: - """The tagline block body, decided in this one place for header(), bootstrap(), upgrade() and header_only(). - R2: the tagline is the description. Without one (logged needs-input:description) the block keeps `existing`, the - body it holds now in README `text`, unless that is inherited (guardrail 6: improve, never erase); nothing to keep - gives an empty block, never "****".""" - desc = (ctx.meta.get("description") or "").strip() - if desc: - return f'

{desc}

' if ctx.det["variant"] == "F" else f"**{desc}**" - ctx.log("needs-input:description") - if existing is None or not existing.strip() or inherited_tagline(ctx, existing, text): - return "" - return existing - - -def header(ctx: RepoContext, keep_banner: str | None = None, title: str | None = None, - 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", 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", badge_body(ctx, existing_badges)), tag, "
", "---"] - return "\n\n".join(p for p in parts if p) - - -# ------------------------------------------------------------------------------------------------ -# License (§7.6) and section facts -# ------------------------------------------------------------------------------------------------ -def license_section(ctx: RepoContext) -> tuple[str, str] | None: - rv = standard("readme-variants")["license_rows"] - lic, f = ctx.meta.get("license"), ctx.facts - third = f.known and (ctx.repo.endswith("-fork") or any(x.startswith(("vendor/", "third_party/")) for x in f.files)) - if third: - ctx.log("needs-input:upstream") - return None - # visibility first: a private repository is proprietary in every org, Security included (LICENSE-POLICY.md) - if ctx.public and (ctx.det["variant"] == "F" or (ctx.org.get("license_chip") and lic and lic.upper().startswith("AGPL"))): - return "Licensing", rv["security"] - if not ctx.public: - if ctx.same_owner("TouchedByAnAgent"): - return "License", rv["private_hold"] - year = (ctx.meta.get("created_at") or "")[:4] - if not year: - ctx.log("needs-input:license-year") - return None - private = rv["private"].replace("", year) - if ctx.org.get("family") not in (None, "completetech"): - private = private.replace("CompleteTech LLC", ctx.org.get("display_name") or ctx.owner) - return "License", private - if lic == "MIT": - if f.exists("BRAND_ASSETS.md"): - return "License", rv["skills_mit"] - if ctx.tier.startswith("T2") or ctx.org.get("family") not in (None, "completetech"): - return "License", rv["t2_mit"].replace("", ctx.org.get("display_name") or ctx.owner) - return "License", rv["t1_mit"] - if lic and lic not in ("NOASSERTION",): - brand = ctx.company - return "License", rv["other"].replace("", lic).replace("", "LICENSE").replace("", brand) - ctx.log("needs-input:license") - return None - - -def section_facts(ctx: RepoContext, h2: str) -> str | None: - """Body for a scaffolded section, only from repo facts; None means omit + needs-input.""" - f = ctx.facts - if h2 == "Workflow Diagram" and f.read("assets/diagrams/workflow.mmd"): - return ("Source: [assets/diagrams/workflow.mmd](assets/diagrams/workflow.mmd).\n\n```mermaid\n" - + f.read("assets/diagrams/workflow.mmd").strip() + "\n```") - if h2 == "Install": - cmds = [] - if f.exists("requirements.txt"): - cmds.append("pip install -r requirements.txt") - elif f.exists("pyproject.toml"): - cmds.append("pip install .") - if f.exists("package.json"): - cmds.append("npm install") - if cmds: - return "```bash\n" + "\n".join(cmds) + "\n```" - if h2 == "Quick Start" and f.known: - scripts = list(((f.pyproject().get("project") or {}).get("scripts") or {}).keys()) - pj = f.package_json() - if not scripts and isinstance(pj.get("bin"), dict): - scripts = list(pj["bin"].keys()) - if scripts: - return "```bash\n" + "\n".join(f"{s} --help" for s in scripts[:3]) + "\n```" - if h2 == "Live Site": - hp = ctx.meta.get("homepage") or "" - if hp and ctx.meta.get("homepage_status") == 200: - host = re.sub(r"^https?://", "", hp).rstrip("/") - return f"**[{host}]({hp})**" - if h2 == "Play": - hp = ctx.meta.get("homepage") or "" - if hp and ctx.meta.get("homepage_status") == 200: - return f"**[{re.sub(r'^https?://', '', hp).rstrip('/')}]({hp})**" - if h2 == "Ownership": - if ctx.org.get("family") not in (None, "completetech"): - return f"Organization: {ctx.owner}" - return f"Owner: @CompleteDotTech · Organization: {ctx.owner}" - if h2 == "Files" and ctx.det["variant"] == "J": - rows = [("profile/README.md", f"Organization profile shown at github.com/{ctx.owner}")] - rows = [r for r in rows if f.exists(r[0])] - if f.exists("labels.yml"): - rows.append(("labels.yml", "Label standard (ct-std-v1)")) - if rows: - return "| File | Purpose |\n|---|---|\n" + "\n".join(f"| `{a}` | {b} |" for a, b in rows) - return None - - -def footer_wanted(ctx) -> bool: - """Decision 18 footer wanted (RepoContext.footer_status): the watching org's `footer: false` exempts only its - forks, so its `.github` gets the footer and its other non-fork repositories are held.""" - why = ctx.footer_status() - if why is None: - return True - ctx.log("exempt:footer" if why.startswith("exempt") else why if why.startswith("hold:") else "hold") - return False - - -def callout(ctx) -> str | None: - if ctx.det["variant"] == "I" or (not ctx.public and ctx.det["variant"] in "ABCDE"): - who = "Touched By An Agent" if ctx.same_owner("TouchedByAnAgent") else ctx.company - return f"> [!IMPORTANT]\n> Internal {who} repository. Not for distribution." - return None - - -# ------------------------------------------------------------------------------------------------ -# Modes -# ------------------------------------------------------------------------------------------------ -def scaffold(ctx: RepoContext, existing: str = "") -> str: - variant = ctx.det["variant"] - order = standard("readme-variants")["variants"].get(variant, []) - parts = [header(ctx)] - co = callout(ctx) - if co: - parts.append(co) - keep = [ln for ln in existing.splitlines() if ln.strip() and not ln.startswith("# ")] - for h2, req in order: - if h2 in ("License", "Licensing"): - continue - body = section_facts(ctx, h2) - if h2 == "About" and keep: - body = "\n".join(keep) - if body: - parts.append(f"## {h2}\n\n{body}") - elif req == "R": - ctx.log(f"needs-input:{variant}:{h2}") - lic = license_section(ctx) - if lic: - parts.append(f"## {lic[0]}\n\n{lic[1]}") - if footer_wanted(ctx): - parts.append(footermod.block(ctx.brand)) - return "\n\n".join(parts) + "\n" - - -HEADER_LINE = re.compile( - r"^\s*(\*\*[^*]+\*\*\s*·.*|#\s+.+|!\[[^\]]*\]\(https://img\.shields\.io[^)]*\)\s*|\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*" - r"|]*(shields\.io|badge\.svg|logo)[^>]*>\s*|]*>\s*]*(shields\.io|badge\.svg)[^>]*>\s*\s*" - r"||]*>|]*alt=\"[^\"]*logo[^\"]*\"[^>]*>|

|

|
|
|---)$") - - -def _strip_markers(text: str, keep: dict[str, str] | None = None) -> tuple[str, dict]: - """(text without its marker blocks, {block: body}); a marker example inside fenced or inline code stays. A block - named in `keep` stays where it is, rewrapped around the body `keep` gives it.""" - blocks, out, pos = {}, [], 0 - for m in find_blocks(MARK, text): - blocks[m.group(1)] = m.group(2) - out.append(text[pos:m.start()]) - if keep and m.group(1) in keep: - out.append(wrap(m.group(1), keep[m.group(1)])) - pos = m.end() - return "".join(out) + text[pos:], blocks - - -def _leading_banner(pre: str) -> bool: - """True when the README preamble opens with a third-party banner image (not ours, not a logo or badge).""" - first_img = re.search(r"<(img|picture)\b[^>]*>|!\[[^\]]*\]\([^)]*\)", pre) - return bool(first_img and "docs/images/banner.png" not in first_img.group(0) - and not re.search(r"logo", first_img.group(0), re.I) - and "shields.io" not in first_img.group(0) and "badge.svg" not in first_img.group(0) - and not pre[:first_img.start()].strip().replace("

", "").strip()) - - -def fork_callout(ctx: RepoContext) -> str | None: - """§7.4 G callout; None (logged fork-parent-missing) when the parent is unknown. A fork with commits ahead of its - parent gets the full note with the compare link; a 0-ahead fork (§11.1 F1x) gets the short form, which claims no - changes. When `ahead_by` is not in the metadata the short form is used too (never claim changes nobody counted) - and `needs-input:ahead-by` is logged.""" - parent = ctx.meta.get("parent") or {} - full = parent.get("full_name") if isinstance(parent, dict) else None - if not full: - ctx.log("fork-parent-missing") - return None - ahead = ctx.meta.get("ahead_by") - if not ahead: - if ahead is None: - ctx.log("needs-input:ahead-by") - return (f"> [!NOTE]\n> **Fork of [{full}](https://github.com/{full})**, kept by {ctx.company}. " - "The upstream README follows unchanged; the upstream license applies.") - pbranch = parent.get("default_branch") or "main" - branch = ctx.meta.get("default_branch") or "main" - powner = full.split("/")[0] - return (f"> [!NOTE]\n> **Fork of [{full}](https://github.com/{full})** with {ctx.company} changes. Our changes: " - f"[compare with upstream](https://github.com/{ctx.owner}/{ctx.repo}/compare/{powner}:{pbranch}...{branch}). " - "The upstream README follows unchanged; the upstream license applies.") - - -BANNER_BLOCK = re.compile(re.escape(start("banner")) + r".*?" + re.escape(end("banner")) + r"\n*", re.S) - - -def callout_only(ctx: RepoContext, text: str) -> str: - """Variants G and H (§7.4): the hero banner block, then the callout (unless one is already there), then the body - unchanged, then the decision 18 footer. Nothing else changes. Watching-org forks never reach here (`readme` - exempts them). Review q27 "Full CompleteTech treatment" (brand.yaml readme.fork_readme: full) means the fork - follows its §5.4 variant instead: pass that variant with `readme --variant`.""" - if ctx.det["variant"] == "G" and ctx.brand.readme_opts.get("fork_readme") == "full": - ctx.log("needs-input:fork-variant (q27 full treatment: pass readme --variant for the §5.4 variant)") - body = text - lead_banner = "" - m = BANNER_BLOCK.match(text.lstrip("\n")) - if m: # our banner block is already on top (a re-run): keep it, look for the callout under it - body = text.lstrip("\n")[m.end():] - first = next((ln for ln in body.splitlines() if ln.strip()), "") - out = body - if not first.lstrip().startswith(">"): - if ctx.det["variant"] == "G": - co = fork_callout(ctx) - else: - co = None # the H callout needs the archive date and reason from the registry - ctx.log("needs-input:archive-callout") - if co: - out = co + "\n\n" + body.lstrip("\n") - if ctx.art_status()["banner"] is None: - lead_banner = wrap("banner", banner_html(ctx)) + "\n\n" # decision 13: the hero banner opens G and H too - elif m: - lead_banner = text.lstrip("\n")[:m.end()].rstrip("\n") + "\n\n" - out = lead_banner + out.lstrip("\n") - if footer_wanted(ctx) and footermod.check(ctx.brand, out)[0] == "fail:missing": - out = out.rstrip("\n") + "\n\n" + footermod.block(ctx.brand) + "\n" - return out - - -def preserved(ctx: RepoContext, text: str) -> bool: - """§7.0 preserve list, decided the same way by `readme` (before) and `lint readme` (after the upgrade): - the org's readme_preserve list, any README over 15,000 bytes, and every T2 product README that opens with its - own banner image. Showcase marker blocks (our nav line) are ignored, so the answer survives an upgrade.""" - if ctx.repo in (ctx.org.get("readme_preserve") or []) or len(text.encode()) > 15000: - return True - if ctx.tier != "T2": - return False - body, _ = _strip_markers(text.replace("\r\n", "\n")) - pre, _ = split_sections(body) - return _leading_banner(pre) - - -CENTRED_OPEN = re.compile(r'(?m)^[ \t]*<(p|div) align="center">') - - -def is_footer_like(block: str) -> bool: - """A centred block that credits the company with a complete.tech link: the decision 18 footer, exact or drifted.""" - return bool(re.match(r'\s*<(p|div) align="center">', block)) and "built by" in block.lower() \ - and "complete.tech" in block.lower() - - -FOOTER_TAG = re.compile(r"]*)>", re.I) - - -FOOTER_ATTR = re.compile(r"""\b(src|srcset|alt)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", re.I) - - -def _footer_logo_url(url: str, brand) -> bool: - """`url` is one of the decision 18 footer's own logo files (brand footer `logo_light_url` or `logo_dark_url`), - as configured or drifted only in scheme, branch, query or the github.com blob/raw form of the same file.""" - url = url.strip().split("#", 1)[0].split("?", 1)[0] - for key in ("logo_light_url", "logo_dark_url"): - want = brand.raw["footer"][key] - if url == want: - return True - m = re.match(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/[^/]+/(.+)$", want) - if not m: - continue - own, repo, path = (re.escape(x) for x in m.groups()) - if re.fullmatch(rf"(?i:https?://raw\.githubusercontent\.com/{own}/{repo}/[^/]+/){path}", url) or \ - re.fullmatch(rf"(?i:https?://github\.com/{own}/{repo}/(?:blob|raw)/[^/]+/){path}", url): - return True - return False - - -def is_footer_copy(block: str, brand) -> bool: - """Every line of `block` is a line of the decision 18 footer or a drifted copy of one: only centring, link, logo, - `
`/`` markup, with complete.tech links, the footer's own logo image (its src or srcset is the brand footer - logo URL or a drifted form of it, or its alt is exactly the company) and as text only the credit line ("Built by - ", optionally with the suffix). A sponsor or partner image (whatever its file name), a thanks line or - any other authored text makes it not a footer copy, so it is never removed.""" - import html - f, company = brand.raw["footer"], brand.strings["company"] - credit = {f"{f['line_prefix']} {company}", f"{f['line_prefix']} {company} {f['line_suffix']}"} - - def tag_ok(m: re.Match) -> bool: - name, attrs = m.group(1).lower(), m.group(2) - if m.group(0).startswith(" tuple[str, list[str]]: - """(text, removed lines) with a trailing old footer (an unmarked or drifted decision 18 footer: every line a - footer line, `is_footer_copy`) taken off the end, so the marked footer replaces it instead of following it. Any - other block, a centred credits block with authored lines included, is left as it is (lint reports a second - footer-like block as `fail:duplicate`).""" - body = text.rstrip() - opens = [m for m in CENTRED_OPEN.finditer(masked(body))] - if not opens: - return text, [] - m = opens[-1] - tail = body[m.start():] - if not tail.rstrip().endswith(f"") or not is_footer_like(tail) or not is_footer_copy(tail, brand): - return text, [] - return body[:m.start()].rstrip() + "\n", [ln for ln in tail.splitlines() if ln.strip()] - - -def upgrade(ctx: RepoContext, text: str) -> tuple[str, list[str]]: - """Return (new text, header lines removed). Raises nothing; callers run the shrink checks.""" - variant = ctx.det["variant"] - rv = standard("readme-variants") - if variant in ("G", "H"): - return callout_only(ctx, normalize_text(text)), [] - text = normalize_text(text) - body, blocks = _strip_markers(text) - want_footer = footer_wanted(ctx) - removed: list[str] = [] - if want_footer: # an unmarked or drifted footer at the end is replaced by the marked one, never kept beside it - body, old_footer = drop_old_footer(body, ctx.brand) - removed += old_footer - # preserve list / existing third-party banner - preserve = ctx.repo in (ctx.org.get("readme_preserve") or []) or len(text.encode()) > 15000 - pre, sections = split_sections(body) - keep_pre: list[str] = [] - lines = pre.splitlines() - if _leading_banner(pre) and (ctx.tier == "T2" or preserve): - preserve = True - # q57 `pres`: the hero goes above the README's own banner (kept directly under it), or nothing is added - 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 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) - 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) # its lines are already in `removed` - pre, sections = split_sections(body) - lines = pre.splitlines() - # an unmarked bold line opening the preamble is an old tagline. With a description it is replaced; without one it - # is carried into an empty tagline block (header rule), and a second one stays as authored text: never erased - carry: list[str] = [] - # the pristine-body fallback alone would decide the tagline block (no known template taglines): a bare bold line - # the template cannot have written must not be moved into a block that later runs would empty - fallback_only = (not ctx.meta.get("is_template") and not [x for x in ctx.meta.get("template_taglines") or [] - if isinstance(x, str) and x.strip()] - and bootstrap_state(text) == "pristine") - - def is_header_line(ln: str) -> bool: - s_ = ln.strip() - if HEADER_LINE.match(ln) or "docs/images/banner.png" in ln: - return True - return bool(desc) and s_ in (f"**{desc}**", f"

{desc}

", f"{desc}") - - # group the preamble into units: centred HTML blocks and blocks are classified as a whole - units: list[list[str]] = [] - i = 0 - while i < len(lines): - s_ = lines[i].strip() - closer = {"

": "

", "
": "
", "": ""}.get(s_) - if closer: - j = i + 1 - while j < len(lines) and lines[j].strip() != closer: - j += 1 - units.append(lines[i:j + 1]) - i = j + 1 - continue - units.append([lines[i]]) - i += 1 - for unit in units: - if len(unit) == 1: - ln = unit[0] - s_ = ln.strip() - bold = re.fullmatch(r"\*\*[^*]+\*\*", s_) is not None and not "".join(keep_pre).strip() - is_hdr = is_header_line(ln) or bold - if preserve: # additive only: just an old nav line is replaced - is_hdr = s_.startswith("**") and " · " in s_ and not "".join(keep_pre).strip() - elif bold and not is_header_line(ln) and not desc: - if carry or (old_tag or "").strip() or fallback_only: - # the tagline block already has a line, or (an untouched template copy whose template taglines - # are unknown) the block rule would empty any line moved into it: this one stays authored text - keep_pre.append(ln) - continue - carry.append(ln) - (removed if is_hdr else keep_pre).append(ln) - continue - inner = unit[1:-1] - if all(not x.strip() or is_header_line(x) or x.strip() in ("", "") or x.strip().startswith("" and not preserve: - # old hero wrapper: drop the wrapper and header lines, keep anything else verbatim - for x in unit: - (removed if (x is unit[0] or x is unit[-1] or is_header_line(x)) else keep_pre).append(x) - else: - keep_pre.extend(unit) - intro = "\n".join(keep_pre).strip() - # sections: renames, mapping, Notes demotion, License last - renames = rv["renames"] - order = [h for h, _ in rv["variants"].get(variant, [])] - low_order = {h.lower(): h for h in order} - placed: dict[str, str] = {} - notes: list[str] = [] - license_body = None - existing_notes = None - for h, b in sections: - clean = EMOJI.sub("", h).strip() - name = renames.get(clean.lower(), clean) - if name.lower() in ("license", "licensing", "licence"): - license_body = (name if name != "Licence" else "License", b) - continue - if name.lower() == "notes": - existing_notes = b.strip("\n") - continue - if name.lower() in low_order and low_order[name.lower()] not in placed: - placed[low_order[name.lower()]] = b.strip("\n") - else: - if variant == "D" and name.lower() == "status" and "Findings" not in placed: - placed["Findings"] = b.strip("\n") - continue - notes.append(f"### {clean}\n{b.rstrip()}" if b.strip() else f"### {clean}") - out = [] - if preserve: - # §7.0 preserve list: additive only. The hero banner block goes on top (q57 `pres` default, "Banner above the - # existing content"; none when the user answers "Leave them unchanged"), with the nav line in the §7.1 order; - # the README's own banner and every section stay verbatim and in place; add License if missing, then footer. - if ctx.preserve_unchanged() or ctx.art_status()["banner"] is not None: - out.append(wrap("nav", nav_line(ctx))) - else: - out += lead(ctx, wrap("banner", banner_html(ctx)), wrap("nav", nav_line(ctx))) - if intro: - out.append(intro) - for h, b in sections: - out.append(f"## {h}\n{b.rstrip()}" if b.strip() else f"## {h}") - if not any(h.strip().lower() in ("license", "licensing", "licence") for h, _ in sections): - lic = license_section(ctx) - if lic: - out.append(f"## {lic[0]}\n\n{lic[1]}") - if want_footer: - out.append(footermod.block(ctx.brand)) - return "\n\n".join(x for x in out if x) + "\n", removed - else: - if carry: # an unmarked tagline with no description: it moves into the block (the move is not a lost line) - old_tag = carry[0].strip() - removed.remove(carry[0]) - new_tag = tagline_body(ctx, old_tag, text) - if old_tag and old_tag.strip() and not new_tag: - # 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, existing_badges=blocks.get("badges"))) - co = callout(ctx) - if co and co not in intro: - out.append(co) - if intro: - if variant == "A" or "About" in placed or "About" not in order: - out.append(intro) - else: - placed["About"] = intro + ("\n\n" + placed["About"] if placed.get("About") else "") - for h in order: - if h in ("License", "Licensing"): - continue - if h in placed: - out.append(f"## {h}\n\n{placed[h].strip()}" if placed[h].strip() else f"## {h}") - elif dict(rv["variants"][variant]).get(h) == "R" and not preserve: - body = section_facts(ctx, h) - if body: - out.append(f"## {h}\n\n{body}") - else: - ctx.log(f"needs-input:{variant}:{h}") - if existing_notes or notes: - nb = "\n\n".join(x for x in ([existing_notes] if existing_notes else []) + notes) - out.append(f"## Notes\n\n{nb}") - if license_body: - out.append(f"## {license_body[0]}\n\n{license_body[1].strip()}") - else: - lic = license_section(ctx) - if lic: - out.append(f"## {lic[0]}\n\n{lic[1]}") - if want_footer: - out.append(footermod.block(ctx.brand)) - for b in ("template-body", "checklist"): # inserted in reverse: the checklist stays above the body - if b in blocks: - out.insert(1 if not preserve else 1, wrap(b, blocks[b])) - return "\n\n".join(x for x in out if x) + "\n", removed - - -def header_only(ctx: RepoContext, text: str) -> str: - """Replace only existing marker blocks (what the Action runs).""" - text = normalize_text(text) - desc = (ctx.meta.get("description") or "").strip() - gen = {"nav": nav_line(ctx), "banner": banner_html(ctx), "footer": footermod.markup(ctx.brand)} - 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") - - 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) - return "" - if b == "tagline": # tagline_body: the description's line, else the block as written unless inherited - new = tagline_body(ctx, body, text) - return m.group(0) if not desc and new == body else wrap(b, re.sub(r"\n{3,}", "\n\n", new)) - if b in gen: - return wrap(b, re.sub(r"\n{3,}", "\n\n", gen[b])) - return m.group(0) - return license_block(ctx, reorder_lead(ctx, _sub_blocks(text, sub))) - - -def _seam(left: str, right: str) -> str: - """Join two pieces where a marker block meets authored text: the blank lines on both sides of the join count - together and collapse to at most one blank line. Nothing else in either piece changes.""" - a, b = left.rstrip("\n"), right.lstrip("\n") - n = min(len(left) - len(a) + len(right) - len(b), 2) - return a + "\n" * n + b - - -def _sub_blocks(text: str, sub) -> str: - """MARK.sub(sub, text), except that authored text between blocks comes back byte-identical (fenced code - included, and a marker example inside code is never treated as a block); blank-line runs are tidied only at the - seams around each block (a removed block leaves one blank line).""" - out, pos = "", 0 - for m in find_blocks(MARK, text): - out = text[:m.start()] if not pos else _seam(out, text[pos:m.start()]) - out = _seam(out, sub(m)) - pos = m.end() - return _seam(out, text[pos:]) if pos else text - - -LEAD_PAIR = re.compile(r"\A(\s*)(" + re.escape(start("nav")) + r".*?" + re.escape(end("nav")) + r")(\s*)(" + - re.escape(start("banner")) + r".*?" + re.escape(end("banner")) + r")", re.S) -LEAD_PAIR_REV = re.compile(r"\A(\s*)(" + re.escape(start("banner")) + r".*?" + re.escape(end("banner")) + r")(\s*)(" + - re.escape(start("nav")) + r".*?" + re.escape(end("nav")) + r")", re.S) - - -def reorder_lead(ctx: RepoContext, text: str) -> str: - """Swap a README's leading nav and banner marker blocks into the configured §7.1 order (q57 `nav`), so READMEs - written before the order changed follow it on the next header refresh. Only the two adjacent top blocks move.""" - pat = LEAD_PAIR_REV if nav_first(ctx) else LEAD_PAIR - return pat.sub(lambda m: m.group(1) + m.group(4) + m.group(3) + m.group(2), text, count=1) - - -LICENSE_MARK = "" - - -def license_block(ctx: RepoContext, text: str) -> str: - """Rewrite the paragraph after `` (and its H2) from license_section(), with or without a - LICENSE file: a private repository reads "Proprietary...", a Security repository gets "## Licensing". When the - section cannot be derived (needs-input), the existing text stays.""" - i = masked(text).find(LICENSE_MARK) # the first mark outside code (a documented example is left alone) - if i < 0: - return text - lic = license_section(ctx) - if not lic: - return text - m = re.compile(re.escape(LICENSE_MARK) + r"\n(.*?)(\n\n|\n?$)", re.S).match(text, i) - if m: - text = text[:i] + LICENSE_MARK + "\n" + lic[1] + m.group(2) + text[m.end():] - h = re.search(r"(?m)^## (?:License|Licensing|Licence)[ \t]*\n(\n?)\Z", text[:i]) - return text[:h.start()] + f"## {lic[0]}\n" + h.group(1) + text[i:] if h else text - - -TEMPLATE_H1 = "CompleteTech Repo Template" -PRISTINE = re.compile(r"\n?") -TEMPLATE_BODY = re.compile(re.escape(start("template-body")) + r"\n?(.*?)\n?" + re.escape(end("template-body")), re.S) - - -def body_sha(body: str) -> str: - import hashlib - return hashlib.sha256(body.replace("\r\n", "\n").strip().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}") - - -def bootstrap_state(text: str) -> str: - """'pristine' (the block is byte-for-byte what was generated), 'edited' (a marker without a matching hash: a person - wrote in the block) or 'none' (no marker: the README is the repository's own).""" - found = find_blocks(TEMPLATE_BODY, normalize_text(text)) - if not found: - return "none" - m = found[0] - pm = PRISTINE.search(m.group(1)) - if not pm: - return "none" - rest = PRISTINE.sub("", m.group(1), count=1) - return "pristine" if pm.group(1) and pm.group(1) == body_sha(rest) else "edited" - - -def drop_pristine(text: str) -> str: - """`text` without the pristine marker of the first template-body block outside code; a marker shown as an example - (inline or fenced code) stays byte-identical.""" - found = find_blocks(TEMPLATE_BODY, text) - if not found: - return text - tb = found[0] - return text[:tb.start(1)] + PRISTINE.sub("", tb.group(1), count=1) + text[tb.end(1):] - - -def _header_region(text: str) -> re.Match | None: - """From the top of the README to the rule after the tagline block (the §7.2 header).""" - return re.match(r"\A.*?" + re.escape(end("tagline")) + r"\n(?:.*?\n)??---\n", text, re.S) - - -def bootstrap(ctx: RepoContext, text: str) -> str: - """Fill a README that still carries a generated (pristine) template body: the README variant's sections from - repository facts, the variant's header (Variant F has no H1) and the license section. A pristine body is - regenerated on later runs too, so a repository typed `workspace` on its creation commit is re-bootstrapped once - its code (or `type:` in .github/showcase.yml) gives the real type. Any human edit inside the block changes its - hash, and from then on the block is the repository's own: never overwritten (callers drop the marker).""" - text = normalize_text(text) - if ctx.meta.get("is_template") or bootstrap_state(text) != "pristine": - return text - variant = ctx.det["variant"] - order = standard("readme-variants")["variants"].get(variant, []) - secs = [] - for h2, req in order: - if h2 in ("License", "Licensing"): - continue - body = section_facts(ctx, h2) - if body: - secs.append(f"## {h2}\n\n{body}") - elif req == "R": - secs.append(f"## {h2}\n\n") - ctx.log(f"needs-input:{variant}:{h2}") - new = pristine_block("\n\n".join(secs)) - tb = find_blocks(TEMPLATE_BODY, text)[0] # bootstrap_state found it outside code - text = text[:tb.start()] + new + text[tb.end():] - # the header: rebuilt for this variant unless a person retitled the README - hm = _header_region(text) - if hm: - h1 = re.search(r"(?m)^# (.+)$", hm.group(0)) - auto = cased_title(ctx.repo, ctx.meta.get("description") or "") - 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 - 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) - - -def bootstrap_contributing(ctx: RepoContext, text: str) -> str: - """Point the template's own `.github` guide links at this org. Only links to the template owner's `.github` (any - of its logins) change; a link to another org's or an upstream's guide or security policy stays as written.""" - from .intake import TEMPLATE_REPO - tpl = TEMPLATE_REPO.split("/")[0] - try: - logins = {x.lower() for x in ctx.brand.resolver().aliases(tpl)} - except AttributeError: # a context without a brand: the template login alone - logins = {tpl.lower()} - - def sub(m): - return m.group(1) + ctx.owner + m.group(3) if m.group(2).lower() in logins else m.group(0) - return re.sub(r"(https://github\.com/)([A-Za-z0-9-]+)(/\.github/blob/main/(?:CONTRIBUTING|SECURITY)\.md)", sub, - text).replace(f"[{tpl} contributing guide]", f"[{ctx.owner} contributing guide]") - - -# ------------------------------------------------------------------------------------------------ -# Safety checks -# ------------------------------------------------------------------------------------------------ -def nonws(s: str) -> int: - return len(re.sub(r"\s", "", s)) - - -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): - owned = generated_badges(m.group(2)) or body_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("` / `:end -->` markers so reruns replace only +those blocks. Content is never invented (§1.5 rule 2) and never lost (§7.1 restructuring, multiset check). +""" +from __future__ import annotations + +import re +from collections import Counter +from pathlib import Path +from urllib.parse import quote + +from . import footer as footermod +from .context import RepoContext +from .detect import standard +from .mdcode import code_spans, find_blocks, masked # noqa: F401 (code_spans: re-exported for callers) + +MARK = re.compile(r"\n?(.*?)\n?", re.S) +EMOJI = re.compile("[\U0001F000-\U0001FAFF☀-➿⬀-⯿️‍]") +SHIELDS = "https://img.shields.io/badge/" +GENERATED_BADGES = re.compile(r"\n(.*)", re.S) +BOM = "\ufeff" + + +def normalize_text(text: str) -> str: + """README text as the tools read it: LF line endings, no leading UTF-8 byte order mark (a BOM would hide the H1 + from every `^#` match).""" + text = text.replace("\r\n", "\n") + return text[1:] if text.startswith(BOM) else text + + +def start(b): + return f"" + + +def end(b): + return f"" + + +def wrap(b, body): + return f"{start(b)}\n{body}\n{end(b)}" + + +# ------------------------------------------------------------------------------------------------ +# Small helpers +# ------------------------------------------------------------------------------------------------ +def shield(s: str) -> str: + """A shields.io path segment: `-`/`_` doubled, then percent-encoded, so `#`, `?`, `/` and `%` (C#, F#) stay in + the segment instead of ending the path.""" + return quote(s.replace("-", "--").replace("_", "__"), safe="()!*'.,;:@&=$~") + + +def title_case(s: str) -> str: + rv = standard("readme-variants") + small, keep = set(rv["title_small_words"]), {k.lower(): k for k in rv["title_keep_case"]} + out = [] + for i, w in enumerate(re.split(r"(\s+)", s)): + if not w.strip(): + out.append(w) + continue + low = w.lower() + if low in keep: + out.append(keep[low]) + elif i > 0 and low in small: + out.append(low) + else: + out.append(w[:1].upper() + w[1:]) + return "".join(out) + + +def slug_title(slug: str) -> str: + """Title from a slug; CVE ids keep their form ("acme-cve-2026-12345-check" -> "Acme CVE-2026-12345 Check").""" + cves: list[str] = [] + + def keep(m): + cves.append(m.group(0).upper()) + return f"\x00{len(cves) - 1}\x00" + s = re.sub(r"cve-\d{4}-\d+", keep, slug, flags=re.I) + t = title_case(s.replace("-", " ").replace("_", " ")) + return re.sub(r"\x00(\d+)\x00", lambda m: cves[int(m.group(1))], t) + + +def cased_title(slug: str, description: str = "") -> str: + """slug_title, with each word spelled the way the repository's own description spells it when that spelling is + a deliberate one (mixed case, such as PHP2Shell or OpenClaw), so a brand name is never re-cased to "Php2shell".""" + spelled = {} + for tok in re.findall(r"[A-Za-z0-9][A-Za-z0-9+#]*", description or ""): + if tok != tok.lower() and tok != tok[:1].upper() + tok[1:].lower(): + spelled.setdefault(tok.lower(), tok) + return re.sub(r"[A-Za-z0-9][A-Za-z0-9+#]*", lambda m: spelled.get(m.group(0).lower(), m.group(0)), slug_title(slug)) + + +def product_name(ctx: RepoContext) -> str: + return ctx.facts.h1() or cased_title(ctx.repo, ctx.meta.get("description") or "") + + +def split_sections(text: str) -> tuple[str, list[tuple[str, str]]]: + """(preamble, [(heading text, body)]) split at H2 outside code fences.""" + pre, sections, cur_h, buf = [], [], None, [] + fence = None + for line in text.splitlines(): + m = re.match(r"^(```+|~~~+)", line) + if m: + fence = None if fence and line.startswith(fence) else (fence or m.group(1)) + if fence is None and re.match(r"^## (?!#)", line): + if cur_h is None: + pre = buf + else: + sections.append((cur_h, "\n".join(buf))) + cur_h, buf = line[3:].strip(), [] + continue + buf.append(line) + if cur_h is None: + return "\n".join(buf), [] + sections.append((cur_h, "\n".join(buf))) + return "\n".join(pre), sections + + +# ------------------------------------------------------------------------------------------------ +# Header pieces +# ------------------------------------------------------------------------------------------------ +def nav_line(ctx: RepoContext) -> str: + org, owner = ctx.org, ctx.brand.resolver().canonical(ctx.owner) # links name the org's current login (CQ-03) + if ctx.same_owner("CompleteTech-Skills") and ctx.det["type"] == "agent-skills": + line = ("**CompleteTech LLC Skills** · [Start here](ONBOARDING.md) · [Agent instructions](SKILL.md) · " + "[Skill library](https://github.com/CompleteTech-Skills/agentic-services-orchestrator-skill/blob/main/" + "references/skill-family.md) · [Contributing](CONTRIBUTING.md)") + return line + (" · Internal" if not ctx.public else "") + f = ctx.facts + contrib = "CONTRIBUTING.md" if f.exists("CONTRIBUTING.md") else f"https://github.com/{owner}/.github/blob/main/CONTRIBUTING.md" + sec = "SECURITY.md" if f.exists("SECURITY.md") else f"https://github.com/{owner}/.github/blob/main/SECURITY.md" + if ctx.det["variant"] == "F": + items = [f"**{org.get('nav_name') or org.get('display_name')}**", f"[All checkers](https://github.com/{owner})"] + if f.exists("docs/REFERENCE.md"): + items.append("[Reference](docs/REFERENCE.md)") + if f.exists("LICENSING.md"): + items.append("[Licensing](LICENSING.md)") + items.append(f"[Security]({sec})") + return " · ".join(items) + items = [f"**{org.get('nav_name') or org.get('display_name') or owner}**", f"[All repositories](https://github.com/{owner})"] + hp = ctx.meta.get("homepage") or "" + if ctx.det["variant"] == "B" and hp and ctx.meta.get("homepage_status") == 200: + items.append(f"[Live site]({hp})") + if ctx.det["variant"] == "E" and hp and ctx.meta.get("homepage_status") == 200: + items.append(f"[Play]({hp})") + docs = [x for x in f.files if x.startswith("docs/") and not re.search(r"\.(png|jpe?g|gif|webp|svg)$|/\.", x)] if f.known else [] + if docs: + items.append("[Docs](docs/)") + items += [f"[Contributing]({contrib})", f"[Security]({sec})"] + line = " · ".join(items) + return line + (" · Internal" if not ctx.public else "") + + +def banner_html(ctx: RepoContext) -> str: + return (f'

\n {ctx.repo} by {ctx.company}\n

') + + +def badges(ctx: RepoContext) -> list[tuple[str, str, str | None]]: + """[(alt, image url, link)] per §7.2: language, type, license, CI. 2-4.""" + b = dict(ctx.brand.raw["badges"]) + for k in ("brand", "accent"): # a family outside CompleteTech carries its own badge colours (brand.yaml badge_brand, badge_accent) + if ctx.style.get(f"badge_{k}"): + b[k] = ctx.style[f"badge_{k}"] + jev = ctx.style["name"] == "jevplay-games" + extra = "?labelColor=1E1E1E" if jev else "" + out = [] + f, lang = ctx.facts, ctx.meta.get("language") + py, node = f.requires_python(), f.engines_node() + if lang == "Python" and py: + out.append((f"Python {py}+", f"{SHIELDS}python-{shield(py + '+')}-{b['accent']}?logo=python&logoColor=white", None)) + elif lang in ("JavaScript", "TypeScript") and node: + out.append((f"Node {node}+", f"{SHIELDS}node-{shield(node + '+')}-{b['accent']}?logo=nodedotjs&logoColor=white", None)) + elif lang: + out.append((f"Language {lang}", f"{SHIELDS}language-{shield(lang)}-{b['accent']}{extra}", None)) + if ctx.det["type"] == "agent-skills": + 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)) + 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)) + elif lic and lic not in ("NOASSERTION", "other"): + if ctx.org.get("license_chip") and lic.upper().startswith("AGPL"): + out.append(("AGPL-3.0 or commercial license", f"{SHIELDS}license-{shield('AGPL-3.0 | commercial')}-{b['neutral']}", + "LICENSING.md" if f.exists("LICENSING.md") else None)) + else: + out.append((f"License {lic}", f"{SHIELDS}license-{shield(lic)}-{b['neutral']}{extra}", None)) + if ctx.public and f.known: + wf = sorted(x for x in f.files if x.startswith(".github/workflows/") and x.endswith((".yml", ".yaml"))) + pick = [x for x in wf if "test" in x.rsplit("/", 1)[-1]] or [x for x in wf if "ci" in x.rsplit("/", 1)[-1]] + if pick: + name = pick[0].rsplit("/", 1)[-1] + base = f"https://github.com/{ctx.brand.resolver().canonical(ctx.owner)}/{ctx.repo}/actions/workflows/{name}" + out.append(("Tests" if "test" in name else "CI", f"{base}/badge.svg", base)) + return out[:4] + + +def _local(ctx, url: str) -> str: + """--badges local: render a shields.io badge URL to docs/images/badges/.svg (outlined text, no server).""" + if not getattr(ctx, "local_badges", False) or not url.startswith(SHIELDS): + return url + from urllib.parse import unquote + path = url[len(SHIELDS):].split("?", 1)[0] + parts = re.split(r"(? str: + lines = [] + for alt, url, link in badges(ctx): + url = _local(ctx, url) + img = f"![{alt}]({url})" + lines.append(f"[{img}]({link})" if link else img) + return "\n".join(lines) + + +def badges_html(ctx) -> str: + parts = [] + for alt, url, link in badges(ctx): + url = _local(ctx, url) + img = f'{alt}' + parts.append(f' {img}' if link else f" {img}") + return '

\n' + "\n".join(parts) + "\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 "") + or (existing or "").strip() == template_badge_body(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 "") + replaced_digest = body_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 legacy_profile_badges(brand, body: str) -> 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 = body.splitlines() + if not 1 <= len(lines) <= 3: + return False + kinds = [] + colors = brand.raw["badges"] + 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) not in (".github", ci.group(1)): + 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 + urls = {f"{SHIELDS}{label}-{shield(message)}-{color}{suffix}" for suffix in suffixes} + 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) == body_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".""" + return bool(ctx.brand.readme_opts.get("nav_first")) + + +def lead(ctx: RepoContext, banner: str, nav: str) -> list[str]: + """The banner and nav blocks in the configured order.""" + return [nav, banner] if nav_first(ctx) else [banner, nav] + + +def inherited_tagline(ctx: RepoContext, body: str, text: str) -> bool: + """True when the tagline block `body` of README `text` came from the template this repository was generated from + (never another repository's line is kept): it is one of meta template_taglines (the template's tagline when this + repository was generated, and today); only when those are unknown, an untouched (pristine) template body stands in + as the proof. The template repository's own tagline is never inherited.""" + if ctx.meta.get("is_template"): + return False + known = [x.strip() for x in ctx.meta.get("template_taglines") or [] if isinstance(x, str) and x.strip()] + return body.strip() in known if known else bootstrap_state(text) == "pristine" + + +def tagline_body(ctx: RepoContext, existing: str | None = None, text: str = "") -> str: + """The tagline block body, decided in this one place for header(), bootstrap(), upgrade() and header_only(). + R2: the tagline is the description. Without one (logged needs-input:description) the block keeps `existing`, the + body it holds now in README `text`, unless that is inherited (guardrail 6: improve, never erase); nothing to keep + gives an empty block, never "****".""" + desc = (ctx.meta.get("description") or "").strip() + if desc: + return f'

{desc}

' if ctx.det["variant"] == "F" else f"**{desc}**" + ctx.log("needs-input:description") + if existing is None or not existing.strip() or inherited_tagline(ctx, existing, text): + return "" + return existing + + +def header(ctx: RepoContext, keep_banner: str | None = None, title: str | None = None, + 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", 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", badge_body(ctx, existing_badges)), tag, "
", "---"] + return "\n\n".join(p for p in parts if p) + + +# ------------------------------------------------------------------------------------------------ +# License (§7.6) and section facts +# ------------------------------------------------------------------------------------------------ +def license_section(ctx: RepoContext) -> tuple[str, str] | None: + rv = standard("readme-variants")["license_rows"] + lic, f = ctx.meta.get("license"), ctx.facts + third = f.known and (ctx.repo.endswith("-fork") or any(x.startswith(("vendor/", "third_party/")) for x in f.files)) + if third: + ctx.log("needs-input:upstream") + return None + # visibility first: a private repository is proprietary in every org, Security included (LICENSE-POLICY.md) + if ctx.public and (ctx.det["variant"] == "F" or (ctx.org.get("license_chip") and lic and lic.upper().startswith("AGPL"))): + return "Licensing", rv["security"] + if not ctx.public: + if ctx.same_owner("TouchedByAnAgent"): + return "License", rv["private_hold"] + year = (ctx.meta.get("created_at") or "")[:4] + if not year: + ctx.log("needs-input:license-year") + return None + private = rv["private"].replace("", year) + if ctx.org.get("family") not in (None, "completetech"): + private = private.replace("CompleteTech LLC", ctx.org.get("display_name") or ctx.owner) + return "License", private + if lic == "MIT": + if f.exists("BRAND_ASSETS.md"): + return "License", rv["skills_mit"] + if ctx.tier.startswith("T2") or ctx.org.get("family") not in (None, "completetech"): + return "License", rv["t2_mit"].replace("", ctx.org.get("display_name") or ctx.owner) + return "License", rv["t1_mit"] + if lic and lic not in ("NOASSERTION",): + brand = ctx.company + return "License", rv["other"].replace("", lic).replace("", "LICENSE").replace("", brand) + ctx.log("needs-input:license") + return None + + +def section_facts(ctx: RepoContext, h2: str) -> str | None: + """Body for a scaffolded section, only from repo facts; None means omit + needs-input.""" + f = ctx.facts + if h2 == "Workflow Diagram" and f.read("assets/diagrams/workflow.mmd"): + return ("Source: [assets/diagrams/workflow.mmd](assets/diagrams/workflow.mmd).\n\n```mermaid\n" + + f.read("assets/diagrams/workflow.mmd").strip() + "\n```") + if h2 == "Install": + cmds = [] + if f.exists("requirements.txt"): + cmds.append("pip install -r requirements.txt") + elif f.exists("pyproject.toml"): + cmds.append("pip install .") + if f.exists("package.json"): + cmds.append("npm install") + if cmds: + return "```bash\n" + "\n".join(cmds) + "\n```" + if h2 == "Quick Start" and f.known: + scripts = list(((f.pyproject().get("project") or {}).get("scripts") or {}).keys()) + pj = f.package_json() + if not scripts and isinstance(pj.get("bin"), dict): + scripts = list(pj["bin"].keys()) + if scripts: + return "```bash\n" + "\n".join(f"{s} --help" for s in scripts[:3]) + "\n```" + if h2 == "Live Site": + hp = ctx.meta.get("homepage") or "" + if hp and ctx.meta.get("homepage_status") == 200: + host = re.sub(r"^https?://", "", hp).rstrip("/") + return f"**[{host}]({hp})**" + if h2 == "Play": + hp = ctx.meta.get("homepage") or "" + if hp and ctx.meta.get("homepage_status") == 200: + return f"**[{re.sub(r'^https?://', '', hp).rstrip('/')}]({hp})**" + if h2 == "Ownership": + if ctx.org.get("family") not in (None, "completetech"): + return f"Organization: {ctx.owner}" + return f"Owner: @CompleteDotTech · Organization: {ctx.owner}" + if h2 == "Files" and ctx.det["variant"] == "J": + rows = [("profile/README.md", f"Organization profile shown at github.com/{ctx.owner}")] + rows = [r for r in rows if f.exists(r[0])] + if f.exists("labels.yml"): + rows.append(("labels.yml", "Label standard (ct-std-v1)")) + if rows: + return "| File | Purpose |\n|---|---|\n" + "\n".join(f"| `{a}` | {b} |" for a, b in rows) + return None + + +def footer_wanted(ctx) -> bool: + """Decision 18 footer wanted (RepoContext.footer_status): the watching org's `footer: false` exempts only its + forks, so its `.github` gets the footer and its other non-fork repositories are held.""" + why = ctx.footer_status() + if why is None: + return True + ctx.log("exempt:footer" if why.startswith("exempt") else why if why.startswith("hold:") else "hold") + return False + + +def callout(ctx) -> str | None: + if ctx.det["variant"] == "I" or (not ctx.public and ctx.det["variant"] in "ABCDE"): + who = "Touched By An Agent" if ctx.same_owner("TouchedByAnAgent") else ctx.company + return f"> [!IMPORTANT]\n> Internal {who} repository. Not for distribution." + return None + + +# ------------------------------------------------------------------------------------------------ +# Modes +# ------------------------------------------------------------------------------------------------ +def scaffold(ctx: RepoContext, existing: str = "") -> str: + variant = ctx.det["variant"] + order = standard("readme-variants")["variants"].get(variant, []) + parts = [header(ctx)] + co = callout(ctx) + if co: + parts.append(co) + keep = [ln for ln in existing.splitlines() if ln.strip() and not ln.startswith("# ")] + for h2, req in order: + if h2 in ("License", "Licensing"): + continue + body = section_facts(ctx, h2) + if h2 == "About" and keep: + body = "\n".join(keep) + if body: + parts.append(f"## {h2}\n\n{body}") + elif req == "R": + ctx.log(f"needs-input:{variant}:{h2}") + lic = license_section(ctx) + if lic: + parts.append(f"## {lic[0]}\n\n{lic[1]}") + if footer_wanted(ctx): + parts.append(footermod.block(ctx.brand)) + return "\n\n".join(parts) + "\n" + + +HEADER_LINE = re.compile( + r"^\s*(\*\*[^*]+\*\*\s*·.*|#\s+.+|!\[[^\]]*\]\(https://img\.shields\.io[^)]*\)\s*|\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*" + r"|]*(shields\.io|badge\.svg|logo)[^>]*>\s*|]*>\s*]*(shields\.io|badge\.svg)[^>]*>\s*\s*" + r"||]*>|]*alt=\"[^\"]*logo[^\"]*\"[^>]*>|

|

|
|
|---)$") + + +def _strip_markers(text: str, keep: dict[str, str] | None = None) -> tuple[str, dict]: + """(text without its marker blocks, {block: body}); a marker example inside fenced or inline code stays. A block + named in `keep` stays where it is, rewrapped around the body `keep` gives it.""" + blocks, out, pos = {}, [], 0 + for m in find_blocks(MARK, text): + blocks[m.group(1)] = m.group(2) + out.append(text[pos:m.start()]) + if keep and m.group(1) in keep: + out.append(wrap(m.group(1), keep[m.group(1)])) + pos = m.end() + return "".join(out) + text[pos:], blocks + + +def _leading_banner(pre: str) -> bool: + """True when the README preamble opens with a third-party banner image (not ours, not a logo or badge).""" + first_img = re.search(r"<(img|picture)\b[^>]*>|!\[[^\]]*\]\([^)]*\)", pre) + return bool(first_img and "docs/images/banner.png" not in first_img.group(0) + and not re.search(r"logo", first_img.group(0), re.I) + and "shields.io" not in first_img.group(0) and "badge.svg" not in first_img.group(0) + and not pre[:first_img.start()].strip().replace("

", "").strip()) + + +def fork_callout(ctx: RepoContext) -> str | None: + """§7.4 G callout; None (logged fork-parent-missing) when the parent is unknown. A fork with commits ahead of its + parent gets the full note with the compare link; a 0-ahead fork (§11.1 F1x) gets the short form, which claims no + changes. When `ahead_by` is not in the metadata the short form is used too (never claim changes nobody counted) + and `needs-input:ahead-by` is logged.""" + parent = ctx.meta.get("parent") or {} + full = parent.get("full_name") if isinstance(parent, dict) else None + if not full: + ctx.log("fork-parent-missing") + return None + ahead = ctx.meta.get("ahead_by") + if not ahead: + if ahead is None: + ctx.log("needs-input:ahead-by") + return (f"> [!NOTE]\n> **Fork of [{full}](https://github.com/{full})**, kept by {ctx.company}. " + "The upstream README follows unchanged; the upstream license applies.") + pbranch = parent.get("default_branch") or "main" + branch = ctx.meta.get("default_branch") or "main" + powner = full.split("/")[0] + return (f"> [!NOTE]\n> **Fork of [{full}](https://github.com/{full})** with {ctx.company} changes. Our changes: " + f"[compare with upstream](https://github.com/{ctx.owner}/{ctx.repo}/compare/{powner}:{pbranch}...{branch}). " + "The upstream README follows unchanged; the upstream license applies.") + + +BANNER_BLOCK = re.compile(re.escape(start("banner")) + r".*?" + re.escape(end("banner")) + r"\n*", re.S) + + +def callout_only(ctx: RepoContext, text: str) -> str: + """Variants G and H (§7.4): the hero banner block, then the callout (unless one is already there), then the body + unchanged, then the decision 18 footer. Nothing else changes. Watching-org forks never reach here (`readme` + exempts them). Review q27 "Full CompleteTech treatment" (brand.yaml readme.fork_readme: full) means the fork + follows its §5.4 variant instead: pass that variant with `readme --variant`.""" + if ctx.det["variant"] == "G" and ctx.brand.readme_opts.get("fork_readme") == "full": + ctx.log("needs-input:fork-variant (q27 full treatment: pass readme --variant for the §5.4 variant)") + body = text + lead_banner = "" + m = BANNER_BLOCK.match(text.lstrip("\n")) + if m: # our banner block is already on top (a re-run): keep it, look for the callout under it + body = text.lstrip("\n")[m.end():] + first = next((ln for ln in body.splitlines() if ln.strip()), "") + out = body + if not first.lstrip().startswith(">"): + if ctx.det["variant"] == "G": + co = fork_callout(ctx) + else: + co = None # the H callout needs the archive date and reason from the registry + ctx.log("needs-input:archive-callout") + if co: + out = co + "\n\n" + body.lstrip("\n") + if ctx.art_status()["banner"] is None: + lead_banner = wrap("banner", banner_html(ctx)) + "\n\n" # decision 13: the hero banner opens G and H too + elif m: + lead_banner = text.lstrip("\n")[:m.end()].rstrip("\n") + "\n\n" + out = lead_banner + out.lstrip("\n") + if footer_wanted(ctx) and footermod.check(ctx.brand, out)[0] == "fail:missing": + out = out.rstrip("\n") + "\n\n" + footermod.block(ctx.brand) + "\n" + return out + + +def preserved(ctx: RepoContext, text: str) -> bool: + """§7.0 preserve list, decided the same way by `readme` (before) and `lint readme` (after the upgrade): + the org's readme_preserve list, any README over 15,000 bytes, and every T2 product README that opens with its + own banner image. Showcase marker blocks (our nav line) are ignored, so the answer survives an upgrade.""" + if ctx.repo in (ctx.org.get("readme_preserve") or []) or len(text.encode()) > 15000: + return True + if ctx.tier != "T2": + return False + body, _ = _strip_markers(text.replace("\r\n", "\n")) + pre, _ = split_sections(body) + return _leading_banner(pre) + + +CENTRED_OPEN = re.compile(r'(?m)^[ \t]*<(p|div) align="center">') + + +def is_footer_like(block: str) -> bool: + """A centred block that credits the company with a complete.tech link: the decision 18 footer, exact or drifted.""" + return bool(re.match(r'\s*<(p|div) align="center">', block)) and "built by" in block.lower() \ + and "complete.tech" in block.lower() + + +FOOTER_TAG = re.compile(r"]*)>", re.I) + + +FOOTER_ATTR = re.compile(r"""\b(src|srcset|alt)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", re.I) + + +def _footer_logo_url(url: str, brand) -> bool: + """`url` is one of the decision 18 footer's own logo files (brand footer `logo_light_url` or `logo_dark_url`), + as configured or drifted only in scheme, branch, query or the github.com blob/raw form of the same file.""" + url = url.strip().split("#", 1)[0].split("?", 1)[0] + for key in ("logo_light_url", "logo_dark_url"): + want = brand.raw["footer"][key] + if url == want: + return True + m = re.match(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/[^/]+/(.+)$", want) + if not m: + continue + own, repo, path = (re.escape(x) for x in m.groups()) + if re.fullmatch(rf"(?i:https?://raw\.githubusercontent\.com/{own}/{repo}/[^/]+/){path}", url) or \ + re.fullmatch(rf"(?i:https?://github\.com/{own}/{repo}/(?:blob|raw)/[^/]+/){path}", url): + return True + return False + + +def is_footer_copy(block: str, brand) -> bool: + """Every line of `block` is a line of the decision 18 footer or a drifted copy of one: only centring, link, logo, + `
`/`` markup, with complete.tech links, the footer's own logo image (its src or srcset is the brand footer + logo URL or a drifted form of it, or its alt is exactly the company) and as text only the credit line ("Built by + ", optionally with the suffix). A sponsor or partner image (whatever its file name), a thanks line or + any other authored text makes it not a footer copy, so it is never removed.""" + import html + f, company = brand.raw["footer"], brand.strings["company"] + credit = {f"{f['line_prefix']} {company}", f"{f['line_prefix']} {company} {f['line_suffix']}"} + + def tag_ok(m: re.Match) -> bool: + name, attrs = m.group(1).lower(), m.group(2) + if m.group(0).startswith(" tuple[str, list[str]]: + """(text, removed lines) with a trailing old footer (an unmarked or drifted decision 18 footer: every line a + footer line, `is_footer_copy`) taken off the end, so the marked footer replaces it instead of following it. Any + other block, a centred credits block with authored lines included, is left as it is (lint reports a second + footer-like block as `fail:duplicate`).""" + body = text.rstrip() + opens = [m for m in CENTRED_OPEN.finditer(masked(body))] + if not opens: + return text, [] + m = opens[-1] + tail = body[m.start():] + if not tail.rstrip().endswith(f"") or not is_footer_like(tail) or not is_footer_copy(tail, brand): + return text, [] + return body[:m.start()].rstrip() + "\n", [ln for ln in tail.splitlines() if ln.strip()] + + +def upgrade(ctx: RepoContext, text: str) -> tuple[str, list[str]]: + """Return (new text, header lines removed). Raises nothing; callers run the shrink checks.""" + variant = ctx.det["variant"] + rv = standard("readme-variants") + if variant in ("G", "H"): + return callout_only(ctx, normalize_text(text)), [] + text = normalize_text(text) + body, blocks = _strip_markers(text) + want_footer = footer_wanted(ctx) + removed: list[str] = [] + if want_footer: # an unmarked or drifted footer at the end is replaced by the marked one, never kept beside it + body, old_footer = drop_old_footer(body, ctx.brand) + removed += old_footer + # preserve list / existing third-party banner + preserve = ctx.repo in (ctx.org.get("readme_preserve") or []) or len(text.encode()) > 15000 + pre, sections = split_sections(body) + keep_pre: list[str] = [] + lines = pre.splitlines() + if _leading_banner(pre) and (ctx.tier == "T2" or preserve): + preserve = True + # q57 `pres`: the hero goes above the README's own banner (kept directly under it), or nothing is added + 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 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) + 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) # its lines are already in `removed` + pre, sections = split_sections(body) + lines = pre.splitlines() + # an unmarked bold line opening the preamble is an old tagline. With a description it is replaced; without one it + # is carried into an empty tagline block (header rule), and a second one stays as authored text: never erased + carry: list[str] = [] + # the pristine-body fallback alone would decide the tagline block (no known template taglines): a bare bold line + # the template cannot have written must not be moved into a block that later runs would empty + fallback_only = (not ctx.meta.get("is_template") and not [x for x in ctx.meta.get("template_taglines") or [] + if isinstance(x, str) and x.strip()] + and bootstrap_state(text) == "pristine") + + def is_header_line(ln: str) -> bool: + s_ = ln.strip() + if HEADER_LINE.match(ln) or "docs/images/banner.png" in ln: + return True + return bool(desc) and s_ in (f"**{desc}**", f"

{desc}

", f"{desc}") + + # group the preamble into units: centred HTML blocks and blocks are classified as a whole + units: list[list[str]] = [] + i = 0 + while i < len(lines): + s_ = lines[i].strip() + closer = {"

": "

", "
": "
", "": ""}.get(s_) + if closer: + j = i + 1 + while j < len(lines) and lines[j].strip() != closer: + j += 1 + units.append(lines[i:j + 1]) + i = j + 1 + continue + units.append([lines[i]]) + i += 1 + for unit in units: + if len(unit) == 1: + ln = unit[0] + s_ = ln.strip() + bold = re.fullmatch(r"\*\*[^*]+\*\*", s_) is not None and not "".join(keep_pre).strip() + is_hdr = is_header_line(ln) or bold + if preserve: # additive only: just an old nav line is replaced + is_hdr = s_.startswith("**") and " · " in s_ and not "".join(keep_pre).strip() + elif bold and not is_header_line(ln) and not desc: + if carry or (old_tag or "").strip() or fallback_only: + # the tagline block already has a line, or (an untouched template copy whose template taglines + # are unknown) the block rule would empty any line moved into it: this one stays authored text + keep_pre.append(ln) + continue + carry.append(ln) + (removed if is_hdr else keep_pre).append(ln) + continue + inner = unit[1:-1] + if all(not x.strip() or is_header_line(x) or x.strip() in ("", "") or x.strip().startswith("" and not preserve: + # old hero wrapper: drop the wrapper and header lines, keep anything else verbatim + for x in unit: + (removed if (x is unit[0] or x is unit[-1] or is_header_line(x)) else keep_pre).append(x) + else: + keep_pre.extend(unit) + intro = "\n".join(keep_pre).strip() + # sections: renames, mapping, Notes demotion, License last + renames = rv["renames"] + order = [h for h, _ in rv["variants"].get(variant, [])] + low_order = {h.lower(): h for h in order} + placed: dict[str, str] = {} + notes: list[str] = [] + license_body = None + existing_notes = None + for h, b in sections: + clean = EMOJI.sub("", h).strip() + name = renames.get(clean.lower(), clean) + if name.lower() in ("license", "licensing", "licence"): + license_body = (name if name != "Licence" else "License", b) + continue + if name.lower() == "notes": + existing_notes = b.strip("\n") + continue + if name.lower() in low_order and low_order[name.lower()] not in placed: + placed[low_order[name.lower()]] = b.strip("\n") + else: + if variant == "D" and name.lower() == "status" and "Findings" not in placed: + placed["Findings"] = b.strip("\n") + continue + notes.append(f"### {clean}\n{b.rstrip()}" if b.strip() else f"### {clean}") + out = [] + if preserve: + # §7.0 preserve list: additive only. The hero banner block goes on top (q57 `pres` default, "Banner above the + # existing content"; none when the user answers "Leave them unchanged"), with the nav line in the §7.1 order; + # the README's own banner and every section stay verbatim and in place; add License if missing, then footer. + if ctx.preserve_unchanged() or ctx.art_status()["banner"] is not None: + out.append(wrap("nav", nav_line(ctx))) + else: + out += lead(ctx, wrap("banner", banner_html(ctx)), wrap("nav", nav_line(ctx))) + if intro: + out.append(intro) + for h, b in sections: + out.append(f"## {h}\n{b.rstrip()}" if b.strip() else f"## {h}") + if not any(h.strip().lower() in ("license", "licensing", "licence") for h, _ in sections): + lic = license_section(ctx) + if lic: + out.append(f"## {lic[0]}\n\n{lic[1]}") + if want_footer: + out.append(footermod.block(ctx.brand)) + return "\n\n".join(x for x in out if x) + "\n", removed + else: + if carry: # an unmarked tagline with no description: it moves into the block (the move is not a lost line) + old_tag = carry[0].strip() + removed.remove(carry[0]) + new_tag = tagline_body(ctx, old_tag, text) + if old_tag and old_tag.strip() and not new_tag: + # 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, existing_badges=blocks.get("badges"))) + co = callout(ctx) + if co and co not in intro: + out.append(co) + if intro: + if variant == "A" or "About" in placed or "About" not in order: + out.append(intro) + else: + placed["About"] = intro + ("\n\n" + placed["About"] if placed.get("About") else "") + for h in order: + if h in ("License", "Licensing"): + continue + if h in placed: + out.append(f"## {h}\n\n{placed[h].strip()}" if placed[h].strip() else f"## {h}") + elif dict(rv["variants"][variant]).get(h) == "R" and not preserve: + body = section_facts(ctx, h) + if body: + out.append(f"## {h}\n\n{body}") + else: + ctx.log(f"needs-input:{variant}:{h}") + if existing_notes or notes: + nb = "\n\n".join(x for x in ([existing_notes] if existing_notes else []) + notes) + out.append(f"## Notes\n\n{nb}") + if license_body: + out.append(f"## {license_body[0]}\n\n{license_body[1].strip()}") + else: + lic = license_section(ctx) + if lic: + out.append(f"## {lic[0]}\n\n{lic[1]}") + if want_footer: + out.append(footermod.block(ctx.brand)) + for b in ("template-body", "checklist"): # inserted in reverse: the checklist stays above the body + if b in blocks: + out.insert(1 if not preserve else 1, wrap(b, blocks[b])) + return "\n\n".join(x for x in out if x) + "\n", removed + + +def header_only(ctx: RepoContext, text: str) -> str: + """Replace only existing marker blocks (what the Action runs).""" + text = normalize_text(text) + desc = (ctx.meta.get("description") or "").strip() + gen = {"nav": nav_line(ctx), "banner": banner_html(ctx), "footer": footermod.markup(ctx.brand)} + 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") + + 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) + return "" + if b == "tagline": # tagline_body: the description's line, else the block as written unless inherited + new = tagline_body(ctx, body, text) + return m.group(0) if not desc and new == body else wrap(b, re.sub(r"\n{3,}", "\n\n", new)) + if b in gen: + return wrap(b, re.sub(r"\n{3,}", "\n\n", gen[b])) + return m.group(0) + return license_block(ctx, reorder_lead(ctx, _sub_blocks(text, sub))) + + +def _seam(left: str, right: str) -> str: + """Join two pieces where a marker block meets authored text: the blank lines on both sides of the join count + together and collapse to at most one blank line. Nothing else in either piece changes.""" + a, b = left.rstrip("\n"), right.lstrip("\n") + n = min(len(left) - len(a) + len(right) - len(b), 2) + return a + "\n" * n + b + + +def _sub_blocks(text: str, sub) -> str: + """MARK.sub(sub, text), except that authored text between blocks comes back byte-identical (fenced code + included, and a marker example inside code is never treated as a block); blank-line runs are tidied only at the + seams around each block (a removed block leaves one blank line).""" + out, pos = "", 0 + for m in find_blocks(MARK, text): + out = text[:m.start()] if not pos else _seam(out, text[pos:m.start()]) + out = _seam(out, sub(m)) + pos = m.end() + return _seam(out, text[pos:]) if pos else text + + +LEAD_PAIR = re.compile(r"\A(\s*)(" + re.escape(start("nav")) + r".*?" + re.escape(end("nav")) + r")(\s*)(" + + re.escape(start("banner")) + r".*?" + re.escape(end("banner")) + r")", re.S) +LEAD_PAIR_REV = re.compile(r"\A(\s*)(" + re.escape(start("banner")) + r".*?" + re.escape(end("banner")) + r")(\s*)(" + + re.escape(start("nav")) + r".*?" + re.escape(end("nav")) + r")", re.S) + + +def reorder_lead(ctx: RepoContext, text: str) -> str: + """Swap a README's leading nav and banner marker blocks into the configured §7.1 order (q57 `nav`), so READMEs + written before the order changed follow it on the next header refresh. Only the two adjacent top blocks move.""" + pat = LEAD_PAIR_REV if nav_first(ctx) else LEAD_PAIR + return pat.sub(lambda m: m.group(1) + m.group(4) + m.group(3) + m.group(2), text, count=1) + + +LICENSE_MARK = "" + + +def license_block(ctx: RepoContext, text: str) -> str: + """Rewrite the paragraph after `` (and its H2) from license_section(), with or without a + LICENSE file: a private repository reads "Proprietary...", a Security repository gets "## Licensing". When the + section cannot be derived (needs-input), the existing text stays.""" + i = masked(text).find(LICENSE_MARK) # the first mark outside code (a documented example is left alone) + if i < 0: + return text + lic = license_section(ctx) + if not lic: + return text + m = re.compile(re.escape(LICENSE_MARK) + r"\n(.*?)(\n\n|\n?$)", re.S).match(text, i) + if m: + text = text[:i] + LICENSE_MARK + "\n" + lic[1] + m.group(2) + text[m.end():] + h = re.search(r"(?m)^## (?:License|Licensing|Licence)[ \t]*\n(\n?)\Z", text[:i]) + return text[:h.start()] + f"## {lic[0]}\n" + h.group(1) + text[i:] if h else text + + +TEMPLATE_H1 = "CompleteTech Repo Template" +PRISTINE = re.compile(r"\n?") +TEMPLATE_BODY = re.compile(re.escape(start("template-body")) + r"\n?(.*?)\n?" + re.escape(end("template-body")), re.S) + + +def body_sha(body: str) -> str: + import hashlib + return hashlib.sha256(body.replace("\r\n", "\n").strip().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}") + + +def bootstrap_state(text: str) -> str: + """'pristine' (the block is byte-for-byte what was generated), 'edited' (a marker without a matching hash: a person + wrote in the block) or 'none' (no marker: the README is the repository's own).""" + found = find_blocks(TEMPLATE_BODY, normalize_text(text)) + if not found: + return "none" + m = found[0] + pm = PRISTINE.search(m.group(1)) + if not pm: + return "none" + rest = PRISTINE.sub("", m.group(1), count=1) + return "pristine" if pm.group(1) and pm.group(1) == body_sha(rest) else "edited" + + +def drop_pristine(text: str) -> str: + """`text` without the pristine marker of the first template-body block outside code; a marker shown as an example + (inline or fenced code) stays byte-identical.""" + found = find_blocks(TEMPLATE_BODY, text) + if not found: + return text + tb = found[0] + return text[:tb.start(1)] + PRISTINE.sub("", tb.group(1), count=1) + text[tb.end(1):] + + +def _header_region(text: str) -> re.Match | None: + """From the top of the README to the rule after the tagline block (the §7.2 header).""" + return re.match(r"\A.*?" + re.escape(end("tagline")) + r"\n(?:.*?\n)??---\n", text, re.S) + + +def bootstrap(ctx: RepoContext, text: str) -> str: + """Fill a README that still carries a generated (pristine) template body: the README variant's sections from + repository facts, the variant's header (Variant F has no H1) and the license section. A pristine body is + regenerated on later runs too, so a repository typed `workspace` on its creation commit is re-bootstrapped once + its code (or `type:` in .github/showcase.yml) gives the real type. Any human edit inside the block changes its + hash, and from then on the block is the repository's own: never overwritten (callers drop the marker).""" + text = normalize_text(text) + if ctx.meta.get("is_template") or bootstrap_state(text) != "pristine": + return text + variant = ctx.det["variant"] + order = standard("readme-variants")["variants"].get(variant, []) + secs = [] + for h2, req in order: + if h2 in ("License", "Licensing"): + continue + body = section_facts(ctx, h2) + if body: + secs.append(f"## {h2}\n\n{body}") + elif req == "R": + secs.append(f"## {h2}\n\n") + ctx.log(f"needs-input:{variant}:{h2}") + new = pristine_block("\n\n".join(secs)) + tb = find_blocks(TEMPLATE_BODY, text)[0] # bootstrap_state found it outside code + text = text[:tb.start()] + new + text[tb.end():] + # the header: rebuilt for this variant unless a person retitled the README + hm = _header_region(text) + if hm: + h1 = re.search(r"(?m)^# (.+)$", hm.group(0)) + auto = cased_title(ctx.repo, ctx.meta.get("description") or "") + 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 + 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) + + +def bootstrap_contributing(ctx: RepoContext, text: str) -> str: + """Point the template's own `.github` guide links at this org. Only links to the template owner's `.github` (any + of its logins) change; a link to another org's or an upstream's guide or security policy stays as written.""" + from .intake import TEMPLATE_REPO + tpl = TEMPLATE_REPO.split("/")[0] + try: + logins = {x.lower() for x in ctx.brand.resolver().aliases(tpl)} + except AttributeError: # a context without a brand: the template login alone + logins = {tpl.lower()} + + def sub(m): + return m.group(1) + ctx.owner + m.group(3) if m.group(2).lower() in logins else m.group(0) + return re.sub(r"(https://github\.com/)([A-Za-z0-9-]+)(/\.github/blob/main/(?:CONTRIBUTING|SECURITY)\.md)", sub, + text).replace(f"[{tpl} contributing guide]", f"[{ctx.owner} contributing guide]") + + +# ------------------------------------------------------------------------------------------------ +# Safety checks +# ------------------------------------------------------------------------------------------------ +def nonws(s: str) -> int: + return len(re.sub(r"\s", "", s)) + + +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): + owned = generated_badges(m.group(2)) or body_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("` / `:end -->` markers so reruns replace only -those blocks. Content is never invented (§1.5 rule 2) and never lost (§7.1 restructuring, multiset check). -""" -from __future__ import annotations - -import re -from collections import Counter -from pathlib import Path -from urllib.parse import quote - -from . import footer as footermod -from .context import RepoContext -from .detect import standard -from .mdcode import code_spans, find_blocks, masked # noqa: F401 (code_spans: re-exported for callers) - -MARK = re.compile(r"\n?(.*?)\n?", re.S) -EMOJI = re.compile("[\U0001F000-\U0001FAFF☀-➿⬀-⯿️‍]") -SHIELDS = "https://img.shields.io/badge/" -GENERATED_BADGES = re.compile(r"\n(.*)", re.S) -BOM = "\ufeff" - - -def normalize_text(text: str) -> str: - """README text as the tools read it: LF line endings, no leading UTF-8 byte order mark (a BOM would hide the H1 - from every `^#` match).""" - text = text.replace("\r\n", "\n") - return text[1:] if text.startswith(BOM) else text - - -def start(b): - return f"" - - -def end(b): - return f"" - - -def wrap(b, body): - return f"{start(b)}\n{body}\n{end(b)}" - - -# ------------------------------------------------------------------------------------------------ -# Small helpers -# ------------------------------------------------------------------------------------------------ -def shield(s: str) -> str: - """A shields.io path segment: `-`/`_` doubled, then percent-encoded, so `#`, `?`, `/` and `%` (C#, F#) stay in - the segment instead of ending the path.""" - return quote(s.replace("-", "--").replace("_", "__"), safe="()!*'.,;:@&=$~") - - -def title_case(s: str) -> str: - rv = standard("readme-variants") - small, keep = set(rv["title_small_words"]), {k.lower(): k for k in rv["title_keep_case"]} - out = [] - for i, w in enumerate(re.split(r"(\s+)", s)): - if not w.strip(): - out.append(w) - continue - low = w.lower() - if low in keep: - out.append(keep[low]) - elif i > 0 and low in small: - out.append(low) - else: - out.append(w[:1].upper() + w[1:]) - return "".join(out) - - -def slug_title(slug: str) -> str: - """Title from a slug; CVE ids keep their form ("acme-cve-2026-12345-check" -> "Acme CVE-2026-12345 Check").""" - cves: list[str] = [] - - def keep(m): - cves.append(m.group(0).upper()) - return f"\x00{len(cves) - 1}\x00" - s = re.sub(r"cve-\d{4}-\d+", keep, slug, flags=re.I) - t = title_case(s.replace("-", " ").replace("_", " ")) - return re.sub(r"\x00(\d+)\x00", lambda m: cves[int(m.group(1))], t) - - -def cased_title(slug: str, description: str = "") -> str: - """slug_title, with each word spelled the way the repository's own description spells it when that spelling is - a deliberate one (mixed case, such as PHP2Shell or OpenClaw), so a brand name is never re-cased to "Php2shell".""" - spelled = {} - for tok in re.findall(r"[A-Za-z0-9][A-Za-z0-9+#]*", description or ""): - if tok != tok.lower() and tok != tok[:1].upper() + tok[1:].lower(): - spelled.setdefault(tok.lower(), tok) - return re.sub(r"[A-Za-z0-9][A-Za-z0-9+#]*", lambda m: spelled.get(m.group(0).lower(), m.group(0)), slug_title(slug)) - - -def product_name(ctx: RepoContext) -> str: - return ctx.facts.h1() or cased_title(ctx.repo, ctx.meta.get("description") or "") - - -def split_sections(text: str) -> tuple[str, list[tuple[str, str]]]: - """(preamble, [(heading text, body)]) split at H2 outside code fences.""" - pre, sections, cur_h, buf = [], [], None, [] - fence = None - for line in text.splitlines(): - m = re.match(r"^(```+|~~~+)", line) - if m: - fence = None if fence and line.startswith(fence) else (fence or m.group(1)) - if fence is None and re.match(r"^## (?!#)", line): - if cur_h is None: - pre = buf - else: - sections.append((cur_h, "\n".join(buf))) - cur_h, buf = line[3:].strip(), [] - continue - buf.append(line) - if cur_h is None: - return "\n".join(buf), [] - sections.append((cur_h, "\n".join(buf))) - return "\n".join(pre), sections - - -# ------------------------------------------------------------------------------------------------ -# Header pieces -# ------------------------------------------------------------------------------------------------ -def nav_line(ctx: RepoContext) -> str: - org, owner = ctx.org, ctx.brand.resolver().canonical(ctx.owner) # links name the org's current login (CQ-03) - if ctx.same_owner("CompleteTech-Skills") and ctx.det["type"] == "agent-skills": - line = ("**CompleteTech LLC Skills** · [Start here](ONBOARDING.md) · [Agent instructions](SKILL.md) · " - "[Skill library](https://github.com/CompleteTech-Skills/agentic-services-orchestrator-skill/blob/main/" - "references/skill-family.md) · [Contributing](CONTRIBUTING.md)") - return line + (" · Internal" if not ctx.public else "") - f = ctx.facts - contrib = "CONTRIBUTING.md" if f.exists("CONTRIBUTING.md") else f"https://github.com/{owner}/.github/blob/main/CONTRIBUTING.md" - sec = "SECURITY.md" if f.exists("SECURITY.md") else f"https://github.com/{owner}/.github/blob/main/SECURITY.md" - if ctx.det["variant"] == "F": - items = [f"**{org.get('nav_name') or org.get('display_name')}**", f"[All checkers](https://github.com/{owner})"] - if f.exists("docs/REFERENCE.md"): - items.append("[Reference](docs/REFERENCE.md)") - if f.exists("LICENSING.md"): - items.append("[Licensing](LICENSING.md)") - items.append(f"[Security]({sec})") - return " · ".join(items) - items = [f"**{org.get('nav_name') or org.get('display_name') or owner}**", f"[All repositories](https://github.com/{owner})"] - hp = ctx.meta.get("homepage") or "" - if ctx.det["variant"] == "B" and hp and ctx.meta.get("homepage_status") == 200: - items.append(f"[Live site]({hp})") - if ctx.det["variant"] == "E" and hp and ctx.meta.get("homepage_status") == 200: - items.append(f"[Play]({hp})") - docs = [x for x in f.files if x.startswith("docs/") and not re.search(r"\.(png|jpe?g|gif|webp|svg)$|/\.", x)] if f.known else [] - if docs: - items.append("[Docs](docs/)") - items += [f"[Contributing]({contrib})", f"[Security]({sec})"] - line = " · ".join(items) - return line + (" · Internal" if not ctx.public else "") - - -def banner_html(ctx: RepoContext) -> str: - return (f'

\n {ctx.repo} by {ctx.company}\n

') - - -def badges(ctx: RepoContext) -> list[tuple[str, str, str | None]]: - """[(alt, image url, link)] per §7.2: language, type, license, CI. 2-4.""" - b = dict(ctx.brand.raw["badges"]) - for k in ("brand", "accent"): # a family outside CompleteTech carries its own badge colours (brand.yaml badge_brand, badge_accent) - if ctx.style.get(f"badge_{k}"): - b[k] = ctx.style[f"badge_{k}"] - jev = ctx.style["name"] == "jevplay-games" - extra = "?labelColor=1E1E1E" if jev else "" - out = [] - f, lang = ctx.facts, ctx.meta.get("language") - py, node = f.requires_python(), f.engines_node() - if lang == "Python" and py: - out.append((f"Python {py}+", f"{SHIELDS}python-{shield(py + '+')}-{b['accent']}?logo=python&logoColor=white", None)) - elif lang in ("JavaScript", "TypeScript") and node: - out.append((f"Node {node}+", f"{SHIELDS}node-{shield(node + '+')}-{b['accent']}?logo=nodedotjs&logoColor=white", None)) - elif lang: - out.append((f"Language {lang}", f"{SHIELDS}language-{shield(lang)}-{b['accent']}{extra}", None)) - if ctx.det["type"] == "agent-skills": - 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)) - 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)) - elif lic and lic not in ("NOASSERTION", "other"): - if ctx.org.get("license_chip") and lic.upper().startswith("AGPL"): - out.append(("AGPL-3.0 or commercial license", f"{SHIELDS}license-{shield('AGPL-3.0 | commercial')}-{b['neutral']}", - "LICENSING.md" if f.exists("LICENSING.md") else None)) - else: - out.append((f"License {lic}", f"{SHIELDS}license-{shield(lic)}-{b['neutral']}{extra}", None)) - if ctx.public and f.known: - wf = sorted(x for x in f.files if x.startswith(".github/workflows/") and x.endswith((".yml", ".yaml"))) - pick = [x for x in wf if "test" in x.rsplit("/", 1)[-1]] or [x for x in wf if "ci" in x.rsplit("/", 1)[-1]] - if pick: - name = pick[0].rsplit("/", 1)[-1] - base = f"https://github.com/{ctx.brand.resolver().canonical(ctx.owner)}/{ctx.repo}/actions/workflows/{name}" - out.append(("Tests" if "test" in name else "CI", f"{base}/badge.svg", base)) - return out[:4] - - -def _local(ctx, url: str) -> str: - """--badges local: render a shields.io badge URL to docs/images/badges/.svg (outlined text, no server).""" - if not getattr(ctx, "local_badges", False) or not url.startswith(SHIELDS): - return url - from urllib.parse import unquote - path = url[len(SHIELDS):].split("?", 1)[0] - parts = re.split(r"(? str: - lines = [] - for alt, url, link in badges(ctx): - url = _local(ctx, url) - img = f"![{alt}]({url})" - lines.append(f"[{img}]({link})" if link else img) - return "\n".join(lines) - - -def badges_html(ctx) -> str: - parts = [] - for alt, url, link in badges(ctx): - url = _local(ctx, url) - img = f'{alt}' - parts.append(f' {img}' if link else f" {img}") - return '

\n' + "\n".join(parts) + "\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 "") - or (existing or "").strip() == template_badge_body(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 "") - replaced_digest = body_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 legacy_profile_badges(brand, body: str) -> 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 = body.splitlines() - if not 1 <= len(lines) <= 3: - return False - kinds = [] - colors = brand.raw["badges"] - 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) not in (".github", ci.group(1)): - 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 - urls = {f"{SHIELDS}{label}-{shield(message)}-{color}{suffix}" for suffix in suffixes} - 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) == body_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".""" - return bool(ctx.brand.readme_opts.get("nav_first")) - - -def lead(ctx: RepoContext, banner: str, nav: str) -> list[str]: - """The banner and nav blocks in the configured order.""" - return [nav, banner] if nav_first(ctx) else [banner, nav] - - -def inherited_tagline(ctx: RepoContext, body: str, text: str) -> bool: - """True when the tagline block `body` of README `text` came from the template this repository was generated from - (never another repository's line is kept): it is one of meta template_taglines (the template's tagline when this - repository was generated, and today); only when those are unknown, an untouched (pristine) template body stands in - as the proof. The template repository's own tagline is never inherited.""" - if ctx.meta.get("is_template"): - return False - known = [x.strip() for x in ctx.meta.get("template_taglines") or [] if isinstance(x, str) and x.strip()] - return body.strip() in known if known else bootstrap_state(text) == "pristine" - - -def tagline_body(ctx: RepoContext, existing: str | None = None, text: str = "") -> str: - """The tagline block body, decided in this one place for header(), bootstrap(), upgrade() and header_only(). - R2: the tagline is the description. Without one (logged needs-input:description) the block keeps `existing`, the - body it holds now in README `text`, unless that is inherited (guardrail 6: improve, never erase); nothing to keep - gives an empty block, never "****".""" - desc = (ctx.meta.get("description") or "").strip() - if desc: - return f'

{desc}

' if ctx.det["variant"] == "F" else f"**{desc}**" - ctx.log("needs-input:description") - if existing is None or not existing.strip() or inherited_tagline(ctx, existing, text): - return "" - return existing - - -def header(ctx: RepoContext, keep_banner: str | None = None, title: str | None = None, - 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", 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", badge_body(ctx, existing_badges)), tag, "
", "---"] - return "\n\n".join(p for p in parts if p) - - -# ------------------------------------------------------------------------------------------------ -# License (§7.6) and section facts -# ------------------------------------------------------------------------------------------------ -def license_section(ctx: RepoContext) -> tuple[str, str] | None: - rv = standard("readme-variants")["license_rows"] - lic, f = ctx.meta.get("license"), ctx.facts - third = f.known and (ctx.repo.endswith("-fork") or any(x.startswith(("vendor/", "third_party/")) for x in f.files)) - if third: - ctx.log("needs-input:upstream") - return None - # visibility first: a private repository is proprietary in every org, Security included (LICENSE-POLICY.md) - if ctx.public and (ctx.det["variant"] == "F" or (ctx.org.get("license_chip") and lic and lic.upper().startswith("AGPL"))): - return "Licensing", rv["security"] - if not ctx.public: - if ctx.same_owner("TouchedByAnAgent"): - return "License", rv["private_hold"] - year = (ctx.meta.get("created_at") or "")[:4] - if not year: - ctx.log("needs-input:license-year") - return None - private = rv["private"].replace("", year) - if ctx.org.get("family") not in (None, "completetech"): - private = private.replace("CompleteTech LLC", ctx.org.get("display_name") or ctx.owner) - return "License", private - if lic == "MIT": - if f.exists("BRAND_ASSETS.md"): - return "License", rv["skills_mit"] - if ctx.tier.startswith("T2") or ctx.org.get("family") not in (None, "completetech"): - return "License", rv["t2_mit"].replace("", ctx.org.get("display_name") or ctx.owner) - return "License", rv["t1_mit"] - if lic and lic not in ("NOASSERTION",): - brand = ctx.company - return "License", rv["other"].replace("", lic).replace("", "LICENSE").replace("", brand) - ctx.log("needs-input:license") - return None - - -def section_facts(ctx: RepoContext, h2: str) -> str | None: - """Body for a scaffolded section, only from repo facts; None means omit + needs-input.""" - f = ctx.facts - if h2 == "Workflow Diagram" and f.read("assets/diagrams/workflow.mmd"): - return ("Source: [assets/diagrams/workflow.mmd](assets/diagrams/workflow.mmd).\n\n```mermaid\n" - + f.read("assets/diagrams/workflow.mmd").strip() + "\n```") - if h2 == "Install": - cmds = [] - if f.exists("requirements.txt"): - cmds.append("pip install -r requirements.txt") - elif f.exists("pyproject.toml"): - cmds.append("pip install .") - if f.exists("package.json"): - cmds.append("npm install") - if cmds: - return "```bash\n" + "\n".join(cmds) + "\n```" - if h2 == "Quick Start" and f.known: - scripts = list(((f.pyproject().get("project") or {}).get("scripts") or {}).keys()) - pj = f.package_json() - if not scripts and isinstance(pj.get("bin"), dict): - scripts = list(pj["bin"].keys()) - if scripts: - return "```bash\n" + "\n".join(f"{s} --help" for s in scripts[:3]) + "\n```" - if h2 == "Live Site": - hp = ctx.meta.get("homepage") or "" - if hp and ctx.meta.get("homepage_status") == 200: - host = re.sub(r"^https?://", "", hp).rstrip("/") - return f"**[{host}]({hp})**" - if h2 == "Play": - hp = ctx.meta.get("homepage") or "" - if hp and ctx.meta.get("homepage_status") == 200: - return f"**[{re.sub(r'^https?://', '', hp).rstrip('/')}]({hp})**" - if h2 == "Ownership": - if ctx.org.get("family") not in (None, "completetech"): - return f"Organization: {ctx.owner}" - return f"Owner: @CompleteDotTech · Organization: {ctx.owner}" - if h2 == "Files" and ctx.det["variant"] == "J": - rows = [("profile/README.md", f"Organization profile shown at github.com/{ctx.owner}")] - rows = [r for r in rows if f.exists(r[0])] - if f.exists("labels.yml"): - rows.append(("labels.yml", "Label standard (ct-std-v1)")) - if rows: - return "| File | Purpose |\n|---|---|\n" + "\n".join(f"| `{a}` | {b} |" for a, b in rows) - return None - - -def footer_wanted(ctx) -> bool: - """Decision 18 footer wanted (RepoContext.footer_status): the watching org's `footer: false` exempts only its - forks, so its `.github` gets the footer and its other non-fork repositories are held.""" - why = ctx.footer_status() - if why is None: - return True - ctx.log("exempt:footer" if why.startswith("exempt") else why if why.startswith("hold:") else "hold") - return False - - -def callout(ctx) -> str | None: - if ctx.det["variant"] == "I" or (not ctx.public and ctx.det["variant"] in "ABCDE"): - who = "Touched By An Agent" if ctx.same_owner("TouchedByAnAgent") else ctx.company - return f"> [!IMPORTANT]\n> Internal {who} repository. Not for distribution." - return None - - -# ------------------------------------------------------------------------------------------------ -# Modes -# ------------------------------------------------------------------------------------------------ -def scaffold(ctx: RepoContext, existing: str = "") -> str: - variant = ctx.det["variant"] - order = standard("readme-variants")["variants"].get(variant, []) - parts = [header(ctx)] - co = callout(ctx) - if co: - parts.append(co) - keep = [ln for ln in existing.splitlines() if ln.strip() and not ln.startswith("# ")] - for h2, req in order: - if h2 in ("License", "Licensing"): - continue - body = section_facts(ctx, h2) - if h2 == "About" and keep: - body = "\n".join(keep) - if body: - parts.append(f"## {h2}\n\n{body}") - elif req == "R": - ctx.log(f"needs-input:{variant}:{h2}") - lic = license_section(ctx) - if lic: - parts.append(f"## {lic[0]}\n\n{lic[1]}") - if footer_wanted(ctx): - parts.append(footermod.block(ctx.brand)) - return "\n\n".join(parts) + "\n" - - -HEADER_LINE = re.compile( - r"^\s*(\*\*[^*]+\*\*\s*·.*|#\s+.+|!\[[^\]]*\]\(https://img\.shields\.io[^)]*\)\s*|\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*" - r"|]*(shields\.io|badge\.svg|logo)[^>]*>\s*|]*>\s*]*(shields\.io|badge\.svg)[^>]*>\s*\s*" - r"||]*>|]*alt=\"[^\"]*logo[^\"]*\"[^>]*>|

|

|
|
|---)$") - - -def _strip_markers(text: str, keep: dict[str, str] | None = None) -> tuple[str, dict]: - """(text without its marker blocks, {block: body}); a marker example inside fenced or inline code stays. A block - named in `keep` stays where it is, rewrapped around the body `keep` gives it.""" - blocks, out, pos = {}, [], 0 - for m in find_blocks(MARK, text): - blocks[m.group(1)] = m.group(2) - out.append(text[pos:m.start()]) - if keep and m.group(1) in keep: - out.append(wrap(m.group(1), keep[m.group(1)])) - pos = m.end() - return "".join(out) + text[pos:], blocks - - -def _leading_banner(pre: str) -> bool: - """True when the README preamble opens with a third-party banner image (not ours, not a logo or badge).""" - first_img = re.search(r"<(img|picture)\b[^>]*>|!\[[^\]]*\]\([^)]*\)", pre) - return bool(first_img and "docs/images/banner.png" not in first_img.group(0) - and not re.search(r"logo", first_img.group(0), re.I) - and "shields.io" not in first_img.group(0) and "badge.svg" not in first_img.group(0) - and not pre[:first_img.start()].strip().replace("

", "").strip()) - - -def fork_callout(ctx: RepoContext) -> str | None: - """§7.4 G callout; None (logged fork-parent-missing) when the parent is unknown. A fork with commits ahead of its - parent gets the full note with the compare link; a 0-ahead fork (§11.1 F1x) gets the short form, which claims no - changes. When `ahead_by` is not in the metadata the short form is used too (never claim changes nobody counted) - and `needs-input:ahead-by` is logged.""" - parent = ctx.meta.get("parent") or {} - full = parent.get("full_name") if isinstance(parent, dict) else None - if not full: - ctx.log("fork-parent-missing") - return None - ahead = ctx.meta.get("ahead_by") - if not ahead: - if ahead is None: - ctx.log("needs-input:ahead-by") - return (f"> [!NOTE]\n> **Fork of [{full}](https://github.com/{full})**, kept by {ctx.company}. " - "The upstream README follows unchanged; the upstream license applies.") - pbranch = parent.get("default_branch") or "main" - branch = ctx.meta.get("default_branch") or "main" - powner = full.split("/")[0] - return (f"> [!NOTE]\n> **Fork of [{full}](https://github.com/{full})** with {ctx.company} changes. Our changes: " - f"[compare with upstream](https://github.com/{ctx.owner}/{ctx.repo}/compare/{powner}:{pbranch}...{branch}). " - "The upstream README follows unchanged; the upstream license applies.") - - -BANNER_BLOCK = re.compile(re.escape(start("banner")) + r".*?" + re.escape(end("banner")) + r"\n*", re.S) - - -def callout_only(ctx: RepoContext, text: str) -> str: - """Variants G and H (§7.4): the hero banner block, then the callout (unless one is already there), then the body - unchanged, then the decision 18 footer. Nothing else changes. Watching-org forks never reach here (`readme` - exempts them). Review q27 "Full CompleteTech treatment" (brand.yaml readme.fork_readme: full) means the fork - follows its §5.4 variant instead: pass that variant with `readme --variant`.""" - if ctx.det["variant"] == "G" and ctx.brand.readme_opts.get("fork_readme") == "full": - ctx.log("needs-input:fork-variant (q27 full treatment: pass readme --variant for the §5.4 variant)") - body = text - lead_banner = "" - m = BANNER_BLOCK.match(text.lstrip("\n")) - if m: # our banner block is already on top (a re-run): keep it, look for the callout under it - body = text.lstrip("\n")[m.end():] - first = next((ln for ln in body.splitlines() if ln.strip()), "") - out = body - if not first.lstrip().startswith(">"): - if ctx.det["variant"] == "G": - co = fork_callout(ctx) - else: - co = None # the H callout needs the archive date and reason from the registry - ctx.log("needs-input:archive-callout") - if co: - out = co + "\n\n" + body.lstrip("\n") - if ctx.art_status()["banner"] is None: - lead_banner = wrap("banner", banner_html(ctx)) + "\n\n" # decision 13: the hero banner opens G and H too - elif m: - lead_banner = text.lstrip("\n")[:m.end()].rstrip("\n") + "\n\n" - out = lead_banner + out.lstrip("\n") - if footer_wanted(ctx) and footermod.check(ctx.brand, out)[0] == "fail:missing": - out = out.rstrip("\n") + "\n\n" + footermod.block(ctx.brand) + "\n" - return out - - -def preserved(ctx: RepoContext, text: str) -> bool: - """§7.0 preserve list, decided the same way by `readme` (before) and `lint readme` (after the upgrade): - the org's readme_preserve list, any README over 15,000 bytes, and every T2 product README that opens with its - own banner image. Showcase marker blocks (our nav line) are ignored, so the answer survives an upgrade.""" - if ctx.repo in (ctx.org.get("readme_preserve") or []) or len(text.encode()) > 15000: - return True - if ctx.tier != "T2": - return False - body, _ = _strip_markers(text.replace("\r\n", "\n")) - pre, _ = split_sections(body) - return _leading_banner(pre) - - -CENTRED_OPEN = re.compile(r'(?m)^[ \t]*<(p|div) align="center">') - - -def is_footer_like(block: str) -> bool: - """A centred block that credits the company with a complete.tech link: the decision 18 footer, exact or drifted.""" - return bool(re.match(r'\s*<(p|div) align="center">', block)) and "built by" in block.lower() \ - and "complete.tech" in block.lower() - - -FOOTER_TAG = re.compile(r"]*)>", re.I) - - -FOOTER_ATTR = re.compile(r"""\b(src|srcset|alt)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", re.I) - - -def _footer_logo_url(url: str, brand) -> bool: - """`url` is one of the decision 18 footer's own logo files (brand footer `logo_light_url` or `logo_dark_url`), - as configured or drifted only in scheme, branch, query or the github.com blob/raw form of the same file.""" - url = url.strip().split("#", 1)[0].split("?", 1)[0] - for key in ("logo_light_url", "logo_dark_url"): - want = brand.raw["footer"][key] - if url == want: - return True - m = re.match(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/[^/]+/(.+)$", want) - if not m: - continue - own, repo, path = (re.escape(x) for x in m.groups()) - if re.fullmatch(rf"(?i:https?://raw\.githubusercontent\.com/{own}/{repo}/[^/]+/){path}", url) or \ - re.fullmatch(rf"(?i:https?://github\.com/{own}/{repo}/(?:blob|raw)/[^/]+/){path}", url): - return True - return False - - -def is_footer_copy(block: str, brand) -> bool: - """Every line of `block` is a line of the decision 18 footer or a drifted copy of one: only centring, link, logo, - `
`/`` markup, with complete.tech links, the footer's own logo image (its src or srcset is the brand footer - logo URL or a drifted form of it, or its alt is exactly the company) and as text only the credit line ("Built by - ", optionally with the suffix). A sponsor or partner image (whatever its file name), a thanks line or - any other authored text makes it not a footer copy, so it is never removed.""" - import html - f, company = brand.raw["footer"], brand.strings["company"] - credit = {f"{f['line_prefix']} {company}", f"{f['line_prefix']} {company} {f['line_suffix']}"} - - def tag_ok(m: re.Match) -> bool: - name, attrs = m.group(1).lower(), m.group(2) - if m.group(0).startswith(" tuple[str, list[str]]: - """(text, removed lines) with a trailing old footer (an unmarked or drifted decision 18 footer: every line a - footer line, `is_footer_copy`) taken off the end, so the marked footer replaces it instead of following it. Any - other block, a centred credits block with authored lines included, is left as it is (lint reports a second - footer-like block as `fail:duplicate`).""" - body = text.rstrip() - opens = [m for m in CENTRED_OPEN.finditer(masked(body))] - if not opens: - return text, [] - m = opens[-1] - tail = body[m.start():] - if not tail.rstrip().endswith(f"") or not is_footer_like(tail) or not is_footer_copy(tail, brand): - return text, [] - return body[:m.start()].rstrip() + "\n", [ln for ln in tail.splitlines() if ln.strip()] - - -def upgrade(ctx: RepoContext, text: str) -> tuple[str, list[str]]: - """Return (new text, header lines removed). Raises nothing; callers run the shrink checks.""" - variant = ctx.det["variant"] - rv = standard("readme-variants") - if variant in ("G", "H"): - return callout_only(ctx, normalize_text(text)), [] - text = normalize_text(text) - body, blocks = _strip_markers(text) - want_footer = footer_wanted(ctx) - removed: list[str] = [] - if want_footer: # an unmarked or drifted footer at the end is replaced by the marked one, never kept beside it - body, old_footer = drop_old_footer(body, ctx.brand) - removed += old_footer - # preserve list / existing third-party banner - preserve = ctx.repo in (ctx.org.get("readme_preserve") or []) or len(text.encode()) > 15000 - pre, sections = split_sections(body) - keep_pre: list[str] = [] - lines = pre.splitlines() - if _leading_banner(pre) and (ctx.tier == "T2" or preserve): - preserve = True - # q57 `pres`: the hero goes above the README's own banner (kept directly under it), or nothing is added - 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 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) - 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) # its lines are already in `removed` - pre, sections = split_sections(body) - lines = pre.splitlines() - # an unmarked bold line opening the preamble is an old tagline. With a description it is replaced; without one it - # is carried into an empty tagline block (header rule), and a second one stays as authored text: never erased - carry: list[str] = [] - # the pristine-body fallback alone would decide the tagline block (no known template taglines): a bare bold line - # the template cannot have written must not be moved into a block that later runs would empty - fallback_only = (not ctx.meta.get("is_template") and not [x for x in ctx.meta.get("template_taglines") or [] - if isinstance(x, str) and x.strip()] - and bootstrap_state(text) == "pristine") - - def is_header_line(ln: str) -> bool: - s_ = ln.strip() - if HEADER_LINE.match(ln) or "docs/images/banner.png" in ln: - return True - return bool(desc) and s_ in (f"**{desc}**", f"

{desc}

", f"{desc}") - - # group the preamble into units: centred HTML blocks and blocks are classified as a whole - units: list[list[str]] = [] - i = 0 - while i < len(lines): - s_ = lines[i].strip() - closer = {"

": "

", "
": "
", "": ""}.get(s_) - if closer: - j = i + 1 - while j < len(lines) and lines[j].strip() != closer: - j += 1 - units.append(lines[i:j + 1]) - i = j + 1 - continue - units.append([lines[i]]) - i += 1 - for unit in units: - if len(unit) == 1: - ln = unit[0] - s_ = ln.strip() - bold = re.fullmatch(r"\*\*[^*]+\*\*", s_) is not None and not "".join(keep_pre).strip() - is_hdr = is_header_line(ln) or bold - if preserve: # additive only: just an old nav line is replaced - is_hdr = s_.startswith("**") and " · " in s_ and not "".join(keep_pre).strip() - elif bold and not is_header_line(ln) and not desc: - if carry or (old_tag or "").strip() or fallback_only: - # the tagline block already has a line, or (an untouched template copy whose template taglines - # are unknown) the block rule would empty any line moved into it: this one stays authored text - keep_pre.append(ln) - continue - carry.append(ln) - (removed if is_hdr else keep_pre).append(ln) - continue - inner = unit[1:-1] - if all(not x.strip() or is_header_line(x) or x.strip() in ("", "") or x.strip().startswith("" and not preserve: - # old hero wrapper: drop the wrapper and header lines, keep anything else verbatim - for x in unit: - (removed if (x is unit[0] or x is unit[-1] or is_header_line(x)) else keep_pre).append(x) - else: - keep_pre.extend(unit) - intro = "\n".join(keep_pre).strip() - # sections: renames, mapping, Notes demotion, License last - renames = rv["renames"] - order = [h for h, _ in rv["variants"].get(variant, [])] - low_order = {h.lower(): h for h in order} - placed: dict[str, str] = {} - notes: list[str] = [] - license_body = None - existing_notes = None - for h, b in sections: - clean = EMOJI.sub("", h).strip() - name = renames.get(clean.lower(), clean) - if name.lower() in ("license", "licensing", "licence"): - license_body = (name if name != "Licence" else "License", b) - continue - if name.lower() == "notes": - existing_notes = b.strip("\n") - continue - if name.lower() in low_order and low_order[name.lower()] not in placed: - placed[low_order[name.lower()]] = b.strip("\n") - else: - if variant == "D" and name.lower() == "status" and "Findings" not in placed: - placed["Findings"] = b.strip("\n") - continue - notes.append(f"### {clean}\n{b.rstrip()}" if b.strip() else f"### {clean}") - out = [] - if preserve: - # §7.0 preserve list: additive only. The hero banner block goes on top (q57 `pres` default, "Banner above the - # existing content"; none when the user answers "Leave them unchanged"), with the nav line in the §7.1 order; - # the README's own banner and every section stay verbatim and in place; add License if missing, then footer. - if ctx.preserve_unchanged() or ctx.art_status()["banner"] is not None: - out.append(wrap("nav", nav_line(ctx))) - else: - out += lead(ctx, wrap("banner", banner_html(ctx)), wrap("nav", nav_line(ctx))) - if intro: - out.append(intro) - for h, b in sections: - out.append(f"## {h}\n{b.rstrip()}" if b.strip() else f"## {h}") - if not any(h.strip().lower() in ("license", "licensing", "licence") for h, _ in sections): - lic = license_section(ctx) - if lic: - out.append(f"## {lic[0]}\n\n{lic[1]}") - if want_footer: - out.append(footermod.block(ctx.brand)) - return "\n\n".join(x for x in out if x) + "\n", removed - else: - if carry: # an unmarked tagline with no description: it moves into the block (the move is not a lost line) - old_tag = carry[0].strip() - removed.remove(carry[0]) - new_tag = tagline_body(ctx, old_tag, text) - if old_tag and old_tag.strip() and not new_tag: - # 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, existing_badges=blocks.get("badges"))) - co = callout(ctx) - if co and co not in intro: - out.append(co) - if intro: - if variant == "A" or "About" in placed or "About" not in order: - out.append(intro) - else: - placed["About"] = intro + ("\n\n" + placed["About"] if placed.get("About") else "") - for h in order: - if h in ("License", "Licensing"): - continue - if h in placed: - out.append(f"## {h}\n\n{placed[h].strip()}" if placed[h].strip() else f"## {h}") - elif dict(rv["variants"][variant]).get(h) == "R" and not preserve: - body = section_facts(ctx, h) - if body: - out.append(f"## {h}\n\n{body}") - else: - ctx.log(f"needs-input:{variant}:{h}") - if existing_notes or notes: - nb = "\n\n".join(x for x in ([existing_notes] if existing_notes else []) + notes) - out.append(f"## Notes\n\n{nb}") - if license_body: - out.append(f"## {license_body[0]}\n\n{license_body[1].strip()}") - else: - lic = license_section(ctx) - if lic: - out.append(f"## {lic[0]}\n\n{lic[1]}") - if want_footer: - out.append(footermod.block(ctx.brand)) - for b in ("template-body", "checklist"): # inserted in reverse: the checklist stays above the body - if b in blocks: - out.insert(1 if not preserve else 1, wrap(b, blocks[b])) - return "\n\n".join(x for x in out if x) + "\n", removed - - -def header_only(ctx: RepoContext, text: str) -> str: - """Replace only existing marker blocks (what the Action runs).""" - text = normalize_text(text) - desc = (ctx.meta.get("description") or "").strip() - gen = {"nav": nav_line(ctx), "banner": banner_html(ctx), "footer": footermod.markup(ctx.brand)} - 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") - - 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) - return "" - if b == "tagline": # tagline_body: the description's line, else the block as written unless inherited - new = tagline_body(ctx, body, text) - return m.group(0) if not desc and new == body else wrap(b, re.sub(r"\n{3,}", "\n\n", new)) - if b in gen: - return wrap(b, re.sub(r"\n{3,}", "\n\n", gen[b])) - return m.group(0) - return license_block(ctx, reorder_lead(ctx, _sub_blocks(text, sub))) - - -def _seam(left: str, right: str) -> str: - """Join two pieces where a marker block meets authored text: the blank lines on both sides of the join count - together and collapse to at most one blank line. Nothing else in either piece changes.""" - a, b = left.rstrip("\n"), right.lstrip("\n") - n = min(len(left) - len(a) + len(right) - len(b), 2) - return a + "\n" * n + b - - -def _sub_blocks(text: str, sub) -> str: - """MARK.sub(sub, text), except that authored text between blocks comes back byte-identical (fenced code - included, and a marker example inside code is never treated as a block); blank-line runs are tidied only at the - seams around each block (a removed block leaves one blank line).""" - out, pos = "", 0 - for m in find_blocks(MARK, text): - out = text[:m.start()] if not pos else _seam(out, text[pos:m.start()]) - out = _seam(out, sub(m)) - pos = m.end() - return _seam(out, text[pos:]) if pos else text - - -LEAD_PAIR = re.compile(r"\A(\s*)(" + re.escape(start("nav")) + r".*?" + re.escape(end("nav")) + r")(\s*)(" + - re.escape(start("banner")) + r".*?" + re.escape(end("banner")) + r")", re.S) -LEAD_PAIR_REV = re.compile(r"\A(\s*)(" + re.escape(start("banner")) + r".*?" + re.escape(end("banner")) + r")(\s*)(" + - re.escape(start("nav")) + r".*?" + re.escape(end("nav")) + r")", re.S) - - -def reorder_lead(ctx: RepoContext, text: str) -> str: - """Swap a README's leading nav and banner marker blocks into the configured §7.1 order (q57 `nav`), so READMEs - written before the order changed follow it on the next header refresh. Only the two adjacent top blocks move.""" - pat = LEAD_PAIR_REV if nav_first(ctx) else LEAD_PAIR - return pat.sub(lambda m: m.group(1) + m.group(4) + m.group(3) + m.group(2), text, count=1) - - -LICENSE_MARK = "" - - -def license_block(ctx: RepoContext, text: str) -> str: - """Rewrite the paragraph after `` (and its H2) from license_section(), with or without a - LICENSE file: a private repository reads "Proprietary...", a Security repository gets "## Licensing". When the - section cannot be derived (needs-input), the existing text stays.""" - i = masked(text).find(LICENSE_MARK) # the first mark outside code (a documented example is left alone) - if i < 0: - return text - lic = license_section(ctx) - if not lic: - return text - m = re.compile(re.escape(LICENSE_MARK) + r"\n(.*?)(\n\n|\n?$)", re.S).match(text, i) - if m: - text = text[:i] + LICENSE_MARK + "\n" + lic[1] + m.group(2) + text[m.end():] - h = re.search(r"(?m)^## (?:License|Licensing|Licence)[ \t]*\n(\n?)\Z", text[:i]) - return text[:h.start()] + f"## {lic[0]}\n" + h.group(1) + text[i:] if h else text - - -TEMPLATE_H1 = "CompleteTech Repo Template" -PRISTINE = re.compile(r"\n?") -TEMPLATE_BODY = re.compile(re.escape(start("template-body")) + r"\n?(.*?)\n?" + re.escape(end("template-body")), re.S) - - -def body_sha(body: str) -> str: - import hashlib - return hashlib.sha256(body.replace("\r\n", "\n").strip().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}") - - -def bootstrap_state(text: str) -> str: - """'pristine' (the block is byte-for-byte what was generated), 'edited' (a marker without a matching hash: a person - wrote in the block) or 'none' (no marker: the README is the repository's own).""" - found = find_blocks(TEMPLATE_BODY, normalize_text(text)) - if not found: - return "none" - m = found[0] - pm = PRISTINE.search(m.group(1)) - if not pm: - return "none" - rest = PRISTINE.sub("", m.group(1), count=1) - return "pristine" if pm.group(1) and pm.group(1) == body_sha(rest) else "edited" - - -def drop_pristine(text: str) -> str: - """`text` without the pristine marker of the first template-body block outside code; a marker shown as an example - (inline or fenced code) stays byte-identical.""" - found = find_blocks(TEMPLATE_BODY, text) - if not found: - return text - tb = found[0] - return text[:tb.start(1)] + PRISTINE.sub("", tb.group(1), count=1) + text[tb.end(1):] - - -def _header_region(text: str) -> re.Match | None: - """From the top of the README to the rule after the tagline block (the §7.2 header).""" - return re.match(r"\A.*?" + re.escape(end("tagline")) + r"\n(?:.*?\n)??---\n", text, re.S) - - -def bootstrap(ctx: RepoContext, text: str) -> str: - """Fill a README that still carries a generated (pristine) template body: the README variant's sections from - repository facts, the variant's header (Variant F has no H1) and the license section. A pristine body is - regenerated on later runs too, so a repository typed `workspace` on its creation commit is re-bootstrapped once - its code (or `type:` in .github/showcase.yml) gives the real type. Any human edit inside the block changes its - hash, and from then on the block is the repository's own: never overwritten (callers drop the marker).""" - text = normalize_text(text) - if ctx.meta.get("is_template") or bootstrap_state(text) != "pristine": - return text - variant = ctx.det["variant"] - order = standard("readme-variants")["variants"].get(variant, []) - secs = [] - for h2, req in order: - if h2 in ("License", "Licensing"): - continue - body = section_facts(ctx, h2) - if body: - secs.append(f"## {h2}\n\n{body}") - elif req == "R": - secs.append(f"## {h2}\n\n") - ctx.log(f"needs-input:{variant}:{h2}") - new = pristine_block("\n\n".join(secs)) - tb = find_blocks(TEMPLATE_BODY, text)[0] # bootstrap_state found it outside code - text = text[:tb.start()] + new + text[tb.end():] - # the header: rebuilt for this variant unless a person retitled the README - hm = _header_region(text) - if hm: - h1 = re.search(r"(?m)^# (.+)$", hm.group(0)) - auto = cased_title(ctx.repo, ctx.meta.get("description") or "") - 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 - 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) - - -def bootstrap_contributing(ctx: RepoContext, text: str) -> str: - """Point the template's own `.github` guide links at this org. Only links to the template owner's `.github` (any - of its logins) change; a link to another org's or an upstream's guide or security policy stays as written.""" - from .intake import TEMPLATE_REPO - tpl = TEMPLATE_REPO.split("/")[0] - try: - logins = {x.lower() for x in ctx.brand.resolver().aliases(tpl)} - except AttributeError: # a context without a brand: the template login alone - logins = {tpl.lower()} - - def sub(m): - return m.group(1) + ctx.owner + m.group(3) if m.group(2).lower() in logins else m.group(0) - return re.sub(r"(https://github\.com/)([A-Za-z0-9-]+)(/\.github/blob/main/(?:CONTRIBUTING|SECURITY)\.md)", sub, - text).replace(f"[{tpl} contributing guide]", f"[{ctx.owner} contributing guide]") - - -# ------------------------------------------------------------------------------------------------ -# Safety checks -# ------------------------------------------------------------------------------------------------ -def nonws(s: str) -> int: - return len(re.sub(r"\s", "", s)) - - -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): - owned = generated_badges(m.group(2)) or body_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("` / `:end -->` markers so reruns replace only +those blocks. Content is never invented (§1.5 rule 2) and never lost (§7.1 restructuring, multiset check). +""" +from __future__ import annotations + +import re +from collections import Counter +from pathlib import Path +from urllib.parse import quote + +from . import footer as footermod +from .context import RepoContext +from .detect import standard +from .mdcode import code_spans, find_blocks, masked # noqa: F401 (code_spans: re-exported for callers) + +MARK = re.compile(r"\n?(.*?)\n?", re.S) +EMOJI = re.compile("[\U0001F000-\U0001FAFF☀-➿⬀-⯿️‍]") +SHIELDS = "https://img.shields.io/badge/" +GENERATED_BADGES = re.compile(r"\n(.*)", re.S) +BOM = "\ufeff" + + +def normalize_text(text: str) -> str: + """README text as the tools read it: LF line endings, no leading UTF-8 byte order mark (a BOM would hide the H1 + from every `^#` match).""" + text = text.replace("\r\n", "\n") + return text[1:] if text.startswith(BOM) else text + + +def start(b): + return f"" + + +def end(b): + return f"" + + +def wrap(b, body): + return f"{start(b)}\n{body}\n{end(b)}" + + +# ------------------------------------------------------------------------------------------------ +# Small helpers +# ------------------------------------------------------------------------------------------------ +def shield(s: str) -> str: + """A shields.io path segment: `-`/`_` doubled, then percent-encoded, so `#`, `?`, `/` and `%` (C#, F#) stay in + the segment instead of ending the path.""" + return quote(s.replace("-", "--").replace("_", "__"), safe="()!*'.,;:@&=$~") + + +def title_case(s: str) -> str: + rv = standard("readme-variants") + small, keep = set(rv["title_small_words"]), {k.lower(): k for k in rv["title_keep_case"]} + out = [] + for i, w in enumerate(re.split(r"(\s+)", s)): + if not w.strip(): + out.append(w) + continue + low = w.lower() + if low in keep: + out.append(keep[low]) + elif i > 0 and low in small: + out.append(low) + else: + out.append(w[:1].upper() + w[1:]) + return "".join(out) + + +def slug_title(slug: str) -> str: + """Title from a slug; CVE ids keep their form ("acme-cve-2026-12345-check" -> "Acme CVE-2026-12345 Check").""" + cves: list[str] = [] + + def keep(m): + cves.append(m.group(0).upper()) + return f"\x00{len(cves) - 1}\x00" + s = re.sub(r"cve-\d{4}-\d+", keep, slug, flags=re.I) + t = title_case(s.replace("-", " ").replace("_", " ")) + return re.sub(r"\x00(\d+)\x00", lambda m: cves[int(m.group(1))], t) + + +def cased_title(slug: str, description: str = "") -> str: + """slug_title, with each word spelled the way the repository's own description spells it when that spelling is + a deliberate one (mixed case, such as PHP2Shell or OpenClaw), so a brand name is never re-cased to "Php2shell".""" + spelled = {} + for tok in re.findall(r"[A-Za-z0-9][A-Za-z0-9+#]*", description or ""): + if tok != tok.lower() and tok != tok[:1].upper() + tok[1:].lower(): + spelled.setdefault(tok.lower(), tok) + return re.sub(r"[A-Za-z0-9][A-Za-z0-9+#]*", lambda m: spelled.get(m.group(0).lower(), m.group(0)), slug_title(slug)) + + +def product_name(ctx: RepoContext) -> str: + return ctx.facts.h1() or cased_title(ctx.repo, ctx.meta.get("description") or "") + + +def split_sections(text: str) -> tuple[str, list[tuple[str, str]]]: + """(preamble, [(heading text, body)]) split at H2 outside code fences.""" + pre, sections, cur_h, buf = [], [], None, [] + fence = None + for line in text.splitlines(): + m = re.match(r"^(```+|~~~+)", line) + if m: + fence = None if fence and line.startswith(fence) else (fence or m.group(1)) + if fence is None and re.match(r"^## (?!#)", line): + if cur_h is None: + pre = buf + else: + sections.append((cur_h, "\n".join(buf))) + cur_h, buf = line[3:].strip(), [] + continue + buf.append(line) + if cur_h is None: + return "\n".join(buf), [] + sections.append((cur_h, "\n".join(buf))) + return "\n".join(pre), sections + + +# ------------------------------------------------------------------------------------------------ +# Header pieces +# ------------------------------------------------------------------------------------------------ +def nav_line(ctx: RepoContext) -> str: + org, owner = ctx.org, ctx.brand.resolver().canonical(ctx.owner) # links name the org's current login (CQ-03) + if ctx.same_owner("CompleteTech-Skills") and ctx.det["type"] == "agent-skills": + line = ("**CompleteTech LLC Skills** · [Start here](ONBOARDING.md) · [Agent instructions](SKILL.md) · " + "[Skill library](https://github.com/CompleteTech-Skills/agentic-services-orchestrator-skill/blob/main/" + "references/skill-family.md) · [Contributing](CONTRIBUTING.md)") + return line + (" · Internal" if not ctx.public else "") + f = ctx.facts + contrib = "CONTRIBUTING.md" if f.exists("CONTRIBUTING.md") else f"https://github.com/{owner}/.github/blob/main/CONTRIBUTING.md" + sec = "SECURITY.md" if f.exists("SECURITY.md") else f"https://github.com/{owner}/.github/blob/main/SECURITY.md" + if ctx.det["variant"] == "F": + items = [f"**{org.get('nav_name') or org.get('display_name')}**", f"[All checkers](https://github.com/{owner})"] + if f.exists("docs/REFERENCE.md"): + items.append("[Reference](docs/REFERENCE.md)") + if f.exists("LICENSING.md"): + items.append("[Licensing](LICENSING.md)") + items.append(f"[Security]({sec})") + return " · ".join(items) + items = [f"**{org.get('nav_name') or org.get('display_name') or owner}**", f"[All repositories](https://github.com/{owner})"] + hp = ctx.meta.get("homepage") or "" + if ctx.det["variant"] == "B" and hp and ctx.meta.get("homepage_status") == 200: + items.append(f"[Live site]({hp})") + if ctx.det["variant"] == "E" and hp and ctx.meta.get("homepage_status") == 200: + items.append(f"[Play]({hp})") + docs = [x for x in f.files if x.startswith("docs/") and not re.search(r"\.(png|jpe?g|gif|webp|svg)$|/\.", x)] if f.known else [] + if docs: + items.append("[Docs](docs/)") + items += [f"[Contributing]({contrib})", f"[Security]({sec})"] + line = " · ".join(items) + return line + (" · Internal" if not ctx.public else "") + + +def banner_html(ctx: RepoContext) -> str: + return (f'

\n {ctx.repo} by {ctx.company}\n

') + + +def badges(ctx: RepoContext) -> list[tuple[str, str, str | None]]: + """[(alt, image url, link)] per §7.2: language, type, license, CI. 2-4.""" + b = dict(ctx.brand.raw["badges"]) + for k in ("brand", "accent"): # a family outside CompleteTech carries its own badge colours (brand.yaml badge_brand, badge_accent) + if ctx.style.get(f"badge_{k}"): + b[k] = ctx.style[f"badge_{k}"] + jev = ctx.style["name"] == "jevplay-games" + extra = "?labelColor=1E1E1E" if jev else "" + out = [] + f, lang = ctx.facts, ctx.meta.get("language") + py, node = f.requires_python(), f.engines_node() + if lang == "Python" and py: + out.append((f"Python {py}+", f"{SHIELDS}python-{shield(py + '+')}-{b['accent']}?logo=python&logoColor=white", None)) + elif lang in ("JavaScript", "TypeScript") and node: + out.append((f"Node {node}+", f"{SHIELDS}node-{shield(node + '+')}-{b['accent']}?logo=nodedotjs&logoColor=white", None)) + elif lang: + out.append((f"Language {lang}", f"{SHIELDS}language-{shield(lang)}-{b['accent']}{extra}", None)) + if ctx.det["type"] == "agent-skills": + 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)) + 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)) + elif lic and lic not in ("NOASSERTION", "other"): + if ctx.org.get("license_chip") and lic.upper().startswith("AGPL"): + out.append(("AGPL-3.0 or commercial license", f"{SHIELDS}license-{shield('AGPL-3.0 | commercial')}-{b['neutral']}", + "LICENSING.md" if f.exists("LICENSING.md") else None)) + else: + out.append((f"License {lic}", f"{SHIELDS}license-{shield(lic)}-{b['neutral']}{extra}", None)) + if ctx.public and f.known: + wf = sorted(x for x in f.files if x.startswith(".github/workflows/") and x.endswith((".yml", ".yaml"))) + pick = [x for x in wf if "test" in x.rsplit("/", 1)[-1]] or [x for x in wf if "ci" in x.rsplit("/", 1)[-1]] + if pick: + name = pick[0].rsplit("/", 1)[-1] + base = f"https://github.com/{ctx.brand.resolver().canonical(ctx.owner)}/{ctx.repo}/actions/workflows/{name}" + out.append(("Tests" if "test" in name else "CI", f"{base}/badge.svg", base)) + return out[:4] + + +def _local(ctx, url: str) -> str: + """--badges local: render a shields.io badge URL to docs/images/badges/.svg (outlined text, no server).""" + if not getattr(ctx, "local_badges", False) or not url.startswith(SHIELDS): + return url + from urllib.parse import unquote + path = url[len(SHIELDS):].split("?", 1)[0] + parts = re.split(r"(? str: + lines = [] + for alt, url, link in badges(ctx): + url = _local(ctx, url) + img = f"![{alt}]({url})" + lines.append(f"[{img}]({link})" if link else img) + return "\n".join(lines) + + +def badges_html(ctx) -> str: + parts = [] + for alt, url, link in badges(ctx): + url = _local(ctx, url) + img = f'{alt}' + parts.append(f' {img}' if link else f" {img}") + return '

\n' + "\n".join(parts) + "\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 "") + or (existing or "").strip() == template_badge_body(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 "") + replaced_digest = body_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 legacy_profile_badges(brand, body: str) -> 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 = body.splitlines() + if not 1 <= len(lines) <= 3: + return False + kinds = [] + colors = brand.raw["badges"] + 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) not in (".github", ci.group(1)): + 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 + urls = {f"{SHIELDS}{label}-{shield(message)}-{color}{suffix}" for suffix in suffixes} + 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) == body_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".""" + return bool(ctx.brand.readme_opts.get("nav_first")) + + +def lead(ctx: RepoContext, banner: str, nav: str) -> list[str]: + """The banner and nav blocks in the configured order.""" + return [nav, banner] if nav_first(ctx) else [banner, nav] + + +def inherited_tagline(ctx: RepoContext, body: str, text: str) -> bool: + """True when the tagline block `body` of README `text` came from the template this repository was generated from + (never another repository's line is kept): it is one of meta template_taglines (the template's tagline when this + repository was generated, and today); only when those are unknown, an untouched (pristine) template body stands in + as the proof. The template repository's own tagline is never inherited.""" + if ctx.meta.get("is_template"): + return False + known = [x.strip() for x in ctx.meta.get("template_taglines") or [] if isinstance(x, str) and x.strip()] + return body.strip() in known if known else bootstrap_state(text) == "pristine" + + +def tagline_body(ctx: RepoContext, existing: str | None = None, text: str = "") -> str: + """The tagline block body, decided in this one place for header(), bootstrap(), upgrade() and header_only(). + R2: the tagline is the description. Without one (logged needs-input:description) the block keeps `existing`, the + body it holds now in README `text`, unless that is inherited (guardrail 6: improve, never erase); nothing to keep + gives an empty block, never "****".""" + desc = (ctx.meta.get("description") or "").strip() + if desc: + return f'

{desc}

' if ctx.det["variant"] == "F" else f"**{desc}**" + ctx.log("needs-input:description") + if existing is None or not existing.strip() or inherited_tagline(ctx, existing, text): + return "" + return existing + + +def header(ctx: RepoContext, keep_banner: str | None = None, title: str | None = None, + 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", 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", badge_body(ctx, existing_badges)), tag, "
", "---"] + return "\n\n".join(p for p in parts if p) + + +# ------------------------------------------------------------------------------------------------ +# License (§7.6) and section facts +# ------------------------------------------------------------------------------------------------ +def license_section(ctx: RepoContext) -> tuple[str, str] | None: + rv = standard("readme-variants")["license_rows"] + lic, f = ctx.meta.get("license"), ctx.facts + third = f.known and (ctx.repo.endswith("-fork") or any(x.startswith(("vendor/", "third_party/")) for x in f.files)) + if third: + ctx.log("needs-input:upstream") + return None + # visibility first: a private repository is proprietary in every org, Security included (LICENSE-POLICY.md) + if ctx.public and (ctx.det["variant"] == "F" or (ctx.org.get("license_chip") and lic and lic.upper().startswith("AGPL"))): + return "Licensing", rv["security"] + if not ctx.public: + if ctx.same_owner("TouchedByAnAgent"): + return "License", rv["private_hold"] + year = (ctx.meta.get("created_at") or "")[:4] + if not year: + ctx.log("needs-input:license-year") + return None + private = rv["private"].replace("", year) + if ctx.org.get("family") not in (None, "completetech"): + private = private.replace("CompleteTech LLC", ctx.org.get("display_name") or ctx.owner) + return "License", private + if lic == "MIT": + if f.exists("BRAND_ASSETS.md"): + return "License", rv["skills_mit"] + if ctx.tier.startswith("T2") or ctx.org.get("family") not in (None, "completetech"): + return "License", rv["t2_mit"].replace("", ctx.org.get("display_name") or ctx.owner) + return "License", rv["t1_mit"] + if lic and lic not in ("NOASSERTION",): + brand = ctx.company + return "License", rv["other"].replace("", lic).replace("", "LICENSE").replace("", brand) + ctx.log("needs-input:license") + return None + + +def section_facts(ctx: RepoContext, h2: str) -> str | None: + """Body for a scaffolded section, only from repo facts; None means omit + needs-input.""" + f = ctx.facts + if h2 == "Workflow Diagram" and f.read("assets/diagrams/workflow.mmd"): + return ("Source: [assets/diagrams/workflow.mmd](assets/diagrams/workflow.mmd).\n\n```mermaid\n" + + f.read("assets/diagrams/workflow.mmd").strip() + "\n```") + if h2 == "Install": + cmds = [] + if f.exists("requirements.txt"): + cmds.append("pip install -r requirements.txt") + elif f.exists("pyproject.toml"): + cmds.append("pip install .") + if f.exists("package.json"): + cmds.append("npm install") + if cmds: + return "```bash\n" + "\n".join(cmds) + "\n```" + if h2 == "Quick Start" and f.known: + scripts = list(((f.pyproject().get("project") or {}).get("scripts") or {}).keys()) + pj = f.package_json() + if not scripts and isinstance(pj.get("bin"), dict): + scripts = list(pj["bin"].keys()) + if scripts: + return "```bash\n" + "\n".join(f"{s} --help" for s in scripts[:3]) + "\n```" + if h2 == "Live Site": + hp = ctx.meta.get("homepage") or "" + if hp and ctx.meta.get("homepage_status") == 200: + host = re.sub(r"^https?://", "", hp).rstrip("/") + return f"**[{host}]({hp})**" + if h2 == "Play": + hp = ctx.meta.get("homepage") or "" + if hp and ctx.meta.get("homepage_status") == 200: + return f"**[{re.sub(r'^https?://', '', hp).rstrip('/')}]({hp})**" + if h2 == "Ownership": + if ctx.org.get("family") not in (None, "completetech"): + return f"Organization: {ctx.owner}" + return f"Owner: @CompleteDotTech · Organization: {ctx.owner}" + if h2 == "Files" and ctx.det["variant"] == "J": + rows = [("profile/README.md", f"Organization profile shown at github.com/{ctx.owner}")] + rows = [r for r in rows if f.exists(r[0])] + if f.exists("labels.yml"): + rows.append(("labels.yml", "Label standard (ct-std-v1)")) + if rows: + return "| File | Purpose |\n|---|---|\n" + "\n".join(f"| `{a}` | {b} |" for a, b in rows) + return None + + +def footer_wanted(ctx) -> bool: + """Decision 18 footer wanted (RepoContext.footer_status): the watching org's `footer: false` exempts only its + forks, so its `.github` gets the footer and its other non-fork repositories are held.""" + why = ctx.footer_status() + if why is None: + return True + ctx.log("exempt:footer" if why.startswith("exempt") else why if why.startswith("hold:") else "hold") + return False + + +def callout(ctx) -> str | None: + if ctx.det["variant"] == "I" or (not ctx.public and ctx.det["variant"] in "ABCDE"): + who = "Touched By An Agent" if ctx.same_owner("TouchedByAnAgent") else ctx.company + return f"> [!IMPORTANT]\n> Internal {who} repository. Not for distribution." + return None + + +# ------------------------------------------------------------------------------------------------ +# Modes +# ------------------------------------------------------------------------------------------------ +def scaffold(ctx: RepoContext, existing: str = "") -> str: + variant = ctx.det["variant"] + order = standard("readme-variants")["variants"].get(variant, []) + parts = [header(ctx)] + co = callout(ctx) + if co: + parts.append(co) + keep = [ln for ln in existing.splitlines() if ln.strip() and not ln.startswith("# ")] + for h2, req in order: + if h2 in ("License", "Licensing"): + continue + body = section_facts(ctx, h2) + if h2 == "About" and keep: + body = "\n".join(keep) + if body: + parts.append(f"## {h2}\n\n{body}") + elif req == "R": + ctx.log(f"needs-input:{variant}:{h2}") + lic = license_section(ctx) + if lic: + parts.append(f"## {lic[0]}\n\n{lic[1]}") + if footer_wanted(ctx): + parts.append(footermod.block(ctx.brand)) + return "\n\n".join(parts) + "\n" + + +HEADER_LINE = re.compile( + r"^\s*(\*\*[^*]+\*\*\s*·.*|#\s+.+|!\[[^\]]*\]\(https://img\.shields\.io[^)]*\)\s*|\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*" + r"|]*(shields\.io|badge\.svg|logo)[^>]*>\s*|]*>\s*]*(shields\.io|badge\.svg)[^>]*>\s*\s*" + r"||]*>|]*alt=\"[^\"]*logo[^\"]*\"[^>]*>|

|

|
|
|---)$") + + +def _strip_markers(text: str, keep: dict[str, str] | None = None) -> tuple[str, dict]: + """(text without its marker blocks, {block: body}); a marker example inside fenced or inline code stays. A block + named in `keep` stays where it is, rewrapped around the body `keep` gives it.""" + blocks, out, pos = {}, [], 0 + for m in find_blocks(MARK, text): + blocks[m.group(1)] = m.group(2) + out.append(text[pos:m.start()]) + if keep and m.group(1) in keep: + out.append(wrap(m.group(1), keep[m.group(1)])) + pos = m.end() + return "".join(out) + text[pos:], blocks + + +def _leading_banner(pre: str) -> bool: + """True when the README preamble opens with a third-party banner image (not ours, not a logo or badge).""" + first_img = re.search(r"<(img|picture)\b[^>]*>|!\[[^\]]*\]\([^)]*\)", pre) + return bool(first_img and "docs/images/banner.png" not in first_img.group(0) + and not re.search(r"logo", first_img.group(0), re.I) + and "shields.io" not in first_img.group(0) and "badge.svg" not in first_img.group(0) + and not pre[:first_img.start()].strip().replace("

", "").strip()) + + +def fork_callout(ctx: RepoContext) -> str | None: + """§7.4 G callout; None (logged fork-parent-missing) when the parent is unknown. A fork with commits ahead of its + parent gets the full note with the compare link; a 0-ahead fork (§11.1 F1x) gets the short form, which claims no + changes. When `ahead_by` is not in the metadata the short form is used too (never claim changes nobody counted) + and `needs-input:ahead-by` is logged.""" + parent = ctx.meta.get("parent") or {} + full = parent.get("full_name") if isinstance(parent, dict) else None + if not full: + ctx.log("fork-parent-missing") + return None + ahead = ctx.meta.get("ahead_by") + if not ahead: + if ahead is None: + ctx.log("needs-input:ahead-by") + return (f"> [!NOTE]\n> **Fork of [{full}](https://github.com/{full})**, kept by {ctx.company}. " + "The upstream README follows unchanged; the upstream license applies.") + pbranch = parent.get("default_branch") or "main" + branch = ctx.meta.get("default_branch") or "main" + powner = full.split("/")[0] + return (f"> [!NOTE]\n> **Fork of [{full}](https://github.com/{full})** with {ctx.company} changes. Our changes: " + f"[compare with upstream](https://github.com/{ctx.owner}/{ctx.repo}/compare/{powner}:{pbranch}...{branch}). " + "The upstream README follows unchanged; the upstream license applies.") + + +BANNER_BLOCK = re.compile(re.escape(start("banner")) + r".*?" + re.escape(end("banner")) + r"\n*", re.S) + + +def callout_only(ctx: RepoContext, text: str) -> str: + """Variants G and H (§7.4): the hero banner block, then the callout (unless one is already there), then the body + unchanged, then the decision 18 footer. Nothing else changes. Watching-org forks never reach here (`readme` + exempts them). Review q27 "Full CompleteTech treatment" (brand.yaml readme.fork_readme: full) means the fork + follows its §5.4 variant instead: pass that variant with `readme --variant`.""" + if ctx.det["variant"] == "G" and ctx.brand.readme_opts.get("fork_readme") == "full": + ctx.log("needs-input:fork-variant (q27 full treatment: pass readme --variant for the §5.4 variant)") + body = text + lead_banner = "" + m = BANNER_BLOCK.match(text.lstrip("\n")) + if m: # our banner block is already on top (a re-run): keep it, look for the callout under it + body = text.lstrip("\n")[m.end():] + first = next((ln for ln in body.splitlines() if ln.strip()), "") + out = body + if not first.lstrip().startswith(">"): + if ctx.det["variant"] == "G": + co = fork_callout(ctx) + else: + co = None # the H callout needs the archive date and reason from the registry + ctx.log("needs-input:archive-callout") + if co: + out = co + "\n\n" + body.lstrip("\n") + if ctx.art_status()["banner"] is None: + lead_banner = wrap("banner", banner_html(ctx)) + "\n\n" # decision 13: the hero banner opens G and H too + elif m: + lead_banner = text.lstrip("\n")[:m.end()].rstrip("\n") + "\n\n" + out = lead_banner + out.lstrip("\n") + if footer_wanted(ctx) and footermod.check(ctx.brand, out)[0] == "fail:missing": + out = out.rstrip("\n") + "\n\n" + footermod.block(ctx.brand) + "\n" + return out + + +def preserved(ctx: RepoContext, text: str) -> bool: + """§7.0 preserve list, decided the same way by `readme` (before) and `lint readme` (after the upgrade): + the org's readme_preserve list, any README over 15,000 bytes, and every T2 product README that opens with its + own banner image. Showcase marker blocks (our nav line) are ignored, so the answer survives an upgrade.""" + if ctx.repo in (ctx.org.get("readme_preserve") or []) or len(text.encode()) > 15000: + return True + if ctx.tier != "T2": + return False + body, _ = _strip_markers(text.replace("\r\n", "\n")) + pre, _ = split_sections(body) + return _leading_banner(pre) + + +CENTRED_OPEN = re.compile(r'(?m)^[ \t]*<(p|div) align="center">') + + +def is_footer_like(block: str) -> bool: + """A centred block that credits the company with a complete.tech link: the decision 18 footer, exact or drifted.""" + return bool(re.match(r'\s*<(p|div) align="center">', block)) and "built by" in block.lower() \ + and "complete.tech" in block.lower() + + +FOOTER_TAG = re.compile(r"]*)>", re.I) + + +FOOTER_ATTR = re.compile(r"""\b(src|srcset|alt)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", re.I) + + +def _footer_logo_url(url: str, brand) -> bool: + """`url` is one of the decision 18 footer's own logo files (brand footer `logo_light_url` or `logo_dark_url`), + as configured or drifted only in scheme, branch, query or the github.com blob/raw form of the same file.""" + url = url.strip().split("#", 1)[0].split("?", 1)[0] + for key in ("logo_light_url", "logo_dark_url"): + want = brand.raw["footer"][key] + if url == want: + return True + m = re.match(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/[^/]+/(.+)$", want) + if not m: + continue + own, repo, path = (re.escape(x) for x in m.groups()) + if re.fullmatch(rf"(?i:https?://raw\.githubusercontent\.com/{own}/{repo}/[^/]+/){path}", url) or \ + re.fullmatch(rf"(?i:https?://github\.com/{own}/{repo}/(?:blob|raw)/[^/]+/){path}", url): + return True + return False + + +def is_footer_copy(block: str, brand) -> bool: + """Every line of `block` is a line of the decision 18 footer or a drifted copy of one: only centring, link, logo, + `
`/`` markup, with complete.tech links, the footer's own logo image (its src or srcset is the brand footer + logo URL or a drifted form of it, or its alt is exactly the company) and as text only the credit line ("Built by + ", optionally with the suffix). A sponsor or partner image (whatever its file name), a thanks line or + any other authored text makes it not a footer copy, so it is never removed.""" + import html + f, company = brand.raw["footer"], brand.strings["company"] + credit = {f"{f['line_prefix']} {company}", f"{f['line_prefix']} {company} {f['line_suffix']}"} + + def tag_ok(m: re.Match) -> bool: + name, attrs = m.group(1).lower(), m.group(2) + if m.group(0).startswith(" tuple[str, list[str]]: + """(text, removed lines) with a trailing old footer (an unmarked or drifted decision 18 footer: every line a + footer line, `is_footer_copy`) taken off the end, so the marked footer replaces it instead of following it. Any + other block, a centred credits block with authored lines included, is left as it is (lint reports a second + footer-like block as `fail:duplicate`).""" + body = text.rstrip() + opens = [m for m in CENTRED_OPEN.finditer(masked(body))] + if not opens: + return text, [] + m = opens[-1] + tail = body[m.start():] + if not tail.rstrip().endswith(f"") or not is_footer_like(tail) or not is_footer_copy(tail, brand): + return text, [] + return body[:m.start()].rstrip() + "\n", [ln for ln in tail.splitlines() if ln.strip()] + + +def upgrade(ctx: RepoContext, text: str) -> tuple[str, list[str]]: + """Return (new text, header lines removed). Raises nothing; callers run the shrink checks.""" + variant = ctx.det["variant"] + rv = standard("readme-variants") + if variant in ("G", "H"): + return callout_only(ctx, normalize_text(text)), [] + text = normalize_text(text) + body, blocks = _strip_markers(text) + want_footer = footer_wanted(ctx) + removed: list[str] = [] + if want_footer: # an unmarked or drifted footer at the end is replaced by the marked one, never kept beside it + body, old_footer = drop_old_footer(body, ctx.brand) + removed += old_footer + # preserve list / existing third-party banner + preserve = ctx.repo in (ctx.org.get("readme_preserve") or []) or len(text.encode()) > 15000 + pre, sections = split_sections(body) + keep_pre: list[str] = [] + lines = pre.splitlines() + if _leading_banner(pre) and (ctx.tier == "T2" or preserve): + preserve = True + # q57 `pres`: the hero goes above the README's own banner (kept directly under it), or nothing is added + 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 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) + 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) # its lines are already in `removed` + pre, sections = split_sections(body) + lines = pre.splitlines() + # an unmarked bold line opening the preamble is an old tagline. With a description it is replaced; without one it + # is carried into an empty tagline block (header rule), and a second one stays as authored text: never erased + carry: list[str] = [] + # the pristine-body fallback alone would decide the tagline block (no known template taglines): a bare bold line + # the template cannot have written must not be moved into a block that later runs would empty + fallback_only = (not ctx.meta.get("is_template") and not [x for x in ctx.meta.get("template_taglines") or [] + if isinstance(x, str) and x.strip()] + and bootstrap_state(text) == "pristine") + + def is_header_line(ln: str) -> bool: + s_ = ln.strip() + if HEADER_LINE.match(ln) or "docs/images/banner.png" in ln: + return True + return bool(desc) and s_ in (f"**{desc}**", f"

{desc}

", f"{desc}") + + # group the preamble into units: centred HTML blocks and blocks are classified as a whole + units: list[list[str]] = [] + i = 0 + while i < len(lines): + s_ = lines[i].strip() + closer = {"

": "

", "
": "
", "": ""}.get(s_) + if closer: + j = i + 1 + while j < len(lines) and lines[j].strip() != closer: + j += 1 + units.append(lines[i:j + 1]) + i = j + 1 + continue + units.append([lines[i]]) + i += 1 + for unit in units: + if len(unit) == 1: + ln = unit[0] + s_ = ln.strip() + bold = re.fullmatch(r"\*\*[^*]+\*\*", s_) is not None and not "".join(keep_pre).strip() + is_hdr = is_header_line(ln) or bold + if preserve: # additive only: just an old nav line is replaced + is_hdr = s_.startswith("**") and " · " in s_ and not "".join(keep_pre).strip() + elif bold and not is_header_line(ln) and not desc: + if carry or (old_tag or "").strip() or fallback_only: + # the tagline block already has a line, or (an untouched template copy whose template taglines + # are unknown) the block rule would empty any line moved into it: this one stays authored text + keep_pre.append(ln) + continue + carry.append(ln) + (removed if is_hdr else keep_pre).append(ln) + continue + inner = unit[1:-1] + if all(not x.strip() or is_header_line(x) or x.strip() in ("", "") or x.strip().startswith("" and not preserve: + # old hero wrapper: drop the wrapper and header lines, keep anything else verbatim + for x in unit: + (removed if (x is unit[0] or x is unit[-1] or is_header_line(x)) else keep_pre).append(x) + else: + keep_pre.extend(unit) + intro = "\n".join(keep_pre).strip() + # sections: renames, mapping, Notes demotion, License last + renames = rv["renames"] + order = [h for h, _ in rv["variants"].get(variant, [])] + low_order = {h.lower(): h for h in order} + placed: dict[str, str] = {} + notes: list[str] = [] + license_body = None + existing_notes = None + for h, b in sections: + clean = EMOJI.sub("", h).strip() + name = renames.get(clean.lower(), clean) + if name.lower() in ("license", "licensing", "licence"): + license_body = (name if name != "Licence" else "License", b) + continue + if name.lower() == "notes": + existing_notes = b.strip("\n") + continue + if name.lower() in low_order and low_order[name.lower()] not in placed: + placed[low_order[name.lower()]] = b.strip("\n") + else: + if variant == "D" and name.lower() == "status" and "Findings" not in placed: + placed["Findings"] = b.strip("\n") + continue + notes.append(f"### {clean}\n{b.rstrip()}" if b.strip() else f"### {clean}") + out = [] + if preserve: + # §7.0 preserve list: additive only. The hero banner block goes on top (q57 `pres` default, "Banner above the + # existing content"; none when the user answers "Leave them unchanged"), with the nav line in the §7.1 order; + # the README's own banner and every section stay verbatim and in place; add License if missing, then footer. + if ctx.preserve_unchanged() or ctx.art_status()["banner"] is not None: + out.append(wrap("nav", nav_line(ctx))) + else: + out += lead(ctx, wrap("banner", banner_html(ctx)), wrap("nav", nav_line(ctx))) + if intro: + out.append(intro) + for h, b in sections: + out.append(f"## {h}\n{b.rstrip()}" if b.strip() else f"## {h}") + if not any(h.strip().lower() in ("license", "licensing", "licence") for h, _ in sections): + lic = license_section(ctx) + if lic: + out.append(f"## {lic[0]}\n\n{lic[1]}") + if want_footer: + out.append(footermod.block(ctx.brand)) + return "\n\n".join(x for x in out if x) + "\n", removed + else: + if carry: # an unmarked tagline with no description: it moves into the block (the move is not a lost line) + old_tag = carry[0].strip() + removed.remove(carry[0]) + new_tag = tagline_body(ctx, old_tag, text) + if old_tag and old_tag.strip() and not new_tag: + # 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, existing_badges=blocks.get("badges"))) + co = callout(ctx) + if co and co not in intro: + out.append(co) + if intro: + if variant == "A" or "About" in placed or "About" not in order: + out.append(intro) + else: + placed["About"] = intro + ("\n\n" + placed["About"] if placed.get("About") else "") + for h in order: + if h in ("License", "Licensing"): + continue + if h in placed: + out.append(f"## {h}\n\n{placed[h].strip()}" if placed[h].strip() else f"## {h}") + elif dict(rv["variants"][variant]).get(h) == "R" and not preserve: + body = section_facts(ctx, h) + if body: + out.append(f"## {h}\n\n{body}") + else: + ctx.log(f"needs-input:{variant}:{h}") + if existing_notes or notes: + nb = "\n\n".join(x for x in ([existing_notes] if existing_notes else []) + notes) + out.append(f"## Notes\n\n{nb}") + if license_body: + out.append(f"## {license_body[0]}\n\n{license_body[1].strip()}") + else: + lic = license_section(ctx) + if lic: + out.append(f"## {lic[0]}\n\n{lic[1]}") + if want_footer: + out.append(footermod.block(ctx.brand)) + for b in ("template-body", "checklist"): # inserted in reverse: the checklist stays above the body + if b in blocks: + out.insert(1 if not preserve else 1, wrap(b, blocks[b])) + return "\n\n".join(x for x in out if x) + "\n", removed + + +def header_only(ctx: RepoContext, text: str) -> str: + """Replace only existing marker blocks (what the Action runs).""" + text = normalize_text(text) + desc = (ctx.meta.get("description") or "").strip() + gen = {"nav": nav_line(ctx), "banner": banner_html(ctx), "footer": footermod.markup(ctx.brand)} + 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") + + 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) + return "" + if b == "tagline": # tagline_body: the description's line, else the block as written unless inherited + new = tagline_body(ctx, body, text) + return m.group(0) if not desc and new == body else wrap(b, re.sub(r"\n{3,}", "\n\n", new)) + if b in gen: + return wrap(b, re.sub(r"\n{3,}", "\n\n", gen[b])) + return m.group(0) + return license_block(ctx, reorder_lead(ctx, _sub_blocks(text, sub))) + + +def _seam(left: str, right: str) -> str: + """Join two pieces where a marker block meets authored text: the blank lines on both sides of the join count + together and collapse to at most one blank line. Nothing else in either piece changes.""" + a, b = left.rstrip("\n"), right.lstrip("\n") + n = min(len(left) - len(a) + len(right) - len(b), 2) + return a + "\n" * n + b + + +def _sub_blocks(text: str, sub) -> str: + """MARK.sub(sub, text), except that authored text between blocks comes back byte-identical (fenced code + included, and a marker example inside code is never treated as a block); blank-line runs are tidied only at the + seams around each block (a removed block leaves one blank line).""" + out, pos = "", 0 + for m in find_blocks(MARK, text): + out = text[:m.start()] if not pos else _seam(out, text[pos:m.start()]) + out = _seam(out, sub(m)) + pos = m.end() + return _seam(out, text[pos:]) if pos else text + + +LEAD_PAIR = re.compile(r"\A(\s*)(" + re.escape(start("nav")) + r".*?" + re.escape(end("nav")) + r")(\s*)(" + + re.escape(start("banner")) + r".*?" + re.escape(end("banner")) + r")", re.S) +LEAD_PAIR_REV = re.compile(r"\A(\s*)(" + re.escape(start("banner")) + r".*?" + re.escape(end("banner")) + r")(\s*)(" + + re.escape(start("nav")) + r".*?" + re.escape(end("nav")) + r")", re.S) + + +def reorder_lead(ctx: RepoContext, text: str) -> str: + """Swap a README's leading nav and banner marker blocks into the configured §7.1 order (q57 `nav`), so READMEs + written before the order changed follow it on the next header refresh. Only the two adjacent top blocks move.""" + pat = LEAD_PAIR_REV if nav_first(ctx) else LEAD_PAIR + return pat.sub(lambda m: m.group(1) + m.group(4) + m.group(3) + m.group(2), text, count=1) + + +LICENSE_MARK = "" + + +def license_block(ctx: RepoContext, text: str) -> str: + """Rewrite the paragraph after `` (and its H2) from license_section(), with or without a + LICENSE file: a private repository reads "Proprietary...", a Security repository gets "## Licensing". When the + section cannot be derived (needs-input), the existing text stays.""" + i = masked(text).find(LICENSE_MARK) # the first mark outside code (a documented example is left alone) + if i < 0: + return text + lic = license_section(ctx) + if not lic: + return text + m = re.compile(re.escape(LICENSE_MARK) + r"\n(.*?)(\n\n|\n?$)", re.S).match(text, i) + if m: + text = text[:i] + LICENSE_MARK + "\n" + lic[1] + m.group(2) + text[m.end():] + h = re.search(r"(?m)^## (?:License|Licensing|Licence)[ \t]*\n(\n?)\Z", text[:i]) + return text[:h.start()] + f"## {lic[0]}\n" + h.group(1) + text[i:] if h else text + + +TEMPLATE_H1 = "CompleteTech Repo Template" +PRISTINE = re.compile(r"\n?") +TEMPLATE_BODY = re.compile(re.escape(start("template-body")) + r"\n?(.*?)\n?" + re.escape(end("template-body")), re.S) + + +def body_sha(body: str) -> str: + import hashlib + return hashlib.sha256(body.replace("\r\n", "\n").strip().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}") + + +def bootstrap_state(text: str) -> str: + """'pristine' (the block is byte-for-byte what was generated), 'edited' (a marker without a matching hash: a person + wrote in the block) or 'none' (no marker: the README is the repository's own).""" + found = find_blocks(TEMPLATE_BODY, normalize_text(text)) + if not found: + return "none" + m = found[0] + pm = PRISTINE.search(m.group(1)) + if not pm: + return "none" + rest = PRISTINE.sub("", m.group(1), count=1) + return "pristine" if pm.group(1) and pm.group(1) == body_sha(rest) else "edited" + + +def drop_pristine(text: str) -> str: + """`text` without the pristine marker of the first template-body block outside code; a marker shown as an example + (inline or fenced code) stays byte-identical.""" + found = find_blocks(TEMPLATE_BODY, text) + if not found: + return text + tb = found[0] + return text[:tb.start(1)] + PRISTINE.sub("", tb.group(1), count=1) + text[tb.end(1):] + + +def _header_region(text: str) -> re.Match | None: + """From the top of the README to the rule after the tagline block (the §7.2 header).""" + return re.match(r"\A.*?" + re.escape(end("tagline")) + r"\n(?:.*?\n)??---\n", text, re.S) + + +def bootstrap(ctx: RepoContext, text: str) -> str: + """Fill a README that still carries a generated (pristine) template body: the README variant's sections from + repository facts, the variant's header (Variant F has no H1) and the license section. A pristine body is + regenerated on later runs too, so a repository typed `workspace` on its creation commit is re-bootstrapped once + its code (or `type:` in .github/showcase.yml) gives the real type. Any human edit inside the block changes its + hash, and from then on the block is the repository's own: never overwritten (callers drop the marker).""" + text = normalize_text(text) + if ctx.meta.get("is_template") or bootstrap_state(text) != "pristine": + return text + variant = ctx.det["variant"] + order = standard("readme-variants")["variants"].get(variant, []) + secs = [] + for h2, req in order: + if h2 in ("License", "Licensing"): + continue + body = section_facts(ctx, h2) + if body: + secs.append(f"## {h2}\n\n{body}") + elif req == "R": + secs.append(f"## {h2}\n\n") + ctx.log(f"needs-input:{variant}:{h2}") + new = pristine_block("\n\n".join(secs)) + tb = find_blocks(TEMPLATE_BODY, text)[0] # bootstrap_state found it outside code + text = text[:tb.start()] + new + text[tb.end():] + # the header: rebuilt for this variant unless a person retitled the README + hm = _header_region(text) + if hm: + h1 = re.search(r"(?m)^# (.+)$", hm.group(0)) + auto = cased_title(ctx.repo, ctx.meta.get("description") or "") + 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 + 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) + + +def bootstrap_contributing(ctx: RepoContext, text: str) -> str: + """Point the template's own `.github` guide links at this org. Only links to the template owner's `.github` (any + of its logins) change; a link to another org's or an upstream's guide or security policy stays as written.""" + from .intake import TEMPLATE_REPO + tpl = TEMPLATE_REPO.split("/")[0] + try: + logins = {x.lower() for x in ctx.brand.resolver().aliases(tpl)} + except AttributeError: # a context without a brand: the template login alone + logins = {tpl.lower()} + + def sub(m): + return m.group(1) + ctx.owner + m.group(3) if m.group(2).lower() in logins else m.group(0) + return re.sub(r"(https://github\.com/)([A-Za-z0-9-]+)(/\.github/blob/main/(?:CONTRIBUTING|SECURITY)\.md)", sub, + text).replace(f"[{tpl} contributing guide]", f"[{ctx.owner} contributing guide]") + + +# ------------------------------------------------------------------------------------------------ +# Safety checks +# ------------------------------------------------------------------------------------------------ +def nonws(s: str) -> int: + return len(re.sub(r"\s", "", s)) + + +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): + owned = generated_badges(m.group(2)) or body_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("", "", "".join(out), flags=re.S) + # blank comments in place (keep newlines) so offsets stay valid for the original document + protected = re.sub(r"", lambda m: re.sub(r"[^\n]", " ", m.group(0)), "".join(out), flags=re.S) protected = masked(protected) for key, raw in replacements: protected = protected.replace(key, raw) @@ -144,7 +145,8 @@ def label(value): definitions = {} for match in REF_DEFINITION.finditer(_outside_code(document)): - definitions.setdefault(label(match.group(1)), html.unescape(match.group(2) or match.group(3))) + target = html.unescape(re.sub(r"\\([^\w\s])", r"\1", match.group(2) or match.group(3))) + definitions.setdefault(label(match.group(1)), target) sources = [] consumed = 0 for match in re.finditer(r"!\[", text): diff --git a/tests/test_reference_badge_count.py b/tests/test_reference_badge_count.py index 7d8836a..6e1f8c8 100644 --- a/tests/test_reference_badge_count.py +++ b/tests/test_reference_badge_count.py @@ -130,3 +130,29 @@ def test_angle_destinations_and_html_inside_code_are_excluded(make_ctx, tmp_path 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 diff --git a/tests/test_variant_j_badges.py b/tests/test_variant_j_badges.py index 04ac0ec..0f94617 100644 --- a/tests/test_variant_j_badges.py +++ b/tests/test_variant_j_badges.py @@ -420,3 +420,24 @@ def test_cli_angle_bracketed_shield_destinations_use_palette_tokens(make_ctx, tm (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 From cb65567465598543e50de826cdbd2b36ab0ef803 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 14:06:20 -0400 Subject: [PATCH 15/98] fix: keep README lint source offsets when HTML appears inside Markdown code _outside_code now swaps HTML tags for same-length placeholders restored by offset, and blanks fenced code before HTML tokenizing so unfinished example tags cannot swallow closing fences. Co-Authored-By: Claude Sonnet 5.5 --- scripts/ctshowcase/readmelint.py | 37 ++++++++++++++++++----------- tests/test_reference_badge_count.py | 15 ++++++++++++ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index 1bd9f9f..b803af9 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -8,6 +8,7 @@ from urllib.parse import unquote from . import footer as footermod +from .mdcode import code_spans from .context import RepoContext from .detect import standard from .readme import BANNER_BLOCK, EMOJI, MARK, is_footer_like, masked, normalize_text, nav_first, preserved, split_sections @@ -48,24 +49,32 @@ def handle_starttag(self, tag, attrs): self.images.append((first.get("alt") or "", first.get("src") or "", start, end)) +def _blank(text: str, a: int, b: int, fill: str = " ") -> str: + return text[:a] + re.sub(r"[^\n]", fill, text[a:b]) + text[b:] + + def _outside_code(text: str) -> str: - """Mask Markdown code without interpreting literal backticks in HTML attributes as code.""" - out, replacements, pos = [], [], 0 - for index, (start, end) in enumerate(_HTMLTokens(text).tags): + """Mask Markdown code without interpreting literal backticks in HTML attributes as code. + The result always has the same length (and newline positions) as `text`.""" + # fenced code is blanked before HTML tokenizing so an unfinished example tag cannot swallow the closing fence + tokenized = text + for a, b in code_spans(text): + if re.match(r" {0,3}(`{3,}|~{3,})", text[a:a + 8]): + tokenized = _blank(tokenized, a, b) + protected, replacements = text, [] + for start, end in _HTMLTokens(tokenized).tags: raw = text[start:end] - key = f"\x01showcase-html-{index}\x02" - while key in text: - key += "x" - key += "\n" * raw.count("\n") - out.extend((text[pos:start], key)) - replacements.append((key, raw)) - pos = end - out.append(text[pos:]) + key = re.sub(r"[^\n]", "\x02", raw) + if key[:1] != "\n": + key = "\x01" + key[1:] + protected = protected[:start] + key + protected[end:] + replacements.append((start, end, key, raw)) # blank comments in place (keep newlines) so offsets stay valid for the original document - protected = re.sub(r"", lambda m: re.sub(r"[^\n]", " ", m.group(0)), "".join(out), flags=re.S) + protected = re.sub(r"", lambda m: re.sub(r"[^\n]", " ", m.group(0)), protected, flags=re.S) protected = masked(protected) - for key, raw in replacements: - protected = protected.replace(key, raw) + for start, end, key, raw in replacements: + if protected[start:end] == key: + protected = protected[:start] + raw + protected[end:] return protected.replace("\x00", " ") diff --git a/tests/test_reference_badge_count.py b/tests/test_reference_badge_count.py index 6e1f8c8..92f43fe 100644 --- a/tests/test_reference_badge_count.py +++ b/tests/test_reference_badge_count.py @@ -156,3 +156,18 @@ 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 From 378b5cffb085143292aba569f7e16d96cfbd915a Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 14:28:05 -0400 Subject: [PATCH 16/98] fix: replace HTMLParser tokenizer with strict CommonMark tags; mask code before comments; escaped reference titles; local template badge migration Co-Authored-By: Claude Sonnet 5.5 --- scripts/ctshowcase/readme.py | 9 ++- scripts/ctshowcase/readmelint.py | 104 ++++++++++++++------------- tests/test_readmelint_html_tokens.py | 54 ++++++++++++++ tests/test_variant_j_badges.py | 10 +++ 4 files changed, 126 insertions(+), 51 deletions(-) create mode 100644 tests/test_readmelint_html_tokens.py diff --git a/scripts/ctshowcase/readme.py b/scripts/ctshowcase/readme.py index a7b4deb..0005552 100644 --- a/scripts/ctshowcase/readme.py +++ b/scripts/ctshowcase/readme.py @@ -238,7 +238,7 @@ def badge_body(ctx: RepoContext, existing: str | None = None) -> str: 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 (existing or "").strip() == template_badge_body(ctx.brand)) + or (existing or "").strip() 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 @@ -257,6 +257,13 @@ def template_badge_body(brand) -> str: 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. diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index b803af9..2441e29 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -3,7 +3,6 @@ import html import re -from html.parser import HTMLParser from pathlib import Path from urllib.parse import unquote @@ -18,64 +17,69 @@ PALETTE = {"1E3A8A", "005ADD", "067647", "64748B", "E551BA", "F386A1", "1E1E1E"} REF_DEFINITION = re.compile( r"^ {0,3}\[((?:\\.|[^\]\\\n])+)\]:[ \t]*(?:\n[ \t]*)?(?:<([^<>\n]+)>|(\S+?))" - r"(?:[ \t]+(?:\"[^\"\n]*\"|'[^'\n]*'|\([^\)\n]*\)))?[ \t]*$", re.M) + r"(?:[ \t]+(?:\"(?:\\.|[^\"\\\n])*\"|'(?:\\.|[^'\\\n])*'|\((?:\\.|[^()\\\n])*\)))?[ \t]*$", re.M) +# CommonMark raw HTML: an open tag needs a valid tag name, valid attributes and a closing `>`; anything else is text +_ATTR = r"""\s+[A-Za-z_:][A-Za-z0-9_.:-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?""" +HTML_OPEN_TAG = re.compile(rf"<([A-Za-z][A-Za-z0-9-]*)((?:{_ATTR})*)\s*/?>") +HTML_ATTR = re.compile(r"""([A-Za-z_:][A-Za-z0-9_.:-]*)(?:\s*=\s*([^\s"'=<>`]+|'[^']*'|"[^"]*"))?""") +HTML_COMMENT = re.compile(r"", re.S) -class _HTMLTokens(HTMLParser): - """HTML spans let Markdown parsing respect attribute and destination boundaries.""" +class _HTMLTokens: + """Strict CommonMark open tags (never raises): spans let Markdown parsing respect attribute boundaries.""" def __init__(self, text): - super().__init__(convert_charrefs=True) self.text = text self.tags = [] self.images = [] - self.line_starts = [0, *(m.end() for m in re.finditer("\n", text))] - self.feed(text) - self.close() - - def handle_starttag(self, tag, attrs): - line, column = self.getpos() - start = self.line_starts[line - 1] + column - before = start - 1 - while before >= 0 and self.text[before] == "\\": - before -= 1 - if (start - before - 1) % 2: - return - end = start + len(self.get_starttag_text()) - self.tags.append((start, end)) - if tag == "img": - first = {} - for name, value in attrs: - first.setdefault(name, value) - self.images.append((first.get("alt") or "", first.get("src") or "", start, end)) - - -def _blank(text: str, a: int, b: int, fill: str = " ") -> str: - return text[:a] + re.sub(r"[^\n]", fill, text[a:b]) + text[b:] + for match in HTML_OPEN_TAG.finditer(text): + start, end = match.span() + before = start - 1 + while before >= 0 and text[before] == "\\": + before -= 1 + if (start - before - 1) % 2: + 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 _outside_code(text: str) -> str: - """Mask Markdown code without interpreting literal backticks in HTML attributes as code. - The result always has the same length (and newline positions) as `text`.""" - # fenced code is blanked before HTML tokenizing so an unfinished example tag cannot swallow the closing fence - tokenized = text - for a, b in code_spans(text): - if re.match(r" {0,3}(`{3,}|~{3,})", text[a:a + 8]): - tokenized = _blank(tokenized, a, b) - protected, replacements = text, [] - for start, end in _HTMLTokens(tokenized).tags: - raw = text[start:end] - key = re.sub(r"[^\n]", "\x02", raw) - if key[:1] != "\n": - key = "\x01" + key[1:] - protected = protected[:start] + key + protected[end:] - replacements.append((start, end, key, raw)) - # blank comments in place (keep newlines) so offsets stay valid for the original document - protected = re.sub(r"", lambda m: re.sub(r"[^\n]", " ", m.group(0)), protected, flags=re.S) - protected = masked(protected) - for start, end, key, raw in replacements: - if protected[start:end] == key: - protected = protected[:start] + raw + protected[end:] - return protected.replace("\x00", " ") + """Mask Markdown code and comments without reading literal backticks in HTML attributes as code. + Constructs are taken left to right, so a comment opener inside a code span is code and a code span inside an + attribute is attribute text. The result has the same length (and newline positions) as `text`.""" + spans = sorted(code_spans(text)) + out, pos, index = list(text), 0, 0 + tag = comment = None + + def blank(a, b): + for i in range(a, b): + if out[i] != "\n": + out[i] = " " + + while pos < len(text): + while index < len(spans) and spans[index][0] < pos: + index += 1 + code = spans[index] if index < len(spans) else None + if tag is not False and (tag is None or tag.start() < pos): + tag = HTML_OPEN_TAG.search(text, pos) or False + if comment is not False and (comment is None or comment.start() < pos): + comment = HTML_COMMENT.search(text, pos) or False + found = [m for m in (tag, comment) if m] + nxt = min(found, key=lambda m: m.start()) if found else None + if code and (nxt is None or code[0] <= nxt.start()): + 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) def _closing_bracket(text: str, start: int) -> int | None: diff --git a/tests/test_readmelint_html_tokens.py b/tests/test_readmelint_html_tokens.py new file mode 100644 index 0000000..e518d4f --- /dev/null +++ b/tests/test_readmelint_html_tokens.py @@ -0,0 +1,54 @@ +"""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"] diff --git a/tests/test_variant_j_badges.py b/tests/test_variant_j_badges.py index 0f94617..71af76a 100644 --- a/tests/test_variant_j_badges.py +++ b/tests/test_variant_j_badges.py @@ -441,3 +441,13 @@ def test_legacy_language_badge_with_family_palette_migrates(make_ctx, tmp_path): 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 From 5fd324a989991b37348a40314cb786f9413f8e70 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 14:52:34 -0400 Subject: [PATCH 17/98] fix: re-find code spans after HTML tags/comments, multiline reference titles, literal backticks in reference labels, palette check on parsed destinations Co-Authored-By: Claude Sonnet 5.5 --- scripts/ctshowcase/readmelint.py | 60 +++++++++++++++++++++------- tests/test_readmelint_html_tokens.py | 48 ++++++++++++++++++++++ 2 files changed, 93 insertions(+), 15 deletions(-) diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index 2441e29..536e130 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -7,7 +7,7 @@ from urllib.parse import unquote from . import footer as footermod -from .mdcode import code_spans +from .mdcode import FENCE_OPEN, INLINE_CODE, code_spans from .context import RepoContext from .detect import standard from .readme import BANNER_BLOCK, EMOJI, MARK, is_footer_like, masked, normalize_text, nav_first, preserved, split_sections @@ -17,7 +17,8 @@ PALETTE = {"1E3A8A", "005ADD", "067647", "64748B", "E551BA", "F386A1", "1E1E1E"} REF_DEFINITION = re.compile( r"^ {0,3}\[((?:\\.|[^\]\\\n])+)\]:[ \t]*(?:\n[ \t]*)?(?:<([^<>\n]+)>|(\S+?))" - r"(?:[ \t]+(?:\"(?:\\.|[^\"\\\n])*\"|'(?:\\.|[^'\\\n])*'|\((?:\\.|[^()\\\n])*\)))?[ \t]*$", re.M) + r"(?:(?:[ \t]+|[ \t]*\n[ \t]*)(?:\"(?:\\.|[^\"\\\n]|\n(?![ \t]*\n))*\"|'(?:\\.|[^'\\\n]|\n(?![ \t]*\n))*'" + r"|\((?:\\.|[^()\\\n]|\n(?![ \t]*\n))*\)))?[ \t]*$", re.M) # a title may span lines but never a blank one # CommonMark raw HTML: an open tag needs a valid tag name, valid attributes and a closing `>`; anything else is text _ATTR = r"""\s+[A-Za-z_:][A-Za-z0-9_.:-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?""" HTML_OPEN_TAG = re.compile(rf"<([A-Za-z][A-Za-z0-9-]*)((?:{_ATTR})*)\s*/?>") @@ -47,31 +48,56 @@ def __init__(self, text): self.images.append((first.get("alt") or "", first.get("src") or "", start, end)) -def _outside_code(text: str) -> str: +def _fence_spans(text: str) -> list[tuple[int, int]]: + """Fenced code blocks only (the spans `code_spans` opens at a fence line).""" + fences = [] + for a, b in code_spans(text): + begin = text.rfind("\n", 0, a) + 1 + end = text.find("\n", begin) + if FENCE_OPEN.match(text[begin:end if end >= 0 else len(text)]) and not text[begin:a].strip(): + fences.append((a, b)) + return sorted(fences) + + +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, so a comment opener inside a code span is code and a code span inside an - attribute is attribute text. The result has the same length (and newline positions) as `text`.""" - spans = sorted(code_spans(text)) - out, pos, index = list(text), 0, 0 - tag = comment = None + 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) + out, pos = list(text), 0 + tag = comment = inline = None def blank(a, b): for i in range(a, b): if out[i] != "\n": out[i] = " " + def next_inline(start): + while True: + found = INLINE_CODE.search(text, start) + if not found: + return False + inside = next((f for f in fences if f[0] <= found.start() < f[1]), None) + if inside is None: + return found + start = inside[1] + while pos < len(text): - while index < len(spans) and spans[index][0] < pos: - index += 1 - code = spans[index] if index < len(spans) else None + if inline is not False and (inline is None or inline.start() < pos): + inline = next_inline(pos) if tag is not False and (tag is None or tag.start() < pos): tag = HTML_OPEN_TAG.search(text, pos) or False if comment is not False and (comment is None or comment.start() < pos): comment = HTML_COMMENT.search(text, pos) or False + fence = next((f for f in fences if f[0] >= pos), None) + code = min([c for c in ((inline.span() 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 code and (nxt is None or code[0] <= nxt.start()): - blank(*code) + if mask_inline or code is fence: + blank(*code) pos = code[1] elif nxt is not None: if nxt is comment: @@ -157,7 +183,7 @@ def label(value): return " ".join(value.split()).casefold() definitions = {} - for match in REF_DEFINITION.finditer(_outside_code(document)): + for match in REF_DEFINITION.finditer(_outside_code(document, mask_inline=False)): target = html.unescape(re.sub(r"\\([^\w\s])", r"\1", match.group(2) or match.group(3))) definitions.setdefault(label(match.group(1)), target) sources = [] @@ -181,11 +207,12 @@ def label(value): source, consumed = target sources.append((alt, source, match.start(), consumed)) continue - key = label(alt) + # `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 consumed < len(text) and text[consumed] == "[": ref_close = _closing_bracket(text, consumed) if ref_close is not None: - key = label(text[consumed + 1:ref_close]) or key + key = label((document if len(document) >= len(text) else text)[consumed + 1:ref_close]) or key consumed = ref_close + 1 if key in definitions: sources.append((alt, definitions[key], match.start(), consumed)) @@ -295,6 +322,9 @@ 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 are read from parsed image destinations too, so an apostrophe or other HTML delimiter inside a Markdown URL hides nothing + shields += [m.group(1).split("?")[0].split("#")[0] for _, src in _images(_outside_code(plain), plain) + if (m := re.match(r"https://img\.shields\.io/badge/(.+)", src))] for s in shields: col = re.split(r"[?)]", s.rsplit("-", 1)[-1])[0].upper() family_cols = {str(ctx.style.get(k) or "").upper() for k in ("badge_brand", "badge_accent")} diff --git a/tests/test_readmelint_html_tokens.py b/tests/test_readmelint_html_tokens.py index e518d4f..5e4e05d 100644 --- a/tests/test_readmelint_html_tokens.py +++ b/tests/test_readmelint_html_tokens.py @@ -52,3 +52,51 @@ def test_inline_code_angle_bracket_does_not_hide_html_images_from_lint(make_ctx, 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")]), + ("`![F](y.png)`", []), + ("`![F](y.png)` ![G](g.png)", [("G", "g.png")]), +]) +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 = '`![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"] From 5d245a3b6c1202a4fdc569953db8dfbe1cf2f086 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 15:22:37 -0400 Subject: [PATCH 18/98] fix(mdcode): backslash-escaped backtick cannot open an inline code span INLINE_CODE now refuses an opening backtick run preceded by a single unescaped backslash, so escaped backticks no longer hide Markdown images from readmelint R7 validation. Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/mdcode.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/ctshowcase/mdcode.py b/scripts/ctshowcase/mdcode.py index 2c7dfdb..e11ab6c 100644 --- a/scripts/ctshowcase/mdcode.py +++ b/scripts/ctshowcase/mdcode.py @@ -6,7 +6,8 @@ # CommonMark: a backtick fence's info string has no backtick, so "```npm test``` runs" is inline code, not a fence FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}(?=[^`\n]*$)|~{3,})") -INLINE_CODE = re.compile(r"(`+)(?!`).+?(? list[tuple[int, int]]: From 4145cc14b6beb40ebe174d383e99b6c985caa643 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 15:22:43 -0400 Subject: [PATCH 19/98] test: cover escaped and real backticks around angle-bracketed README images Add regression tests for R7 lint of ![Screenshot]() between escaped backticks (literal text, must still report the missing local file), real inline code (must stay hidden), and an even backslash run before a backtick (real code span, must stay hidden). The escaped-backtick test fails on the current source until readmelint's inline-code detection honours escaped opening backticks. Co-Authored-By: Claude Haiku 5.5 --- tests/test_readmelint_html_tokens.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_readmelint_html_tokens.py b/tests/test_readmelint_html_tokens.py index 5e4e05d..8cdc84f 100644 --- a/tests/test_readmelint_html_tokens.py +++ b/tests/test_readmelint_html_tokens.py @@ -100,3 +100,21 @@ def test_apostrophe_in_markdown_badge_url_cannot_hide_an_off_palette_colour(make 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"] From 53759ac47d311bc5815ca4413f42e6fa56305ea8 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 15:40:12 -0400 Subject: [PATCH 20/98] readmelint: parity-aware code, escaped HTML openers, definition titles, HTTP Shields - Inline code is found via mdcode.find_inline_code (tuple spans) instead of INLINE_CODE. - Add _escaped (odd backslash run) and _search_unescaped; HTML tag and comment openers that are backslash-escaped are text and no longer blank the rest of the document. _HTMLTokens uses the same _escaped rule. - Reference-definition spans (label, destination, title) are excluded from Markdown and HTML image extraction; destinations still resolve for real references. - Accept http and https Shields badge destinations (SHIELDS_SRC / SHIELDS_BADGE). Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/readmelint.py | 58 +++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index 536e130..5ee6bb0 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -7,7 +7,7 @@ from urllib.parse import unquote from . import footer as footermod -from .mdcode import FENCE_OPEN, INLINE_CODE, code_spans +from .mdcode import FENCE_OPEN, code_spans, find_inline_code from .context import RepoContext from .detect import standard from .readme import BANNER_BLOCK, EMOJI, MARK, is_footer_like, masked, normalize_text, nav_first, preserved, split_sections @@ -24,6 +24,30 @@ HTML_OPEN_TAG = re.compile(rf"<([A-Za-z][A-Za-z0-9-]*)((?:{_ATTR})*)\s*/?>") HTML_ATTR = re.compile(r"""([A-Za-z_:][A-Za-z0-9_.:-]*)(?:\s*=\s*([^\s"'=<>`]+|'[^']*'|"[^"]*"))?""") HTML_COMMENT = re.compile(r"", 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 _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): + """First match at or after `pos` whose opening character is not backslash-escaped.""" + match = pattern.search(text, pos) + while match and _escaped(text, match.start()): + match = pattern.search(text, match.start() + 1) + return match + + +def _definition_spans(document: str) -> list[tuple[int, int]]: + """Spans of complete reference definitions (label, destination and title): their text is not Markdown.""" + return [(m.start(), m.end()) for m in REF_DEFINITION.finditer(_outside_code(document, mask_inline=False))] class _HTMLTokens: @@ -34,10 +58,7 @@ def __init__(self, text): self.images = [] for match in HTML_OPEN_TAG.finditer(text): start, end = match.span() - before = start - 1 - while before >= 0 and text[before] == "\\": - before -= 1 - if (start - before - 1) % 2: + if _escaped(text, start): continue self.tags.append((start, end)) if match.group(1).lower() == "img": @@ -76,23 +97,23 @@ def blank(a, b): def next_inline(start): while True: - found = INLINE_CODE.search(text, start) + found = find_inline_code(text, start) # (start, end) or None if not found: return False - inside = next((f for f in fences if f[0] <= found.start() < f[1]), None) + inside = next((f for f in fences if f[0] <= found[0] < f[1]), None) if inside is None: return found start = inside[1] while pos < len(text): - if inline is not False and (inline is None or inline.start() < pos): + 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 = HTML_OPEN_TAG.search(text, pos) or False + tag = _search_unescaped(HTML_OPEN_TAG, text, pos) or False if comment is not False and (comment is None or comment.start() < pos): - comment = HTML_COMMENT.search(text, pos) or False + comment = _search_unescaped(HTML_COMMENT, text, pos) or False fence = next((f for f in fences if f[0] >= pos), None) - code = min([c for c in ((inline.span() if inline else None), fence) if c], default=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 code and (nxt is None or code[0] <= nxt.start()): @@ -186,11 +207,14 @@ def label(value): for match in REF_DEFINITION.finditer(_outside_code(document, mask_inline=False)): 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 sources = [] consumed = 0 for match in re.finditer(r"!\[", text): if match.start() < consumed: continue + if any(a <= match.start() < b for a, b in spans): + continue pos = match.start() - 1 while pos >= 0 and text[pos] == "\\": pos -= 1 @@ -232,9 +256,13 @@ def written_alt(alt, start): 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 any(a <= start < b for a, b in spans) result = [(written_alt(alt, start), source) for alt, source, start, end in markdown - if not any(tstart <= start and end <= tend for tstart, tend in tags)] - result += [(alt, source) for alt, source, start, end in parser.images if (start, end) in tags] + if not in_definition(start) and not any(tstart <= start and end <= tend for tstart, tend in tags)] + result += [(alt, source) for alt, source, start, end in parser.images + if (start, end) in tags and not in_definition(start)] return result @@ -312,7 +340,7 @@ def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: header_part = plain.split("\n---\n", 1)[0] badge_header = _outside_code(header_part) image_sources = [source for _, source in _images(badge_header, plain)] - nbadges = sum(bool(src.startswith("https://img.shields.io/badge/") + 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: @@ -324,7 +352,7 @@ def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: warns.append(f"badges-short: {nbadges} badge (no licence or language to show yet)") # colours are read from parsed image destinations too, so an apostrophe or other HTML delimiter inside a Markdown URL hides nothing shields += [m.group(1).split("?")[0].split("#")[0] for _, src in _images(_outside_code(plain), plain) - if (m := re.match(r"https://img\.shields\.io/badge/(.+)", src))] + if (m := SHIELDS_BADGE.match(src))] for s in shields: col = re.split(r"[?)]", s.rsplit("-", 1)[-1])[0].upper() family_cols = {str(ctx.style.get(k) or "").upper() for k in ("badge_brand", "badge_accent")} From a8e691e84ea9bcb18c83d54da8548c6d53be6cfd Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 15:41:19 -0400 Subject: [PATCH 21/98] mdcode: find inline code spans by backslash parity Replace the one-backslash INLINE_CODE lookbehind with find_inline_code, a left-to-right scan. A backtick preceded by an odd number of backslashes is escaped (its run starts one character later); an even count leaves the run as real code, for any number of backslashes. A closing run must be exactly as long as the opener, on the same line. code_spans now loops over find_inline_code per line; fence logic is unchanged. INLINE_CODE stays only as a compatibility name for readmelint until it switches to find_inline_code. Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/mdcode.py | 66 ++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/scripts/ctshowcase/mdcode.py b/scripts/ctshowcase/mdcode.py index e11ab6c..8d0eb91 100644 --- a/scripts/ctshowcase/mdcode.py +++ b/scripts/ctshowcase/mdcode.py @@ -1,15 +1,69 @@ """Markdown code spans, so marker searches skip documented examples. Stdlib only and imports nothing from the package: -`readme` (and through it `readmelint` and `footer`) and the stdlib-only `meta` find marker blocks the same way.""" +`readme` (and through it `readmelint` and `footer`) and the stdlib-only `meta` find marker blocks the same way. +CommonMark escaping: the parity of the backslashes directly before a backtick decides escaping (odd = escaped, +even = real), for any number of backslashes.""" from __future__ import annotations import re # CommonMark: a backtick fence's info string has no backtick, so "```npm test``` runs" is inline code, not a fence FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}(?=[^`\n]*$)|~{3,})") -# CommonMark: a backslash-escaped backtick cannot open a code span +# Compatibility name only: readmelint still searches with it until it calls find_inline_code. Its one-backslash +# lookbehind does not implement the parity rule, so new callers must use find_inline_code instead. INLINE_CODE = re.compile(r"(? 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 _closing_run(text: str, pos: int, k: int) -> tuple[int, int] | None: + """First maximal backtick run of exactly `k` backticks at or after `pos`, found before the line ends.""" + while True: + p = text.find("`", pos) + if p < 0 or text.find("\n", pos, p) >= 0: + return None + q = _run_end(text, p) + if q - p == k: + return p, q + pos = q + + +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 and contain no newline.""" + i = start + while True: + i = text.find("`", i) + if i < 0: + return None + 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) + if closer is not None: + return run_start, closer[1] + i = j + + 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.""" spans, pos, fence, fstart = [], 0, None, 0 @@ -19,7 +73,13 @@ def code_spans(text: str) -> list[tuple[int, int]]: if m: fence, fstart = m.group(1), pos else: - spans += [(pos + c.start(), pos + c.end()) for c in INLINE_CODE.finditer(line)] + at = 0 + while True: + span = find_inline_code(line, at) + if span is None: + break + spans.append((pos + span[0], pos + span[1])) + at = span[1] 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): From 305c0d74ae837469ed0cf3408d2436fd2136b908 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 15:42:36 -0400 Subject: [PATCH 22/98] test: cover backslash parity, escaped comments, definition titles and HTTP badges Co-Authored-By: Claude Sonnet 5.5 --- tests/test_mdcode_parity.py | 33 ++++++++++++++++ tests/test_readmelint_html_tokens.py | 59 ++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 tests/test_mdcode_parity.py 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_readmelint_html_tokens.py b/tests/test_readmelint_html_tokens.py index 8cdc84f..ab8b2a1 100644 --- a/tests/test_readmelint_html_tokens.py +++ b/tests/test_readmelint_html_tokens.py @@ -118,3 +118,62 @@ 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"] From 85c6ac8b5a167b22b2ed39eb488bccc65e97df2c Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 16:04:39 -0400 Subject: [PATCH 23/98] Fix image destinations with backticks and shortcut fallback in readmelint Parse inline image destinations from the unmasked document so literal backticks in filenames are kept. Fall through to shortcut-reference resolution when the inline destination does not parse, so ![CI](current build) with a defined [CI] reference still counts. Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/readmelint.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index 5ee6bb0..07b372b 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -226,11 +226,14 @@ def label(value): alt = text[match.end():close] consumed = close + 1 if consumed < len(text) and text[consumed] == "(": - target = _inline_image_target(text, consumed) + # 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, consumed) if target: - source, consumed = target - sources.append((alt, source, match.start(), consumed)) - continue + source, end = target + sources.append((alt, source, match.start(), end)) + consumed = end + continue + consumed = close + 1 # 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 consumed < len(text) and text[consumed] == "[": From f956e42290f30f35c7034a7fe1735f703268f0fb Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 16:05:27 -0400 Subject: [PATCH 24/98] mdcode: allow inline code spans across soft line breaks Remove the unused INLINE_CODE regex and its stale comment. Inline code now may continue across single newlines within one paragraph; blank lines, fence openers and ATX headings end the paragraph. code_spans scans consecutive non-fence lines as one chunk. Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/mdcode.py | 68 ++++++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/scripts/ctshowcase/mdcode.py b/scripts/ctshowcase/mdcode.py index 8d0eb91..59fbf92 100644 --- a/scripts/ctshowcase/mdcode.py +++ b/scripts/ctshowcase/mdcode.py @@ -8,9 +8,7 @@ # CommonMark: a backtick fence's info string has no backtick, so "```npm test``` runs" is inline code, not a fence FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}(?=[^`\n]*$)|~{3,})") -# Compatibility name only: readmelint still searches with it until it calls find_inline_code. Its one-backslash -# lookbehind does not implement the parity rule, so new callers must use find_inline_code instead. -INLINE_CODE = re.compile(r"(? int: @@ -21,11 +19,32 @@ def _run_end(text: str, i: int) -> int: return j +def _paragraph_end(text: str, pos: int) -> int: + """Index where the paragraph containing `pos` ends: the start of the first later line that is blank, a fence + opener or an ATX heading. A heading line containing `pos` ends at its own line end. Otherwise len(text).""" + line_start = text.rfind("\n", 0, pos) + 1 + if _ATX_HEADING.match(text[line_start:pos]): + eol = text.find("\n", pos) + return len(text) if eol < 0 else eol + nl = text.find("\n", pos) + while nl >= 0: + start = nl + 1 + eol = text.find("\n", start) + line = text[start:] if eol < 0 else text[start:eol] + if not line.strip(" \t\r") or FENCE_OPEN.match(line) or _ATX_HEADING.match(line): + return start + if eol < 0: + break + nl = eol + return len(text) + + def _closing_run(text: str, pos: int, k: int) -> tuple[int, int] | None: - """First maximal backtick run of exactly `k` backticks at or after `pos`, found before the line ends.""" + """First maximal backtick run of exactly `k` backticks at or after `pos` and before the paragraph ends.""" + limit = _paragraph_end(text, pos) while True: - p = text.find("`", pos) - if p < 0 or text.find("\n", pos, p) >= 0: + p = text.find("`", pos, limit) + if p < 0: return None q = _run_end(text, p) if q - p == k: @@ -37,7 +56,8 @@ 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 and contain no newline.""" + content must be non-empty. It may span single newlines within one paragraph, but not a blank line, a fence + opener line or an ATX heading line, so the closing run must come before any of those.""" i = start while True: i = text.find("`", i) @@ -67,19 +87,25 @@ def find_inline_code(text: str, start: int = 0) -> tuple[int, int] | None: 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.""" spans, pos, fence, fstart = [], 0, None, 0 + chunk, cstart = "", 0 # pending paragraph text outside fences, and its absolute start offset for line in text.splitlines(keepends=True): if fence is None: m = FENCE_OPEN.match(line) if m: + _scan_chunk(chunk, cstart, spans) + chunk = "" fence, fstart = m.group(1), pos + elif not line.strip(): + _scan_chunk(chunk, cstart, spans) + chunk = "" + elif _ATX_HEADING.match(line): + _scan_chunk(chunk, cstart, spans) + _scan_chunk(line, pos, spans) + chunk = "" else: - at = 0 - while True: - span = find_inline_code(line, at) - if span is None: - break - spans.append((pos + span[0], pos + span[1])) - at = span[1] + if not chunk: + cstart = pos + chunk += line 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): @@ -88,9 +114,23 @@ def code_spans(text: str) -> list[tuple[int, int]]: pos += len(line) if fence is not None: spans.append((fstart, len(text))) + else: + _scan_chunk(chunk, cstart, spans) return spans +def _scan_chunk(chunk: str, base: int, spans: list[tuple[int, int]]) -> None: + """Append the inline code spans of `chunk`, a run of non-fence lines, as absolute offsets (`chunk` starts at + `base`).""" + at = 0 + while True: + span = find_inline_code(chunk, at) + if span is None: + return + spans.append((base + span[0], base + span[1])) + at = span[1] + + def masked(text: str) -> str: """`text` with every code span blanked (same length, newlines kept), so marker searches skip documented examples and their offsets still index the original.""" From 408b3aa518df33918ff544f040793ac158a0ec00 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 16:08:41 -0400 Subject: [PATCH 25/98] test: cover multiline code spans, backtick image destinations and shortcut-reference fallback Adds multiline inline-code cases (soft breaks, blank line, fence and ATX heading boundaries, absolute offsets around fences, backslash parity), an existing and a missing image whose angle-delimited destination contains backticks, badge counting and local-file validation through the failed-inline-destination fallback, and a multiline placeholder paragraph that must not trip the placeholder check. No existing test asserted the removed no-newline limitation; none was changed or weakened. mdcode.INLINE_CODE has no importers (grep over the repo, tests and .github) and was removed. Co-Authored-By: Claude Sonnet 5.5 --- tests/test_mdcode_multiline.py | 55 +++++++++++++++++++++++++++++ tests/test_readmelint.py | 29 +++++++++++++++ tests/test_reference_badge_count.py | 18 ++++++++++ 3 files changed, 102 insertions(+) create mode 100644 tests/test_mdcode_multiline.py 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_readmelint.py b/tests/test_readmelint.py index 8401ef6..b3c3751 100644 --- a/tests/test_readmelint.py +++ b/tests/test_readmelint.py @@ -45,3 +45,32 @@ 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"] diff --git a/tests/test_reference_badge_count.py b/tests/test_reference_badge_count.py index 92f43fe..c4c08bf 100644 --- a/tests/test_reference_badge_count.py +++ b/tests/test_reference_badge_count.py @@ -12,6 +12,7 @@ def lint_reference(make_ctx, tmp_path, usage, definitions, files=None): (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") @@ -171,3 +172,20 @@ 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 From e6cf52875ec650c5a2323bffe83eae6c8b91682c Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 16:39:44 -0400 Subject: [PATCH 26/98] fix(readmelint): ignore reference definitions inside inline code and dedupe palette shields Definitions whose opening bracket sits inside a non-fenced code span are literal text and no longer become live image definitions (in _markdown_images and _definition_spans). The R7 palette check deduplicates the shields list before and after the parsed-image append so each off-palette colour is reported once. Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/readmelint.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index 07b372b..063cf85 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -45,9 +45,28 @@ def _search_unescaped(pattern, text: str, pos: int): return match +def _in_inline_code(text: str, index: int) -> bool: + """True when `index` lies strictly inside a non-fenced inline code span (a < index < b for a span (a, b)).""" + fences = _fence_spans(text) + start = 0 + while True: + found = find_inline_code(text, start) + if not found or found[0] > index: + return False + a, b = found + inside = next((f for f in fences if f[0] <= a < f[1]), None) + if inside is None: + if a < index < b: + return True + start = b + else: + start = inside[1] + + def _definition_spans(document: str) -> list[tuple[int, int]]: """Spans of complete reference definitions (label, destination and title): their text is not Markdown.""" - return [(m.start(), m.end()) for m in REF_DEFINITION.finditer(_outside_code(document, mask_inline=False))] + return [(m.start(), m.end()) for m in REF_DEFINITION.finditer(_outside_code(document, mask_inline=False)) + if not _in_inline_code(document, m.start())] class _HTMLTokens: @@ -205,6 +224,8 @@ def label(value): definitions = {} for match in REF_DEFINITION.finditer(_outside_code(document, mask_inline=False)): + if _in_inline_code(document, match.start()): + continue # a definition written inside a code span is literal text, not a live definition 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 @@ -340,6 +361,7 @@ def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: 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(dict.fromkeys(shields)) header_part = plain.split("\n---\n", 1)[0] badge_header = _outside_code(header_part) image_sources = [source for _, source in _images(badge_header, plain)] @@ -356,6 +378,7 @@ def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: # colours are read from parsed image destinations too, so an apostrophe or other HTML delimiter inside a Markdown URL hides nothing shields += [m.group(1).split("?")[0].split("#")[0] 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() family_cols = {str(ctx.style.get(k) or "").upper() for k in ("badge_brand", "badge_accent")} From 735a3612095638e6e7fc3f1e7c92543ff0f0ab7d Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 16:50:19 -0400 Subject: [PATCH 27/98] mdcode: decide block boundaries before inline code spans; keep link destinations literal Code spans no longer pair across list items, new quotes, thematic breaks, setext underlines, HTML blocks 1-6, ATX headings or table cells, while continued quote lines and type-7 tags stay one paragraph. Tests cover the reproduced regressions and a boundary interaction matrix. Co-Authored-By: Claude Sonnet 5.5 --- scripts/ctshowcase/mdcode.py | 268 +++++++++++++++++++++---------- scripts/ctshowcase/readmelint.py | 18 ++- tests/test_mdcode_blocks.py | 134 ++++++++++++++++ tests/test_readmelint.py | 64 ++++++++ 4 files changed, 393 insertions(+), 91 deletions(-) create mode 100644 tests/test_mdcode_blocks.py diff --git a/scripts/ctshowcase/mdcode.py b/scripts/ctshowcase/mdcode.py index 59fbf92..392aafd 100644 --- a/scripts/ctshowcase/mdcode.py +++ b/scripts/ctshowcase/mdcode.py @@ -1,14 +1,32 @@ """Markdown code spans, so marker searches skip documented examples. Stdlib only and imports nothing from the package: `readme` (and through it `readmelint` and `footer`) and the stdlib-only `meta` find marker blocks the same way. CommonMark escaping: the parity of the backslashes directly before a backtick decides escaping (odd = escaped, -even = real), for any number of backslashes.""" +even = real), for any number of backslashes. Block structure is decided before inline spans (CommonMark appendix A): +an inline code span never pairs across a block boundary.""" from __future__ import annotations +import functools import re # CommonMark: a backtick fence's info string has no backtick, so "```npm test``` runs" is inline code, not a fence FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}(?=[^`\n]*$)|~{3,})") +_FENCE_CLOSE = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*$") _ATX_HEADING = re.compile(r"^ {0,3}#{1,6}(?:[ \t\r\n]|$)") +_THEMATIC = re.compile(r"^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$") +_SETEXT = re.compile(r"^ {0,3}(?:=+|-+)[ \t]*$") +_BULLET_ITEM = re.compile(r"^ {0,3}[-+*][ \t]+\S") +_ORDERED_ITEM = re.compile(r"^ {0,3}(\d{1,9})[.)][ \t]+\S") +_ITEM_PREFIX = re.compile(r"^ {0,3}(?:[-+*]|\d{1,9}[.)])(?: {1,4}(?=\S)|[ \t]|$)") +_QUOTE = re.compile(r" {0,3}> ?") +_DELIMITER_CELLS = re.compile(r"\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*") +_TYPE_6 = ("address article aside base basefont blockquote body caption center col colgroup dd details dialog dir div dl " + "dt fieldset figcaption figure footer form frame frameset h1 h2 h3 h4 h5 h6 head header hr html iframe legend " + "li link main menu menuitem nav noframes ol optgroup option p param search section summary table tbody td " + "tfoot th thead title tr track ul").split() +# CommonMark HTML blocks 1-6 interrupt a paragraph; type 7 (any other complete tag, like or ) cannot +_HTML_BLOCK = re.compile( + r"^ {0,3}(?:<(?:script|pre|style|textarea)(?:[ \t>]|$)|\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")] + + +@pytest.mark.parametrize("boundary", ["***", "---", "___", "- - -", "

", "

", "", + "

", "", "## Heading", "===", "-"])
+def test_boundary_lines_stop_the_span(boundary):
+    t = "a ` b\n" + boundary + "\nc " + IMAGE + " `z`\n"
+    assert images(t) == [("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, "` + 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(*m.span()): if out[i] != "\n": out[i] = " " + pos = m.end() return "".join(out) From 418b1757b61676ce16d512223d347324f67416eb Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 18:29:23 -0400 Subject: [PATCH 34/98] mdcode: quoted fences strip only their own quote depth; regression tests for #7 round 1 Co-Authored-By: Claude Sonnet 5.5 --- scripts/ctshowcase/mdcode.py | 16 ++++++++++++++-- tests/test_mdcode_blocks.py | 25 +++++++++++++++++++++++++ tests/test_readmelint.py | 25 +++++++++++++++++++++++++ tests/test_reference_badge_count.py | 27 +++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/scripts/ctshowcase/mdcode.py b/scripts/ctshowcase/mdcode.py index a6c215d..e3a1c54 100644 --- a/scripts/ctshowcase/mdcode.py +++ b/scripts/ctshowcase/mdcode.py @@ -53,6 +53,16 @@ def _quotes(body: str) -> tuple[int, str]: 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 @@ -150,7 +160,9 @@ def row_cells(at, raw): 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`) - lead = len(body) - len(body.lstrip(" ")) if outer else len(rest) - len(rest.lstrip(" ")) + # 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 left the item's content column: the # fence ends unclosed at this line, and this line is then processed as an ordinary line @@ -159,7 +171,7 @@ def row_cells(at, raw): 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 = rest if fdepth else body + closer = inner if fdepth else body if col: closer = closer[min(len(closer) - len(closer.lstrip(" ")), col):] close = _FENCE_CLOSE.match(closer) diff --git a/tests/test_mdcode_blocks.py b/tests/test_mdcode_blocks.py index 15cad6e..c89e30c 100644 --- a/tests/test_mdcode_blocks.py +++ b/tests/test_mdcode_blocks.py @@ -231,3 +231,28 @@ def test_continuation_starts_hold_paragraph_continuation_lines_only(): assert {t.index("second line"), t.index("[a]: x")} <= starts for block_start in ("# Head", "first line", "- item", "fenced", "## Next"): assert t.index(block_start) not in starts + + +QUOTED_EXTRA = "> ```text\n> > ```\n> code\n> ```\n> ![Shot]()" + + +def test_extra_quote_markers_inside_a_quoted_fence_are_code_content(): + spans = mdcode.fence_spans(QUOTED_EXTRA) + closer = QUOTED_EXTRA.index("> code\n> ```\n") + len("> code\n> ```\n") + assert spans == ((0, closer),) + assert not covers(QUOTED_EXTRA, "![Shot]") + + +def test_extra_quote_markers_do_not_hide_the_image_after_the_real_closer(): + assert [src for _, src in images(QUOTED_EXTRA)] == ["docs/images/missing.png"] + + +def test_a_real_quoted_closer_still_closes(): + t = "> ```text\n> code\n> ```\n> ![Shot](docs/images/missing.png)" + assert mdcode.fence_spans(t) == ((0, t.index("> ![Shot]")),) + + +def test_a_depth_two_fence_still_opens_and_closes_on_its_own_depth(): + t = "> > ```text\n> > code\n> > ```\n> > ![Shot](docs/images/missing.png)" + assert mdcode.fence_spans(t) == ((0, t.index("> > ![Shot]")),) + assert [src for _, src in images(t)] == ["docs/images/missing.png"] diff --git a/tests/test_readmelint.py b/tests/test_readmelint.py index da9099c..636e888 100644 --- a/tests/test_readmelint.py +++ b/tests/test_readmelint.py @@ -277,3 +277,28 @@ def test_placeholder_inside_an_item_fence_is_code(make_ctx, tmp_path, brand, sni 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) diff --git a/tests/test_reference_badge_count.py b/tests/test_reference_badge_count.py index c4c08bf..b49a407 100644 --- a/tests/test_reference_badge_count.py +++ b/tests/test_reference_badge_count.py @@ -189,3 +189,30 @@ def test_fallback_reference_with_existing_local_definition_passes(make_ctx, tmp_ 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 \", 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 From b85e1704f76a613ccf54c75607eb8cdb1429e366 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 19:12:55 -0400 Subject: [PATCH 39/98] readmelint: read reference definitions inside quote and list containers (#7) Peel blockquote and list-item prefixes only for definition detection (NUL stand-ins keep offsets and the code/comment exclusions); definitions stay global. Add a linear-scaling regression for container definitions. Co-Authored-By: Claude Sonnet 5.5 --- scripts/ctshowcase/readmelint.py | 17 ++++++++++++++--- tests/test_mdcode_scaling.py | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index 00dbdd4..00cf917 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -19,9 +19,10 @@ IMG_HTML = re.compile(r"]*)>", re.I) PALETTE = {"1E3A8A", "005ADD", "067647", "64748B", "E551BA", "F386A1", "1E1E1E"} REF_DEFINITION = re.compile( - r"^ {0,3}\[((?:\\.|[^\]\\\n])+)\]:[ \t]*(?:\n[ \t]*)?(?:<([^<>\n]+)>|(\S+?))" - r"(?:(?:[ \t]+|[ \t]*\n[ \t]*)(?:\"(?:\\.|[^\"\\\n]|\n(?![ \t]*\n))*\"|'(?:\\.|[^'\\\n]|\n(?![ \t]*\n))*'" - r"|\((?:\\.|[^()\\\n]|\n(?![ \t]*\n))*\)))?[ \t]*$", re.M) # a title may span lines but never a blank one + r"^\x00* {0,3}\[((?:\\.|[^\]\\\n])+)\]:[ \t\x00]*(?:\n[ \t\x00]*)?(?:<([^<>\n]+)>|([^\s\x00]+?))" + r"(?:(?:[ \t]+|[ \t\x00]*\n[ \t\x00]*)(?:\"(?:\\.|[^\"\\\n]|\n(?![ \t\x00]*\n))*\"|'(?:\\.|[^'\\\n]|\n(?![ \t\x00]*\n))*'" + r"|\((?:\\.|[^()\\\n]|\n(?![ \t\x00]*\n))*\)))?[ \t]*$", re.M) +# a title may span lines but never a blank one; NUL stands in for a blanked quote or list-item prefix (see below) # CommonMark raw HTML: an open tag needs a valid tag name, valid attributes and a closing `>`; anything else is text _ATTR = r"""\s+[A-Za-z_:][A-Za-z0-9_.:-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?""" HTML_OPEN_TAG = re.compile(rf"<([A-Za-z][A-Za-z0-9-]*)((?:{_ATTR})*)\s*/?>") @@ -106,6 +107,16 @@ def _definition_candidates(document: str) -> str: if out[i] != "\n": out[i] = " " pos = m.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. + pos = 0 + for raw in re.findall(r"[^\n]*\n|[^\n]+", document): + body = raw.rstrip("\r\n") + line = "".join(out[pos:pos + len(body)]) + prefix = len(line) - len(mdcode._containers(line)[2]) + out[pos:pos + prefix] = ["\x00"] * prefix + pos += len(raw) return "".join(out) diff --git a/tests/test_mdcode_scaling.py b/tests/test_mdcode_scaling.py index 0fabcfa..b173c9b 100644 --- a/tests/test_mdcode_scaling.py +++ b/tests/test_mdcode_scaling.py @@ -81,3 +81,18 @@ def test_layout_is_built_once_per_document_across_the_whole_path(): readmelint._images(readmelint._outside_code(doc), doc) readmelint._definition_spans(doc) assert mdcode.WORK["layout_builds"] == 1 + + +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) From 182c37eb3fc9ce8d3447b64bc11a86bbd4f565ae Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 19:42:07 -0400 Subject: [PATCH 40/98] test(mdcode): regression tests for backticks inside raw HTML blocks (#7) Literal backticks inside a raw HTML block (div, details, table, and the types 1-5 pre/script/style/comment/PI/CDATA) pair as a Markdown code span today and hide the block's real from R7. Adds the exact finding scenario, a lint-level image read, a nested variant, a matrix of opener and closer pairs (types 1-5 get a blank line after the opener, so a block that ended at a blank line would pair the backticks), and controls for a type-6 block ending at a blank line, a type-7 inline tag paragraph and a type-1 block closed on its own line. The 12 regression cases fail on b85e170. Co-Authored-By: Claude Haiku 5.5 --- tests/test_mdcode_blocks.py | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_mdcode_blocks.py b/tests/test_mdcode_blocks.py index c89e30c..6ac0fc6 100644 --- a/tests/test_mdcode_blocks.py +++ b/tests/test_mdcode_blocks.py @@ -256,3 +256,62 @@ def test_a_depth_two_fence_still_opens_and_closes_on_its_own_depth(): t = "> > ```text\n> > code\n> > ```\n> > ![Shot](docs/images/missing.png)" assert mdcode.fence_spans(t) == ((0, t.index("> > ![Shot]")),) assert [src for _, src in images(t)] == ["docs/images/missing.png"] + + +# --- raw HTML blocks: literal backticks inside a raw HTML block never open a code span (CommonMark 4.6) ------------- +RAW_IMG = 'Shot' + + +def test_literal_backticks_inside_html_block_do_not_hide_the_image(): + t = '
\nUse a literal ` character.\n' + RAW_IMG + '\nAnother ` character.\n
\n' + assert mdcode.inline_code_spans(t) == () + assert not covers(t, '\n' + assert images(t) == [("Shot", "docs/images/missing.png")] + + +def test_nested_raw_html_blocks_keep_the_image_visible(): + t = "
\n
\nUse a literal ` character.\n" + RAW_IMG + "\nAnother ` character.\n
\n
\n" + assert mdcode.inline_code_spans(t) == () + assert not covers(t, RAW_IMG) + + +# Types 1-5 (pre, script, style, comment, PI, CDATA) end only at their end condition, so they get a blank line right +# after the opener: were the block to end at that blank line, the two backtick lines would pair and hide the image. +RAW_HTML_PAIRS = [ + pytest.param("
", "
", False, id="div"), + pytest.param("
", "
", False, id="details"), + pytest.param("", "
", False, id="table"), + pytest.param("
", "
", True, id="pre"), + pytest.param("", True, id="script"), + pytest.param("", True, id="style"), + pytest.param("", 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`"] From 011b20a7421b973f358b71f51f76d61d3825e889 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 19:45:49 -0400 Subject: [PATCH 41/98] mdcode: track raw HTML block extent so literal backticks stay literal (#7) An HTML block (CommonMark 4.6 types 1-7) is raw text: types 1-5 run to their own end condition, types 6 and 7 to the next blank line, inside quotes and list items with no lazy continuation. Its lines make no inline region, so a backtick there never pairs and the block's real stays visible to R7. Type 7 cannot interrupt a paragraph. Adds quote, list, type 7, unclosed and lint-level regressions; two html-token fixtures that began a line with a comment now put it inside a paragraph, since a line-leading comment is a raw HTML block. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + scripts/ctshowcase/mdcode.py | 52 ++++++++++++++++++++++++++-- tests/test_mdcode_blocks.py | 32 +++++++++++++++++ tests/test_readmelint.py | 21 +++++++++++ tests/test_readmelint_html_tokens.py | 7 ++-- 5 files changed, 108 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1780846..0ac7cfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo - 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. +- 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. - Windows contributor tests now use explicit UTF-8, native path basenames, a platform-independent read-only metadata stub, and Git Bash with stdin-safe Action scripts. All 15 issue #9 failures are individually documented; the full suite now runs in Windows, Linux and macOS CI. - Lint accepts file operands after options on older Python 3.12 versions while continuing to reject unrecognized options. - The Action's optional render arguments support macOS Bash 3.2, including an empty argument array with `set -u`. diff --git a/scripts/ctshowcase/mdcode.py b/scripts/ctshowcase/mdcode.py index e3a1c54..d1e50c2 100644 --- a/scripts/ctshowcase/mdcode.py +++ b/scripts/ctshowcase/mdcode.py @@ -4,7 +4,9 @@ even = real), for any number of backslashes. Block structure is decided before inline spans (CommonMark appendix A): an inline code span never pairs across a block boundary. A fence inside a block quote or a list item is container-aware: it opens and closes on the container's own lines, its quote and item markers are container syntax, and a line of lower -quote depth (or, in an item, a line left of the item's content column) ends it.""" +quote depth (or, in an item, a line left of the item's content column) ends it. A raw HTML block (CommonMark 4.6) is +tracked the same way: its lines are literal text, so a backtick inside one never opens a code span; types 1-5 run to +their own end condition, types 6 and 7 to the next blank line, and normal Markdown resumes after it.""" from __future__ import annotations import bisect @@ -36,6 +38,32 @@ + "|".join(_TYPE_6) + r")(?:[ \t]|/?>|$))", re.I) +# the seven HTML block kinds (CommonMark 4.6). Types 1-5 end at a marker found on a line (the start line included); +# types 6 and 7 end at the next blank line. Type 7 is one complete open or closing tag alone on its line and cannot +# interrupt a paragraph, so an inline tag in running text (`a` b`) stays Markdown. +_HTML_OPEN_1_5 = ( + (re.compile(r"^ {0,3}<(?:script|pre|style|textarea)(?:[ \t>]|$)", re.I), + re.compile(r"", re.I)), + (re.compile(r"^ {0,3}")), + (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 @@ -144,6 +172,8 @@ def _layout(text: str) -> tuple[tuple[tuple[int, int], ...], tuple[tuple[int, in 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 + # 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: @@ -179,6 +209,20 @@ def row_cells(at, raw): 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): + html = None # its quote or list item ended: an HTML block has no lazy continuation + 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 + else: + if end.search(rest): + html = None + continue if not rest.strip(" \t"): flush(seg) seg, table, base = [], False, 0 @@ -214,8 +258,12 @@ def row_cells(at, raw): opener = FENCE_OPEN.match(crest) if opener: fence, fstart = (opener.group(1), cdepth, col, bool(col) and not _QUOTE.match(body)), at - elif _ATX_HEADING.match(rest) or _HTML_BLOCK.match(rest): + 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 + if kind is True or not kind.search(crest): + 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) diff --git a/tests/test_mdcode_blocks.py b/tests/test_mdcode_blocks.py index 6ac0fc6..9695b34 100644 --- a/tests/test_mdcode_blocks.py +++ b/tests/test_mdcode_blocks.py @@ -315,3 +315,35 @@ def test_type_7_paragraph_still_pairs_its_backticks(): 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 = "`![F](y.png)`", []), - ("`![F](y.png)` ![G](g.png)", [("G", "g.png")]), + ("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 = '`![F](y.png)`' + 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"] From 8e3329ba4bbd5c27256985b1bbccd7bf7ad3bbbf Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 20:08:22 -0400 Subject: [PATCH 42/98] mdcode: expose raw HTML block spans from _layout Add html_block_spans(text) (lru_cached) returning sorted, non-overlapping (start, end) offsets of every raw HTML block. _layout now returns a 4th element with the spans; existing [0]-[2] callers are unchanged. Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/mdcode.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/scripts/ctshowcase/mdcode.py b/scripts/ctshowcase/mdcode.py index d1e50c2..9845095 100644 --- a/scripts/ctshowcase/mdcode.py +++ b/scripts/ctshowcase/mdcode.py @@ -160,14 +160,18 @@ def _interrupts(rest: str, depth: int, para_depth: int, base: int) -> bool: @functools.lru_cache(maxsize=16) -def _layout(text: str) -> tuple[tuple[tuple[int, int], ...], tuple[tuple[int, int], ...], tuple[int, ...]]: - """(inline regions, fenced spans, continuation starts). 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).""" +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) @@ -215,13 +219,16 @@ def row_cells(at, raw): 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): html = None # its quote or list item ended: an HTML block has no lazy continuation + 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: if end.search(rest): html = None + html_blocks.append((hstart, pos)) continue if not rest.strip(" \t"): flush(seg) @@ -262,7 +269,10 @@ def row_cells(at, raw): 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 - if kind is True or not kind.search(crest): + 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) @@ -271,7 +281,9 @@ def row_cells(at, raw): fences.append((fstart, len(text))) else: flush(seg) - return tuple(regions), tuple(fences), tuple(sorted(conts)) + 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) @@ -343,6 +355,12 @@ def fence_spans(text: str) -> tuple[tuple[int, int], ...]: 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 From 2a36ccb250efe838767699010667ee332312f986 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 20:09:11 -0400 Subject: [PATCH 43/98] readmelint: raw HTML blocks are literal for definitions, escapes and tags (#7) Reference definitions inside a raw HTML block are not live Markdown definitions, so they no longer register. Backslash escapes are literal inside raw HTML blocks: an escaped tag there is still scanned, while an escaped tag in ordinary Markdown stays excluded. Code-span masking applies the same exemption to tags. Block extent comes from mdcode.html_block_spans (sibling change, cached per document). Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/readmelint.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index 00cf917..b7619ab 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -41,10 +41,14 @@ def _escaped(text: str, i: int) -> bool: return count % 2 == 1 -def _search_unescaped(pattern, text: str, pos: int): - """First match at or after `pos` whose opening character is not backslash-escaped.""" +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(text, match.start()): + while match and escaped(match.start()): match = pattern.search(text, match.start() + 1) return match @@ -64,6 +68,12 @@ def _in_inline_code(text: str, index: int) -> bool: return _span_at(mdcode.inline_code_spans(text), index, strict=True) is not None +def _in_html_block(text: str, index: int) -> bool: + """True when `index` lies inside a raw HTML block (CommonMark HTML block extent): there a backslash is literal text, + and the block's lines are not Markdown reference definitions. The spans are cached per document by `mdcode`.""" + return _span_at(mdcode.html_block_spans(text), index) is not None + + def _badge_colour(path: str) -> str: """Upper-cased colour of a Shields badge path (the text after `/badge/`): its last segment, with the query and fragment removed. Shields escapes a literal dash as a pair `--`; a lone `-` is the structural separator. The scan @@ -94,6 +104,12 @@ def _definition_candidates(document: str) -> str: for i in range(a, b): if out[i] != "\n": out[i] = " " + # raw HTML block text is literal HTML, not Markdown: a `[label]: dest` line inside it defines nothing. The block's + # extent ends at its end condition, so a definition after the block still counts. + for a, b in mdcode.html_block_spans(document): + for i in range(a, b): + if out[i] != "\n": + out[i] = " " fences = mdcode.fence_spans(document) pos = 0 while (m := HTML_COMMENT.search(document, pos)): @@ -150,7 +166,7 @@ def __init__(self, text): self.images = [] for match in HTML_OPEN_TAG.finditer(text): start, end = match.span() - if _escaped(text, start): + 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": @@ -176,6 +192,7 @@ def _outside_code(text: str, mask_inline: bool = True) -> str: 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 @@ -210,7 +227,7 @@ def next_destination(start): 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) or False + 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_unescaped(HTML_COMMENT, text, pos) or False n = bisect.bisect_left(fences, (pos, -1)) From d59c631b90f5227a3ac1bd91439111ce2a2060a6 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 20:10:16 -0400 Subject: [PATCH 44/98] test: raw HTML block definitions, escapes and html_block_spans extents (#7) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_mdcode_html_spans.py | 33 +++++++++++++++++++++++++++++++++ tests/test_readmelint.py | 25 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 tests/test_mdcode_html_spans.py diff --git a/tests/test_mdcode_html_spans.py b/tests/test_mdcode_html_spans.py new file mode 100644 index 0000000..ab369b9 --- /dev/null +++ b/tests/test_mdcode_html_spans.py @@ -0,0 +1,33 @@ +"""Extents of raw HTML blocks (CommonMark 4.6), as exposed by mdcode.html_block_spans.""" +from ctshowcase import mdcode + + +def pieces(text): + return [text[a:b] for a, b in mdcode.html_block_spans(text)] + + +def test_type6_block_ends_before_the_blank_line(): + assert pieces("
\nraw\n\nafter\n") == ["
\nraw\n"] + + +def test_comment_block_ends_on_its_end_line(): + assert pieces("\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_readmelint.py b/tests/test_readmelint.py index 21a59ac..9bdf850 100644 --- a/tests/test_readmelint.py +++ b/tests/test_readmelint.py @@ -427,3 +427,28 @@ def test_backticks_after_the_html_block_ends_at_a_blank_line_still_hide_an_image 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"]) From 98fac0d9315b444fb815734df19aa1ce13a791c8 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 20:44:53 -0400 Subject: [PATCH 45/98] fix: end list-item fence at a dedented quote opened by a non-indented line (#7) A non-blank line at the fence's quote depth or deeper that is indented below the list item's content column now ends an unclosed list-item fence, so a dedented `>` after `- ```text` is processed as a real block quote instead of staying inside the fence. Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/mdcode.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/ctshowcase/mdcode.py b/scripts/ctshowcase/mdcode.py index 9845095..458ac73 100644 --- a/scripts/ctshowcase/mdcode.py +++ b/scripts/ctshowcase/mdcode.py @@ -197,9 +197,10 @@ def row_cells(at, raw): # 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 left the item's content column: the - # fence ends unclosed at this line, and this line is then processed as an ordinary line + 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 else: From 9d651ce2b6ac8d0ac7a1e7c5cac0ca325d41d68f Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 20:45:17 -0400 Subject: [PATCH 46/98] test: dedented quote after a list-item fence (#7) Add failing regressions for a dedented blockquote that follows a list-item fence (dash, star, plus, ordered markers), a blank line inside the item fence, two angle-bracketed badges after the fence, and the indented control cases. The dedented cases currently keep the quote inside the fence (mdcode fence_spans), so the image is not reported and badges are not counted. Co-Authored-By: Claude Haiku 5.5 --- tests/test_mdcode_blocks.py | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_mdcode_blocks.py b/tests/test_mdcode_blocks.py index 9695b34..41f8523 100644 --- a/tests/test_mdcode_blocks.py +++ b/tests/test_mdcode_blocks.py @@ -219,6 +219,47 @@ def test_dedent_below_the_content_column_ends_an_item_fence(): assert images(t) == [("x", "docs/images/missing.png")] +ITEM_MARKERS = [("- ", 2), ("* ", 2), ("+ ", 2), ("1. ", 3)] + + +@pytest.mark.parametrize("marker,indent", ITEM_MARKERS, ids=["dash", "star", "plus", "ordered"]) +def test_dedented_quote_after_item_fence_ends_the_fence(marker, indent): + pad = " " * indent + t = f"{marker}```text\n{pad}example\n> ![Shot]()\n\nOrdinary paragraph.\n" + assert len(mdcode.fence_spans(t)) == 1 + (a, b), = mdcode.fence_spans(t) + assert b == t.index("> ![Shot]") + assert images(t) == [("Shot", "docs/images/missing.png")] + + +@pytest.mark.parametrize("marker,indent", ITEM_MARKERS, ids=["dash", "star", "plus", "ordered"]) +def test_indented_quote_inside_item_fence_is_control_content(marker, indent): + pad = " " * indent + t = f"{marker}```text\n{pad}example\n{pad}> ![Shot]()\n{pad}```\n" + (a, b), = mdcode.fence_spans(t) + assert b == len(t) + assert images(t) == [] + + +def test_blank_line_does_not_end_item_fence_but_dedented_quote_does(): + t = "- ```text\n example\n\n> ![Shot]()\n" + (a, b), = mdcode.fence_spans(t) + assert b == t.index("> ![Shot]") + assert images(t) == [("Shot", "docs/images/missing.png")] + + +def test_two_angle_delimited_badges_after_item_fence_are_both_counted(): + t = "- ```text\n example\n> ![A]() ![B]()\n" + assert len(images(t)) == 2 + + +def test_deeper_quote_inside_quoted_item_fence_stays_content(): + t = "> - ```text\n> a\n> > ![x](docs/images/missing.png)\n> ```\n" + (a, b), = mdcode.fence_spans(t) + assert b == len(t) + assert images(t) == [] + + @pytest.mark.parametrize("t", ["- > ```text\n > a\n > ```\n", "> - ```text\n> a\n> ```\n"], ids=["item-quote", "quote-item"]) def test_composed_item_and_quote_fences_are_one_span(t): From b7b8409b80ffdd6c1a84a67ac2fd5790c4ffab41 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 20:48:01 -0400 Subject: [PATCH 47/98] mdcode: dedented line ends an item raw HTML block; lint regressions for the dedented quote (#7) Raw HTML block container check used depth == hdepth, so a dedented line opening a deeper quote stayed inside the block (same defect as the list-item fence). Adds lint-level image and badge regressions for the fence case and tests for the HTML block case. Co-Authored-By: Claude Sonnet 5.5 --- scripts/ctshowcase/mdcode.py | 5 +++-- tests/test_mdcode_blocks.py | 25 +++++++++++++++++++++++++ tests/test_readmelint.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/scripts/ctshowcase/mdcode.py b/scripts/ctshowcase/mdcode.py index 458ac73..b1a8f8c 100644 --- a/scripts/ctshowcase/mdcode.py +++ b/scripts/ctshowcase/mdcode.py @@ -218,8 +218,9 @@ def row_cells(at, raw): 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): - html = None # its quote or list item ended: an HTML block has no lazy continuation + 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"): diff --git a/tests/test_mdcode_blocks.py b/tests/test_mdcode_blocks.py index 41f8523..22384da 100644 --- a/tests/test_mdcode_blocks.py +++ b/tests/test_mdcode_blocks.py @@ -388,3 +388,28 @@ def test_type_7_tag_alone_on_a_line_is_raw_but_cannot_interrupt_a_paragraph(): def test_unclosed_type_2_block_runs_to_the_end_and_masks_nothing(): t = "", "
", "", "## Heading", "===", "-"])
 def test_boundary_lines_stop_the_span(boundary):
     t = "a ` b\n" + boundary + "\nc " + IMAGE + " `z`\n"
-    assert images(t) == [("x", "docs/images/missing.png")]
+    # 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]")
 
 
@@ -127,7 +134,8 @@ def test_matrix_marker_block_survives(kind, above):
 @pytest.mark.parametrize("above,tail", [("Price is 5` per unit", " `z`"), ("Run `make", " done`")])
 def test_matrix_image_stays_rendered(kind, above, tail):
     t = _stack(kind, above, IMAGE + tail)
-    assert images(t) == [("x", "docs/images/missing.png")], kind
+    # after a type 6 opener (`html6`) the payload line is inside the raw HTML block and its image syntax is literal text
+    assert images(t) == ([] if kind == "html6" else [("x", "docs/images/missing.png")]), kind
 
 
 @pytest.mark.parametrize("above,tail", [("Price is 5` per unit", " `z`"), ("Run `make", " done`")])
diff --git a/tests/test_readmelint.py b/tests/test_readmelint.py
index 9d0248c..8998787 100644
--- a/tests/test_readmelint.py
+++ b/tests/test_readmelint.py
@@ -486,3 +486,55 @@ def test_dedented_quote_badges_after_a_list_item_fence_are_counted(make_ctx, tmp
     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) From 663f0319bad61ad33d1905baba550b7a5234b543 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 21:22:30 -0400 Subject: [PATCH 49/98] test(badges): use valid GFM mixed fixtures and pin the adjacent-mix count (#7) Replace the invalid html=True fixture of test_local_profile_badge_plus_ci_passes_lint with markdown, html_both and html_blank_boundary cases. The adjacent HTML/Markdown mix, where the Markdown CI badge follows the HTML img with no blank line, renders one img on GitHub (gfm-d11-mixed_adjacent.html), so it is pinned as a separate negative test expecting "R7 2-4 header badges required (found 1)". The blank-line boundary renders two imgs (gfm-d11-mixed_blank_boundary.html). The scaffold emits Markdown-only badges for variant J, so the mixed fixtures are hand-built, not generated output. Co-Authored-By: Claude Haiku 5.5 --- tests/test_variant_j_badges.py | 39 +++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/tests/test_variant_j_badges.py b/tests/test_variant_j_badges.py index 71af76a..7b440f4 100644 --- a/tests/test_variant_j_badges.py +++ b/tests/test_variant_j_badges.py @@ -210,8 +210,15 @@ def test_real_template_bootstrap_replaces_inherited_badge(make_ctx, tmp_path): assert not any("badge" in f for f in profile_cli(ctx, tmp_path, "lint", "readme")["fails"]) -@pytest.mark.parametrize("html", [False, True]) -def test_local_profile_badge_plus_ci_passes_lint(make_ctx, tmp_path, html): +# GFM evidence (GitHub Markdown API, mode gfm): +# - gfm-d11-mixed_blank_boundary.html: a blank line between the HTML and the Markdown CI badge +# renders TWO elements, so a valid mixed fixture must keep that boundary. +# - gfm-d11-mixed_adjacent.html: with no blank line, the HTML block absorbs the Markdown CI badge, which +# stays literal text and renders exactly ONE . +# The scaffold only emits Markdown badges for variant J (badges_md) with --badges local, so neither +# HTML fixture below is generated output; the adjacent mix cannot be produced by the generator. +@pytest.mark.parametrize("mode", ["markdown", "html_both", "html_blank_boundary"]) +def test_local_profile_badge_plus_ci_passes_lint(make_ctx, tmp_path, mode): (tmp_path / ".github/workflows").mkdir(parents=True) (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n", encoding="utf-8") ctx = profile_context(make_ctx, tmp_path, license=None) @@ -219,13 +226,39 @@ def test_local_profile_badge_plus_ci_passes_lint(make_ctx, tmp_path, html): path = tmp_path / "README.md" text = path.read_text(encoding="utf-8") assert "docs/images/badges/type-github-profile.svg" in text - if html: + if mode == "html_both": text = re.sub(r"!\[([^\]]+)\]\((docs/images/badges/[^)]+)\)", r'\1', text) + text = re.sub(r"\[!\[([^\]]+)\]\(([^)]+)\)\]\(([^)]+)\)", + r'\1', text) + path.write_text(text, encoding="utf-8") + elif mode == "html_blank_boundary": + text = re.sub(r"!\[([^\]]+)\]\((docs/images/badges/[^)]+)\)", + r'\1', text) + text = text.replace(">\n[![CI]", ">\n\n[![CI]") + assert ">\n\n[![CI]" in text path.write_text(text, encoding="utf-8") assert not any("badge" in f for f in profile_cli(ctx, tmp_path, "lint", "readme")["fails"]) +def test_local_profile_adjacent_html_markdown_badge_mix_counts_one(make_ctx, tmp_path): + # Negative control for the GFM evidence in test_local_profile_badge_plus_ci_passes_lint: + # no blank line after the HTML , so the Markdown CI badge is absorbed as literal text. + (tmp_path / ".github/workflows").mkdir(parents=True) + (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n", encoding="utf-8") + ctx = profile_context(make_ctx, tmp_path, license=None) + profile_cli(ctx, tmp_path, "readme", "--mode", "scaffold", "--badges", "local", "--write") + path = tmp_path / "README.md" + text = path.read_text(encoding="utf-8") + assert "docs/images/badges/type-github-profile.svg" in text + text = re.sub(r"!\[([^\]]+)\]\((docs/images/badges/[^)]+)\)", + r'\1', text) + assert ">\n[![CI]" in text + path.write_text(text, encoding="utf-8") + result = profile_cli(ctx, tmp_path, "lint", "readme", expected_code=1) + assert "R7 2-4 header badges required (found 1)" in result["fails"] + + @pytest.mark.parametrize("mention", ["docs/images/badges/type-github-profile.svg", "```markdown\n![Example](docs/images/badges/type-github-profile.svg)\n```"]) def test_local_badge_prose_and_code_examples_do_not_count(make_ctx, tmp_path, mention): From 4ef02c4dd51b3e4c5aeda1abe28e30b416746038 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 21:55:58 -0400 Subject: [PATCH 50/98] test(readmelint): add regression cases for backslash comments and link-destination backticks Adds five regression tests for #7 covering raw-HTML comment backslash parity (1 and 3 backslashes), escaped comment outside raw HTML, and bare versus valid link-destination backticks. Tests for raw-HTML backslash comments and the bare destination case depend on the source fix and are expected to fail until it lands. Co-Authored-By: Claude Haiku 5.5 --- tests/test_readmelint.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_readmelint.py b/tests/test_readmelint.py index 8998787..f67c1a0 100644 --- a/tests/test_readmelint.py +++ b/tests/test_readmelint.py @@ -538,3 +538,35 @@ def work(n): 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 From 8adf5ff8f02fe64e9993a60ffab05f9c901a823a Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 31 Oct 2026 21:56:00 -0400 Subject: [PATCH 51/98] fix(readmelint): honour literal backslashes in raw HTML comments and require a link opener for destinations Pass html_spans to the HTML_COMMENT search so a backslash before \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), +} + + +@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") From c868e46e87060fff3da0a8d7daebf20e828afe28 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 01:04:27 -0400 Subject: [PATCH 66/98] test(#7): add consecutive reference control to orphan junction cases Adds the consecutive_reference_control case: two full references [a][ref`x] [b][ref`x] followed by a code-only image. GFM renders two links and zero images, so lint must report no failure. At HEAD this case fails with an R7 false positive, which the readmelint source fix must resolve. Also extends the GFM expectation comment to cover the controls. Co-Authored-By: Claude Haiku 5.5 --- tests/test_readmelint.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_readmelint.py b/tests/test_readmelint.py index 1971840..b713f76 100644 --- a/tests/test_readmelint.py +++ b/tests/test_readmelint.py @@ -319,7 +319,8 @@ def test_backtick_in_a_resolved_reference_label_does_not_pair_with_later_code(ma 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, both controls 0 images. +# GFM expectation (test data): orphan junction renders 1 image, raw comment boundary 1, and the inline-comment, +# valid-reference and consecutive-reference controls 0 images. IMAGE_IN_CODE = "`![Shot]()`" REF_DEF = "\n\n[ref`x]: https://example.test/" ORPHAN_JUNCTION_CASES = { @@ -327,6 +328,7 @@ def test_backtick_in_a_resolved_reference_label_does_not_pair_with_later_code(ma "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), } From 743d3908fbf84a0df21a8c77cd8520d47944de51 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 01:05:58 -0400 Subject: [PATCH 67/98] fix(#7): read a resolved reference label past inline code The opener pass stopped each label scan at the next inline code start, so a backtick inside a resolved reference label opened code that paired with a later backtick. In `[a][ref`x] [b][ref`x] `![Shot](...)` the second label's backtick then hid the image that GFM renders as code. The label of a full reference is now read up to the next non-inline inert region (tag, comment, fence or raw HTML block). The loop bound and the opener accounting are unchanged, so opener and failed-destination work counts match the previous head. Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/readmelint.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index cfb4c02..b8223a4 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -378,7 +378,8 @@ def _active_openers(text: str) -> frozenset[int]: 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. Inert regions are found + (`_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`.""" global _OPENER_STEPS @@ -437,6 +438,17 @@ def next_inline(start): regions.append(html_spans[h]) 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]) jump = None i = pos while i < limit: # plain text between inert regions: brackets are read one position at a time @@ -457,7 +469,8 @@ def next_inline(start): if not is_image: # completing a link deactivates every earlier link opener barrier = len(stack) break - form = _reference_form(text, open_idx, i, limit, keys) + # 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 `]` From 130c150b03cee616d83bdcbbefde9803c5dc8548 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 01:30:48 -0400 Subject: [PATCH 68/98] test(readme): add distinct-label and three-same-label consecutive reference controls Adds two GFM-matching controls to ORPHAN_JUNCTION_CASES per the 6dcc adjudication: two references with distinct backtick-bearing labels and three references with the same label, both rendering 0 images. Co-Authored-By: Claude Haiku 5.5 --- tests/test_readmelint.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_readmelint.py b/tests/test_readmelint.py index b713f76..e544e0b 100644 --- a/tests/test_readmelint.py +++ b/tests/test_readmelint.py @@ -320,7 +320,8 @@ def test_backtick_in_a_resolved_reference_label_does_not_pair_with_later_code(ma # GFM expectation (test data): orphan junction renders 1 image, raw comment boundary 1, and the inline-comment, -# valid-reference and consecutive-reference controls 0 images. +# 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 = { @@ -329,6 +330,11 @@ def test_backtick_in_a_resolved_reference_label_does_not_pair_with_later_code(ma "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), } From 9ab43471fe7ff154c66d07d8132f0c426cd9cef5 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 01:02:58 -0500 Subject: [PATCH 69/98] fix(readme): reject unbalanced bare definitions and make definitions inert openers - _definitions drops a REF_DEFINITION whose bare destination has unbalanced unescaped parentheses (CommonMark), so the line stays ordinary Markdown and cannot hide a rendered image or satisfy a badge count. - _active_openers treats each live definition span as an inert block region: brackets and backticks inside it are literal and the opener stack is cleared entering and leaving it, like raw HTML blocks. Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/readmelint.py | 53 +++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index b8223a4..2721d18 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -136,15 +136,37 @@ def _definition_candidates(document: str) -> str: return "".join(out) +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. Computed once per document.""" + 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) @@ -381,10 +403,11 @@ def _active_openers(text: str) -> frozenset[int]: (`_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`.""" + `_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 `![`) @@ -398,9 +421,14 @@ def next_inline(start): if not found: return False inside = _span_at(fences, found[0]) - if inside is None: - return found - start = inside[1] + 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) @@ -417,6 +445,13 @@ def next_inline(start): 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): @@ -436,6 +471,9 @@ def next_inline(start): 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 @@ -449,6 +487,8 @@ def next_inline(start): 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 @@ -503,6 +543,9 @@ def next_inline(start): 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) From 587dd58a0db2d307c98e9108bba2d046b6b43266 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 01:04:26 -0500 Subject: [PATCH 70/98] test(readme): cover inert definitions and balanced bare destinations; document lint rules (#7) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + references/readme-variants.md | 2 +- tests/test_reference_badge_count.py | 49 +++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 793531c..0916416 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo - 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. - 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 --bootstrap --write` no longer regenerates a marked footer that holds authored content. When the edited-template upgrade is refused by the never-drop-a-line check and the marked footer carries a partner or foreign image, the command now exits with a check failure (`readme-shrink`) and leaves `README.md` unchanged instead of falling back to a header refresh that rewrote the footer; a plain old footer copy still takes the fallback. The same refusal now applies in every bootstrap state (pristine and unmarked as well as edited), checked before any write, so a pristine or unmarked template can no longer silently replace a partner or foreign footer image. - Fork (G) and backup (H) READMEs migrate an unmarked trailing footer whose logo is a configured own-site historical source (`footer.legacy_logo_urls` or `docs/images/logo.svg`) to the single current footer; partner, foreign and look-alike images stay authored and are reported as no allowance. diff --git a/references/readme-variants.md b/references/readme-variants.md index f315682..bd3afd9 100644 --- a/references/readme-variants.md +++ b/references/readme-variants.md @@ -68,7 +68,7 @@ Variant J (`.github`) uses the `GitHub profile` type badge from `references/stan The source template's exact type badge is replaced with the target repository's badges during upgrade, header refresh and bootstrap, even after its body is edited or its template marker is removed. Custom authored replacements remain protected. Locally rendered badges under `docs/images/badges/*.svg` count toward R7 when referenced as Markdown or HTML images; merely mentioning an SVG path does not count. Markdown reference images (full, collapsed and shortcut) resolve their definitions anywhere in the document, with case-insensitive labels and the first definition winning. Inline and reference image alt text may contain nested or escaped brackets. HTML images accept quoted or unquoted attributes and case-insensitive attribute names. Only rendered header images count, not unused definitions, links, escaped syntax, HTML attribute text or code examples. All recognized images retain the alt-text and local-file checks. -Markdown angle-delimited destinations such as `![Screenshot]()` retain their local-file check. Backticks inside HTML attribute values are literal text, while Markdown code spans and fences remain excluded from image counting and validation. +Markdown angle-delimited destinations such as `![Screenshot]()` retain their local-file check. Backticks inside HTML attribute values are literal text, while Markdown code spans and fences remain excluded from image counting and validation. A bare reference destination must have balanced unescaped parentheses (an escaped parenthesis does not count); otherwise the line is not a definition and its content is read as ordinary Markdown, so it can neither hide an image nor satisfy a badge count. Live reference definitions are inert for opener scanning: a backtick or bracket inside one never pairs with markup on the following lines. Each label and message segment doubles `-` and `_` (shields.io's escapes) and is then percent-encoded, so `C#` reads `language-C%23-1E3A8A` and `#`, `?`, `/` or `%` never end the path early. diff --git a/tests/test_reference_badge_count.py b/tests/test_reference_badge_count.py index b49a407..120d4dd 100644 --- a/tests/test_reference_badge_count.py +++ b/tests/test_reference_badge_count.py @@ -216,3 +216,52 @@ def test_real_comment_containing_a_definition_still_hides_it(make_ctx, tmp_path) def test_escaped_comment_opener_does_not_start_a_comment(make_ctx, tmp_path): result = _count(make_ctx, tmp_path, r"Write \`) are searched after that column is removed. Items whose marker precedes their quote (`- > x`) are pushed at depth 0 and measured on the raw line. Public API and return shapes are unchanged. Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/mdcode.py | 46 ++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/scripts/ctshowcase/mdcode.py b/scripts/ctshowcase/mdcode.py index 3915e8e..77d7edc 100644 --- a/scripts/ctshowcase/mdcode.py +++ b/scripts/ctshowcase/mdcode.py @@ -176,8 +176,9 @@ def _layout(text: str) -> tuple[tuple[tuple[int, int], ...], tuple[tuple[int, in 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 - # content columns of the open depth-0 list items; they persist across blank lines (see the list update below) - lists: list[int] = [] + # 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 @@ -230,7 +231,8 @@ def row_cells(at, raw): 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: - if end.search(rest): + # 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 @@ -265,23 +267,37 @@ def row_cells(at, raw): 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 dedented non-blank line ends it. A depth-0 line - # pops the items it is dedented out of and an item marker pushes its content column; quoted lines leave `lists`. + # 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) - lead = len(body) - len(body.lstrip(" ")) - if cdepth == 0: - while lists and lead < lists[-1]: - lists.pop() - if col: - lists.append(col) + 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 at that item's content column - in_item = cdepth == 0 and col == 0 and bool(lists) and lead >= lists[-1] - item_opener = FENCE_OPEN.match(body[lists[-1]:]) if in_item else None + # 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), 0, lists[-1], True), at + 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: From 1b961eb3ac5c24952e74d273313adceb84553391 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 01:42:45 -0500 Subject: [PATCH 74/98] test(readme): regression cases for invalid labels, item HTML and quoted list fences (#7) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_mdcode_blocks.py | 646 +++++++++++++++++++----------------- tests/test_readmelint.py | 27 ++ 2 files changed, 367 insertions(+), 306 deletions(-) diff --git a/tests/test_mdcode_blocks.py b/tests/test_mdcode_blocks.py index 3dffb0d..607f116 100644 --- a/tests/test_mdcode_blocks.py +++ b/tests/test_mdcode_blocks.py @@ -1,285 +1,285 @@ -"""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")] - - +"""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"
+
+
+@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, ""], + "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, "\n{body}" + return f"\n{body}" def template_badge_body(brand) -> str: @@ -327,7 +327,7 @@ def legacy_profile_badges(brand, body: str, style: dict | None = None) -> bool: 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) == body_sha(match.group(3))) + return bool(match and match.group(1) == badge_sha(match.group(3))) def nav_first(ctx: RepoContext) -> bool: @@ -1181,6 +1181,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}") From 46be40ed1d497ee1f8eb2a114597175186eeaae9 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 02:03:19 -0500 Subject: [PATCH 77/98] fix(readme-lint): treat link titles as literal and respect HTML attributes when collecting definitions - Resolved inline link destinations and titles are literal text: `_link_literal_spans` marks them, and image scanners skip images, tags and attributes inside them. Linked images in the link text remain images. - `_definition_candidates` scans HTML open tags and comments left to right, so a comment opener inside an attribute value no longer blanks through a later live reference definition. Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/readmelint.py | 56 ++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index 7d18447..d2bdc7a 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -111,18 +111,38 @@ def _definition_candidates(document: str) -> str: if out[i] != "\n": out[i] = " " fences = mdcode.fence_spans(document) + html_spans = mdcode.html_block_spans(document) + # Left-to-right scan over the next HTML open tag and the next comment, as in `_outside_code`: a comment opener that + # sits inside a complete open tag (for example an attribute value) is attribute text, so the tag is consumed whole. pos = 0 - while (m := HTML_COMMENT.search(document, pos)): - start = m.start() + tag = comment = None + while True: + if tag is not False and (tag is None or tag.start() < pos): + tag = _search_unescaped(HTML_OPEN_TAG, document, pos, html_spans) or False + if comment is not False and (comment is None or comment.start() < pos): + comment = _search_unescaped(HTML_COMMENT, document, pos, html_spans) or False + found = [m for m in (tag, comment) if m] + if not found: + break + nxt = min(found, key=lambda m: m.start()) + start = nxt.start() + if nxt is tag: + # a tag inside inline code or a fence is text: the search resumes one character on. A real tag is consumed + # without blanking, so a `` 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(*m.span()): + for i in range(*nxt.span()): if out[i] != "\n": out[i] = " " - pos = m.end() + 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. @@ -671,6 +691,21 @@ def _inline_image_target(text: str, start: int) -> tuple[str, int] | 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): @@ -683,6 +718,7 @@ def label(value): 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 sources = [] consumed = 0 for match in re.finditer(r"!\[", text): @@ -690,6 +726,8 @@ def label(value): 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 @@ -735,7 +773,9 @@ def inside(spans, start, end): span = _span_at(spans, start) return span is not None and end <= span[1] - tags = [(start, end) for start, end in parser.tags if not inside(image_spans, start, end)] + 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 @@ -744,11 +784,13 @@ def written_alt(alt, start): 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)] + 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)] + if (start, end) in kept and not in_definition(start) and not in_link_literal(start)] return result From 24086a762ca37f6648fe200e046f2858a5faa7da Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 02:07:24 -0500 Subject: [PATCH 78/98] test(readme): link-title, comment-in-tag and badge digest regressions; CRLF-tolerant generated marker; docs (#7) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + SKILL.md | 2 +- references/readme-variants.md | 2 +- scripts/ctshowcase/readme.py | 2 +- tests/test_readmelint.py | 58 ++++++++++++++++++++++++++++++++++ tests/test_variant_j_badges.py | 45 ++++++++++++++++++++++++++ 6 files changed, 107 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95dded6..422a982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo - 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. - `readme --bootstrap --write` no longer regenerates a marked footer that holds authored content. When the edited-template upgrade is refused by the never-drop-a-line check and the marked footer carries a partner or foreign image, the command now exits with a check failure (`readme-shrink`) and leaves `README.md` unchanged instead of falling back to a header refresh that rewrote the footer; a plain old footer copy still takes the fallback. The same refusal now applies in every bootstrap state (pristine and unmarked as well as edited), checked before any write, so a pristine or unmarked template can no longer silently replace a partner or foreign footer image. - Fork (G) and backup (H) READMEs migrate an unmarked trailing footer whose logo is a configured own-site historical source (`footer.legacy_logo_urls` or `docs/images/logo.svg`) to the single current footer; partner, foreign and look-alike images stay authored and are reported as no allowance. - Own-site historical footer logo recognition is URL-derived: the company-website logo lives in `footer.legacy_logo_urls` in `references/brand.yaml` (lock refreshed through `config --write-lock`) and matches only exactly (scheme, host case-insensitively, port, path; no userinfo), so no brand host or path remains in Python. The generic local `docs/images/logo.svg` migration is a file convention, not brand data, and stays distinct. diff --git a/SKILL.md b/SKILL.md index 1006f47..5561b22 100644 --- a/SKILL.md +++ b/SKILL.md @@ -248,7 +248,7 @@ content sidebar --facts FILE [--packages FILE]... [--current FILE] [--repo OWNER | `intro` | Renders the profile intro (animated only for the personal profile and T1 flagship orgs; `.still` reduced-motion frame). | `--out` | | `footer` | Prints the decision 18 footer using the published masters in this skill's `assets/brand/`, between `showcase:footer` markers, or checks that the README ends with it; `--check --online` also HEADs both raw logo URLs and fails (`fail:offline`) unless both return 200 (the Phase 4.0 hard gate). `--org OWNER` answers `exempt`, `exempt:footer` or `hold` for an org outside the footer's scope; for the watching org, `--check` still reads the README (its `.github` gets the footer, and `--org` cannot tell it from a fork). | stdout | | `readme` | Scaffolds, upgrades or refreshes the header marker blocks; never drops a line (multiset check). `--bootstrap` fills a template body only while its marker's sha256 still matches (a person's edit makes the block theirs). | `README.md` with `--write`; `CONTRIBUTING.md` with `--bootstrap` | -| `lint` | SVG rules, README rules (R7 plus decisions 13 and 18) and banner PNG checks; `public-leak` checks any public file the run writes against the estate's private set (private slugs and names under every login of the owner, members-only pins, internal notes from the org rows, the registry's top-level notes and judge_changes and the profile's internal keys; OPS-03), reading the private registry and `--meta-dir` only; `prose` runs the CQ-17 checks on text the run writes (§1.2 brand strings, no emoji, no placeholders, the §4.1 formula for descriptions and taglines, typos when the optional `codespell` is installed; `--preserved` text only warns). | nothing | +| `lint` | SVG rules, README rules (R7 plus decisions 13 and 18; the destination and title of an inline link are literal text, never an image, and a comment opener inside a complete HTML tag is text) and banner PNG checks; `public-leak` checks any public file the run writes against the estate's private set (private slugs and names under every login of the owner, members-only pins, internal notes from the org rows, the registry's top-level notes and judge_changes and the profile's internal keys; OPS-03), reading the private registry and `--meta-dir` only; `prose` runs the CQ-17 checks on text the run writes (§1.2 brand strings, no emoji, no placeholders, the §4.1 formula for descriptions and taglines, typos when the optional `codespell` is installed; `--preserved` text only warns). | nothing | | `contact-sheet` | Lays outputs out the way GitHub shows them, on `#0d1117` and `#ffffff` (proofing surfaces, not asset variants). `--org ORG --cross-surface` is the G4 gate: the org avatar at 32 px, a hero banner, a repo card, the social preview and the org banner on one labelled sheet; a missing surface is logged `needs-input:cross-surface:` (result `incomplete`). | `PATH.on-github-dark.png`, `PATH.on-github-light.png` | | `doctor` | Prints the definition-of-done row: `R1`–`R11`, `banner`, `icon`, `card`, `footer`, `done`. No network. `--estate` classifies every repository in `--meta-dir` that `registry/estate.yaml` does not list with `decisions/POLICY.md` (`keep`, `move`, `archive`, `delete`, `merge`, `rename`, `rename-default-branch`; never acted on) and diffs the template-managed files of template-born checkouts against the current template (bump PRs are proposals). | `--out` (merges its own entries into `NEW_PROPOSALS.yaml`; others stay) | | `new` | Plans a repository from the template: the `gh repo create OWNER/NAME --template CompleteTech-LLC/completetech-repo-template --private` writer job, then `adopt` on it (naming N1–N4 and the §5.4 type are checked first; the watching org, placeholder orgs and refused owners exit 2). | `--records`, `--jobs` | diff --git a/references/readme-variants.md b/references/readme-variants.md index 5d58eda..7398409 100644 --- a/references/readme-variants.md +++ b/references/readme-variants.md @@ -68,7 +68,7 @@ Variant J (`.github`) uses the `GitHub profile` type badge from `references/stan The source template's exact type badge is replaced with the target repository's badges during upgrade, header refresh and bootstrap, even after its body is edited or its template marker is removed. Custom authored replacements remain protected. Locally rendered badges under `docs/images/badges/*.svg` count toward R7 when referenced as Markdown or HTML images; merely mentioning an SVG path does not count. Markdown reference images (full, collapsed and shortcut) resolve their definitions anywhere in the document, with case-insensitive labels and the first definition winning. Inline and reference image alt text may contain nested or escaped brackets. HTML images accept quoted or unquoted attributes and case-insensitive attribute names. Only rendered header images count, not unused definitions, links, escaped syntax, HTML attribute text or code examples. All recognized images retain the alt-text and local-file checks. -Markdown angle-delimited destinations such as `![Screenshot]()` retain their local-file check. Backticks inside HTML attribute values are literal text, while Markdown code spans and fences remain excluded from image counting and validation. A bare reference destination must have balanced unescaped parentheses (an escaped parenthesis does not count); otherwise the line is not a definition and its content is read as ordinary Markdown, so it can neither hide an image nor satisfy a badge count. Live reference definitions are inert for opener scanning: a backtick or bracket inside one never pairs with markup on the following lines. A definition label must not contain an unescaped opening bracket (`[bad[label]: ...` is ordinary Markdown whose image renders and is checked; `[ok\[label]: ...` stays a definition). Inside list items and block quotes the container syntax is removed before the block rules apply: raw HTML indented into an item is an HTML block whose literal backticks cannot hide its ``, and a fence opened on an item's continuation line (also inside a quote, `> - x` then `> ```text`) ends where a line leaves that item, so a later dedented image is still checked. +Markdown angle-delimited destinations such as `![Screenshot]()` retain their local-file check. Backticks inside HTML attribute values are literal text, while Markdown code spans and fences remain excluded from image counting and validation. A bare reference destination must have balanced unescaped parentheses (an escaped parenthesis does not count); otherwise the line is not a definition and its content is read as ordinary Markdown, so it can neither hide an image nor satisfy a badge count. Live reference definitions are inert for opener scanning: a backtick or bracket inside one never pairs with markup on the following lines. A definition label must not contain an unescaped opening bracket (`[bad[label]: ...` is ordinary Markdown whose image renders and is checked; `[ok\[label]: ...` stays a definition). Inside list items and block quotes the container syntax is removed before the block rules apply: raw HTML indented into an item is an HTML block whose literal backticks cannot hide its ``, and a fence opened on an item's continuation line (also inside a quote, `> - x` then `> ```text`) ends where a line leaves that item, so a later dedented image is still checked. The destination and title of a resolved inline link are literal text and not an image: `[Docs](https://example.test "![License](...)")` renders no image, so it neither counts as a badge nor needs a local file, while a linked image in the link text (`[![Shot](docs/images/shot.png)](https://example.test)`) is still read. A comment opener inside a complete HTML tag (for example ``) is attribute text, so it never blanks a later reference definition. The generated-badge digest preserves leading and trailing spaces on rows (four leading spaces make an indented code block, so that edit makes the block authored) and normalizes only line endings and the marker's separator newlines; the template-body digest is unchanged. Each label and message segment doubles `-` and `_` (shields.io's escapes) and is then percent-encoded, so `C#` reads `language-C%23-1E3A8A` and `#`, `?`, `/` or `%` never end the path early. diff --git a/scripts/ctshowcase/readme.py b/scripts/ctshowcase/readme.py index a67a935..21dcb1b 100644 --- a/scripts/ctshowcase/readme.py +++ b/scripts/ctshowcase/readme.py @@ -19,7 +19,7 @@ EMOJI = re.compile("[\U0001F000-\U0001FAFF☀-➿⬀-⯿️‍]") SHIELDS = "https://img.shields.io/badge/" GENERATED_BADGES = re.compile(r"\n(.*)", re.S) + r"(?: replaces=([0-9a-f]{64}))? -->\r?\n(.*)", re.S) BOM = "\ufeff" diff --git a/tests/test_readmelint.py b/tests/test_readmelint.py index 1f1b070..47dac91 100644 --- a/tests/test_readmelint.py +++ b/tests/test_readmelint.py @@ -1034,3 +1034,61 @@ def test_r3_nested_container_cases(make_ctx, tmp_path, brand, body, expect_missi 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 diff --git a/tests/test_variant_j_badges.py b/tests/test_variant_j_badges.py index 7b440f4..ecb3ed7 100644 --- a/tests/test_variant_j_badges.py +++ b/tests/test_variant_j_badges.py @@ -484,3 +484,48 @@ def test_local_inherited_template_badge_migrates_like_remote(make_ctx, tmp_path) 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")) + + +@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(body.replace("\n", "\r\n")) From e6d882d12f659f4188abba38bd07928431677781 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 02:16:53 -0500 Subject: [PATCH 79/98] test(readme): tab-indented badge digest ownership and untouched-row refresh controls Add a tab-indented first-row regression mirroring the 4-space control, an untouched generated badge refresh control on license change, and a tab assert in the badge digest ownership test. Co-Authored-By: Claude Haiku 5.5 --- tests/test_variant_j_badges.py | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_variant_j_badges.py b/tests/test_variant_j_badges.py index ecb3ed7..53e87c8 100644 --- a/tests/test_variant_j_badges.py +++ b/tests/test_variant_j_badges.py @@ -515,6 +515,40 @@ def test_crlf_converted_untouched_block_still_counts_as_generated(make_ctx, tmp_ 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) @@ -528,4 +562,5 @@ def test_marker_separator_newline_variations_still_validate(make_ctx, tmp_path, 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")) From 75cb59ab0012f90da6e0ce1713c82a2f64de2a24 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 02:17:41 -0500 Subject: [PATCH 80/98] docs(changelog): note badge digest ownership regression coverage (#7) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 422a982..e2f8c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo - 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. +- A committed regression covers badge digest ownership for tab and 4-space indentation, untouched-row refresh and CRLF normalization. - `readme --bootstrap --write` no longer regenerates a marked footer that holds authored content. When the edited-template upgrade is refused by the never-drop-a-line check and the marked footer carries a partner or foreign image, the command now exits with a check failure (`readme-shrink`) and leaves `README.md` unchanged instead of falling back to a header refresh that rewrote the footer; a plain old footer copy still takes the fallback. The same refusal now applies in every bootstrap state (pristine and unmarked as well as edited), checked before any write, so a pristine or unmarked template can no longer silently replace a partner or foreign footer image. - Fork (G) and backup (H) READMEs migrate an unmarked trailing footer whose logo is a configured own-site historical source (`footer.legacy_logo_urls` or `docs/images/logo.svg`) to the single current footer; partner, foreign and look-alike images stay authored and are reported as no allowance. - Own-site historical footer logo recognition is URL-derived: the company-website logo lives in `footer.legacy_logo_urls` in `references/brand.yaml` (lock refreshed through `config --write-lock`) and matches only exactly (scheme, host case-insensitively, port, path; no userinfo), so no brand host or path remains in Python. The generic local `docs/images/logo.svg` migration is a file convention, not brand data, and stays distinct. From 2cf18b911b1e15d354b844e07879965db67e7b2b Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 02:34:03 -0500 Subject: [PATCH 81/98] fix(readme): keep indented and edited badge rows authored Template recognition used str.strip(), so an indented or trailing-space copy of the inherited type row was treated as the template and replaced. Compare with line endings and separator newlines normalized instead, and derive legacy badge lines the same way so spaces and tabs stay significant. Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/readme.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/ctshowcase/readme.py b/scripts/ctshowcase/readme.py index 21dcb1b..9a3e9a9 100644 --- a/scripts/ctshowcase/readme.py +++ b/scripts/ctshowcase/readme.py @@ -233,12 +233,18 @@ 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 (existing or "").strip() in template_badge_bodies(ctx.brand)) + 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 @@ -270,7 +276,7 @@ def legacy_profile_badges(brand, body: str, style: dict | None = None) -> bool: 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 = body.splitlines() + lines = _eol_normalized(body).split("\n") if not 1 <= len(lines) <= 3: return False kinds = [] From 39afd905c0f40fa648b6bf4bf30de814807aa6c9 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 02:40:52 -0500 Subject: [PATCH 82/98] fix(readmelint): container definitions, link-title comments and boundary brackets - Definitions on list-item continuation lines: a non-blank line indented to the content column of an open list item (nested items stacked, within the same block quote, tabs advancing to multiples of four) has those columns peeled, so ` [ref]: dest` after `1. item` is read as a definition. Top-level indented lines with no open item stay indented code. - Comment scan: an HTML comment or tag opener inside a resolved inline link destination or title is literal text. The spans are computed once per document in one pass and read by bisection; no call into _definitions() (no recursion). - _closing_bracket returns None when the scan crosses a blank line, and reference images reject an alt span or label that contains a block boundary other than a soft line break. Fences come from the written document, so the layout is built once per document. Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/readmelint.py | 96 ++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index d2bdc7a..fdd2e24 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -112,6 +112,7 @@ def _definition_candidates(document: str) -> str: out[i] = " " fences = mdcode.fence_spans(document) html_spans = mdcode.html_block_spans(document) + links = _inline_link_spans(document, fences) # resolved link destinations and titles: literal text, computed once # Left-to-right scan over the next HTML open tag and the next comment, as in `_outside_code`: a comment opener that # sits inside a complete open tag (for example an attribute value) is attribute text, so the tag is consumed whole. pos = 0 @@ -126,6 +127,10 @@ def _definition_candidates(document: str) -> str: break nxt = min(found, key=lambda m: m.start()) start = nxt.start() + inside = _span_at(links, start) + if inside is not None: # an opener inside a link destination or title is text: resume after that span + pos = inside[1] + continue if nxt is tag: # a tag inside inline code or a fence is text: the search resumes one character on. A real tag is consumed # without blanking, so a `\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.""" diff --git a/tests/test_readmelint.py b/tests/test_readmelint.py index 47dac91..7f6b29d 100644 --- a/tests/test_readmelint.py +++ b/tests/test_readmelint.py @@ -1092,3 +1092,70 @@ def test_real_comment_with_tag_like_text_still_hides_a_definition(make_ctx, tmp_ '![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 = "` is an HTML block running to the end of the document: nothing after it is live doc = "\n{body}" @@ -1304,7 +1305,8 @@ def preservation(before: str, after: str, removed: list[str]) -> list[str]: replacements.add(match.group(2)) def omit_owned_badges(m): - owned = generated_badges(m.group(2)) or body_sha(m.group(2)) in replacements + # 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) From 0e34b765efa4ec43b237b4cfacaa7172fe57aabc Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 04:01:24 -0500 Subject: [PATCH 88/98] fix(readmelint): keep inline titles inside a paragraph and consume link openers - Destination parsing rejects a paragraph boundary in the leading whitespace, the whitespace before the title or `)`, the title itself and the whitespace after it. The boundary check is one bisection per region over cached blank-line starts. - The inline link span scan keeps a stack of link-text openers. Each `](` consumes one opener, so a completed link's opener cannot back an orphan destination. Co-Authored-By: Claude Haiku 5.5 --- scripts/ctshowcase/readmelint.py | 47 ++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index fdd2e24..274c6dd 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -193,25 +193,32 @@ def _whitespace_columns(text: str, target: int | None = None) -> tuple[int, int] 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. A `](` opens a span only when a `[` opened a link - text earlier in the same paragraph (no blank line between). Computed in one pass; the spans are sorted and disjoint.""" + 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 - last_open = None # the most recent unescaped `[` of the current paragraph + 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): + 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"): - last_open = None # a blank line ends the paragraph + 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() == "[": - if not _escaped(document, at): - last_open = at - elif last_open is not None and not _escaped(document, at) and not _in_inline_code(document, at): + 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])) @@ -697,6 +704,19 @@ def _reference_label_spans(document: str) -> tuple[tuple[int, int], ...]: 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 @@ -721,6 +741,8 @@ def _inline_image_target(text: str, start: int) -> tuple[str, int] | None: 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 @@ -753,9 +775,12 @@ def _inline_image_target(text: str, start: int) -> tuple[str, int] | None: 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 @@ -764,10 +789,14 @@ def _inline_image_target(text: str, start: int) -> tuple[str, int] | None: at = bisect.bisect_left(ends, pos) # the first unescaped end character at or after the title start if at == len(ends): return None - pos = ends[at] + 1 + if _blank_between(text, pos, ends[at]): # (c) a title may span lines, never a paragraph boundary + return None + 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 From c5c2e8851fd84ab005449a99730a0e630ae9ad7b Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 04:04:56 -0500 Subject: [PATCH 89/98] test+docs(readme): round-4 regressions for badge digests, paragraph-bounded titles and consumed openers (#7) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ SKILL.md | 2 +- references/readme-variants.md | 2 +- tests/test_readmelint.py | 53 +++++++++++++++++++++++++++++++++ tests/test_variant_j_badges.py | 54 ++++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e49fb5b..b5e4e44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo - 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 and hide a real HTML comment (#7). - `readme --bootstrap --write` no longer regenerates a marked footer that holds authored content. When the edited-template upgrade is refused by the never-drop-a-line check and the marked footer carries a partner or foreign image, the command now exits with a check failure (`readme-shrink`) and leaves `README.md` unchanged instead of falling back to a header refresh that rewrote the footer; a plain old footer copy still takes the fallback. The same refusal now applies in every bootstrap state (pristine and unmarked as well as edited), checked before any write, so a pristine or unmarked template can no longer silently replace a partner or foreign footer image. - Fork (G) and backup (H) READMEs migrate an unmarked trailing footer whose logo is a configured own-site historical source (`footer.legacy_logo_urls` or `docs/images/logo.svg`) to the single current footer; partner, foreign and look-alike images stay authored and are reported as no allowance. - Own-site historical footer logo recognition is URL-derived: the company-website logo lives in `footer.legacy_logo_urls` in `references/brand.yaml` (lock refreshed through `config --write-lock`) and matches only exactly (scheme, host case-insensitively, port, path; no userinfo), so no brand host or path remains in Python. The generic local `docs/images/logo.svg` migration is a file convention, not brand data, and stays distinct. diff --git a/SKILL.md b/SKILL.md index 024ca38..e5cba9c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -78,7 +78,7 @@ Run every command **from inside the target repository's checkout**, with the ski | 3 | `topics`, `labels --plan` | Proposals only. Add `--records FILE` to emit change records, `--gh` to print the exact `gh` commands. | | 4 | Apply the approved description and topics on GitHub (the writer), or write them into `meta.json` | Before any art: the banner subtitle, the card and the README tagline come from the description, so rendering first breaks R2 (tagline equals description) the moment the writer applies the new one. | | 5 | `banner`, then `card` (and `content social`) | `art-fail` (exit 3) writes nothing. `needs-input` (exit 4) names the missing fact, including fewer than two fact chips (`needs-input:banner-chips`; add `chips:` to `.github/showcase.yml` or pass `--key-chip`/`--chip`). | -| 6 | `readme`, then `readme --write` | Dry run prints a unified diff first. `readme-shrink` (exit 1) writes nothing. Variant J generates a GitHub-profile type badge and a license badge when the license is known; a content digest distinguishes generated rows that refresh from authored badge blocks that remain for linting. Template recognition normalizes only line endings and separator newlines, so an indented, tab-prefixed or trailing-space copy of the inherited type row is authored and is never replaced. | +| 6 | `readme`, then `readme --write` | Dry run prints a unified diff first. `readme-shrink` (exit 1) writes nothing. Variant J generates a GitHub-profile type badge and a license badge when the license is known; a whitespace-preserving content digest (also used for the migration `replaces=` identity) distinguishes generated rows that refresh from authored badge blocks that remain for linting. Template recognition normalizes only line endings and separator newlines, so an indented, tab-prefixed or trailing-space copy of the inherited type row is authored and is never replaced. | | 7 | `footer --check README.md`, `lint all` | Every applicable check passes; `incomplete` is allowed for logged `needs-input` omissions. | | 8 | `doctor --header` | One coverage row. `done` only when every applicable cell is `pass`, `warn`, `incomplete` or `n/a`, and the banner has two to four chips. | | 9 | `contact-sheet` | A human (or an independent verifier) views the set on both GitHub page colours before rollout. Pass PNGs: `card --png`, `org-tile --png` previews, or install the optional `resvg_py` (`requirements-preview.txt`) so SVG inputs (the intro's static frame) render too; otherwise an SVG input exits 2. | diff --git a/references/readme-variants.md b/references/readme-variants.md index 209a2ae..f8cb3de 100644 --- a/references/readme-variants.md +++ b/references/readme-variants.md @@ -68,7 +68,7 @@ Variant J (`.github`) uses the `GitHub profile` type badge from `references/stan The source template's exact type badge is replaced with the target repository's badges during upgrade, header refresh and bootstrap, even after its body is edited or its template marker is removed. Custom authored replacements remain protected. Locally rendered badges under `docs/images/badges/*.svg` count toward R7 when referenced as Markdown or HTML images; merely mentioning an SVG path does not count. Markdown reference images (full, collapsed and shortcut) resolve their definitions anywhere in the document, with case-insensitive labels and the first definition winning. Inline and reference image alt text may contain nested or escaped brackets. HTML images accept quoted or unquoted attributes and case-insensitive attribute names. Only rendered header images count, not unused definitions, links, escaped syntax, HTML attribute text or code examples. All recognized images retain the alt-text and local-file checks. -Markdown angle-delimited destinations such as `![Screenshot]()` retain their local-file check. Backticks inside HTML attribute values are literal text, while Markdown code spans and fences remain excluded from image counting and validation. A bare reference destination must have balanced unescaped parentheses (an escaped parenthesis does not count); otherwise the line is not a definition and its content is read as ordinary Markdown, so it can neither hide an image nor satisfy a badge count. Live reference definitions are inert for opener scanning: a backtick or bracket inside one never pairs with markup on the following lines. A definition label must not contain an unescaped opening bracket (`[bad[label]: ...` is ordinary Markdown whose image renders and is checked; `[ok\[label]: ...` stays a definition). Inside list items and block quotes the container syntax is removed before the block rules apply: raw HTML indented into an item is an HTML block whose literal backticks cannot hide its ``, and a fence opened on an item's continuation line (also inside a quote, `> - x` then `> ```text`) ends where a line leaves that item, so a later dedented image is still checked. The destination and title of a resolved inline link are literal text and not an image: `[Docs](https://example.test "![License](...)")` renders no image, so it neither counts as a badge nor needs a local file, while a linked image in the link text (`[![Shot](docs/images/shot.png)](https://example.test)`) is still read. A comment opener inside a complete HTML tag (for example ``) is attribute text, so it never blanks a later reference definition. The generated-badge digest preserves leading and trailing spaces on rows (four leading spaces make an indented code block, so that edit makes the block authored) and normalizes only line endings and the marker's separator newlines; the template-body digest is unchanged. The inherited type-row template is recognized the same way: only line endings (CRLF, CR) and the newlines that separate the row from the markers are normalized, so spaces and tabs stay significant and an indented, tab-prefixed or trailing-space row inside the `showcase:badges` markers is authored (no `replaces=` digest, preserved byte for byte by header refresh and upgrade), while the exact template, a CRLF-terminated or newline-padded body and the local SVG form are still replaced. A list item's continuation lines belong to the item: a reference definition indented to the content column of an open list item (nested items and quote containers included, tabs advancing to multiples of four) is a live definition, whereas a four-space indented definition with no open list item is indented code. A comment opener inside a resolved inline link destination or title (`[Docs](https://example.test "\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): + # The second `](` has no active opener, so ``, 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): @@ -1247,4 +1247,27 @@ def test_genuine_link_title_still_protects_its_comment_opener(make_ctx, tmp_path def test_code_span_bracket_is_not_a_link_opener(make_ctx, tmp_path): - assert ONE_BADGE in _header_with(make_ctx, tmp_path, '`[` ](x "' + assert MISSING in _usage_fails(make_ctx, tmp_path, brand, doc) + + +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 From a4f5cb7b33de17d6f7e2c41d3952f1c9395f3d94 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 04:16:00 -0500 Subject: [PATCH 92/98] docs(readme): inline HTML comments close within their paragraph (#7) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- references/readme-variants.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5e4e44..5c39303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo - 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 and hide a real HTML comment (#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 " [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 From 0af2a07f03873a3a851f9442d7c1431b2cff05bb Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 04:45:14 -0500 Subject: [PATCH 94/98] fix(readmelint): link titles end at block boundaries, multiline definition labels, tab residual indentation (#7) Co-Authored-By: Claude Sonnet 5.5 --- scripts/ctshowcase/readmelint.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index 0a15d1b..d68bbc8 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -19,7 +19,7 @@ IMG_HTML = re.compile(r"]*)>", re.I) PALETTE = {"1E3A8A", "005ADD", "067647", "64748B", "E551BA", "F386A1", "1E1E1E"} REF_DEFINITION = re.compile( - r"^\x00* {0,3}\[((?:\\.|[^\[\]\\\n])+)\]:[ \t\x00]*(?:\n[ \t\x00]*)?(?:<([^<>\n]+)>|([^\s\x00]+?))" + r"^\x00* {0,3}\[((?:\\.|[^\[\]\\\n]|\n(?![ \t\x00]*\n))+)\]:[ \t\x00]*(?:\n[ \t\x00]*)?(?:<([^<>\n]+)>|([^\s\x00]+?))" r"(?:(?:[ \t]+|[ \t\x00]*\n[ \t\x00]*)(?:\"(?:\\.|[^\"\\\n]|\n(?![ \t\x00]*\n))*\"|'(?:\\.|[^'\\\n]|\n(?![ \t\x00]*\n))*'" r"|\((?:\\.|[^()\\\n]|\n(?![ \t\x00]*\n))*\)))?[ \t]*$", re.M) # a title may span lines but never a blank one; NUL stands in for a blanked quote or list-item prefix (see below) @@ -189,7 +189,13 @@ def _definition_candidates(document: str) -> str: else: prefix = quoted if items and not blank: - prefix += _whitespace_columns(line[quoted:], items[-1][1])[0] + 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) @@ -804,8 +810,8 @@ def _inline_image_target(text: str, start: int) -> tuple[str, int] | None: at = bisect.bisect_left(ends, pos) # the first unescaped end character at or after the title start if at == len(ends): return None - if _blank_between(text, pos, ends[at]): # (c) a title may span lines, never a paragraph boundary - 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 From fdb8b09e81e71948d285d2104b606db16d8dbeeb Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 04:45:15 -0500 Subject: [PATCH 95/98] docs: block-bounded link titles, multiline definition labels and tab residual columns (#7) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- SKILL.md | 2 +- references/readme-variants.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ef2c2..8b4e16b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo - 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 "` closes in the same paragraph, so an orphan `](x "` closes in the same paragraph, so an orphan `](x "\n\n" + BADGE_LICENSE_LINE + "\n" + body = type_line + "\n" + middle + "\n\n" + LIC_DEFINITION + "\n\n-->\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"] @@ -1258,6 +1258,59 @@ def test_orphan_comment_opener_does_not_hide_a_missing_local_image(make_ctx, tmp 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) From 7d85785aa9966af8c284b1b415aa038a1bbd4b54 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 05:05:09 -0500 Subject: [PATCH 97/98] fix(readmelint): parenthesized titles reject unescaped '(', ordered markers other than 1. do not interrupt a paragraph (#7) Co-Authored-By: Claude Sonnet 5.5 --- scripts/ctshowcase/readmelint.py | 34 +++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index d68bbc8..8cd48c4 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -432,21 +432,32 @@ def _closing_bracket(text: str, start: int) -> int | None: # 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]|$)|(?:[-+*]|\d{1,9}[.)])(?:[ \t]|$)" + 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], ...]) -> bool: +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. Each check reads at most the two adjacent lines (the previous line is found backwards only as far + 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): @@ -458,6 +469,9 @@ def _block_boundary(text: str, newline: int, fences: tuple[tuple[int, int], ...] 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 @@ -471,8 +485,9 @@ def _crosses_block(text: str, start: int, end: int, fences: tuple[tuple[int, int """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): + if _block_boundary(text, newline, fences, base): return True newline = text.find("\n", newline + 1, end) return False @@ -657,7 +672,7 @@ def next_inline(start): if j < n and text[j] == "\n": # a blank line is a paragraph boundary stack.clear() barrier = 0 - elif _block_boundary(text, i, fences): # so is the start of a block without a blank line + 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 @@ -740,9 +755,9 @@ def _blank_between(text: str, a: int, b: int) -> bool: @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 + """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]] = {'"': [], "'": [], ")": []} + 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 == "\\": @@ -810,6 +825,11 @@ def _inline_image_target(text: str, start: int) -> tuple[str, int] | None: 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 From f9d635837f73a420afe4dc275a33186669a97d6e Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 1 Nov 2026 05:05:56 -0500 Subject: [PATCH 98/98] docs: parenthesized-title parens and ordered-marker paragraph interruption in README lint (#7) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + SKILL.md | 2 +- references/readme-variants.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b4e16b..c6bf1b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo - `content social` renders the personal-profile capstone and backup repositories wherever R10 and the `adopt` upload job include them; it previously exempted them, so the planned upload had no image. Where R10 leaves them out (the archive org, an archived capture) they stay exempt. - Label job plans include a per-repository `labels:OWNER/REPO` group for writer reconciliation. Explicit orchestrator groups are preserved. - `adopt` withholds jobs and change records for an archived capture, with instructions to re-capture inside the approved unarchive/polish/re-archive cycle. Security jobs are emitted only for active eligible repositories. +- README lint rejects an unescaped opening parenthesis inside a parenthesized link title (`[Docs](https://example.test (see ![Shot](docs/images/missing.png)))` is not a link, so Shot renders and its missing file is reported; backslash-escaped parentheses stay a valid title) and applies the paragraph-interruption rule to ordered-list markers in multiline image alt text and labels: only `1.` interrupts (a sibling item of an enclosing list still does), so a `2.` continuation line no longer drops a Variant J header badge. Regressions cover each case with its control (#7). ## 1.0.0 — 2026-10-26 diff --git a/SKILL.md b/SKILL.md index 36f1423..907924e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -78,7 +78,7 @@ Run every command **from inside the target repository's checkout**, with the ski | 3 | `topics`, `labels --plan` | Proposals only. Add `--records FILE` to emit change records, `--gh` to print the exact `gh` commands. | | 4 | Apply the approved description and topics on GitHub (the writer), or write them into `meta.json` | Before any art: the banner subtitle, the card and the README tagline come from the description, so rendering first breaks R2 (tagline equals description) the moment the writer applies the new one. | | 5 | `banner`, then `card` (and `content social`) | `art-fail` (exit 3) writes nothing. `needs-input` (exit 4) names the missing fact, including fewer than two fact chips (`needs-input:banner-chips`; add `chips:` to `.github/showcase.yml` or pass `--key-chip`/`--chip`). | -| 6 | `readme`, then `readme --write` | Dry run prints a unified diff first. `readme-shrink` (exit 1) writes nothing. Variant J generates a GitHub-profile type badge and a license badge when the license is known; a whitespace-preserving content digest (also used for the migration `replaces=` identity) distinguishes generated rows that refresh from authored badge blocks that remain for linting. Template recognition normalizes only line endings and separator newlines, so an indented, tab-prefixed or trailing-space copy of the inherited type row is authored and is never replaced. README lint reads link titles to the end of their block (a heading, list item or fence ends one; a soft line break does not), accepts reference definition labels across a soft line break, and keeps the residual columns of a tab that crosses a list content column (four make code, not a definition). | +| 6 | `readme`, then `readme --write` | Dry run prints a unified diff first. `readme-shrink` (exit 1) writes nothing. Variant J generates a GitHub-profile type badge and a license badge when the license is known; a whitespace-preserving content digest (also used for the migration `replaces=` identity) distinguishes generated rows that refresh from authored badge blocks that remain for linting. Template recognition normalizes only line endings and separator newlines, so an indented, tab-prefixed or trailing-space copy of the inherited type row is authored and is never replaced. README lint reads link titles to the end of their block (a heading, list item or fence ends one; a soft line break does not), accepts reference definition labels across a soft line break, and keeps the residual columns of a tab that crosses a list content column (four make code, not a definition). A parenthesized link title that holds an unescaped `(` is no title (the link is invalid, so an image inside it renders and is checked; escaped parentheses stay valid), and an ordered-list marker other than `1.` does not interrupt a multiline image alt or label outside a list (a sibling item of an enclosing list still does). | | 7 | `footer --check README.md`, `lint all` | Every applicable check passes; `incomplete` is allowed for logged `needs-input` omissions. | | 8 | `doctor --header` | One coverage row. `done` only when every applicable cell is `pass`, `warn`, `incomplete` or `n/a`, and the banner has two to four chips. | | 9 | `contact-sheet` | A human (or an independent verifier) views the set on both GitHub page colours before rollout. Pass PNGs: `card --png`, `org-tile --png` previews, or install the optional `resvg_py` (`requirements-preview.txt`) so SVG inputs (the intro's static frame) render too; otherwise an SVG input exits 2. | diff --git a/references/readme-variants.md b/references/readme-variants.md index 1013f16..a54dd2a 100644 --- a/references/readme-variants.md +++ b/references/readme-variants.md @@ -68,7 +68,7 @@ Variant J (`.github`) uses the `GitHub profile` type badge from `references/stan The source template's exact type badge is replaced with the target repository's badges during upgrade, header refresh and bootstrap, even after its body is edited or its template marker is removed. Custom authored replacements remain protected. Locally rendered badges under `docs/images/badges/*.svg` count toward R7 when referenced as Markdown or HTML images; merely mentioning an SVG path does not count. Markdown reference images (full, collapsed and shortcut) resolve their definitions anywhere in the document, with case-insensitive labels and the first definition winning. Inline and reference image alt text may contain nested or escaped brackets. HTML images accept quoted or unquoted attributes and case-insensitive attribute names. Only rendered header images count, not unused definitions, links, escaped syntax, HTML attribute text or code examples. All recognized images retain the alt-text and local-file checks. -Markdown angle-delimited destinations such as `![Screenshot]()` retain their local-file check. Backticks inside HTML attribute values are literal text, while Markdown code spans and fences remain excluded from image counting and validation. A bare reference destination must have balanced unescaped parentheses (an escaped parenthesis does not count); otherwise the line is not a definition and its content is read as ordinary Markdown, so it can neither hide an image nor satisfy a badge count. Live reference definitions are inert for opener scanning: a backtick or bracket inside one never pairs with markup on the following lines. Link titles end at block boundaries (a heading, list item or fence), while soft line breaks stay inside the title; a reference definition label may span a soft line break but not a blank line; a tab that crosses a list item's content column keeps its residual indentation, so four remaining columns make indented code, not a definition. A definition label must not contain an unescaped opening bracket (`[bad[label]: ...` is ordinary Markdown whose image renders and is checked; `[ok\[label]: ...` stays a definition). Inside list items and block quotes the container syntax is removed before the block rules apply: raw HTML indented into an item is an HTML block whose literal backticks cannot hide its ``, and a fence opened on an item's continuation line (also inside a quote, `> - x` then `> ```text`) ends where a line leaves that item, so a later dedented image is still checked. The destination and title of a resolved inline link are literal text and not an image: `[Docs](https://example.test "![License](...)")` renders no image, so it neither counts as a badge nor needs a local file, while a linked image in the link text (`[![Shot](docs/images/shot.png)](https://example.test)`) is still read. A comment opener inside a complete HTML tag (for example ``) is attribute text, so it never blanks a later reference definition. The generated-badge digest preserves leading and trailing spaces on rows (four leading spaces make an indented code block, so that edit makes the block authored) and normalizes only line endings and the marker's separator newlines; the template-body digest is unchanged. The inherited type-row template is recognized the same way: only line endings (CRLF, CR) and the newlines that separate the row from the markers are normalized, so spaces and tabs stay significant and an indented, tab-prefixed or trailing-space row inside the `showcase:badges` markers is authored (no `replaces=` digest, preserved byte for byte by header refresh and upgrade), while the exact template, a CRLF-terminated or newline-padded body and the local SVG form are still replaced. A list item's continuation lines belong to the item: a reference definition indented to the content column of an open list item (nested items and quote containers included, tabs advancing to multiples of four) is a live definition, whereas a four-space indented definition with no open list item is indented code. A comment opener inside a resolved inline link destination or title (`[Docs](https://example.test "` closes in the same paragraph, so an orphan `](x "` closes in the same paragraph, so an orphan `](x "