-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(githooks): canonical docstring scanner with a known-answer suite #1073
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,221 @@ | ||||||
| #!/usr/bin/env bash | ||||||
| # SPDX-License-Identifier: MPL-2.0 | ||||||
| # docstring-scan.sh — report the functions a change touches, and whether each is documented. | ||||||
| # | ||||||
| # The canonical docstring predicate for the estate. Three consumers call it (or re-implement it | ||||||
| # against the same fixtures): the ~/.claude Stop hook, the .githooks pre-commit validator, and the | ||||||
| # CI backstop. Keep ONE copy of this logic; share the fixture corpus, not the code. | ||||||
| # | ||||||
| # It asks the question CodeRabbit's "Docstring Coverage" pre-merge check asks — "of the functions | ||||||
| # this diff touches, how many carry a docstring?" — and calibrates against a known answer: | ||||||
| # hyperpolymath/standards PR #1034 at 1cc72cdc80c9 ⇒ 2 files, 13 functions, 0 documented, 3 skipped. | ||||||
| # | ||||||
| # Usage: | ||||||
| # docstring-scan.sh --worktree uncommitted work vs HEAD (untracked files count as added) | ||||||
| # docstring-scan.sh --staged the index vs HEAD (pre-commit) | ||||||
| # docstring-scan.sh --range BASE..HEAD a commit range (CI, calibration) | ||||||
| # add --check to exit 1 when a NEWLY-ADDED function is undocumented. | ||||||
| # | ||||||
| # Output (stdout): one TSV row per touched function, then one SUMMARY line. | ||||||
| # path<TAB>line<TAB>symbol<TAB>added|modified<TAB>documented|undocumented | ||||||
| # path<TAB>-<TAB>-<TAB>-<TAB>skipped (a changed source file in an unsupported language) | ||||||
| # SUMMARY files=N functions=N documented=N undocumented=N added_undocumented=N skipped=N coverage=P% | ||||||
| # The denominator is always printed: "0 undocumented" out of 0 functions is a vacuous pass and must | ||||||
| # not read the same as a real one. | ||||||
| # | ||||||
| # Exit: 0 report produced (and, with --check, no added undocumented function); 1 --check found an | ||||||
| # added undocumented function; 2 usage or git error. | ||||||
| # | ||||||
| # Tier 1 (this version): shell. Every other source extension reports as SKIPPED — never as | ||||||
| # documented. Documentation files (.adoc/.md/.rst/.txt-less docs) are ignored outright, as | ||||||
| # CodeRabbit ignores them. | ||||||
|
|
||||||
| set -uo pipefail | ||||||
|
|
||||||
| MODE=""; RANGE=""; CHECK=0 | ||||||
| while [ $# -gt 0 ]; do | ||||||
| case "$1" in | ||||||
| --worktree) MODE=worktree ;; | ||||||
| --staged) MODE=staged ;; | ||||||
| --range) MODE=range; RANGE="${2:-}"; shift ;; | ||||||
| --check) CHECK=1 ;; | ||||||
| -h|--help) sed -n '2,31p' "$0"; exit 0 ;; | ||||||
| *) printf 'docstring-scan: unknown argument: %s\n' "$1" >&2; exit 2 ;; | ||||||
| esac | ||||||
| shift | ||||||
| done | ||||||
| [ -n "$MODE" ] || { printf 'docstring-scan: one of --worktree, --staged, --range BASE..HEAD is required\n' >&2; exit 2; } | ||||||
| git rev-parse --git-dir >/dev/null 2>&1 || { printf 'docstring-scan: not inside a git repository\n' >&2; exit 2; } | ||||||
|
|
||||||
| G=(git -c core.quotePath=false) | ||||||
|
|
||||||
| # Resolve the base revision and the revision (or source) the new content is read from. | ||||||
| if [ "$MODE" = range ]; then | ||||||
| case "$RANGE" in *..*) ;; *) printf 'docstring-scan: --range needs BASE..HEAD\n' >&2; exit 2 ;; esac | ||||||
| BASE="${RANGE%%..*}"; HEADREV="${RANGE##*..}" | ||||||
| "${G[@]}" rev-parse --verify -q "$BASE^{commit}" >/dev/null || { printf 'docstring-scan: bad base %s\n' "$BASE" >&2; exit 2; } | ||||||
| "${G[@]}" rev-parse --verify -q "$HEADREV^{commit}" >/dev/null || { printf 'docstring-scan: bad head %s\n' "$HEADREV" >&2; exit 2; } | ||||||
| else | ||||||
| # A repo with no commits yet has no HEAD: everything is added. | ||||||
| if "${G[@]}" rev-parse --verify -q HEAD >/dev/null; then BASE=HEAD; else BASE=""; fi | ||||||
| fi | ||||||
|
|
||||||
| # Print the new content of a path for the active mode. | ||||||
| new_content() { | ||||||
| case "$MODE" in | ||||||
| worktree) cat -- "$1" ;; | ||||||
| staged) "${G[@]}" show ":$1" ;; | ||||||
| range) "${G[@]}" show "$HEADREV:$1" ;; | ||||||
| esac | ||||||
| } | ||||||
|
|
||||||
| # Print the base content of a path, or nothing when it did not exist at the base. | ||||||
| old_content() { | ||||||
| [ -n "$BASE" ] || return 0 | ||||||
| "${G[@]}" show "$BASE:$1" 2>/dev/null || true | ||||||
| } | ||||||
|
|
||||||
| # List changed paths (NUL-separated) with a status letter: A (whole file new) or M (touched). | ||||||
| changed_paths() { | ||||||
| local diffargs=() | ||||||
| case "$MODE" in | ||||||
| worktree) diffargs=("$BASE") ;; | ||||||
| staged) diffargs=(--cached "$BASE") ;; | ||||||
| range) diffargs=("$BASE" "$HEADREV") ;; | ||||||
| esac | ||||||
| if [ -z "$BASE" ]; then | ||||||
| # No base commit: every tracked or staged file is new. | ||||||
| "${G[@]}" ls-files -z | while IFS= read -r -d '' p; do printf 'A\t%s\0' "$p"; done | ||||||
| else | ||||||
| "${G[@]}" diff --no-renames --name-status -z --diff-filter=AM "${diffargs[@]}" -- \ | ||||||
| | while IFS= read -r -d '' st && IFS= read -r -d '' p; do printf '%s\t%s\0' "$st" "$p"; done | ||||||
| fi | ||||||
| if [ "$MODE" = worktree ]; then | ||||||
| "${G[@]}" ls-files -z --others --exclude-standard | while IFS= read -r -d '' p; do printf 'A\t%s\0' "$p"; done | ||||||
| fi | ||||||
| } | ||||||
|
|
||||||
| # Print the changed new-side line ranges of a touched file as "start end" lines. | ||||||
| touched_ranges() { | ||||||
| local diffargs=() | ||||||
| case "$MODE" in | ||||||
| worktree) diffargs=("$BASE") ;; | ||||||
| staged) diffargs=(--cached "$BASE") ;; | ||||||
| range) diffargs=("$BASE" "$HEADREV") ;; | ||||||
| esac | ||||||
| "${G[@]}" diff --no-renames -U0 "${diffargs[@]}" -- "$1" \ | ||||||
| | sed -nE 's/^@@ -[0-9,]+ \+([0-9]+)(,([0-9]+))? @@.*/\1 \3/p' \ | ||||||
| | awk '{ n = ($2 == "") ? 1 : $2; if (n > 0) print $1, $1 + n - 1; else print $1, $1 }' | ||||||
| } | ||||||
|
|
||||||
| # Classify a path: shell | doc | other (unsupported source, reported as skipped) | ignore. | ||||||
| classify() { | ||||||
| local p="$1" base="${1##*/}" | ||||||
| case "$base" in | ||||||
| *.sh|*.bash|*.bats) echo shell; return ;; | ||||||
| *.adoc|*.md|*.rst|*.org|*.txt|*.json|*.toml|*.yml|*.yaml|*.scm|*.lock|*.a2ml|*.csv|*.tsv|*.svg|*.png|*.jpg|*.gif|*.ico) | ||||||
| # CodeRabbit reports data files as "unsupported" (skipped) and prose as nothing at all. | ||||||
| case "$base" in *.adoc|*.md|*.rst|*.org) echo doc ;; *) echo other ;; esac; return ;; | ||||||
| esac | ||||||
| # An extensionless file is shell when its shebang says so. | ||||||
| case "$base" in | ||||||
| *.*) echo other ;; | ||||||
| *) if [ "$MODE" = worktree ] && [ -f "$p" ]; then | ||||||
| head -c 64 -- "$p" 2>/dev/null | head -1 | grep -qE '^#!.*\b(ba|z|k|da)?sh\b' && { echo shell; return; } | ||||||
| fi | ||||||
| echo other ;; | ||||||
| esac | ||||||
| } | ||||||
|
|
||||||
| # Emit "line<TAB>end<TAB>name<TAB>documented|undocumented" for every shell function in stdin. | ||||||
| # Heredoc bodies are skipped; a same-line trailing comment is NOT a docstring (PR #1034 proves it); | ||||||
| # shebang, shellcheck directives, SPDX headers and bare "#" lines do not count as documentation. | ||||||
| shell_functions() { | ||||||
| awk ' | ||||||
| function flush_doc() { doc = 0; seen = 0 } | ||||||
| BEGIN { hd = ""; doc = 0; seen = 0; open_n = 0 } | ||||||
| { | ||||||
| line = $0 | ||||||
| if (hd != "") { | ||||||
| chk = line; if (hd_strip) sub(/^\t+/, "", chk) | ||||||
| if (chk == hd) hd = "" | ||||||
| flush_doc(); next | ||||||
| } | ||||||
| # Close an open multi-line function at a line that is exactly its indent plus "}". | ||||||
| if (open_n > 0 && line ~ ("^" open_indent "}")) { | ||||||
| print open_start "\t" NR "\t" open_name "\t" open_doc; open_n = 0 | ||||||
| } | ||||||
| if (line ~ /^[ \t]*#/) { | ||||||
| c = line; sub(/^[ \t]*#+[ \t]*/, "", c) | ||||||
| if (line ~ /^#!/ || c ~ /^shellcheck[ \t]/ || c ~ /^SPDX-/ || c == "") { seen = 1 } | ||||||
| else { doc = 1; seen = 1 } | ||||||
| next | ||||||
| } | ||||||
| name = "" | ||||||
| if (match(line, /^[ \t]*function[ \t]+[A-Za-z_][A-Za-z0-9_:.-]*/)) { | ||||||
| name = substr(line, RSTART, RLENGTH); sub(/^[ \t]*function[ \t]+/, "", name) | ||||||
| } else if (match(line, /^[ \t]*[A-Za-z_][A-Za-z0-9_:.-]*[ \t]*\(\)/)) { | ||||||
| name = substr(line, RSTART, RLENGTH); sub(/[ \t]*\(\)$/, "", name); sub(/^[ \t]*/, "", name) | ||||||
| } | ||||||
| if (name != "") { | ||||||
| status = doc ? "documented" : "undocumented" | ||||||
| ind = line; sub(/[^ \t].*$/, "", ind) | ||||||
| rest = line; o = gsub(/\{/, "{", rest); cl = gsub(/\}/, "}", rest) | ||||||
| if (o > 0 && o == cl) { print NR "\t" NR "\t" name "\t" status } | ||||||
| else { | ||||||
| if (open_n > 0) print open_start "\t" (NR - 1) "\t" open_name "\t" open_doc | ||||||
| open_n = 1; open_start = NR; open_name = name; open_doc = status; open_indent = ind | ||||||
| } | ||||||
| } | ||||||
| # Enter a heredoc (not a <<< herestring) after the function check, so "f() { cat <<EOF" works. | ||||||
| if (match(line, /<<-?[ \t]*["\047]?[A-Za-z_][A-Za-z0-9_]*["\047]?/) && line !~ /<<</) { | ||||||
| tok = substr(line, RSTART, RLENGTH); hd_strip = (tok ~ /^<<-/) | ||||||
| sub(/^<<-?[ \t]*/, "", tok); gsub(/["\047]/, "", tok); hd = tok | ||||||
| } | ||||||
| flush_doc() | ||||||
| } | ||||||
| END { if (open_n > 0) print open_start "\t" NR "\t" open_name "\t" open_doc } | ||||||
| ' | ||||||
| } | ||||||
|
|
||||||
| TMP="$(mktemp -d -t docscan.XXXXXX)" || exit 2 | ||||||
| trap 'rm -rf "$TMP"' EXIT | ||||||
|
|
||||||
| files=0; functions=0; documented=0; undocumented=0; added_undoc=0; skipped=0 | ||||||
|
|
||||||
| while IFS= read -r -d '' rec; do | ||||||
| st="${rec%%$'\t'*}"; p="${rec#*$'\t'}" | ||||||
| kind="$(classify "$p")" | ||||||
| case "$kind" in | ||||||
| doc|ignore) continue ;; | ||||||
| other) printf '%s\t-\t-\t-\tskipped\n' "$p"; skipped=$((skipped + 1)); continue ;; | ||||||
| esac | ||||||
| new_content "$p" > "$TMP/new" 2>/dev/null || continue | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '60,110p' .githooks/docstring-scan.sh
sed -n '185,200p' .githooks/docstring-scan.shRepository: hyperpolymath/standards Length of output: 2655 🏁 Script executed: sed -n '1,65p' .githooks/docstring-scan.sh
sed -n '100,125p' .githooks/docstring-scan.sh
sed -n '185,198p' .githooks/docstring-scan.shRepository: hyperpolymath/standards Length of output: 5254 Handle Deleted paths are filtered by 🐛 Suggested fix- new_content "$p" > "$TMP/new" 2>/dev/null || continue
+ new_content "$p" > "$TMP/new" || { printf 'docstring-scan: cannot read %s\n' "$p" >&2; exit 2; }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| old_content "$p" | shell_functions | cut -f3 | sort -u > "$TMP/oldnames" | ||||||
| shell_functions < "$TMP/new" > "$TMP/fns" | ||||||
| if [ "$st" = A ]; then : > "$TMP/ranges"; else touched_ranges "$p" > "$TMP/ranges"; fi | ||||||
| hit=0 | ||||||
| while IFS=$'\t' read -r start end name status; do | ||||||
| if [ "$st" != A ]; then | ||||||
| awk -v s="$start" -v e="$end" '$1 <= e && $2 >= s { f = 1 } END { exit !f }' "$TMP/ranges" || continue | ||||||
| fi | ||||||
| if [ "$st" = A ] || ! grep -qxF -- "$name" "$TMP/oldnames"; then origin=added; else origin=modified; fi | ||||||
| printf '%s\t%s\t%s\t%s\t%s\n' "$p" "$start" "$name" "$origin" "$status" | ||||||
| hit=1; functions=$((functions + 1)) | ||||||
| if [ "$status" = documented ]; then documented=$((documented + 1)); else | ||||||
| undocumented=$((undocumented + 1)); [ "$origin" = added ] && added_undoc=$((added_undoc + 1)) | ||||||
| fi | ||||||
| done < "$TMP/fns" | ||||||
| [ "$hit" = 1 ] && files=$((files + 1)) | ||||||
| done < <(changed_paths) | ||||||
|
|
||||||
| if [ "$functions" -gt 0 ]; then | ||||||
| cov="$(awk -v d="$documented" -v n="$functions" 'BEGIN { printf "%.2f", 100 * d / n }')" | ||||||
| else | ||||||
| cov="n/a" | ||||||
| fi | ||||||
| printf 'SUMMARY files=%d functions=%d documented=%d undocumented=%d added_undocumented=%d skipped=%d coverage=%s%%\n' \ | ||||||
| "$files" "$functions" "$documented" "$undocumented" "$added_undoc" "$skipped" "$cov" | ||||||
|
|
||||||
| [ "$CHECK" = 1 ] && [ "$added_undoc" -gt 0 ] && exit 1 | ||||||
| exit 0 | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| #!/usr/bin/env bash | ||
| # SPDX-License-Identifier: MPL-2.0 | ||
| # docstring-scan-test.sh — fixture suite for .githooks/docstring-scan.sh. | ||
| # | ||
| # The fixtures, not the scanner code, are the estate's single source of truth for the docstring | ||
| # predicate: the Stop hook, the pre-commit validator and Hypatia's re-implementation must all pass | ||
| # them. SCANNER=<path> injects an alternative implementation (or a mutant). | ||
|
|
||
| set -uo pipefail | ||
|
|
||
| ROOT="$(cd "$(dirname "$0")/../.." && pwd)" | ||
| SCANNER="${SCANNER:-$ROOT/.githooks/docstring-scan.sh}" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Resolve With Resolve the override to an absolute path before the first directory change. 🤖 Prompt for AI Agents |
||
| [ -f "$SCANNER" ] || { echo "FATAL: scanner not found at $SCANNER" >&2; exit 2; } | ||
|
|
||
| PASS=0; FAIL=0 | ||
| # Record a passing assertion and print its description. | ||
| ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } | ||
| # Record a failing assertion and print its expected and actual values. | ||
| bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n expected: %s\n actual: %s\n' "$1" "$2" "$3"; } | ||
| # Compare expected and actual values, then record the assertion result. | ||
| check(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$2" "$3"; fi; } | ||
| # Extract one key's value from the scanner's SUMMARY line. | ||
| field(){ printf '%s\n' "$1" | sed -nE "s/^SUMMARY .*\b$2=([^ ]+).*/\1/p"; } | ||
| # Print the status column of one symbol's row. | ||
| row() { printf '%s\n' "$1" | awk -F'\t' -v s="$2" '$3==s{print $4 "/" $5}'; } | ||
|
|
||
| WORK="$(mktemp -d -t docscan-test.XXXXXX)" | ||
| trap 'rm -rf "$WORK"' EXIT | ||
|
|
||
| # Create a fresh repository with one committed baseline file and cd into it. | ||
| newrepo() { | ||
| rm -rf "$WORK/r"; mkdir -p "$WORK/r"; cd "$WORK/r" || exit 2 | ||
| git init -q . && git config user.email t@example.invalid && git config user.name t | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Clear inherited Git environment variables before running Git. If a caller exports Clear repository-local Git environment variables at startup, before calibration and fixture setup. Based on learnings: scripts that invoke Git must clear inherited repository-local Git environment variables. 🤖 Prompt for AI AgentsSource: Learnings |
||
| git config commit.gpgsign false && git config core.hooksPath /dev/null | ||
| cat > base.sh <<'EOF' | ||
| #!/usr/bin/env bash | ||
| # Print a greeting. | ||
| greet() { | ||
| echo hello | ||
| } | ||
|
|
||
| legacy() { | ||
| echo old | ||
| } | ||
| EOF | ||
| git add base.sh && git commit -qm base | ||
| } | ||
|
|
||
| # Run the scanner in the current repository. | ||
| scan() { bash "$SCANNER" "$@" 2>&1; } | ||
|
|
||
| echo "== calibration: standards PR #1034 at 1cc72cdc80c9 (CodeRabbit: 13 functions / 2 files / 0.00% / 3 skipped)" | ||
| if git -C "$ROOT" cat-file -e 1cc72cdc80c9 2>/dev/null; then | ||
| out="$(cd "$ROOT" && scan --range 1cc72cdc80c9^..1cc72cdc80c9)" | ||
| check "calibration files" 2 "$(field "$out" files)" | ||
| check "calibration functions" 13 "$(field "$out" functions)" | ||
| check "calibration documented" 0 "$(field "$out" documented)" | ||
| check "calibration skipped" 3 "$(field "$out" skipped)" | ||
| check "calibration coverage" 0.00% "$(field "$out" coverage)" | ||
| else | ||
| # A skip is not a pass: a shallow clone must not report the known-answer control as green. | ||
| bad "calibration commit present (fetch full history)" "1cc72cdc80c9 reachable" "absent" | ||
| fi | ||
|
|
||
| echo "== planted positive: a new undocumented function blocks under --check" | ||
| newrepo | ||
| cat > new.sh <<'EOF' | ||
| #!/usr/bin/env bash | ||
| # Documented helper. | ||
| good() { :; } | ||
| undoc() { :; } | ||
| EOF | ||
| out="$(scan --worktree)"; rc=0; scan --worktree --check >/dev/null || rc=$? | ||
| check "untracked new file is scanned" 2 "$(field "$out" functions)" | ||
| check "good is documented" added/documented "$(row "$out" good)" | ||
| check "undoc is undocumented" added/undocumented "$(row "$out" undoc)" | ||
| check "--check exits 1" 1 "$rc" | ||
|
|
||
| echo "== negative control: touching only a documented function is silent" | ||
| newrepo | ||
| sed -i 's/echo hello/echo hello world/' base.sh | ||
| out="$(scan --worktree)"; rc=0; scan --worktree --check >/dev/null || rc=$? | ||
| check "one touched function" 1 "$(field "$out" functions)" | ||
| check "undocumented is zero" 0 "$(field "$out" undocumented)" | ||
| check "--check exits 0" 0 "$rc" | ||
|
|
||
| echo "== added vs modified: editing an undocumented legacy body reports but does not block" | ||
| newrepo | ||
| sed -i 's/echo old/echo older/' base.sh | ||
| out="$(scan --worktree)"; rc=0; scan --worktree --check >/dev/null || rc=$? | ||
| check "legacy is modified/undocumented" modified/undocumented "$(row "$out" legacy)" | ||
| check "untouched greet is not reported" "" "$(row "$out" greet)" | ||
| check "--check exits 0 on modified-only" 0 "$rc" | ||
| printf 'fresh() {\n :\n}\n' >> base.sh | ||
| rc=0; scan --worktree --check >/dev/null || rc=$? | ||
| check "adding an undocumented function beside it blocks" 1 "$rc" | ||
|
|
||
| echo "== predicate edges" | ||
| newrepo | ||
| cat > edge.sh <<'EOF' | ||
| #!/usr/bin/env bash | ||
| trailing() { :; } # a same-line comment is not a docstring | ||
|
|
||
| # shellcheck disable=SC2034 | ||
| directive() { :; } | ||
|
|
||
| # Real documentation. | ||
| # shellcheck disable=SC2034 | ||
| docthendirective() { :; } | ||
|
|
||
| function kw_style { | ||
| : | ||
| } | ||
|
|
||
| cat <<'INNER' | ||
| first body line | ||
| inheredoc() { :; } | ||
| INNER | ||
| EOF | ||
| out="$(scan --worktree)" | ||
| check "trailing comment is not documentation" added/undocumented "$(row "$out" trailing)" | ||
| check "shellcheck directive is not documentation" added/undocumented "$(row "$out" directive)" | ||
| check "doc above a directive still documents" added/documented "$(row "$out" docthendirective)" | ||
| check "function keyword form is detected" added/undocumented "$(row "$out" kw_style)" | ||
| check "a heredoc body is not code" "" "$(row "$out" inheredoc)" | ||
| check "edge function count" 4 "$(field "$out" functions)" | ||
|
|
||
| echo "== skipped is skipped, never documented" | ||
| newrepo | ||
| printf 'fn main() {}\n' > main.rs | ||
| printf '= Notes\n' > notes.adoc | ||
| out="$(scan --worktree)" | ||
| check "unsupported source is skipped" 1 "$(field "$out" skipped)" | ||
| check "it contributes no functions" 0 "$(field "$out" functions)" | ||
| check "coverage is n/a, not 100%" n/a% "$(field "$out" coverage)" | ||
|
|
||
| echo "== mode parity: --staged and --worktree ask the same question" | ||
| newrepo | ||
| cat > par.sh <<'EOF' | ||
| # Documented. | ||
| a() { :; } | ||
| b() { :; } | ||
| EOF | ||
| sed -i 's/echo old/echo older/' base.sh | ||
| w="$(scan --worktree | sort)" | ||
| git add -A | ||
| s="$(scan --staged | sort)" | ||
| check "staged verdict equals worktree verdict" "$w" "$s" | ||
|
|
||
| echo "== awkward paths: spaces and non-ASCII" | ||
| newrepo | ||
| mkdir -p "dir with space" "naïve" | ||
| printf 'x() { :; }\n' > "dir with space/a b.sh" | ||
| printf '# Doc.\ny() { :; }\n' > "naïve/ü.sh" | ||
| out="$(scan --worktree)" | ||
| check "path with spaces is scanned" "added/undocumented" "$(row "$out" x)" | ||
| check "non-ASCII path is scanned" "added/documented" "$(row "$out" y)" | ||
|
|
||
| echo "== errors are loud" | ||
| rc=0; (cd "$WORK" && bash "$SCANNER" --worktree >/dev/null 2>&1) || rc=$? | ||
| check "outside a git repository exits 2" 2 "$rc" | ||
| rc=0; scan >/dev/null || rc=$? | ||
| check "no mode exits 2" 2 "$rc" | ||
|
|
||
| echo | ||
| printf 'docstring-scan-test: %d passed, %d failed\n' "$PASS" "$FAIL" | ||
| [ "$FAIL" -eq 0 ] | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Extensionless shell scripts are skipped in
--stagedand--rangemodes.The shebang check runs only when
MODEisworktree. It also reads the worktree file, not the new content for the active mode. For example, a staged.githooks/pre-commitwith#!/usr/bin/env bashis classified asotherand reported asskipped. The pre-commit gate and the CI gate then miss new undocumented functions in hook files. The--worktreereport for the same change classifies the file asshell. This breaks the staged/worktree parity that the PR claims.Read the first line with
new_contentfor every mode.🐛 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents