diff --git a/CHANGELOG.md b/CHANGELOG.md index cb2d558cf..484df2ec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.17.1] — 2026-08-23 — alias-proof shipped pipelines + +### Fixed + +- **A user alias on a coreutil could hijack shipped pipelines.** Dispatchers run in the user's + *interactive* shell, where aliases are live and expand at parse time. On a machine with + `tr='track-activity report'`, every `... | tr -d ' '` in `lib/` ran that command instead, and its + output landed in the variable where a count belonged — `teach deploy --dry-run` printed + `Would deploy Monthly Terminal Report (2026-08):` instead of `Would deploy 675 files:`. All 101 + pipeline sites across 34 files now use `command tr`, which bypasses aliases and functions. + Guarded by `tests/test-alias-shadowing.zsh`, including a negative control so the test cannot pass + vacuously if the environment stops reproducing the hijack. + ## [7.17.0] — 2026-08-23 — teach deploy safety + CI coverage ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 72879e65a..1526d2ca6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ This file provides guidance to Claude Code when working with code in this reposi **flow-cli** - Pure ZSH plugin for ADHD-optimized workflow management. Zero dependencies. Standalone (works without Oh-My-Zsh or any plugin manager). - **Architecture:** Pure ZSH plugin (no Node.js runtime required) -- **Current Version:** v7.17.0 +- **Current Version:** v7.17.1 - **Install:** Homebrew (recommended), or any plugin manager - **Source:** `source /opt/homebrew/opt/flow-cli/flow.plugin.zsh` (via Homebrew) - **Optional:** Atlas integration for enhanced state management @@ -217,7 +217,7 @@ export FLOW_FORCE_DISPATCHER_OBS=1 # Force-keep one dispatcher (FLOW_F ## Current Status -**Version:** v7.17.0 | **Tests:** 12000+ (75/75 suite, 1 skipped — tool absence) | **Docs:** https://Data-Wise.github.io/flow-cli/ +**Version:** v7.17.1 | **Tests:** 12000+ (75/75 suite, 1 skipped — tool absence) | **Docs:** https://Data-Wise.github.io/flow-cli/ --- diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3ea9e902f..5a3b2a374 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/), and this pro ## [Unreleased] +## [7.17.1] — 2026-08-23 — alias-proof shipped pipelines + +### Fixed + +- **A user alias on a coreutil could hijack shipped pipelines.** Dispatchers run in the user's + *interactive* shell, where aliases are live and expand at parse time. On a machine with + `tr='track-activity report'`, every `... | tr -d ' '` in `lib/` ran that command instead, and its + output landed in the variable where a count belonged — `teach deploy --dry-run` printed + `Would deploy Monthly Terminal Report (2026-08):` instead of `Would deploy 675 files:`. All 101 + pipeline sites across 34 files now use `command tr`, which bypasses aliases and functions. + Guarded by `tests/test-alias-shadowing.zsh`, including a negative control so the test cannot pass + vacuously if the environment stops reproducing the hijack. + ## [7.17.0] — 2026-08-23 — teach deploy safety + CI coverage ### Added diff --git a/docs/index.md b/docs/index.md index a69c2b608..cfb048413 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,10 +29,11 @@ tags: **New here?** `setup` walks you through configuration interactively, or run `tutorial` for 12 hands-on lessons at your own pace — both are guided, no docs required to start. -!!! success "🎉 What's New in v7.17.0" +!!! success "🎉 What's New in v7.17.1" **`teach deploy --dry-run` is genuinely read-only** — it no longer aborts in CI mode, no longer prompts to commit (a pty wrapper used to auto-accept that and create a real commit), and it now names the uncommitted files its plan excludes. **`teach deploy --direct` merges with `--no-ff`** — every deploy is one revertable commit again, so `teach deploy --rollback` can undo a multi-commit deploy as a unit. **`em undo`** — single-step undo of the last `em star`/`flag`/`unflag`/`move`, plus `em move --recent` for quick folder re-picks. + **Shipped pipelines are alias-proof** — a user alias on a coreutil (e.g. `tr`) could hijack internal pipelines and replace counts with unrelated output; all 101 sites now use `command tr`. Full details → [Changelog](CHANGELOG.md). --- @@ -326,4 +327,4 @@ ref # Quick-reference card (forgot the syntax? this is faster than --- -**v7.17.0** · Pure ZSH · Zero Dependencies · MIT License +**v7.17.1** · Pure ZSH · Zero Dependencies · MIT License diff --git a/flow.plugin.zsh b/flow.plugin.zsh index 8399e92f6..68d1fbe0a 100644 --- a/flow.plugin.zsh +++ b/flow.plugin.zsh @@ -187,7 +187,7 @@ _flow_plugin_init # Export loaded marker export FLOW_PLUGIN_LOADED=1 -export FLOW_VERSION="7.17.0" +export FLOW_VERSION="7.17.1" # Register exit hook for plugin cleanup add-zsh-hook zshexit _flow_plugin_cleanup diff --git a/lib/atlas-bridge.zsh b/lib/atlas-bridge.zsh index 3dcb0f6bc..bc3866090 100644 --- a/lib/atlas-bridge.zsh +++ b/lib/atlas-bridge.zsh @@ -850,7 +850,7 @@ _flow_where_fallback() { done if [[ -n "$status_dir" ]]; then - local proj_status=$(_flow_status_field "$status_dir" "Status" | tr -d ' ') + local proj_status=$(_flow_status_field "$status_dir" "Status" | command tr -d ' ') local focus=$(_flow_status_field "$status_dir" "Focus") [[ -n "$proj_status" ]] && echo " Status: $proj_status" diff --git a/lib/backup-helpers.zsh b/lib/backup-helpers.zsh index 0a252167e..df30b61ba 100644 --- a/lib/backup-helpers.zsh +++ b/lib/backup-helpers.zsh @@ -344,7 +344,7 @@ _teach_count_backups() { return 0 fi - find "$backup_dir" -maxdepth 1 -type d -name "*.20*" 2>/dev/null | wc -l | tr -d ' ' + find "$backup_dir" -maxdepth 1 -type d -name "*.20*" 2>/dev/null | wc -l | command tr -d ' ' } # ============================================================================= @@ -603,7 +603,7 @@ _teach_confirm_delete() { fi # Count files - local file_count=$(find "$backup_path" -type f 2>/dev/null | wc -l | tr -d ' ') + local file_count=$(find "$backup_path" -type f 2>/dev/null | wc -l | command tr -d ' ') echo " Files: $file_count" echo "" @@ -669,7 +669,7 @@ _teach_preview_cleanup() { echo "" local backups=$(_teach_list_backups "$content_path") - local backup_count=$(echo "$backups" | wc -l | tr -d ' ') + local backup_count=$(echo "$backups" | wc -l | command tr -d ' ') if [[ "$backup_count" -eq 0 ]]; then echo " ${FLOW_COLORS[dim]}No backups to clean${FLOW_COLORS[reset]}" diff --git a/lib/cache-analysis.zsh b/lib/cache-analysis.zsh index 380b798bf..9052b9626 100644 --- a/lib/cache-analysis.zsh +++ b/lib/cache-analysis.zsh @@ -40,7 +40,7 @@ _analyze_cache_size() { fi # Count files - local file_count=$(find "$cache_dir" -type f 2>/dev/null | wc -l | tr -d ' ') + local file_count=$(find "$cache_dir" -type f 2>/dev/null | wc -l | command tr -d ' ') # Get size in bytes (portable: use du -sk for KB, then convert) local size_kb=0 @@ -109,7 +109,7 @@ _analyze_cache_by_directory() { local dir_size_human=$(_cache_format_bytes "$dir_size_bytes") # Count files - local dir_files=$(find "$subdir" -type f 2>/dev/null | wc -l | tr -d ' ') + local dir_files=$(find "$subdir" -type f 2>/dev/null | wc -l | command tr -d ' ') # Calculate percentage local percentage=0 diff --git a/lib/cache-helpers.zsh b/lib/cache-helpers.zsh index 4652b7e06..fae715d03 100644 --- a/lib/cache-helpers.zsh +++ b/lib/cache-helpers.zsh @@ -53,7 +53,7 @@ _cache_status() { fi # Count files - local file_count=$(find "$freeze_dir" -type f 2>/dev/null | wc -l | tr -d ' ') + local file_count=$(find "$freeze_dir" -type f 2>/dev/null | wc -l | command tr -d ' ') # Get size local size_bytes=0 @@ -587,7 +587,7 @@ _cache_analyze() { while IFS= read -r subdir; do local subdir_name=$(basename "$subdir") local subdir_size=$(du -sh "$subdir" 2>/dev/null | awk '{print $1}') - local subdir_files=$(find "$subdir" -type f 2>/dev/null | wc -l | tr -d ' ') + local subdir_files=$(find "$subdir" -type f 2>/dev/null | wc -l | command tr -d ' ') printf "${FLOW_COLORS[header]}│${FLOW_COLORS[reset]} %-30s %8s (%s files)\n" \ "$subdir_name" "$subdir_size" "$subdir_files" @@ -705,7 +705,7 @@ _cache_clean() { dirs_to_delete+=("_site") if command -v du &>/dev/null; then local site_size=$(du -sh "$site_dir" 2>/dev/null | awk '{print $1}') - local site_files=$(find "$site_dir" -type f 2>/dev/null | wc -l | tr -d ' ') + local site_files=$(find "$site_dir" -type f 2>/dev/null | wc -l | command tr -d ' ') total_files=$((total_files + site_files)) fi fi diff --git a/lib/concept-extraction.zsh b/lib/concept-extraction.zsh index 3eea27b6b..42c3c1745 100644 --- a/lib/concept-extraction.zsh +++ b/lib/concept-extraction.zsh @@ -42,7 +42,7 @@ _extract_concepts_from_frontmatter() { concepts_json=$(echo "$frontmatter" | yq eval -o json '.concepts // ""' - 2>/dev/null) # Return empty if concepts is null, empty string, empty array, or array with only empty strings local trimmed - trimmed=$(echo "$concepts_json" | tr -d '\n' | xargs) + trimmed=$(echo "$concepts_json" | command tr -d '\n' | xargs) # Check if trimmed result is empty, "[]", or "[""]" if [[ -z "$trimmed" || "$trimmed" == "[]" || "$trimmed" == "[\"\"]" ]]; then echo -n "" @@ -74,12 +74,12 @@ _parse_introduced_concepts() { if [[ "$concepts_json" =~ '^\[.*\]$' ]]; then # Array of objects format: extract .id from each object local introduced - introduced=$(echo "$concepts_json" | yq eval -o json '.[] | .id' - 2>/dev/null | sed 's/"//g' | tr '\n' ' ' | xargs) + introduced=$(echo "$concepts_json" | yq eval -o json '.[] | .id' - 2>/dev/null | sed 's/"//g' | command tr '\n' ' ' | xargs) echo "$introduced" else # Simple format: extract .introduces array local introduced - introduced=$(echo "$concepts_json" | yq eval -o json '.introduces // [] | .[]' - 2>/dev/null | sed 's/"//g' | tr '\n' ' ' | xargs) + introduced=$(echo "$concepts_json" | yq eval -o json '.introduces // [] | .[]' - 2>/dev/null | sed 's/"//g' | command tr '\n' ' ' | xargs) echo "$introduced" fi } @@ -102,12 +102,12 @@ _parse_required_concepts() { if [[ "$concepts_json" =~ '^\[.*\]$' ]]; then # Array of objects format: extract all .prerequisites arrays and flatten local required - required=$(echo "$concepts_json" | yq eval -o json '.[] | .prerequisites // [] | .[]' - 2>/dev/null | sed 's/"//g' | sort -u | tr '\n' ' ' | xargs) + required=$(echo "$concepts_json" | yq eval -o json '.[] | .prerequisites // [] | .[]' - 2>/dev/null | sed 's/"//g' | sort -u | command tr '\n' ' ' | xargs) echo "$required" else # Simple format: extract .requires array local required - required=$(echo "$concepts_json" | yq eval -o json '.requires // [] | .[]' - 2>/dev/null | sed 's/"//g' | tr '\n' ' ' | xargs) + required=$(echo "$concepts_json" | yq eval -o json '.requires // [] | .[]' - 2>/dev/null | sed 's/"//g' | command tr '\n' ' ' | xargs) echo "$required" fi } @@ -231,7 +231,7 @@ _extract_concepts_from_frontmatter() { return 0 fi concepts_json=$(echo "$frontmatter" | yq eval -o json '.concepts // ""' - 2>/dev/null) - trimmed=$(echo "$concepts_json" | tr -d '\n' | xargs) + trimmed=$(echo "$concepts_json" | command tr -d '\n' | xargs) if [[ -z "$trimmed" || "$trimmed" == "[]" || "$trimmed" == "[\"\"]" ]]; then echo -n "" else @@ -248,9 +248,9 @@ _parse_introduced_concepts() { fi # Check if array format: [{id: "...", ...}] if [[ "$concepts_json" =~ '^\[.*\]$' ]]; then - introduced=$(echo "$concepts_json" | yq eval -o json '.[] | .id' - 2>/dev/null | sed 's/"//g' | tr '\n' ' ' | xargs) + introduced=$(echo "$concepts_json" | yq eval -o json '.[] | .id' - 2>/dev/null | sed 's/"//g' | command tr '\n' ' ' | xargs) else - introduced=$(echo "$concepts_json" | yq eval -o json '.introduces // [] | .[]' - 2>/dev/null | sed 's/"//g' | tr '\n' ' ' | xargs) + introduced=$(echo "$concepts_json" | yq eval -o json '.introduces // [] | .[]' - 2>/dev/null | sed 's/"//g' | command tr '\n' ' ' | xargs) fi echo "$introduced" } @@ -264,9 +264,9 @@ _parse_required_concepts() { fi # Check if array format: [{prerequisites: [...], ...}] if [[ "$concepts_json" =~ '^\[.*\]$' ]]; then - required=$(echo "$concepts_json" | yq eval -o json '.[] | .prerequisites // [] | .[]' - 2>/dev/null | sed 's/"//g' | sort -u | tr '\n' ' ' | xargs) + required=$(echo "$concepts_json" | yq eval -o json '.[] | .prerequisites // [] | .[]' - 2>/dev/null | sed 's/"//g' | sort -u | command tr '\n' ' ' | xargs) else - required=$(echo "$concepts_json" | yq eval -o json '.requires // [] | .[]' - 2>/dev/null | sed 's/"//g' | tr '\n' ' ' | xargs) + required=$(echo "$concepts_json" | yq eval -o json '.requires // [] | .[]' - 2>/dev/null | sed 's/"//g' | command tr '\n' ' ' | xargs) fi echo "$required" } diff --git a/lib/core.zsh b/lib/core.zsh index 8cefc0d54..63551893e 100644 --- a/lib/core.zsh +++ b/lib/core.zsh @@ -869,7 +869,7 @@ _flow_tty_handoff_cleanup() { # # Example: # _flow_status_field "$project_path" "Focus" -# _flow_status_field "$project_path" "Progress" | tr -d '%' # site strips % +# _flow_status_field "$project_path" "Progress" | command tr -d '%' # site strips % # # Notes: # - Handles both the "## Field:" markdown dialect and the plain "field:" diff --git a/lib/date-parser.zsh b/lib/date-parser.zsh index 6f258e766..41b3015a4 100644 --- a/lib/date-parser.zsh +++ b/lib/date-parser.zsh @@ -84,7 +84,7 @@ _date_parse_quarto_yaml() { # Extract date value from YAML frontmatter local date_value - date_value=$(yq eval ".${field} // \"\"" "$file" 2>/dev/null | tr -d '"') + date_value=$(yq eval ".${field} // \"\"" "$file" 2>/dev/null | command tr -d '"') # Return empty if not found or null if [[ -z "$date_value" || "$date_value" == "null" ]]; then @@ -601,7 +601,7 @@ _date_load_config() { if [[ -n "$exam_date" && "$exam_date" != "null" ]]; then # Normalize exam name to key (lowercase, spaces to underscores) - local exam_key=$(echo "$exam_name" | tr '[:upper:]' '[:lower:]' | tr ' ' '_') + local exam_key=$(echo "$exam_name" | command tr '[:upper:]' '[:lower:]' | command tr ' ' '_') printf 'CONFIG_DATES[exam_%s]="%s"\n' "$exam_key" "$exam_date" fi done @@ -650,7 +650,7 @@ _date_load_config() { holiday_date=$(yq eval ".semester_info.holidays[$i].date" "$config_file" 2>/dev/null) if [[ -n "$holiday_date" && "$holiday_date" != "null" ]]; then - local holiday_key=$(echo "$holiday_name" | tr '[:upper:]' '[:lower:]' | tr ' ' '_') + local holiday_key=$(echo "$holiday_name" | command tr '[:upper:]' '[:lower:]' | command tr ' ' '_') printf 'CONFIG_DATES[holiday_%s]="%s"\n' "$holiday_key" "$holiday_date" fi done diff --git a/lib/deploy-rollback-helpers.zsh b/lib/deploy-rollback-helpers.zsh index e66eed78e..6392b2822 100644 --- a/lib/deploy-rollback-helpers.zsh +++ b/lib/deploy-rollback-helpers.zsh @@ -201,7 +201,7 @@ _deploy_perform_rollback() { # Record rollback in deploy history if typeset -f _deploy_history_append >/dev/null 2>&1; then local file_count=0 - file_count=$(git diff --name-only "${full_hash}^" "$full_hash" 2>/dev/null | wc -l | tr -d ' ') + file_count=$(git diff --name-only "${full_hash}^" "$full_hash" 2>/dev/null | wc -l | command tr -d ' ') _deploy_history_append \ "rollback" \ "$commit_after" \ diff --git a/lib/dispatchers/dot-doctor-integration.zsh b/lib/dispatchers/dot-doctor-integration.zsh index bbfdad7dd..ee75f9b65 100644 --- a/lib/dispatchers/dot-doctor-integration.zsh +++ b/lib/dispatchers/dot-doctor-integration.zsh @@ -36,7 +36,7 @@ _dots_doctor_integration() { # Check for uncommitted changes local status_output=$(chezmoi status 2>/dev/null) if [[ -n "$status_output" ]]; then - local count=$(echo "$status_output" | wc -l | tr -d ' ') + local count=$(echo "$status_output" | wc -l | command tr -d ' ') _flow_log_warning "$count uncommitted changes" else _flow_log_success "No uncommitted changes" diff --git a/lib/dispatchers/dots-dispatcher.zsh b/lib/dispatchers/dots-dispatcher.zsh index 1f122b728..fb08eeea5 100644 --- a/lib/dispatchers/dots-dispatcher.zsh +++ b/lib/dispatchers/dots-dispatcher.zsh @@ -300,8 +300,8 @@ _dots_size() { echo "${FLOW_COLORS[header]}│${FLOW_COLORS[reset]} ${FLOW_COLORS[header]}│${FLOW_COLORS[reset]}" # Check for nested .git directories - local git_count=$(find "$chezmoi_dir" -name ".git" -type d -not -path "$chezmoi_dir/.git" 2>/dev/null | wc -l | tr -d ' ') - local git_dotf_count=$(find "$chezmoi_dir" -name "dot_git" -type d 2>/dev/null | wc -l | tr -d ' ') + local git_count=$(find "$chezmoi_dir" -name ".git" -type d -not -path "$chezmoi_dir/.git" 2>/dev/null | wc -l | command tr -d ' ') + local git_dotf_count=$(find "$chezmoi_dir" -name "dot_git" -type d 2>/dev/null | wc -l | command tr -d ' ') local total_git_dirs=$((git_count + git_dotf_count)) if (( total_git_dirs > 0 )); then @@ -311,7 +311,7 @@ _dots_size() { fi # Check for large files (>100KB) - local large_count=$(find "$chezmoi_dir" -type f -not -path "$chezmoi_dir/.git/*" -size +100k 2>/dev/null | wc -l | tr -d ' ') + local large_count=$(find "$chezmoi_dir" -type f -not -path "$chezmoi_dir/.git/*" -size +100k 2>/dev/null | wc -l | command tr -d ' ') if (( large_count > 0 )); then echo "${FLOW_COLORS[header]}│${FLOW_COLORS[reset]} ${FLOW_COLORS[warning]}⚠️ Found $large_count files larger than 100KB${FLOW_COLORS[reset]} ${FLOW_COLORS[header]}│${FLOW_COLORS[reset]}" echo "${FLOW_COLORS[header]}│${FLOW_COLORS[reset]} ${FLOW_COLORS[muted]}Review with: dots size | grep '⚠️'${FLOW_COLORS[reset]} ${FLOW_COLORS[header]}│${FLOW_COLORS[reset]}" @@ -648,7 +648,7 @@ _dots_show_file_diff() { echo "" echo "${FLOW_COLORS[header]}─────────────────────────────────────────────────${FLOW_COLORS[reset]}" chezmoi diff "$file" 2>/dev/null | head -20 - local line_count=$(chezmoi diff "$file" 2>/dev/null | wc -l | tr -d ' ') + local line_count=$(chezmoi diff "$file" 2>/dev/null | wc -l | command tr -d ' ') if [[ $line_count -gt 20 ]]; then echo "${FLOW_COLORS[muted]}... (${line_count} lines total, showing first 20)${FLOW_COLORS[reset]}" fi @@ -1268,7 +1268,7 @@ _dots_doctor_check_chezmoi_health() { fi # 5. Check managed file count - local managed_count=$(chezmoi managed 2>/dev/null | wc -l | tr -d ' ') + local managed_count=$(chezmoi managed 2>/dev/null | wc -l | command tr -d ' ') if (( managed_count > 0 )); then echo "${FLOW_COLORS[header]}│${FLOW_COLORS[reset]} ${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} $managed_count files managed ${FLOW_COLORS[header]}│${FLOW_COLORS[reset]}" else @@ -1295,7 +1295,7 @@ _dots_doctor_check_chezmoi_health() { local large_files=$(find "$chezmoi_dir" -type f -not -path "$chezmoi_dir/.git/*" -size +100k 2>/dev/null) local large_count=0 if [[ -n "$large_files" ]]; then - large_count=$(echo "$large_files" | wc -l | tr -d ' ') + large_count=$(echo "$large_files" | wc -l | command tr -d ' ') fi if (( large_count > 0 )); then @@ -1323,10 +1323,10 @@ _dots_doctor_check_chezmoi_health() { local git_count=0 local git_dotf_count=0 if [[ -n "$git_dirs" ]]; then - git_count=$(echo "$git_dirs" | wc -l | tr -d ' ') + git_count=$(echo "$git_dirs" | wc -l | command tr -d ' ') fi if [[ -n "$git_dotf_dirs" ]]; then - git_dotf_count=$(echo "$git_dotf_dirs" | wc -l | tr -d ' ') + git_dotf_count=$(echo "$git_dotf_dirs" | wc -l | command tr -d ' ') fi local total_git=$((git_count + git_dotf_count)) @@ -1573,7 +1573,7 @@ fi for name in "${selected_secrets[@]}"; do # Convert to SCREAMING_SNAKE_CASE - local env_name=$(echo "${name}" | tr '[:lower:]-' '[:upper:]_') + local env_name=$(echo "${name}" | command tr '[:lower:]-' '[:upper:]_') envrc_content+="export ${env_name}=\$(sec get ${name}) " done @@ -1600,7 +1600,7 @@ fi echo "" echo "Environment variables:" for name in "${selected_secrets[@]}"; do - local env_name=$(echo "${name}" | tr '[:lower:]-' '[:upper:]_') + local env_name=$(echo "${name}" | command tr '[:lower:]-' '[:upper:]_') echo " ${FLOW_COLORS[accent]}\$${env_name}${FLOW_COLORS[reset]}" done echo "" diff --git a/lib/dispatchers/email-dispatcher.zsh b/lib/dispatchers/email-dispatcher.zsh index 38cf4c943..98319a047 100644 --- a/lib/dispatchers/email-dispatcher.zsh +++ b/lib/dispatchers/email-dispatcher.zsh @@ -632,7 +632,7 @@ _em_safety_gate() { body_text=$(echo "$draft_content" | awk '/^$/{found=1;next} found{print}') # Check for empty body - if [[ -z "$body_text" || "$(echo "$body_text" | tr -d '[:space:]')" == "" ]]; then + if [[ -z "$body_text" || "$(echo "$body_text" | command tr -d '[:space:]')" == "" ]]; then _flow_log_warning "Empty email body" fi @@ -646,7 +646,7 @@ _em_safety_gate() { if [[ -n "$body_text" ]]; then echo "$body_text" | head -15 local total_lines - total_lines=$(echo "$body_text" | wc -l | tr -d ' ') + total_lines=$(echo "$body_text" | wc -l | command tr -d ' ') (( total_lines > 15 )) && echo -e " ${_C_DIM}... ($((total_lines - 15)) more lines)${_C_NC}" fi echo -e "${_C_DIM}$(printf '%.0s─' {1..60})${_C_NC}" diff --git a/lib/dispatchers/sec-dispatcher.zsh b/lib/dispatchers/sec-dispatcher.zsh index f967d6711..750d7657f 100644 --- a/lib/dispatchers/sec-dispatcher.zsh +++ b/lib/dispatchers/sec-dispatcher.zsh @@ -299,7 +299,7 @@ _sec_status() { echo "" echo "${FLOW_COLORS[info]}Keychain:${FLOW_COLORS[reset]}" echo " • Status: ${FLOW_COLORS[success]}active${FLOW_COLORS[reset]}" - echo " • Location: $(security list-keychains 2>/dev/null | head -1 | tr -d ' \"')" + echo " • Location: $(security list-keychains 2>/dev/null | head -1 | command tr -d ' \"')" # Count secrets local secret_count=$(_sec_count_keychain 2>/dev/null || echo "0") @@ -329,7 +329,7 @@ _sec_status() { echo "" echo "${FLOW_COLORS[info]}Keychain:${FLOW_COLORS[reset]}" echo " • Status: ${FLOW_COLORS[success]}active${FLOW_COLORS[reset]} (primary)" - echo " • Location: $(security list-keychains 2>/dev/null | head -1 | tr -d ' \"')" + echo " • Location: $(security list-keychains 2>/dev/null | head -1 | command tr -d ' \"')" local kc_count=$(_sec_count_keychain 2>/dev/null || echo "0") echo " • Secrets: $kc_count" echo "" @@ -1465,7 +1465,7 @@ _sec_sync_github() { echo "Will sync ${#selected_secrets[@]} secret(s) to ${repo_name}:" for name in "${selected_secrets[@]}"; do # Convert to SCREAMING_SNAKE_CASE for GitHub - local gh_name=$(echo "${name}" | tr '[:lower:]-' '[:upper:]_') + local gh_name=$(echo "${name}" | command tr '[:lower:]-' '[:upper:]_') echo " ${FLOW_COLORS[muted]}${name}${FLOW_COLORS[reset]} → ${FLOW_COLORS[accent]}${gh_name}${FLOW_COLORS[reset]}" done echo "" @@ -1494,7 +1494,7 @@ _sec_sync_github() { fi # Convert to SCREAMING_SNAKE_CASE - local gh_name=$(echo "${name}" | tr '[:lower:]-' '[:upper:]_') + local gh_name=$(echo "${name}" | command tr '[:lower:]-' '[:upper:]_') # Set GitHub secret if echo "$secret_value" | gh secret set "$gh_name" --repo "$repo_name" 2>/dev/null; then diff --git a/lib/dispatchers/teach-dates.zsh b/lib/dispatchers/teach-dates.zsh index f8d61052f..3e4a1c667 100644 --- a/lib/dispatchers/teach-dates.zsh +++ b/lib/dispatchers/teach-dates.zsh @@ -194,7 +194,7 @@ _teach_dates_sync() { local file_num=1 for file in "${(@k)file_mismatches}"; do - local count=$(echo "${file_mismatches[$file]}" | wc -w | tr -d ' ') + local count=$(echo "${file_mismatches[$file]}" | wc -w | command tr -d ' ') printf " %d. %s (%d mismatch)\n" "$file_num" "$file" "$count" ((file_num++)) done diff --git a/lib/dispatchers/teach-deploy-enhanced.zsh b/lib/dispatchers/teach-deploy-enhanced.zsh index 707fbf293..7e0691d85 100644 --- a/lib/dispatchers/teach-deploy-enhanced.zsh +++ b/lib/dispatchers/teach-deploy-enhanced.zsh @@ -421,7 +421,7 @@ _deploy_dry_run_report() { # here. Say so, otherwise the count silently understates what would ship. if ! _git_is_clean 2>/dev/null; then local _uncommitted - _uncommitted=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') + _uncommitted=$(git status --porcelain 2>/dev/null | wc -l | command tr -d ' ') echo "" echo "${FLOW_COLORS[warn]} Note:${FLOW_COLORS[reset]} $_uncommitted uncommitted file(s) are NOT included below." echo "${FLOW_COLORS[dim]} A real deploy would offer to commit them first.${FLOW_COLORS[reset]}" @@ -432,7 +432,7 @@ _deploy_dry_run_report() { files_changed=$(git diff --name-status "$prod_branch"..."$draft_branch" 2>/dev/null) if [[ -n "$files_changed" ]]; then - local file_count=$(echo "$files_changed" | wc -l | tr -d ' ') + local file_count=$(echo "$files_changed" | wc -l | command tr -d ' ') echo "" echo " Would deploy $file_count files:" @@ -1035,7 +1035,7 @@ _teach_deploy_enhanced() { if typeset -f _deploy_history_append >/dev/null 2>&1; then local _commit_after="${DEPLOY_COMMIT_AFTER:-$(git rev-parse --short=8 HEAD 2>/dev/null)}" local _commit_before="${DEPLOY_COMMIT_BEFORE:-}" - local _file_count="${DEPLOY_FILE_COUNT:-$(git diff --name-only HEAD~1 HEAD 2>/dev/null | wc -l | tr -d ' ')}" + local _file_count="${DEPLOY_FILE_COUNT:-$(git diff --name-only HEAD~1 HEAD 2>/dev/null | wc -l | command tr -d ' ')}" local _elapsed="${DEPLOY_DURATION:-0}" _deploy_history_append "direct" "$_commit_after" "$_commit_before" "$draft_branch" "$prod_branch" "$_file_count" "$smart_message" "null" "null" "$_elapsed" echo " ${FLOW_COLORS[dim]}History logged: #$(( $(_deploy_history_count) )) ($(date '+%Y-%m-%d %H:%M'))${FLOW_COLORS[reset]}" @@ -1182,7 +1182,7 @@ _teach_deploy_enhanced() { local modified=$(echo "$files_changed" | grep -c "^M" || echo 0) local added=$(echo "$files_changed" | grep -c "^A" || echo 0) local deleted=$(echo "$files_changed" | grep -c "^D" || echo 0) - local total=$(echo "$files_changed" | wc -l | tr -d ' ') + local total=$(echo "$files_changed" | wc -l | command tr -d ' ') echo "" echo "${FLOW_COLORS[dim]}Summary: $total files ($added added, $modified modified, $deleted deleted)${FLOW_COLORS[reset]}" diff --git a/lib/dispatchers/teach-doctor-impl.zsh b/lib/dispatchers/teach-doctor-impl.zsh index 0b64ba1d2..27055e568 100644 --- a/lib/dispatchers/teach-doctor-impl.zsh +++ b/lib/dispatchers/teach-doctor-impl.zsh @@ -222,7 +222,7 @@ _teach_health_indicator() { jq -r '.status // empty' "$status_file" 2>/dev/null else # Fallback: grep for status field - grep -o '"status": *"[^"]*"' "$status_file" 2>/dev/null | head -1 | grep -o '"[^"]*"$' | tr -d '"' + grep -o '"status": *"[^"]*"' "$status_file" 2>/dev/null | head -1 | grep -o '"[^"]*"$' | command tr -d '"' fi } @@ -566,7 +566,7 @@ _teach_doctor_check_git() { _teach_doctor_pass "Working tree clean" json_results+=("{\"check\":\"working_tree\",\"status\":\"pass\",\"message\":\"clean\"}") else - local changes=$(echo "$porcelain" | wc -l | tr -d ' ') + local changes=$(echo "$porcelain" | wc -l | command tr -d ' ') _teach_doctor_warn "$changes uncommitted changes" json_results+=("{\"check\":\"working_tree\",\"status\":\"warn\",\"message\":\"$changes uncommitted\"}") fi @@ -1002,7 +1002,7 @@ _teach_doctor_check_cache() { fi # Check cache file count - local cache_files=$(find _freeze -type f 2>/dev/null | wc -l | tr -d ' ') + local cache_files=$(find _freeze -type f 2>/dev/null | wc -l | command tr -d ' ') if [[ "$json" == "false" && "$quiet" == "false" ]]; then echo " ${FLOW_COLORS[muted]}→ $cache_files cached files${FLOW_COLORS[reset]}" fi @@ -1144,12 +1144,12 @@ _teach_doctor_check_macros() { # Show unused macros: first 5 by default, all in verbose if [[ "$quiet" == "false" ]]; then if [[ "$verbose" == "true" ]]; then - echo " ${FLOW_COLORS[muted]}→ Unused: $(echo "$unused" | tr '\n' ' ' | sed 's/ $//')${FLOW_COLORS[reset]}" + echo " ${FLOW_COLORS[muted]}→ Unused: $(echo "$unused" | command tr '\n' ' ' | sed 's/ $//')${FLOW_COLORS[reset]}" elif (( unused_count > 5 )); then - local preview=$(echo "$unused" | head -5 | tr '\n' ' ' | sed 's/ $//') + local preview=$(echo "$unused" | head -5 | command tr '\n' ' ' | sed 's/ $//') echo " ${FLOW_COLORS[muted]}→ $preview ... (+$((unused_count - 5)) more, use --verbose)${FLOW_COLORS[reset]}" else - echo " ${FLOW_COLORS[muted]}→ Unused: $(echo "$unused" | tr '\n' ' ' | sed 's/ $//')${FLOW_COLORS[reset]}" + echo " ${FLOW_COLORS[muted]}→ Unused: $(echo "$unused" | command tr '\n' ' ' | sed 's/ $//')${FLOW_COLORS[reset]}" fi fi else diff --git a/lib/dispatchers/teach/teach-backup.zsh b/lib/dispatchers/teach/teach-backup.zsh index 826eb8fda..f76be87e5 100644 --- a/lib/dispatchers/teach/teach-backup.zsh +++ b/lib/dispatchers/teach/teach-backup.zsh @@ -215,7 +215,7 @@ EOF while IFS= read -r backup; do local backup_name=$(basename "$backup") local size=$(du -sh "$backup" 2>/dev/null | awk '{print $1}') - local file_count=$(find "$backup" -type f 2>/dev/null | wc -l | tr -d ' ') + local file_count=$(find "$backup" -type f 2>/dev/null | wc -l | command tr -d ' ') # Extract timestamp from backup name local timestamp=$(echo "$backup_name" | grep -o '[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}-[0-9]\{4\}' || echo "") @@ -564,7 +564,7 @@ _teach_backup_update_metadata() { local backup_name=$(basename "$backup_path") local timestamp=$(date +%s) local size=$(du -sh "$backup_path" 2>/dev/null | awk '{print $1}') - local file_count=$(find "$backup_path" -type f 2>/dev/null | wc -l | tr -d ' ') + local file_count=$(find "$backup_path" -type f 2>/dev/null | wc -l | command tr -d ' ') # Add to metadata (simplified - full JSON manipulation would need jq) # For now, just append a simple entry diff --git a/lib/dispatchers/teach/teach-status.zsh b/lib/dispatchers/teach/teach-status.zsh index ca28e3f97..82769d695 100644 --- a/lib/dispatchers/teach/teach-status.zsh +++ b/lib/dispatchers/teach/teach-status.zsh @@ -116,7 +116,7 @@ _teach_show_status_full() { # Check for open PRs (requires gh CLI) if command -v gh >/dev/null 2>&1; then - local pr_count=$(gh pr list --state open 2>/dev/null | wc -l | tr -d ' ') + local pr_count=$(gh pr list --state open 2>/dev/null | wc -l | command tr -d ' ') if [[ "$pr_count" -gt 0 ]]; then echo " Open PRs: ${FLOW_COLORS[warning]}$pr_count pending${FLOW_COLORS[reset]}" # Show first PR details @@ -215,7 +215,7 @@ _teach_show_status_full() { local found_content=false for dir label in "${(@kv)content_dirs}"; do if [[ -d "$dir" ]]; then - local count=$(find "$dir" -maxdepth 2 -name "*.md" -o -name "*.qmd" 2>/dev/null | wc -l | tr -d ' ') + local count=$(find "$dir" -maxdepth 2 -name "*.md" -o -name "*.qmd" 2>/dev/null | wc -l | command tr -d ' ') if [[ "$count" -gt 0 ]]; then printf " %-20s %s files\n" "$label:" "$count" found_content=true diff --git a/lib/dispatchers/tok-dispatcher.zsh b/lib/dispatchers/tok-dispatcher.zsh index 3753962ef..28625975b 100644 --- a/lib/dispatchers/tok-dispatcher.zsh +++ b/lib/dispatchers/tok-dispatcher.zsh @@ -1539,15 +1539,15 @@ _tok_mint_jwt() { header='{"alg":"RS256","typ":"JWT"}' payload="{\"iat\":$now,\"exp\":$exp,\"iss\":\"$app_id\"}" - b64_header=$(printf '%s' "$header" | openssl enc -base64 -A | tr '+/' '-_' | tr -d '=') - b64_payload=$(printf '%s' "$payload" | openssl enc -base64 -A | tr '+/' '-_' | tr -d '=') + b64_header=$(printf '%s' "$header" | openssl enc -base64 -A | command tr '+/' '-_' | command tr -d '=') + b64_payload=$(printf '%s' "$payload" | openssl enc -base64 -A | command tr '+/' '-_' | command tr -d '=') tmp_key=$(mktemp) printf '%s' "$private_key" > "$tmp_key" signature=$(printf '%s.%s' "$b64_header" "$b64_payload" | \ openssl dgst -sha256 -sign "$tmp_key" -binary 2>/dev/null | \ - openssl enc -base64 -A | tr '+/' '-_' | tr -d '=') + openssl enc -base64 -A | command tr '+/' '-_' | command tr -d '=') rm -f "$tmp_key" diff --git a/lib/dispatchers/wt-dispatcher.zsh b/lib/dispatchers/wt-dispatcher.zsh index 57d0cb132..e36ccdf5f 100644 --- a/lib/dispatchers/wt-dispatcher.zsh +++ b/lib/dispatchers/wt-dispatcher.zsh @@ -205,10 +205,10 @@ _wt_overview() { # Detect session status local wt_session_icon="" if [[ -d "$wt_path/.claude" ]]; then - local session_age=$(find "$wt_path/.claude" -type f -mtime -1 2>/dev/null | wc -l | tr -d ' ') + local session_age=$(find "$wt_path/.claude" -type f -mtime -1 2>/dev/null | wc -l | command tr -d ' ') if [[ "$session_age" -gt 0 ]]; then # Active session (< 24h) - local active_count=$(find "$wt_path/.claude" -type f -mmin -30 2>/dev/null | wc -l | tr -d ' ') + local active_count=$(find "$wt_path/.claude" -type f -mmin -30 2>/dev/null | wc -l | command tr -d ' ') if [[ "$active_count" -gt 0 ]]; then wt_session_icon="🟢" else @@ -307,7 +307,7 @@ _wt_get_path() { # Fallback: check expected hierarchical path local project=$(basename "$git_root") - local folder=$(echo "$branch" | tr '/' '-') + local folder=$(echo "$branch" | command tr '/' '-') local expected_path="$FLOW_WORKTREE_DIR/$project/$folder" if [[ -d "$expected_path" ]]; then @@ -342,7 +342,7 @@ _wt_create() { fi local project=$(basename "$git_root") - local folder=$(echo "$branch" | tr '/' '-') + local folder=$(echo "$branch" | command tr '/' '-') local target_dir="$FLOW_WORKTREE_DIR/$project/$folder" # Create project directory if needed diff --git a/lib/dotfile-helpers.zsh b/lib/dotfile-helpers.zsh index db0706ca4..27ffab5af 100644 --- a/lib/dotfile-helpers.zsh +++ b/lib/dotfile-helpers.zsh @@ -355,7 +355,7 @@ _dotf_get_modified_count() { return fi - local count=$(chezmoi status 2>/dev/null | wc -l | tr -d ' ') + local count=$(chezmoi status 2>/dev/null | wc -l | command tr -d ' ') # Sanitize: strip whitespace and validate numeric format count="${count##*( )}" # Remove leading spaces @@ -438,7 +438,7 @@ _dotf_get_tracked_count() { return fi - local count=$(chezmoi managed 2>/dev/null | wc -l | tr -d ' ') + local count=$(chezmoi managed 2>/dev/null | wc -l | command tr -d ' ') # Sanitize: strip whitespace and validate numeric format count="${count##*( )}" # Remove leading spaces @@ -572,7 +572,7 @@ _dotf_get_status_line() { local behind_count="" local chezmoi_dir="${HOME}/.local/share/chezmoi" if [[ -d "$chezmoi_dir/.git" ]]; then - behind_count=$(cd "$chezmoi_dir" && git rev-list HEAD..@{u} 2>/dev/null | wc -l | tr -d ' ') + behind_count=$(cd "$chezmoi_dir" && git rev-list HEAD..@{u} 2>/dev/null | wc -l | command tr -d ' ') # Sanitize: strip whitespace and validate numeric format behind_count="${behind_count##*( )}" behind_count="${behind_count%%*( )}" @@ -593,7 +593,7 @@ _dotf_get_status_line() { local ahead_count="" local chezmoi_dir="${HOME}/.local/share/chezmoi" if [[ -d "$chezmoi_dir/.git" ]]; then - ahead_count=$(cd "$chezmoi_dir" && git rev-list @{u}..HEAD 2>/dev/null | wc -l | tr -d ' ') + ahead_count=$(cd "$chezmoi_dir" && git rev-list @{u}..HEAD 2>/dev/null | wc -l | command tr -d ' ') # Sanitize: strip whitespace and validate numeric format ahead_count="${ahead_count##*( )}" ahead_count="${ahead_count%%*( )}" @@ -680,7 +680,7 @@ _dotf_resolve_file_path() { # Count matches local match_count - match_count=$(echo "$matched_files" | wc -l | tr -d ' ') + match_count=$(echo "$matched_files" | wc -l | command tr -d ' ') # Sanitize: strip whitespace and validate numeric format match_count="${match_count##*( )}" @@ -1374,7 +1374,7 @@ _dotf_check_git_in_path() { if [[ -d "$target/.git" ]] && command -v git &>/dev/null; then # Fast path: check for git submodules local submodule_count - submodule_count=$(git -C "$target" submodule status 2>/dev/null | wc -l | tr -d ' ') + submodule_count=$(git -C "$target" submodule status 2>/dev/null | wc -l | command tr -d ' ') # Sanitize count submodule_count="${submodule_count##*( )}" @@ -1393,7 +1393,7 @@ _dotf_check_git_in_path() { # Slow path: use find with timeout for non-git directories # Check directory size first local file_count - file_count=$(find "$target" -type f 2>/dev/null | head -1000 | wc -l | tr -d ' ') + file_count=$(find "$target" -type f 2>/dev/null | head -1000 | wc -l | command tr -d ' ') # Sanitize count file_count="${file_count##*( )}" diff --git a/lib/em-himalaya.zsh b/lib/em-himalaya.zsh index c405765db..f76186829 100644 --- a/lib/em-himalaya.zsh +++ b/lib/em-himalaya.zsh @@ -411,7 +411,7 @@ _em_hml_search() { # himalaya only supports single-word subject search; # pick the longest word as the most distinctive keyword local keyword - keyword=$(echo "$query" | tr ' ' '\n' | awk '{ print length, $0 }' | sort -rn | head -1 | cut -d' ' -f2-) + keyword=$(echo "$query" | command tr ' ' '\n' | awk '{ print length, $0 }' | sort -rn | head -1 | cut -d' ' -f2-) [[ -z "$keyword" ]] && keyword="$query" himalaya envelope list -f "$folder" --output json \ subject "$keyword" 2>/dev/null diff --git a/lib/email-helpers.zsh b/lib/email-helpers.zsh index 74e045f5c..529d4bb69 100644 --- a/lib/email-helpers.zsh +++ b/lib/email-helpers.zsh @@ -128,7 +128,7 @@ _em_confirm_send() { # Check for empty body local body_lines - body_lines=$(awk '/^$/{found=1;next} found{print}' "$draft_file" | wc -l | tr -d ' ') + body_lines=$(awk '/^$/{found=1;next} found{print}' "$draft_file" | wc -l | command tr -d ' ') if [[ "$body_lines" -eq 0 ]]; then _flow_log_warning "Empty email body" printf " Send anyway? [y/N] " diff --git a/lib/hooks/pre-commit-template.zsh b/lib/hooks/pre-commit-template.zsh index ead538bdc..2f28c43af 100644 --- a/lib/hooks/pre-commit-template.zsh +++ b/lib/hooks/pre-commit-template.zsh @@ -180,7 +180,7 @@ _check_images() { local image_refs image_refs=$(grep -oE '!\[.*?\]\([^)]+\)|include_graphics\(["\x27]([^"\x27]+)["\x27]\)' "$file" | \ sed -E 's/.*\(([^)]+)\).*/\1/' | \ - tr -d '"' | tr -d "'") + tr -d '"' | command tr -d "'") if [[ -z "$image_refs" ]]; then return 0 # No images to check diff --git a/lib/index-helpers.zsh b/lib/index-helpers.zsh index c3308ed82..94924139e 100644 --- a/lib/index-helpers.zsh +++ b/lib/index-helpers.zsh @@ -390,7 +390,7 @@ _update_index_link() { local link_text="- [$title]($basename)" # Get file line count to detect append case - local file_lines=$(wc -l < "$index_file" | tr -d ' ') + local file_lines=$(wc -l < "$index_file" | command tr -d ' ') # If insert_line is 0 or > file_lines, append at end # (sed can't insert past EOF, so use echo >> instead) diff --git a/lib/parallel-helpers.zsh b/lib/parallel-helpers.zsh index 34cb5f2fa..3d47dbf38 100644 --- a/lib/parallel-helpers.zsh +++ b/lib/parallel-helpers.zsh @@ -545,7 +545,7 @@ _parallel_render() { fi # Check for failures - local failed_count=$(echo "$results_json" | grep -o '"status":[^0]' | wc -l | tr -d ' ') + local failed_count=$(echo "$results_json" | grep -o '"status":[^0]' | wc -l | command tr -d ' ') # Cleanup trap - INT TERM EXIT @@ -600,7 +600,7 @@ _monitor_progress() { # Count completed jobs local completed=0 if [[ -f "$results_file" ]]; then - completed=$(wc -l < "$results_file" | tr -d ' ') + completed=$(wc -l < "$results_file" | command tr -d ' ') fi # Update progress diff --git a/lib/parallel-progress.zsh b/lib/parallel-progress.zsh index a95bdb319..40bacc8e3 100644 --- a/lib/parallel-progress.zsh +++ b/lib/parallel-progress.zsh @@ -257,12 +257,12 @@ _show_worker_status() { # For now, just show queue status local remaining=0 if [[ -f "$queue_file" ]]; then - remaining=$(wc -l < "$queue_file" | tr -d ' ') + remaining=$(wc -l < "$queue_file" | command tr -d ' ') fi local completed=0 if [[ -f "$results_file" ]]; then - completed=$(wc -l < "$results_file" | tr -d ' ') + completed=$(wc -l < "$results_file" | command tr -d ' ') fi echo " Active workers: ${num_workers}" @@ -455,7 +455,7 @@ _display_error_details() { local results_json="$1" # Count failures - local failed_count=$(echo "$results_json" | grep -o '"status":[^0]' | wc -l | tr -d ' ') + local failed_count=$(echo "$results_json" | grep -o '"status":[^0]' | wc -l | command tr -d ' ') if [[ $failed_count -eq 0 ]]; then return 0 diff --git a/lib/plugin-loader.zsh b/lib/plugin-loader.zsh index 2fb46fada..23854c41e 100644 --- a/lib/plugin-loader.zsh +++ b/lib/plugin-loader.zsh @@ -313,7 +313,7 @@ _flow_plugin_metadata() { type="file" # Try to extract version from file header - local header_version=$(command grep -m1 "^# Version:" "$plugin_path" 2>/dev/null | command cut -d: -f2 | tr -d ' ') + local header_version=$(command grep -m1 "^# Version:" "$plugin_path" 2>/dev/null | command cut -d: -f2 | command tr -d ' ') [[ -n "$header_version" ]] && version="$header_version" elif [[ -d "$plugin_path" ]]; then diff --git a/lib/project-cache.zsh b/lib/project-cache.zsh index 564c31b54..3c69fa273 100644 --- a/lib/project-cache.zsh +++ b/lib/project-cache.zsh @@ -272,7 +272,7 @@ _proj_cache_stats() { local age=$((now - cache_time)) local age_str=$(_proj_format_duration "$age") - local count=$(tail -n +2 "$PROJ_CACHE_FILE" 2>/dev/null | wc -l | tr -d ' ') + local count=$(tail -n +2 "$PROJ_CACHE_FILE" 2>/dev/null | wc -l | command tr -d ' ') local status_icon local status_text diff --git a/lib/r-helpers.zsh b/lib/r-helpers.zsh index d21914b19..543ec42ef 100644 --- a/lib/r-helpers.zsh +++ b/lib/r-helpers.zsh @@ -110,11 +110,11 @@ _detect_r_packages_from_description() { # Get Imports section local imports - imports=$(awk '/^Imports:/{flag=1;next}/^[A-Z]/{flag=0}flag' "$desc_file" | tr -d ' ' | tr ',' '\n' | grep -v '^$') + imports=$(awk '/^Imports:/{flag=1;next}/^[A-Z]/{flag=0}flag' "$desc_file" | command tr -d ' ' | command tr ',' '\n' | grep -v '^$') # Get Depends section (excluding R itself) local depends - depends=$(awk '/^Depends:/{flag=1;next}/^[A-Z]/{flag=0}flag' "$desc_file" | tr -d ' ' | tr ',' '\n' | grep -v '^$' | grep -v '^R(') + depends=$(awk '/^Depends:/{flag=1;next}/^[A-Z]/{flag=0}flag' "$desc_file" | command tr -d ' ' | command tr ',' '\n' | grep -v '^$' | grep -v '^R(') packages=$(echo -e "${imports}\n${depends}" | sort -u) diff --git a/lib/report-generator.zsh b/lib/report-generator.zsh index 80694ab74..91439d0c5 100644 --- a/lib/report-generator.zsh +++ b/lib/report-generator.zsh @@ -593,7 +593,7 @@ _report_concept_graph_text() { # Sort weeks and output local sorted_weeks - sorted_weeks=($(echo "${(k)concept_by_week[@]}" | tr ' ' '\n' | sort -n)) + sorted_weeks=($(echo "${(k)concept_by_week[@]}" | command tr ' ' '\n' | sort -n)) for week in $sorted_weeks; do [[ "$week" -eq 0 ]] && continue @@ -719,7 +719,7 @@ _report_week_breakdown() { # Build JSON array local sorted_weeks - sorted_weeks=($(echo "${(k)week_concepts[@]}" | tr ' ' '\n' | sort -n)) + sorted_weeks=($(echo "${(k)week_concepts[@]}" | command tr ' ' '\n' | sort -n)) for week in $sorted_weeks; do [[ "$week" -eq 0 ]] && continue diff --git a/lib/slide-optimizer.zsh b/lib/slide-optimizer.zsh index 73f797233..ff2750111 100644 --- a/lib/slide-optimizer.zsh +++ b/lib/slide-optimizer.zsh @@ -136,7 +136,7 @@ _slide_analyze_structure() { local content="${rest#*|}" # Calculate metrics - local word_count=$(echo "$content" | wc -w | tr -d '[:space:]') + local word_count=$(echo "$content" | wc -w | command tr -d '[:space:]') local code_chunks=$(echo "$content" | grep -c '```{' 2>/dev/null || true) code_chunks=${code_chunks//[^0-9]/} : ${code_chunks:=0} @@ -259,7 +259,7 @@ _slide_extract_sections() { echo "$json" | jq -r '.sections[] | "\(.level)|\(.heading)|\(.word_count)|\(.code_chunks)|\(.examples)|\(.definitions)"' 2>/dev/null else # Basic regex extraction for each section object - echo "$json" | tr ',' '\n' | while IFS= read -r chunk; do + echo "$json" | command tr ',' '\n' | while IFS= read -r chunk; do if [[ "$chunk" =~ '"level":([0-9]+)' ]]; then local level="${match[1]}" fi @@ -350,7 +350,7 @@ _slide_identify_key_concepts() { if [[ "$line" =~ '\*\*([^*]+)\*\*' ]]; then local term="${match[1]}" # Only include if it looks like a concept (2-5 words, starts with capital) - local wc=$(echo "$term" | wc -w | tr -d ' ') + local wc=$(echo "$term" | wc -w | command tr -d ' ') if [[ $wc -ge 2 && $wc -le 5 && "$term" =~ '^[A-Z]' ]]; then term="${term//\"/\\\"}" [[ "$first" == "true" ]] && first=false || concepts+=',' @@ -599,7 +599,7 @@ _slide_apply_breaks() { fi # Count words in section - local line_words=$(echo "$line" | wc -w | tr -d ' ') + local line_words=$(echo "$line" | wc -w | command tr -d ' ') current_section_words=$(( current_section_words + line_words )) # Insert break if section is too long and we're at a paragraph boundary diff --git a/lib/status-dashboard.zsh b/lib/status-dashboard.zsh index b977fe204..9287e0859 100644 --- a/lib/status-dashboard.zsh +++ b/lib/status-dashboard.zsh @@ -231,8 +231,8 @@ _teach_show_status_dashboard() { # Index health (content count) local lecture_count=0 local assignment_count=0 - [[ -d "lectures" ]] && lecture_count=$(find lectures -maxdepth 2 \( -name "*.md" -o -name "*.qmd" \) 2>/dev/null | wc -l | tr -d ' ') - [[ -d "assignments" ]] && assignment_count=$(find assignments -maxdepth 2 \( -name "*.md" -o -name "*.qmd" \) 2>/dev/null | wc -l | tr -d ' ') + [[ -d "lectures" ]] && lecture_count=$(find lectures -maxdepth 2 \( -name "*.md" -o -name "*.qmd" \) 2>/dev/null | wc -l | command tr -d ' ') + [[ -d "assignments" ]] && assignment_count=$(find assignments -maxdepth 2 \( -name "*.md" -o -name "*.qmd" \) 2>/dev/null | wc -l | command tr -d ' ') [[ "$lecture_count" =~ ^[0-9]+$ ]] || lecture_count=0 [[ "$assignment_count" =~ ^[0-9]+$ ]] || assignment_count=0 diff --git a/lib/template-helpers.zsh b/lib/template-helpers.zsh index a148ef1e4..9cf05f744 100644 --- a/lib/template-helpers.zsh +++ b/lib/template-helpers.zsh @@ -343,17 +343,17 @@ _teach_load_config_variables() { if [[ -f "$config_file" ]]; then # Extract course code local course_code - course_code=$(grep -E '^ code:' "$config_file" 2>/dev/null | head -1 | sed 's/.*code:[ ]*//' | tr -d '"'"'") + course_code=$(grep -E '^ code:' "$config_file" 2>/dev/null | head -1 | sed 's/.*code:[ ]*//' | command tr -d '"'"'") [[ -n "$course_code" ]] && eval "${array_name}[COURSE]=\"\$course_code\"" # Extract instructor local instructor - instructor=$(grep -E '^ instructor:' "$config_file" 2>/dev/null | head -1 | sed 's/.*instructor:[ ]*//' | tr -d '"'"'") + instructor=$(grep -E '^ instructor:' "$config_file" 2>/dev/null | head -1 | sed 's/.*instructor:[ ]*//' | command tr -d '"'"'") [[ -n "$instructor" ]] && eval "${array_name}[INSTRUCTOR]=\"\$instructor\"" # Extract semester local semester - semester=$(grep -E '^ name:' "$config_file" 2>/dev/null | head -1 | sed 's/.*name:[ ]*//' | tr -d '"'"'") + semester=$(grep -E '^ name:' "$config_file" 2>/dev/null | head -1 | sed 's/.*name:[ ]*//' | command tr -d '"'"'") [[ -n "$semester" ]] && eval "${array_name}[SEMESTER]=\"\$semester\"" fi } @@ -429,8 +429,8 @@ _teach_slugify() { local input="$1" echo "$input" \ - | tr '[:upper:]' '[:lower:]' \ - | tr -cs '[:alnum:]' '-' \ + | command tr '[:upper:]' '[:lower:]' \ + | command tr -cs '[:alnum:]' '-' \ | sed 's/^-//; s/-$//' } diff --git a/lib/tui.zsh b/lib/tui.zsh index b972f61d1..0b395fea5 100644 --- a/lib/tui.zsh +++ b/lib/tui.zsh @@ -47,8 +47,8 @@ _flow_progress_bar() { local empty=$(( width - filled )) printf "%s%s %d%%" \ - "$(printf '%*s' "$filled" '' | tr ' ' "$filled_char")" \ - "$(printf '%*s' "$empty" '' | tr ' ' "$empty_char")" \ + "$(printf '%*s' "$filled" '' | command tr ' ' "$filled_char")" \ + "$(printf '%*s' "$empty" '' | command tr ' ' "$empty_char")" \ "$percent" } @@ -213,7 +213,7 @@ _flow_box() { [[ -n "$title" ]] && printf " %s " "$title" local title_len=${#title} local remaining=$(( inner_width - title_len - 2 )) - printf "%${remaining}s" '' | tr ' ' '─' + printf "%${remaining}s" '' | command tr ' ' '─' printf "╮\n" # Content @@ -223,7 +223,7 @@ _flow_box() { # Bottom border printf "╰" - printf "%${width}s" '' | tr ' ' '─' + printf "%${width}s" '' | command tr ' ' '─' printf "╯\n" } diff --git a/man/man1/agenda.1 b/man/man1/agenda.1 index 082c3fc37..4ebf369af 100644 --- a/man/man1/agenda.1 +++ b/man/man1/agenda.1 @@ -1,6 +1,6 @@ .\" Man page for the agenda command (forward-looking schedule view) .\" Updated: June 2026 -.TH AGENDA 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH AGENDA 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME agenda \- forward-looking schedule across all projects .SH SYNOPSIS diff --git a/man/man1/at.1 b/man/man1/at.1 index 8b1dcf734..8122ada55 100644 --- a/man/man1/at.1 +++ b/man/man1/at.1 @@ -1,6 +1,6 @@ .\" Man page for at dispatcher (Atlas bridge) .\" Generated: June 2026 -.TH AT 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH AT 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME at \- Atlas project intelligence bridge (optional integration) .SH SYNOPSIS diff --git a/man/man1/cc.1 b/man/man1/cc.1 index 3bb8765d6..3249973d6 100644 --- a/man/man1/cc.1 +++ b/man/man1/cc.1 @@ -1,6 +1,6 @@ .\" Man page for cc dispatcher (Claude Code launcher) .\" Generated: June 2026 -.TH CC 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH CC 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME cc \- Claude Code launcher and dispatcher .SH SYNOPSIS diff --git a/man/man1/dash.1 b/man/man1/dash.1 index b2b385105..58e2b41e4 100644 --- a/man/man1/dash.1 +++ b/man/man1/dash.1 @@ -1,6 +1,6 @@ .\" Man page for the dash command (project dashboard) .\" Updated: June 2026 -.TH DASH 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH DASH 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME dash \- ADHD-friendly project dashboard .SH SYNOPSIS diff --git a/man/man1/dots.1 b/man/man1/dots.1 index 7fbb6eba1..e708aa232 100644 --- a/man/man1/dots.1 +++ b/man/man1/dots.1 @@ -1,6 +1,6 @@ .\" Man page for dots dispatcher (Dotfile Management) .\" Generated: June 2026 -.TH DOTS 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH DOTS 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME dots \- Dotfile management dispatcher (chezmoi wrapper) .SH SYNOPSIS diff --git a/man/man1/em.1 b/man/man1/em.1 index 38f3ed436..eac9d333f 100644 --- a/man/man1/em.1 +++ b/man/man1/em.1 @@ -1,6 +1,6 @@ .\" Man page for em dispatcher (Email / himalaya) .\" Generated: June 2026 -.TH EM 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH EM 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME em \- Email dispatcher (himalaya wrapper) .SH SYNOPSIS diff --git a/man/man1/flow-claude.1 b/man/man1/flow-claude.1 index ab68a0543..57e637ed9 100644 --- a/man/man1/flow-claude.1 +++ b/man/man1/flow-claude.1 @@ -1,4 +1,4 @@ -.TH FLOW-CLAUDE 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH FLOW-CLAUDE 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME flow-claude \- Claude Code environment health checker .SH SYNOPSIS diff --git a/man/man1/flow.1 b/man/man1/flow.1 index d727904bc..a544bbe71 100644 --- a/man/man1/flow.1 +++ b/man/man1/flow.1 @@ -1,6 +1,6 @@ .\" Man page for flow command .\" Updated: June 2026 -.TH FLOW 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH FLOW 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME flow \- ADHD-friendly workflow CLI for developers .SH SYNOPSIS diff --git a/man/man1/g.1 b/man/man1/g.1 index cb94f1cb6..5f216cf04 100644 --- a/man/man1/g.1 +++ b/man/man1/g.1 @@ -1,6 +1,6 @@ .\" Man page for g dispatcher (Git workflows) .\" Updated: June 2026 -.TH G 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH G 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME g \- Git commands dispatcher .SH SYNOPSIS diff --git a/man/man1/mcp.1 b/man/man1/mcp.1 index 642bc51a4..e45800d44 100644 --- a/man/man1/mcp.1 +++ b/man/man1/mcp.1 @@ -1,6 +1,6 @@ .\" Man page for mcp dispatcher (MCP server management) .\" Updated: June 2026 -.TH MCP 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH MCP 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME mcp \- MCP server management dispatcher .SH SYNOPSIS diff --git a/man/man1/morning.1 b/man/man1/morning.1 index f7679e2e1..76aa60e07 100644 --- a/man/man1/morning.1 +++ b/man/man1/morning.1 @@ -1,6 +1,6 @@ .\" Man page for the morning command (daily startup routine) .\" Updated: June 2026 -.TH MORNING 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH MORNING 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME morning \- ADHD-friendly daily startup routine .SH SYNOPSIS diff --git a/man/man1/prompt.1 b/man/man1/prompt.1 index 3846c378f..b759ab5f6 100644 --- a/man/man1/prompt.1 +++ b/man/man1/prompt.1 @@ -1,6 +1,6 @@ .\" Man page for prompt dispatcher (Prompt Engine Switcher) .\" Generated: June 2026 -.TH PROMPT 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH PROMPT 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME prompt \- Prompt engine switcher .SH SYNOPSIS diff --git a/man/man1/qu.1 b/man/man1/qu.1 index 23e21ae62..04547f9a3 100644 --- a/man/man1/qu.1 +++ b/man/man1/qu.1 @@ -1,6 +1,6 @@ .\" Man page for qu dispatcher (Quarto publishing) .\" Updated: June 2026 -.TH QU 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH QU 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME qu \- Quarto publishing dispatcher .SH SYNOPSIS diff --git a/man/man1/r.1 b/man/man1/r.1 index 2beecbedb..4ad69047c 100644 --- a/man/man1/r.1 +++ b/man/man1/r.1 @@ -1,6 +1,6 @@ .\" Man page for r dispatcher (R package development) .\" Updated: June 2026 -.TH R 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH R 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME r \- R package development dispatcher .SH SYNOPSIS diff --git a/man/man1/sec.1 b/man/man1/sec.1 index 0edf8dd2d..de8c45968 100644 --- a/man/man1/sec.1 +++ b/man/man1/sec.1 @@ -1,6 +1,6 @@ .\" Man page for sec dispatcher (Secret Management) .\" Generated: June 2026 -.TH SEC 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH SEC 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME sec \- Secret management dispatcher (Keychain and Bitwarden) .SH SYNOPSIS diff --git a/man/man1/teach.1 b/man/man1/teach.1 index a4e61d076..5941d4f4d 100644 --- a/man/man1/teach.1 +++ b/man/man1/teach.1 @@ -1,6 +1,6 @@ .\" Man page for teach dispatcher (Teaching Workflow) .\" Generated: June 2026 -.TH TEACH 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH TEACH 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME teach \- Teaching workflow dispatcher (Scholar integration) .SH SYNOPSIS diff --git a/man/man1/tm.1 b/man/man1/tm.1 index ef6bc490d..287655eb0 100644 --- a/man/man1/tm.1 +++ b/man/man1/tm.1 @@ -1,6 +1,6 @@ .\" Man page for tm dispatcher (Terminal Manager) .\" Generated: June 2026 -.TH TM 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH TM 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME tm \- Terminal manager dispatcher .SH SYNOPSIS diff --git a/man/man1/today.1 b/man/man1/today.1 index c743b9a84..ce8071812 100644 --- a/man/man1/today.1 +++ b/man/man1/today.1 @@ -1,6 +1,6 @@ .\" Man page for the today command (quick daily status) .\" Updated: June 2026 -.TH TODAY 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH TODAY 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME today \- quick daily status .SH SYNOPSIS diff --git a/man/man1/tok.1 b/man/man1/tok.1 index f96b04873..065d9a953 100644 --- a/man/man1/tok.1 +++ b/man/man1/tok.1 @@ -1,6 +1,6 @@ .\" Man page for tok dispatcher (Token Management) .\" Generated: June 2026 -.TH TOK 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH TOK 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME tok \- Token lifecycle management dispatcher .SH SYNOPSIS diff --git a/man/man1/v.1 b/man/man1/v.1 index beebc0e96..f817206a4 100644 --- a/man/man1/v.1 +++ b/man/man1/v.1 @@ -1,6 +1,6 @@ .\" Man page for v dispatcher (Vibe / Workflow Automation) .\" Generated: June 2026 -.TH V 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH V 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME v \- Workflow automation dispatcher (vibe coding mode) .SH SYNOPSIS diff --git a/man/man1/week.1 b/man/man1/week.1 index 606c23a0d..6345d77c4 100644 --- a/man/man1/week.1 +++ b/man/man1/week.1 @@ -1,6 +1,6 @@ .\" Man page for the week command (weekly review helper) .\" Updated: June 2026 -.TH WEEK 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH WEEK 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME week \- weekly review helper .SH SYNOPSIS diff --git a/man/man1/wt.1 b/man/man1/wt.1 index 8a8ea06c0..73cd99935 100644 --- a/man/man1/wt.1 +++ b/man/man1/wt.1 @@ -1,6 +1,6 @@ .\" Man page for wt dispatcher (Git Worktree Management) .\" Generated: June 2026 -.TH WT 1 "June 2026" "flow-cli 7.17.0" "User Commands" +.TH WT 1 "June 2026" "flow-cli 7.17.1" "User Commands" .SH NAME wt \- Git worktree management dispatcher .SH SYNOPSIS diff --git a/package.json b/package.json index f705af133..cbc9c1484 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flow-cli", - "version": "7.17.0", + "version": "7.17.1", "description": "ADHD-optimized ZSH workflow plugin", "private": true, "scripts": { diff --git a/tests/run-all.sh b/tests/run-all.sh index d42b69e1e..12b7b4655 100755 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -133,6 +133,7 @@ run_test ./tests/test-teach-deploy-v2-integration.zsh run_test ./tests/test-teach-deploy-dryrun-readonly.zsh run_test ./tests/test-teach-deploy-merge-topology.zsh run_test ./tests/test-changelog-parity.zsh +run_test ./tests/test-alias-shadowing.zsh run_test ./tests/test-production-conflict-detection.zsh echo "" diff --git a/tests/test-alias-shadowing.zsh b/tests/test-alias-shadowing.zsh new file mode 100755 index 000000000..cb5df62fe --- /dev/null +++ b/tests/test-alias-shadowing.zsh @@ -0,0 +1,75 @@ +#!/usr/bin/env zsh +# test-alias-shadowing.zsh +# +# Dispatchers run in the user's INTERACTIVE shell, where aliases are live and +# expand at parse time. A user alias on a coreutil silently hijacks any +# pipeline that uses it. +# +# This is not hypothetical. A real user had: +# +# tr='track-activity report' +# +# so every `... | tr -d ' '` in lib/ ran `track-activity report -d ' '`, and +# its output landed in the variable instead of the count. Observed live in +# `teach deploy --dry-run`: +# +# Would deploy Monthly Terminal Report (2026-08): +# ================================== files: +# +# where "675" belonged. 101 pipeline sites across 34 files were affected. +# The fix is `command tr`, which bypasses aliases and functions. +# +# This test plants a hostile alias and asserts the count still comes out +# numeric — it is a positive control, so it FAILS if the `command` prefix is +# ever dropped. + +set -uo pipefail +ROOT="${0:A:h}/.." + +PASS=0 +FAIL=0 +_ok() { PASS=$((PASS+1)); echo " ✅ $1"; } +_bad() { FAIL=$((FAIL+1)); echo " ❌ $1"; [[ -n "${2:-}" ]] && echo " $2"; } + +echo "=== alias shadowing ===" + +# 1. No bare `| tr` survives in shipped lib/. This is the whole-codebase guard; +# a new one added later fails here rather than in a user's terminal. +bare=$(grep -rn '|[[:space:]]*tr ' "$ROOT/lib" --include='*.zsh' 2>/dev/null | wc -l | command tr -d ' ') +if [[ "$bare" == "0" ]]; then + _ok "no bare '| tr' in lib/*.zsh (all use 'command tr')" +else + _bad "$bare bare '| tr' pipeline(s) in lib/" \ + "$(grep -rn '|[[:space:]]*tr ' "$ROOT/lib" --include='*.zsh' 2>/dev/null | head -3)" +fi + +# 2. Behavioural: with a hostile alias live, `command tr` still yields a count. +hostile=$(zsh -c ' + alias tr="echo HIJACKED" + setopt aliases + printf "a\nb\nc\n" | wc -l | command tr -d " " +' 2>/dev/null | command tr -d ' \n') +if [[ "$hostile" == "3" ]]; then + _ok "'command tr' survives a hostile tr alias (got 3)" +else + _bad "'command tr' was hijacked" "expected 3, got '$hostile'" +fi + +# 3. Negative control: the bare form MUST be hijacked. If this stops failing, +# the environment no longer reproduces the bug and assertion 2 proves +# nothing — better to know that than to trust a green test. +naive=$(zsh -c ' + alias tr="echo HIJACKED" + setopt aliases + eval "printf \"a\nb\nc\n\" | wc -l | tr -d \" \"" +' 2>/dev/null | command tr -d ' \n') +if [[ "$naive" == *HIJACKED* ]]; then + _ok "negative control: bare 'tr' IS hijacked, so the guard is meaningful" +else + _bad "negative control did not reproduce the hijack" \ + "got '$naive' — assertion 2 may be vacuous; check zsh alias semantics" +fi + +echo "" +echo "=== $PASS passed, $FAIL failed ===" +[[ $FAIL -eq 0 ]]