Skip to content

Latest commit

 

History

History
488 lines (435 loc) · 65 KB

File metadata and controls

488 lines (435 loc) · 65 KB

CLI Architecture

Command Pattern

The CLI uses argh for declarative arg parsing:

  • Each command is a FromArgs struct in its own module under src/cli/commands/
  • cli::TopLevel holds a top-level --version switch plus the Subcommand enum; main.rs parses argv and dispatches, answering a bare --version itself ahead of argh (cli::is_bare_version) since the subcommand argh requires is not one, so a bare tsv gets argh's own required-subcommand error
  • argh has no struct-flattening attribute, so the shared input fields (--content, --stdin, --parser, file path) are declared per command and assembled into cli::input::InputArgs for resolution

Adding Commands: Create src/cli/commands/newcmd.rs with a FromArgs struct and a run() method, add a variant to Subcommand in cli/mod.rs.

Shared Infrastructure

tsv_cli exports CLI infrastructure as a library, reused by tsv_debug for consistent UX:

  • Input handling (file, --content, --stdin) — cli/input.rs
  • File/directory discovery with extension filter, gitignore-aware ignore evaluation (hierarchical .gitignore/.formatignore/.prettierignore), and the non-git heuristic fallback — cli/discover.rs
  • The --pretty re-indenter (tab-indented form of the compact wire, no deserializer) — json_utils.rs

