compass

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