#!/usr/bin/env bash set -euo pipefail DIRECTIVE='^[[:space:]]*//(go:|nolint)|^[[:space:]]*///[[:space:]]*] devscripts/comment-scan devscripts/comment-scan --language Reports the comments the ShellHub code style does not allow. Two kinds stay: a doc comment on an exported declaration (what revive's exported rule and jsdoc/require-jsdoc ask for), and a linter suppression or tool directive with its reason. Name the thing in code instead, and put the reason a name cannot carry in the commit message, where git blame reaches it. --diff [] Check only the lines added since the merge base with (default origin/master), committed or not, plus untracked files. Prints :: and exits 1 on a finding. This is the check CI runs on a pull request. Print : for every disallowed comment in . --language Print the language is read as, or nothing. Languages: Go, TypeScript/JavaScript (and JSX), Astro, HTML, CSS, YAML, shell, bats, Dockerfiles and Makefiles. A script without an extension is read by its shebang. USAGE } language() { case "${1##*/}" in *.go) echo go ;; *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) echo ts ;; *.astro|*.html) echo markup ;; *.css) echo css ;; *.yml|*.yaml) echo yaml ;; *.sh|*.bash|*.bats|*.zsh|*.mk|Makefile|GNUmakefile|Dockerfile|Dockerfile.*|*.Dockerfile|*.dockerfile|Containerfile) echo shell ;; *.*) ;; *) if [[ -f "$1" ]] && head -n 1 "$1" | grep -qE '^#!.*[/ ](ba|da|k|z)?sh([[:space:]]|$)'; then echo shell fi ;; esac } scan_hash() { awk -v yaml="$2" -v directive="$HASH_DIRECTIVE" -v licence="$LICENCE" ' function indent_of(s, t) { t = s sub(/[^[:space:]].*$/, "", t) gsub(/\t/, " ", t) return length(t) } function is_comment(i) { return !opaque[i] && src[i] ~ /^[[:space:]]*#/ } { src[NR] = $0 } END { scalar = -1 for (i = 1; i <= NR; i++) { line = src[i] if (heredoc != "") { opaque[i] = 1 t = line gsub(/^[[:space:]]+|[[:space:]]+$/, "", t) if (t == heredoc) heredoc = "" continue } if (scalar >= 0) { if (line ~ /^[[:space:]]*$/ || indent_of(line) > scalar) { if (!script) { opaque[i] = 1; continue } } else { scalar = -1 } } if (line ~ /^[[:space:]]*#/) continue if (yaml && scalar < 0 && line ~ /:[[:space:]]*[|>][-+0-9]*[[:space:]]*(#.*)?$/) { scalar = indent_of(line) key = line sub(/^[[:space:]]*(-[[:space:]]+)?/, "", key) sub(/[[:space:]]*:.*$/, "", key) script = (key == "run" || key == "script" || key == "command" || key == "entrypoint") continue } if (match(line, /<<-?[[:space:]]*["\047]?[A-Za-z_][A-Za-z0-9_]*/) && substr(line, RSTART - 1, 1) != "<") { heredoc = substr(line, RSTART, RLENGTH) sub(/^<<-?[[:space:]]*["\047]?/, "", heredoc) } } for (i = 1; i <= NR; i++) { if (!is_comment(i)) continue if (i == 1 && src[i] ~ /^#!/) continue if (src[i] ~ directive) continue for (j = i; j <= NR && is_comment(j); j++) continue for (s = i; s >= 1 && is_comment(s); s--) continue licensed = 0 for (m = s + 1; m < j; m++) if (src[m] ~ licence) licensed = 1 if (licensed) continue print i ":" src[i] } } ' "$1" } scan_slash() { awk -v ts="$2" -v markup="$3" -v directive="$DIRECTIVE" -v licence="$LICENCE" ' function is_comment(s) { return s ~ /^[[:space:]]*\/\// || s ~ /^[[:space:]]*\/\*/ || (ts && s ~ /^[[:space:]]*\{\/\*/) || (markup && s ~ /^[[:space:]]*/) { blocked[i] = 1; open = 1; closer = "-->" } } } function indent_of(s, t) { t = s sub(/[^[:space:]].*$/, "", t) gsub(/\t/, " ", t) return length(t) } function declares(s) { if (s ~ /^package[[:space:]]/) return 1 if (s ~ /^func[[:space:]]+(\([^)]*\)[[:space:]]*)?[A-Z]/) return 1 if (s ~ /^(type|const|var)[[:space:]]+[A-Z]/) return 1 if (s ~ /^(type|const|var)[[:space:]]*\(/) return 1 if (ts && s ~ /^export[[:space:]]/) return 1 if (ts && s ~ /^(function|class|const|let|var|interface|type|enum)[[:space:]]/) { name = s sub(/^(function|class|const|let|var|interface|type|enum)[[:space:]]+/, "", name) sub(/[^A-Za-z0-9_$].*$/, "", name) if (name in exported) return 1 } return 0 } function collect_exports( i, line, names, n, k) { for (i = 1; i <= NR; i++) { line = src[i] if (line ~ /^export[[:space:]]+default[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*;?[[:space:]]*$/) { name = line sub(/^export[[:space:]]+default[[:space:]]+/, "", name) sub(/[^A-Za-z0-9_$].*$/, "", name) exported[name] = 1 continue } if (line ~ /^export[[:space:]]*\{/) { names = line sub(/^export[[:space:]]*\{/, "", names) sub(/\}.*$/, "", names) n = split(names, k, ",") for (j2 = 1; j2 <= n; j2++) { name = k[j2] gsub(/^[[:space:]]+|[[:space:]]+$/, "", name) sub(/[[:space:]]+as[[:space:]]+.*$/, "", name) sub(/^type[[:space:]]+/, "", name) if (name != "") exported[name] = 1 } } } } { src[NR] = $0 } END { mark_blocks() collect_exports() if (markup) exported["Props"] = 1 for (i = 1; i <= NR; i++) { if (group == 2) { if (src[i] !~ /^[[:space:]]*$/ && indent_of(src[i]) == member_indent) member[i] = 1 if (src[i] ~ /^[})]/) group = 0 } else if (group) { member[i] = 1 if (src[i] ~ /^[})]/) group = 0 } else if (src[i] ~ /^(export[[:space:]]+)?(default[[:space:]]+)?(abstract[[:space:]]+)?class[[:space:]]/) { group = 2 member_indent = -1 } else if (src[i] ~ /^(export[[:space:]]+)?(type|const|var|interface)[[:space:]]*([A-Z][^=]*)?[({][[:space:]]*$/) { group = 1 } if (group == 2 && member_indent < 0 && src[i] !~ /^[[:space:]]*$/ && indent_of(src[i]) > 0) member_indent = indent_of(src[i]) } for (i = 1; i <= NR; i++) { if (!(is_comment(src[i]) || blocked[i])) continue if (src[i] ~ directive) continue for (j = i; j <= NR && (is_comment(src[j]) || blocked[j]); j++) continue for (s = i; s >= 1 && (is_comment(src[s]) || blocked[s]); s--) continue licensed = 0 for (m = s + 1; m < j; m++) if (src[m] ~ licence) licensed = 1 if (licensed) continue for (d = j; d <= NR && src[d] ~ /^[[:space:]]*$/; d++) continue if (d <= NR && (declares(src[d]) || (member[d] && src[d] ~ /^[[:space:]]+([A-Z]|(static|async|get|set|readonly|private|public|protected)[[:space:]]|[a-z_$][A-Za-z0-9_$]*[<(])/))) continue print i ":" src[i] } } ' "$1" } scan_file() { case "$(language "$1")" in go|css) scan_slash "$1" 0 0 ;; ts) scan_slash "$1" 1 0 ;; markup) scan_slash "$1" 1 1 ;; yaml) scan_hash "$1" 1 ;; shell) scan_hash "$1" 0 ;; esac } added_lines() { awk '/^@@/ { match($0, /\+[0-9]+(,[0-9]+)?/) split(substr($0, RSTART + 1, RLENGTH - 1), r, ",") n = (2 in r) ? r[2] : 1 for (i = 0; i < n; i++) print r[1] + i }' } annotation_escape() { local s=$1 s=${s//'%'/'%25'} s=${s//$'\r'/'%0D'} s=${s//$'\n'/'%0A'} printf '%s' "$s" } scan_diff() { local base=$1 root merge_base found=0 path lines report n text root=$(git rev-parse --show-toplevel) cd "$root" merge_base=$(git merge-base "$base" HEAD) || { echo "comment-scan: no merge base with $base; fetch it first (git fetch origin)." >&2 exit 2 } while IFS= read -r path; do [[ -f "$path" && ! -L "$path" ]] || continue [[ -n "$(language "$path")" ]] || continue if git ls-files --error-unmatch -- "$path" >/dev/null 2>&1; then lines=$(git diff -U0 "$merge_base" -- "$path" | added_lines | tr '\n' ' ') else lines=$(awk '{ printf "%d ", NR }' "$path") fi [[ -n "${lines// /}" ]] || continue report=$(scan_file "$path" | awk -v nums="$lines" ' BEGIN { split(nums, a, " "); for (i in a) want[a[i]] = 1 } { split($0, p, ":"); if (p[1] in want) print }') [[ -n "$report" ]] || continue while IFS= read -r line; do n=${line%%:*} text=${line#*:} text=${text#"${text%%[![:space:]]*}"} echo "$path:$n: $text" if [[ "${GITHUB_ACTIONS:-}" == true ]]; then echo "::error file=$(annotation_escape "$path"),line=$n::$(annotation_escape "Comment not allowed: $text")" fi found=$((found + 1)) done <<<"$report" done < <({ git diff --name-only --diff-filter=d "$merge_base" -- git ls-files --others --exclude-standard } | sort -u) if ((found > 0)); then echo echo "$found comment(s) above are not allowed. Run devscripts/comment-scan --help for the rule." exit 1 fi } case "${1:-}" in -h|--help) usage ;; --language) language "$2" ;; --diff) scan_diff "${2:-origin/master}" ;; "") usage >&2; exit 2 ;; *) scan_file "$1" ;; esac