Binary Structure

  • tsv (production): Pure Rust, no external tool dependencies

    • Crates: tsv_cli
    • Commands: parse, format (plus the top-level --version switch)
  • tsv npm bin, native (@fuzdev/tsv): the production tsv binary itself, shipped inside each @fuzdev/tsv-<triple> platform package and exec'd by the loader's crates/tsv_napi/npm/bin.js dispatcher (argv, stdio, exit codes, and signals forwarded verbatim) — so npx tsv on the native package has this CLI's exact contract, real --jobs parallelism included. When no binary is reachable the dispatcher falls back to the JS mirror below.

  • tsv GitHub Release asset: the same production binary, one per platform package (tsv-<triple>, tsv-win32-x64.exe), attached to each GitHub Release beside a SHA256SUMS, for use without npm (curl + chmod +x). Pulled back from the npm registry at release time, so it is byte-identical to the binary the platform package ships; every asset carries a build provenance attestation (gh attestation verify <file> -R fuzdev/tsv).

  • tsv npm bin, WASM (@fuzdev/tsv-wasm): crates/tsv_wasm/npm/cli.js — a hand-written Node mirror of this CLI's contract (subcommands, flags, exit codes, output streams, traversal rules). --jobs is real: path mode fans onto node:worker_threads, spawning cli.js as its own worker. Where it differs from the native CLI is in the two defaults, both of which are smaller here:

    • When to pool at all. A pool costs tens of milliseconds to bring up here against the native pool's ~50 µs of thread spawn, so with no --jobs given the run stays single-threaded until the in-scope file count clears a threshold — measured at ~565 files on the WASM engine and ~394 on N-API, with the shipped constants set above each so a pool is only taken where it clearly pays.
    • How wide. Not the native min(logical, ceil(1.5 × physical)). That rule assumes an idle machine, and on the WASM engine V8's own wasm tier-up is already using roughly a third of one before the first worker exists — so the pool peaks at half the physical cores and regresses past it. The N-API mirror has no compiler thread to compete with and peaks at the physical core count.

    An explicit --jobs N is held to the same 4 × logical ceiling as the native CLI, warned about on stderr when it bites (see §Multi-File Formatting's parallelism note; cli.js restates the native clamp_worker_count by hand — same constant, same message, so both surfaces refuse the same numbers). The logical count under that ceiling, and under both default widths, is Rust's available_parallelism on either side: the affinity mask capped by the cgroup CPU quota. Node's and Bun's availableParallelism() leave the quota out (Deno's applies it), so cli.js reads it itself (cgroup_cpu_quota, a transcription of std's) — under a --cpus or CPUQuota= limit every runtime names the native CLI's ceiling rather than one sized past the quota. They also ACCEPT the same ones, which takes its own restatement: the flag's value is parsed as a Rust usize on one side and by a hand transcription of usize::from_str on the other (usize_from_str) — ASCII digits, an optional leading +, nothing past usize::MAX, refused in ParseIntError's own words — and scripts/test_napi_npm.ts runs the accept/reject table through both bins, which is the only place both exist at once. Repetition needs no restatement of its own: cli.js parses argv by a transcription of argh's grammar (parse_argv), so a second value for any value-taking flag (--content, --parser, --source-type, --jobs) is duplicate values provided on both. Repeated SWITCHES stay fine on both — argh counts them. The bound does a different job here than natively: a JS worker is a whole V8 isolate (~13 MB resident on either engine, where the native thread's reservation is lazily committed and costs ~none), so an unbounded width on a large tree hits the machine's memory — ending in an uncatchable OOM SIGKILL — long before the OS refuses a thread, and the file-count clamp bounds nothing on exactly the trees large enough to matter. An explicit width remains the only way to compare the two paths at a given size, which calibrating those defaults needed; every size that calibration uses is far under the ceiling on both. --content, --stdin, and --list are single-threaded on both CLIs, and so is --jobs 1 — except in cli.js over the N-API engine, where a width of 1 is a pool of one worker, since a native overflow on the main thread is a SIGSEGV no catch survives and only a pool worker carries the reserved stack (§Recursion Depth). The parallel and single-threaded paths report identically (same sorted stdout, same summary, same exit code), so the split is a cost decision and not a contract one. One source: it imports its engine from ./index.js, so the copy staged into the native @fuzdev/tsv (as the dispatcher's fallback) binds to the N-API engine with no adapter — and its workers, having no compiled module to inherit, load that engine themselves, while WASM workers take the main thread's module through the package's ./worker entry and recompile nothing. Behavioral changes to format/parse here must be mirrored there and in the CLI tests of scripts/test_npm.ts (wasm) and scripts/test_napi_npm.ts (native).

  • tsv_debug (development): Uses embedded Deno sidecar for external tools

    • Reuses tsv_cli infrastructure
    • Commands: ~50 subcommands — the full catalog lives in the root CLAUDE.md §Debug Tooling and audits.md (which sections this list deliberately doesn't duplicate): fixtures (fixture_init, fixtures_validate, fixtures_update*, fixtures_audit), oracles (check, compare, ast_diff, canonical_parse, format_prettier, test262, tsc_conformance — see typechecker.md), the standing audit family (comment_audit, gap_audit, census_audit, …), the compiler harnesses (compile_*, canonical_compile, render_compare), and profiling/metrics (profile, json_profile, arena_stats, buffer_sizes, metrics, line_width, lex_diff)

External Tools (via Embedded Deno Sidecar)

tsv_debug calls these external tools via an embedded Deno sidecar (spawned lazily on first use; bulk commands spawn a small pool of sidecar processes — see crates/tsv_debug/CLAUDE.md):

  1. prettier + prettier-plugin-svelte

    • Used by: compare, format_prettier, fixture management
    • Purpose: Format code, compare outputs, validate formatter behavior
  2. svelte

    • Used by: canonical_parse, ast_diff, fixture management
    • Purpose: Parse Svelte code with official compiler
  3. acorn + @sveltejs/acorn-typescript

    • Used by: canonical_parse, ast_diff, fixture management
    • Purpose: Parse TypeScript code (matches Svelte's TS parser)

Versions are pinned (exact) in crates/tsv_debug/src/deno/sidecar.ts — the source of truth; they are not repeated here. benches/js/package.json pins the same versions independently for the bench harness; keep the two in sync.

Input Handling

All content-processing commands support three input methods:

  • File path: command <file> - Auto-detects parser/type from extension, which must be one tsv handles (the dispatch has no unknown arm, so a .md would otherwise parse as TypeScript); an unsupported one is an argument error, the same message format <file> gives, unless --parser names the grammar outright; a directory is refused by name ahead of either check (<dir>: is a directory (one file is expected)), since every command resolving a file this way reads one file — tsv_debug's too — and the read would otherwise fail under a message calling it one
  • Content: command --content <string> --parser <type> - Requires explicit --parser svelte|typescript|css
  • Stdin: command --stdin --parser <type> - Requires explicit --parser svelte|typescript|css

parse and format also take --source-type script|module (TypeScript only — naming one for a Svelte or CSS input is an error, as it is on every JS binding, never a silent drop; the C FFI alone accepts its module code 0 on every language, the neutral value of a required u32, while rejecting the script code there — tsv_ffi's lib.rs states the carve-out) — ESTree's own spelling, and the value the wire's Program.sourceType carries. It selects the parse goal: at script, await is an ordinary identifier and top-level import/export, for await and import.meta are errors (a TypeScript namespace body keeps its import/export). Unset, parse uses module, while format uses module retried as script if that parse fails (see §Multi-File Formatting). For format the flag applies to --content/--stdin only — a path argument with --source-type is a usage error (exit 2), since path mode resolves the source type per file and Svelte and CSS have no goal at all; parse honors --source-type on file paths too. The goal does not decide strictness: Module code is strict, Script code is strict only once a "use strict" directive prologue says so (see CLAUDE.md §Strictness; the goal axis itself is conformance_test262.md §Module Strict, Script by Directive). Three constructs follow strictness rather than the goal: a with statement, a leading-zero numeric literal (010, 08) and a legacy string escape ('\7', '\8') all parse in a sloppy script and are syntax errors in strict code.

--source-type is a grammar input, not a style setting

tsv is non-configurable by design — "no config files, CLI flags, or runtime options" (CLAUDE.md §Configuration) — and --source-type is not an exception to that contract, because the contract governs style. What this flag selects is which grammar symbol the parse starts from: ecma262 gives ParseScript and ParseModule as two separate entry points over the same text, and a source that is a script is not a module with a setting flipped. The flag shapes only the parse the formatter runs; formatting itself is non-configurable, so no --source-type value changes how anything is printed. The same axis appears on the bindings as the sourceType option (tsv_wasm, @fuzdev/tsv) and as the C-ABI source-type code (tsv_ffi); there is no style knob on any of them either.

parse also takes --no-locations: it emits the span-only wire — start/end offsets but no per-node loc (line/column) object, and for Svelte no name_loc either. loc is derivable from the offsets plus source, so nothing is lost for a consumer that has the source; it mirrors acorn's locations: false. No-op for CSS (parseCss emits no loc). Orthogonal to --source-type (the source type drives the parser, --no-locations the writer), so the two compose.

Implemented in tsv_cli/src/cli/input.rs

Recursion Depth

The parser and the printer are recursive descents, so nesting depth costs stack — and a stack overflow is not a catchable panic. No catch_unwind and no panic contract can turn it into a per-file error the way they do every other failure; it kills the process, and a directory format that dies that way has already rewritten some files, having printed none of them (changed paths are reported after the run, not as they are written). What the user sees is exit 134 (SIGABRT on Unix), two lines of runtime message naming the thread that overflowed (tsv for the subcommand, tsv-format for a pool worker — which is why those threads are named at all: it is the only diagnostic the failure leaves), and no record of what changed.

So the ceiling is stated rather than inherited. main runs the whole subcommand on a thread with STACK_SIZE reserved (cli/stack.rs), and the format workers reserve the same, which makes the depth a property of tsv instead of a property of the route, the host and the platform:

  • inherited, the main thread's stack is the process RLIMIT_STACK on Unix — commonly 8 MiB, but whatever the machine says — and 1 MiB on Windows, where the linker writes it into the executable header and nothing at run time can raise it. A spawned thread inherits Rust's 2 MiB instead, and RUST_MIN_STACK moves that one but not the main thread's.
  • so without the reservation, one binary has an 8x depth difference between tsv format <path> and tsv format --content on the same input on the same Windows machine — and the asymmetry points the other way on a machine whose RLIMIT_STACK is above the pool's own reservation, where the pool becomes the shallower route. tsv parse has no pool at all, so it took the inherited stack on every platform.
  • which recursion binds depends on the shape, and on parens — the shape the flat figure below is quoted on — the two sides are level: parse reaches the same depth as format on the same input (37,329 parens at 32 MiB on both). ⚠️ That does not generalize. Wherever a member chain is involved the printer binds, and by a wide margin: a nested memberish call (a.f(a.f(…))) parses to 27,506 levels and formats to 9,208, and a nested computed subscript (a[a[…]]) parses to 34,270 and formats to 11,613 — the chain printer's own frames set the ceiling at ~⅓ of the parser's. The wire-JSON writer adds nothing on top of the parser on any shape measured.

Measured on const x = ((((…1…))));, one nesting level costs ~0.88 KiB of stack in a release build (~16 KiB in a debug build, where frames are much larger), so the shipped CLI reaches ~37,300 levels on every route and every platform. For scale: the parsers tsv stands in for stop earlier and on the same input — acorn + @sveltejs/acorn-typescript at 497 levels and prettier at 805, both through V8's own checked stack limit, which is why theirs is a catchable RangeError and tsv's is not. The deepest file in the tsc corpus nests 69 levels; the exposure is generated and minified code.

Parens are not the tightest shape, only the easiest to state. Per nesting level, in a release build: nested arrow bodies (() => {…}) and nested memberish calls (a.f(a.f(…))) ~3.56 KiB (the two worst measured, level with each other at ~9,200 levels — the depth every shape clears), nested computed subscripts (a[a[…]]) ~2.8, TS object literals ~2.4, TS types ~2.35, statement nesting ~2.0, Svelte elements ~1.7, nested binary chains ~1.5, unary chains ~1.25, calls ~1.2, array literals ~1.14, parens ~0.88, ternary / assignment chains ~0.50, CSS rules ~0.4.

The two chain shapes used to head that list, because a member chain is printed from a grouped view of a linearized chain and those frames sit on the expression cycle: they cost 7.4 and 6.7 KiB a level until ChainGroup stopped owning a SmallVec of node copies and became a borrowed sub-slice (16 bytes) — ~2.2 KiB a level back on both and ~0.5 on nested arrow bodies — and 5.1 and 4.5 until the peeled trailing member tail stopped being collected into a second SmallVec and became a pair of borrowed runs, another ~1.2 KiB a level on both. A third slice came off five shapes at once — the same 0.39 KiB from each — when the chain linearizer stopped returning its 464-byte node buffer and started filling the caller's: the buffer lives in the caller either way, so a returned one cost a second slot in the expression dispatcher's frame, which every shape on the expression cycle pays. Both chain shapes, nested arrow bodies and TS object literals each dropped 0.39, and so did unary chains (1.64 → 1.25 — nearly a quarter of what a level there had cost). The two chain shapes are also the ones on which the printer, not the parser, sets the ceiling — see the bullet above. ⚠️ And they are the only two shapes the Expression enum's own width does not reach: every other row above moved when it went from 176 bytes to 72 (Svelte elements 3.1 → 1.7, calls 1.9 → 1.25, parens 1.2 → 0.94), while these two stayed put, because the chain printer's frames — not an Expression slot — are what sets them. The TSType enum's width reaches a different subset again: narrowing it 112 → 80 moved TS types 3.2 → 2.35, ternary 0.56 → 0.50, parens 0.94 → 0.88, calls 1.25 → 1.2 and array literals 1.2 → 1.14, and left Svelte elements, TS object literals and both chain shapes exactly where they were.

What sets a shape's cost is the stack slots its cycle's functions reserve, not the work they do: a frame is sized once for the widest arm, and every level pays all of it whichever arm it takes — so a dispatcher that holds one by-value AST node per arm multiplies that node's size by its arm count, at every level, forever. This is why no parse_* on the expression cycle hands its caller a bare Expression by value: a node builder either boxes into the arena at its own tail (ParsedExpr::from_expr, leaving the caller an 8-byte reference) or returns its own concrete node struct — an ObjectExpression is 32 B, and the dispatcher arm that wraps one back into an Expression builds a temporary the compiler merges with its sibling arms' rather than a return slot it cannot. The printer answers the same pressure with the same move on its own side: the chain entry points fill a caller-owned ChainNodeVec rather than returning one, because a returned buffer needs a slot to be built in and a slot in the caller to be handed to, and only the second of those is load-bearing.

The node enums also answer the same pressure from the other side, by density, and in two different ways. The first is rare-variant boxing — a variant wide enough to set the enum's size on its own, and rare enough that an arena allocation apiece is free, holds its payload by reference. Expression's five widest (ClassExpression / FunctionExpression / ArrowFunctionExpression / MetaProperty / TaggedTemplateExpression) make it 72 B rather than 176; TSType's three widest (TSImportType, TSConstructorType, TSInferType) make it 80 B rather than 112; Statement's rare declaration heads (TSTypeAliasDeclaration, ExportDefaultDeclaration, ClassDeclaration, FunctionDeclaration, TSInterfaceDeclaration, TSDeclareFunction, ExportAllDeclaration, TSImportEqualsDeclaration, TSEnumDeclaration, TSModuleDeclaration, TSExportAssignment) plus its four loop / try heads one level down do the same. Rarity is what makes those free — each is ≤0.2% of statements, a classic for (;;) is 0.05–0.22%, and the five expression variants together are ~3% of expressions, of which the two widest are ~0.02% — while the width is paid on every element of every slice and on every ?-propagation copy. Expression's ladder stops where rarity does: the next-widest is CallExpression at 64 B and it is 14–21% of expressions.

The second is a slot borrow, which needs no rarity argument at all, because the inline slot was never where the node lives: the expression parser threads an &'arena Expression, so a by-value Expression field is a 72-byte copy out of the arena, and naming it by reference removes work rather than adding an allocation. That is how Property (an object literal's key: value, and a destructuring pattern's) and VariableDeclarator went from 160 B to 32, and how every Expression-holding statement head followed (ExpressionStatement 88 → 24, IfStatement / SwitchStatement / SwitchCase 96 → 32, WhileStatement / DoWhileStatement 88 → 24, ReturnStatement / ThrowStatement 80 → 16). With those heads narrowed, ImportDeclaration (6.8–11.7% of statements) and ExportNamedDeclaration (2.4–4.0%) were the only variants left setting the enum's width, so they are arena-boxed too — not for rarity but because they are the ceiling, and a boxed head copies the same bytes into the arena that it would have moved into the enum. Together those take Statement to 72 B rather than 544; the next-widest inline variant is TryStatement at 64 B, which is where the ladder stops.

The wire has no reader ceiling of its own. tsv parse --pretty re-indents the compact wire bytes in one linear pass (json_utils::indent_json_with_tabs) rather than reading them back into a tree, so it stops exactly where the parser stops. It did not always: the pretty route used to round-trip through a serde_json::Value, and serde_json's default recursion limit of 128 JSON levels — two per nested array (the node and its elements), three per nested object literal — refused a wire past ~60 nested arrays or ~40 nested objects that the compact route had just emitted, a clean exit 1 on input the parser handles at 400× the depth. No tool tsv stands in for bounds depth by choice: JSON.parse is iterative in V8 and JSC and takes a million levels, and acorn, Svelte's parser and prettier each stop only at V8's stack (1,023 nested arrays for acorn

  • @sveltejs/acorn-typescript, 767 for prettier's typescript parser). The one reader of the wire left is tsv_debug's json module (the fixture gate, the sidecar transport, every audit's Value walk), which reads with the limit disabled on the same STACK_SIZE reservation: a Value read costs ~0.6 KiB of stack per JSON level (measured, and the same for the drop, == and pretty-print walks), so ~1.2 KiB per nested array against the parser's ~1.14 — the read reaches ~27,500 arrays where the parse reaches ~28,000, a 2% band on adversarial input where a dev tool would overflow instead of erroring, and ~1.8 KiB per nested object against the parser's ~2.4, where the parser binds. Fixture typescript/expressions/objects/nested_deep (45 nested object literals, 145 wire levels) pins the pipeline past the old ceiling.

The other surfaces have their own ceilings, set by their hosts, and the CLI's reservation does not reach them:

surface stack depth
tsv (this CLI), every route STACK_SIZE, explicit ~37,300
N-API addon on the host's main thread the host process's RLIMIT_STACK ~7,810 at 8 MiB
N-API addon on a worker_threads worker Node's 4 MiB stackSizeMb default ~3,880
WASM, any host the wasm shadow stack, 1 MiB by link default ~2,510

The two binding rows are the host's thread, so the addon cannot size them; a host that needs the depth raises it itself (new Worker(…, {resourceLimits: {stackSizeMb}})), which is the same shape as the arena-retention advice in tsv_napi/CLAUDE.md §Threading & host residency. A native overflow there is a bare SIGSEGV with no message, since Rust's guard-page handler is installed by its runtime startup and a cdylib loaded into Node never runs it.

The WASM overflow is a trap the process survives but the instance does not — it poisons every later call. The npm packages ship a reinstantiate() recovery hook, and the JS CLI (cli.js) calls it on any trap in format_one — and on the RangeError V8 raises when a deep call exhausts the engine's native stack before its shadow stack, which strands the instance too — so a too-deep file is one per-file error (… (WASM engine trapped and was reinstantiated)) and the rest of the run formats normally — on the sequential path and in every pool worker alike. See tsv_wasm/CLAUDE.md §Panic Reporting.

On the JS side each route reserves the same stack, because a route would otherwise decide the depth: cli.js meets V8's own stack before the module's on any thread smaller than a few MiB, and the main thread holds ~1 MiB where a Node worker holds 4. So every pool worker reserves the native CLI's STACK_SIZE (resourceLimits.stackSizeMb, restated as WORKER_STACK_SIZE_MB), and the sequential route re-runs a file whose main-thread format hit V8's RangeError in a one-worker pool (retry_overflowed_files) — one worker start, paid only when a file overflowed. Measured on a flat a + a + … chain with the WASM package:

runtime sequential route (--jobs 1) pool (--jobs 2)
Node ~62,600 terms, on the retry — the module's own stack traps ~62,600
Deno ~8,000 — its workers ignore stackSizeMb ~8,000
Bun ~27,800, on the retry ~5,700, whatever stackSizeMb says

On Bun the two columns are not two stacks. Bun ignores stackSizeMb (a pool worker reads ~5,700 at 4, 32 and 256 MiB alike), and what moves its depth is whether the recursive wasm code has already been warmed: JavaScriptCore compiles a wasm function in tiers, and a worker handed a module whose recursion another thread has exercised — which the shared WebAssembly.Module carries across — reaches ~27,800 on the same stack where a cold one stops at ~5,700. The sequential route's retry is always warm, because the main thread's failed attempt ran the very file first; a pool worker is warm only once it has formatted the same shapes at some depth itself (a 2,000-term chain twenty times does it, 400 small unrelated files do not), so on Bun a pool's depth depends on what the pool formatted before the deep file. Bun's main thread reads ~7,200 cold and ~35,400 warm by the same measure — so on Bun, unlike Deno, the retry clears files the first attempt overflowed on.

Without the reservation Node's routes stop near 7,800 and 31,000 terms. On the N-API engine an overflow is a process-fatal SIGSEGV rather than a RangeError, so nothing can be retried — which is why cli.js never formats a path on the N-API main thread: a --jobs 1 run there is a pool of one worker (resolve_route), and path mode's ceiling is the worker's reserved stack on every route, as the native CLI's is. Its pool workers take the same reservation, so the 4 MiB worker row in the table above is not cli.js's own pool. What keeps the table's main-thread row on N-API is the single-input mode — --content/--stdin and parse run on the main thread and take the process's RLIMIT_STACK — so a deep single input there is a bare SIGSEGV with no output and nothing written; on the WASM engine the same input is reported as the engine's failure (Error: the engine failed on this input …), never as a parse error.

Multi-File Formatting

tsv format accepts any mix of files and directories:

  • Discovery: directories recurse over the JS/TS family (.ts/.mts/.cts/.js/.mjs/.cjs, all parsed as TypeScript — .jsx/.tsx are out of scope), .svelte, and .css (compound forms like .svelte.ts included), each extension read without regard to ASCII case (A.TS, styles.CSS), as prettier infers a parser from the lowercased name — a walk, a named path, the parser dispatch and the .mjs/.mts goal all read it the same way, on both bins. The safety nets .git, node_modules, .sl, .hg, .svn, .jj are always pruned. A path an argument names is bounded by the ignore files alone. The safety nets and the build-output heuristic prune what a walk discovers — they are tsv's guesses about a tree, and a guess never overrides a path someone typed — so they grade neither a named path nor its ancestors: tsv format node_modules/pkg, dist/sub or .cache/x walks what naming its parent walks there, and below the named root they classify every child as usual. An ignore rule is one the user or their repo wrote, and it bounds a named path — file or directory — exactly as it bounds the walk that would have reached it, through an ancestor or at the path itself: naming pkg/dist doesn't override a dist/ rule, nor naming vendor/x.ts a vendor/ one, the line prettier, ESLint, oxfmt and deno fmt draw too. Such a path is skipped. A named file a .formatignore or .prettierignore rule excludes is skipped quietly, as prettier skips it, whether or not a .gitignore excludes it too — at the file or an ancestor, and even where that .gitignore rule sits above the tsv rule and bounds the path on its own, the case the directory warning below names both blockers for: those files exist to say what not to format, the rule is the author's verdict on the file however the path is bounded, and a pre-commit hook handing over its staged files names such a file on every commit that touches one (ESLint, which warns there, ships --no-warn-ignored for exactly that noise; tsv has no flags to offer). Every other exclusion prints a stderr warning naming the file the excluding rule sits in and how to undo it — a .gitignore rule is about version control, so one excluding a named file is a surprise, and a named directory is a scope someone typed. A path only a .gitignore excludes gets the lines that re-include it and nothing beside it, for the repo root's own tsv file (read after every .gitignore, so its ! wins): !/src/a.gen.ts for a path the rule matched itself, and for one under an excluded directory that directory re-included, its contents excluded again, and so on one level at a time down to the path (!/build/, /build/*, !/build/a.ts) — every line anchored with a leading /, without which a one-segment pattern matches at every depth, and spelling its path literally (each *, ?, [, ], \ and trailing space escaped), so a [slug] directory names itself rather than a character class. A path holding a control character other than a tab — a line feed, which splits any line; a carriage return at a file name's end, which a line's end drops; or any other, which an ignore file could hold only raw and the warning will not print — gets no lines, and the warning says to narrow the rule instead. The file named is the one the root reads: its .prettierignore where it has no .formatignore, since a .formatignore created beside it would shadow every rule in it. For a named directory, the rule named is the shallowest exclusion's — a .formatignore/.prettierignore rule over a .gitignore rule at the same prefix, the .gitignore rule where it excludes an ancestor above the tsv rule's — and the remedy turns on what else excludes the path. Only tsv rules: the rule is the user's own to narrow or negate. A .gitignore rule anywhere at or below the named one: narrowing a tsv rule would leave it standing, and every ! under an excluded directory is inert, so the warning gives the lines that re-include the path level by level from the shallowest exclusion down, for the repo-root tsv file — "after that rule" when the named rule is a root tsv rule (a later line wins there), "which also override" a root tsv rule below a named .gitignore one — while a tsv rule in a deeper file, read after the root's lines, stays the user's to narrow and is named as the second blocker: one warning stating both, not one per rerun — wherever that deeper file was read. None inside a directory a rule excludes is (next), so a rule in one is named only once the directory is re-included and the path named again. A run whose every argument was an excluded file exits 0 rather than failing with No files to format — what a pre-commit hook hands over when only ignored files are staged — while a run left empty by an excluded directory is still that error, so a mis-scoped command fails loudly. File arguments share one ignore scope, moved from each argument's directory to the next's (popping back to their common ancestor and pushing down), so the ignore files above many named files are read and parsed once rather than once per directory holding one. No ignore file inside a directory a rule excludes is read for a named path, as the walk that prunes the directory reads none — so nothing in one is warned about, and a rule in one can still exclude the path once the directory is re-included. A file arg is held to the extension check first — the parser dispatch behind a path has no unknown arm (everything that isn't .svelte or .css goes to the TypeScript parser), so a named .json/.md/extensionless file would be parsed as TypeScript: usually a baffling syntax error, and occasionally a successful rewrite of a file tsv doesn't support (a top-level-array .json reprints as a TS expression statement, semicolon and all, which is no longer valid JSON). Naming one is an argument error instead — reported alongside the unresolvable-path errors, failing the run upfront with nothing written, the same line prettier draws with "No parser could be inferred". A directory arg is a scope rather than a target, so the check doesn't apply to it: unsupported files inside are filtered out by the walk. A shell glob (tsv format *) names such directories as well: a safety-net or heuristic one (node_modules, dist) it walks, and one an ignore file excludes it skips, with the directory warning. Symlinks inside directories are not followed; pass them explicitly — and a passed one is graded where it was typed: a path argument that is itself a symbolic link is bounded by the ignore files at its own path (its parent canonicalized, its name kept, the repo root found from there), as git check-ignore and prettier read it, not where it points — a .gitignore naming link.ts excludes it, a link into a gitignored build/ is not under build/, and a linked directory root is bounded by the rules of the repo it sits in even when it points outside that repo; only the overlap dedup below reads the link's target. And it is graded as the link it is, whatever it points at, as git grades it: a directory-only rule (foo/) does not match a symlinked directory argument (git check-ignore foo says not ignored), the link is walked as a directory once it is in scope, a warning's re-include names it as a file (!/foo, since a !/foo/ reaches no link), and one a rule does exclude counts as an excluded file argument — a run naming only such links exits 0. Hard links are not detected either: two names for one inode are two files in scope, each read and formatted by its own name — a --check lists both, and a format rewrites the inode through whichever name it reaches first and finds the other already formatted, so it reports one name or both (the same bytes either way; under a parallel run the second name can also read the first's write in flight, the exposure any process writing a file while tsv reads it has).

  • Ignore files (two regimes, keyed on .git): for each directory root, the format root — the scope boundary, derived from the argument, never the cwd — is the repo root inside a git tree (a hard stop where the upward walk ends, so nothing above the repo is read and --check is reproducible) or the filesystem root outside one. The regime is decided once at the target root, and any ignored directory is pruned (its whole subtree is skipped).

    • Inside a repo, discovery honors, relative to the repo root:

      • .gitignore — hierarchical and repo-rooted exactly like git (gitignore syntax, matched against git check-ignore on case-sensitive filesystems). This goes beyond Prettier, which reads only one .gitignore and one .prettierignore, both relative to its own directory (the cwd by default), and ignores nested ones entirely.
      • .formatignore — hierarchical (one per directory from the repo root down, deeper wins), applied after .gitignore so its ! can re-include a gitignore'd path (subject to git's parent-directory rule).
      • .prettierignore — drop-in compat, honored hierarchically as well (one per directory from the repo root down, deeper wins), read as the tsv-layer fallback in any directory with no .formatignore of its own; a sibling .formatignore shadows it per-directory (used alone when present, even if that .formatignore is present-but-unreadable — a read error can't silently demote tsv's native file to prettier's). Like the hierarchical .gitignore above, this goes beyond Prettier's single cwd-relative .prettierignore — so a monorepo that runs prettier per-package (each package with its own .prettierignore) is honored from one repo-root tsv invocation. Because the shadow silently drops the sibling .prettierignore's rules for that directory (Prettier applies both files), tsv emits a non-fatal stderr warning wherever a .formatignore shadows a .prettierignore, pointing at merging the patterns into .formatignore. Compat caveat: as a tsv layer a .prettierignore ! can re-include a path .gitignore excluded (subject to git's parent-directory rule), whereas Prettier treats .gitignore and .prettierignore as independent sources OR'd together, where a .prettierignore ! can't rescue a gitignore'd file — tsv's model is the more powerful superset, and the divergence only surfaces for a .prettierignore ! targeting a gitignore'd path (rare).
    • Outside a repo, .gitignore and .prettierignore are not read (as git itself does); only .formatignore governs, hierarchically from the filesystem root down — so a ~/.formatignore is global config for loose files. A .prettierignore in the target root (the directory tsv was pointed at, where prettier would have read it) raises a non-fatal stderr warning — rename it to .formatignore, or git init — without changing what gets formatted. The warning is bounded to the target root: outside a repo tsv's regime is .formatignore-only at every depth, so this is one courtesy heads-up at the entry point (not a per-directory scan), and an ancestor of a subdirectory target has no repo boundary to anchor on.

    • Heuristic fallback: a .gitignore in scope is authoritative and turns the heuristic off; with no .gitignore, the heuristic — hidden directories plus dist/build/target — is the fallback "not source" guess, except that an explicit tsv-layer ! re-include overrides it.

    • Re-include idiom: to selectively re-include under a pruned (or otherwise ignored) directory, re-include the directory itself first — !/dist/ admits the whole directory, then /dist/* + !/dist/keep.ts narrows it back to just the files you want. The leading / anchors each line to the directory of the ignore file holding it; without it a one-segment !dist/ re-includes a dist at every depth. A bare !dist/keep.ts (without the directory re-include) is a no-op — the heuristic prunes dist before descending, mirroring git's parent-directory rule, and a gitignored (or tsv-excluded) dist/ blocks a later !dist/keep.ts the same way. tsv emits a stderr warning in either case (non-fatal — no effect on the exit code, stdout, or --list/--check output), once per pruned directory, saying what pruned it (the heuristic, or the rule and its file), naming the file the re-include was written in, and spelling the lines that reach what the rule named, for that file — anchored and relative to its directory, since a line spelled from the repo root does nothing in a nested file, and outside a repo, where the format root is the filesystem root, in any file: !/dist/, /dist/*, !/dist/keep.ts for !dist/keep.ts, with !/dist/sub/, /dist/sub/* between for a nested !dist/sub/keep.ts (every directory down to the target has to be opened, and a /dist/* alone would close dist/sub again), the last lines being every re-include written under the directory, each the author's own pattern anchored (a /dist/* line would silence any it did not re-spell after it) — so a glob (!dist/*.ts) keeps its glob, and a rule reaching below the directory (!dist/**/keep.ts) opens it with /dist/** + !/dist/**/ in place of /dist/*, git's idiom for every directory and no file. A tsv layer is read after every .gitignore and a later line wins within a file, so the lines override the excluding rule wherever it sits, except a tsv rule in a file deeper than the re-include's, which is read after it and which the warning says to narrow or negate instead — beside the lines that pass a .gitignore rule standing behind it, which narrowing alone would leave in force.

    • Subdirectory invocation: because the boundary is found by walking up, the repo-root rules apply even from a subdirectory, and a subdirectory named directly is bounded by the same ignore rules as when it is reached via an ancestor — only the safety nets and the heuristic, which grade no named path, can tell the two apart. But a tree that contains repos (a non-repo directory with .git subdirectories below it) does not honor the inner repos' .gitignores — run tsv per repo.

    • Piped output — a closed consumer is not a failure. tsv format . | head fills the 64 KiB pipe buffer on any tree whose changed-path report exceeds it, so head has exited by the time the rest is written. Both bins stop writing and finish the run on their own terms: the exit code still reports the work (0 clean, 1 --check would-change, 2 errors) and the stderr summary still prints when stderr is not the closed fd, so tsv format . | head stays informative. The rule covers both fds2>&1 | head closes the same pipe for both, and a stdout-only rule would just move the failure one line down, onto the summary.

      Why not the two alternatives, since this is a shipped exit-code contract: 141 (what a tool killed by SIGPIPE reports) and restoring SIGPIPE to SIG_DFL both replace the 0/1/2 verdict with "the reader left", and for --check the exit code is the API. Exiting 0 is also the honest answer for format, whose stdout is a report of files already rewritten rather than the product: a reader that left does not un-format them. And only this answer is one both bins can give identically — Node ignores SIGPIPE too, so cli.js could never die by the signal, only fake a code where the native side died by one. Any other write error still aborts loudly on both: a report truncated by a full disk, with nothing said about it, is worse than a crash.

      The mechanism differs because the two runtimes fail differently. Native: Rust sets SIGPIPE to SIG_IGN at startup, so the write returns EPIPE and println!/eprintln! panic on it — every byte therefore goes through cli/out.rs (write_stdout / write_stderr, and the out_line! / err_line! macros over them), which absorbs BrokenPipe, waits out WouldBlock (a backoff capped at a millisecond, partial writes honored — the fd's blocking-ness belongs to the open file description a child shares with its parent, and a Node parent that opens its own piped process.stdout after spawning tsv asynchronously, a task runner logging beside it, flips that description to non-blocking under the running child; libuv resets fds 0–2 to blocking at the spawn itself, so the @fuzdev/tsv loader, waiting in spawnSync, never does), and panics on anything else. parse rides the same writer, so a closed reader there does not report 1, its parse-error code. cli.js writes both fds synchronously (an async process.stdout.write before process.exit truncates), which is only safe while the fd stays blocking — and it takes that away from itself: spawning the worker pool pipes the workers' stdio through the parent, which flips fd 1 to non-blocking. Its write_fd therefore loops over writeSync, honors partial writes, sleeps 1 ms and retries on EAGAIN, goes quiet on EPIPE, and on any other error prints failed printing to stdout: … and exits with the shipped native binary's abort status (134) — never a verdict code, since an uncaught throw's 1 reads as --check's would-change. The two bins reach EAGAIN by different roads — cli.js flips its own fd, the native CLI inherits a flipped one — and answer it alike. The read side takes the same rule: a parent that opens its own piped process.stdin flips fd 0 the same way, and --stdin on either bin (Input::from_stdin, cli.js's read_fd_to_end) waits out EAGAIN there too rather than reporting a slow writer as a read error the moment the pipe is momentarily empty.

      A consumer that is merely slow gets every line on both bins: EPIPE ends the output, EAGAIN is waited out, and any other write error is a failure. Pinned by tests/cli_tests/ (*_closed_pipe_*, *_slow_pipe_*, *_non_blocking_stdout_*) and scripts/test_npm.ts's twin rows.

    • A non-UTF-8 file NAME: the native walk joins each entry's raw bytes, so a file or directory whose name is not UTF-8 is discovered, formatted, and listed by its own bytes on unix (the changed-path report and --list print paths as bytes there, cli::out::path_bytes; elsewhere the U+FFFD spelling is all there is). A stderr diagnostic spells such a name with U+FFFD on every platform (cli::out::path_text), deliberately: a diagnostic is prose for a reader and a script reads stdout, and a byte-faithful stderr would put raw non-UTF-8 into the one channel every consumer reads as text. Two limits: such a name reaches the matcher in its lossy spelling, so a ? or class counts one U+FFFD where git counts each byte (the multibyte edge tsv_ignore's CLAUDE.md scopes the git parity to ASCII by), and a non-UTF-8 path given as an argument is refused at the argv boundary (Invalid utf8: …, exit 1 — argh reads &str), so it is reachable only through a directory. cli.js sees such a name only as Node spells it, lossily, on every route, so it reports the file as unreadable rather than formatting it — a deliberate, permanent split: Node hands the bytes back only as Buffers (readdir's encoding: 'buffer'), which would have to travel as a second path type through the walk, the ignore-file reads, the sort, the canonical-path dedup (realpathSync resolves a Buffer path through its lossy spelling and fails), the worker handoff and the report, for a name no published repo holds.

    • A path holding a control character or a double quote is printed C-quoted, as git prints one. One rule for every path either bin prints — the --list and changed-path lines on stdout, the per-file error: lines, the traversal errors, the ignore-file warnings and the warning naming an excluded argument, a bad path argument, parse's read failure — stated once in tsv_discover::quote_path (the warnings are built there already; the native CLI routes its own paths through cli::out::path_bytes / path_text, and cli.js restates the rule by hand, as it does clamp_worker_count): a path prints verbatim unless it holds a control character (U+0000–U+001F, U+007F) or a double quote, in which case the whole path is wrapped in double quotes and C-escaped the way git ls-files prints such a name under core.quotePath=false\a \b \t \n \v \f \r \" \\ by name, any other control character as three octal digits ("r\033s.ts"), every other byte as itself (a character outside ASCII prints raw even inside the quotes, as git prints it, and so does a non-UTF-8 byte on a unix stdout listing; a stderr diagnostic spells that byte as U+FFFD, below). A backslash escapes inside a quoted path but does not trigger the quoting on its own — every Windows path holds one — so a quoted path unquotes exactly as git's does while a plain src\a.ts prints as it is. Two things follow. A diagnostic that names such a path stays one line (raw, a line feed in the name would split the warning: line in two and a carriage return garble the terminal). And the stdout listings stay parseable by whatever already reads git ls-files or git status: a line beginning with " is a quoted path, any other line names the file exactly, so a script over tsv format --list or the changed-path report can unquote with the routine it uses for git (or treat the leading " as the tell that a name needs it). The one text that stays literal is a re-include pattern a warning offers (`!/build/n<TAB>o.ts`): it is meant to be pasted into an ignore file, which reads no escapes, so it is spelled as the file must hold it — and a pattern that would hold any control character but a tab is not offered at all, as above (a tab is spelled raw: it renders as the whitespace it is, and the pasted line reads it back as the file holds it). The ignore-file names in prose (the repo-root .gitignore) hold nothing to quote. Pinned by tests/cli_tests/ (test_format_quotes_a_path_holding_a_control_character_wherever_it_prints_it, test_argument_errors_quote_a_path_holding_a_control_character) and scripts/test_npm.ts's twin row; the spellings themselves by tsv_discover's quote_path_* tests, taken from git 2.47's own output.

    • Invalid UTF-8 in a source file: reading is strict UTF-8 on both CLIs — the native one because Rust's read_to_string refuses invalid bytes, and cli.js because it decodes through TextDecoder(..., {fatal: true}) rather than readFileSync(path, 'utf-8'), which would substitute U+FFFD. The distinction is not cosmetic on the format path: a stray byte inside a string literal still parses after substitution, so a lossy reader would write the repaired text back over the author's file and call it formatted. Both bins instead report read failed: stream did not contain valid UTF-8, count the file as an error, and leave every byte in place. The same strict-UTF-8 rule holds for --stdin and for parse, each with its own message (Error reading from stdin: …, Error reading file <path>: …).

    • Unreadable ignore files: a .gitignore/.formatignore/.prettierignore that is present but can't be read (invalid UTF-8 — reading is strict UTF-8 on both the native and WASM CLIs — or a permission error) is not silently treated as absent: tsv emits a non-fatal stderr warning and drops that file's rules (so an unreadable .gitignore also leaves the build-output heuristic on for its subtree). A file that genuinely isn't there, or is deleted between the directory listing and the read, stays silent. This is also a --check reproducibility hazard — surfacing it is the point. Present means a regular file, reached through a symlink when the name is one; a directory of that name is not an ignore file and stays silent — the same rule in a walked directory and a preloaded ancestor. The one exception is .gitignore, which git never reads through a symbolic link in a working tree (gitignore(5)): a symlinked .gitignore is not applied — so the build-output heuristic stays on for its subtree, as for an unreadable one — and warns, by the same rule in both walks. .formatignore and .prettierignore keep reading through links, as prettier does.

    • --check reproducibility assumes the ignore files are committed: a local/uncommitted .formatignore or .prettierignore (or git's unread .git/info/exclude / core.excludesFile) makes a clean CI checkout disagree.

    • Shared by construction: the matcher is the tsv_ignore crate's IgnoreStack; the per-directory prune/descend policy (heuristic, safety nets, the shadow warning) is the tsv_discover crate's verdict. The WASM CLI, the native npm package, and editors call into the same two crates, so every surface agrees rather than hand-mirroring the logic. See cli/discover.rs.

  • Source type: module, retried as a script. Path mode names no source type — a directory can hold Svelte and CSS beside JS/TS, and there is no one grammar to declare for the run — so each JS/TS file is parsed as a module, and only if that parse fails is it retried as a script. That is what lets a legacy sloppy script (a with statement, a leading-zero literal or escape, await as an ordinary name) format from a bare path. The retry runs on the error path only, so nothing the module grammar already accepts is ever reinterpreted, and the printer never reads the goal — no formatted output changes for any module-valid file. When both grammars reject the file, the reported error is the attempt's whose grammar the file was written against, decided in two steps. A script retry that died on a goal gate — a top-level import/export, an import.meta, a top-level for await, or the operand a module reads after a top-level await, the constructs only a module holds — has proved the file a module wherever that construct sits, so the module error is reported: a broken module's script attempt dies there even when its real error comes first (definitions first, export at the bottom — position alone would blame the valid export line). Otherwise the error that reached further into the source is reported, the module's on a tie: a broken sloppy script's module attempt dies early at its first with/legacy literal/await name — the construct the retry exists to admit — so the script error (the typo) is reported rather than a pointer at a line tsv accepts. prettier's babel parser reaches the same answer by tolerating those strict-mode productions at the module goal (allowedReasonCodes). Pinned by tests/format_fallback_error_attribution.rs. An explicit --source-type is exact--source-type module refuses a script-only source rather than retrying — which is why it is a usage error in path mode rather than a per-run override. parse has no fallback at either surface: its wire's Program.sourceType is a claim about which grammar produced the AST, and one settled goal has to produce it. The same rule reaches every format surface that takes no source type from its caller: the JS CLI's path mode, an editor's format_typescript(source), and the format_* exports of all three bindings called with no sourceType.

  • Two extensions settle the goal themselves, and skip the retry. .mjs and .mts are ES modules whatever any config says — Node loads a .mjs as ESM unconditionally, and TypeScript maps both to ModuleKind.ESNext with the extension overriding module — so a path with one of those names is parsed as a module with no script retry (tsv_ts::Goal::from_extension). The fallback above exists to reach a legacy sloppy script, and a file that is a module by its own name cannot be one; without the narrowing, tsv format a.mjs would format a with statement that no runtime would load. Nothing else settles a goal: .js/.ts are ambiguous by design, and .cjs/.cts are the CommonJS half of that same switch — script code, but nothing in tsv's output turns on it, so they keep the fallback with the rest. The narrowing can only ever reject a file the fallback would have formatted: the retry runs on a module-parse failure alone, so no module-valid source formats differently. Both tsv bins apply it — the native CLI per file in format_file, and crates/tsv_wasm/npm/cli.js from a hand-restated copy (as with clamp_worker_count), pinned on both sides. parse <file> reads no extension rule: with no flag every file parses as a module already, and an explicit --source-type script on a .mts is honored as the caller's exact claim rather than refused — the precedence prettier draws too (its __babelSourceType option beats getSourceType(filepath)), and the one parse takes on every surface, since the wire's sourceType is what was asked for. An editor keeps the fallback, because it has no path to read: the VS Code extension dispatches on the document's languageId (.mjs arrives as javascript, .mts as typescript) and calls the binding's bare format_typescript(source). So a .mjs holding a sloppy script is unformattable from the CLI and formats on save — the same split the goal axis draws everywhere between a surface that names a file and one that is handed a buffer.

  • Fail-fast args, isolated traversal: path args that don't resolve to a file or directory fail the whole run before anything is written (every bad arg reported); traversal errors below a valid root (e.g. an unreadable subdirectory) report to stderr and discovery continues. A relative directory root that cannot be made absolute — its working directory was deleted out from under the run — is such an error for that root (cannot resolve a relative path: the working directory is unavailable) rather than a walk anchored on no format root, which would read none of its ancestors' ignore files.

  • No per-file options: formatting style is fixed (see CLAUDE.md §Configuration). In particular <svelte:options preserveWhitespace /> is not detected — whitespace handling is uniform, with only <pre>/<textarea> content whitespace-sensitive; see conformance_svelte.md §Template Whitespace.

  • Deduplication: with multiple path args, overlapping spellings of the same file (src vs ./src, absolute vs relative, symlink aliases) dedupe by canonical path, keeping the first spelling in sorted order. Only arguments that can overlap pay for it — a file argument among them, or one directory root an ancestor-or-self of another; a single root or disjoint roots can't produce duplicates, so the per-file canonicalization is skipped. Discovery's warnings and traversal errors collapse the same way: an ignore-file warning (a shadowed .prettierignore, an unreadable ignore file, a .prettierignore outside a repo) and a traversal error (an unreadable directory) name their directory by its absolute path, whichever root or argument spelling reached it, so tsv format . sub — the repo root walked as . and preloaded as sub's ancestor — and tsv format . ./ each warn once, and tsv format t ./t reports an unreadable t/locked once. Every canonical path the native walk takes — the dedup key, the working directory, a named path's parent — is read through one canonicalize that strips the verbatim prefix Windows' fs::canonicalize returns (\\?\C:\…C:\…, \\?\UNC\s\v\\s\v; a no-op on unix), so a diagnostic naming the format root outside a repo prints C:\, and one argument whose parent would not canonicalize (spelled by the lexical fallback) does not read as a different format root from its neighbor that did. Verified against std's own Windows path code rather than on a Windows runner: every fs call re-adds the prefix itself where a path is long enough to need it (maybe_verbatim, behind read_dir, metadata, and the File::open under read_to_string and write), and passes a shorter absolute path to Win32 as spelled — the spelling every walked path, joined from the argument, already takes — so the strip opens no access path the walk did not already use. The one residual is a name only a verbatim path reaches, a trailing dot or space Win32 normalizes away or a reserved device name, which git refuses to check out on Windows and which the walk never reached through the argument's spelling either. Node's realpathSync never returns the prefix, so cli.js needs no strip.

  • In-place writes: files are rewritten only when output differs (no mtime churn), in place — a plain truncate-and-write, as prettier does, which keeps the inode, its mode and any hard link to it, and means a process killed or a disk filled mid-write can leave the file truncated (an atomic temp-file-and-rename would swap the inode and break hard links; not planned). --content/--stdin keep printing to stdout.

  • --check: lists files that would change without writing; exits 1 if any would. For CI. Also works with --content/--stdin (nothing printed to stdout; the exit code is the API) for editor integrations.

  • --list: prints the discovered in-scope files (one per line, a name holding a control character or a double quote C-quoted as git prints it — see above) without formatting — a read-only view of the set format would touch, after the ignore files are applied. Path mode only (errors with --content/--stdin), mutually exclusive with --check, and takes no --jobs (it spawns no pool, so a width there is refused as it is with --content). Unlike the format action, an empty scope is a valid answer (exit 0, no output) rather than the "no supported files" error; traversal errors still exit 2. Useful for debugging ignore-file scoping and for scripting over the set.

  • Parallelism: files format concurrently on std::thread::scope workers claiming one file at a time from a shared queue — dynamic load balancing with no thread-pool dependency. --jobs N overrides the worker count, floored at 1 (--jobs 0 is a width, not an opt-out — it means --jobs 1) and clamped to the file count where that is known up front — explicit file arguments and multiple roots; a single directory root streams (below) and cannot know the count before it walks, so it spawns the full width and the surplus workers park; path mode only, an error with --content/--stdin. Each worker reserves the same stack every other tsv thread runs on (STACK_SIZE, cli/stack.rs), so the pool is not a route with a depth ceiling of its own — see §Recursion Depth.

    An explicit --jobs is held to 4 × logical CPUs, warned about on stderr when it bites. Four per core is far past what the workload can use — the default lands below the logical count for measured reasons — so the ceiling is about blast radius, not throughput: each worker reserves STACK_SIZE of address space, and an unbounded count takes task slots until the OS refuses, which on a systemd machine is the login session's whole TasksMax and wedges every other process on it.

    And a --jobs the OS still won't give narrows the pool rather than failing the run. The count is a user-supplied number, so a refused thread is an ordinary outcome of an ordinary argument, and the work is claimed rather than partitioned — however many workers exist drain the whole list between them. tsv warns (warning: only N of M format workers started) and formats the tree; if not one thread could be started, it says so and formats on the calling thread. Both messages are the JS CLI's, word for word.

    The default is min(logical CPUs, ceil(1.5 × physical cores)), not one worker per logical CPU. This workload does not scale onto SMT siblings — the per-file work is memory-bound, and on a large tree the discovery walk is the bottleneck, so extra workers compete with it for cores. One worker per logical CPU costs up to 28% on walk-bound trees while buying nothing on flat repos. The SMT width is read once from /sys/devices/system/cpu/cpu0/topology/thread_siblings_list; where that is unavailable (no SMT, or a non-Linux platform) the cap is inert and the default is the logical count, so it can only ever lower the worker count.

  • Streaming discovery: a single directory root — the common invocation — feeds the workers as the walk finds files, so the directory walk runs beside the first files' parse+format rather than in front of an idle pool. It is worth having: the walk is 5–10% of the wall on an application repo, and 40–67% on a repo with a large tree, where it can outrun what the pool consumes. Other argument shapes (explicit files, multiple roots) discover the whole set first, because the canonical-path dedup above is set-wide. The set of files formatted is identical either way, as is the reporting order below — only the order work is handed out differs.

  • Error isolation: a per-file read/parse/write error (or panic, caught via catch_unwind — effective only in builds with panic = "unwind"; release uses panic = "abort") reports to stderr and processing continues.

  • Deterministic reporting: changed paths print to stdout in sorted-path order regardless of completion order — component-wise, each component by code point, the order a path's UTF-8 bytes sort in, and the same order on both bins (cli.js compares code points rather than UTF-16 units, which would put an astral-plane name ahead of U+E000..U+FFFF) — each spelled by the one quoting rule above (verbatim, or C-quoted as git prints a name holding a control character or a double quote); errors (traversal and per-file) and the summary line go to stderr.

  • Exit codes: 0 clean, 1 would-change (--check only), 2 errors.