add orient-fileexts: count files by extension across a project area
commit 37decbe12923458140132334cc2aa9b459b7b80d · parent 8e40b02d6a
· browse this tree
Ximianth, 2026-06-24 14:10:02 UTC
Quick "what is this project?" signal: file counts grouped by extension, sorted by frequency, with rare extensions (<3 files) lumped into a single trailing "(other)" line so the output stays bounded on diverse trees. - Prunes dotdirs and a small list of well-known build/vendor dirs (`node_modules`, `target`, `dist`, `build`, `vendor`). - Groups extensionless files and dotfiles like `.gitignore` under `(none)`, since those aren't really extensions in the language sense. - Suppresses `find`'s permission-denied stderr noise (common when running under shared trees like /tmp), but passes other errors through so real problems still surface.
diff --git a/README.md b/README.md index a3187ff..28b7362 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,13 @@ or compose into larger summaries (e.g. an agent's system prompt). ## Scripts -- `orient-cwd` -- describes the current working directory. -- `orient-git` -- describes the surrounding git repo, if any. -- `orient-project` -- inlines root orientation docs and detects a meta repo. +- `orient-cwd` -- describes the current working directory. - `orient-entrypoints` -- detects entrypoint systems (Makefile etc.) and lists their targets. -- `skim-makefile` -- summarizes a Makefile's targets as a bullet list. -- `summarize-md-toc` -- emits a markdown file's heading structure as a bullet list. +- `orient-fileexts` -- counts files by extension across a project area. +- `orient-git` -- describes the surrounding git repo, if any. +- `orient-project` -- inlines root orientation docs and detects a meta repo. +- `skim-makefile` -- summarizes a Makefile's targets as a bullet list. +- `summarize-md-toc` -- emits a markdown file's heading structure as a bullet list. Scripts share verb-prefix conventions (`orient-*`, `summarize-*`, ...) so they tab-complete as groups diff --git a/orient-fileexts b/orient-fileexts new file mode 100755 index 0000000..a600f8b --- /dev/null +++ b/orient-fileexts @@ -0,0 +1,109 @@ +#!/bin/bash +# Counts files by extension across a project area. +# +# Usage: orient-fileexts [PATH] +# PATH defaults to the git toplevel of cwd, else cwd itself. +# +# Output: a bullet list of `.ext: N` lines, sorted by count descending. +# Extensions with fewer than 3 files are lumped into a trailing +# "(other)" line so the output stays bounded on diverse projects. +# +# Silent (no output, exit 0) when no countable files are found. +# +# Filtering: +# - Dotdirs (`.git`, `.venv`, etc.) are pruned entirely. +# - A small list of well-known build/vendor dirs is also 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. +# - Dotfiles with no further dot (`.gitignore`, `.editorconfig`) are +# grouped under `(none)` rather than reported as an `.editorconfig` +# extension; they're not really extensions in the language sense. + +set -euo pipefail + +# Resolve the search root. +if [ $# -ge 1 ]; then + root=$(cd "$1" && pwd) +elif root=$(git rev-parse --show-toplevel 2>/dev/null); then + : +else + root="$PWD" +fi + +# Walk the tree, pruning noise dirs. +# `find` prune semantics: match the prune predicate, then `-o -print` the rest. +# We prune by basename so the rules apply at any depth. +mapfile -t exts < <( + find "$root" \ + \( \ + -type d \ + \( -name '.*' \ + -o -name 'node_modules' \ + -o -name 'target' \ + -o -name 'dist' \ + -o -name 'build' \ + -o -name 'vendor' \ + \) -prune \ + \) -o \ + \( -type f -printf '%f\n' \) \ + 2> >(grep -v 'Permission denied' >&2) \ + | awk ' + { + # Strip leading dot for dotfiles before extension extraction, + # so `.gitignore` is treated as having no extension rather than + # an extension of "gitignore". + name = $0 + if (substr(name, 1, 1) == ".") name = substr(name, 2) + + # Find the last dot; everything after it is the extension. + # No dot, or trailing dot, means no extension. + n = length(name) + idx = 0 + for (i = n; i >= 1; i--) { + if (substr(name, i, 1) == ".") { idx = i; break } + } + if (idx == 0 || idx == n) { + print "(none)" + } else { + print "." substr(name, idx + 1) + } + } + ' \ + | sort | uniq -c | sort -rn +) + +# Silent if nothing to report. +if [ ${#exts[@]} -eq 0 ]; then + exit 0 +fi + +# Split into shown vs. lumped buckets at the count >= 3 threshold. +# (Threshold chosen empirically: 1 or 2 files is rarely a meaningful +# signal about a project's character; 3 starts to suggest a pattern.) +# `uniq -c` emits lines like " 25 .md" with leading whitespace, +# so we use `read` to split on whitespace cleanly. +shown_counts=() +shown_exts=() +other_files=0 +other_kinds=0 +for line in "${exts[@]}"; do + read -r count ext <<<"$line" + if [ "$count" -ge 3 ]; then + shown_counts+=("$count") + shown_exts+=("$ext") + else + other_files=$((other_files + count)) + other_kinds=$((other_kinds + 1)) + fi +done + +for i in "${!shown_counts[@]}"; do + echo "- \`${shown_exts[$i]}\`: ${shown_counts[$i]}" +done +# Pluralize cleanly: "1 file" / "N files", "1 extension" / "N extensions". +if [ "$other_kinds" -gt 0 ]; then + file_word="files"; [ "$other_files" -eq 1 ] && file_word="file" + ext_word="extensions"; [ "$other_kinds" -eq 1 ] && ext_word="extension" + echo "- (other): ${other_files} ${file_word} across ${other_kinds} ${ext_word}" +fi