Skip to content
dirstat v1 specification
Edit
On this page

Authoritative v1 specification for dirstat, with numbered requirements for the CLI, traversal, classification, canonical naming, statistics, and output.

#dirstat v1 specification

dirstat summarizes the files in a directory tree, grouped by format, with aggregate statistics (counts, sizes, lines of code) rendered as a colored terminal table or as JSON. It is a clean-slate Go redesign of an internal Python prototype, built for speed: no subprocesses, parallel scanning, single static binary.

This document is the authoritative spec for the initial implementation. Every requirement is numbered (R1, R2, ...) so audits can address them individually.

#1. Project shape

  • R1. Go module github.com/smm-h/dirstat, Go directive go 1.25.7, flat

package main at the repo root (no cmd/), matching safegit/saferm layout.

  • R2. CLI built on github.com/smm-h/strictcli/go/strictcli, unpinned latest.

.strictcli/schema.json committed.

  • R3. rlsbl-managed: .rlsbl/config.json with targets: ["go"], pipeline

{"type": "go", "local": true, "install_paths": ["."]}, private: false, pre-release hooks running go vet ./..., go build -o /dev/null ., and go test ./... -race -count=1. JSONL changelog in use from the first commit.

  • R4. selfdoc-managed root templates only: selfdoc.json with

root_files: [".stricttools/docs/_README.md", ".stricttools/docs/_CLAUDE.md"]; no docs site, no deploy config. Generated README.md/CLAUDE.md are committed and chmod 444.

  • R5. Version 0.1.0 in VERSION; binary version via -X main.Version ldflags

with debug.ReadBuildInfo() fallback. The symbol name must match the one .goreleaser.yml injects: the linker silently ignores an -X naming a symbol that does not exist. MIT LICENSE, Copyright (c) 2026 smm-h.

  • R6. Dependencies: strictcli, github.com/gabriel-vasile/mimetype (content

sniffing), go-git's plumbing/format/gitignore package (ignore matching), golang.org/x/term (terminal size), github.com/mattn/go-runewidth (display-cell width for table column measurement, R28). github.com/smm-h/go-toml-edit was added with §11 (scan config). No CGO (CGO_ENABLED=0 must work).

#2. CLI surface

  • R7. Single command: dirstat scan [where]. where is an optional positional

argument, default ".", the directory to summarize. A missing or non-directory path is a hard error (exit code for usage/input error, message to stderr).

  • R8. Flags (strictcli conventions: every flag and the positional argument

declares exactly one presence -- Required(), Optional() or Default(v); a closed set is declared as Choices(Ch(<value>, <help>) ...), one record per value, so --help describes each value beneath its flag; auto --no-* negation on bools; short names only where listed). scan is read_only, so a declared default is legal on it -- the mutating-default ban reaches only commands that write. The Default column below reads optional where the flag declares no value at all:

2. CLI surface
FlagTypeDefaultSemantics
--methodchoice: ext, type, hybridhybridGrouping method (§3)
--formatschoice: raw, canonicalrawHow the chosen group's name is spelled (R48, R49)
--depthint-1Max directory depth below root; -1 = unlimited; root is depth 0
--excludestring, repeatable, uniquecurated list (R45)Exact base-name matches to skip (dirs and files)
--configstring"" (none)Path to a TOML scan-config file (§12); no default path, never auto-discovered
--ignoredchoice: include, exclude, onlyexcludeGitignored-path handling (§5)
--hiddenchoice: include, excludeincludeDot-prefixed entries; the root dir itself is never treated as hidden
--statsstring, repeatable, uniqueoptional (absent = all)Which stats to compute/show; valid: count, total-size, min-size, max-size, avg-size, total-loc, min-loc, max-loc, avg-loc; invalid value = hard error
--typechoice: text, binary, bothbothFilter groups by text/binary classification
--sort-bystring, repeatable, uniquecountSort columns, in precedence order; valid: format plus the nine stat names; invalid = hard error
--sort-orderchoice: asc, descdescApplied to all sort columns
--topint-1Keep only the first N groups after sorting (table only); -1 or 0 = all; values below -1 are a usage error; in split mode applies per table
--showchoice: summary, table, bothbothWhich sections to render (table output only)
--combinedbooltrueOne merged table vs separate text/binary tables
--singletonschoice: show, collapseshowCollapse one-file formats into a (singletons) row (table only, §7)
--list-no-extboolfalseList the paths of extensionless files after the summary
--legendbooltrueText/binary color legend under the combined table
--colorsbooltrueANSI colors (auto-disabled when stdout is not a TTY)
--humanbooltrueHuman-readable sizes and thousands separators (table only)
--stylechoice: unicode, asciiunicodeTable border character set
  • R9. Flag semantics that the prototype got wrong and dirstat must define cleanly:

