From e67f53f679f352d7e59b5f0215eed473a549cd0e Mon Sep 17 00:00:00 2001 From: Y1hsiaochunnn <66012385+Y1hsiaochunnn@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:08:02 +0800 Subject: [PATCH] Harden repository quality gates --- .github/pull_request_template.md | 4 +- .github/pull_request_template_ZH.md | 6 +- .github/workflows/examples.yml | 41 +- docs/ci.md | 20 +- docs/ci_ZH.md | 14 +- scripts/audit_markdown.py | 1865 +++++++++++++++++++++++++++ scripts/ci_scope.py | 69 +- tests/test_audit_markdown.py | 1004 ++++++++++++++ tests/test_ci_scope.py | 122 +- tests/test_discover_examples.py | 15 + 10 files changed, 3139 insertions(+), 21 deletions(-) create mode 100644 scripts/audit_markdown.py create mode 100644 tests/test_audit_markdown.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 71d158d0..a314d198 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,7 +2,7 @@ [简体中文](pull_request_template_ZH.md) -- TODO + ## Validation @@ -21,4 +21,4 @@ ## Notes -- TODO + diff --git a/.github/pull_request_template_ZH.md b/.github/pull_request_template_ZH.md index 910179cd..d73a7708 100644 --- a/.github/pull_request_template_ZH.md +++ b/.github/pull_request_template_ZH.md @@ -2,7 +2,7 @@ [English](pull_request_template.md) -- TODO + ## 验证 @@ -18,3 +18,7 @@ - 受影响的示例路径和框架: - 硬件或引脚注意事项: - 固件工件影响: + +## 备注 + + diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index ae38cf4d..00378fcb 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -32,9 +32,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Check repository documentation conventions + - name: Check established documentation compatibility rules run: python3 scripts/check_docs.py . --config config/markdown-audit.json - - name: Run routing, discovery, and documentation checker tests + - name: Run routing, discovery, and Markdown audit tests run: python3 -m unittest discover -s tests -v scope: @@ -47,10 +47,11 @@ jobs: arduino_paths: ${{ steps.classify.outputs.arduino_paths }} firmware_paths: ${{ steps.classify.outputs.firmware_paths }} unknown_paths: ${{ steps.classify.outputs.unknown_paths }} + docs_only: ${{ steps.classify.outputs.docs_only }} steps: - uses: actions/checkout@v4 with: - fetch-depth: 2 + fetch-depth: 0 - id: classify shell: bash run: | @@ -62,27 +63,47 @@ jobs: echo 'arduino_paths=[]' echo 'firmware_paths=[]' echo 'unknown_paths=[]' + echo 'docs_only=false' } >> "$GITHUB_OUTPUT" + python3 scripts/audit_markdown.py . --all --config config/markdown-audit.json else if [[ "${{ github.event_name }}" == "pull_request" ]]; then base="${{ github.event.pull_request.base.sha }}" else base="${{ github.event.before }}" fi - git fetch --no-tags --depth=1 origin "$base" - git diff --name-status -z "$base" "${{ github.sha }}" > changed-files.z - python3 scripts/ci_scope.py --name-status-file changed-files.z --github-output "$GITHUB_OUTPUT" + if [[ -z "$base" || "$base" == "0000000000000000000000000000000000000000" ]]; then + echo "A non-empty base SHA is required for changed-scope CI" >&2 + exit 1 + fi + git fetch --no-tags origin "$base" + git cat-file -e "$base^{commit}" + git diff --name-status -z --find-renames "$base" "${{ github.sha }}" > changed-files.z + git diff --name-status --find-renames "$base" "${{ github.sha }}" > changed-files.txt + if [[ ! -s changed-files.z || ! -s changed-files.txt ]]; then + echo "Changed-file diff is missing or empty" >&2 + exit 1 + fi + python3 scripts/ci_scope.py --config config/markdown-audit.json --name-status-file changed-files.z --github-output "$GITHUB_OUTPUT" + docs_only="$(grep '^docs_only=' "$GITHUB_OUTPUT" | tail -n1 | cut -d= -f2-)" + audit_args=(. --changed-files-from changed-files.txt --config config/markdown-audit.json) + if [[ "$docs_only" == "true" ]]; then + audit_args+=(--expect-docs-only) + fi + python3 scripts/audit_markdown.py "${audit_args[@]}" fi esp_idf_mode="$(grep '^esp_idf_mode=' "$GITHUB_OUTPUT" | tail -n1 | cut -d= -f2-)" arduino_mode="$(grep '^arduino_mode=' "$GITHUB_OUTPUT" | tail -n1 | cut -d= -f2-)" firmware_paths="$(grep '^firmware_paths=' "$GITHUB_OUTPUT" | tail -n1 | cut -d= -f2-)" unknown_paths="$(grep '^unknown_paths=' "$GITHUB_OUTPUT" | tail -n1 | cut -d= -f2-)" + docs_only="$(grep '^docs_only=' "$GITHUB_OUTPUT" | tail -n1 | cut -d= -f2-)" { echo "### CI scope" echo "- ESP-IDF mode: $esp_idf_mode" echo "- Arduino mode: $arduino_mode" echo "- Firmware paths: $firmware_paths" echo "- Unknown paths: $unknown_paths" + echo "- Documentation-only: $docs_only" } >> "$GITHUB_STEP_SUMMARY" discover-esp-idf: @@ -95,11 +116,13 @@ jobs: steps: - uses: actions/checkout@v4 - id: discover + env: + SELECTED_PATHS: ${{ needs.scope.outputs.esp_idf_paths }} shell: bash run: | args=() if [[ "${{ needs.scope.outputs.esp_idf_mode }}" == "selected" ]]; then - args+=(--selected-paths '${{ needs.scope.outputs.esp_idf_paths }}') + args+=(--selected-paths "$SELECTED_PATHS") fi python3 scripts/discover_examples.py --surface esp-idf \ --selector "${{ github.event.inputs.target || 'all' }}" \ @@ -144,11 +167,13 @@ jobs: steps: - uses: actions/checkout@v4 - id: discover + env: + SELECTED_PATHS: ${{ needs.scope.outputs.arduino_paths }} shell: bash run: | args=() if [[ "${{ needs.scope.outputs.arduino_mode }}" == "selected" ]]; then - args+=(--selected-paths '${{ needs.scope.outputs.arduino_paths }}') + args+=(--selected-paths "$SELECTED_PATHS") fi python3 scripts/discover_examples.py --surface arduino \ --selector "${{ github.event.inputs.target || 'all' }}" \ diff --git a/docs/ci.md b/docs/ci.md index 4914f95b..a15c14db 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -31,14 +31,28 @@ Start the workflow manually with `target=all`, an example directory name such as ## Documentation Checks -The lightweight repository checker runs the focused homepage, bilingual-link, -and local-link checks configured in `config/markdown-audit.json`: +The complete self-contained Markdown audit uses `config/markdown-audit.json` +for ownership, bilingual pairing and reciprocal language links, local and HTML +links/fragments, homepage structure, and public-text privacy checks. Changed +PR and push scopes run it with the complete rename-aware input: + +```text +python3 scripts/audit_markdown.py . --changed-files-from changed-files.txt --config config/markdown-audit.json +``` + +When the route classifier confirms every old and new affected path is Markdown +or an exact allowlisted documentation asset file, CI also adds +`--expect-docs-only`. Missing, empty, malformed, or unavailable changed-file +data fails the scope job rather than silently passing or selecting all builds. + +The legacy checker remains for compatibility with its focused homepage, +bilingual-link, and local-link conventions: ```text python3 scripts/check_docs.py . --config config/markdown-audit.json ``` -It is intentionally a limited repository checker, not a complete Markdown audit. +It is intentionally limited and does not replace the complete audit. ## Firmware Artifacts diff --git a/docs/ci_ZH.md b/docs/ci_ZH.md index 6d56e25a..94d474df 100644 --- a/docs/ci_ZH.md +++ b/docs/ci_ZH.md @@ -11,13 +11,23 @@ ## 文档检查 -轻量级仓库检查器会按 `config/markdown-audit.json` 运行针对主页、双语链接和本地链接的检查: +完整、自包含的 Markdown 审核使用 `config/markdown-audit.json` 检查所有权、双语配对和互链、 +本地及 HTML 链接/片段、主页结构和公开文本隐私。PR 和 push 的变更范围使用完整的重命名感知输入: + +```text +python3 scripts/audit_markdown.py . --changed-files-from changed-files.txt --config config/markdown-audit.json +``` + +当路由分类器确认每个旧路径和新路径均为 Markdown 或精确列入允许列表的文档资源文件时,CI 还会添加 +`--expect-docs-only`。缺失、为空、格式错误或不可用的变更文件数据会使范围作业失败,不会静默通过或选择全部构建。 + +旧版轻量检查器仍保留,用于兼容其针对主页、双语链接和本地链接的约定: ```text python3 scripts/check_docs.py . --config config/markdown-audit.json ``` -该检查器有意保持为范围有限的仓库检查器,并非完整的 Markdown 审核。 +该检查器有意保持为范围有限的仓库检查器,不能替代完整审核。 ## Arduino diff --git a/scripts/audit_markdown.py b/scripts/audit_markdown.py new file mode 100644 index 00000000..fdf9d500 --- /dev/null +++ b/scripts/audit_markdown.py @@ -0,0 +1,1865 @@ +#!/usr/bin/env python3 +"""Audit repository Markdown ownership, bilingual structure, and diff scope. + +The script is read-only and uses only the Python standard library. It is designed +for both local maintenance and a lightweight GitHub Actions documentation gate. +""" + +from __future__ import annotations + +import argparse +import copy +import fnmatch +import html +import json +import os +import re +import subprocess +import sys +import unicodedata +import urllib.parse +from collections import Counter +from dataclasses import asdict, dataclass +from pathlib import Path, PurePosixPath +from typing import Iterable, Sequence + + +VALID_CATEGORIES = { + "first_party_customer", + "first_party_maintainer", + "first_party_wrapper", + "managed_component", + "third_party", + "embedded_upstream", + "unknown", +} +AUDITED_OWNERSHIP = { + "first_party_customer", + "first_party_maintainer", + "first_party_wrapper", +} +UPSTREAM_OWNERSHIP = {"managed_component", "third_party", "embedded_upstream"} + +DEFAULT_CONFIG = { + "classification_rules": [ + { + "category": "managed_component", + "patterns": ["managed_components/**", "**/managed_components/**"], + }, + { + "category": "third_party", + "patterns": [ + "third_party/**", + "**/third_party/**", + "third-party/**", + "**/third-party/**", + "vendor/**", + "**/vendor/**", + "external/**", + "**/external/**", + ], + }, + { + "category": "embedded_upstream", + "patterns": [ + "upstream/**", + "**/upstream/**", + "submodules/**", + "**/submodules/**", + "libraries/**", + "**/libraries/**", + "**/components/waveshare__*/**", + ], + }, + { + "category": "unknown", + "patterns": [ + "lib/**", + "**/lib/**", + "libs/**", + "**/libs/**", + "deps/**", + "**/deps/**", + "dependencies/**", + "**/dependencies/**", + "middleware/**", + "**/middleware/**", + "sdk/**", + "**/sdk/**", + ], + }, + { + "category": "first_party_wrapper", + "patterns": [ + "wrappers/**", + "**/wrappers/**", + "overrides/**", + "**/overrides/**", + "**/*_safe/**", + "**/bsp_extra/**", + ], + }, + { + "category": "unknown", + "patterns": [ + "components/**", + "**/components/**", + "examples/**/components/**", + ], + }, + { + "category": "first_party_maintainer", + "patterns": [ + ".github/**", + "**/.github/**", + "CONTRIBUTING*.md", + "SUPPORT*.md", + "SECURITY*.md", + "CHANGELOG*.md", + "releases/*.md", + "releases/**/*.md", + "config/*.md", + "config/**/*.md", + "assets/*.md", + "assets/**/*.md", + "docs/ci*.md", + "docs/**/ci*.md", + "docs/component*.md", + "docs/**/component*.md", + "docs/repository*.md", + "docs/**/repository*.md", + ], + }, + ], + "exclude_patterns": [ + ".git/**", + "**/.git/**", + "build/**", + "**/build/**", + "build*/**", + "**/build*/**", + "node_modules/**", + "**/node_modules/**", + "__pycache__/**", + "**/__pycache__/**", + ], + "pair_exempt_patterns": [], + "language_link_exempt_patterns": [], + "relative_link_ignore_patterns": [], + "docs_only_allowed_patterns": [], + "sensitive_allow_regexes": [], + "bilingual_pairs": [], + "bilingual_directory_mappings": [], + "homepage_h3_emoji_allow_patterns": [], + "homepage_pairs": [ + {"english": "README.md", "chinese": "README_ZH.md", "profile": "auto"}, + ], +} + +MARKDOWN_LINK_RE = re.compile( + r"!?\[[^\]]*\]\(\s*(<[^>]+>|[^\s)]+)(?:\s+['\"][^'\"]*['\"])?\s*\)" +) +HTML_LINK_RE = re.compile(r"\b(?:href|src)\s*=\s*['\"]([^'\"]+)['\"]", re.IGNORECASE) +REFERENCE_LINK_RE = re.compile(r"^\s*\[[^\]]+\]:\s*(<[^>]+>|\S+)", re.MULTILINE) +HTML_ANCHOR_RE = re.compile( + r"]*\bhref\s*=\s*['\"]([^'\"]+)['\"][^>]*>(.*?)", + re.IGNORECASE | re.DOTALL, +) +HTML_H1_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) +HTML_ID_RE = re.compile( + r"<[A-Za-z][^>]*\b(?:id|name)\s*=\s*(?:['\"]([^'\"]+)['\"]|([^\s>]+))", + re.IGNORECASE, +) +HTML_IMG_RE = re.compile(r"]*>", re.IGNORECASE) +HTML_ALT_RE = re.compile(r"\balt\s*=\s*['\"]([^'\"]*)['\"]", re.IGNORECASE) +CENTERED_DIV_RE = re.compile( + r"]*\balign\s*=\s*(?:['\"]center['\"]|center)(?:\s|>)", + re.IGNORECASE, +) +STRONG_RE = re.compile(r"]*>.*?", re.IGNORECASE | re.DOTALL) +H2_RE = re.compile(r"^##(?!#)\s+(.+?)\s*$", re.MULTILINE) +H3_RE = re.compile(r"^###(?!#)\s+(.+?)\s*$", re.MULTILINE) +ATX_HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.+?)[ \t]*$", re.MULTILINE) +TAG_RE = re.compile(r"<[^>]+>") +EMOJI_RE = re.compile(r"[\U0001F000-\U0001FAFF\u2139\u2600-\u27BF]") +FENCE_RE = re.compile(r"^\s*(```|~~~)") + +QUICK_LINK_RULES = ( + ("quick_start", "🚀", re.compile(r"quick\s*start|getting\s*started|快速开始|快速入门", re.I)), + ("esp_idf", "🧩", re.compile(r"esp[- ]idf", re.I)), + ("arduino", "🔧", re.compile(r"arduino", re.I)), + ("firmware", "📦", re.compile(r"firmware|固件", re.I)), + ("documentation", "📚", re.compile(r"documentation|documents?|docs\b|文档|资料", re.I)), + ("product", "🌐", re.compile(r"product|产品", re.I)), +) + +H2_ICON_RULES = ( + ("🖥️", re.compile(r"hardware|硬件", re.I)), + ("✨", re.compile(r"overview|概述|简介", re.I)), + ("📱", re.compile(r"brookesia|application\s+firmware|应用固件", re.I)), + ("📦", re.compile(r"firmware|release|固件|发布", re.I)), + ("🧪", re.compile(r"examples?|示例|例程", re.I)), + ("🛠️", re.compile(r"toolchains?|工具链", re.I)), + ("🗂️", re.compile(r"repository\s+(?:layout|structure)|仓库(?:结构|布局)", re.I)), + ("📚", re.compile(r"documentation|文档", re.I)), + ("🤝", re.compile(r"support|contribut|支持|贡献", re.I)), + ("📄", re.compile(r"licen[cs]e|许可证|许可", re.I)), +) + +BADGE_RULES = ( + ("build", re.compile(r"build|actions?|workflow|\bci\b|构建", re.I)), + ("release", re.compile(r"release|firmware|version|固件|发布|版本", re.I)), + ("license", re.compile(r"licen[cs]e|许可证|许可", re.I)), +) + +HOMEPAGE_COMPONENTS = { + "centered_header", + "html_h1", + "subtitle", + "badges", + "language_switch", + "quick_links", + "hero_image", + "separator", + "h2", +} +HOMEPAGE_PROFILES = { + "auto": set(), + "single-product": set(HOMEPAGE_COMPONENTS), + "single_product": set(HOMEPAGE_COMPONENTS), + "multi-product-hub": { + "centered_header", + "html_h1", + "subtitle", + "language_switch", + "separator", + "h2", + }, + "multi_product_hub": { + "centered_header", + "html_h1", + "subtitle", + "language_switch", + "separator", + "h2", + }, +} +VALID_QUICK_LINK_KEYS = {item[0] for item in QUICK_LINK_RULES} +VALID_BADGE_KEYS = {item[0] for item in BADGE_RULES} + +SENSITIVE_RULES = ( + ( + "LOCAL_ABSOLUTE_PATH", + re.compile(r"(?i)(? in public documentation", + ), + ( + "MAC_ADDRESS", + re.compile(r"(?i)\b(?:[0-9A-F]{2}[:-]){5}[0-9A-F]{2}\b"), + "remove or replace device-specific MAC addresses", + ), + ( + "CREDENTIAL_OR_TOKEN", + re.compile( + r"(?i)(?:\b(?:github_pat_[A-Za-z0-9_]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{20,}|Bearer\s+[A-Za-z0-9._~+/-]{20,})|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----)" + ), + "remove credentials and rotate any real secret before publishing", + ), + ( + "TOOL_OR_MODEL_PROVENANCE", + re.compile( + r"(?i)\b(?:(?:generated|written|edited|translated|created|reviewed)\s+(?:by|with|using)|(?:tool|model)(?:\s+(?:used|source|name))?\s*[:=])\s*(?:OpenAI|ChatGPT|Codex|Claude|Gemini|Copilot|GPT[-\s]?[0-9][A-Za-z0-9_.-]*)" + ), + "remove editing-tool or model provenance from repository-public text", + ), +) + +SOURCE_SUFFIXES = { + ".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".ino", ".py", ".ps1", + ".sh", ".bat", ".cmake", ".s", ".S", +} +SOURCE_NAMES = {"CMakeLists.txt", "Kconfig", "Makefile"} +CONFIG_SUFFIXES = {".yml", ".yaml", ".json", ".toml", ".ini", ".conf", ".defaults", ".csv"} +CONFIG_NAMES = {"sdkconfig", "sdkconfig.defaults", "idf_component.yml", "partitions.csv"} +# Checked-in Waveshare factory/delivery firmware convention. Other binary +# suffixes remain non-Markdown scope, but are not labelled as a Waveshare +# firmware convention without repository evidence. +FIRMWARE_SUFFIXES = {".bin"} +PACKAGE_SUFFIXES = {".zip", ".7z", ".tar", ".tgz", ".gz", ".xz", ".bz2"} +DOCUMENTATION_ASSET_SUFFIXES = {".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"} + +# ``url`` in an ESP-IDF component manifest is sometimes a product page rather +# than source provenance. Treat it as a repository only for established source +# hosts or an explicitly Git-shaped URL; ``repository`` remains authoritative +# for self-hosted forges as well. +SOURCE_REPOSITORY_HOSTS = { + "bitbucket.org", + "codeberg.org", + "gitee.com", + "github.com", + "gitlab.com", +} +COMPONENT_SOURCE_SUFFIXES = { + ".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".s", ".S", +} + + +@dataclass(frozen=True) +class Change: + status: str + path: str + old_path: str | None = None + + +@dataclass(frozen=True) +class Finding: + severity: str + code: str + path: str + line: int | None + message: str + hint: str + + +class AuditError(RuntimeError): + """Operational or configuration error (exit code 2).""" + + +def posix_path(value: str | Path) -> str: + value = str(value).replace("\\", "/") + while value.startswith("./"): + value = value[2:] + return value + + +def matches(path: str, patterns: Iterable[str]) -> bool: + normalized = posix_path(path) + return any(fnmatch.fnmatchcase(normalized, posix_path(pattern)) for pattern in patterns) + + +def normalized_config_path(value: str, label: str, *, allow_root: bool = False) -> str: + normalized = posix_path(value.strip()) + if allow_root and normalized in {"", "."}: + return "" + normalized = normalized.strip("/") + pure = PurePosixPath(normalized) + if ( + not normalized + or pure.is_absolute() + or ".." in pure.parts + or re.match(r"^[A-Za-z]:", normalized) + ): + raise AuditError(f"{label} must be a repository-relative path: {value!r}") + return normalized + + +def parse_language_pair(value: object, label: str) -> dict[str, str]: + if not isinstance(value, dict) or set(value) != {"english", "chinese"}: + raise AuditError(f"{label} must contain english and chinese") + if not all(isinstance(value[name], str) for name in ("english", "chinese")): + raise AuditError(f"{label} paths must be strings") + pair = { + name: normalized_config_path(value[name], f"{label}.{name}") + for name in ("english", "chinese") + } + if pair["english"] == pair["chinese"]: + raise AuditError(f"{label} must use two different paths") + return pair + + +def validate_narrow_h3_regex(pattern: str, label: str) -> None: + try: + compiled = re.compile(pattern) + except re.error as exc: + raise AuditError(f"invalid {label} entry {pattern!r}: {exc}") from exc + if not pattern.startswith("^") or not pattern.endswith("$") or compiled.fullmatch(""): + raise AuditError( + f"{label} entry must be a non-empty, fully anchored narrow regex: {pattern!r}" + ) + + +def parse_homepage_pair(value: object, label: str) -> dict: + required = {"english", "chinese"} + optional = { + "profile", + "required_components", + "required_quick_links", + "required_badges", + "required_h2_icons", + "h3_emoji_allow_patterns", + } + if not isinstance(value, dict) or not required.issubset(value) or set(value) - required - optional: + raise AuditError( + f"{label} must contain english and chinese plus only supported homepage settings" + ) + pair = parse_language_pair( + {"english": value["english"], "chinese": value["chinese"]}, label + ) + profile = value.get("profile", "auto") + if not isinstance(profile, str) or profile not in HOMEPAGE_PROFILES: + raise AuditError( + f"{label}.profile must be one of: {', '.join(sorted(HOMEPAGE_PROFILES))}" + ) + pair["profile"] = profile + list_keys = { + "required_components", + "required_quick_links", + "required_badges", + "required_h2_icons", + "h3_emoji_allow_patterns", + } + for key in list_keys: + items = value.get(key, []) + if not isinstance(items, list) or not all(isinstance(item, str) for item in items): + raise AuditError(f"{label}.{key} must contain strings") + pair[key] = list(items) + invalid_components = sorted(set(pair["required_components"]) - HOMEPAGE_COMPONENTS) + if invalid_components: + raise AuditError(f"{label}.required_components has unknown values: {', '.join(invalid_components)}") + invalid_quick = sorted(set(pair["required_quick_links"]) - VALID_QUICK_LINK_KEYS) + if invalid_quick: + raise AuditError(f"{label}.required_quick_links has unknown values: {', '.join(invalid_quick)}") + invalid_badges = sorted(set(pair["required_badges"]) - VALID_BADGE_KEYS) + if invalid_badges: + raise AuditError(f"{label}.required_badges has unknown values: {', '.join(invalid_badges)}") + for pattern in pair["h3_emoji_allow_patterns"]: + validate_narrow_h3_regex(pattern, f"{label}.h3_emoji_allow_patterns") + return pair + + +def validate_pair_conflicts(config: dict) -> None: + partners: dict[str, str] = {} + for index, pair in enumerate([*config["bilingual_pairs"], *config["homepage_pairs"]]): + english, chinese = pair["english"], pair["chinese"] + for source, target in ((english, chinese), (chinese, english)): + previous = partners.get(source) + if previous is not None and previous != target: + raise AuditError( + f"conflicting bilingual companion for {source!r}: {previous!r} and {target!r}" + ) + partners[source] = target + + +def parse_docs_only_asset_paths(value: object, label: str) -> list[str]: + """Validate explicit documentation assets allowed beside Markdown files.""" + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise AuditError(f"{label} must be a list of strings") + paths: list[str] = [] + for item in value: + normalized = normalized_config_path(item, label) + if ( + item != normalized + or item.endswith("/") + or "//" in item + or any(character in item for character in "*?[]") + or PurePosixPath(normalized).suffix.lower() not in DOCUMENTATION_ASSET_SUFFIXES + ): + raise AuditError( + f"{label} entries must be explicit repository-relative documentation asset files" + ) + paths.append(normalized) + return paths + + +def load_config(path: Path | None) -> dict: + config = copy.deepcopy(DEFAULT_CONFIG) + if path is None: + return config + try: + user = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise AuditError(f"cannot read JSON config {path}: {exc}") from exc + if not isinstance(user, dict): + raise AuditError("config root must be a JSON object") + known = set(DEFAULT_CONFIG) + unknown = sorted(set(user) - known) + if unknown: + raise AuditError("unknown config keys: " + ", ".join(unknown)) + for key, value in user.items(): + if not isinstance(value, list): + raise AuditError(f"config key {key!r} must be a list") + if key == "classification_rules": + rules = [] + for index, rule in enumerate(value): + if not isinstance(rule, dict) or set(rule) != {"category", "patterns"}: + raise AuditError(f"classification_rules[{index}] must contain category and patterns") + category = rule["category"] + patterns = rule["patterns"] + if category not in VALID_CATEGORIES: + raise AuditError(f"invalid classification category: {category!r}") + if not isinstance(patterns, list) or not all(isinstance(item, str) for item in patterns): + raise AuditError(f"classification_rules[{index}].patterns must be strings") + rules.append({"category": category, "patterns": patterns}) + config[key] = rules + config[key] + elif key == "docs_only_allowed_patterns": + config[key] = parse_docs_only_asset_paths(value, key) + elif key == "homepage_pairs": + config[key] = [ + parse_homepage_pair(pair, f"homepage_pairs[{index}]") + for index, pair in enumerate(value) + ] + elif key == "bilingual_pairs": + config[key] = [ + parse_language_pair(pair, f"bilingual_pairs[{index}]") + for index, pair in enumerate(value) + ] + elif key == "bilingual_directory_mappings": + mappings = [] + for index, mapping in enumerate(value): + label = f"bilingual_directory_mappings[{index}]" + if ( + not isinstance(mapping, dict) + or not {"english", "chinese"}.issubset(mapping) + or set(mapping) - {"english", "chinese", "chinese_suffix"} + ): + raise AuditError( + f"{label} must contain english and chinese plus optional chinese_suffix" + ) + if not all(isinstance(mapping[name], str) for name in ("english", "chinese")): + raise AuditError(f"{label} paths must be strings") + parsed = { + name: normalized_config_path(mapping[name], f"{label}.{name}", allow_root=True) + for name in ("english", "chinese") + } + chinese_suffix = mapping.get("chinese_suffix", "") + if not isinstance(chinese_suffix, str) or not re.fullmatch( + r"[A-Za-z0-9_-]*", chinese_suffix + ): + raise AuditError( + f"{label}.chinese_suffix must be an optional filename suffix such as _ZH" + ) + parsed["chinese_suffix"] = chinese_suffix + if parsed["english"] == parsed["chinese"]: + raise AuditError(f"{label} must use two different directory roots") + mappings.append(parsed) + config[key] = mappings + elif not all(isinstance(item, str) for item in value): + raise AuditError(f"config key {key!r} must contain strings") + else: + config[key] = config[key] + value + for pattern in config["sensitive_allow_regexes"]: + try: + re.compile(pattern) + except re.error as exc: + raise AuditError(f"invalid sensitive_allow_regexes entry {pattern!r}: {exc}") from exc + for pattern in config["homepage_h3_emoji_allow_patterns"]: + validate_narrow_h3_regex(pattern, "homepage_h3_emoji_allow_patterns") + validate_pair_conflicts(config) + return config + + +def classify(path: str, config: dict) -> str: + for rule in config["classification_rules"]: + if matches(path, rule["patterns"]): + return rule["category"] + return "first_party_customer" + + +def repository_identity(value: str, *, field: str = "repository") -> str | None: + """Return a credential-free forge/repository identity for comparison. + + Browser suffixes such as GitHub ``/tree/...`` are ignored. The returned + value never contains user-info, query strings, fragments, or the original + URL, so a credential-bearing Git remote cannot leak into audit output. + """ + raw = value.strip().strip('"\'') + if not raw or field not in {"repository", "url"}: + return None + + host = "" + path = "" + scheme = "" + scp_match = re.fullmatch( + r"(?:[^/@:\s]+@)?(?P[^/:\s]+):(?P[^?#\s]+)", raw + ) + if scp_match and "://" not in raw: + scheme = "ssh" + host = scp_match.group("host") + path = scp_match.group("path") + else: + try: + parsed = urllib.parse.urlsplit(raw) + except ValueError: + return None + scheme = parsed.scheme.lower() + if scheme not in {"http", "https", "git", "ssh"} or not parsed.hostname: + return None + host = parsed.hostname + path = parsed.path + + host = host.lower().rstrip(".") + if host.startswith("www.") and host[4:] in SOURCE_REPOSITORY_HOSTS: + host = host[4:] + raw_path = urllib.parse.unquote(path).strip("/") + if not raw_path: + return None + + git_shaped = scheme in {"git", "ssh"} or raw_path.lower().endswith(".git") + if field == "url" and host not in SOURCE_REPOSITORY_HOSTS and not git_shaped: + return None + + parts = [part for part in raw_path.split("/") if part] + if host in {"github.com", "bitbucket.org", "gitee.com", "codeberg.org"}: + if len(parts) < 2: + return None + parts = parts[:2] + else: + # GitLab and compatible forges may use nested groups. Their web URLs + # put browsing state after /-/; common non-GitLab tree/blob forms are + # also cut without retaining branch or file names. + if "-" in parts: + dash = parts.index("-") + if dash + 1 < len(parts) and parts[dash + 1] in {"tree", "blob"}: + parts = parts[:dash] + for marker in ("tree", "blob"): + if marker in parts[2:]: + parts = parts[:parts.index(marker)] + break + if len(parts) < 2: + return None + parts[-1] = re.sub(r"(?i)\.git$", "", parts[-1]) + if not parts[-1]: + return None + return host + "/" + "/".join(parts) + + +def discover_self_repository_identities(root: Path) -> set[str]: + """Read Git remotes and return only normalized, credential-free identities.""" + remotes = subprocess.run( + ["git", "remote"], cwd=root, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, check=False, text=True, + ) + if remotes.returncode != 0: + return set() + identities: set[str] = set() + for remote in remotes.stdout.splitlines(): + if not remote or not re.fullmatch(r"[^\s\0]+", remote): + continue + urls = subprocess.run( + ["git", "remote", "get-url", "--all", remote], cwd=root, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False, + text=True, + ) + if urls.returncode != 0: + continue + for value in urls.stdout.splitlines(): + identity = repository_identity(value) + if identity: + identities.add(identity) + return identities + + +def _manifest_repository_identities(manifest: Path) -> tuple[set[str], set[str]]: + """Extract top-level repository/url metadata without requiring a YAML parser.""" + try: + lines = manifest.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return set(), set() + found: dict[str, set[str]] = {"repository": set(), "url": set()} + for line in lines: + # A dependency may itself contain url/repository keys. Only component + # metadata at column zero describes the directory being classified. + if line[:1].isspace(): + continue + match = re.match(r"^(repository|url)\s*:\s*(.*?)\s*$", line, re.IGNORECASE) + if not match: + continue + field = match.group(1).lower() + value = match.group(2).strip() + if value[:1] in {'"', "'"} and value[-1:] == value[:1]: + value = value[1:-1] + else: + value = re.split(r"\s+#", value, maxsplit=1)[0].strip() + identity = repository_identity(value, field=field) + if identity: + found[field].add(identity) + return found["repository"], found["url"] + + +def _is_nested_component_source_root(repo_root: Path, component_root: Path) -> bool: + """Require a conservative, self-contained component/source-root shape.""" + if component_root == repo_root or not (component_root / "CMakeLists.txt").is_file(): + return False + try: + children = list(component_root.iterdir()) + except OSError: + return False + has_readme = any( + child.is_file() and child.name.lower().startswith("readme") + and child.suffix.lower() == ".md" + for child in children + ) + has_license = any( + child.is_file() + and child.name.lower().startswith(("license", "licence", "copying")) + for child in children + ) + has_source = any( + (child.is_file() and child.suffix in COMPONENT_SOURCE_SUFFIXES) + or (child.is_dir() and child.name.lower() in {"include", "src"}) + for child in children + ) + return has_readme and has_license and has_source + + +def discover_dynamic_upstream_roots(root: Path) -> set[str]: + """Discover conservative nested ESP-IDF source roots owned by another repo. + + Discovery is intentionally disabled when the checkout has no comparable Git + remote. This preserves first-party/unknown auditing for archives and avoids + turning a lone external homepage URL into an ownership assertion. + """ + root = root.resolve() + self_identities = discover_self_repository_identities(root) + if not self_identities: + return set() + roots: set[str] = set() + for manifest in root.rglob("idf_component.yml"): + component_root = manifest.parent + if not _is_nested_component_source_root(root, component_root): + continue + repositories, urls = _manifest_repository_identities(manifest) + candidates = repositories or urls + if not candidates or candidates & self_identities: + continue + try: + relative = component_root.relative_to(root) + except ValueError: + continue + roots.add(posix_path(relative).strip("/")) + return roots + + +def path_is_within(path: str, root: str) -> bool: + normalized = posix_path(path).strip("/") + normalized_root = posix_path(root).strip("/") + return normalized == normalized_root or normalized.startswith(normalized_root + "/") + + +def discover_gitlink_roots(root: Path) -> set[str]: + """Return submodule/gitlink roots without requiring an initialized checkout.""" + roots: set[str] = set() + gitmodules = root / ".gitmodules" + if gitmodules.is_file(): + try: + content = gitmodules.read_text(encoding="utf-8", errors="replace") + except OSError: + content = "" + for match in re.finditer(r"(?mi)^\s*path\s*=\s*(.+?)\s*$", content): + value = posix_path(match.group(1).strip().strip('"\'')) + pure = PurePosixPath(value) + if value and not pure.is_absolute() and ".." not in pure.parts: + roots.add(value.strip("/")) + + process = subprocess.run( + ["git", "ls-files", "--stage", "-z"], + cwd=root, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + ) + if process.returncode == 0: + for entry in process.stdout.decode("utf-8", errors="surrogateescape").split("\0"): + if not entry or "\t" not in entry: + continue + metadata, path = entry.split("\t", 1) + if metadata.split(maxsplit=1)[0] == "160000": + roots.add(posix_path(path).strip("/")) + return {item for item in roots if item} + + +def classify_at_root( + path: str, + config: dict, + gitlink_roots: set[str], + dynamic_upstream_roots: set[str] | None = None, +) -> str: + upstream_roots = gitlink_roots | (dynamic_upstream_roots or set()) + if any(path_is_within(path, root) for root in upstream_roots): + return "embedded_upstream" + return classify(path, config) + + +def is_excluded(path: str, config: dict) -> bool: + return matches(path, config["exclude_patterns"]) + + +def iter_markdown_files(root: Path, config: dict) -> list[str]: + result: list[str] = [] + for current, dirnames, filenames in os.walk(root): + current_path = Path(current) + kept: list[str] = [] + for dirname in dirnames: + relative = posix_path((current_path / dirname).relative_to(root)) + "/" + if not is_excluded(relative, config): + kept.append(dirname) + dirnames[:] = kept + for filename in filenames: + path = current_path / filename + relative = posix_path(path.relative_to(root)) + if path.suffix.lower() == ".md" and not is_excluded(relative, config): + result.append(relative) + return sorted(result, key=str.lower) + + +def tracked_markdown_files(root: Path, config: dict) -> list[str]: + """Use the repository index for --all, excluding ignored/generated files.""" + top = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=root, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + text=True, + ) + if top.returncode != 0: + return iter_markdown_files(root, config) + try: + if Path(top.stdout.strip()).resolve() != root.resolve(): + return iter_markdown_files(root, config) + except OSError: + return iter_markdown_files(root, config) + process = subprocess.run( + ["git", "ls-files", "-z"], + cwd=root, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if process.returncode != 0: + return iter_markdown_files(root, config) + result = [] + for item in process.stdout.decode("utf-8", errors="surrogateescape").split("\0"): + path = posix_path(item) + if ( + path + and path.lower().endswith(".md") + and not is_excluded(path, config) + and (root / path).is_file() + ): + result.append(path) + return sorted(set(result), key=str.lower) + + +def run_git(root: Path, args: Sequence[str]) -> bytes: + process = subprocess.run( + ["git", *args], cwd=root, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False + ) + if process.returncode: + detail = process.stderr.decode("utf-8", errors="replace").strip() + raise AuditError(f"git {' '.join(args)} failed: {detail or 'unknown error'}") + return process.stdout + + +def parse_name_status_z(data: bytes) -> list[Change]: + tokens = data.decode("utf-8", errors="surrogateescape").split("\0") + if tokens and tokens[-1] == "": + tokens.pop() + changes: list[Change] = [] + index = 0 + while index < len(tokens): + status = tokens[index] + index += 1 + if not status: + continue + code = status[0] + if code in {"R", "C"}: + if index + 1 >= len(tokens): + raise AuditError("malformed git rename/copy output") + old_path, new_path = tokens[index], tokens[index + 1] + index += 2 + changes.append(Change(status=code, path=posix_path(new_path), old_path=posix_path(old_path))) + else: + if index >= len(tokens): + raise AuditError("malformed git name-status output") + changes.append(Change(status=code, path=posix_path(tokens[index]))) + index += 1 + return changes + + +def changes_from_base(root: Path, base: str) -> list[Change]: + return parse_name_status_z(run_git(root, ["diff", "--name-status", "-z", "--find-renames", f"{base}...HEAD", "--"])) + + +def require_clean_base_checkout(root: Path) -> None: + status = run_git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]) + if status: + raise AuditError( + "--base requires a clean checkout so audited content matches HEAD; " + "use an isolated worktree/clone for the committed range or --working-tree for local edits" + ) + + +def repository_has_head(root: Path) -> bool: + """Return whether ``HEAD`` resolves to a commit, including unborn repos safely. + + ``git rev-parse --verify --quiet`` deliberately returns 1 with no output for + an unborn branch. Any other failure is operational (for example, a path + outside a Git worktree or a corrupt repository) and must remain visible to + callers instead of being mistaken for an empty change set. + """ + + args = ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"] + process = subprocess.run( + ["git", *args], + cwd=root, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if process.returncode == 0: + return True + if process.returncode == 1 and not process.stdout and not process.stderr: + return False + detail = process.stderr.decode("utf-8", errors="replace").strip() + raise AuditError(f"git {' '.join(args)} failed: {detail or 'unknown error'}") + + +def merge_unborn_changes(staged: Sequence[Change], unstaged: Sequence[Change]) -> list[Change]: + """Merge index and worktree diffs when the repository has no commit yet. + + A path added to the index and then edited in the worktree is still an + addition relative to an unborn branch, while an unstaged deletion must stay + a deletion. Likewise, a later content edit must not discard rename/copy + provenance already reported by Git. Other later worktree statuses replace + the index status because they describe the current filesystem state. + """ + + unique: dict[str, Change] = {} + for change in staged: + unique[change.path] = change + for change in unstaged: + previous = unique.get(change.path) + if ( + previous is not None + and change.status == "M" + and previous.status in {"A", "R", "C"} + ): + continue + unique[change.path] = change + return list(unique.values()) + + +def changes_from_worktree(root: Path) -> list[Change]: + if repository_has_head(root): + changes = parse_name_status_z( + run_git(root, ["diff", "--name-status", "-z", "--find-renames", "HEAD", "--"]) + ) + else: + # With no HEAD, ``git diff --cached`` intentionally compares the index + # to Git's empty tree. The second diff contributes edits between the + # index and worktree; neither command needs a hard-coded object hash. + staged = parse_name_status_z( + run_git(root, ["diff", "--cached", "--name-status", "-z", "--find-renames", "--"]) + ) + unstaged = parse_name_status_z( + run_git(root, ["diff", "--name-status", "-z", "--find-renames", "--"]) + ) + changes = merge_unborn_changes(staged, unstaged) + untracked = run_git(root, ["ls-files", "--others", "--exclude-standard", "-z"]).decode( + "utf-8", errors="surrogateescape" + ) + changes.extend(Change("A", posix_path(item)) for item in untracked.split("\0") if item) + unique: dict[str, Change] = {} + for change in changes: + unique[change.path] = change + return sorted(unique.values(), key=lambda item: item.path.lower()) + + +def changes_from_file(path: Path) -> list[Change]: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + raise AuditError(f"cannot read changed-files list {path}: {exc}") from exc + changes: list[Change] = [] + for number, raw in enumerate(lines, 1): + line = raw.strip() + if not line or line.startswith("#"): + continue + parts = line.split("\t") + if len(parts) == 1: + changes.append(Change("M", posix_path(parts[0]))) + elif parts[0] and parts[0][0] in {"R", "C"} and len(parts) == 3: + changes.append(Change(parts[0][0], posix_path(parts[2]), posix_path(parts[1]))) + elif len(parts) == 2 and parts[0] and parts[0][0] in "ADMTCU": + changes.append(Change(parts[0][0], posix_path(parts[1]))) + else: + raise AuditError(f"invalid changed-files entry at {path}:{number}: {raw!r}") + if not changes: + raise AuditError(f"changed-files list is empty: {path}") + return changes + + +def validate_change_paths(changes: Iterable[Change]) -> None: + for change in changes: + path = PurePosixPath(change.path) + if path.is_absolute() or ".." in path.parts or re.match(r"^[A-Za-z]:", change.path): + raise AuditError(f"changed path must be repository-relative: {change.path!r}") + + +def read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + raise AuditError(f"cannot read {path}: {exc}") from exc + + +def strip_fenced_code(text: str) -> str: + output: list[str] = [] + fence: str | None = None + for line in text.splitlines(keepends=True): + marker = FENCE_RE.match(line) + if marker: + current = marker.group(1) + if fence is None: + fence = current + elif current == fence: + fence = None + output.append("\n" if line.endswith("\n") else "") + elif fence is None: + output.append(re.sub(r"`[^`\n]*`", "", line)) + else: + output.append("\n" if line.endswith("\n") else "") + return "".join(output) + + +def iter_links(text: str) -> Iterable[tuple[str, int]]: + searchable = strip_fenced_code(text) + seen: set[tuple[int, str]] = set() + for pattern in (MARKDOWN_LINK_RE, HTML_LINK_RE, REFERENCE_LINK_RE): + for match in pattern.finditer(searchable): + target = html.unescape(match.group(1).strip().strip("<>")) + item = (match.start(1), target) + if item in seen: + continue + seen.add(item) + yield target, searchable.count("\n", 0, match.start(1)) + 1 + + +def local_reference(raw: str) -> tuple[str, str] | None: + if not raw or raw.startswith("//"): + return None + parsed = urllib.parse.urlsplit(raw) + if parsed.scheme or parsed.netloc: + return None + value = urllib.parse.unquote(parsed.path).replace("\\", "/") + fragment = unicodedata.normalize( + "NFC", html.unescape(urllib.parse.unquote(parsed.fragment)) + ) + if not value and not fragment: + return None + return value, fragment + + +def local_target(raw: str) -> str | None: + reference = local_reference(raw) + return reference[0] or None if reference is not None else None + + +def resolve_repo_link(root: Path, document: str, target: str) -> tuple[Path | None, str | None]: + candidate = (root / PurePosixPath(document).parent / PurePosixPath(target)).resolve() + try: + relative = posix_path(candidate.relative_to(root.resolve())) + except ValueError: + return None, None + return candidate, relative + + +def strip_fenced_blocks(text: str) -> str: + output: list[str] = [] + fence: str | None = None + for line in text.splitlines(keepends=True): + marker = FENCE_RE.match(line) + if marker: + current = marker.group(1) + if fence is None: + fence = current + elif current == fence: + fence = None + output.append("\n" if line.endswith("\n") else "") + elif fence is None: + output.append(line) + else: + output.append("\n" if line.endswith("\n") else "") + return "".join(output) + + +def github_heading_slug(title: str) -> str: + value = html.unescape(title) + value = re.sub(r"!?\[([^\]]*)\]\([^)]*\)", r"\1", value) + value = TAG_RE.sub("", value).replace("`", "") + value = unicodedata.normalize("NFC", value).strip().lower() + kept: list[str] = [] + for character in value: + if character == "\ufe0f": + continue + category = unicodedata.category(character) + if character.isspace(): + kept.append(" ") + elif character in {"-", "_"} or category[0] in {"L", "N", "M"}: + kept.append(character) + return re.sub(r"\s+", "-", "".join(kept)).strip("-") + + +def document_anchors(text: str) -> set[str]: + searchable = strip_fenced_blocks(text) + anchors: set[str] = set() + for match in HTML_ID_RE.finditer(searchable): + raw = match.group(1) if match.group(1) is not None else match.group(2) + anchors.add(unicodedata.normalize("NFC", html.unescape(urllib.parse.unquote(raw)))) + + headings: list[str] = [] + lines = searchable.splitlines() + index = 0 + while index < len(lines): + line = lines[index] + match = re.match(r"^(#{1,6})[ \t]+(.+?)[ \t]*$", line) + if match: + headings.append(re.sub(r"[ \t]+#+[ \t]*$", "", match.group(2))) + elif ( + line.strip() + and index + 1 < len(lines) + and re.match(r"^[ \t]*(?:=+|-+)[ \t]*$", lines[index + 1]) + ): + headings.append(line.strip()) + index += 1 + index += 1 + + counts: Counter[str] = Counter() + for heading in headings: + base = github_heading_slug(heading) + if not base: + continue + occurrence = counts[base] + counts[base] += 1 + anchors.add(base if occurrence == 0 else f"{base}-{occurrence}") + return anchors + + +def link_findings(root: Path, document: str, text: str, config: dict) -> list[Finding]: + findings: list[Finding] = [] + anchor_cache: dict[str, set[str]] = {} + for raw, line in iter_links(text): + reference = local_reference(raw) + if reference is None: + continue + target, fragment = reference + if not target: + candidate = root / document + relative = document + else: + candidate, relative = resolve_repo_link(root, document, target) + if candidate is None or relative is None: + findings.append(Finding( + "error", "RELATIVE_LINK_ESCAPES_REPO", document, line, + f"relative link escapes the repository: {raw}", + "use a repository-relative target that remains inside the checkout", + )) + continue + if matches(relative, config["relative_link_ignore_patterns"]): + continue + if not candidate.exists(): + findings.append(Finding( + "error", "RELATIVE_LINK_MISSING", document, line, + f"relative link target does not exist: {raw}", + "fix the path or add the intended tracked target", + )) + continue + if fragment and candidate.is_file() and candidate.suffix.lower() in {".md", ".html", ".htm"}: + if relative not in anchor_cache: + target_text = text if relative == document else read_text(candidate) + anchor_cache[relative] = document_anchors(target_text) + if fragment not in anchor_cache[relative]: + findings.append(Finding( + "error", "RELATIVE_LINK_FRAGMENT_MISSING", document, line, + f"relative link fragment does not exist in {relative}: #{fragment}", + "fix the fragment to match a GitHub-style heading or explicit HTML id/name", + )) + return findings + + +def configured_pair_map(config: dict) -> dict[str, tuple[str, str]]: + result: dict[str, tuple[str, str]] = {} + for pair in [*config.get("bilingual_pairs", []), *config.get("homepage_pairs", [])]: + english, chinese = pair["english"], pair["chinese"] + result[english] = (chinese, "english") + result[chinese] = (english, "chinese") + return result + + +def relative_under(path: str, root: str) -> str | None: + normalized = posix_path(path).strip("/") + normalized_root = posix_path(root).strip("/") + if not normalized_root: + return normalized + if normalized == normalized_root: + return "" + prefix = normalized_root + "/" + if normalized.startswith(prefix): + return normalized[len(prefix):] + return None + + +def join_mapping_root(root: str, relative: str) -> str: + return "/".join(item for item in (root.strip("/"), relative.strip("/")) if item) + + +def add_chinese_filename_suffix(relative: str, suffix: str) -> str | None: + if not suffix: + return relative + if not relative.lower().endswith(".md"): + return None + return relative[:-3] + suffix + ".md" + + +def remove_chinese_filename_suffix(relative: str, suffix: str) -> str | None: + if not suffix: + return relative + marker = suffix + ".md" + if not relative.endswith(marker): + return None + return relative[:-len(marker)] + ".md" + + +def pair_for(path: str, config: dict | None = None) -> tuple[str, str]: + normalized = posix_path(path) + policy = config or DEFAULT_CONFIG + explicit = configured_pair_map(policy).get(normalized) + if explicit is not None: + return explicit + + candidates: list[tuple[int, str, str]] = [] + for mapping in policy.get("bilingual_directory_mappings", []): + english_root, chinese_root = mapping["english"], mapping["chinese"] + chinese_suffix = mapping.get("chinese_suffix", "") + chinese_relative = relative_under(normalized, chinese_root) + if chinese_relative is not None: + english_relative = remove_chinese_filename_suffix( + chinese_relative, chinese_suffix + ) + if english_relative is not None: + candidates.append(( + len(chinese_root), + join_mapping_root(english_root, english_relative), + "chinese", + )) + # A nested Chinese root must not also be interpreted through an + # overlapping English root from the same mapping. + continue + english_relative = relative_under(normalized, english_root) + if english_relative is not None: + suffixed = add_chinese_filename_suffix(english_relative, chinese_suffix) + if suffixed is not None: + candidates.append(( + len(english_root), + join_mapping_root(chinese_root, suffixed), + "english", + )) + if candidates: + _, counterpart, role = max(candidates, key=lambda item: item[0]) + return counterpart, role + if normalized.endswith("_ZH.md"): + return normalized[:-6] + ".md", "chinese" + if normalized.endswith(".md"): + return normalized[:-3] + "_ZH.md", "english" + raise ValueError(path) + + +def resolved_link_paths(root: Path, document: str, text: str) -> list[tuple[str, int]]: + result: list[tuple[str, int]] = [] + for raw, line in iter_links(text): + target = local_target(raw) + if target is None: + continue + _, relative = resolve_repo_link(root, document, target) + if relative is not None: + result.append((relative, line)) + return result + + +def markdown_block_ids(text: str) -> dict[int, int]: + """Map source lines to blank-line-delimited Markdown blocks.""" + result: dict[int, int] = {} + block = 0 + for number, line in enumerate(text.splitlines(), 1): + result[number] = block + if not line.strip(): + block += 1 + return result + + +def bilingual_findings( + root: Path, selected: list[Change], classifications: dict[str, str], config: dict, all_mode: bool +) -> list[Finding]: + findings: list[Finding] = [] + pairs: set[tuple[str, str]] = set() + for change in selected: + path = change.path + if classifications.get(path) not in AUDITED_OWNERSHIP or matches(path, config["pair_exempt_patterns"]): + continue + if change.status == "D": + counterpart, _ = pair_for(path, config) + if (root / counterpart).is_file(): + findings.append(Finding( + "error", "BILINGUAL_PAIR_REMOVED", counterpart, None, + f"deleting {path} leaves its first-party language companion orphaned", + "restore the deleted companion, remove both pages deliberately, or add a narrow documented exception", + )) + continue + if re.search(r"_(?:CN|zh|cn)\.md$", path): + findings.append(Finding( + "warning", "NONSTANDARD_FIRST_PARTY_LANGUAGE_SUFFIX", path, None, + "first-party Simplified Chinese documentation does not use the _ZH.md convention", + "prefer an English main file with a _ZH.md companion; preserve upstream names in upstream-owned trees", + )) + counterpart, role = pair_for(path, config) + counterpart_path = root / counterpart + if not counterpart_path.is_file(): + severity = "warning" if all_mode else "error" + findings.append(Finding( + severity, "BILINGUAL_PAIR_MISSING", path, None, + f"first-party Markdown has no expected companion: {counterpart}", + "split the page into an English main file and _ZH.md companion; exempt only an explicit user requirement or machine consumer", + )) + continue + english, chinese = (path, counterpart) if role == "english" else (counterpart, path) + pairs.add((english, chinese)) + + for english, chinese in sorted(pairs): + for source, target in ((english, chinese), (chinese, english)): + text = read_text(root / source) + links = resolved_link_paths(root, source, text) + target_lines = [line for linked, line in links if linked == target] + if not target_lines: + findings.append(Finding( + "error", "BILINGUAL_LINK_MISSING", source, None, + f"language companion is not linked: {target}", + "add a reciprocal language entry near the top of both files", + )) + elif min(target_lines) > 40: + findings.append(Finding( + "warning", "BILINGUAL_LINK_NOT_NEAR_TOP", source, min(target_lines), + f"language companion link appears late in the document: {target}", + "place the language switch near the top so readers can find it immediately", + )) + + checked_paths = {change.path for change in selected} + for path in sorted(checked_paths): + if classifications.get(path) not in AUDITED_OWNERSHIP: + continue + if matches(path, config["language_link_exempt_patterns"]): + continue + full_path = root / path + if not full_path.is_file(): + continue + counterpart, source_role = pair_for(path, config) + own_pair = counterpart + document_text = read_text(full_path) + resolved_links = resolved_link_paths(root, path, document_text) + block_ids = markdown_block_ids(document_text) + links_by_block: dict[int, set[str]] = {} + for linked, line in resolved_links: + links_by_block.setdefault(block_ids.get(line, line), set()).add(linked) + for linked, line in resolved_links: + if linked == own_pair or not linked.lower().endswith(".md"): + continue + sibling, linked_role = pair_for(linked, config) + same_block = links_by_block.get(block_ids.get(line, line), set()) + if source_role == "chinese": + if linked_role == "chinese": + continue + if sibling in same_block: + continue + if (root / sibling).is_file(): + findings.append(Finding( + "error", "WRONG_LANGUAGE_INTERNAL_LINK", path, line, + f"Chinese page links to English target although a Chinese companion exists: {linked}", + f"link to {sibling} instead", + )) + elif linked_role == "chinese": + if sibling in same_block: + continue + if (root / sibling).is_file(): + findings.append(Finding( + "error", "WRONG_LANGUAGE_INTERNAL_LINK", path, line, + f"English page links to Chinese target although an English companion exists: {linked}", + f"link to {sibling} instead", + )) + return findings + + +def line_for_offset(text: str, offset: int) -> int: + return text.count("\n", 0, offset) + 1 + + +def sensitive_findings(path: str, text: str, config: dict) -> list[Finding]: + findings: list[Finding] = [] + allow = [re.compile(pattern) for pattern in config["sensitive_allow_regexes"]] + for code, pattern, hint in SENSITIVE_RULES: + for match in pattern.finditer(text): + # Allow only this exact detected value. A safe placeholder elsewhere + # on the line must never suppress another credential or identifier. + if any(item.fullmatch(match.group(0)) for item in allow): + continue + findings.append(Finding( + "error", code, path, line_for_offset(text, match.start()), + "public Markdown contains a disallowed environment or private-data shape (value redacted)", + hint, + )) + local_user = os.environ.get("USERNAME") or os.environ.get("USER") or Path.home().name + if local_user and len(local_user) >= 3: + pattern = re.compile(rf"(?i)(? str: + value = html.unescape(TAG_RE.sub("", title)).strip() + if value and EMOJI_RE.match(value[0]): + return value.split(maxsplit=1)[0] + return "" + + +def header_quick_links(text: str) -> list[tuple[str, str, str]]: + header_end = text.lower().find("") + header = text if header_end < 0 else text[:header_end] + result: list[tuple[str, str, str]] = [] + for href, body in HTML_ANCHOR_RE.findall(header): + label = html.unescape(TAG_RE.sub("", body)).strip() + if not label: + continue + for key, expected, pattern in QUICK_LINK_RULES: + if pattern.search(label): + result.append((key, heading_token(label), expected)) + break + return result + + +def homepage_header(text: str) -> tuple[str, int, bool]: + centered = CENTERED_DIV_RE.search(text) + start = centered.start() if centered else 0 + end_start = text.lower().find("", start) + if end_start < 0: + return (text[start:] if centered else "", len(text), bool(centered)) + end = end_start + len("") + return text[start:end], end, bool(centered) + + +def homepage_badges(header: str) -> list[str]: + result: list[str] = [] + for href, body in HTML_ANCHOR_RE.findall(header): + image = HTML_IMG_RE.search(body) + if image is None: + continue + alt_match = HTML_ALT_RE.search(image.group(0)) + alt = alt_match.group(1) if alt_match else "" + searchable = html.unescape(alt + " " + href) + role = "other" + for key, pattern in BADGE_RULES: + if pattern.search(searchable): + role = key + break + result.append(role) + return result + + +def ordered_subsequence(required: Sequence[str], actual: Sequence[str]) -> bool: + if not required: + return True + index = 0 + for item in actual: + if item == required[index]: + index += 1 + if index == len(required): + return True + return False + + +def homepage_findings( + root: Path, + english: str, + chinese: str, + settings: dict | None = None, + global_h3_allow_patterns: Sequence[str] = (), +) -> list[Finding]: + findings: list[Finding] = [] + settings = settings or {"profile": "auto"} + profile = settings.get("profile", "auto") + required_components = HOMEPAGE_PROFILES[profile] | set(settings.get("required_components", [])) + required_quick = settings.get("required_quick_links", []) + required_badges = settings.get("required_badges", []) + required_h2 = settings.get("required_h2_icons", []) + h3_allow = [ + re.compile(pattern) + for pattern in [ + *global_h3_allow_patterns, + *settings.get("h3_emoji_allow_patterns", []), + ] + ] + texts = {english: read_text(root / english), chinese: read_text(root / chinese)} + h2_tokens: dict[str, list[str]] = {} + quick: dict[str, list[tuple[str, str, str]]] = {} + badges: dict[str, list[str]] = {} + components: dict[str, dict[str, bool]] = {} + allowed_h3: dict[str, list[tuple[int, str]]] = {} + for path, text in texts.items(): + counterpart = chinese if path == english else english + header, header_end, centered = homepage_header(text) + h1 = HTML_H1_RE.search(header) + if not h1: + findings.append(Finding( + "warning", "HOMEPAGE_HTML_H1_MISSING", path, None, + "homepage does not contain the mature centered HTML product-title pattern", + "preserve or add a plain product-name

