diff --git a/.github/workflows/runtime-policy.yml b/.github/workflows/runtime-policy.yml index 8110273..54b4103 100644 --- a/.github/workflows/runtime-policy.yml +++ b/.github/workflows/runtime-policy.yml @@ -1,10 +1,15 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. +# SPDX-License-Identifier: MPL-2.0 # Runtime and package-manager policy check. # -# Authority: hyperpolymath/standards LANGUAGE-POLICY.adoc §1. -# Ordering: Bun (1st) > Deno (2nd) > pnpm (3rd) > npm (last resort). +# Authority: hyperpolymath/standards LANGUAGE-POLICY.adoc §1.3–§1.4 (owner ruling +# 2026-10-14). Ordering: Bun (1st) > Deno (2nd) > pnpm (3rd) > npm (last resort). +# +# One manifest, every runtime: package.json is the only manifest, and +# `js-runtimes regen` writes bun.lock, deno.lock and pnpm-lock.yaml from it. Those +# three locks are ONE dependency graph locked three ways, not three toolchains; +# governance's "JS runtimes" jobs check each one against package.json. # # REPLACES npm-bun-blocker.yml, which failed any build carrying `bun.lockb` with # the message "npm/bun artifacts detected. Use Deno instead." That gate blocked @@ -12,11 +17,14 @@ # 55 repositories. # # What this fails on, deliberately: -# MIXED TOOLCHAINS -- two different package managers' lockfiles in one repo. -# That is real, actionable drift: two dependency graphs that can disagree. +# ANY tracked package-lock.json. npm is LAST but PERMITTED as a runtime, and +# it keeps no lock: package-lock.json is refused (standards#67). Next to the +# js-runtimes locks it is also MIXED TOOLCHAINS, two dependency graphs that +# can disagree. # What it does NOT fail on: -# Using bun, deno, pnpm or npm. npm is LAST but PERMITTED; the check reports -# the tier in use so drift is visible without blocking legitimate work. +# The js-runtimes locks together, or any one of them alone; a deno.json is a +# notice (Deno is tier 2, LANGUAGE-POLICY §1.3). The check reports the tiers +# in use so drift is visible without blocking legitimate work. name: Runtime Policy on: push: @@ -33,7 +41,7 @@ permissions: jobs: runtime-policy: name: Runtime Policy - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read @@ -44,30 +52,38 @@ jobs: run: | set -euo pipefail - bun=0; deno=0; pnpm=0; npm=0 + bun=0; deno=0; pnpm=0; npm=0; deno_cfg=0 [ -f bun.lockb ] || [ -f bun.lock ] && bun=1 || true - [ -f deno.lock ] || [ -f deno.json ] || [ -f deno.jsonc ] && deno=1 || true + [ -f deno.lock ] && deno=1 || true + [ -f deno.json ] || [ -f deno.jsonc ] && deno_cfg=1 || true [ -f pnpm-lock.yaml ] && pnpm=1 || true [ -f package-lock.json ] && npm=1 || true - total=$((bun + deno + pnpm + npm)) + # bun, deno and pnpm locks are the one graph js-runtimes regen writes. + regen=$((bun + deno + pnpm)) - if [ "$total" -eq 0 ]; then - echo "::notice::No JS/TS package manager in use — nothing to check." + if [ $((regen + npm + deno_cfg)) -eq 0 ]; then + echo "::notice::No JS package manager in use — nothing to check." exit 0 fi - # Report the tier actually in use (LANGUAGE-POLICY.adoc §1). + # Report the tiers actually in use (LANGUAGE-POLICY.adoc §1). [ "$bun" -eq 1 ] && echo "Bun — tier 1 (preferred)" - [ "$deno" -eq 1 ] && echo "Deno — tier 2 (accepted; existing projects are grandfathered)" + [ "$deno" -eq 1 ] && echo "Deno — tier 2" + [ "$deno_cfg" -eq 1 ] && echo "::notice::A deno.json is authored for Deno (tier 2, permitted; notice only, LANGUAGE-POLICY.adoc §1.3). Keep scripts in package.json so Bun runs them too." [ "$pnpm" -eq 1 ] && echo "pnpm — tier 3" - [ "$npm" -eq 1 ] && echo "::warning::npm lockfile present. npm is tier 4, the last resort — permitted, never preferred. See LANGUAGE-POLICY.adoc §1." + [ "$npm" -eq 1 ] && echo "npm — tier 4 (last resort)" - if [ "$total" -gt 1 ]; then - echo "::error::Mixed toolchains: $total package managers have lockfiles in this repository." - echo "Two dependency graphs that can disagree is real drift. Pick one — preferring the" - echo "highest tier present — and delete the others' lockfiles." + if [ "$npm" -eq 1 ]; then + if [ "$regen" -gt 0 ]; then + echo "::error::Mixed toolchains: package-lock.json sits next to the js-runtimes locks." + echo "Two dependency graphs that can disagree is real drift." + else + echo "::error::package-lock.json is refused (standards#67). npm is the last resort, permitted but never preferred, and it keeps no lock." + fi + echo "Delete package-lock.json and let 'js-runtimes regen' write bun.lock, deno.lock" + echo "and pnpm-lock.yaml from package.json. See LANGUAGE-POLICY.adoc §1.4." exit 1 fi - echo "✅ Single package manager in use." + echo "✅ One dependency graph in use." diff --git a/src/diagnostics/connectivity.sh b/src/diagnostics/connectivity.sh index 6d2523b..fb8f9f3 100644 --- a/src/diagnostics/connectivity.sh +++ b/src/diagnostics/connectivity.sh @@ -2,9 +2,9 @@ # Connectivity diagnostics module # Source utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../utils/logging.sh" -source "${SCRIPT_DIR}/../utils/system.sh" +NR_UTILS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../utils" && pwd)" +source "${NR_UTILS_DIR}/logging.sh" +source "${NR_UTILS_DIR}/system.sh" # Test basic connectivity test_basic_connectivity() { @@ -167,7 +167,7 @@ test_mtu() { # Subtract 28 bytes for IP + ICMP header local packet_size=$((size - 28)) - if ping -c 1 -W 2 -M do -s ${packet_size} 8.8.8.8 >/dev/null 2>&1; then + if ping -c 1 -W 2 -M 'do' -s ${packet_size} 8.8.8.8 >/dev/null 2>&1; then log_success " Packet size ${size} bytes: OK" break else diff --git a/src/diagnostics/dns.sh b/src/diagnostics/dns.sh index 51c5f09..3b23a7e 100644 --- a/src/diagnostics/dns.sh +++ b/src/diagnostics/dns.sh @@ -2,9 +2,9 @@ # DNS diagnostics module # Source utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../utils/logging.sh" -source "${SCRIPT_DIR}/../utils/system.sh" +NR_UTILS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../utils" && pwd)" +source "${NR_UTILS_DIR}/logging.sh" +source "${NR_UTILS_DIR}/system.sh" # Check DNS configuration check_dns_config() { diff --git a/src/diagnostics/firewall.sh b/src/diagnostics/firewall.sh index afbbf9a..4cef14e 100644 --- a/src/diagnostics/firewall.sh +++ b/src/diagnostics/firewall.sh @@ -2,10 +2,10 @@ # Firewall diagnostics module # Source utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../utils/logging.sh" -source "${SCRIPT_DIR}/../utils/system.sh" -source "${SCRIPT_DIR}/../utils/privileges.sh" +NR_UTILS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../utils" && pwd)" +source "${NR_UTILS_DIR}/logging.sh" +source "${NR_UTILS_DIR}/system.sh" +source "${NR_UTILS_DIR}/privileges.sh" # Check iptables rules check_iptables() { diff --git a/src/diagnostics/interfaces.sh b/src/diagnostics/interfaces.sh index dd429be..788122d 100644 --- a/src/diagnostics/interfaces.sh +++ b/src/diagnostics/interfaces.sh @@ -2,9 +2,9 @@ # Network interface diagnostics module # Source utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../utils/logging.sh" -source "${SCRIPT_DIR}/../utils/system.sh" +NR_UTILS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../utils" && pwd)" +source "${NR_UTILS_DIR}/logging.sh" +source "${NR_UTILS_DIR}/system.sh" # Check network interfaces check_interfaces() { @@ -187,7 +187,7 @@ check_interface_conflicts() { ip=$(get_interface_ip "${iface}") if [[ -n "${ip}" ]]; then - if [[ -n "${ip_map[${ip}]}" ]]; then + if [[ -n "${ip_map[${ip}]:-}" ]]; then log_error " Duplicate IP ${ip} on ${iface} and ${ip_map[${ip}]}" issues=$((issues + 1)) else diff --git a/src/diagnostics/networkmanager.sh b/src/diagnostics/networkmanager.sh index 89b9f7c..3ed6c5d 100644 --- a/src/diagnostics/networkmanager.sh +++ b/src/diagnostics/networkmanager.sh @@ -2,9 +2,9 @@ # NetworkManager diagnostics module # Source utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../utils/logging.sh" -source "${SCRIPT_DIR}/../utils/system.sh" +NR_UTILS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../utils" && pwd)" +source "${NR_UTILS_DIR}/logging.sh" +source "${NR_UTILS_DIR}/system.sh" # Check NetworkManager status check_nm_status() { diff --git a/src/diagnostics/routing.sh b/src/diagnostics/routing.sh index 59ce834..b4cac2f 100644 --- a/src/diagnostics/routing.sh +++ b/src/diagnostics/routing.sh @@ -2,9 +2,9 @@ # Routing diagnostics module # Source utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../utils/logging.sh" -source "${SCRIPT_DIR}/../utils/system.sh" +NR_UTILS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../utils" && pwd)" +source "${NR_UTILS_DIR}/logging.sh" +source "${NR_UTILS_DIR}/system.sh" # Check routing table check_routing_table() { diff --git a/src/main.sh b/src/main.sh index a971913..20fce16 100755 --- a/src/main.sh +++ b/src/main.sh @@ -5,7 +5,8 @@ set -euo pipefail -# Script directory +# Script directory. The modules sourced below resolve their own directory +# into NR_UTILS_DIR / NR_REPAIRS_DIR and must not assign SCRIPT_DIR. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Version @@ -123,29 +124,23 @@ run_all_diagnostics() { log_info "Network Manager: $(detect_network_manager)" echo "" - # Run diagnostics - diagnose_interfaces - total_issues=$((total_issues + $?)) + # Diagnostic failures report issues; collect them without triggering errexit. + diagnose_interfaces || total_issues=$((total_issues + $?)) echo "" - diagnose_routing - total_issues=$((total_issues + $?)) + diagnose_routing || total_issues=$((total_issues + $?)) echo "" - diagnose_dns - total_issues=$((total_issues + $?)) + diagnose_dns || total_issues=$((total_issues + $?)) echo "" - diagnose_connectivity - total_issues=$((total_issues + $?)) + diagnose_connectivity || total_issues=$((total_issues + $?)) echo "" - diagnose_firewall - total_issues=$((total_issues + $?)) + diagnose_firewall || total_issues=$((total_issues + $?)) echo "" - diagnose_networkmanager - total_issues=$((total_issues + $?)) + diagnose_networkmanager || total_issues=$((total_issues + $?)) echo "" # Summary diff --git a/src/repairs/dns.sh b/src/repairs/dns.sh index 174686d..1203142 100644 --- a/src/repairs/dns.sh +++ b/src/repairs/dns.sh @@ -2,11 +2,11 @@ # DNS repair procedures # Source utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../utils/logging.sh" -source "${SCRIPT_DIR}/../utils/system.sh" -source "${SCRIPT_DIR}/../utils/privileges.sh" -source "${SCRIPT_DIR}/../utils/backup.sh" +NR_UTILS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../utils" && pwd)" +source "${NR_UTILS_DIR}/logging.sh" +source "${NR_UTILS_DIR}/system.sh" +source "${NR_UTILS_DIR}/privileges.sh" +source "${NR_UTILS_DIR}/backup.sh" # Repair DNS configuration repair_dns_config() { diff --git a/src/repairs/interfaces.sh b/src/repairs/interfaces.sh index 946e312..c7e50df 100644 --- a/src/repairs/interfaces.sh +++ b/src/repairs/interfaces.sh @@ -2,10 +2,10 @@ # Network interface repair procedures # Source utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../utils/logging.sh" -source "${SCRIPT_DIR}/../utils/system.sh" -source "${SCRIPT_DIR}/../utils/privileges.sh" +NR_UTILS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../utils" && pwd)" +source "${NR_UTILS_DIR}/logging.sh" +source "${NR_UTILS_DIR}/system.sh" +source "${NR_UTILS_DIR}/privileges.sh" # Bring up network interface interface_up() { diff --git a/src/repairs/networkmanager.sh b/src/repairs/networkmanager.sh index af850fe..55a982c 100644 --- a/src/repairs/networkmanager.sh +++ b/src/repairs/networkmanager.sh @@ -2,10 +2,10 @@ # NetworkManager repair procedures # Source utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../utils/logging.sh" -source "${SCRIPT_DIR}/../utils/system.sh" -source "${SCRIPT_DIR}/../utils/privileges.sh" +NR_UTILS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../utils" && pwd)" +source "${NR_UTILS_DIR}/logging.sh" +source "${NR_UTILS_DIR}/system.sh" +source "${NR_UTILS_DIR}/privileges.sh" # Restart NetworkManager restart_networkmanager() { diff --git a/src/repairs/routing.sh b/src/repairs/routing.sh index deee95d..ac553d7 100644 --- a/src/repairs/routing.sh +++ b/src/repairs/routing.sh @@ -2,10 +2,13 @@ # Routing repair procedures # Source utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../utils/logging.sh" -source "${SCRIPT_DIR}/../utils/system.sh" -source "${SCRIPT_DIR}/../utils/privileges.sh" +NR_UTILS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../utils" && pwd)" +# Resolved now: the functions below source interfaces.sh after other +# modules have loaded. +NR_REPAIRS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${NR_UTILS_DIR}/logging.sh" +source "${NR_UTILS_DIR}/system.sh" +source "${NR_UTILS_DIR}/privileges.sh" # Add default route add_default_route() { @@ -89,7 +92,16 @@ remove_duplicate_routes() { return 0 } -# Repair default route +# Remove duplicate default routes if the initial route query succeeds; +# otherwise try renewing DHCP on the primary interface. +# Returns 0 when the initial `ip route show default` succeeds, even if its +# output is empty or the gateway is unreachable. The DHCP fallback returns 0 +# only if renewal and a subsequent route query succeed; otherwise it returns 1, +# including when no primary interface is found. +# May remove routes and release/renew a DHCP lease. Requests root/sudo access +# and exits the shell if access cannot be obtained. With errexit active, +# unguarded failures such as interface lookup or sourcing helpers can terminate +# the caller before a return status is produced. repair_default_route() { log_section "Repair Default Route" @@ -134,7 +146,7 @@ repair_default_route() { log_step "Attempting to obtain gateway via DHCP" # Renew DHCP which should set up routes - source "${SCRIPT_DIR}/interfaces.sh" + source "${NR_REPAIRS_DIR}/interfaces.sh" if renew_dhcp "${primary}"; then sleep 2 @@ -148,7 +160,15 @@ repair_default_route() { return 1 } -# Flush and rebuild routing table +# Flush the main routing table and try to rebuild routes by renewing DHCP on +# the primary interface selected before the flush. May disconnect the network +# and release/renew a DHCP lease; no rollback is performed on failure. +# Returns 0 if DHCP renewal and the subsequent `ip route show default` succeed, +# even if the query output is empty. Returns 1 if no primary interface is found, +# renewal fails or the final query fails. +# Requests root/sudo access and exits the shell if access cannot be obtained. +# With errexit active, unguarded failures such as interface lookup, flushing +# routes or sourcing helpers can terminate the caller before returning. flush_routing_table() { log_section "Flush and Rebuild Routing Table" @@ -171,7 +191,7 @@ flush_routing_table() { log_step "Rebuilding routes via DHCP" # Renew DHCP to rebuild routes - source "${SCRIPT_DIR}/interfaces.sh" + source "${NR_REPAIRS_DIR}/interfaces.sh" if renew_dhcp "${primary}"; then sleep 3 diff --git a/src/utils/logging.sh b/src/utils/logging.sh index 7f5b030..73efe5f 100644 --- a/src/utils/logging.sh +++ b/src/utils/logging.sh @@ -2,8 +2,8 @@ # Logging utilities for network repair tool # Source color utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/colors.sh" +NR_UTILS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${NR_UTILS_DIR}/colors.sh" # Log levels declare -gA LOG_LEVELS=( diff --git a/src/utils/privileges.sh b/src/utils/privileges.sh index 4ca6a64..3f28093 100644 --- a/src/utils/privileges.sh +++ b/src/utils/privileges.sh @@ -43,7 +43,7 @@ require_root() { SUDO_KEEPER_PID=$! # Kill sudo keeper on exit - trap "kill ${SUDO_KEEPER_PID} 2>/dev/null || true" EXIT + trap 'kill "${SUDO_KEEPER_PID}" 2>/dev/null || true' EXIT } # Run command with appropriate privileges diff --git a/tests/run-tests.sh b/tests/run-tests.sh index cb2ff0c..c9a2ec7 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -252,9 +252,11 @@ test_safe_mode() { print_result "FAIL" "Repairs should be blocked in safe mode" fi - # Test that diagnose works without flags - if "${PROJECT_DIR}/network-repair" diagnose >/dev/null 2>&1 || \ - "${PROJECT_DIR}/network-repair" diagnose 2>&1 | grep -q "Diagnostic"; then + # Diagnose returns 1 for network issues, but must complete in safe mode. + # Capture once so pipefail and grep closing early cannot mask the result. + local diagnose_status=0 + output=$("${PROJECT_DIR}/network-repair" diagnose 2>&1) || diagnose_status=$? + if [[ ${diagnose_status} -le 1 && "${output}" == *"=== Diagnostic Summary ==="* ]]; then print_result "PASS" "Diagnose works in safe mode" else print_result "FAIL" "Diagnose should work in safe mode" diff --git a/tests/test-utils.sh b/tests/test-utils.sh index 98cd1fe..4b439e4 100755 --- a/tests/test-utils.sh +++ b/tests/test-utils.sh @@ -15,10 +15,20 @@ source "${PROJECT_DIR}/src/utils/system.sh" test_colors() { echo "Testing color utilities..." - # Test that color variables are defined - [[ -n "${COLOR_RED}" ]] || { echo "COLOR_RED not defined"; exit 1; } - [[ -n "${COLOR_GREEN}" ]] || { echo "COLOR_GREEN not defined"; exit 1; } - [[ -n "${COLOR_RESET}" ]] || { echo "COLOR_RESET not defined"; exit 1; } + # Colors are defined but may be empty when stdout is not a terminal. + [[ "${COLOR_RED+x}" == x ]] || { echo "COLOR_RED not defined"; exit 1; } + [[ "${COLOR_GREEN+x}" == x ]] || { echo "COLOR_GREEN not defined"; exit 1; } + [[ "${COLOR_RESET+x}" == x ]] || { echo "COLOR_RESET not defined"; exit 1; } + + # Explicitly disabled colors must stay empty regardless of terminal support. + ( + USE_COLORS=never + source "${PROJECT_DIR}/src/utils/colors.sh" + [[ -z "${COLOR_RED}${COLOR_GREEN}${COLOR_RESET}" ]] || { + echo "Colors should be empty when disabled" + exit 1 + } + ) echo "✓ Color utilities work" } @@ -60,6 +70,26 @@ test_system() { echo "✓ System utilities work" } +# Test that sourcing a module leaves the caller's SCRIPT_DIR alone. Each module +# loads in a fresh shell started from an unrelated directory. +test_module_loading() { + echo "Testing module loading..." + + local module + for module in "${PROJECT_DIR}/src/utils/logging.sh" \ + "${PROJECT_DIR}"/src/diagnostics/*.sh \ + "${PROJECT_DIR}"/src/repairs/*.sh; do + bash -euc ' + cd / + SCRIPT_DIR=caller-directory + source "$1" + [[ "${SCRIPT_DIR}" == caller-directory ]] + ' _ "${module}" || { echo "Sourcing ${module} changed SCRIPT_DIR"; exit 1; } + done + + echo "✓ Modules leave the caller's SCRIPT_DIR alone" +} + # Run all tests main() { echo "Running utility function tests..." @@ -68,6 +98,7 @@ main() { test_colors test_logging test_system + test_module_loading echo "" echo "All utility tests passed!"