The CLI uses argh for declarative arg parsing:
- Each command is a
FromArgsstruct in its own module undersrc/cli/commands/ cli::TopLevelholds a top-level--versionswitch plus theSubcommandenum (Optionsolely so a bare--versionparses; a baretsvreproduces argh's required-subcommand error — seeTopLevel::run);main.rsparses argv and dispatches- argh has no struct-flattening attribute, so the shared input fields (
--content,--stdin,--parser, file path) are declared per command and assembled intocli::input::InputArgsfor 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.
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
--prettyre-indenter (tab-indented form of the compact wire, no deserializer) —json_utils.rs
-
tsv(production): Pure Rust, no external tool dependencies- Crates:
tsv_cli - Commands:
parse,format(plus the top-level--versionswitch)
- Crates:
-
tsvnpm bin, native (@fuzdev/tsv): the productiontsvbinary itself, shipped inside each@fuzdev/tsv-<triple>platform package and exec'd by the loader'scrates/tsv_napi/npm/bin.jsdispatcher (argv, stdio, exit codes, and signals forwarded verbatim) — sonpx tsvon the native package has this CLI's exact contract, real--jobsparallelism included. When no binary is reachable the dispatcher falls back to the JS mirror below. -
tsvnpm 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).--jobsis real: path mode fans ontonode:worker_threads, spawningcli.jsas 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
--jobsgiven 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 Nis held to the same4 × logicalceiling as the native CLI, warned about on stderr when it bites (see §Multi-File Formatting's parallelism note;cli.jsrestates the nativeclamp_worker_countby 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'savailable_parallelismon either side: the affinity mask capped by the cgroup CPU quota. Node's and Bun'savailableParallelism()leave the quota out (Deno's applies it), socli.jsreads it itself (cgroup_cpu_quota, a transcription of std's) — under a--cpusorCPUQuota=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 Rustusizeon one side and by a hand transcription ofusize::from_stron the other (usize_from_str) — ASCII digits, an optional leading+, nothing pastusize::MAX, refused inParseIntError's own words — andscripts/test_napi_npm.tsruns the accept/reject table through both bins, which is the only place both exist at once. Repetition needs no restatement of its own:cli.jsparses argv by a transcription of argh's grammar (parse_argv), so a second value for any value-taking flag (--content,--parser,--source-type,--jobs) isduplicate values providedon 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.--jobs 1,--content,--stdin, and--listare single-threaded on both CLIs. 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./workerentry and recompile nothing. Behavioral changes toformat/parsehere must be mirrored there and in the CLI tests ofscripts/test_npm.ts(wasm) andscripts/test_napi_npm.ts(native). - 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
-
tsv_debug(development): Uses embedded Deno sidecar for external tools- Reuses
tsv_cliinfrastructure - 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)
- Reuses
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):
-
prettier + prettier-plugin-svelte
- Used by:
compare,format_prettier, fixture management - Purpose: Format code, compare outputs, validate formatter behavior
- Used by:
-
svelte
- Used by:
canonical_parse,ast_diff, fixture management - Purpose: Parse Svelte code with official compiler
- Used by:
-
acorn + @sveltejs/acorn-typescript
- Used by:
canonical_parse,ast_diff, fixture management - Purpose: Parse TypeScript code (matches Svelte's TS parser)
- Used by:
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.
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.mdwould otherwise parse as TypeScript); an unsupported one is an argument error, the same messageformat <file>gives, unless--parsernames the grammar outright - 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.
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
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_STACKon 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, andRUST_MIN_STACKmoves that one but not the main thread's. - so without the reservation, one binary has an 8x depth difference between
tsv format <path>andtsv format --contenton the same input on the same Windows machine — and the asymmetry points the other way on a machine whoseRLIMIT_STACKis above the pool's own reservation, where the pool becomes the shallower route.tsv parsehas 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:
parsereaches the same depth asformaton 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. 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'stypescriptparser). The one reader of the wire left istsv_debug'sjsonmodule (the fixture gate, the sidecar transport, every audit'sValuewalk), which reads with the limit disabled on the sameSTACK_SIZEreservation: aValueread 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. Fixturetypescript/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 the route used to 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.
Before the reservation Node's routes stopped 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 is retried; its
pool workers take the same reservation, so the 4 MiB worker row in the table above is not
cli.js's own pool.
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/.tsxare out of scope),.svelte, and.css(compound forms like.svelte.tsincluded). The safety nets.git,node_modules,.sl,.hg,.svn,.jjare 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/subor.cache/xwalks 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: namingpkg/distdoesn't override adist/rule, nor namingvendor/x.tsavendor/one, the line prettier, ESLint, oxfmt and deno fmt draw too. Such a path is skipped. A named file a.formatignoreor.prettierignorerule excludes is skipped quietly, as prettier skips it, whether or not a.gitignoreexcludes it too: those files exist to say what not to format, 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-ignoredfor 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.gitignorerule 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.gitignoreexcludes 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.tsfor 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 no line can spell — one holding a line feed, which splits any line, or a file name ending in a carriage return, which a line's end drops — gets no lines, and the warning says to narrow the rule instead. The file named is the one the root reads: its.prettierignorewhere it has no.formatignore, since a.formatignorecreated beside it would shadow every rule in it. A.formatignore/.prettierignorerule excluding a named directory is the user's own to narrow, and such a rule is the one a warning names wherever it excludes the path at all, even where a.gitignoreexcludes the path first: re-including past the.gitignorewould leave the rule standing — and, added to the same file, override it. A run whose every argument was an excluded file exits 0 rather than failing withNo 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.svelteor.cssgoes 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.jsonreprints 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. -
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--checkis 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 againstgit check-ignoreon case-sensitive filesystems). This goes beyond Prettier, which reads only one.gitignoreand 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.gitignoreso 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.formatignoreof its own; a sibling.formatignoreshadows it per-directory (used alone when present, even if that.formatignoreis present-but-unreadable — a read error can't silently demote tsv's native file to prettier's). Like the hierarchical.gitignoreabove, this goes beyond Prettier's single cwd-relative.prettierignore— so a monorepo that runsprettierper-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.formatignoreshadows a.prettierignore, pointing at merging the patterns into.formatignore. Compat caveat: as a tsv layer a.prettierignore!can re-include a path.gitignoreexcluded (subject to git's parent-directory rule), whereas Prettier treats.gitignoreand.prettierignoreas 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,
.gitignoreand.prettierignoreare not read (as git itself does); only.formatignoregoverns, hierarchically from the filesystem root down — so a~/.formatignoreis global config for loose files. A.prettierignorein 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, orgit 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
.gitignorein scope is authoritative and turns the heuristic off; with no.gitignore, the heuristic — hidden directories plusdist/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.tsnarrows 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 adistat every depth. A bare!dist/keep.ts(without the directory re-include) is a no-op — the heuristic prunesdistbefore descending, mirroring git's parent-directory rule (a gitignoreddist/likewise blocks a later!dist/keep.ts). tsv emits a stderr warning for this case (non-fatal — no effect on the exit code, stdout, or--list/--checkoutput), naming the file the re-include was written in and spelling the directory escape for that file —!/dist/inpkg/.formatignorefor a prunedpkg/dist— 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. -
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
.gitsubdirectories below it) does not honor the inner repos'.gitignores — run tsv per repo. -
Piped output — a closed consumer is not a failure.
tsv format . | headfills the 64 KiB pipe buffer on any tree whose changed-path report exceeds it, soheadhas 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--checkwould-change, 2 errors) and the stderr summary still prints when stderr is not the closed fd, sotsv format . | headstays informative. The rule covers both fds —2>&1 | headcloses 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
SIGPIPEreports) and restoringSIGPIPEtoSIG_DFLboth replace the 0/1/2 verdict with "the reader left", and for--checkthe exit code is the API. Exiting 0 is also the honest answer forformat, 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 ignoresSIGPIPEtoo, socli.jscould 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
SIGPIPEtoSIG_IGNat startup, so the write returnsEPIPEandprintln!/eprintln!panic on it — every byte therefore goes throughcli/out.rs(write_stdout/write_stderr, and theout_line!/err_line!macros over them), which absorbsBrokenPipe, waits outWouldBlock(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 pipedprocess.stdoutafter spawningtsvasynchronously, 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/tsvloader, waiting inspawnSync, never does), and panics on anything else.parserides the same writer, so a closed reader there no longer reports1, its parse-error code.cli.jswrites both fds synchronously (an asyncprocess.stdout.writebeforeprocess.exittruncates), 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. Itswrite_fdtherefore loops overwriteSync, honors partial writes, sleeps 1 ms and retries onEAGAIN, and goes quiet onEPIPE. The two bins reachEAGAINby different roads —cli.jsflips its own fd, the native CLI inherits a flipped one — and answer it alike.A consumer that is merely slow gets every line on both bins:
EPIPEends the output,EAGAINis waited out, and any other write error is a failure. Pinned bytests/cli_tests.rs(*_closed_pipe_*,*_slow_pipe_*,*_non_blocking_stdout_*) andscripts/test_npm.ts's twin rows. -
Invalid UTF-8 in a source file: reading is strict UTF-8 on both CLIs — the native one because Rust's
read_to_stringrefuses invalid bytes, andcli.jsbecause it decodes throughTextDecoder(..., {fatal: true})rather thanreadFileSync(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 reportread failed: stream did not contain valid UTF-8, count the file as an error, and leave every byte in place. Same rule for--stdinand forparse. -
Unreadable ignore files: a
.gitignore/.formatignore/.prettierignorethat 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.gitignorealso 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--checkreproducibility 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.gitignoreis 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..formatignoreand.prettierignorekeep reading through links, as prettier does. -
--checkreproducibility assumes the ignore files are committed: a local/uncommitted.formatignoreor.prettierignore(or git's unread.git/info/exclude/core.excludesFile) makes a clean CI checkout disagree. -
Shared by construction: the matcher is the
tsv_ignorecrate'sIgnoreStack; the per-directory prune/descend policy (heuristic, safety nets, the shadow warning) is thetsv_discovercrate'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. Seecli/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
withstatement, a leading-zero literal or escape,awaitas 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-levelimport/export, animport.meta, a top-levelfor await, or the operand a module reads after a top-levelawait, 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,exportat the bottom — position alone would blame the validexportline). 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 firstwith/legacy literal/awaitname — 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 bytests/format_fallback_error_attribution.rs. An explicit--source-typeis exact —--source-type modulerefuses a script-only source rather than retrying — which is why it is a usage error in path mode rather than a per-run override.parsehas no fallback at either surface: its wire'sProgram.sourceTypeis 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'sformat_typescript(source), and theformat_*exports of all three bindings called with nosourceType. -
Two extensions settle the goal themselves, and skip the retry.
.mjsand.mtsare ES modules whatever any config says — Node loads a.mjsas ESM unconditionally, and TypeScript maps both toModuleKind.ESNextwith the extension overridingmodule— 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.mjswould format awithstatement that no runtime would load. Nothing else settles a goal:.js/.tsare ambiguous by design, and.cjs/.ctsare 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. Bothtsvbins apply it — the native CLI per file informat_file, andcrates/tsv_wasm/npm/cli.jsfrom a hand-restated copy (as withclamp_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 scripton a.mtsis honored as the caller's exact claim rather than refused — the precedence prettier draws too (its__babelSourceTypeoption beatsgetSourceType(filepath)), and the one parse takes on every surface, since the wire'ssourceTypeis what was asked for. An editor keeps the fallback, because it has no path to read: the VS Code extension dispatches on the document'slanguageId(.mjsarrives asjavascript,.mtsastypescript) and calls the binding's bareformat_typescript(source). So a.mjsholding 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 (
srcvs./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 collapse the same way: an ignore-file warning (a shadowed.prettierignore, an unreadable ignore file, a.prettierignoreoutside a repo) names its directory by the directory's absolute path, whichever root or argument spelling reached it, sotsv format . sub— the repo root walked as.and preloaded assub's ancestor — andtsv format . ./each warn once. -
In-place writes: files are rewritten only when output differs (no mtime churn).
--content/--stdinkeep 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) without formatting — a read-only view of the setformatwould touch, after the ignore files are applied. Path mode only (errors with--content/--stdin) and mutually exclusive with--check. 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::scopeworkers claiming one file at a time from a shared queue — dynamic load balancing with no thread-pool dependency.--jobs Noverrides the worker count, clamped to the file count and floored at 1 (--jobs 0is a width, not an opt-out — it means--jobs 1); 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
--jobsis held to4 × 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 reservesSTACK_SIZEof address space, and an unbounded count takes task slots until the OS refuses, which on a systemd machine is the login session's wholeTasksMaxand wedges every other process on it.And a
--jobsthe 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 withpanic = "unwind"; release usespanic = "abort") reports to stderr and processing continues. -
Deterministic reporting: changed paths print to stdout in sorted-path order regardless of completion order; errors (traversal and per-file) and the summary line go to stderr.
-
Exit codes: 0 clean, 1 would-change (
--checkonly), 2 errors.