rendering-only flags (--show, --combined, --singletons, --legend, --colors, --human, --style, --top) have no effect on the machine payload. --sort-by/--sort-order order groups in both outputs. --stats limits which stats are computed and emitted in both outputs. --list-no-ext adds data to both outputs (a section in table mode, a field in the payload).

#3. Grouping methods

  • R10. Extension normalization: last-dot suffix, lowercased, dot stripped

(Path.suffix semantics: archive.tar.gzgz; dotfiles like .bashrc and bare names like Makefile have no extension).

  • R11. ext: group name is the extension, or (no extension); text/binary decided

by the extension list only; no content sniffing ever.

  • R12. type: every file is content-sniffed; group name is the detected MIME type

or (unknown) when detection fails; text/binary via the MIME rule (R15).

  • R13. hybrid (default): a file whose extension is in the text-extensions list is

text without sniffing (the map is trusted on a hit). A file whose extension misses the list is content-sniffed: the group name stays the extension, and the MIME rule (R15) alone decides text/binary — a missing map entry never means binary. Extensionless files are sniffed and grouped by MIME type, falling back to (no extension) when sniffing yields nothing. A sniff that cannot read the file makes the file unreadable (R20).

Added after 0.4.0, and numbered from the end of the requirement list per the numbering convention: --formats decides how the group name chosen by R11–R13 is spelled, without changing which files are grouped together by method, which files are sniffed, or the text/binary verdict.

  • R48. --formats raw (default) counts every file under the group name R11–R13

