diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aa53fa2efb5..19aa158b093 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,8 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star - Helper scripts in `bin/` are plain bash. Each starts with a usage header comment; keep it accurate when you change behavior. Test scripts and helpers in `tests/` are plain bash too. - `bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, pinned shellcheck version, and pinned actionlint workflow lint), and both CI and the no-mistakes pre-push gate run it, so local and CI can never diverge. + `bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, pinned shellcheck version, and pinned actionlint workflow lint), and both CI and the no-mistakes pre-push gate run its no-argument full-analysis path. + Its header and `--help` output own the exact local lint modes and flags. A malformed `.github/workflows/*.yml`, including a self-broken `ci.yml`, fails that local lint path before merge because a broken workflow cannot report its own breakage. It pins one exact shellcheck version and one exact actionlint version and refuses to run under any other. Print the shellcheck pin with `bin/fm-lint.sh --required-version` and the actionlint pin with `bin/fm-lint-workflows.sh --required-version`. diff --git a/bin/fm-lint.sh b/bin/fm-lint.sh index 53a3f0cff89..3eb5b53609d 100755 --- a/bin/fm-lint.sh +++ b/bin/fm-lint.sh @@ -5,6 +5,9 @@ # ambient configuration disabled, and one exact ShellCheck version. CI and # no-mistakes both invoke this script with no arguments, so the rule set, # version, bounded execution, and diagnostics ordering cannot drift. +# The explicit --fast mode is local-only and disables ShellCheck's extended +# dataflow analysis while preserving ordinary shell lint checks. CI and +# no-mistakes keep the full-analysis no-argument default. # Tests stop source analysis at imported production modules because every # production shell is already a canonical, source-aware root of this same run. # The default (no explicit-path) path also runs bin/fm-lint-workflows.sh so a @@ -34,6 +37,7 @@ # # Usage: # fm-lint.sh lint the context-selected file set (see above) +# fm-lint.sh --fast [path]... local lint with extended analysis disabled # fm-lint.sh ... lint explicit roots with the same config # fm-lint.sh --jobs <1|2> [path]... override bounded worker count # fm-lint.sh --telemetry ... write a quiet metrics snapshot @@ -59,7 +63,7 @@ fm_lint_worker_stop() { fm_lint_worker() { # local manifest=$1 output_dir=$2 shard_index=$3 tab index path output rc=0 - local -a roots + local -a roots shellcheck_args roots=() tab=$(printf '\t') while IFS="$tab" read -r index path || [ -n "${index:-}${path:-}" ]; do @@ -71,7 +75,11 @@ fm_lint_worker() { # trap 'fm_lint_worker_stop; exit 129' HUP trap 'fm_lint_worker_stop; exit 130' INT trap 'fm_lint_worker_stop; exit 143' TERM - "$FM_LINT_SHELLCHECK" --norc --external-sources -- "${roots[@]}" > "$output.out" 2>&1 & + shellcheck_args=(--norc --external-sources) + if [ "${FM_LINT_INTERNAL_FAST:-0}" -eq 1 ]; then + shellcheck_args+=(--extended-analysis=false) + fi + "$FM_LINT_SHELLCHECK" "${shellcheck_args[@]}" -- "${roots[@]}" > "$output.out" 2>&1 & FM_LINT_WORKER_SHELLCHECK_PID=$! wait "$FM_LINT_WORKER_SHELLCHECK_PID" || rc=$? FM_LINT_WORKER_SHELLCHECK_PID= @@ -100,7 +108,11 @@ if [ "${1:-}" = "--required-version" ]; then fi fm_lint_usage() { - sed -n '2,42{s/^# \{0,1\}//;p;}' "$SELF" + awk ' + NR == 1 { next } + /^#/ { sub(/^# ?/, ""); print; next } + { exit } + ' "$SELF" } # Default no-args lint also validates GitHub workflows. Explicit paths stay a @@ -112,6 +124,8 @@ fm_lint_run_workflows() { JOBS=${FM_LINT_JOBS:-2} TELEMETRY=${FM_LINT_TELEMETRY:-} +FAST=0 +ANALYSIS_MODE=full LIST_FILES=0 while [ "$#" -gt 0 ]; do case "$1" in @@ -133,6 +147,11 @@ while [ "$#" -gt 0 ]; do TELEMETRY=${1#*=} shift ;; + --fast) + FAST=1 + ANALYSIS_MODE=fast + shift + ;; --list-files) LIST_FILES=1 shift @@ -154,6 +173,11 @@ case "$JOBS" in *) printf 'fm-lint.sh: jobs must be 1 or 2, got %s.\n' "$JOBS" >&2; exit 2 ;; esac +if [ "$FAST" -eq 1 ] && { [ "${GITHUB_ACTIONS:-}" = true ] || [ "${CI:-}" = true ]; }; then + printf 'fm-lint.sh: --fast is local-only; CI uses full ShellCheck analysis.\n' >&2 + exit 2 +fi + # fm_lint_changed_base_ref prints the ref to diff the working branch against: # the local origin/main tracking ref when present, else local main. Returns # nonzero when neither is resolvable, which the caller treats as "no @@ -247,6 +271,11 @@ if [ "$resolved" != "$REQUIRED_SHELLCHECK" ]; then "$REQUIRED_SHELLCHECK" "$resolved" "$REQUIRED_SHELLCHECK" >&2 exit 1 fi +if [ "$FAST" -eq 1 ]; then + printf 'fm-lint.sh: fast local mode; ShellCheck extended analysis disabled\n' >&2 +else + printf 'fm-lint.sh: full ShellCheck extended analysis enabled\n' >&2 +fi if [ "$CHANGED_MODE" -eq 1 ] && [ "$ROOT_COUNT" -eq 0 ]; then printf 'fm-lint.sh: no changed lint targets\n' @@ -379,18 +408,18 @@ fm_lint_run_worker() { # if [ "$(uname)" = Darwin ]; then exec "$PERL_BIN" -e 'setpgrp(0, 0) or die "setpgrp: $!"; exec @ARGV or die "exec: $!"' \ /usr/bin/time -lp -o "$timing" \ - env FM_LINT_INTERNAL=1 FM_LINT_SHELLCHECK="$SHELLCHECK_BIN" \ + env FM_LINT_INTERNAL=1 FM_LINT_INTERNAL_FAST="$FAST" FM_LINT_SHELLCHECK="$SHELLCHECK_BIN" \ "${BASH:-bash}" "$SELF" --internal-worker "$manifest" "$OUTPUT_DIR" "$worker_index" else exec "$PERL_BIN" -e 'setpgrp(0, 0) or die "setpgrp: $!"; exec @ARGV or die "exec: $!"' \ /usr/bin/time -f 'wall_seconds=%e\nuser_seconds=%U\nsystem_seconds=%S\nmax_rss_kib=%M' -o "$timing" \ - env FM_LINT_INTERNAL=1 FM_LINT_SHELLCHECK="$SHELLCHECK_BIN" \ + env FM_LINT_INTERNAL=1 FM_LINT_INTERNAL_FAST="$FAST" FM_LINT_SHELLCHECK="$SHELLCHECK_BIN" \ "${BASH:-bash}" "$SELF" --internal-worker "$manifest" "$OUTPUT_DIR" "$worker_index" fi else [ -z "$TELEMETRY" ] || printf 'timing_unavailable=1\n' > "$timing" exec "$PERL_BIN" -e 'setpgrp(0, 0) or die "setpgrp: $!"; exec @ARGV or die "exec: $!"' \ - env FM_LINT_INTERNAL=1 FM_LINT_SHELLCHECK="$SHELLCHECK_BIN" \ + env FM_LINT_INTERNAL=1 FM_LINT_INTERNAL_FAST="$FAST" FM_LINT_SHELLCHECK="$SHELLCHECK_BIN" \ "${BASH:-bash}" "$SELF" --internal-worker "$manifest" "$OUTPUT_DIR" "$worker_index" fi } @@ -521,6 +550,7 @@ EOF printf 'git_head\t%s\n' "$git_head" printf 'content_cksum\t%s\n' "$content_cksum" printf 'shellcheck_version\t%s\n' "$resolved" + printf 'analysis_mode\t%s\n' "$ANALYSIS_MODE" printf 'jobs\t%s\n' "$JOBS" printf 'root_count\t%s\n' "$ROOT_COUNT" printf 'direct_lines\t%s\n' "$direct_lines" diff --git a/tests/fm-lint.test.sh b/tests/fm-lint.test.sh index 33af053eb3d..8041c76628e 100755 --- a/tests/fm-lint.test.sh +++ b/tests/fm-lint.test.sh @@ -151,6 +151,17 @@ pinned_ready() { [ "$(shellcheck --version | awk '/^version:/ {print $2; exit}')" = "$REQUIRED" ] } +test_help_reports_the_complete_interface() { + local help + help=$("$LINT" --help) || fail "fm-lint.sh --help failed" + assert_contains "$help" "--telemetry" "fm-lint.sh --help omitted --telemetry" + assert_contains "$help" "--required-version" "fm-lint.sh --help omitted --required-version" + assert_contains "$help" "--list-files" "fm-lint.sh --help omitted --list-files" + assert_contains "$help" "--help" "fm-lint.sh --help omitted --help" + assert_contains "$help" "--fast" "fm-lint.sh --help omitted --fast" + pass "fm-lint.sh --help reports the complete executable interface" +} + test_list_files_reports_the_shell_inventory() { local listed expected # CI=true forces the full canonical set regardless of the ambient branch or @@ -227,7 +238,9 @@ fm_lint_write_diff_file() { # that answers --version with the pinned version and otherwise logs the file # roots it was asked to check (one per line) instead of actually analyzing # them, so changed-file mode tests can assert exactly which files fm-lint.sh -# selected without depending on real ShellCheck findings. +# selected without depending on real ShellCheck findings. When +# FM_TEST_MODE_LOG is set, it records the effective analysis mode, treating +# ShellCheck's default as full analysis. fm_lint_stub_shellcheck() { local fakebin=$1 log=$2 : > "$log" @@ -237,13 +250,116 @@ if [ "\${1:-}" = --version ]; then printf 'ShellCheck - shell script analysis tool\nversion: 0.11.0\n' exit 0 fi -shift 3 +mode=on +while [ "\$#" -gt 0 ] && [ "\$1" != -- ]; do + [ "\$1" = --extended-analysis=false ] && mode=off + shift +done +if [ -n "\${FM_TEST_MODE_LOG:-}" ]; then + printf '%s\n' "\$mode" >> "\$FM_TEST_MODE_LOG" +fi +[ "\$#" -eq 0 ] || shift printf '%s\n' "\$@" >> "$log" exit 0 SH chmod +x "$fakebin/shellcheck" } +test_fast_mode_disables_extended_analysis() { + local tmp fakebin log mode_log telemetry fixture out + tmp=$(fm_test_tmproot fm-lint-fast-mode) + fakebin=$(fm_fakebin "$tmp") + fixture="$tmp/fixture.sh" + log="$tmp/shellcheck.log" + mode_log="$tmp/mode.log" + telemetry="$tmp/telemetry.tsv" + cat > "$fixture" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "${1:-ok}" +SH + chmod +x "$fixture" + fm_lint_stub_shellcheck "$fakebin" "$log" + + out=$(PATH="$fakebin:$PATH" GITHUB_ACTIONS='' CI='' FM_LINT_JOBS=1 \ + FM_TEST_MODE_LOG="$mode_log" "$LINT" --fast --telemetry "$telemetry" "$fixture" 2>&1) \ + || fail "fast lint mode failed"$'\n'"$out" + [ "$(cat "$mode_log")" = off ] \ + || fail "fast lint mode did not disable extended analysis" + [ "$(cat "$log")" = "$fixture" ] \ + || fail "fast lint mode did not lint the requested root" + assert_grep $'analysis_mode\tfast' "$telemetry" "telemetry did not record fast analysis mode" + pass "fm-lint.sh --fast disables ShellCheck extended analysis" +} + +test_ci_defaults_to_full_analysis() { + local tmp fakebin log mode_log fixture out + tmp=$(fm_test_tmproot fm-lint-ci-analysis) + fakebin=$(fm_fakebin "$tmp") + fixture="$tmp/fixture.sh" + log="$tmp/shellcheck.log" + mode_log="$tmp/mode.log" + cat > "$fixture" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "${1:-ok}" +SH + chmod +x "$fixture" + fm_lint_stub_shellcheck "$fakebin" "$log" + + out=$(PATH="$fakebin:$PATH" CI=true GITHUB_ACTIONS=true FM_LINT_FAST=1 FM_LINT_JOBS=1 \ + FM_TEST_MODE_LOG="$mode_log" "$LINT" "$fixture" 2>&1) \ + || fail "CI full lint mode failed"$'\n'"$out" + [ "$(cat "$mode_log")" = on ] \ + || fail "CI default did not keep full ShellCheck analysis" + pass "fm-lint.sh keeps full ShellCheck analysis by default in CI" +} + +test_ci_rejects_explicit_fast_mode() { + local tmp fakebin log fixture out rc + tmp=$(fm_test_tmproot fm-lint-ci-reject-fast) + fakebin=$(fm_fakebin "$tmp") + fixture="$tmp/fixture.sh" + log="$tmp/shellcheck.log" + cat > "$fixture" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "${1:-ok}" +SH + chmod +x "$fixture" + fm_lint_stub_shellcheck "$fakebin" "$log" + + rc=0 + out=$(PATH="$fakebin:$PATH" CI=true GITHUB_ACTIONS=true FM_LINT_JOBS=1 \ + "$LINT" --fast "$fixture" 2>&1) || rc=$? + [ "$rc" -eq 2 ] \ + || fail "CI accepted explicit fast lint mode (exit $rc)"$'\n'"$out" + assert_contains "$out" "--fast is local-only" \ + "CI fast-mode rejection did not explain the policy" + [ ! -s "$log" ] || fail "CI invoked ShellCheck after rejecting fast mode" + pass "fm-lint.sh rejects explicit --fast mode in CI" +} + +test_fast_mode_catches_a_real_lint_defect() { + if ! pinned_ready; then + pass "SKIP (ShellCheck $REQUIRED not resolved): fast lint-defect regression check" + return + fi + local tmp bad out rc + tmp=$(fm_test_tmproot fm-lint-fast-bad) + bad="$tmp/bad.sh" + cat > "$bad" <<'SH' +#!/usr/bin/env bash +foo() { + local a= b= + echo "$a$b" +} +foo +SH + rc=0 + out=$(GITHUB_ACTIONS='' CI='' "$LINT" --fast "$bad" 2>&1) || rc=$? + [ "$rc" -ne 0 ] || fail "fast lint mode passed a known-bad fixture"$'\n'"$out" + assert_contains "$out" "SC1007" "fast lint mode did not report the expected ShellCheck finding" + pass "fm-lint.sh --fast catches an ordinary shell lint defect" +} + test_changed_mode_lints_only_the_changed_file() { local tmp fakebin log diff_file out target tmp=$(fm_test_tmproot fm-lint-changed) @@ -711,6 +827,7 @@ SH || fail "telemetry-enabled clean lint failed" [ "$telemetry_out" = "$out_clean_2" ] || fail "quiet telemetry changed routine lint output" assert_grep $'format\tfm-lint-telemetry-v1' "$telemetry" "telemetry format marker is missing" + assert_grep $'analysis_mode\tfull' "$telemetry" "telemetry did not record full analysis mode" assert_grep $'jobs\t2' "$telemetry" "telemetry did not record bounded jobs" assert_grep $'root_count\t1' "$telemetry" "telemetry did not record root count" assert_grep $'wall_seconds\t' "$telemetry" "telemetry did not record wall time" @@ -877,7 +994,12 @@ SH pass "seeded dispatcher, adapter, production-owner, and test-local diagnostics preserve parity" } +test_help_reports_the_complete_interface test_list_files_reports_the_shell_inventory +test_fast_mode_disables_extended_analysis +test_ci_defaults_to_full_analysis +test_ci_rejects_explicit_fast_mode +test_fast_mode_catches_a_real_lint_defect test_pins_an_explicit_version test_installer_retries_transient_download_failure test_installer_selects_platform_archive_url_and_checksum