inside the centered visual header", + )) + elif EMOJI_RE.search(html.unescape(TAG_RE.sub("", h1.group(1)))): + findings.append(Finding( + "error", "HOMEPAGE_H1_EMOJI", path, line_for_offset(text, h1.start(1)), + "HTML product title contains an emoji", + "keep the level-one product title as the plain product name", + )) + tokens: list[str] = [] + for match in H2_RE.finditer(text): + token = heading_token(match.group(1)) + tokens.append(token) + if not token: + findings.append(Finding( + "error", "HOMEPAGE_H2_EMOJI_MISSING", path, line_for_offset(text, match.start(1)), + f"primary section lacks a leading semantic emoji: {match.group(1).strip()}", + "restore a stable semantic emoji on each primary ## section", + )) + else: + title = EMOJI_RE.sub("", html.unescape(TAG_RE.sub("", match.group(1))), count=1).lstrip("️ ") + for expected, pattern in H2_ICON_RULES: + if pattern.search(title): + if token != expected: + findings.append(Finding( + "error", "HOMEPAGE_H2_ICON", path, line_for_offset(text, match.start(1)), + f"primary section semantic icon is {token}, expected {expected} for {title!r}", + "restore the stable semantic icon while keeping the localized section text", + )) + break + h2_tokens[path] = tokens + allowed_h3[path] = [] + for index, match in enumerate(H3_RE.finditer(text)): + raw_title = html.unescape(TAG_RE.sub("", match.group(1))).strip() + token = heading_token(raw_title) + if token and any(pattern.fullmatch(raw_title) for pattern in h3_allow): + allowed_h3[path].append((index, token)) + elif token: + findings.append(Finding( + "error", "HOMEPAGE_H3_EMOJI", path, line_for_offset(text, match.start(1)), + f"tertiary section unexpectedly starts with an emoji: {match.group(1).strip()}", + "keep ### headings plain or add a narrow anchored allow pattern for an intentional bilingual exception", + )) + quick[path] = header_quick_links(header) + for key, actual, expected in quick[path]: + if actual != expected: + findings.append(Finding( + "error", "HOMEPAGE_QUICK_LINK_ICON", path, None, + f"quick-link semantic {key!r} uses {actual or 'no icon'} instead of {expected}", + "restore the standard semantic icon without copying product-specific URLs or text", + )) + badges[path] = homepage_badges(header) + header_images = len(HTML_IMG_RE.findall(header)) + language_targets = { + linked for linked, _ in resolved_link_paths(root, path, header) + } + tail = text[header_end:] + has_separator = bool( + re.match(r"(?:[ \t]*\r?\n)*[ \t]*(?:---|\*\*\*|___)[ \t]*(?:\r?\n|$)", tail) + ) + components[path] = { + "centered_header": centered, + "html_h1": h1 is not None, + "subtitle": bool(STRONG_RE.search(header)), + "badges": bool(badges[path]), + "language_switch": counterpart in language_targets, + "quick_links": bool(quick[path]), + "hero_image": header_images > len(badges[path]), + "separator": has_separator, + "h2": bool(tokens), + } + for component in sorted(required_components): + if not components[path][component]: + findings.append(Finding( + "error", "HOMEPAGE_REQUIRED_COMPONENT_MISSING", path, None, + f"homepage profile {profile!r} requires component {component!r}", + "restore the mature baseline component or select/configure the correct generic homepage profile", + )) + actual_quick = [key for key, _, _ in quick[path]] + if not ordered_subsequence(required_quick, actual_quick): + findings.append(Finding( + "error", "HOMEPAGE_REQUIRED_QUICK_LINK_MISSING", path, None, + f"required quick-link sequence is absent: {required_quick}", + "restore the configured semantic quick-link roles in order; localized URLs may differ", + )) + if not ordered_subsequence(required_badges, badges[path]): + findings.append(Finding( + "error", "HOMEPAGE_REQUIRED_BADGE_MISSING", path, None, + f"required badge-role sequence is absent: {required_badges}", + "restore the configured generic badge roles without hardcoding a product URL", + )) + if not ordered_subsequence(required_h2, tokens): + findings.append(Finding( + "error", "HOMEPAGE_REQUIRED_H2_MISSING", path, None, + f"required primary-section emoji sequence is absent: {required_h2}", + "restore the configured baseline sections and their semantic emoji in order", + )) + for component in sorted(HOMEPAGE_COMPONENTS): + if components[english][component] != components[chinese][component]: + findings.append(Finding( + "error", "HOMEPAGE_COMPONENT_ASYMMETRY", english, None, + f"English and Chinese homepage component {component!r} differs", + "keep the generic visual/header contract symmetric across languages", + )) + if h2_tokens[english] != h2_tokens[chinese]: + findings.append(Finding( + "error", "HOMEPAGE_H2_ASYMMETRY", english, None, + f"English and Chinese primary-section emoji sequences differ: {h2_tokens[english]} != {h2_tokens[chinese]}", + "keep primary section presence, order, and semantic emoji symmetric across languages", + )) + english_quick = [(key, icon) for key, icon, _ in quick[english]] + chinese_quick = [(key, icon) for key, icon, _ in quick[chinese]] + if english_quick != chinese_quick: + findings.append(Finding( + "error", "HOMEPAGE_QUICK_LINK_ASYMMETRY", english, None, + f"English and Chinese quick-link semantics differ: {english_quick} != {chinese_quick}", + "keep semantic slots, icons, order, and presence symmetric; localized external URLs may differ", + )) + if badges[english] != badges[chinese]: + findings.append(Finding( + "error", "HOMEPAGE_BADGE_ASYMMETRY", english, None, + f"English and Chinese badge roles differ: {badges[english]} != {badges[chinese]}", + "keep badge roles and order symmetric while localizing accessible labels", + )) + if allowed_h3[english] != allowed_h3[chinese]: + findings.append(Finding( + "error", "HOMEPAGE_H3_EMOJI_ASYMMETRY", english, None, + f"allowed tertiary emoji positions differ: {allowed_h3[english]} != {allowed_h3[chinese]}", + "use the same allowed emoji at the corresponding ### position in both languages", + )) + return findings + + +def docs_only_findings(changes: list[Change], config: dict) -> list[Finding]: + findings: list[Finding] = [] + for change in changes: + for path in (change.path, change.old_path): + if path is None or path.lower().endswith(".md") or path in config["docs_only_allowed_patterns"]: + continue + name = PurePosixPath(path).name + suffix = PurePosixPath(path).suffix.lower() + if suffix in FIRMWARE_SUFFIXES: + code = "DOCS_ONLY_FIRMWARE_BINARY" + detail = "firmware binary" + hint = "restore the immutable artifact; documentation work must not rebuild or replace it" + elif suffix in PACKAGE_SUFFIXES: + code = "DOCS_ONLY_RELEASE_PACKAGE" + detail = "release or delivery package" + hint = "restore the immutable package; documentation splitting must not repackage or change its hash" + elif suffix in SOURCE_SUFFIXES or name in SOURCE_NAMES: + code = "DOCS_ONLY_SOURCE_CHANGE" + detail = "source or executable script" + hint = "separate implementation changes from the documentation-only diff" + elif suffix in CONFIG_SUFFIXES or name in CONFIG_NAMES: + code = "DOCS_ONLY_CONFIG_CHANGE" + detail = "configuration or workflow input" + hint = "separate configuration changes from the documentation-only diff" + else: + code = "DOCS_ONLY_NON_MARKDOWN_CHANGE" + detail = "non-Markdown file" + hint = "allow a narrow documentation asset pattern in config or remove it from the docs-only diff" + findings.append(Finding( + "error", code, path, None, + f"documentation-only scope includes a {detail}", hint, + )) + return findings + + +def audit( + root: Path, + changes: list[Change], + config: dict, + *, + all_mode: bool, + expect_docs_only: bool, +) -> dict: + root = root.resolve() + validate_change_paths(changes) + inventory = tracked_markdown_files(root, config) if all_mode else iter_markdown_files(root, config) + gitlink_roots = discover_gitlink_roots(root) + dynamic_upstream_roots = discover_dynamic_upstream_roots(root) + classifications = { + path: classify_at_root(path, config, gitlink_roots, dynamic_upstream_roots) + for path in inventory + } + for change in changes: + if change.path.lower().endswith(".md"): + classifications.setdefault( + change.path, + classify_at_root(change.path, config, gitlink_roots, dynamic_upstream_roots), + ) + candidates = [ + change for change in changes + if change.status != "D" and change.path.lower().endswith(".md") and (root / change.path).is_file() + and not is_excluded(change.path, config) + ] + selected = [ + change for change in candidates + if not (all_mode and classifications[change.path] in UPSTREAM_OWNERSHIP) + ] + findings: list[Finding] = [] + if expect_docs_only: + findings.extend(docs_only_findings(changes, config)) + for change in selected: + category = classifications[change.path] + text = read_text(root / change.path) + findings.extend(link_findings(root, change.path, text, config)) + if category in AUDITED_OWNERSHIP or category == "unknown": + findings.extend(sensitive_findings(change.path, text, config)) + elif category in UPSTREAM_OWNERSHIP: + if not all_mode: + findings.append(Finding( + "warning", "UPSTREAM_MARKDOWN_CHANGED", change.path, None, + f"changed Markdown is classified as {category}", + "avoid product-local translation or rewriting; update from upstream or override classification narrowly", + )) + if category == "unknown": + findings.append(Finding( + "warning" if all_mode else "error", "MARKDOWN_OWNERSHIP_UNKNOWN", change.path, None, + "Markdown ownership is unknown", + "classify the path narrowly as first-party, managed, third-party, or embedded upstream before editing", + )) + deleted_markdown = [ + change for change in changes + if change.status == "D" and change.path.lower().endswith(".md") and not is_excluded(change.path, config) + ] + findings.extend(bilingual_findings(root, selected + deleted_markdown, classifications, config, all_mode)) + selected_paths = {change.path for change in selected} + for pair in config["homepage_pairs"]: + english, chinese = pair["english"], pair["chinese"] + if not (root / english).is_file() or not (root / chinese).is_file(): + continue + if all_mode or english in selected_paths or chinese in selected_paths: + findings.extend(homepage_findings( + root, + english, + chinese, + pair, + config["homepage_h3_emoji_allow_patterns"], + )) + findings.sort(key=lambda item: (0 if item.severity == "error" else 1, item.path.lower(), item.line or 0, item.code)) + return { + "schema_version": 1, + "repository": root.name, + "scope": { + "all_markdown": all_mode, + "expect_docs_only": expect_docs_only, + "changed_files": 0 if all_mode else len(changes), + "inventory_markdown": len(inventory), + "selected_markdown": len(selected), + }, + "classification_inventory": dict(sorted(Counter(classifications.values()).items())), + "selected_files": [ + { + "status": "I" if all_mode else change.status, + "path": change.path, + "category": classifications[change.path], + } + for change in selected + ], + "findings": [asdict(item) for item in findings], + "summary": { + "errors": sum(item.severity == "error" for item in findings), + "warnings": sum(item.severity == "warning" for item in findings), + }, + } + + +def print_text(report: dict) -> None: + scope = report["scope"] + counts = ", ".join(f"{key}={value}" for key, value in report["classification_inventory"].items()) or "none" + print(f"Markdown audit: {report['repository']}") + print(f"Scope: changed_files={scope['changed_files']} selected_markdown={scope['selected_markdown']} all={scope['all_markdown']} docs_only={scope['expect_docs_only']}") + print(f"Classification inventory: {counts}") + for finding in report["findings"]: + location = finding["path"] + (f":{finding['line']}" if finding["line"] else "") + print(f"[{finding['severity'].upper()}] {finding['code']} {location}: {finding['message']}") + print(f" hint: {finding['hint']}") + summary = report["summary"] + print(f"Summary: errors={summary['errors']} warnings={summary['warnings']}") + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Audit Markdown ownership, bilingual links, homepage symmetry, privacy, and docs-only scope." + ) + parser.add_argument("repo", type=Path, help="repository checkout to audit") + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--base", help="audit files changed from merge-base(BASE, HEAD)") + source.add_argument("--working-tree", action="store_true", help="audit staged, unstaged, and untracked files") + source.add_argument("--changed-files-from", type=Path, help="read paths or git name-status lines from a UTF-8 file") + source.add_argument("--all", action="store_true", help="audit all Markdown files; existing unpaired pages are warnings") + parser.add_argument("--config", type=Path, help="optional JSON policy extension") + parser.add_argument("--expect-docs-only", action="store_true", help="fail if the selected diff contains non-doc scope") + parser.add_argument("--strict", action="store_true", help="make warnings fail the command") + parser.add_argument("--format", choices=("text", "json"), default="text") + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") + args = parse_args(argv) + if not args.repo.is_dir(): + print(f"error: repository path does not exist or is not a directory: {args.repo}", file=sys.stderr) + return 2 + if args.all and args.expect_docs_only: + print("error: --expect-docs-only requires a changed-file scope, not --all", file=sys.stderr) + return 2 + try: + config = load_config(args.config) + root = args.repo.resolve() + if args.base: + require_clean_base_checkout(root) + changes = changes_from_base(root, args.base) + elif args.working_tree: + changes = changes_from_worktree(root) + elif args.changed_files_from: + changes = changes_from_file(args.changed_files_from) + else: + changes = [Change("I", path) for path in tracked_markdown_files(root, config)] + report = audit( + root, changes, config, all_mode=args.all, expect_docs_only=args.expect_docs_only + ) + except AuditError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + except Exception as exc: # pragma: no cover - defensive CLI boundary + print(f"internal error: {type(exc).__name__}: {exc}", file=sys.stderr) + return 3 + if args.format == "json": + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + print_text(report) + if report["summary"]["errors"] or (args.strict and report["summary"]["warnings"]): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci_scope.py b/scripts/ci_scope.py index cc94e631..3ba991a5 100644 --- a/scripts/ci_scope.py +++ b/scripts/ci_scope.py @@ -11,7 +11,23 @@ import json import sys from dataclasses import dataclass, field -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath + + +AUDIT_CONFIG_KEYS = { + "classification_rules", + "exclude_patterns", + "pair_exempt_patterns", + "language_link_exempt_patterns", + "relative_link_ignore_patterns", + "docs_only_allowed_patterns", + "sensitive_allow_regexes", + "bilingual_pairs", + "bilingual_directory_mappings", + "homepage_h3_emoji_allow_patterns", + "homepage_pairs", +} +DOCUMENTATION_ASSET_SUFFIXES = {".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"} @dataclass @@ -22,6 +38,7 @@ class Scope: arduino_all: bool = False firmware: list[str] = field(default_factory=list) unknown: list[str] = field(default_factory=list) + docs_only: bool = True def mark_all(self) -> None: self.esp_all = True @@ -32,6 +49,44 @@ def normalize(path: str) -> str: return path.replace("\\", "/").strip("/") +def load_docs_only_asset_paths(path: Path | None) -> tuple[str, ...]: + """Load explicit documentation assets allowed beside Markdown files.""" + if path is None: + return () + try: + config = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"cannot read JSON config {path}: {exc}") from exc + if not isinstance(config, dict): + raise ValueError("config root must be a JSON object") + unknown = sorted(set(config) - AUDIT_CONFIG_KEYS) + if unknown: + raise ValueError("unknown config keys: " + ", ".join(unknown)) + paths = config.get("docs_only_allowed_patterns", []) + if not isinstance(paths, list) or not all(isinstance(item, str) for item in paths): + raise ValueError("config docs_only_allowed_patterns must be a list of strings") + safe_paths: list[str] = [] + for item in paths: + normalized = item.replace("\\", "/") + parts = PurePosixPath(normalized).parts + if ( + not normalized + or item != normalized + or normalized.startswith("/") + or ".." in parts + or normalized.endswith("/") + or "//" in normalized + or (len(normalized) >= 2 and normalized[1] == ":") + or any(character in normalized for character in "*?[]") + or PurePosixPath(normalized).suffix.lower() not in DOCUMENTATION_ASSET_SUFFIXES + ): + raise ValueError( + "docs_only_allowed_patterns entries must be explicit repository-relative documentation asset files" + ) + safe_paths.append(normalized) + return tuple(safe_paths) + + def parse_name_status(data: bytes) -> list[str]: """Return every affected path, retaining both sides of renames/deletions.""" if not data: @@ -50,6 +105,8 @@ def parse_name_status(data: bytes) -> list[str]: if not paths: raise ValueError("no changed paths") return paths + if not data.endswith(b"\0"): + raise ValueError("truncated NUL-delimited changed-file data") fields = [item.decode("utf-8", "surrogateescape") for item in data.split(b"\0") if item] paths: list[str] = [] index = 0 @@ -89,10 +146,12 @@ def project_for(path: str, surface: str) -> str | None: return "/".join(parts[:3]) -def classify(paths: list[str]) -> Scope: +def classify(paths: list[str], docs_only_asset_paths: tuple[str, ...] = ()) -> Scope: scope = Scope() for path in paths: lower = path.lower() + if not (lower.endswith(".md") or path in docs_only_asset_paths): + scope.docs_only = False if path.startswith("FirmWare/"): scope.firmware.append(path) continue @@ -137,16 +196,20 @@ def mode(all_selected: bool, paths: set[str]) -> str: "arduino_paths": json.dumps(sorted(scope.arduino), separators=(",", ":")), "firmware_paths": json.dumps(sorted(scope.firmware), separators=(",", ":")), "unknown_paths": json.dumps(sorted(scope.unknown), separators=(",", ":")), + "docs_only": str(scope.docs_only).lower(), } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, help="Markdown audit config with docs-only asset allowlist") parser.add_argument("--name-status-file", type=argparse.FileType("rb"), default=sys.stdin.buffer) parser.add_argument("--github-output", type=argparse.FileType("a", encoding="utf-8")) args = parser.parse_args() try: - result = payload(classify(parse_name_status(args.name_status_file.read()))) + result = payload(classify( + parse_name_status(args.name_status_file.read()), load_docs_only_asset_paths(args.config) + )) except ValueError as exc: print(f"ci_scope: {exc}", file=sys.stderr) return 2 diff --git a/tests/test_audit_markdown.py b/tests/test_audit_markdown.py new file mode 100644 index 00000000..57777642 --- /dev/null +++ b/tests/test_audit_markdown.py @@ -0,0 +1,1004 @@ +from __future__ import annotations + +import importlib.util +import json +import re +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = REPOSITORY_ROOT / "scripts" / "audit_markdown.py" +SPEC = importlib.util.spec_from_file_location("repository_audit_markdown", SCRIPT_PATH) +assert SPEC and SPEC.loader +audit_markdown = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = audit_markdown +SPEC.loader.exec_module(audit_markdown) + + +EN_HOME = """
+

