From 16c81e5f9d48066d14865007194e5f0627099df4 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Fri, 30 Oct 2026 08:56:56 -0400 Subject: [PATCH 01/23] content icon: --strict and family key icon_strict_family_ink (E3.2.1) Family-ink fails instead of warning when `--strict` is given on `content icon check` or `content icon normalize`, or when the family sets `icon_strict_family_ink: true`. Off by default; the default output and exit codes are unchanged. With no family palette the family-ink check is still not emitted. Co-Authored-By: Claude Haiku 5.5 --- CHANGELOG.md | 1 + references/brand.schema.json | 3 ++ references/content.md | 6 ++- scripts/ctshowcase/content.py | 15 +++++--- tests/test_content.py | 69 +++++++++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46719ce..2da2ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo ### Added +- `content icon check` and `content icon normalize` take `--strict` (and the family key `icon_strict_family_ink: true`, off by default) to fail instead of warn when the dominant icon ink is not a family ink. Without a family palette the family-ink check is still not emitted. - `org labels ORG --repos-from-gh` plans labels across accessible repositories without the private estate registry, with paginated read-only GitHub capture, optional verified local checkouts, unchanged reference guards, and a documented org-default-forms-first workflow. Registry-backed plans still require the org registry row, and checkout repository names match GitHub case-insensitively, including resolved pending renames. Registry-free runs also isolate lazy brand lookups from estate decision files, and held reference rewrites retain their file/line guidance with an incomplete result. Workflow/labeler/release/Renovate references remain final skips rather than unresolved input requests. Missing Windows device-named checkouts fall back to captured metadata instead of aborting the org plan. Pending transfers check the repository's current owner's default issue forms and hold renames when that evidence is unavailable. Sparse or partially missing checkouts retain reference holds based on captured paths and the Git index, while unrelated omitted files remain allowed. ### Fixed diff --git a/references/brand.schema.json b/references/brand.schema.json index 9e40fc9..0b54f70 100644 --- a/references/brand.schema.json +++ b/references/brand.schema.json @@ -223,6 +223,9 @@ }, "icon_pixel_art": { "type": "boolean" + }, + "icon_strict_family_ink": { + "type": "boolean" } } }, diff --git a/references/content.md b/references/content.md index 835907e..5dde9ff 100644 --- a/references/content.md +++ b/references/content.md @@ -27,8 +27,8 @@ Every action also takes `--report FILE` (the JSON result), `--jobs FILE` (append |---|---|---|---| | `social` | R10 | The 1280×640 repository social preview (the contract name; the top-level `social-preview` is a hidden, deprecated alias with the same options and file), plus with `--job` the `browser` upload job (Settings → Social preview → Edit → Upload, then a read-only `usesCustomOpenGraphImage` check). Public T1 and T2 repositories only, the personal-profile capstone and backups included; forks, archived, `.github` and Pages repositories get no job. The image's footer reads `github.com//`, the slug the job uploads to (after an approved org rename, never the former login); when the rendered footer and the job's slug differ, the job is withheld (`needs-input:social-login`, exit 1). | `--out` | | `icon prompt` | RT-04 | Fills the repository-icon prompt from the art direction (option A: the Neon Night glyph; option B: the isometric illustration) and the repository's facts. The glyph itself is the writer's: `needs-writer` (exit 1) until `--glyph` supplies `glyph_name`, `glyph_description`, `spark_placement` and `size_class`; nothing is written before then. Option B appends the family addition (the trademark negatives in `references/art-direction.md`, such as the TCG one) found under any login of the org (CQ-03) or its display name, so a re-keyed `brand.yaml` row keeps it; when the org's addition is not in the art direction, the prompt is refused (`needs-input:family-additions`) rather than written without it. | `--out` | -| `icon normalize` | RT-04 | Keys a returned candidate to transparency (or keeps an opaque night tile full-bleed), trims and fits it to the 688 px live area of a 1024 px RGBA PNG, then runs the QA. A failing QA writes nothing (`art-fail:icon-qa`, exit 1). | `--out` (default `assets/icon.png` in the checkout), `--preview` | -| `icon check` | RT-04 | Automated icon QA: size, byte budget, alpha, live area, coverage, contrast on the card, family inks, not the org glyph, one file. OCR and the 48 px set sheet stay manual. | nothing | +| `icon normalize` | RT-04 | Keys a returned candidate to transparency (or keeps an opaque night tile full-bleed), trims and fits it to the 688 px live area of a 1024 px RGBA PNG, then runs the QA. A failing QA writes nothing (`art-fail:icon-qa`, exit 1). | `--out` (default `assets/icon.png` in the checkout), `--preview`, `--strict` | +| `icon check` | RT-04 | Automated icon QA: size, byte budget, alpha, live area, coverage, contrast on the card, family inks, not the org glyph, one file. OCR and the 48 px set sheet stay manual. | `--strict` | | `links` | CQ-04, RP-09 | Every relative link, anchor and image target in the displayed README (GitHub's `.github/`, root, `docs/` precedence, or `--readme-path`), `docs/**`, `.github/*.md` and the org profile. With `--base-ref`, only breakage the change introduces fails; `--strict` fails any. `--online` adds cached, polite remote checks (HEAD first, one request per host per `--delay` seconds, at most `--max-remote`) and `gh` `full_name` checks for GitHub links, so a renamed org or repository is proposed under its current name. Offline, a link to this repository under its former slug (a transfer left its badges, release and clone URLs on the old org) is `moved` with a rewrite proposal only when an approved repository `rename` or `transfer` action in `decisions/estate-actions.json` records that exact move (the resolver's `repo_before`, CQ-03); a repository of the same name under another org is a different repository and is never rewritten (every org has its own `.github`). Every URL `references/brand.yaml` holds (the decision 18 footer's href and logo URLs, the brand and hub sites) is fixed content: it is still checked, but no org rename, transfer or online `full_name` answer proposes rewriting it. Rewrites match whole targets, so `…/tests.yml` never half-rewrites `…/tests.yml/badge.svg`. Broken links carry proposals; `--diff` prints them as a unified diff that is never applied. | `--cache` | | `linguist` | RP-07, CQ-08 | Paths that distort the language bar (vendored, generated, docs and example trees), the proposed append-only `# ct-std linguist` block for `.gitattributes`, the bar before and after, and the primary language a chip, card dot or badge may show (manifest runtime, then a `language:` override, then the corrected top language). | nothing | | `actions` | RP-06, RP-01, CQ-13 | Red runs and README status badges for failing workflows, upstream-only automation in forks (CLA, stale, labeler), and what a README merge would start (deploys first). Workflow on/off changes are proposals that need a `workflow-toggle` approval (`--approval WORKFLOW=ID`, keyed by the workflow file name); the job's argv names the workflow file (`gh workflow disable stale.yml -R OWNER/REPO`), never its numeric id, because the guard matches the approval's `detail` against it. | nothing | @@ -43,6 +43,8 @@ Each action prints one JSON object (`command: "content"`, `action`, `owner`, `re None by default. `links --online` and `images --online` make cached, rate-limited HEAD/GET requests to the hosts the files link to and read-only `gh api` GETs; `--cache` is the only state they keep. Local, private-network and `example.*` hosts are allowlisted and never fetched. +`--strict` (on `icon check` and `icon normalize`, also set by the family key `icon_strict_family_ink: true` in `references/brand.yaml`, which is off by default) makes the family-ink check fail instead of warn when the icon's dominant ink is not a family ink. Without the flag it stays a warning and the exit code is unchanged. With no family palette (`icon_ink` and `icon_spark` absent) the family-ink check is not emitted, so `--strict` has no effect there and `needs-input:icon-palette` is still logged. + ## Without the estate bundle For an older custom `--config` that omits `icon_ink` or `icon_spark`, normalization diff --git a/scripts/ctshowcase/content.py b/scripts/ctshowcase/content.py index 1245921..17f4684 100644 --- a/scripts/ctshowcase/content.py +++ b/scripts/ctshowcase/content.py @@ -2128,9 +2128,10 @@ def _dhash(img, size: int = 16) -> int: def icon_qa(data: bytes, *, card: str, ink: list[str] | None = None, org_mark: Path | None = None, - siblings: list[str] | None = None) -> dict: + siblings: list[str] | None = None, strict: bool = False) -> dict: """Automated icon checks (f.3, art QA): size, budget, alpha, live area, coverage, contrast on the card, family - inks, not the org glyph, one file. OCR and the 48 px set sheet stay manual.""" + inks, not the org glyph, one file. OCR and the 48 px set sheet stay manual. `strict` makes a failing family-ink + check fail the icon (default: warn); with no `ink` the family-ink check is not emitted at all.""" from PIL import Image from .color import contrast, hexstr, rgb as hexrgb from .pnglint import validate_png @@ -2190,7 +2191,8 @@ def add(name, ok, detail="", level="fail"): add("contrast-on-card", cr >= 3.0, f"dominant ink {dom} on {card}: {cr:.2f}:1 (>= 3:1)") if ink: near = min(_de(lab(hexrgb(dom)), lab(hexrgb(x))) for x in ink) - add("family-ink", near <= 12, f"dominant ink {dom}, nearest family ink ΔE {near:.1f}", level="warn") + add("family-ink", near <= 12, f"dominant ink {dom}, nearest family ink ΔE {near:.1f}", + level="fail" if strict else "warn") else: add("contrast-on-card", False, "no opaque ink") if org_mark and Path(org_mark).is_file() and Path(org_mark).stat().st_size > 0: @@ -2256,6 +2258,8 @@ def add_parser(sub, cli=None): "(default assets/icon.png in the checkout); check: the icon to check") a.add_argument("--preview", help="normalize: also write a 48 px preview on the card colour") a.add_argument("--allow-hold", action="store_true", help="work on a family that is on hold (as banner and card)") + a.add_argument("--strict", action="store_true", help="fail (not warn) when the dominant ink is not a family ink; " + "also set by the family key icon_strict_family_ink") a = cs.add_parser("links", parents=[common], help="CQ-04: relative links, anchors and remote targets") a.add_argument("files", nargs="*", help="Markdown files (default: displayed README, docs/**, .github/*.md, profile)") @@ -2539,6 +2543,7 @@ def _cmd_icon(args, cli, resolver, head): card = ctx.style["tokens"]["card_fill"] org = org_row(resolver, ctx.brand, ctx.owner) mark = ctx.brand.asset(org.get("mark")) + strict = bool(getattr(args, "strict", False) or ctx.style.get("icon_strict_family_ink")) if args.icon_action == "prompt": art = load_art_direction(args.art_direction) glyph = None @@ -2584,7 +2589,7 @@ def _cmd_icon(args, cli, resolver, head): src.load() img, info = normalize_icon(src, args.ground, card, pixel_art=bool(ctx.style.get("icon_pixel_art"))) data, qlogs = icon_bytes(img) - qa = icon_qa(data, card=card, ink=ink, org_mark=mark, siblings=siblings) + qa = icon_qa(data, card=card, ink=ink, org_mark=mark, siblings=siblings, strict=strict) out = Path(args.out) if args.out else cli.default_out(args, "assets/icon.png") if qa["result"] == "fail": return dict(head, result="fail", out=None, info=info, qa=qa, logs=ctx.logs + qlogs + ["art-fail:icon-qa"], @@ -2600,7 +2605,7 @@ def _cmd_icon(args, cli, resolver, head): if path is None or not path.is_file(): return dict(head, result="fail", file=str(path) if path else None, logs=ctx.logs + ["needs-input:repo-icon"]), \ CHECK_FAILED - qa = icon_qa(path.read_bytes(), card=card, ink=ink, org_mark=mark, siblings=siblings) + qa = icon_qa(path.read_bytes(), card=card, ink=ink, org_mark=mark, siblings=siblings, strict=strict) return dict(head, result=qa["result"], file=str(path), qa=qa, logs=ctx.logs), _code(qa["result"]) diff --git a/tests/test_content.py b/tests/test_content.py index 791db82..ee019df 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -754,3 +754,72 @@ def test_add_parser_registers_every_action(): content = sub.choices["content"] acts = next(a for a in content._actions if isinstance(a, argparse._SubParsersAction)) assert set(acts.choices) == set(C.ACTIONS) + + +# --------------------------------------------------------------------------------------------------- +# icon family ink: warn by default, fail with --strict or the family key icon_strict_family_ink (E3.2.1) +# --------------------------------------------------------------------------------------------------- +def _check_result(qa, name): + return next((c["result"] for c in qa["checks"] if c["check"] == name), None) + + +def test_icon_family_ink_warns_by_default_and_fails_when_strict(brand): + card = brand.style(NW)["tokens"]["card_fill"] + img = C.normalize_icon(_candidate(card, ink=(0, 90, 221)), "transparent", card)[0] + data = C.icon_bytes(img)[0] + qa = C.icon_qa(data, card=card, ink=["#8B5CF6", "#22D3EE"]) + assert _check_result(qa, "family-ink") == "warn" and qa["result"] == "warn", qa + qa = C.icon_qa(data, card=card, ink=["#8B5CF6", "#22D3EE"], strict=True) + assert _check_result(qa, "family-ink") == "fail" and qa["result"] == "fail", qa + assert "dominant ink" in next(c for c in qa["checks"] if c["check"] == "family-ink")["detail"] + qa = C.icon_qa(data, card=card, ink=["#0858D8"], strict=True) + assert _check_result(qa, "family-ink") == "pass", qa + # without a family palette the check is not emitted at all, so strict changes nothing + qa = C.icon_qa(data, card=card, strict=True) + assert _check_result(qa, "family-ink") is None + + +def test_icon_strict_flag_and_family_key(capsys, tmp_path, brand): + import copy + import yaml + card = brand.style(NW)["tokens"]["card_fill"] + meta = tmp_path / "meta.json" + meta.write_text(json.dumps(meta_dict()), encoding="utf-8") + old = tmp_path / "old-blue" / "assets" / "icon.png" + old.parent.mkdir(parents=True) + old.write_bytes(C.icon_bytes(C.normalize_icon(_candidate(card, ink=(0, 90, 221)), "transparent", card)[0])[0]) + other = tmp_path / "other" / "assets" / "icon.png" + other.parent.mkdir(parents=True) + other.write_bytes(C.icon_bytes(C.normalize_icon(_candidate(card, ink=(139, 92, 246)), "transparent", card)[0])[0]) + check = ("--meta", meta, "--no-repo-dir", "content", "icon", "check") + art = ("--art-direction", FIX / "ART_DIRECTION.min.md") + + # the old Northwind blue is the family ink: pass with or without --strict + code, res = run_content(capsys, *check, "--out", old, *art, *NO_ESTATE) + assert code == 0 and res["result"] == "pass", res + code, res = run_content(capsys, *check, "--out", old, "--strict", *art, *NO_ESTATE) + assert code == 0 and res["result"] == "pass", res + + # a non-family ink: warn by default (exit 0), fail with --strict (exit 1) + code, res = run_content(capsys, *check, "--out", other, *art, *NO_ESTATE) + assert code == 0 and res["result"] == "warn" and _check_result(res["qa"], "family-ink") == "warn", res + code, res = run_content(capsys, *check, "--out", other, "--strict", *art, *NO_ESTATE) + assert code == 1 and res["result"] == "fail" and _check_result(res["qa"], "family-ink") == "fail", res + + # the family key turns the strict behaviour on for the Northwind family, without the flag + raw = copy.deepcopy(brand.raw) + raw["families"]["completetech"]["icon_strict_family_ink"] = True + cfg = tmp_path / "brand-strict.yaml" + cfg.write_text(yaml.safe_dump(raw, sort_keys=False, allow_unicode=True), encoding="utf-8") + code, res = run_content(capsys, "--config", cfg, *check, "--out", other, *art, *NO_ESTATE) + assert code == 1 and res["result"] == "fail" and _check_result(res["qa"], "family-ink") == "fail", res + + +def test_schema_accepts_icon_strict_family_ink(brand): + import copy + from ctshowcase.config import schema_errors + raw = copy.deepcopy(brand.raw) + raw["families"]["completetech"]["icon_strict_family_ink"] = True + assert schema_errors(raw) == [] + raw["families"]["completetech"]["icon_strict_family_ink"] = "yes" + assert schema_errors(raw) != [] From d5c8cc9867305f49038f973e99d3a5bcfb04e24f Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Fri, 30 Oct 2026 09:14:43 -0400 Subject: [PATCH 02/23] content icon: configurable contrast floor --min-contrast (E3.2.2) The contrast-on-card floor is now configurable per run (`--min-contrast`) and per family (`icon_min_contrast`, schema range 1 to 21). Precedence is CLI > family key > 3.0, so the default output is unchanged. 4.5:1 is documented as the recommended card-plate floor but stays off by default. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + references/brand.schema.json | 5 +++ references/content.md | 6 ++-- scripts/ctshowcase/content.py | 29 ++++++++++++--- tests/test_content.py | 68 +++++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2da2ec9..8244c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo ### Added +- `content icon check` and `content icon normalize` take `--min-contrast` (and the family key `icon_min_contrast`, range 1 to 21) to set the contrast floor of the icon ink on the card. Precedence is the flag, then the family key, then the default 3.0, so existing results do not change. 4.5:1 is recommended for the card plate but is not the default. - `content icon check` and `content icon normalize` take `--strict` (and the family key `icon_strict_family_ink: true`, off by default) to fail instead of warn when the dominant icon ink is not a family ink. Without a family palette the family-ink check is still not emitted. - `org labels ORG --repos-from-gh` plans labels across accessible repositories without the private estate registry, with paginated read-only GitHub capture, optional verified local checkouts, unchanged reference guards, and a documented org-default-forms-first workflow. Registry-backed plans still require the org registry row, and checkout repository names match GitHub case-insensitively, including resolved pending renames. Registry-free runs also isolate lazy brand lookups from estate decision files, and held reference rewrites retain their file/line guidance with an incomplete result. Workflow/labeler/release/Renovate references remain final skips rather than unresolved input requests. Missing Windows device-named checkouts fall back to captured metadata instead of aborting the org plan. Pending transfers check the repository's current owner's default issue forms and hold renames when that evidence is unavailable. Sparse or partially missing checkouts retain reference holds based on captured paths and the Git index, while unrelated omitted files remain allowed. diff --git a/references/brand.schema.json b/references/brand.schema.json index 0b54f70..3c79fac 100644 --- a/references/brand.schema.json +++ b/references/brand.schema.json @@ -226,6 +226,11 @@ }, "icon_strict_family_ink": { "type": "boolean" + }, + "icon_min_contrast": { + "type": "number", + "minimum": 1, + "maximum": 21 } } }, diff --git a/references/content.md b/references/content.md index 5dde9ff..5eb590d 100644 --- a/references/content.md +++ b/references/content.md @@ -27,8 +27,8 @@ Every action also takes `--report FILE` (the JSON result), `--jobs FILE` (append |---|---|---|---| | `social` | R10 | The 1280×640 repository social preview (the contract name; the top-level `social-preview` is a hidden, deprecated alias with the same options and file), plus with `--job` the `browser` upload job (Settings → Social preview → Edit → Upload, then a read-only `usesCustomOpenGraphImage` check). Public T1 and T2 repositories only, the personal-profile capstone and backups included; forks, archived, `.github` and Pages repositories get no job. The image's footer reads `github.com//`, the slug the job uploads to (after an approved org rename, never the former login); when the rendered footer and the job's slug differ, the job is withheld (`needs-input:social-login`, exit 1). | `--out` | | `icon prompt` | RT-04 | Fills the repository-icon prompt from the art direction (option A: the Neon Night glyph; option B: the isometric illustration) and the repository's facts. The glyph itself is the writer's: `needs-writer` (exit 1) until `--glyph` supplies `glyph_name`, `glyph_description`, `spark_placement` and `size_class`; nothing is written before then. Option B appends the family addition (the trademark negatives in `references/art-direction.md`, such as the TCG one) found under any login of the org (CQ-03) or its display name, so a re-keyed `brand.yaml` row keeps it; when the org's addition is not in the art direction, the prompt is refused (`needs-input:family-additions`) rather than written without it. | `--out` | -| `icon normalize` | RT-04 | Keys a returned candidate to transparency (or keeps an opaque night tile full-bleed), trims and fits it to the 688 px live area of a 1024 px RGBA PNG, then runs the QA. A failing QA writes nothing (`art-fail:icon-qa`, exit 1). | `--out` (default `assets/icon.png` in the checkout), `--preview`, `--strict` | -| `icon check` | RT-04 | Automated icon QA: size, byte budget, alpha, live area, coverage, contrast on the card, family inks, not the org glyph, one file. OCR and the 48 px set sheet stay manual. | `--strict` | +| `icon normalize` | RT-04 | Keys a returned candidate to transparency (or keeps an opaque night tile full-bleed), trims and fits it to the 688 px live area of a 1024 px RGBA PNG, then runs the QA. A failing QA writes nothing (`art-fail:icon-qa`, exit 1). | `--out` (default `assets/icon.png` in the checkout), `--preview`, `--strict`, `--min-contrast` | +| `icon check` | RT-04 | Automated icon QA: size, byte budget, alpha, live area, coverage, contrast on the card (floor set by `--min-contrast`, default 3:1), family inks, not the org glyph, one file. OCR and the 48 px set sheet stay manual. | `--strict`, `--min-contrast` | | `links` | CQ-04, RP-09 | Every relative link, anchor and image target in the displayed README (GitHub's `.github/`, root, `docs/` precedence, or `--readme-path`), `docs/**`, `.github/*.md` and the org profile. With `--base-ref`, only breakage the change introduces fails; `--strict` fails any. `--online` adds cached, polite remote checks (HEAD first, one request per host per `--delay` seconds, at most `--max-remote`) and `gh` `full_name` checks for GitHub links, so a renamed org or repository is proposed under its current name. Offline, a link to this repository under its former slug (a transfer left its badges, release and clone URLs on the old org) is `moved` with a rewrite proposal only when an approved repository `rename` or `transfer` action in `decisions/estate-actions.json` records that exact move (the resolver's `repo_before`, CQ-03); a repository of the same name under another org is a different repository and is never rewritten (every org has its own `.github`). Every URL `references/brand.yaml` holds (the decision 18 footer's href and logo URLs, the brand and hub sites) is fixed content: it is still checked, but no org rename, transfer or online `full_name` answer proposes rewriting it. Rewrites match whole targets, so `…/tests.yml` never half-rewrites `…/tests.yml/badge.svg`. Broken links carry proposals; `--diff` prints them as a unified diff that is never applied. | `--cache` | | `linguist` | RP-07, CQ-08 | Paths that distort the language bar (vendored, generated, docs and example trees), the proposed append-only `# ct-std linguist` block for `.gitattributes`, the bar before and after, and the primary language a chip, card dot or badge may show (manifest runtime, then a `language:` override, then the corrected top language). | nothing | | `actions` | RP-06, RP-01, CQ-13 | Red runs and README status badges for failing workflows, upstream-only automation in forks (CLA, stale, labeler), and what a README merge would start (deploys first). Workflow on/off changes are proposals that need a `workflow-toggle` approval (`--approval WORKFLOW=ID`, keyed by the workflow file name); the job's argv names the workflow file (`gh workflow disable stale.yml -R OWNER/REPO`), never its numeric id, because the guard matches the approval's `detail` against it. | nothing | @@ -45,6 +45,8 @@ None by default. `links --online` and `images --online` make cached, rate-limite `--strict` (on `icon check` and `icon normalize`, also set by the family key `icon_strict_family_ink: true` in `references/brand.yaml`, which is off by default) makes the family-ink check fail instead of warn when the icon's dominant ink is not a family ink. Without the flag it stays a warning and the exit code is unchanged. With no family palette (`icon_ink` and `icon_spark` absent) the family-ink check is not emitted, so `--strict` has no effect there and `needs-input:icon-palette` is still logged. +`--min-contrast` sets the contrast floor of the icon's dominant ink on the card plate (on `icon normalize` and `icon check`). The precedence is the CLI flag, then the family key `icon_min_contrast` in `references/brand.yaml`, then the default 3.0. The default stays 3:1 because a higher floor would change results that already pass. The recommended floor for the card plate is 4.5:1 (WCAG AA body text); set it with `--min-contrast 4.5` or `icon_min_contrast: 4.5` for a family. The valid range is 1 to 21; any other value, or a non-number, is a usage error (exit 2). A failing contrast check fails the icon (exit 1), as the other failing icon checks do. + ## Without the estate bundle For an older custom `--config` that omits `icon_ink` or `icon_spark`, normalization diff --git a/scripts/ctshowcase/content.py b/scripts/ctshowcase/content.py index 17f4684..79311c8 100644 --- a/scripts/ctshowcase/content.py +++ b/scripts/ctshowcase/content.py @@ -2127,11 +2127,24 @@ def _dhash(img, size: int = 16) -> int: return bits +ICON_MIN_CONTRAST = 3.0 # default contrast floor of the icon ink on the card (E3.2.2) + + +def icon_contrast_floor(cli_value, family_value) -> float: + """The contrast floor of the icon ink on the card: `--min-contrast`, else the family key `icon_min_contrast`, else + 3.0. Only 1 to 21 is valid (WCAG contrast range); a bool or non-number is refused.""" + value = cli_value if cli_value is not None else family_value if family_value is not None else ICON_MIN_CONTRAST + if isinstance(value, bool) or not isinstance(value, (int, float)) or not 1 <= value <= 21: + raise ShowcaseError(f"--min-contrast / icon_min_contrast must be between 1 and 21, got {value}", log="usage") + return float(value) + + def icon_qa(data: bytes, *, card: str, ink: list[str] | None = None, org_mark: Path | None = None, - siblings: list[str] | None = None, strict: bool = False) -> dict: + siblings: list[str] | None = None, strict: bool = False, min_contrast: float = 3.0) -> dict: """Automated icon checks (f.3, art QA): size, budget, alpha, live area, coverage, contrast on the card, family inks, not the org glyph, one file. OCR and the 48 px set sheet stay manual. `strict` makes a failing family-ink - check fail the icon (default: warn); with no `ink` the family-ink check is not emitted at all.""" + check fail the icon (default: warn); with no `ink` the family-ink check is not emitted at all. `min_contrast` is + the contrast floor of the dominant ink on the card (default 3.0; see icon_contrast_floor).""" from PIL import Image from .color import contrast, hexstr, rgb as hexrgb from .pnglint import validate_png @@ -2188,7 +2201,8 @@ def add(name, ok, detail="", level="fail"): counts[key] = counts.get(key, 0) + 1 dom = hexstr(max(counts, key=counts.get)) cr = contrast(dom, card) - add("contrast-on-card", cr >= 3.0, f"dominant ink {dom} on {card}: {cr:.2f}:1 (>= 3:1)") + floor = float(min_contrast) + add("contrast-on-card", cr >= floor, f"dominant ink {dom} on {card}: {cr:.2f}:1 (>= {floor:g}:1)") if ink: near = min(_de(lab(hexrgb(dom)), lab(hexrgb(x))) for x in ink) add("family-ink", near <= 12, f"dominant ink {dom}, nearest family ink ΔE {near:.1f}", @@ -2260,6 +2274,9 @@ def add_parser(sub, cli=None): a.add_argument("--allow-hold", action="store_true", help="work on a family that is on hold (as banner and card)") a.add_argument("--strict", action="store_true", help="fail (not warn) when the dominant ink is not a family ink; " "also set by the family key icon_strict_family_ink") + a.add_argument("--min-contrast", type=float, default=None, + help="contrast floor of the icon ink on the card plate (default: family key icon_min_contrast, " + "else 3.0; 4.5 is recommended)") a = cs.add_parser("links", parents=[common], help="CQ-04: relative links, anchors and remote targets") a.add_argument("files", nargs="*", help="Markdown files (default: displayed README, docs/**, .github/*.md, profile)") @@ -2574,6 +2591,7 @@ def _cmd_icon(args, cli, resolver, head): note="The glyph is the writer's: fill glyph_name, glyph_description, spark_placement and size_class " "(--glyph FILE) and re-run until the result is ok; nothing is written before then.") return obj, _code(result) + floor = icon_contrast_floor(getattr(args, "min_contrast", None), ctx.style.get("icon_min_contrast")) ink = [] try: art = load_art_direction(args.art_direction) @@ -2589,7 +2607,7 @@ def _cmd_icon(args, cli, resolver, head): src.load() img, info = normalize_icon(src, args.ground, card, pixel_art=bool(ctx.style.get("icon_pixel_art"))) data, qlogs = icon_bytes(img) - qa = icon_qa(data, card=card, ink=ink, org_mark=mark, siblings=siblings, strict=strict) + qa = icon_qa(data, card=card, ink=ink, org_mark=mark, siblings=siblings, strict=strict, min_contrast=floor) out = Path(args.out) if args.out else cli.default_out(args, "assets/icon.png") if qa["result"] == "fail": return dict(head, result="fail", out=None, info=info, qa=qa, logs=ctx.logs + qlogs + ["art-fail:icon-qa"], @@ -2605,7 +2623,8 @@ def _cmd_icon(args, cli, resolver, head): if path is None or not path.is_file(): return dict(head, result="fail", file=str(path) if path else None, logs=ctx.logs + ["needs-input:repo-icon"]), \ CHECK_FAILED - qa = icon_qa(path.read_bytes(), card=card, ink=ink, org_mark=mark, siblings=siblings, strict=strict) + qa = icon_qa(path.read_bytes(), card=card, ink=ink, org_mark=mark, siblings=siblings, strict=strict, + min_contrast=floor) return dict(head, result=qa["result"], file=str(path), qa=qa, logs=ctx.logs), _code(qa["result"]) diff --git a/tests/test_content.py b/tests/test_content.py index ee019df..3d45ae5 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -823,3 +823,71 @@ def test_schema_accepts_icon_strict_family_ink(brand): assert schema_errors(raw) == [] raw["families"]["completetech"]["icon_strict_family_ink"] = "yes" assert schema_errors(raw) != [] + + +# --------------------------------------------------------------------------------------------------- +# configurable contrast floor (E3.2.2): default 3.0, `--min-contrast` > family key `icon_min_contrast` > 3.0 +# --------------------------------------------------------------------------------------------------- +CONTRAST_INK = (122, 141, 176) # ~3.1:1 on the card: between the 3.0 default floor and the 4.5 recommendation + + +def test_icon_min_contrast_default_3_and_configurable(brand): + from ctshowcase.color import contrast + card = brand.style(NW)["tokens"]["card_fill"] + img = C.normalize_icon(_candidate(card, ink=CONTRAST_INK), "transparent", card)[0] + data = C.icon_bytes(img)[0] + qa = C.icon_qa(data, card=card) + assert _check_result(qa, "contrast-on-card") == "pass", qa + detail = next(c["detail"] for c in qa["checks"] if c["check"] == "contrast-on-card") + assert detail.endswith("(>= 3:1)"), detail + # self-check: the measured ink lies between the default floor and the recommended one + ratio = contrast(qa["dominant_ink"], card) + assert 3.0 < ratio < 4.5, ratio + qa = C.icon_qa(data, card=card, min_contrast=4.5) + assert _check_result(qa, "contrast-on-card") == "fail" and qa["result"] == "fail", qa + detail = next(c["detail"] for c in qa["checks"] if c["check"] == "contrast-on-card") + assert "(>= 4.5:1)" in detail, detail + # an explicit 3.0 is the default + assert C.icon_qa(data, card=card, min_contrast=3.0)["checks"] == C.icon_qa(data, card=card)["checks"] + + +def test_icon_contrast_floor_helper(): + from ctshowcase.errors import ShowcaseError + assert C.icon_contrast_floor(None, None) == 3.0 + assert C.icon_contrast_floor(4.5, None) == 4.5 + assert C.icon_contrast_floor(None, 4.5) == 4.5 + assert C.icon_contrast_floor(3.5, 4.5) == 3.5 # the CLI value wins over the family key + for bad in (0.5, 22, True, "x"): + with pytest.raises(ShowcaseError) as exc: + C.icon_contrast_floor(bad, None) + assert exc.value.log == "usage" and exc.value.code == 2, bad + with pytest.raises(ShowcaseError): + C.icon_contrast_floor(None, 22) + + +def test_icon_min_contrast_cli(capsys, tmp_path, brand): + card = brand.style(NW)["tokens"]["card_fill"] + img = C.normalize_icon(_candidate(card, ink=CONTRAST_INK), "transparent", card)[0] + out = write(tmp_path, {"repo/assets/icon.png": C.icon_bytes(img)[0]}) / "repo" / "assets" / "icon.png" + meta = tmp_path / "meta.json" + meta.write_text(json.dumps(meta_dict()), encoding="utf-8") + check = ("--meta", meta, "--no-repo-dir", "content", "icon", "check", "--out", out, + "--art-direction", FIX / "ART_DIRECTION.min.md", *NO_ESTATE) + code, res = run_content(capsys, *check, "--min-contrast", "4.5") + assert code == 1 and res["result"] == "fail", res + code, res = run_content(capsys, *check) + assert code == 0 and res["result"] in ("pass", "warn"), res + code, res = run_content(capsys, *check, "--min-contrast", "30") + assert code == 2, res + + +def test_schema_icon_min_contrast(brand): + import copy + from ctshowcase.config import schema_errors + raw = copy.deepcopy(brand.raw) + raw["families"]["completetech"]["icon_min_contrast"] = 4.5 + assert schema_errors(raw) == [] + raw["families"]["completetech"]["icon_min_contrast"] = 0.5 + assert schema_errors(raw) != [] + raw["families"]["completetech"]["icon_min_contrast"] = 22 + assert schema_errors(raw) != [] From 89d9c7587f5e7100024fa4b53284dc028dc6352d Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Fri, 30 Oct 2026 09:45:16 -0400 Subject: [PATCH 03/23] content icon: opt-in OCR no-text check --ocr (E3.2.3) `content icon check` and `content icon normalize` take `--ocr`. When tesseract is on PATH the icon is flattened on white and read; words of 2+ alphanumerics warn (fail with --strict). Without the tool, or if it fails, no-text stays a manual review and the logs name needs-input:ocr-unavailable or needs-input:ocr-failed. The result and exit code do not change when the tool is missing. Off by default; the default manual entry is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + references/content.md | 6 +- scripts/ctshowcase/content.py | 5394 +++++++++++++++++---------------- tests/test_content.py | 94 + 4 files changed, 2825 insertions(+), 2670 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8244c4f..0cd5741 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo ### Added +- `content icon check` and `content icon normalize` take `--ocr` (opt-in, off by default) to read the icon with `tesseract` on `PATH` for text: words of 2 or more alphanumerics warn (fail with `--strict`); without the tool the check is skipped with `needs-input:ocr-unavailable` and `no-text` stays a manual review, so the result and exit code do not change. - `content icon check` and `content icon normalize` take `--min-contrast` (and the family key `icon_min_contrast`, range 1 to 21) to set the contrast floor of the icon ink on the card. Precedence is the flag, then the family key, then the default 3.0, so existing results do not change. 4.5:1 is recommended for the card plate but is not the default. - `content icon check` and `content icon normalize` take `--strict` (and the family key `icon_strict_family_ink: true`, off by default) to fail instead of warn when the dominant icon ink is not a family ink. Without a family palette the family-ink check is still not emitted. - `org labels ORG --repos-from-gh` plans labels across accessible repositories without the private estate registry, with paginated read-only GitHub capture, optional verified local checkouts, unchanged reference guards, and a documented org-default-forms-first workflow. Registry-backed plans still require the org registry row, and checkout repository names match GitHub case-insensitively, including resolved pending renames. Registry-free runs also isolate lazy brand lookups from estate decision files, and held reference rewrites retain their file/line guidance with an incomplete result. Workflow/labeler/release/Renovate references remain final skips rather than unresolved input requests. Missing Windows device-named checkouts fall back to captured metadata instead of aborting the org plan. Pending transfers check the repository's current owner's default issue forms and hold renames when that evidence is unavailable. Sparse or partially missing checkouts retain reference holds based on captured paths and the Git index, while unrelated omitted files remain allowed. diff --git a/references/content.md b/references/content.md index 5eb590d..b2da981 100644 --- a/references/content.md +++ b/references/content.md @@ -27,8 +27,8 @@ Every action also takes `--report FILE` (the JSON result), `--jobs FILE` (append |---|---|---|---| | `social` | R10 | The 1280×640 repository social preview (the contract name; the top-level `social-preview` is a hidden, deprecated alias with the same options and file), plus with `--job` the `browser` upload job (Settings → Social preview → Edit → Upload, then a read-only `usesCustomOpenGraphImage` check). Public T1 and T2 repositories only, the personal-profile capstone and backups included; forks, archived, `.github` and Pages repositories get no job. The image's footer reads `github.com//`, the slug the job uploads to (after an approved org rename, never the former login); when the rendered footer and the job's slug differ, the job is withheld (`needs-input:social-login`, exit 1). | `--out` | | `icon prompt` | RT-04 | Fills the repository-icon prompt from the art direction (option A: the Neon Night glyph; option B: the isometric illustration) and the repository's facts. The glyph itself is the writer's: `needs-writer` (exit 1) until `--glyph` supplies `glyph_name`, `glyph_description`, `spark_placement` and `size_class`; nothing is written before then. Option B appends the family addition (the trademark negatives in `references/art-direction.md`, such as the TCG one) found under any login of the org (CQ-03) or its display name, so a re-keyed `brand.yaml` row keeps it; when the org's addition is not in the art direction, the prompt is refused (`needs-input:family-additions`) rather than written without it. | `--out` | -| `icon normalize` | RT-04 | Keys a returned candidate to transparency (or keeps an opaque night tile full-bleed), trims and fits it to the 688 px live area of a 1024 px RGBA PNG, then runs the QA. A failing QA writes nothing (`art-fail:icon-qa`, exit 1). | `--out` (default `assets/icon.png` in the checkout), `--preview`, `--strict`, `--min-contrast` | -| `icon check` | RT-04 | Automated icon QA: size, byte budget, alpha, live area, coverage, contrast on the card (floor set by `--min-contrast`, default 3:1), family inks, not the org glyph, one file. OCR and the 48 px set sheet stay manual. | `--strict`, `--min-contrast` | +| `icon normalize` | RT-04 | Keys a returned candidate to transparency (or keeps an opaque night tile full-bleed), trims and fits it to the 688 px live area of a 1024 px RGBA PNG, then runs the QA. A failing QA writes nothing (`art-fail:icon-qa`, exit 1). | `--out` (default `assets/icon.png` in the checkout), `--preview`, `--strict`, `--min-contrast`, `--ocr` | +| `icon check` | RT-04 | Automated icon QA: size, byte budget, alpha, live area, coverage, contrast on the card (floor set by `--min-contrast`, default 3:1), family inks, not the org glyph, one file. OCR is opt-in with `--ocr`; the 48 px set sheet stays manual. | `--strict`, `--min-contrast`, `--ocr` | | `links` | CQ-04, RP-09 | Every relative link, anchor and image target in the displayed README (GitHub's `.github/`, root, `docs/` precedence, or `--readme-path`), `docs/**`, `.github/*.md` and the org profile. With `--base-ref`, only breakage the change introduces fails; `--strict` fails any. `--online` adds cached, polite remote checks (HEAD first, one request per host per `--delay` seconds, at most `--max-remote`) and `gh` `full_name` checks for GitHub links, so a renamed org or repository is proposed under its current name. Offline, a link to this repository under its former slug (a transfer left its badges, release and clone URLs on the old org) is `moved` with a rewrite proposal only when an approved repository `rename` or `transfer` action in `decisions/estate-actions.json` records that exact move (the resolver's `repo_before`, CQ-03); a repository of the same name under another org is a different repository and is never rewritten (every org has its own `.github`). Every URL `references/brand.yaml` holds (the decision 18 footer's href and logo URLs, the brand and hub sites) is fixed content: it is still checked, but no org rename, transfer or online `full_name` answer proposes rewriting it. Rewrites match whole targets, so `…/tests.yml` never half-rewrites `…/tests.yml/badge.svg`. Broken links carry proposals; `--diff` prints them as a unified diff that is never applied. | `--cache` | | `linguist` | RP-07, CQ-08 | Paths that distort the language bar (vendored, generated, docs and example trees), the proposed append-only `# ct-std linguist` block for `.gitattributes`, the bar before and after, and the primary language a chip, card dot or badge may show (manifest runtime, then a `language:` override, then the corrected top language). | nothing | | `actions` | RP-06, RP-01, CQ-13 | Red runs and README status badges for failing workflows, upstream-only automation in forks (CLA, stale, labeler), and what a README merge would start (deploys first). Workflow on/off changes are proposals that need a `workflow-toggle` approval (`--approval WORKFLOW=ID`, keyed by the workflow file name); the job's argv names the workflow file (`gh workflow disable stale.yml -R OWNER/REPO`), never its numeric id, because the guard matches the approval's `detail` against it. | nothing | @@ -47,6 +47,8 @@ None by default. `links --online` and `images --online` make cached, rate-limite `--min-contrast` sets the contrast floor of the icon's dominant ink on the card plate (on `icon normalize` and `icon check`). The precedence is the CLI flag, then the family key `icon_min_contrast` in `references/brand.yaml`, then the default 3.0. The default stays 3:1 because a higher floor would change results that already pass. The recommended floor for the card plate is 4.5:1 (WCAG AA body text); set it with `--min-contrast 4.5` or `icon_min_contrast: 4.5` for a family. The valid range is 1 to 21; any other value, or a non-number, is a usage error (exit 2). A failing contrast check fails the icon (exit 1), as the other failing icon checks do. +`--ocr` (on `icon check` and `icon normalize`, off by default) adds an optional text check: when `tesseract` is on `PATH`, the icon is flattened on white and read with it, and words of 2 or more alphanumeric characters count as text. Found text warns (`no-text`, detail `OCR found text: ...`) and fails the icon with `--strict`; no text passes. Single characters and punctuation are ignored. Without `--ocr`, or without `tesseract`, nothing changes: `no-text` stays a manual review, and an absent tool logs `needs-input:ocr-unavailable` without changing the result or the exit code (a tool that fails to run logs `needs-input:ocr-failed`). The tool is never installed by this package. The 48 px set sheet stays manual. + ## Without the estate bundle For an older custom `--config` that omits `icon_ink` or `icon_spark`, normalization diff --git a/scripts/ctshowcase/content.py b/scripts/ctshowcase/content.py index 79311c8..f25f232 100644 --- a/scripts/ctshowcase/content.py +++ b/scripts/ctshowcase/content.py @@ -1,2668 +1,2726 @@ -"""`showcase.py content ...`: links (CQ-04, RP-09), linguist (RP-07, CQ-08), actions (RP-06, RP-01, CQ-13), images -(RP-03, RP-10), sidebar (RP-05), icon (RT-04) and social (R10; the contract name of the social preview, with the -hidden top-level `social-preview` kept as a deprecated alias). - -Read-only toward GitHub, like every other module. Nothing here rewrites a README, edits `.gitattributes`, touches a -workflow file or uploads anything: each action reports facts and emits proposals (unified diffs, `gh` argv and -`scripts/writer.py` jobs `{id, phase, kind, repo|org, approval_id?, argv, note}`; UI-only steps are jobs of kind -`browser` with the exact click path in `steps`). The only files written are the paths given with `--report`, `--jobs` -(JSON lines, appended), `--out`, `--out-dir`, `--preview` and `--cache`. Network use is opt-in (`--online`): cached, -polite HEAD/GET requests and read-only `gh api` GETs. - -Every org lookup goes through the shared `resolver.Resolver` (CQ-03): after an approved rename -(CompleTetech-Security -> CompleteTech-Security, CompleteTech-Pokemon -> CompleteTech-TCG) a repository keeps its -brand row, its owner status (watching, T3, excluded), its packages and its links, and jobs name the current login. -""" -from __future__ import annotations - -import difflib -import hashlib -import html -import io -import json -import re -import subprocess -import time -import unicodedata -import urllib.error -import urllib.parse -import urllib.request -from datetime import datetime, timezone -from pathlib import Path, PurePosixPath - -from . import ROOT, STANDARD, __version__, estate_candidates -from .config import load_yaml_text -from .errors import CHECK_FAILED, OK, ArtFail, NeedsInput, Refused, ShowcaseError -from .resolver import Resolver - -# the estate hand-off tree when the skill lives inside it (or is its installed copy); optional everywhere -ESTATE = next((c for c in estate_candidates() if (c / "art").is_dir()), ROOT.parent) -SKILL_ART_DIRECTION = ROOT / "references" / "art-direction.md" -DEFAULT_ART_DIRECTION = SKILL_ART_DIRECTION - - -def _now() -> str: - return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -# ===================================================================================================== -# Org lookups (CQ-03): always through the shared resolver.Resolver -# ===================================================================================================== -def brand_key(r: Resolver | None, login: str) -> str: - """The brand.yaml `orgs:` key for any login of the org (old or new), else the login itself.""" - return (r.brand_key(login) if r else None) or login - - -def current_login(r: Resolver | None, login: str) -> str: - """The org's login after approved renames: what links, URLs and writer jobs name.""" - return r.canonical(login) if r else login - - -def org_row(r: Resolver | None, brand, login: str, require: bool = False) -> dict: - if r is None: - row = brand.org(login) - if require and not row.get("known"): - raise NeedsInput("org", f"{login} has no row in references/brand.yaml") - return dict(row, brand_key=login) - row = r.brand_org(login, strict=require) - row.setdefault("brand_key", r.brand_key(login) or login) - return row - - -def org_style(r: Resolver | None, brand, login: str) -> dict: - return brand.style(brand_key(r, login)) - - -def in_estate(r: Resolver | None, login: str) -> bool: - """An org of the estate (a brand or registry row under any of its logins), not an excluded owner.""" - if r is None or not login: - return False - return (r.brand_key(login) is not None or r.registry_key(login) is not None) and r.owner_status(login) != "excluded" - - -def load_resolver(brand, registry=None, approved=None, approved_yaml=None, actions=None) -> Resolver: - """The shared resolver from brand.yaml plus the registry and approvals (defaults: the estate bundle beside the - skill, when present). Read-only: the registry is private data and is never copied anywhere.""" - return Resolver.from_paths(brand, registry, approved, approved_yaml, actions) - - -# ===================================================================================================== -# Writer jobs -# ===================================================================================================== -_JID = re.compile(r"[^A-Za-z0-9._:@/+-]+") -SAFETY_STEP = ("Never click a control inside a \"Danger Zone\" section and never type an owner, repository or org name " - "into a confirmation field; if a dialog mentioning delete, transfer, archive, rename or visibility opens, " - "press Escape and log ui-danger-dialog.") - - -def job_id(*parts) -> str: - """A writer job id: letters, digits and ._:@/+- only (scripts/writer.py ID_RE), at most 200 characters.""" - s = _JID.sub("-", ":".join(str(p) for p in parts if p not in (None, ""))).strip("-.") - return s[:200] or "job" - - -def writer_job(jid: str, phase, kind: str, target: str, argv: list, note: str, approval_id: str | None = None, - **extra) -> dict: - j = {"id": jid, "phase": str(phase), "kind": kind, ("repo" if "/" in target else "org"): target} - if approval_id: - j["approval_id"] = approval_id - j["argv"] = [str(a) for a in argv] - j["note"] = note - j.update(extra) - return j - - -def browser_job(jid: str, phase, target: str, url: str, ui_action: str, steps: list[str], note: str, - approval: dict | None = None) -> dict: - shot = "state/screens/" + re.sub(r"[^A-Za-z0-9._-]+", "_", jid) + ".png" - steps = [*steps, f"Take a screenshot of the result and save it as {shot}.", SAFETY_STEP] - j = writer_job(jid, phase, "browser", target, [], note, url=url, ui_action=ui_action, steps=steps) - if approval: - j["approval"] = approval - return j - - -# ===================================================================================================== -# Repository tree (what GitHub sees) -# ===================================================================================================== -SKIP_WALK = {".git", "node_modules", ".venv", "venv", "__pycache__", ".showcase-cache"} - - -class Tree: - """Files of a checkout as GitHub serves them: tracked plus untracked-but-not-ignored files that exist (git work - tree), every file on disk (no git), or the tree at a git ref (`--base-ref`).""" - - def __init__(self, root: str | Path, ref: str | None = None): - self.root, self.ref = Path(root), ref - self.git = False - self.files = self._list() - self.lower: dict[str, list[str]] = {} - self.dirs: set[str] = {""} - for f in self.files: - self.lower.setdefault(f.lower(), []).append(f) - parts = f.split("/") - for i in range(1, len(parts)): - self.dirs.add("/".join(parts[:i])) - self._cache: dict[str, str | None] = {} - - def _git(self, *args) -> str | None: - try: - p = subprocess.run(["git", "-C", str(self.root), *args], capture_output=True, timeout=60) - except (OSError, subprocess.SubprocessError): - return None - return p.stdout.decode("utf-8", "surrogateescape") if p.returncode == 0 else None - - def _list(self) -> set[str]: - if self.ref: - out = self._git("ls-tree", "-r", "--name-only", "-z", self.ref) - if out is None: - raise ShowcaseError(f"cannot list {self.ref} in {self.root}", log="usage") - self.git = True - return {f for f in out.split("\0") if f} - if (self.root / ".git").exists(): - out = self._git("ls-files", "-z", "--cached", "--others", "--exclude-standard") - if out is not None: - self.git = True - return {f for f in out.split("\0") if f and (self.root / f).is_file()} - files = set() - for p in self.root.rglob("*"): - rel = p.relative_to(self.root) - if p.is_file() and not (set(rel.parts) & SKIP_WALK): - files.add(rel.as_posix()) - return files - - def is_file(self, rel: str) -> bool: - return rel in self.files - - def is_dir(self, rel: str) -> bool: - return rel.rstrip("/") in self.dirs - - def read(self, rel: str) -> str | None: - if rel not in self._cache: - if rel not in self.files: - self._cache[rel] = None - elif self.ref: - self._cache[rel] = self._git("show", f"{self.ref}:{rel}") - else: - try: - self._cache[rel] = (self.root / rel).read_text(encoding="utf-8", errors="replace") - except OSError: - self._cache[rel] = None - return self._cache[rel] - - def read_bytes(self, rel: str) -> bytes | None: - if rel not in self.files: - return None - if self.ref: - try: - p = subprocess.run(["git", "-C", str(self.root), "show", f"{self.ref}:{rel}"], capture_output=True, - timeout=60) - except (OSError, subprocess.SubprocessError): - return None - return p.stdout if p.returncode == 0 else None - try: - return (self.root / rel).read_bytes() - except OSError: - return None - - def size(self, rel: str) -> int: - if not self.ref: - try: - return (self.root / rel).stat().st_size - except OSError: - return 0 - b = self.read_bytes(rel) - return len(b) if b is not None else 0 - - -def displayed_readme(tree: Tree, given: str | None = None) -> str | None: - """The README GitHub displays (RP-09): `.github/README.md`, then the root, then `docs/` (GitHub's precedence), or - the `/readme` `.path` captured in metadata.""" - if given: - return given if tree.is_file(given) else None - for d in (".github/", "", "docs/"): - for name in ("README.md", "readme.md", "Readme.md", "README.markdown", "README"): - if tree.is_file(d + name): - return d + name - return None - - -def markdown_files(tree: Tree, readme: str | None) -> list[str]: - """README (displayed path), docs/**/*.md, .github/*.md and the org profile README (CQ-04 scope).""" - out = [readme] if readme else [] - for f in sorted(tree.files): - low = f.lower() - if not low.endswith((".md", ".markdown")) or f in out: - continue - if low.startswith("docs/") or (low.startswith(".github/") and low.count("/") == 1) or low in ( - "profile/readme.md", ".github/profile/readme.md"): - out.append(f) - return out - - -# ===================================================================================================== -# Markdown scanning -# ===================================================================================================== -FENCE = re.compile(r"^ {0,3}(`{3,}(?=[^`\n]*$)|~{3,})") # CommonMark: no backtick in a ``` info string -INLINE_CODE = re.compile(r"(`+)(?:.+?)\1") -ATX = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$") -SETEXT = re.compile(r"^ {0,3}(=+|-+)[ \t]*$") -MD_LINK = re.compile(r"(!?)\[((?:[^\[\]\n]|\[[^\[\]\n]*\])*)\]\(\s*(<[^>\n]*>|[^\s()]*(?:\([^\s()]*\)[^\s()]*)*)" - r"(?:\s+(?:\"[^\"\n]*\"|'[^'\n]*'|\([^)\n]*\)))?\s*\)") -REF_DEF = re.compile(r"^ {0,3}\[([^\]\n]+)\]:\s*(<[^>\n]*>|\S+)") -HTML_TAG = re.compile(r"<(a|img|source|link|video|audio|image|use)\b([^>]*)>", re.I) -ATTR = re.compile(r"""(?]+))""", re.I) -ANCHOR_ATTR = re.compile(r"""<[a-z][^>]*?(?]*>(.*?)", re.I | re.S) -BARE_URL = re.compile(r"https?://[^\s<>()\[\]\"'`]+") -IMG_EXT = (".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif", ".bmp", ".ico") - - -def scan_lines(text: str) -> list[tuple[int, str | None, str | None]]: - """(line number, text without HTML comments, the same with inline code blanked); both None inside a code fence.""" - out, fence, comment = [], None, False - for n, raw in enumerate(text.replace("\r\n", "\n").split("\n"), 1): - m = FENCE.match(raw) - if fence: - if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence) and not raw.strip().strip(fence[0]): - fence = None - out.append((n, None, None)) - continue - if m and not comment: - fence = m.group(1) - out.append((n, None, None)) - continue - line, rest = "", raw - while rest: - if comment: - end = rest.find("-->") - if end < 0: - rest = "" - break - rest, comment = rest[end + 3:], False - continue - start = rest.find("") + if end < 0: + rest = "" + break + rest, comment = rest[end + 3:], False + continue + start = rest.find("") - if end < 0: - rest = "" - break - rest, comment = rest[end + 3:], False - continue - start = rest.find("") + if end < 0: + rest = "" + break + rest, comment = rest[end + 3:], False + continue + start = rest.find("