#!/bin/bash # Classifies files by type and counts them across a project area. # # Usage: orient-files [-x|--list-unclassified] [PATH] # PATH defaults to the git toplevel of cwd, else cwd itself. # # Output: a bullet list, one row per type, sorted by count descending. # Each row has three columns: type name, count, and a hint indicating # which heuristic(s) matched. Types with fewer than 3 files are lumped # into a trailing "(other)" line so the output stays bounded. # # Example output: # - markdown: 25 (`.md`) # - typescript: 1241 (`.ts`) # - bash: 14 (shebang, `.bash`) # - image: 108 (`.png`, `.jpg`, `.gif`) # - unknown-shell: 7 (`.sh`) # - (other): 9 files across 5 kinds # # Type names are canonical (lowercased) language/format names, chosen # for legibility when scanning. Extensions without a mapping fall # through to using the literal extension as the type name, so unfamiliar # projects stay informative without needing the table to be exhaustive. # # Heuristics: # - Extension: a small handwritten table mapping `.md` -> markdown etc. # - Shebang: for executable regular files, the first 100 bytes are # read and the first line is matched against a known interpreter list # (bash, sh, zsh, dash, ksh, python, node, perl, ruby, lua, awk). # Shebang dominates extension when both produce a verdict: a `.sh` # file with `#!/usr/bin/env python3` counts as python. # The sniff is done in pure bash (no fork per file); it is capped in # total per run (see SHEBANG_CAP) because after a few hundred hits the # interpreter mix is well characterized and further sniffs are pure cost. # The hint column lists which heuristics fired for a given type: it's # a tally of "reasons we looked", not a derivation trace. # # Extensionless files (e.g. `LICENSE`, `Makefile`) and dotfiles whose # name carries no further dot (`.gitignore`, `.editorconfig`) are folded # into `(other)` along with files of rare extensions. # With `-x`/`--list-unclassified`, every file that landed in `(other)` # is itemized as nested bullets, grouped by type, so it's clear what # the lump actually contains. In practice this is a useful peek: the # `(other)` bucket often hides dotfile-shaped config that's central to # a project's identity. # # Silent (no output, exit 0) when no countable files are found. # # Bounded walk: # - This script runs at session-launch time; a stall here delays every # subsequent tool call. The traversal is bounded by a total budget of # "path handles" (each file or directory visited counts as 1), defaulting # to 20000 and overridable via COMPASS_MAX_FILES. # - Sibling subtrees share the remaining budget by fair split at descent # time; a large subtree can't starve its neighbours. When any subtree # hits its share, it's pruned and named in a truncation footer so the # partiality is visible. # - Filesystem order is used as-is (no sort). Sums are commutative, and # avoiding a sort avoids buffering a whole listing before descending. # # Filtering: # - Dotdirs (`.git`, `.venv`, etc.) are pruned entirely. # - A small list of well-known build/vendor dirs is also statically pruned # (`node_modules`, `target`, `dist`, `build`, `vendor`). # The bias is conservative: rather miss a real file under a misnamed # `vendor/` than have `node_modules` dominate every count. # - Ecosystem-aware prunes: when a directory contains a recognized # ecosystem marker (`Cargo.toml`, `package.json`, `pyproject.toml`, ...), # that ecosystem's conventional build/cache dirs get pruned *when # descending through that directory*. This picks up common heavy dirs # without needing the static list to enumerate every variant. set -euo pipefail list_unclassified=0 while [ $# -gt 0 ]; do case "$1" in -x|--list-unclassified) list_unclassified=1; shift ;; -h|--help) sed -n '2,/^$/p' "$0" | sed 's/^# \?//' exit 0 ;; --) shift; break ;; -*) >&2 echo "orient-files: unknown option: $1"; exit 2 ;; *) break ;; esac done # Resolve the search root via the single source of truth. # `find-project-root` is strict (git toplevel or exit 1); we add a cwd # fallback here since classifying files outside a project is still useful. self_dir=$(cd "$(dirname "$(readlink -f "$0")")" && pwd) if [ $# -ge 1 ]; then root=$(cd "$1" && pwd) elif root=$("$self_dir/find-project-root" 2>/dev/null); then : else root="$PWD" fi # Extension -> canonical type name. # Names roughly follow github-linguist's language list (consulted for # inspiration, not pulled in as a dependency). # `.sh` maps to `unknown-shell` rather than `bash`: the actual interpreter # of a bare `.sh` file depends on the system `/bin/sh`, which varies by # distro, so flagging that ambiguity is more honest than guessing. # Shebang-based detection resolves specific shells for executable files. declare -A ext_to_type=( [.md]=markdown [.markdown]=markdown [.rs]=rust [.py]=python [.ts]=typescript [.tsx]=typescript [.js]=javascript [.mjs]=javascript [.cjs]=javascript [.jsx]=javascript [.go]=go [.v]=vlang [.c]=c [.h]=c [.cpp]=cpp [.cc]=cpp [.cxx]=cpp [.hpp]=cpp [.hh]=cpp [.java]=java [.rb]=ruby [.php]=php [.cs]=csharp [.swift]=swift [.kt]=kotlin [.kts]=kotlin [.lua]=lua [.zig]=zig [.dart]=dart [.scala]=scala [.ex]=elixir [.exs]=elixir [.erl]=erlang [.hs]=haskell [.clj]=clojure [.cljs]=clojure [.r]=r [.R]=r [.jl]=julia [.pl]=perl [.pm]=perl [.sql]=sql [.sh]=unknown-shell [.bash]=bash [.nu]=nushell [.json]=json [.yaml]=yaml [.yml]=yaml [.toml]=toml [.html]=html [.htm]=html [.css]=css [.xml]=xml [.ini]=ini [.csv]=csv [.tsv]=tsv [.png]=image [.jpg]=image [.jpeg]=image [.gif]=image [.svg]=image [.webp]=image [.txt]=text [.log]=log ) # Interpreter name (as it appears in a shebang) -> canonical type name. # Order in the regex matters only for completeness, not for precedence: # any single file's shebang names one interpreter. declare -A interp_to_type=( [bash]=bash [sh]=sh [zsh]=zsh [dash]=dash [ksh]=ksh [python]=python [python2]=python [python3]=python [node]=javascript [nodejs]=javascript [perl]=perl [ruby]=ruby [lua]=lua [awk]=awk [gawk]=awk [mawk]=awk ) # Static prune list -- directory basenames we never descend into. # Kept as an associative array for O(1) lookup during the walk. # Dotdirs are pruned separately by pattern (".*") so they don't need entries here. declare -A static_prune=( [node_modules]=1 [target]=1 [dist]=1 [build]=1 [vendor]=1 ) # Ecosystem marker table. # Presence of the marker file in a directory adds the listed basenames to # the prune set *for that directory's children only*. This localizes the # prune to where it's actually appropriate: a `target/` next to a Cargo.toml # is a Rust build dir; a `target/` in some random directory might not be. # Multiple markers accumulate. Order within a value is irrelevant. declare -A ecosystem_prunes=( [Cargo.toml]="target" [package.json]="node_modules .next .nuxt" [pyproject.toml]="__pycache__ .venv venv .tox .pytest_cache .mypy_cache .ruff_cache" [setup.py]="__pycache__ .venv venv .tox .pytest_cache .mypy_cache .ruff_cache" [mix.exs]="_build deps" [pom.xml]="target" [build.gradle]="build .gradle" [build.gradle.kts]="build .gradle" [CMakeLists.txt]="build" [MODULE.bazel]="" [WORKSPACE]="" ) # `bazel-*` prunes are handled specially: they're a prefix pattern rather # than exact names, and are triggered by any bazel marker. Encoded below # as a boolean the walker checks. # --- Budget, collectors, & helpers ------------------------------------ # # The walker is deliberately kept minimal: for each classifiable file it # just emits one tab-separated record (`shebang_type\tname`) into the # `records` global. All the per-file classification math (extension # lookup, per-type tallies, hint composition) happens in a single awk # pass afterwards. # # We measured pure-bash classification-per-file at ~1ms/file on this # machine -- dominated by function-call overhead and associative-array # manipulation. Awk chews the same records in a fraction of a second. # The recursive walker itself is fine (~1s / 100k entries). # # Shebang sniffing has to happen during the walk (it reads bytes off the # actual file), so it runs inline, is capped by SHEBANG_CAP, and its # verdict is embedded in the record. budget_max=${COMPASS_MAX_FILES:-20000} budget_used=0 # Wallclock backstop. # The budget above bounds *algorithmic* work; this backstop protects # against pathological I/O (network mounts, symlink loops we missed, # spinning rust with a huge cold cache). Uses bash's `SECONDS` builtin, # which auto-increments and is essentially free to read. # Not configurable via env for now -- if this ever fires in normal use # we have a real problem to fix, not a knob to tune. WALLCLOCK_SECS=10 SECONDS=0 wallclock_hit=0 # Total shebang sniffs performed. Capped because a few hundred is plenty # to characterize the interpreter mix; beyond that we're just paying I/O. shebang_sniffs=0 SHEBANG_CAP=200 # One record per classified file, tab-separated: `shebang_type\tname`. # `shebang_type` is empty if the file wasn't sniffed or the sniff missed. records=() # Truncation records. Each entry is "path\tcount", where count is how many # path handles were spent in that subtree before it was cut off. truncated=() # sniff_shebang: identify a script's interpreter, in pure bash (no fork). # Reads up to 100 bytes of the first line via bash `read`. Echoes the # canonical type name on a match, or nothing on a miss. # Uses `interp_to_type` from the enclosing scope. # Caveat: bash `read` silently discards NUL bytes, so a file starting with # NULs then `#!` would false-positive; this is not a real shape for scripts. sniff_shebang() { local path=$1 local line tok base name IFS= read -r -n 100 line <"$path" || : [[ $line == '#!'* ]] || return 0 line=${line#'#!'} for tok in $line; do [[ $tok == -* ]] && continue base=${tok##*/} [[ $base == env ]] && continue name=${interp_to_type[$base]:-} [ -n "$name" ] && printf '%s' "$name" return 0 done } # walk_dir: recursively census one directory, respecting a local cap. # # @param dir absolute path to the directory # @param cap max path handles this subtree may consume (files + dirs) # # Consumes from and updates the global `budget_used`. If either the # subtree's cap or the global budget would be exceeded, the walk of this # subtree is truncated and recorded in `truncated`. # # The count of "path handles" charges 1 per entry visited (file or dir), # whether it ends up classified, pruned, or descended. It's a proxy for # how many path strings the walker touches -- close enough to real cost. # # @state (global) budget_used, type_counts, type_exts, type_shebang, # ext_counts, type_files, truncated, shebang_sniffs walk_dir() { local dir=$1 cap=$2 local start=$budget_used # Enumerate depth-1 entries. `dotglob` catches `.hidden` names (but # not `.`/`..`, which are excluded by bash regardless); `nullglob` makes # an empty dir yield an empty array rather than a literal `*`. # We use bash globbing rather than forking `find` per directory: for a # 20k-entry walk that's a few hundred to a few thousand directory reads, # and paying a fork each would dominate the runtime. local -a entries shopt -s dotglob nullglob entries=("$dir"/*) shopt -u dotglob nullglob # First pass: determine ecosystem-driven prunes for THIS directory. # We look at the entry names (not stat) so this pass is free. local -A local_prune=() local bazel_prune=0 local entry base for entry in "${entries[@]}"; do base=${entry##*/} if [ -n "${ecosystem_prunes[$base]:-}" ]; then local p for p in ${ecosystem_prunes[$base]}; do local_prune[$p]=1 done fi # Bazel markers (WORKSPACE, MODULE.bazel) trigger prefix pruning # of `bazel-*` symlinks Bazel creates at the workspace root. if [ "$base" = "WORKSPACE" ] || [ "$base" = "MODULE.bazel" ]; then bazel_prune=1 fi done # Second pass: classify files, collect subdirs. local -a subdirs=() local is_dotdir for entry in "${entries[@]}"; do base=${entry##*/} # Charge one path handle per entry seen. budget_used=$((budget_used + 1)) # Cap checks. If we're over either cap, or past the wallclock # backstop, stop processing this directory entirely. Any remaining # entries here (and their subtrees) are the truncation. if [ $((budget_used - start)) -gt "$cap" ] || [ "$budget_used" -ge "$budget_max" ]; then truncated+=("$dir"$'\t'"$((budget_used - start))") return 0 fi if [ "$SECONDS" -ge "$WALLCLOCK_SECS" ]; then wallclock_hit=1 truncated+=("$dir"$'\t'"$((budget_used - start))") return 0 fi if [ -d "$entry" ] && [ ! -L "$entry" ]; then # Directory. Apply prune rules; otherwise enqueue for descent. case "$base" in .*) continue ;; # dotdir: prune esac if [ -n "${static_prune[$base]:-}" ]; then continue; fi if [ -n "${local_prune[$base]:-}" ]; then continue; fi if [ "$bazel_prune" -eq 1 ] && [[ $base == bazel-* ]]; then continue; fi subdirs+=("$entry") elif [ -f "$entry" ]; then # Regular file (also catches symlinks-to-files by virtue of -f). local shebang_type="" if [ -x "$entry" ] && [ "$shebang_sniffs" -lt "$SHEBANG_CAP" ]; then shebang_sniffs=$((shebang_sniffs + 1)) shebang_type=$(sniff_shebang "$entry") fi records+=("${shebang_type}"$'\t'"${base}") fi # Other entry kinds (sockets, fifos, dangling symlinks) are silently ignored. done # Descend into subdirs with fair-share budgets. # At each step: share = min(remaining_local, remaining_global) / remaining_siblings. # Unspent share flows naturally back into the pool for later siblings # because we compute the divisor from what's left, each time. local n=${#subdirs[@]} i=0 sub share remaining_local remaining_global for sub in "${subdirs[@]}"; do remaining_local=$((cap - (budget_used - start))) remaining_global=$((budget_max - budget_used)) if [ "$remaining_local" -le 0 ] || [ "$remaining_global" -le 0 ]; then # Record every remaining sibling as truncated at 0 -- they never got # a chance. Distinct from "walked partially" so the report can show it. local j for (( j=i; j= 1; i--) { if (substr(s, i, 1) == ".") { idx = i; break } } if (idx == 0 || idx == n) return "(none)" return "." substr(s, idx + 1) } in_records { shebang_type = $1 name = $2 ext = classify_ext(name) if (shebang_type != "") { type = shebang_type type_shebang[type] += 1 } else if (ext == "(none)") { type = "extensionless" } else { type = (ext in ext_to_type) ? ext_to_type[ext] : ext ext_counts[ext] += 1 if (!(type in type_exts)) { type_exts[type] = ext } else if (index(" " type_exts[type] " ", " " ext " ") == 0) { type_exts[type] = type_exts[type] " " ext } } type_counts[type] += 1 if (list_unclassified) { if (!(type in type_files)) { type_files[type] = name } else { type_files[type] = type_files[type] "\n" name } } } # Comparator: count desc, name asc. Emulated by inserting into a # sortable array of "NNNNNNNN\ttype" strings, then sorting via asort. END { if (length(type_counts) == 0) exit 0 # Build sortable rows. n = 0 for (t in type_counts) { n += 1 rows[n] = sprintf("%08d\t%s", 99999999 - type_counts[t], t) } # Ascending sort of rows now yields our desired order (count desc, then name asc). asort(rows) # Split into shown (count >= 3) and other buckets. OTHER_THRESHOLD = 3 shown_n = 0 other_n = 0 other_files_total = 0 for (i = 1; i <= n; i++) { split(rows[i], parts, "\t") t = parts[2] c = type_counts[t] if (c >= OTHER_THRESHOLD) { shown_n += 1 shown_type[shown_n] = t shown_count[shown_n] = c } else { other_n += 1 other_type[other_n] = t other_files_total += c } } # Emit per-type rows. for (i = 1; i <= shown_n; i++) { t = shown_type[i] c = shown_count[i] hint = "" if (t in type_shebang) hint = "shebang" if (t in type_exts) { # Sort this types extensions by their per-ext count desc, name asc. split(type_exts[t], exts_arr, " ") em = 0 delete ext_rows for (k in exts_arr) { em += 1 ext_rows[em] = sprintf("%08d\t%s", 99999999 - ext_counts[exts_arr[k]], exts_arr[k]) } asort(ext_rows) for (k = 1; k <= em; k++) { split(ext_rows[k], eparts, "\t") ext_name = eparts[2] if (hint == "") hint = "`" ext_name "`" else hint = hint ", `" ext_name "`" } } printf "- %-16s %5d (%s)\n", t ":", c, hint } # Emit the (other) summary line if anything landed there. if (other_n > 0) { fw = (other_files_total == 1) ? "file" : "files" kw = (other_n == 1) ? "kind" : "kinds" printf "- (other): %d %s across %d %s\n", other_files_total, fw, other_n, kw } if (list_unclassified && other_n > 0) { for (i = 1; i <= other_n; i++) { t = other_type[i] printf " - %s (%d):\n", t, type_counts[t] split(type_files[t], files_arr, "\n") fn = 0 delete sorted_files for (k in files_arr) { fn += 1; sorted_files[fn] = files_arr[k] } asort(sorted_files) for (k = 1; k <= fn; k++) { printf " - `%s`\n", sorted_files[k] } } } } ' # Truncation footer. # # Naming every capped subtree gets very noisy fast (a wide monorepo can # have hundreds of siblings all capped at ~equal shares). Show the top # 10 by budget spent, then summarize the rest with a count. The top-10 # picks out the subtrees where the walker got farthest before giving up # -- these are usually the more interesting/heavy ones to know about. if [ ${#truncated[@]} -gt 0 ]; then if [ "$wallclock_hit" -eq 1 ]; then echo "- (truncated: wallclock ${WALLCLOCK_SECS}s reached; ${budget_used} path handles walked)" else echo "- (truncated: budget of ${budget_max} path handles exhausted; ${budget_used} spent)" fi TOP_N=10 mapfile -t sorted_trunc < <( for entry in "${truncated[@]}"; do printf '%s\n' "$entry" done | sort -t$'\t' -k2,2rn ) shown=0 for entry in "${sorted_trunc[@]}"; do if [ "$shown" -ge "$TOP_N" ]; then break; fi sub_path=${entry%%$'\t'*} sub_count=${entry##*$'\t'} rel=${sub_path#"$root"/} [ "$rel" = "$sub_path" ] && rel=$sub_path echo " - \`${rel}/\` capped at ${sub_count}" shown=$((shown + 1)) done if [ ${#truncated[@]} -gt "$TOP_N" ]; then echo " - ... ($((${#truncated[@]} - TOP_N)) more capped subtrees)" fi fi