Demo Board

+

Compact synthetic development board

+

+ Build Examples + Latest Release + License +

+

简体中文 · 🌐 Product Page · 📚 Documentation · 📦 Firmware · 🚀 Quick Start · 🧩 ESP-IDF · 🔧 Arduino

+ Demo Board +
+ +--- + +## ✨ Overview + +[Guide](docs/guide.md) + +### ESP-IDF +""" + +ZH_HOME = """
+

Demo Board

+

紧凑型合成测试开发板

+

+ 构建示例 + 最新版本 + 许可证 +

+

English · 🌐 产品页面 · 📚 产品文档 · 📦 固件 · 🚀 快速开始 · 🧩 ESP-IDF · 🔧 Arduino

+ Demo Board +
+ +--- + +## ✨ 概述 + +[指南](docs/guide_ZH.md) + +### ESP-IDF +""" + + +class MarkdownAuditTests(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory() + self.root = Path(self.tempdir.name) + self.write("README.md", EN_HOME) + self.write("README_ZH.md", ZH_HOME) + self.write( + "docs/guide.md", + "[简体中文](guide_ZH.md)\n\n[Home](../README.md)\n\nUse `COMx` or `/dev/ttyACM0`.\n", + ) + self.write( + "docs/guide_ZH.md", + "[English](guide.md)\n\n[首页](../README_ZH.md)\n\n使用 ``。\n", + ) + self.write("examples/esp-idf/.keep", "") + self.write("examples/arduino/.keep", "") + self.write("assets/hero.png", b"synthetic-hero") + self.write("LICENSE", "synthetic license\n") + + def tearDown(self) -> None: + self.tempdir.cleanup() + + def write(self, relative: str, text: str | bytes) -> None: + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(text, bytes): + path.write_bytes(text) + else: + path.write_text(text, encoding="utf-8") + + def init_git(self, origin: str = "https://github.com/example-org/product-repo.git") -> None: + commands = ( + ["git", "init"], + ["git", "config", "user.name", "Synthetic Test"], + ["git", "config", "user.email", "synthetic@example.invalid"], + ["git", "remote", "add", "origin", origin], + ) + for command in commands: + subprocess.run( + command, + cwd=self.root, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + def write_component_source_root(self, relative: str, manifest: str, readme: str) -> None: + self.write(f"{relative}/idf_component.yml", manifest) + self.write(f"{relative}/CMakeLists.txt", "idf_component_register(SRCS component.c)\n") + self.write(f"{relative}/component.c", "void synthetic_component(void) {}\n") + self.write(f"{relative}/LICENSE", "synthetic permissive license\n") + self.write(f"{relative}/README.md", readme) + + def changes(self, *paths: str, status: str = "A") -> list: + return [audit_markdown.Change(status, path) for path in paths] + + def report( + self, + changes: list, + *, + config: dict | None = None, + docs_only: bool = False, + all_mode: bool = False, + ) -> dict: + return audit_markdown.audit( + self.root, + changes, + config or audit_markdown.load_config(None), + all_mode=all_mode, + expect_docs_only=docs_only, + ) + + @staticmethod + def codes(report: dict) -> set[str]: + return {item["code"] for item in report["findings"]} + + def test_clean_bilingual_repository_passes(self) -> None: + report = self.report( + self.changes("README.md", "README_ZH.md", "docs/guide.md", "docs/guide_ZH.md"), + docs_only=True, + ) + self.assertEqual({"errors": 0, "warnings": 0}, report["summary"]) + self.assertEqual("first_party_customer", report["selected_files"][0]["category"]) + + def test_new_first_party_page_requires_pair(self) -> None: + self.write("docs/new-feature.md", "[Home](../README.md)\n") + report = self.report(self.changes("docs/new-feature.md")) + self.assertIn("BILINGUAL_PAIR_MISSING", self.codes(report)) + self.assertEqual(1, report["summary"]["errors"]) + + def test_modified_mixed_language_page_requires_split(self) -> None: + self.write("docs/mixed.md", "# Guide / 指南\n\nEnglish.\n\n中文。\n") + changed_report = self.report(self.changes("docs/mixed.md", status="M")) + self.assertIn("BILINGUAL_PAIR_MISSING", self.codes(changed_report)) + self.assertEqual(1, changed_report["summary"]["errors"]) + + historical_report = self.report( + self.changes("docs/mixed.md", status="S"), + all_mode=True, + ) + self.assertEqual({"errors": 0, "warnings": 1}, historical_report["summary"]) + + def test_deleting_one_language_companion_fails(self) -> None: + (self.root / "docs" / "guide_ZH.md").unlink() + report = self.report(self.changes("docs/guide_ZH.md", status="D")) + self.assertIn("BILINGUAL_PAIR_REMOVED", self.codes(report)) + + def test_reciprocal_and_same_language_links_are_enforced(self) -> None: + self.write("docs/guide_ZH.md", "[首页](../README.md)\n") + report = self.report(self.changes("docs/guide.md", "docs/guide_ZH.md", status="M")) + self.assertIn("BILINGUAL_LINK_MISSING", self.codes(report)) + self.assertIn("WRONG_LANGUAGE_INTERNAL_LINK", self.codes(report)) + + def test_side_by_side_language_index_is_not_wrong_language(self) -> None: + self.write( + "docs/index.md", + "[简体中文](index_ZH.md)\n\n[Guide](guide.md)\n([指南](guide_ZH.md))\n", + ) + self.write( + "docs/index_ZH.md", + "[English](index.md)\n\n[指南](guide_ZH.md)\n([Guide](guide.md))\n", + ) + report = self.report(self.changes("docs/index.md", "docs/index_ZH.md")) + self.assertNotIn("WRONG_LANGUAGE_INTERNAL_LINK", self.codes(report)) + + def test_relative_links_cover_markdown_reference_html_and_code_fences(self) -> None: + self.write("docs/My Guide.md", "target\n") + self.write( + "docs/links.md", + "[inline](My%20Guide.md)\n[ref][guide]\n\n" + "[guide]: \n```md\n[ignored](missing.md)\n```\n", + ) + self.write("docs/links_ZH.md", "[English](links.md)\n") + self.write("assets/pixel.png", b"synthetic") + report = self.report(self.changes("docs/links.md", "docs/links_ZH.md")) + self.assertNotIn("RELATIVE_LINK_MISSING", self.codes(report)) + self.write("docs/links.md", "[简体中文](links_ZH.md)\n[broken](absent.md)\n") + report = self.report(self.changes("docs/links.md", status="M")) + self.assertIn("RELATIVE_LINK_MISSING", self.codes(report)) + + def test_relative_link_fragments_use_github_slugs_duplicates_unicode_and_html_ids(self) -> None: + self.write( + "docs/target.md", + "# Hello, World!\n\n" + "## Repeat\n\n" + "## Repeat\n\n" + "## 中文 标题\n\n" + '\n' + '\n', + ) + self.write( + "docs/anchors.md", + "[简体中文](anchors_ZH.md)\n\n" + "## Local Heading\n\n" + "[same page](#local-heading)\n" + "[punctuation](target.md#hello-world)\n" + "[first duplicate](target.md#repeat)\n" + "[second duplicate](target.md#repeat-1)\n" + "[Unicode](target.md#%E4%B8%AD%E6%96%87-%E6%A0%87%E9%A2%98)\n" + "[HTML id](target.md#manual-anchor)\n" + "[HTML name](target.md#legacy-anchor)\n", + ) + self.write("docs/anchors_ZH.md", "[English](anchors.md)\n") + report = self.report(self.changes("docs/anchors.md", "docs/anchors_ZH.md")) + self.assertNotIn("RELATIVE_LINK_FRAGMENT_MISSING", self.codes(report)) + + self.write( + "docs/anchors.md", + (self.root / "docs" / "anchors.md").read_text(encoding="utf-8") + + "[broken fragment](target.md#repeat-2)\n", + ) + report = self.report(self.changes("docs/anchors.md", status="M")) + self.assertIn("RELATIVE_LINK_FRAGMENT_MISSING", self.codes(report)) + + def test_sensitive_allowlist_is_scoped_to_the_matched_value(self) -> None: + token = "ghp_" + "B" * 36 + self.write( + "docs/scoped.md", + f"[简体中文](scoped_ZH.md)\nAllowed COM42 and forbidden {token} share a line.\n", + ) + self.write("docs/scoped_ZH.md", "[English](scoped.md)\n") + config = audit_markdown.load_config(None) + config["sensitive_allow_regexes"] = [r"^COM42$"] + report = self.report( + self.changes("docs/scoped.md", "docs/scoped_ZH.md"), config=config + ) + self.assertNotIn("ACTUAL_SERIAL_PORT", self.codes(report)) + self.assertIn("CREDENTIAL_OR_TOKEN", self.codes(report)) + self.assertNotIn(token, json.dumps(report)) + + def test_homepage_visual_contract(self) -> None: + self.write( + "README.md", + EN_HOME.replace("

