← Runnable Projects

阅读中文版本

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.

beginnerjavascriptunverified

Project

node-fs-walk-cli

Entry point: walkstats.mjs

Install

node --version

Run

node walkstats.mjs ./src

Test

node --test walkstats.test.mjs

Expected 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.