Directory Walk Statistics CLI
A streaming directory walker that reports file count, total bytes, and per-extension breakdowns, skipping symlinks by default for safe traversal.
Project
node-fs-walk-cli
Entry point: walkstats.mjs
Install
node --versionRun
node walkstats.mjs ./srcTest
node --test walkstats.test.mjsExpected output
For a temp tree of text and json files the CLI prints totals plus a per-extension table sorted by bytes.Dependencies
- node >=20
Security note
Symlinks are skipped to avoid cycles and escaping the target root; pass followSymlinks explicitly to opt in.
What it does
Walks a directory tree iteratively, skipping symlinks by default, and reports file counts, byte totals, and per-extension breakdowns sorted by bytes.
import { readdirSync, statSync, lstatSync } from 'node:fs';
import { join } from 'node:path';
export function walk(root, { followSymlinks = false } = {}) {
const files = [];
const stack = [root];
while (stack.length) {
const dir = stack.pop();
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
const st = entry.isSymbolicLink() && !followSymlinks ? null : lstatSync(full);
if (entry.isDirectory() || (st && st.isDirectory())) stack.push(full);
else if (entry.isFile()) files.push(full);
}
}
return files;
}
export function stats(root, options) {
const files = walk(root, options);
const byExt = new Map();
let bytes = 0;
for (const file of files) {
const st = statSync(file);
bytes += st.size;
const ext = file.includes('.') ? file.slice(file.lastIndexOf('.') + 1) : '(none)';
const agg = byExt.get(ext) ?? { files: 0, bytes: 0 };
agg.files += 1;
agg.bytes += st.size;
byExt.set(ext, agg);
}
return { files: files.length, bytes, byExt: Object.fromEntries([...byExt.entries()].sort((a, b) => b[1].bytes - a[1].bytes)) };
}
const [, , root] = process.argv;
if (root) {
const s = stats(root);
console.log('files=' + s.files + ' bytes=' + s.bytes);
for (const [ext, agg] of Object.entries(s.byExt)) {
console.log('.' + ext + ' files=' + agg.files + ' bytes=' + agg.bytes);
}
}
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, basename } from 'node:path';
import { walk, stats } from './walkstats.mjs';
function makeTree() {
const dir = mkdtempSync(join(tmpdir(), 'walkstats-'));
writeFileSync(join(dir, 'a.txt'), 'hello');
mkdirSync(join(dir, 'sub'));
writeFileSync(join(dir, 'sub', 'b.json'), '{}');
writeFileSync(join(dir, 'sub', 'c.txt'), 'world!!');
return dir;
}
test('walk collects files across nested directories', () => {
const dir = makeTree();
const files = walk(dir).map((f) => basename(f)).sort();
assert.deepEqual(files, ['a.txt', 'b.json', 'c.txt']);
rmSync(dir, { recursive: true, force: true });
});
test('stats totals bytes and groups by extension', () => {
const dir = makeTree();
const s = stats(dir);
assert.equal(s.files, 3);
assert.equal(s.bytes, 5 + 2 + 7);
assert.equal(s.byExt.txt.files, 2);
assert.equal(s.byExt.json.files, 1);
rmSync(dir, { recursive: true, force: true });
});
test('symlinks are skipped unless opted in', (t) => {
const dir = makeTree();
try {
symlinkSync(join(dir, 'a.txt'), join(dir, 'link.txt'));
} catch (err) {
if (err.code === 'EPERM' || err.code === 'EACCES') {
t.skip('symlink creation not permitted on this platform');
return;
}
throw err;
}
assert.equal(walk(dir).length, 3);
rmSync(dir, { recursive: true, force: true });
});
test('extension-less files land in the none bucket', () => {
const dir = mkdtempSync(join(tmpdir(), 'walkstats-'));
writeFileSync(join(dir, 'Makefile'), 'all:');
const s = stats(dir);
assert.equal(s.byExt['(none)'].files, 1);
rmSync(dir, { recursive: true, force: true });
});
Run and verify
node --test walkstats.test.mjs
node walkstats.mjs ./src
Run the tests against generated temp trees, then point the CLI at a directory to get the totals table.
Expected Output
The test run should print passing tests for nested collection, per-extension grouping, and symlink skipping (the symlink test may report skipped on platforms without symlink permission). The CLI’s console.log output should print a totals line followed by one line per extension sorted by bytes. Against the test fixture tree — a.txt (5 bytes), sub/b.json (2 bytes), sub/c.txt (7 bytes) — the Output: is:
files=3 bytes=26
.txt files=2 bytes=12
.json files=1 bytes=2
Extension-less files such as a Makefile land in the (none) bucket instead, and the walk is iterative over an explicit stack, so deep trees never grow the call stack.
Limitations
The walk is synchronous and single-threaded; very large trees block the event loop and stat calls dominate on cold caches.