Demo Board

", "

🚀 Demo Board

") + .replace("🌐 Product Page", "Product Page") + .replace("### ESP-IDF", "### 🧩 ESP-IDF"), + ) + self.write("README_ZH.md", ZH_HOME.replace("## ✨ 概述", "## 📚 概述")) + report = self.report(self.changes("README.md", "README_ZH.md", status="M")) + codes = self.codes(report) + self.assertIn("HOMEPAGE_H1_EMOJI", codes) + self.assertIn("HOMEPAGE_QUICK_LINK_ICON", codes) + self.assertIn("HOMEPAGE_H2_ICON", codes) + self.assertIn("HOMEPAGE_H2_ASYMMETRY", codes) + self.assertIn("HOMEPAGE_H3_EMOJI", codes) + + def test_single_product_profile_detects_symmetric_homepage_deletion(self) -> None: + config = audit_markdown.load_config(None) + config["homepage_pairs"] = [ + { + "english": "README.md", + "chinese": "README_ZH.md", + "profile": "single-product", + "required_components": [], + "required_quick_links": [ + "product", + "documentation", + "firmware", + "quick_start", + "esp_idf", + "arduino", + ], + "required_badges": ["build", "release", "license"], + "required_h2_icons": ["✨"], + "h3_emoji_allow_patterns": [], + } + ] + baseline = self.report( + self.changes("README.md", "README_ZH.md", status="M"), config=config + ) + self.assertEqual({"errors": 0, "warnings": 0}, baseline["summary"]) + + for path in ("README.md", "README_ZH.md"): + text = (self.root / path).read_text(encoding="utf-8") + text = text.replace('
\n', "") + text = text.replace("
\n\n---\n", "") + text = re.sub(r"(?m)^## ✨ .*\n", "", text) + self.write(path, text) + report = self.report( + self.changes("README.md", "README_ZH.md", status="M"), config=config + ) + codes = self.codes(report) + self.assertIn("HOMEPAGE_REQUIRED_COMPONENT_MISSING", codes) + self.assertIn("HOMEPAGE_REQUIRED_QUICK_LINK_MISSING", codes) + self.assertIn("HOMEPAGE_REQUIRED_BADGE_MISSING", codes) + self.assertIn("HOMEPAGE_REQUIRED_H2_MISSING", codes) + self.assertNotIn("HOMEPAGE_COMPONENT_ASYMMETRY", codes) + + def test_h3_emoji_requires_narrow_allow_pattern_and_bilingual_symmetry(self) -> None: + self.write("README.md", EN_HOME.replace("### ESP-IDF", "### ⚠️ Caution")) + self.write("README_ZH.md", ZH_HOME.replace("### ESP-IDF", "### ⚠️ 注意")) + config = audit_markdown.load_config(None) + config["homepage_h3_emoji_allow_patterns"] = [r"^⚠️ (?:Caution|注意)$"] + report = self.report( + self.changes("README.md", "README_ZH.md", status="M"), config=config + ) + self.assertNotIn("HOMEPAGE_H3_EMOJI", self.codes(report)) + self.assertNotIn("HOMEPAGE_H3_EMOJI_ASYMMETRY", self.codes(report)) + + self.write("README_ZH.md", ZH_HOME.replace("### ESP-IDF", "### ℹ️ 注意")) + report = self.report( + self.changes("README.md", "README_ZH.md", status="M"), config=config + ) + self.assertIn("HOMEPAGE_H3_EMOJI", self.codes(report)) + self.assertIn("HOMEPAGE_H3_EMOJI_ASYMMETRY", self.codes(report)) + + config_path = self.root / "broad-h3.json" + config_path.write_text( + json.dumps({"homepage_h3_emoji_allow_patterns": [".*"]}), + encoding="utf-8", + ) + with self.assertRaises(audit_markdown.AuditError): + audit_markdown.load_config(config_path) + + def test_centralized_chinese_tree_pairs_links_and_homepage_profile(self) -> None: + en_home = """
+