produced, verbatim: the normalized extension, the sniffed MIME type, or (no extension)/(unknown). --formats canonical maps that name through an embedded alias table (internal/config/data/canonical_formats.txt, old new pairs, one per line, # comments) before the file is counted, so alias formats merge into one group: mjs/cjs into js, mts/cts into ts, h into c, hh/hpp into cpp, and the script MIME types the sniffer emits (text/x-python, text/x-shellscript, text/x-perl, text/x-ruby, text/x-php, text/x-lua, text/x-tcl) into their format names. A name the table does not list is unchanged. A malformed line in the table is a hard error when the table is read, never a skipped line. formats is also a scan config key (R42), with the flag/file conflict rule of R43 applying to it.

  • R49. Under --formats canonical, an extensionless file whose first line is a

shebang is named by its interpreter, before the MIME sniff gets a say: the sniffer has no signature for most scripts and answers text/plain, which names nothing. An env wrapper is stripped (its options, its -S and --split-string forms, and any NAME=value assignments), the uv run X and uvx X runner forms resolve to X, and a trailing version suffix is dropped (python3.12 reads as python). The mapping is python*py, bash/sh/zsh/dash/kshsh, node/deno/bunjs, perlpl, rubyrb, phpphp, fishfish, awk/gawkawk, Rscriptr, lualua. An interpreter outside the mapping falls through to the sniff, whose result still goes through the alias table (R48). The sniff still runs either way: it decides text/binary (R15) and counts toward Files sniffed, and the shebang only names the group. ext, which reads no content at all (R11), is untouched.

  • R50. The payload shape is unchanged by --formats (R34): only group names,

their counts and unique_formats differ, so raw output stays byte-identical to what pre---formats versions produced. Tests: table-driven unit tests for the alias-table parser and the shebang parser (env forms, runner forms, version stripping, unknown-interpreter fallthrough), a config-key test, and integration tests pinning the merged groups and a shebang-named group against the real embedded table.

#4. Text/binary classification

  • R14. Extension-classified files are text iff the extension is in the embedded

text-extensions list. A zero-byte file is text under every method, whatever its extension: an empty file has no bytes that could make it binary, and it contributes 0 LOC. Where the extension decides the verdict, the size short-circuits it and no content is read; where the method groups by MIME type (R12, and R13's extensionless path) the sniff still runs to name the group but cannot override emptiness.

  • R15. MIME-classified files are text iff the MIME type starts with text/ or is in

the embedded text-mimetypes list. The result is a strict bool.

  • R16. Both lists are embedded via go:embed, seeded from the prototype's

lists and extended since (about 210 extensions incl. svg, Godot's text formats, mod/sum and the shader languages, but deliberately not lock, which is text for some tools and binary for others and must therefore be sniffed per file; about 45 mimetypes incl. image/svg+xml, application/json, application/x-empty, inode/x-empty). No runtime config files, no lookup in cwd/HOME/XDG. Changing the lists means a rebuild.

#5. Traversal

  • R17. Walk with filepath.WalkDir-level efficiency (DirEntry, no redundant stat).

Root is depth 0. Directories beyond --depth, matching --exclude, hidden (when excluded), or gitignored (when excluded) are pruned: not counted, not descended into.

  • R18. Gitignore handling is fully in-process using go-git's gitignore package:

patterns from every .gitignore in the tree, .git/info/exclude, and the global core.excludesFile. Modes: include = no matching at all; exclude = drop ignored paths; only = keep only ignored paths (only prunes ignored dirs' contents from exclusion — files under an ignored dir count as ignored). When the target is not inside a git work tree, exclude and only behave as if no patterns exist; this must be documented in the flag help.

  • R19. Symlinks are never followed — neither file nor directory symlinks — but every

symlink encountered in any traversed directory is counted, so the symlink count does not depend on traversal order (fixes a prototype bug where symlinks under pruned dirs were missed; symlinks under pruned dirs are legitimately out of scope, but all symlinks in traversed dirs must be counted even when they would be filtered by name rules).

  • R20. Unreadable directories and files (permission errors, stat failures) are

skipped and counted in a summary stat Unreadable (skipped). Never a warning spew; never a crash.

  • R21. Per-file work (LOC counting, content sniffing) runs in a worker pool sized to

runtime.GOMAXPROCS(0). Results must be deterministic regardless of scheduling.

#6. Statistics

  • R22. Per group: count, total-size, min-size, max-size, avg-size, and for

text groups total-loc, min-loc, max-loc, avg-loc. Binary groups have no LOC values (rendered -, JSON null). Averages are rounded to nearest integer, not floored. Stats not requested via --stats are neither computed nor shown (LOC is expensive; skipping it must actually skip the file reads), with one exception: sorting by a LOC key via --sort-by forces LOC computation even when LOC stats are not displayed.

  • R23. LOC = number of \n bytes, plus 1 if the file is non-empty and does not end

with \n. Counted with buffered bytes.Count-style reads (no line decoding, no size cap). Read errors count the file as unreadable (R20), not LOC 0.

  • R24. Summary stats: Directories, Files, Files without extension,

Max depth, Symlinks (skipped), Executables, Unique formats, Files sniffed, Unreadable (skipped). Files without extension counts only files that passed all filters including --type (fixes prototype bug). Executables = regular files with any execute permission bit. Files sniffed = number of content-sniff operations performed.

#7. Table output

  • R25. Section order: summary (heading Summary, a rule line, label: value

lines), then table(s), then optional extensionless-file list, respecting --show.

  • R26. Combined mode: one table Files by Format with text rows and binary rows

distinguished by color. Split mode (--no-combined): Text Files by Format and Binary Files by Format; the binary table omits LOC columns. Row-building logic must be shared, not duplicated per table (prototype wart).

  • R27. Columns in fixed order: Format, Count, Total Size, Min Size,

Max Size, Avg Size, Total LOC, Min LOC, Max LOC, Avg LOC — each present only if its stat is selected. Sizes humanized in 1024-based units with one decimal (except bare bytes) when --human; raw integers otherwise. Counts get thousands separators when --human.

  • R28. Width adaptation: query terminal width (x/term; fallback 80 when not a

TTY). If the table overflows, shrink only the Format column down to a floor of 10, truncating cells with a .. marker. Column widths are measured in terminal display cells (runewidth), not bytes, and truncation never splits a UTF-8 rune. MIME-type group names (any name containing a /) truncate from the left, keeping the subtype and prefixing ..: their prefix is shared and uninformative, so tail truncation would render every application/* group as the same indistinguishable applicat... All other names keep their prefix and take the marker as a suffix. Whenever colors are inactive (non-TTY or --no-colors), width is pinned to 80 so that --no-colors output is byte-identical to non-TTY output (R30).

  • R29. Border styles: unicode (light box-drawing, the prototype's look) and

ascii (+ - |). One shared renderer parameterized by charset.

  • R30. Colors: ANSI 256-color themes (dark and light) embedded as data; theme keys:

text/binary/header fg+bg, stat label, stat value, error, border. Dark unless COLORFGBG clearly indicates a light background. Colors apply only when --colors is true AND stdout is a TTY. --no-colors output must be byte-identical to non-TTY output. The theme's error color is applied to the error: prefix of stderr error messages when stderr is a TTY and --colors is true (stderr coloring is independent of machine mode).

  • R31. Legend (■ Text files ■ Binary files) renders only when --legend,

--combined, --type both, and colors are active (it is meaningless without color).

  • R32. --singletons collapse: groups with exactly one file are merged into a

single (singletons) pseudo-row (count = number of merged groups, sizes and text-only LOC aggregated); the row participates in sorting by its aggregate values and renders in a neutral (header) color. In split mode the collapse happens within each table independently.

  • R33. Empty result → No files found. instead of an empty table.

#8. Machine output

  • R34. Machine output is strictcli's --json mode, and dirstat declares no

output-format flag of its own. Under --json, stdout carries exactly one document -- the framework's envelope -- and no table; the scan document is the envelope's payload member. No ANSI codes ever, raw integer values (no humanization). The payload is a consumer contract (external tools will parse it): field names are stable, evolution is additive-only.

  • R35. Payload shape, declared to the framework as a JSON Schema on the scan

command and validated at emission, so a deviating document fails the run instead of reaching a consumer:

json { "root": "/abs/path", "method": "hybrid", "summary": { "directories": 0, "files": 0, "files_without_extension": 0, "max_depth": 0, "symlinks": 0, "executables": 0, "unique_formats": 0, "files_sniffed": 0, "unreadable": 0 }, "groups": [ { "format": "go", "text": true, "count": 0, "total_size": 0, "min_size": 0, "max_size": 0, "avg_size": 0, "total_loc": 0, "min_loc": 0, "max_loc": 0, "avg_loc": 0 } ], "no_extension_files": ["relative/path"] }

The binary's version is the envelope's app_version and is not restated in the payload. Groups are ordered per --sort-by/--sort-order. Stats absent from --stats are omitted from group objects; LOC fields are null for binary groups. no_extension_files (paths relative to root, sorted) present only with --list-no-ext. --top and --singletons do not affect the payload (R9).

#9. Sorting

  • R36. Multi-key sort over --sort-by columns with a single --sort-order

direction. format compares case-insensitively. For LOC keys, binary groups sort as 0. Ties broken by format name ascending so output is deterministic.

#10. Testing

  • R37. Unit tests colocated per package, table-driven. Integration tests in

internal/test/ build the real binary once (TestMain pattern from saferm) and run it against fixture trees created with t.TempDir() (including: nested gitignore, hidden files, symlinks, permission-denied entries where the platform allows, extensionless files, empty dirs).

  • R38. Golden-file tests for machine output: fixture tree in, committed golden

envelope compared byte-for-byte (after normalizing the absolute root path and the app version, and re-indenting the one-line document). Golden files live in internal/test/testdata/.

  • R39. Determinism test: two runs over the same fixture tree produce identical

output despite the parallel worker pool.

  • R40. go vet ./... clean, gofmt clean, go test ./... -race passes.

#11. Scan config file

Added after 0.1.0. A TOML file supplying scan defaults, loaded and fully validated before scanning begins. The file is used only when explicitly requested via --config <path> — dirstat never auto-discovers config from XDG, HOME, the current directory, or the scanned tree. Only scan-semantic keys are allowed; rendering and output options are rejected with a hard error naming the key.

  • R41. --config <path>: when set, the file MUST exist and parse as TOML; a

missing, unreadable, or malformed file is a hard error (exit 2) reporting the path and, for parse errors, line/column (go-toml-edit ParseError). Loaded and fully validated before any scanning starts.

  • R42. Allowed keys (flag names with underscores), exactly the scan-semantic set:

exclude (string array), method, formats, depth (integer), ignored, hidden, type, stats (string array), sort_by (string array), sort_order. Values are validated with the same rules as the corresponding flags (choices, valid stat names, uniqueness). Any unknown key, rendering/output key (e.g. colors, style, output, top), wrong type, or invalid value is a hard error (exit 2) naming the key and the allowed key set. where cannot be set from config.

  • R43. Conflict rule (no silent override): if a key is present in the config file

AND its flag was explicitly passed on the command line, that is a hard error (exit 2) naming the key. Explicit passing is detected from raw os.Args (matching --flag value and --flag=value forms for the R42 flag set). CLI flags for keys the file does not set remain usable alongside --config.

  • R44. Effective settings: registered flag defaults, overlaid by config-file

values, overlaid by CLI values only for keys absent from the file (R43 makes the file/CLI overlap impossible). Rendering flags are unaffected by config.

  • R45. Built-in default excludes: --exclude registers a curated default list:

.git, node_modules, .venv, vendor, build, dist, target, zig-out, zig-pkg, .next, .svelte-kit, __pycache__, .mypy_cache, .ruff_cache, .pytest_cache, .hypothesis, .gradle, .idea, .wrangler, .vscode. The list is visible in --help. An explicit exclude = [] in the config file (or any CLI --exclude) replaces it entirely; there is no additive merging anywhere.

  • R46. Visibility: when a config file is in effect, the table summary gains a

Config: <path> line (first summary line, absolute path), and the machine payload gains an additive optional field "config" (absolute path) after "method". Absent entirely when --config is not used (additive schema evolution, R34).

  • R47. Tests: unit tests for the TOML loader/validator and the argv explicitness

scanner; integration tests covering happy path, missing file, malformed TOML, unknown key, rendering key, wrong type, file/CLI conflict, exclude = [], and a golden JSON fixture exercising the config field. The default-exclude behavior change is covered by updated integration expectations (e.g. .git no longer scanned by default; --exclude "" style overrides still honored).

#12. Non-goals for v1

  • No result caching (measure first; the subprocess elimination and parallel scan

are expected to make it unnecessary).

  • No interactive/TUI mode.
  • No symlink following.
  • No auto-discovered config and no runtime classification/theme data: extension

lists, mimetype lists, and themes are embedded-only, and dirstat never picks up config from XDG, HOME, cwd, or the scanned tree. (The explicit --config scan file of §11 is the sole, opt-in file input.)

  • Never bump to 1.0.0 (stays 0.x until explicitly authorized).

More tools from this site

  • claudestream Drive Claude Code from Python: run it as a subprocess and read its output as typed events, with async and sync sessions, sandbox policies, and tools you define in Python
  • claudewheel A TUI Claude Code Launcher that lets you have more than one profile, manage sessions lifecycle, pick the exact CC version, model to use (even older unlisted ones), pick which GitHub account to use, etc.
  • fastware A batteries-included ASGI framework: msgspec JSON, a managed Granian server, dependency injection, SSE, WebSockets, auth, and a test client
  • go-toml-edit Zero-dep TOML editing library for Go with comment preservation
  • howmuchleft The fastest Claude Code statusline: context window, 5-hour, and weekly limit usage as three customizable gradient bars, rendering in about 6 ms
  • orxtra
  • pgdesign
  • predraw Declarative rendering pipeline: describe a scene in JSON and get SVG, PNG and WebP out, with light and dark style tokens, reusable components and text converted to path outlines
  • reposummary Turn a git repository's history into a Markdown journal: pick a time window or revision range and get a readable digest of what changed, optionally narrated by an LLM
  • rlsbl Release orchestration and project scaffolding CLI that bumps versions, validates a structured JSONL changelog, tags only the commit CI verified, and publishes to npm, PyPI, Go and more
  • safegit git wrapper CLI that gives each commit its own temporary index and retries ref updates on conflict, so concurrent agents share one repository
  • saferm Command-line replacement for rm that archives every deletion with a mandatory reason and the context it ran in, so deleted files can be listed, inspected and restored
  • selfdoc Static Site Generator that builds a project's documentation site directly from its source code, so the docs can never drift from the code they describe, with SEO/AEO, first-class blog, search, and cross-project linking built in
  • strictcli
  • stricttest An always-on test-isolation floor: a pytest plugin and a Go env-hygiene module that make a test suite structurally unable to reach real credentials, the real HOME, the network, or the development repository.
  • wesktop A Python framework that turns an ASGI web app into a desktop application, serving it from a local Granian server and displaying it in a native OS window via pywebview
Search