|
| 1 | +#!/bin/ksh |
| 2 | +set -eu |
| 3 | +( set -o pipefail ) 2>/dev/null && set -o pipefail |
| 4 | + |
| 5 | +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)" |
| 6 | +PROJECT_ROOT="$(CDPATH= cd -- "${SCRIPT_DIR}/../.." && pwd -P)" |
| 7 | +COMMON_LIB="${PROJECT_ROOT}/scripts/lib/common.ksh" |
| 8 | + |
| 9 | +[ -f "${COMMON_LIB}" ] || { |
| 10 | + print -- "ERROR missing shared library: ${COMMON_LIB}" >&2 |
| 11 | + exit 1 |
| 12 | +} |
| 13 | + |
| 14 | +. "${COMMON_LIB}" |
| 15 | + |
| 16 | +FAIL=0 |
| 17 | +WARN=0 |
| 18 | +PASS=0 |
| 19 | + |
| 20 | +pass() { print -- "[$(timestamp)] PASS $*"; PASS=$((PASS + 1)); } |
| 21 | +warn() { print -- "[$(timestamp)] WARN $*"; WARN=$((WARN + 1)); } |
| 22 | +fail() { print -- "[$(timestamp)] FAIL $*"; FAIL=$((FAIL + 1)); } |
| 23 | + |
| 24 | +if command_exists python3; then |
| 25 | + PYTHON_BIN="$(command -v python3)" |
| 26 | +elif command_exists python; then |
| 27 | + PYTHON_BIN="$(command -v python)" |
| 28 | +else |
| 29 | + die "python interpreter not available for documentation integrity checks" |
| 30 | +fi |
| 31 | + |
| 32 | +TMP_RESULT="$(mktemp "${TMPDIR:-/tmp}/openbsd-mailstack-docs.XXXXXX")" |
| 33 | +cleanup() { |
| 34 | + rm -f "${TMP_RESULT}" |
| 35 | +} |
| 36 | +trap cleanup EXIT HUP INT TERM |
| 37 | + |
| 38 | +"${PYTHON_BIN}" - "${PROJECT_ROOT}" >"${TMP_RESULT}" <<'PYEOF' |
| 39 | +import re |
| 40 | +import sys |
| 41 | +from pathlib import Path |
| 42 | +
|
| 43 | +root = Path(sys.argv[1]).resolve() |
| 44 | +md_files = sorted(root.rglob('*.md')) |
| 45 | +link_re = re.compile(r'\[[^\]]+\]\(([^)]+)\)') |
| 46 | +path_re = re.compile(r'(?<![A-Za-z0-9_./-])(\./[A-Za-z0-9_./<>-]+|README\.md|CONTRIBUTING\.md|SECURITY\.md|CODE_OF_CONDUCT\.md|docs/[A-Za-z0-9_./<>-]+|scripts/[A-Za-z0-9_./<>-]+|maint/[A-Za-z0-9_./<>-]+|services/[A-Za-z0-9_./<>-]+|config/[A-Za-z0-9_./<>-]+)') |
| 47 | +
|
| 48 | +allow_missing_suffixes = ('.local', '.local.env') |
| 49 | +allow_missing_fragments = ( |
| 50 | + 'config/local/', |
| 51 | + '.work/', |
| 52 | + '/build/', |
| 53 | + '<profile-name>', |
| 54 | + 'build/default', |
| 55 | + '<', |
| 56 | +) |
| 57 | +
|
| 58 | +def allowed_missing(target: str) -> bool: |
| 59 | + if any(target.endswith(s) for s in allow_missing_suffixes): |
| 60 | + return True |
| 61 | + if any(fragment in target for fragment in allow_missing_fragments): |
| 62 | + return True |
| 63 | + return False |
| 64 | +
|
| 65 | +missing_links = [] |
| 66 | +missing_paths = [] |
| 67 | +
|
| 68 | +for md in md_files: |
| 69 | + text = md.read_text(encoding='utf-8') |
| 70 | +
|
| 71 | + for match in link_re.finditer(text): |
| 72 | + target = match.group(1).strip() |
| 73 | + if not target or target.startswith(('http://', 'https://', 'mailto:', '#')): |
| 74 | + continue |
| 75 | + target = target.split('#', 1)[0] |
| 76 | + if not target: |
| 77 | + continue |
| 78 | + resolved = (md.parent / target).resolve() |
| 79 | + if not resolved.exists(): |
| 80 | + missing_links.append((md.relative_to(root).as_posix(), target)) |
| 81 | +
|
| 82 | + for match in path_re.finditer(text): |
| 83 | + target = match.group(1).rstrip('.,:)`') |
| 84 | + repo_rel = target[2:] if target.startswith('./') else target |
| 85 | + if repo_rel.startswith('/'): |
| 86 | + continue |
| 87 | + if allowed_missing(repo_rel): |
| 88 | + continue |
| 89 | + resolved = (root / repo_rel).resolve() |
| 90 | + if resolved.exists(): |
| 91 | + continue |
| 92 | + example_resolved = None |
| 93 | + if repo_rel.startswith('config/') and repo_rel.endswith('.conf'): |
| 94 | + example_resolved = (root / f"{repo_rel}.example").resolve() |
| 95 | + if example_resolved is not None and example_resolved.exists(): |
| 96 | + continue |
| 97 | + missing_paths.append((md.relative_to(root).as_posix(), target)) |
| 98 | +
|
| 99 | +for path, target in missing_links: |
| 100 | + print(f'MISSING_LINK\t{path}\t{target}') |
| 101 | +for path, target in missing_paths: |
| 102 | + print(f'MISSING_PATH\t{path}\t{target}') |
| 103 | +PYEOF |
| 104 | + |
| 105 | +if [ ! -s "${TMP_RESULT}" ]; then |
| 106 | + pass "all markdown links and documented repo paths resolve or are intentionally local/generated" |
| 107 | +else |
| 108 | + TAB="$(printf '\t')" |
| 109 | + while IFS="${TAB}" read -r _kind _doc _target; do |
| 110 | + case "${_kind}" in |
| 111 | + MISSING_LINK) |
| 112 | + fail "broken markdown link in ${_doc}: ${_target}" |
| 113 | + ;; |
| 114 | + MISSING_PATH) |
| 115 | + fail "documented repo path in ${_doc} does not exist: ${_target}" |
| 116 | + ;; |
| 117 | + *) |
| 118 | + warn "unexpected documentation checker output: ${_kind} ${_doc} ${_target}" |
| 119 | + ;; |
| 120 | + esac |
| 121 | + done < "${TMP_RESULT}" |
| 122 | +fi |
| 123 | + |
| 124 | +print |
| 125 | +print -- "Documentation integrity summary" |
| 126 | +print -- " PASS count : ${PASS}" |
| 127 | +print -- " WARN count : ${WARN}" |
| 128 | +print -- " FAIL count : ${FAIL}" |
| 129 | +print |
| 130 | + |
| 131 | +[ "${FAIL}" -eq 0 ] |
0 commit comments