Product Family

+

Multi-product documentation hub

+

简体中文

+
+ +--- + +## ✨ Overview + +[Guide](docs/guide.md) +""" + zh_home = """
+

Product Family

+

多产品文档中心

+

English

+
+ +--- + +## ✨ 概述 + +[指南](guide.md) +""" + self.write("README.md", en_home) + self.write("docs/zh-CN/README.md", zh_home) + self.write( + "docs/guide.md", + "[简体中文](zh-CN/guide.md)\n\n[Home](../README.md)\n", + ) + self.write( + "docs/zh-CN/guide.md", + "[English](../guide.md)\n\n[首页](README.md)\n", + ) + config_path = self.root / "central-policy.json" + config_path.write_text( + json.dumps( + { + "bilingual_pairs": [ + {"english": "README.md", "chinese": "docs/zh-CN/README.md"} + ], + "bilingual_directory_mappings": [ + {"english": "docs", "chinese": "docs/zh-CN"} + ], + "homepage_pairs": [ + { + "english": "README.md", + "chinese": "docs/zh-CN/README.md", + "profile": "multi-product-hub", + "required_h2_icons": ["✨"], + } + ], + } + ), + encoding="utf-8", + ) + config = audit_markdown.load_config(config_path) + report = self.report( + self.changes( + "README.md", + "docs/zh-CN/README.md", + "docs/guide.md", + "docs/zh-CN/guide.md", + ), + config=config, + ) + relevant = { + code for code in self.codes(report) + if code.startswith("BILINGUAL_") + or code.startswith("HOMEPAGE_") + or code == "WRONG_LANGUAGE_INTERNAL_LINK" + } + self.assertEqual(set(), relevant, report["findings"]) + self.assertEqual( + ("docs/zh-CN/guide.md", "english"), + audit_markdown.pair_for("docs/guide.md", config), + ) + self.assertEqual( + ("docs/guide.md", "chinese"), + audit_markdown.pair_for("docs/zh-CN/guide.md", config), + ) + self.write( + "docs/zh-CN/README.md", + zh_home.replace("[指南](guide.md)", "[指南](../guide.md)"), + ) + wrong = self.report( + self.changes("docs/zh-CN/README.md", status="M"), config=config + ) + self.assertIn("WRONG_LANGUAGE_INTERNAL_LINK", self.codes(wrong)) + + def test_centralized_chinese_tree_supports_zh_filename_suffix(self) -> None: + self.write( + "docs/en/GUIDE.md", + "[简体中文](../zh-CN/GUIDE_ZH.md)\n\n[Other](OTHER.md)\n", + ) + self.write( + "docs/zh-CN/GUIDE_ZH.md", + "[English](../en/GUIDE.md)\n\n[其他](OTHER_ZH.md)\n", + ) + self.write("docs/en/OTHER.md", "English target\n") + self.write("docs/zh-CN/OTHER_ZH.md", "中文目标\n") + config_path = self.root / "central-suffix-policy.json" + config_path.write_text( + json.dumps( + { + "bilingual_directory_mappings": [ + { + "english": "docs/en", + "chinese": "docs/zh-CN", + "chinese_suffix": "_ZH", + } + ] + } + ), + encoding="utf-8", + ) + config = audit_markdown.load_config(config_path) + self.assertEqual( + ("docs/zh-CN/GUIDE_ZH.md", "english"), + audit_markdown.pair_for("docs/en/GUIDE.md", config), + ) + self.assertEqual( + ("docs/en/GUIDE.md", "chinese"), + audit_markdown.pair_for("docs/zh-CN/GUIDE_ZH.md", config), + ) + report = self.report( + self.changes("docs/en/GUIDE.md", "docs/zh-CN/GUIDE_ZH.md"), + config=config, + ) + self.assertFalse( + { + "BILINGUAL_PAIR_MISSING", + "BILINGUAL_LINK_MISSING", + "WRONG_LANGUAGE_INTERNAL_LINK", + } + & self.codes(report), + report["findings"], + ) + + self.write( + "docs/zh-CN/GUIDE_ZH.md", + "[English](../en/GUIDE.md)\n\n[其他](../en/OTHER.md)\n", + ) + wrong = self.report( + self.changes("docs/zh-CN/GUIDE_ZH.md", status="M"), config=config + ) + self.assertIn("WRONG_LANGUAGE_INTERNAL_LINK", self.codes(wrong)) + + def test_public_text_rules_redact_values(self) -> None: + drive_path = "Q:" + "\\Profiles\\sample-user\\build" + port = "COM" + "42" + mac = ":".join(["02", "00", "00", "00", "00", "01"]) + token = "ghp_" + "A" * 36 + provenance = "Generated by " + "ChatGPT" + self.write("docs/private.md", "[简体中文](private_ZH.md)\n" + "\n".join([drive_path, port, mac, token, provenance])) + self.write("docs/private_ZH.md", "[English](private.md)\n") + report = self.report(self.changes("docs/private.md", "docs/private_ZH.md")) + codes = self.codes(report) + self.assertTrue( + {"LOCAL_ABSOLUTE_PATH", "ACTUAL_SERIAL_PORT", "MAC_ADDRESS", "CREDENTIAL_OR_TOKEN", "TOOL_OR_MODEL_PROVENANCE"}.issubset(codes) + ) + rendered = json.dumps(report) + self.assertNotIn(token, rendered) + self.assertNotIn(drive_path, rendered) + + def test_docs_only_scope_rejects_source_config_binary_and_package(self) -> None: + self.write("firmware/app/main.c", "int main(void) { return 0; }\n") + self.write("firmware/app/idf_component.yml", "dependencies: {}\n") + self.write("firmware/factory.bin", b"synthetic-firmware") + self.write("firmware/media.zip", b"synthetic-package") + self.write("firmware/other.uf2", b"synthetic-other-binary") + self.write("firmware/other.elf", b"synthetic-other-binary") + self.write("firmware/other.hex", b"synthetic-other-binary") + report = self.report( + self.changes( + "docs/guide.md", + "firmware/app/main.c", + "firmware/app/idf_component.yml", + "firmware/factory.bin", + "firmware/media.zip", + "firmware/other.uf2", + "firmware/other.elf", + "firmware/other.hex", + ), + docs_only=True, + ) + codes = self.codes(report) + self.assertTrue( + { + "DOCS_ONLY_SOURCE_CHANGE", + "DOCS_ONLY_CONFIG_CHANGE", + "DOCS_ONLY_FIRMWARE_BINARY", + "DOCS_ONLY_RELEASE_PACKAGE", + }.issubset(codes) + ) + by_path = {item["path"]: item["code"] for item in report["findings"]} + self.assertEqual("DOCS_ONLY_FIRMWARE_BINARY", by_path["firmware/factory.bin"]) + self.assertEqual("DOCS_ONLY_RELEASE_PACKAGE", by_path["firmware/media.zip"]) + for path in ("firmware/other.uf2", "firmware/other.elf", "firmware/other.hex"): + self.assertEqual("DOCS_ONLY_NON_MARKDOWN_CHANGE", by_path[path]) + + def test_docs_only_assets_are_explicit_image_files(self) -> None: + valid = self.root / "valid-doc-assets.json" + valid.write_text( + json.dumps({"docs_only_allowed_patterns": ["assets/hero.jpg"]}), encoding="utf-8" + ) + self.assertEqual(["assets/hero.jpg"], audit_markdown.load_config(valid)["docs_only_allowed_patterns"]) + for index, value in enumerate(( + "**", "Material/**", "/tmp/image.jpg", "../outside.jpg", "assets/", + "firmware/main.c", "config/a.yml", "FirmWare/recovery.bin", "FirmWare/package.zip", "", + )): + path = self.root / f"invalid-doc-assets-{index}.json" + path.write_text(json.dumps({"docs_only_allowed_patterns": [value]}), encoding="utf-8") + with self.assertRaises(audit_markdown.AuditError): + audit_markdown.load_config(path) + + def test_docs_only_rename_keeps_the_old_path_in_scope(self) -> None: + report = self.report( + [audit_markdown.Change("R", "docs/renamed.md", "firmware/main.c")], + docs_only=True, + ) + self.assertIn("DOCS_ONLY_SOURCE_CHANGE", self.codes(report)) + + def test_ownership_defaults_and_config_override(self) -> None: + self.write("managed_components/pkg/README.md", "upstream\n") + self.write("third_party/pkg/README.md", "upstream\n") + self.write("libraries/pkg/README_CN.md", "upstream\n") + self.write("components/bsp_extra/README.md", "local wrapper\n") + self.write("components/player_safe/player/README.md", "local safety wrapper\n") + self.write("components/vendor_core/README.md", "vendored\n") + config_path = self.root / "policy.json" + config_path.write_text( + json.dumps( + { + "classification_rules": [ + {"category": "embedded_upstream", "patterns": ["components/vendor_core/**"]} + ] + } + ), + encoding="utf-8", + ) + config = audit_markdown.load_config(config_path) + self.assertEqual("managed_component", audit_markdown.classify("managed_components/pkg/README.md", config)) + self.assertEqual("third_party", audit_markdown.classify("third_party/pkg/README.md", config)) + self.assertEqual("embedded_upstream", audit_markdown.classify("libraries/pkg/README_CN.md", config)) + self.assertEqual("first_party_wrapper", audit_markdown.classify("components/bsp_extra/README.md", config)) + self.assertEqual("first_party_wrapper", audit_markdown.classify("components/player_safe/player/README.md", config)) + self.assertEqual("embedded_upstream", audit_markdown.classify("components/vendor_core/README.md", config)) + report = self.report( + self.changes( + "managed_components/pkg/README.md", + "third_party/pkg/README.md", + "libraries/pkg/README_CN.md", + "components/vendor_core/README.md", + ), + config=config, + ) + self.assertNotIn("BILINGUAL_PAIR_MISSING", self.codes(report)) + self.assertEqual(4, sum(item["code"] == "UPSTREAM_MARKDOWN_CHANGED" for item in report["findings"])) + + def test_dependency_roots_are_conservative_and_wrapper_boundaries_are_nested(self) -> None: + config = audit_markdown.load_config(None) + for path in ( + "lib/pkg/README.md", + "libs/pkg/README.md", + "deps/pkg/README.md", + "dependencies/pkg/README.md", + "middleware/pkg/README.md", + "sdk/pkg/README.md", + "components/pkg/README.md", + ): + self.assertEqual("unknown", audit_markdown.classify(path, config), path) + self.assertEqual( + "first_party_wrapper", + audit_markdown.classify("wrappers/board/README.md", config), + ) + self.assertEqual( + "unknown", + audit_markdown.classify("wrappers/board/deps/core/README.md", config), + ) + self.assertEqual( + "embedded_upstream", + audit_markdown.classify("wrappers/board/upstream/core/README.md", config), + ) + + self.write( + ".gitmodules", + '[submodule "custom-core"]\n\tpath = custom/core\n\turl = https://example.invalid/core.git\n', + ) + self.write("custom/core/README.md", "upstream-owned\n") + report = self.report(self.changes("custom/core/README.md"), config=config) + self.assertEqual("embedded_upstream", report["selected_files"][0]["category"]) + self.assertIn("UPSTREAM_MARKDOWN_CHANGED", self.codes(report)) + + def test_external_manifest_source_root_inside_wrapper_is_dynamic_upstream(self) -> None: + self.init_git() + upstream_root = "firmware/app/components/player_safe/vendor-player" + upstream_readme = f"{upstream_root}/README.md" + self.write_component_source_root( + upstream_root, + "description: Synthetic vendored component\n" + "url: https://github.com/upstream-org/vendor-player\n" + "version: 1.0.0\n", + "# Vendored player\n\n[Upstream-only test](../test)\n", + ) + subprocess.run( + ["git", "add", "."], cwd=self.root, check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + + roots = audit_markdown.discover_dynamic_upstream_roots(self.root) + self.assertEqual({upstream_root}, roots) + self.assertEqual( + "embedded_upstream", + audit_markdown.classify_at_root( + upstream_readme, audit_markdown.load_config(None), set(), roots + ), + ) + + process = subprocess.run( + [sys.executable, str(SCRIPT_PATH), str(self.root), "--all", "--format", "json"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual(0, process.returncode, process.stderr or process.stdout) + report = json.loads(process.stdout) + self.assertNotIn( + upstream_readme, + {item["path"] for item in report["selected_files"]}, + ) + self.assertFalse( + any(item["path"] == upstream_readme for item in report["findings"]), + report["findings"], + ) + + def test_self_manifest_and_incomplete_external_metadata_remain_audited(self) -> None: + self.init_git() + wrapper_root = "wrappers/audio_safe/local-component" + wrapper_readme = f"{wrapper_root}/README.md" + self.write_component_source_root( + wrapper_root, + "repository: https://github.com/example-org/product-repo/tree/main/wrappers/audio_safe\n" + "url: https://example.invalid/product-page\n", + "# Product-local wrapper\n\n[Missing local page](missing.md)\n", + ) + unknown_root = "components/local-component" + self.write_component_source_root( + unknown_root, + "url: git@github.com:example-org/product-repo.git\n", + "# Ambiguous local component\n", + ) + # A lone external manifest URL is not enough without a component-source + # root shape; this must remain visible to first-party review. + self.write( + "wrappers/metadata_safe/metadata-only/idf_component.yml", + "url: https://github.com/upstream-org/metadata-only\n", + ) + self.write( + "wrappers/metadata_safe/metadata-only/README.md", + "# Metadata only\n\n[Missing](missing.md)\n", + ) + + roots = audit_markdown.discover_dynamic_upstream_roots(self.root) + self.assertEqual(set(), roots) + config = audit_markdown.load_config(None) + self.assertEqual( + "first_party_wrapper", + audit_markdown.classify_at_root(wrapper_readme, config, set(), roots), + ) + self.assertEqual( + "unknown", + audit_markdown.classify_at_root( + f"{unknown_root}/README.md", config, set(), roots + ), + ) + report = self.report(self.changes(wrapper_readme), config=config, all_mode=True) + self.assertIn("RELATIVE_LINK_MISSING", self.codes(report)) + + def test_repository_identity_drops_credentials_and_non_source_urls(self) -> None: + raw = ( + "https://synthetic-user:synthetic-password@github.com/" + "example-org/product-repo.git?auth=synthetic-value#fragment" + ) + identity = audit_markdown.repository_identity(raw) + self.assertEqual("github.com/example-org/product-repo", identity) + for private_part in ( + "synthetic-user", "synthetic-password", "synthetic-value", "fragment" + ): + self.assertNotIn(private_part, identity or "") + self.assertIsNone( + audit_markdown.repository_identity( + "https://www.example.invalid/products/board", field="url" + ) + ) + + def test_gitlink_index_is_recognized_without_path_name_hints(self) -> None: + isolated = tempfile.TemporaryDirectory() + self.addCleanup(isolated.cleanup) + root = Path(isolated.name) + subprocess.run(["git", "init"], cwd=root, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + subprocess.run(["git", "config", "user.name", "Synthetic Test"], cwd=root, check=True) + subprocess.run(["git", "config", "user.email", "synthetic@example.invalid"], cwd=root, check=True) + (root / "seed.txt").write_text("seed\n", encoding="utf-8") + subprocess.run(["git", "add", "seed.txt"], cwd=root, check=True) + subprocess.run(["git", "commit", "-m", "seed"], cwd=root, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + commit = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=root, check=True, + stdout=subprocess.PIPE, text=True, + ).stdout.strip() + subprocess.run( + ["git", "update-index", "--add", "--cacheinfo", f"160000,{commit},custom/module"], + cwd=root, + check=True, + ) + self.assertIn("custom/module", audit_markdown.discover_gitlink_roots(root)) + + def test_all_mode_inventories_upstream_without_treating_it_as_changed(self) -> None: + self.write("managed_components/pkg/README.md", "[missing](absent.md)\n") + self.write("third_party/pkg/README.md", "[missing](absent.md)\n") + self.write("libraries/pkg/README.md", "[missing](absent.md)\n") + self.write("lib/ambiguous/README.md", "[missing](absent.md)\n") + report = self.report( + self.changes( + "managed_components/pkg/README.md", + "third_party/pkg/README.md", + "libraries/pkg/README.md", + "lib/ambiguous/README.md", + status="I", + ), + all_mode=True, + ) + selected = {item["path"] for item in report["selected_files"]} + self.assertEqual({"lib/ambiguous/README.md"}, selected) + self.assertNotIn("UPSTREAM_MARKDOWN_CHANGED", self.codes(report)) + self.assertEqual(0, report["scope"]["changed_files"]) + self.assertGreaterEqual(report["classification_inventory"].get("managed_component", 0), 1) + self.assertGreaterEqual(report["classification_inventory"].get("third_party", 0), 1) + self.assertGreaterEqual(report["classification_inventory"].get("embedded_upstream", 0), 1) + + def test_all_mode_uses_tracked_files_and_skips_ignored_generated_components(self) -> None: + commands = ( + ["git", "init"], + ["git", "config", "user.name", "Synthetic Test"], + ["git", "config", "user.email", "synthetic@example.invalid"], + ) + for command in commands: + subprocess.run(command, cwd=self.root, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.write(".gitignore", "managed_components/\n") + subprocess.run(["git", "add", "."], cwd=self.root, check=True) + subprocess.run( + ["git", "commit", "-m", "tracked synthetic fixture"], + cwd=self.root, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.write("managed_components/generated/README.md", "ignored generated content\n") + tracked = audit_markdown.tracked_markdown_files( + self.root, audit_markdown.load_config(None) + ) + self.assertNotIn("managed_components/generated/README.md", tracked) + + def test_ambiguous_example_component_requires_ownership_classification(self) -> None: + path = "examples/esp-idf/demo/components/ProductFeature/README.md" + self.write(path, "# Product-local or upstream?\n") + config = audit_markdown.load_config(None) + + self.assertEqual("unknown", audit_markdown.classify(path, config)) + report = self.report(self.changes(path), config=config) + self.assertIn("MARKDOWN_OWNERSHIP_UNKNOWN", self.codes(report)) + self.assertNotIn("UPSTREAM_MARKDOWN_CHANGED", self.codes(report)) + + def test_cli_exit_codes_and_json_contract(self) -> None: + changed = self.root / "changed.txt" + changed.write_text("A\tREADME.md\nA\tREADME_ZH.md\nA\tdocs/guide.md\nA\tdocs/guide_ZH.md\n", encoding="utf-8") + process = subprocess.run( + [sys.executable, str(SCRIPT_PATH), str(self.root), "--changed-files-from", str(changed), "--format", "json"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual(0, process.returncode, process.stderr) + parsed = json.loads(process.stdout) + self.assertEqual(1, parsed["schema_version"]) + self.assertTrue(all(not Path(item["path"]).is_absolute() for item in parsed["selected_files"])) + + self.write("docs/guide.md", "[简体中文](guide_ZH.md)\n[missing](absent.md)\n") + process = subprocess.run( + [sys.executable, str(SCRIPT_PATH), str(self.root), "--changed-files-from", str(changed), "--format", "json"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual(1, process.returncode) + + empty = self.root / "empty-changed.txt" + empty.write_text("# no changed paths\n\n", encoding="utf-8") + process = subprocess.run( + [sys.executable, str(SCRIPT_PATH), str(self.root), "--changed-files-from", str(empty)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual(2, process.returncode) + self.assertIn("changed-files list is empty", process.stderr) + + bad_config = self.root / "bad.json" + bad_config.write_text('{"misspelled": []}', encoding="utf-8") + process = subprocess.run( + [sys.executable, str(SCRIPT_PATH), str(self.root), "--all", "--config", str(bad_config)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual(2, process.returncode) + self.assertIn("unknown config keys", process.stderr) + + def test_base_scope_rejects_dirty_checkout(self) -> None: + commands = ( + ["git", "init"], + ["git", "config", "user.name", "Synthetic Test"], + ["git", "config", "user.email", "synthetic@example.invalid"], + ["git", "add", "."], + ["git", "commit", "-m", "initial synthetic fixture"], + ) + for command in commands: + subprocess.run( + command, + cwd=self.root, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + self.write("docs/guide.md", "[简体中文](guide_ZH.md)\n\nlocal edit\n") + + process = subprocess.run( + [sys.executable, str(SCRIPT_PATH), str(self.root), "--base", "HEAD"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual(2, process.returncode) + self.assertIn("--base requires a clean checkout", process.stderr) + + def test_working_tree_scope_supports_unborn_staged_unstaged_and_untracked(self) -> None: + self.init_git() + self.write("staged-then-deleted.txt", "synthetic staged content\n") + subprocess.run( + ["git", "add", "."], + cwd=self.root, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + (self.root / "staged-then-deleted.txt").unlink() + self.write( + "docs/guide.md", + "[简体中文](guide_ZH.md)\n\n[Home](../README.md)\n\nUse ``.\n\nUnstaged edit.\n", + ) + self.write("docs/extra.md", "[简体中文](extra_ZH.md)\n") + self.write("docs/extra_ZH.md", "[English](extra.md)\n") + + changes = audit_markdown.changes_from_worktree(self.root) + statuses = {change.path: change.status for change in changes} + self.assertEqual("D", statuses["staged-then-deleted.txt"]) + + process = subprocess.run( + [ + sys.executable, + str(SCRIPT_PATH), + str(self.root), + "--working-tree", + "--format", + "json", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual(0, process.returncode, process.stderr or process.stdout) + report = json.loads(process.stdout) + selected = {item["path"]: item["status"] for item in report["selected_files"]} + self.assertEqual("A", selected["README.md"]) + # The index has an addition and the worktree has a later modification; + # relative to an unborn branch the combined status remains an addition. + self.assertEqual("A", selected["docs/guide.md"]) + self.assertEqual("A", selected["docs/extra.md"]) + self.assertEqual("A", selected["docs/extra_ZH.md"]) + + def test_working_tree_scope_preserves_deletion_and_rename_with_head(self) -> None: + self.init_git() + subprocess.run( + ["git", "add", "."], + cwd=self.root, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + subprocess.run( + ["git", "commit", "-m", "initial synthetic fixture"], + cwd=self.root, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + subprocess.run( + ["git", "mv", "docs/guide.md", "docs/renamed.md"], + cwd=self.root, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + (self.root / "docs" / "guide_ZH.md").unlink() + + changes = audit_markdown.changes_from_worktree(self.root) + by_path = {change.path: change for change in changes} + self.assertEqual("R", by_path["docs/renamed.md"].status) + self.assertEqual("docs/guide.md", by_path["docs/renamed.md"].old_path) + self.assertEqual("D", by_path["docs/guide_ZH.md"].status) + + def test_working_tree_scope_does_not_hide_non_git_errors(self) -> None: + isolated = tempfile.TemporaryDirectory() + self.addCleanup(isolated.cleanup) + with self.assertRaises(audit_markdown.AuditError) as context: + audit_markdown.changes_from_worktree(Path(isolated.name)) + self.assertIn("git rev-parse", str(context.exception)) + + def test_repository_config_is_narrow_and_consumable(self) -> None: + path = REPOSITORY_ROOT / "config" / "markdown-audit.json" + raw = json.loads(path.read_text(encoding="utf-8")) + for key in ( + "classification_rules", + "exclude_patterns", + "pair_exempt_patterns", + "language_link_exempt_patterns", + "relative_link_ignore_patterns", + "sensitive_allow_regexes", + "bilingual_pairs", + "bilingual_directory_mappings", + "homepage_h3_emoji_allow_patterns", + ): + self.assertEqual([], raw[key], key) + self.assertEqual( + ["Material/images/ESP32-S3-Touch-AMOLED-2.06.jpg"], + raw["docs_only_allowed_patterns"], + ) + self.assertEqual("single-product", raw["homepage_pairs"][0]["profile"]) + config = audit_markdown.load_config(path) + self.assertEqual("unknown", audit_markdown.classify("deps/pkg/README.md", config)) + self.assertEqual( + "first_party_wrapper", + audit_markdown.classify("wrappers/board/README.md", config), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ci_scope.py b/tests/test_ci_scope.py index 793f8028..2c6dd58b 100644 --- a/tests/test_ci_scope.py +++ b/tests/test_ci_scope.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +import json import subprocess import tempfile import unittest @@ -15,8 +16,8 @@ def changed(*fields: str) -> list[str]: class ScopeTests(unittest.TestCase): - def route(self, *fields: str) -> ci_scope.Scope: - return ci_scope.classify(changed(*fields)) + def route(self, *fields: str, docs_only_allowed_patterns: tuple[str, ...] = ()) -> ci_scope.Scope: + return ci_scope.classify(changed(*fields), docs_only_allowed_patterns) def test_markdown_never_builds_examples(self) -> None: for path in ( @@ -70,6 +71,28 @@ def test_rename_deletion_and_unknown_paths_are_conservative(self) -> None: self.assertTrue(unknown.esp_all and unknown.arduino_all) self.assertEqual(unknown.unknown, ["unclassified/build.input"]) + def test_nul_renames_and_deletions_retain_every_source_impact(self) -> None: + renamed = self.route( + "R100", "examples/esp-idf/01_AXP2101/main/main.c", "docs/renamed.md" + ) + self.assertEqual(renamed.esp_idf, {"examples/esp-idf/01_AXP2101"}) + self.assertFalse(renamed.docs_only) + moved_between_surfaces = self.route( + "R100", "examples/esp-idf/01_AXP2101/main/main.c", + "examples/arduino/01_HelloWorld/01_HelloWorld.ino", + ) + self.assertEqual(moved_between_surfaces.esp_idf, {"examples/esp-idf/01_AXP2101"}) + self.assertEqual(moved_between_surfaces.arduino, {"examples/arduino/01_HelloWorld"}) + arduino_deleted = self.route("D", "examples/arduino/01_HelloWorld/01_HelloWorld.ino") + self.assertEqual(arduino_deleted.arduino, {"examples/arduino/01_HelloWorld"}) + idf_deleted = self.route("D", "examples/esp-idf/01_AXP2101/main/main.c") + self.assertEqual(idf_deleted.esp_idf, {"examples/esp-idf/01_AXP2101"}) + + def test_nul_input_rejects_truncated_or_malformed_records(self) -> None: + for payload in (b"M\0", b"R100\0old\0", b"M\0README.md"): + with self.assertRaises(ValueError): + ci_scope.parse_name_status(payload) + def test_empty_or_unavailable_data_fails_closed(self) -> None: with self.assertRaises(ValueError): ci_scope.parse_name_status(b"") @@ -81,6 +104,63 @@ def test_empty_or_unavailable_data_fails_closed(self) -> None: ) self.assertEqual(result.returncode, 2) + def test_docs_only_requires_only_documentation_paths(self) -> None: + docs = self.route("M", "README.md", "M", "examples/esp-idf/01_AXP2101/README.md") + self.assertTrue(docs.docs_only) + mixed = self.route( + "M", "FirmWare/README.md", "M", "FirmWare/recovery.bin", "M", "FirmWare/package.zip", + "M", "FirmWare/tool.c", "M", "examples/arduino/01_HelloWorld/01_HelloWorld.ino", + ) + self.assertFalse(mixed.docs_only) + self.assertEqual( + mixed.firmware, + ["FirmWare/README.md", "FirmWare/recovery.bin", "FirmWare/package.zip", "FirmWare/tool.c"], + ) + self.assertEqual(mixed.arduino, {"examples/arduino/01_HelloWorld"}) + + def test_docs_only_uses_the_audit_allowlist_for_every_rename_path(self) -> None: + allowed = ("Material/images/ESP32-S3-Touch-AMOLED-2.06.jpg",) + self.assertTrue(self.route( + "M", "Material/images/ESP32-S3-Touch-AMOLED-2.06.jpg", + docs_only_allowed_patterns=allowed, + ).docs_only) + self.assertTrue(self.route( + "R100", "README.md", "Material/images/ESP32-S3-Touch-AMOLED-2.06.jpg", + docs_only_allowed_patterns=allowed, + ).docs_only) + for path in ("Material/photo.png", "Schematic/board.pdf", "docs/note.txt", "docs/note.markdown"): + self.assertFalse(self.route("M", path, docs_only_allowed_patterns=allowed).docs_only, path) + + def test_docs_only_config_rejects_malformed_unknown_and_unsafe_patterns(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + cases = ( + ("not-json.json", "{", "cannot read JSON config"), + ("unknown.json", json.dumps({"unexpected": []}), "unknown config keys"), + ("not-list.json", json.dumps({"docs_only_allowed_patterns": "Material/**"}), "list of strings"), + ("globstar.json", json.dumps({"docs_only_allowed_patterns": ["**"]}), "explicit repository-relative documentation asset files"), + ("glob.json", json.dumps({"docs_only_allowed_patterns": ["Material/**"]}), "explicit repository-relative documentation asset files"), + ("absolute.json", json.dumps({"docs_only_allowed_patterns": ["/tmp/image.jpg"]}), "explicit repository-relative documentation asset files"), + ("parent.json", json.dumps({"docs_only_allowed_patterns": ["../outside.jpg"]}), "explicit repository-relative documentation asset files"), + ("directory.json", json.dumps({"docs_only_allowed_patterns": ["Material/"]}), "explicit repository-relative documentation asset files"), + ("source.json", json.dumps({"docs_only_allowed_patterns": ["docs/check.py"]}), "explicit repository-relative documentation asset files"), + ("config.json", json.dumps({"docs_only_allowed_patterns": ["docs/check.yml"]}), "explicit repository-relative documentation asset files"), + ("binary.json", json.dumps({"docs_only_allowed_patterns": ["FirmWare/recovery.bin"]}), "explicit repository-relative documentation asset files"), + ("archive.json", json.dumps({"docs_only_allowed_patterns": ["FirmWare/package.zip"]}), "explicit repository-relative documentation asset files"), + ("empty.json", json.dumps({"docs_only_allowed_patterns": [""]}), "explicit repository-relative documentation asset files"), + ) + for name, content, message in cases: + path = root / name + path.write_text(content, encoding="utf-8") + with self.assertRaisesRegex(ValueError, message): + ci_scope.load_docs_only_asset_paths(path) + accepted = root / "accepted.json" + accepted.write_text(json.dumps({"docs_only_allowed_patterns": ["Material/images/ESP32-S3-Touch-AMOLED-2.06.jpg"]}), encoding="utf-8") + self.assertEqual( + ("Material/images/ESP32-S3-Touch-AMOLED-2.06.jpg",), + ci_scope.load_docs_only_asset_paths(accepted), + ) + def test_workflow_cli_shape_writes_outputs(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -91,6 +171,8 @@ def test_workflow_cli_shape_writes_outputs(self) -> None: [ sys.executable, str(Path(ci_scope.__file__)), + "--config", + str(Path(__file__).resolve().parents[1] / "config" / "markdown-audit.json"), "--name-status-file", str(changed_file), "--github-output", @@ -103,4 +185,40 @@ def test_workflow_cli_shape_writes_outputs(self) -> None: outputs = output_file.read_text(encoding="utf-8") self.assertIn("esp_idf_mode=none", outputs) self.assertIn("arduino_mode=selected", outputs) + self.assertIn("docs_only=false", outputs) self.assertIn('arduino_paths=["examples/arduino/01_HelloWorld"]', outputs) + + def test_cli_exposes_rename_scope_and_rejects_malformed_nul_data(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + changed_file = root / "changed-files.z" + changed_file.write_bytes( + b"R100\0examples/esp-idf/01_AXP2101/main/main.c\0docs/renamed.md\0" + ) + result = subprocess.run( + [sys.executable, str(Path(ci_scope.__file__)), "--name-status-file", str(changed_file)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('esp_idf_paths=["examples/esp-idf/01_AXP2101"]', result.stdout) + self.assertIn("docs_only=false", result.stdout) + changed_file.write_bytes(b"R100\0old\0") + malformed = subprocess.run( + [sys.executable, str(Path(ci_scope.__file__)), "--name-status-file", str(changed_file)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + self.assertEqual(malformed.returncode, 2) + self.assertIn("rename/copy status missing destination path", malformed.stderr) + + def test_workflow_uses_complete_changed_scope_and_conditional_docs_gate(self) -> None: + workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/examples.yml").read_text(encoding="utf-8") + self.assertIn("fetch-depth: 0", workflow) + self.assertIn('git diff --name-status -z --find-renames "$base" "${{ github.sha }}"', workflow) + self.assertIn('python3 scripts/ci_scope.py --config config/markdown-audit.json --name-status-file changed-files.z --github-output "$GITHUB_OUTPUT"', workflow) + self.assertIn("--changed-files-from changed-files.txt --config config/markdown-audit.json", workflow) + self.assertIn('audit_args+=(--expect-docs-only)', workflow) + self.assertEqual(2, workflow.count("SELECTED_PATHS: ${{ needs.scope.outputs.")) + self.assertEqual(2, workflow.count('args+=(--selected-paths "$SELECTED_PATHS")')) + self.assertNotIn("args+=(--selected-paths '${{ needs.scope.outputs.", workflow) + self.assertIn('base="${{ github.event.pull_request.base.sha }}"', workflow) + self.assertIn('base="${{ github.event.before }}"', workflow) diff --git a/tests/test_discover_examples.py b/tests/test_discover_examples.py index 13233b76..af25b3c1 100644 --- a/tests/test_discover_examples.py +++ b/tests/test_discover_examples.py @@ -1,6 +1,8 @@ from __future__ import annotations import argparse +import json +import subprocess import sys import tempfile import unittest @@ -35,3 +37,16 @@ def test_default_versions_are_current(self) -> None: self.assertIn("v5.5.5", Path(discover_examples.__file__).read_text(encoding="utf-8")) self.assertIn('default="3.3.11"', Path(discover_examples.__file__).read_text(encoding="utf-8")) + def test_cli_accepts_selected_json_paths_with_single_quotes(self) -> None: + project = "examples/esp-idf/idf'quoted" + (self.repo / project / "main").mkdir(parents=True) + (self.repo / project / "CMakeLists.txt").write_text("project(test)") + result = subprocess.run( + [ + sys.executable, str(Path(discover_examples.__file__)), "--repo", str(self.repo), + "--surface", "esp-idf", "--idf-versions", "v5.5.5", "--selected-paths", json.dumps([project]), + ], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual([project], [item["path"] for item in json.loads(result.stdout)["include"]])