Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
42 changes: 36 additions & 6 deletions bin/fm-lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <path>... lint explicit roots with the same config
# fm-lint.sh --jobs <1|2> [path]... override bounded worker count
# fm-lint.sh --telemetry <path> ... write a quiet metrics snapshot
Expand All @@ -59,7 +63,7 @@ fm_lint_worker_stop() {

fm_lint_worker() { # <manifest> <output-dir> <shard-index>
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
Expand All @@ -71,7 +75,11 @@ fm_lint_worker() { # <manifest> <output-dir> <shard-index>
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=
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -133,6 +147,11 @@ while [ "$#" -gt 0 ]; do
TELEMETRY=${1#*=}
shift
;;
--fast)
FAST=1
ANALYSIS_MODE=fast
shift
;;
--list-files)
LIST_FILES=1
shift
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -379,18 +408,18 @@ fm_lint_run_worker() { # <worker-index>
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
}
Expand Down Expand Up @@ -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"
Expand Down
126 changes: 124 additions & 2 deletions tests/fm-lint.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
[ "$(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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading