mini-static 0.31.2

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
# PLAN-extract-build.md — Extract the CSS/JS build pipeline into `mini-build`

## Where We Are

`mini-static` is two things in one crate. The serving core — request resolution,
streaming, conditional requests, ranges, connection lifecycle — is 2,891 lines across
`server.rs`, `resolve.rs`, `handler.rs`, `error.rs`, `reload.rs`, `spa.rs`. A CSS/JS
build pipeline is 1,358 lines (32% of the crate) across `css.rs`, `js.rs`, `source.rs`,
`tool.rs`, `watcher.rs`: it discovers files under registered source folders, shells out
to `lightningcss`/`esbuild`, and writes results into an output dir that **defaults to the
served root**.

Concretely, today:

- The same process both writes into and serves from the served root. `mini-static`
  cannot claim to be read-only.
- Because it writes what it serves, the output dir is deliberately never watched — doing
  so would feed each pipeline its own writes back into its own trigger. Live-reload
  therefore watches *source* folders and infers that outputs followed. (As of 0.28.7 it
  also watches the served root when no pipeline is configured — commit 0 below, already
  shipped. The two-branch shape that leaves is exactly what B2 collapses.)
- The pipeline is the crate's only reason to enable `tokio`'s `process` feature.
- Per-file mode spawns one subprocess per file, sequentially: `css.rs:208` loops and
  `await`s `run_tool` per file (`css.rs:219`). At roughly 1–5 ms per process spawn, 100
  files is a half-second of serialized fork/exec. Nothing measures this — `benches/`
  covers path resolution and the HTTP request path, both in microseconds.
- `css.rs`, `js.rs`, and `source.rs` sit at 73%, 77%, and 80% line coverage against
  98–100% for `resolve.rs` and `error.rs`, and `tests/real_tools.rs` is `#[ignore]`d
  because it needs `lightningcss`/`esbuild` on `PATH`.

Public pipeline surface to be removed: `with_source_folder`, `with_asset_folder`,
`with_output_dir`, `with_bundle_root`, `with_css_tool`, `with_js_tool`,
`with_prune_output`, `Server::build()`, and the exported `CssOptions`, `CssTool`,
`JsOptions`, `JsTool`.

Consumers: `mini-unified` and `mini-serve` use **none** of it (verified by grep over
their sources). The only in-repo user is `examples/mini-static.rs`. Outside the repo, one
website.

## Where We Need To Be

- A new standalone crate, `mini-build`, owns CSS/JS/asset processing. It has no HTTP
  types and does not depend on `mini-static`; `mini-static` does not depend on it. They
  compose through a directory: the builder writes one, the server serves one.
- `mini-static` never writes to disk. Its file access is read-only, and that is stated
  as a property rather than an accident.
- `mini-static`'s live-reload watches **the directory it serves**. The
  "never watch the output dir" constraint disappears, because the server no longer
  produces that output — the feedback loop it guarded against is now impossible.
- `mini-static` no longer enables `tokio`'s `process` feature and no longer shells out
  to anything.
- `mini-build` has its own benchmark suite measuring whole-tree build wall-clock, so the
  optimizations that are currently invisible — parallel invocation, batching several
  inputs into one tool call, incremental skip-unchanged, `esbuild`-style persistent
  process — become measurable. This plan does **not** implement them; it creates the
  place where they can be.
- `mini-build` CI installs `lightningcss` and `esbuild`, so its tool tests run by default
  instead of being `#[ignore]`d.
- The removal is advertised loudly: `mini-static` 0.29.0 (0.x minor = breaking), with a
  migration section at the top of the README — which `lib.rs` includes, so it reaches
  docs.rs — showing before/after for every removed method.

## Commits

Commit 0 is independent of the extraction entirely and should land first. Phase A lands
in the new `mini-build` repo and breaks nothing. Phase B is the breaking change in
`mini-static`. Phase C updates consumers. Within a phase, each commit is independently
revertible; Phase B must not land before Phase A publishes.

### 0. Fix live-reload's watch targets — **DONE** (0.28.7, `5e95d18`)
- Does: `watch_targets()` (`src/server.rs:931`) returns `source_folders + bundle_roots +
  asset_folders` and **never the served root**. So `Server::new(root).with_live_reload()`
  with no pipeline configured watches nothing: the SSE stream opens, stays open, and
  never emits. Editing a served file does nothing. Meanwhile `with_live_reload`'s own doc
  comment states the watcher runs "over the server's root" and broadcasts "whenever a
  served file is added, modified, or removed." The documented headline behavior of the
  feature does not happen for a plain static site.

  Add the served root to `watch_targets()`. This is a bug fix, not a refactor, and it is
  **not** gated on the extraction — it is only listed here because the grill found it
  while checking B2's assumptions.

  **Provenance, established after the grill:** not a regression from the recent hardening
  work. `f756e5e` originally called `start_watching(root_canon)` directly; `7a4362b`
  ("split source + output bundle/minification", shipped as **0.15.0**) replaced that with
  the `watch_targets()` input list, and a configuration with no pipeline has watched
  nothing ever since. The doc comment was never updated, so it kept describing the
  pre-0.15.0 behavior.
- Verifies: a new test enables `with_live_reload()` with **no** source folders, connects
  to the SSE stream, writes a file into the served root, and asserts a reload frame
  arrives — the case no existing test covers (`tests/live_reload.rs` asserts broadcast
  only at lines 160 and 209, both of which configure `with_source_folder`).
- Touches: `src/server.rs` (~10 LOC), `tests/live_reload.rs` (~50 LOC).
- Reverts cleanly: yes — entirely self-contained.
- **Correction, found while implementing:** this commit's original note said to "exclude
  the output dir from the served-root walk" while the pipeline still writes there. That
  does not work — `output_dir` *defaults to* the served root, so excluding it excludes
  everything and fixes nothing. What shipped instead branches on `has_pipeline()`: with no
  pipeline, watch the served root; with one, keep watching pipeline inputs, since
  `SourcePipeline` already broadcasts its own outputs once written and its echo
  suppression (`src/source.rs:289`) is keyed to input folders. So `watch_targets()` is now
  a two-branch function, and B2's job is deleting the second branch rather than deleting
  an exclusion.

### A1. Scaffold `mini-build` and move the pipeline modules verbatim — **DONE** (`7be1c0b`, `backstack/build`)
- Does: New crate. Move `css.rs`, `js.rs`, `source.rs`, `tool.rs` and their unit tests
  across. "Verbatim" is not quite true and the difference is the whole risk of this
  commit, so it is enumerated: `source.rs` imports `crate::reload::ChangeType` and
  `crate::watcher::{Broadcaster, ChangeEvent}` (`src/source.rs:5-6`), and **both of those
  modules stay in `mini-static`**`reload::ChangeType` is the SSE payload type, and the
  watcher still drives live-reload. So A1 must give `mini-build` its own change-type enum
  and its own change channel rather than importing the server's. `css.rs`/`js.rs` depend
  only on `crate::tool`, which moves with them; those two are genuinely verbatim.
  No API design in this commit — relocation only, so any later diff is real signal. It
  also stays **async and `tokio`-based**, even though A3 converts it to sync: moving and
  rewriting in one diff would put both changes beyond the reach of the equivalence harness
  A2 builds. Inheriting `tokio` here is deliberate and temporary.
- Verifies: every moved unit test passes in its new home, at the **same count per
  module** as before the move — 7 css, 6 js, 9 source, 7 tool, matched exactly against
  `mini-static`, so a silently dropped test file shows up as a number.
- Touches: new repo, ~1,200 LOC moved plus ~40 LOC replacing the two borrowed types.
- Reverts cleanly: yes — deleting a new repo affects nothing.
- **Correction: `tests/real_tools.rs` does not move.** The plan assumed it was a pipeline
  test; every case in it builds a `Server` and calls `Server::build()`, so it is a
  server-integration test of the pipeline, not a test of the pipeline. Rewriting it
  against `SourcePipeline` is API work belonging to A2, and A5's tools-installed CI job
  needs `mini-build`-native equivalents written from scratch. The file stays in
  `mini-static` until B1 removes what it tests.
- **Note: four items have no caller yet**`CssOptions::is_minify`, `JsOptions::is_minify`,
  `tool::locate_on_path`, and `is_executable_file` were reached through
  `Server::required_tool_binaries` and `Server::run_on`'s pre-bind check, both of which are
  server glue that did not move. They carry `#[allow(dead_code)]` with a comment naming A2
  as their incoming caller, rather than being made `pub` — which would be API design this
  commit is explicitly not doing. A2 removes the annotations.
- **Note: the change channel came across as `src/change.rs`**`ChangeType`, `ChangeEvent`,
  and `Broadcaster` reproduced rather than shared, per the plan. `start_watching` stayed
  behind; A4 needs its own. Four tests cover the new module, two ported from
  `tests/unit/watcher.rs` and two new ones pinning extension classification, which decides
  which pipeline owns a file and so is behavior rather than convenience.

### A2. `mini_build::Builder` public API
- Does: State the crate's purpose in its docs first — **"produce the directory a static
  server serves"** — because that sentence is what decides scope questions like whether a
  byte-identical asset copy belongs here (it does; see Open Questions). Then replace the
  `Server` builder methods with a standalone `Builder`: `source_folder`, `asset_folder`,
  `output_dir`, `css_tool`, `js_tool`, `prune_output`, and `build()`. Same semantics, same
  overlap rejection, same `.min.*` bypass, same fail-loud tool-missing check — the
  validation logic moves with the code rather than being rewritten.
- Verifies: **byte-identical output**, compared *in the same process run* against
  `mini-static` 0.28.3 taken as a dev-dependency — **not** against committed golden files.
  The distinction is load-bearing: the pipeline shells out to `lightningcss`/`esbuild`, so
  its output depends on the installed tool version. Goldens generated once would fail
  spuriously the day CI picks up a newer tool, and the natural response — regenerating
  them — would silently destroy the only evidence the move preserved behavior. Running
  both implementations against the same binaries in the same run compares the two things
  that actually need comparing. This is the check that makes a 1,200-line move safe;
  without it, "it compiles and the unit tests pass" is not evidence the pipeline still
  does the same thing.
- Touches: `mini-build` (~200 LOC of API surface), tests (~150 LOC).
- Reverts cleanly: yes.

### A3. Convert to synchronous execution; drop `tokio`**DONE** (`d4ea087`)
- Does: Replace `tokio::process` with `std::process` and `async fn` with plain functions.
  Reimplement the 30 s `TOOL_TIMEOUT` and `kill_on_drop`, the only capabilities
  `tokio::process` was providing. Remove `tokio` from `Cargo.toml`.
- **Deviation: the per-file loop stayed sequential.** The plan said to parallelize it here
  with threads or `rayon`. That conflates a substrate change with an optimization, and
  A5's benchmarks — the only thing that could show whether parallelism helps and by how
  much — do not exist yet. Converting `await`-in-a-loop to a sequential sync loop is the
  minimal faithful translation; parallelism is now a commit after A5, measured. This also
  keeps A3's diff within reach of A2's harness, which compares bytes and would not notice
  a race that only manifests under load.

  Deliberately *not* in scope: batching multiple inputs into one tool invocation, a
  persistent `esbuild` process, and incremental skip-unchanged. Those are the optimizations
  the extraction exists to enable, and each deserves its own commit measured against A5's
  benchmarks. This commit only changes the substrate.
- Verifies: A2's byte-identical comparison still passes — it is the whole reason this is a
  separate commit, since it is what catches a mistranslation of the tool-invocation or
  timeout logic. Plus a test asserting a tool that hangs is killed at the timeout, which is
  the behavior most likely to be lost in the rewrite; and `cargo tree` showing no `tokio`.
- Touches: `mini-build` (~200 LOC changed), `Cargo.toml`.
- Reverts cleanly: yes — the async version is one revert away, and A2's harness proves
  either direction is correct.
- Note, carried forward to whenever parallelism does land: it changes *timing*, not
  output. If the byte-identical test ever passes serially and fails in parallel, that is a
  real bug — two files racing on one output path — not a flaky test. Do not paper over it
  with a retry.
- **What the harness earned:** the sync implementation produced output byte-identical to
  `mini-static`'s async pipeline on the first run. Two implementations that no longer share
  an execution model, agreeing on every byte, is a claim no amount of reading the diff
  could have supported.
- **What the harness could not see:** it compares output bytes, so a rewrite that dropped
  the tool timeout entirely would still pass for every tool that terminates — and hang
  forever on the one that does not. Two tests cover that gap directly: a hanging tool is
  killed at the timeout, and a timed-out child leaves no process behind. The second exists
  because `std::process::Child` does not kill on drop the way tokio's did. Removing the
  `kill()` call fails both, and makes the suite take 30 s instead of 2.6 s as the abandoned
  child holds its pipes open — the leak is visible in the clock as well as the assertion.

### A4. Watch mode — **DONE** (`21f051c`)
- Does: Port the mtime-polling watcher so `Builder::watch()` rebuilds affected outputs on
  source change — the same trigger logic live-reload drives today, minus the broadcaster.
  This is the development workflow, and it is what makes the decoupled design work:
  `mini-build` watches sources and writes the output dir; `mini-static` watches the dir it
  serves and pushes the reload. Note what that buys — the "reload only after the output
  exists" ordering the coupled design maintains deliberately becomes structural, since
  `mini-static` cannot observe a change before the file is written.
- Verifies: a test edits a source file under `watch()` and asserts the corresponding
  output is rewritten within a bounded interval; a bundle-mode edit rebuilds the bundle,
  a per-file-mode edit rebuilds just that file.
- Touches: `mini-build` (~150 LOC incl. the watcher).
- Reverts cleanly: yes — `build()` stands alone without it.
- **Two decisions the async original never had to make.** Stopping: `mini-static`'s
  watcher loops forever because its task dies with the process. A library handing back a
  handle cannot do that, so `WatchHandle` signals an atomic and joins on drop — a
  watcher outliving its handle is a leak that surfaces as builds running after the caller
  believes it stopped. Failure reporting: `process_change` can fail, and the original
  `eprintln!`'d. A library writing to its host's stderr uninvited is the same complaint
  this plan levelled at `mini-static`, and an error queue nobody drains is unbounded, so
  `watch` takes an `on_error` callback — a caller must name what happens to a failed
  rebuild rather than inherit silence.
- **A real race, found by a failing test.** The baseline mtime snapshot was originally
  taken *inside* the spawned thread, so a caller who edited a file immediately after
  `watch()` returned could have that edit absorbed into the baseline and silently never
  built. Invisible in casual use, reliable under a script. The snapshot now happens before
  the thread starts, making "once `watch` returns, later writes are seen" a guarantee
  rather than a race won by luck.
- **A test premise that was wrong, not the code.** The `on_error` test first used per-file
  mode, which *deliberately* degrades a tool failure to a raw copy and returns `Ok`
  (`css::build_css_file`). Written that way the test passed for the wrong reason — by
  never failing at all. It now uses bundle mode, where failures genuinely propagate, and
  says so, because the asymmetry is easy to trip over twice.

### A5. Build benchmarks, CI, publish 0.1.0 — **BUILT, NOT PUBLISHED** (`debaae9`)
- Does: `benches/build.rs` measuring whole-tree wall-clock for a fixture of N CSS and N
  JS files; CI job installing `lightningcss`/`esbuild`; README; publish. **Confirm the
  crate name is free on crates.io before A1**, not here — discovering it at publish time
  means renaming a finished crate. Note publishes are permanent: a bad one can be yanked
  but not replaced, so this is the one commit in the plan with no true revert.
- Verifies: `cargo bench` reports a baseline (record the numbers in the bench's module
  docs, as `mini-static` does); CI runs the tool tests un-ignored.
- Touches: `mini-build` (~150 LOC), CI config, README.
- Reverts cleanly: everything except the publish, which is why the publish is held.
- **Publishing is deliberately not done.** It is the one irreversible step in the plan —
  crates.io versions can be yanked but never replaced — and it is outward-facing. Awaiting
  an explicit go-ahead rather than inferring one from "proceed". The name is confirmed
  free (checked against a control query, since a rate-limited response is
  indistinguishable from "available" if you do not look).
- **The benchmark found the real number, and it is far worse than this plan assumed.**
  The plan estimated 1–5 ms per process spawn. Measured: **~23 ms**, linear in file count
  (23.9 ms/file at 10 files, 23.1 ms/file at 50). Fifty CSS files take 1.155 s to bundle;
  mirroring fifty files takes 13.8 ms. Roughly **90% of a real build is process startup**,
  which no amount of tuning discovery or copying can touch — only spawning fewer processes
  does. Baseline table recorded in `benches/build.rs`.

  | case | 10 files | 100 files | 400 files |
  |---|---|---|---|
  | `per_file` (passthrough) | 3.30 ms | 26.3 ms | 105.5 ms |
  | `asset_mirror` | 2.11 ms | 13.8 ms | 54.0 ms |
  | `css_bundle` (spawns per file) | 238.8 ms | 1.155 s at 50 | not run |

- **Correction to A1's correction:** `real_tools.rs` was rewritten against `Builder`
  rather than ported, as A1 predicted. It is **not** `#[ignore]`d, unlike the original:
  the tests detect the binaries and skip loudly when absent, so a developer without them
  still gets a green suite while CI — which installs both — genuinely exercises them. An
  `#[ignore]`d test does not run even on a machine that could run it, which is the worse
  failure: the coverage exists and nobody benefits.
- Coverage 85.52%, above the standing 80% gate.

### B1. Remove the pipeline from `mini-static`**DONE** (0.29.0, published)
- Does: Delete `css.rs`, `js.rs`, `source.rs`, `tool.rs`; remove the eight builder
  methods, `Server::build()`, and the four exported option/tool types; remove the
  startup-build and tool-presence-check blocks from `run_on`; drop `tokio`'s `process`
  feature. Remove `StaticError::PipelineSetup` and `StaticError::Build`, now unreachable.
  No deprecation shims: with no consumers to shim for, a stub that silently does nothing
  is worse than a compile error next to a migration guide.
- Verifies: the full remaining suite passes; `cargo tree` no longer shows `tokio/process`
  in the enabled feature set; a grep asserts no `Command`/`Stdio` remains in `src/`; and
  — the point of the whole exercise — a grep asserts `src/` contains no filesystem write
  call, making "the server never writes" checkable rather than aspirational.
- Touches: `src/` (−~1,200 LOC), `src/lib.rs`, `Cargo.toml`.
- Reverts cleanly: yes, but only meaningfully alongside B2.
- Note: removing `StaticError::{PipelineSetup, Build}` is breaking even though the enum is
  `#[non_exhaustive]` — that attribute forces consumers to carry a wildcard arm, but any
  code *naming* those variants stops compiling. Intended and advertised in B3.
- **Resolved: nothing else needed a bump.** Under 0.x semver each minor is its own
  compatibility range, so `mini-unified` and `mini-docs` (pinned `0.19`) and the three
  `castra` servers (pinned `0.7.1`) do not resolve to 0.29 and therefore do not break.
  They are simply stale — none of them receives any of the 0.22–0.29 security work until
  someone bumps them deliberately, which is a separate decision from this migration.
- **B2 was folded in rather than sequenced.** Removing the pipeline made `watch_targets`'
  second branch unreachable, so the compiler required the collapse in the same commit;
  splitting them would have meant committing code that does not build.
- Verified, not asserted: `src/` contains no filesystem write call, no `Command`/`Stdio`,
  and `Cargo.toml` no longer enables `tokio`'s `process` feature. 3,239 lines removed
  against 113 added. Coverage rose 83.9% → 88.92%, since the modules dragging it down
  (`css.rs` 73%, `js.rs` 77%, `source.rs` 80%) left with the pipeline.

### B2. Collapse `watch_targets()` to the served root — **DONE** (folded into B1)
- Does: Commit 0 left `watch_targets()` with two branches — served root when
  `has_pipeline()` is false, pipeline inputs otherwise. B1 removes pipelines entirely, so
  `has_pipeline()` and the whole second branch become dead code. Delete both, along with
  the `with_bundle_root` vestige, leaving a function that returns the served root. Say in
  the docs *why* it collapses: nothing writes what the server watches any more, so the
  feedback loop the second branch existed to avoid cannot occur.
- Verifies: the live-reload suite passes with the served root as the sole watch target —
  including `live_reload_broadcasts_for_a_served_file_with_no_pipeline_configured`, which
  commit 0 added and which must keep passing unchanged; plus a test that writes into the
  served root while the server runs and asserts exactly one frame, proving no self-trigger
  loop now that nothing writes.
- Touches: `src/server.rs`, `src/watcher.rs` (~60 LOC removed), `tests/live_reload.rs`.
- Reverts cleanly: yes — reverting restores redundant watch targets, not broken ones,
  because commit 0 owns the behavior consumers rely on.
- Note: watching the served root walks the whole *built site* every 500 ms, where the old
  targets were smaller source trees. For a large site that is a real per-tick cost, and it
  arrives with commit 0 rather than here. Event-driven watching (`notify`) is the fix if
  it bites; it is not in scope.

### B3. Advertise the break; 0.29.0 — **DONE**
- Does: README migration section at the very top — before/after for all eight methods,
  naming `mini-build` and its version — plus a line in the status blockquote. Update
  `examples/mini-static.rs` to drop the pipeline demo, and the `Dockerfile` to remove the
  now-doubly-unnecessary `pkg-config`/`libssl-dev` (nothing in the tree links OpenSSL).
  Bump to **0.29.0**: under 0.x semver the minor bump *is* the breaking-change signal.
- Verifies: `cargo test --doc` passes (the README is compiled as crate docs, so migration
  snippets must be real code or fenced as `text`); the example builds and runs.
- Touches: `README.md`, `examples/mini-static.rs`, `Dockerfile`, `Cargo.toml`.
- Reverts cleanly: yes.

### C1. Update the website — **DONE** (`fef7c1d` in `Documents/victor`)
- Does: Switch the site's build to `mini-build`, pinned to 0.1, run ahead of serving.
- Verifies: the site's CSS/JS outputs are byte-identical to what it produced before the
  switch, and it serves correctly from the built directory.
- Touches: the website repo.
- Reverts cleanly: yes — no directory structure moved, so a revert is a pin change.
- **Byte-identical, verified against the same sources.** `styles.css` and `scripts.js`
  both hash identically between the old `mini-static` pipeline and `mini-build`.
- **The comparison failed first, for a methodology reason worth recording.** The "before"
  build ran in a `git worktree` at HEAD while the "after" ran in the working tree, which
  had uncommitted edits to `src/styles/typography.css` and `src/styles/variables.css` — a
  theme change in progress. Different inputs, so of course different outputs; the CSS
  differed while the JS matched, precisely because no JS source had been touched. I had
  truncated `git status` with `head -3` and taken it for the whole list. Copying the two
  modified sources into the worktree made the comparison valid, and the hashes then
  matched exactly. **A byte-comparison across two trees is only evidence if the inputs are
  provably the same.**
- **Consumer scope was wider than this plan claimed, and it still came out fine.** The
  plan said `mini-unified` and `mini-serve` were the only in-repo consumers. A search
  across all of `~/Documents` found six crates depending on `mini-static`: those two plus
  `mini-docs`, and three `castra` frontend servers. None uses the pipeline API —
  `mini-docs`' apparent hits were its own `Builder::build()`, and it pins 0.19 regardless.
  The website really is the only consumer that breaks.
- **What the migration actually looks like:** `configured_server` keeps only serving
  concerns (404 page, spa root, transition); a new `configured_asset_builder` holds the
  source folders, tools, and asset folder. `build_static` calls the builder and is no
  longer async. `main.rs` builds explicitly before constructing the server — the server
  no longer builds anything — and in debug builds starts `Builder::watch`, so the
  intended chain runs for real: `mini-build` watches `src/` and writes `public/`,
  `mini-static` watches `public/` and pushes the reload. That second half only works
  because of commit 0, which is why the website's `mini-static` pin moved to `0.28`.
- **Finished across three commits** as each dependency became available: `fef7c1d`
  (migration, path dependency), `260e08a` (published `mini-build` 0.1), `5fc9d61`
  (`mini-static` 0.29 plus the benches).
- **The benches were missed the first time.** C1 checked `src/` for pipeline API use and
  found the two entry points, but `benches/request_serving.rs` and
  `benches/asset_pipeline.rs` also called `Server::build()`. They only surfaced when the
  0.29 bump made them fail to compile. A grep scoped to `src/` is not a grep of the crate.

### A6. Batch tool invocations — **DONE** (`5d55398`, `49c0d08`, `a4b5cb7`)
- Does: Send many inputs to one tool invocation instead of one per file, in bundle mode
  and per-file mode, for both CSS and JS. Not in the original plan — it exists because A5's
  benchmarks made the cost impossible to ignore.
- Result, measured before and after on the same fixture:

  | case | 10 files | 50 files |
  |---|---|---|
  | `css_bundle` before → after | 238.8 → 194 ms | 1155 → **195 ms** |
  | `per_file_minify` before → after | 236 → 189 ms | 1128 → **192 ms** |
  | `css_bundle_flat` (one directory) | 36.5 ms | 48.5 ms |

  **5.9x** on fifty files in both modes, and — the part that matters more — cost stopped
  scaling with file count. Ten files and fifty now cost the same, because both sit in the
  same eight directories, and it is directories that decide how many processes spawn. The
  eight-directory fixture is deliberately adversarial; a real site keeps stylesheets in one
  or two folders and sees the `_flat` column.
- **Grouping by directory is correctness, not caution.** `lightningcss --output-dir`
  *flattens*: `one/shared.css` and `two/shared.css` batched together collide and one
  silently vanishes. Verified against the real binary before designing, not assumed.
  (`esbuild --outdir` preserves structure, but both use the same strategy rather than
  depending on the difference.)
- **The per-file fallback preserves a contract batching would otherwise break.** Per-file
  mode promises a malformed source degrades to a raw copy *while its neighbours are still
  minified*. Batched, one bad file fails the whole invocation — so a failed batch rebuilds
  its group one file at a time. The naive alternative (copy the whole group raw) is caught
  by a dedicated test.
- **A correction to A5's reported number.** A5 recorded ~23 ms/file as the tool's startup.
  Wrong: `lightningcss` itself takes ~5 ms, and bare `std::process::Command` costs the same
  ~19 ms as this crate's wrapper — so it is Rust's spawn on this platform, where `fork`
  scales with the parent's address space, not the tool and not our code.
- Coverage 86.07%. `js.rs` was left at 60.5% by the mirrored batch path until tests were
  written for it directly — a copied implementation is exactly the kind that drifts
  unnoticed.

## Open Questions

- **Where the watcher lives.** Both crates need mtime polling: `mini-build` to trigger
  rebuilds, `mini-static` to trigger reloads. The recommendation is to duplicate ~137
  lines rather than introduce a third crate for one polling loop — S6's "write it twice
  if you must" over a coupling that would bind two otherwise-independent crates. Revisit
  only if a third consumer appears.
- ~~**Does `mini-build` need watch mode at all (A4)?**~~ **Resolved: yes.** It is the
  development workflow — sources rebuild, the server notices the output, the browser
  reloads. Kept in A4. Consequence to accept: two 500 ms polling loops now run in series,
  so worst-case reload latency roughly doubles. Event-driven watching (`notify`) is the
  fix if that becomes annoying, and it is a `mini-build` concern — one of the tunings
  extraction exists to make possible.
- ~~**Asset folders may not belong in a *build* crate.**~~ **Resolved: they belong in
  `mini-build`**, on the condition that the crate's stated purpose is *"produce the
  directory a static server serves"* rather than *"run bundlers"* — under that framing a
  byte-identical copy is in scope, and it earns its keep over plain `cp -r` because asset
  changes should trigger reload and stale-output pruning like anything else. A2 should
  write that purpose statement into the crate docs first, since it is what makes the
  answer non-arbitrary.

  Rejected alternative, recorded because it is the tempting one: give `mini-static`
  multi-root serving so requests fall through to asset directories with no copy at all —
  no duplication, no staleness. It is rejected because the crate's security story is one
  root, canonicalized once, with `starts_with` as the boundary. N roots means N
  boundaries, precedence rules, and paths resolvable two ways: a meaningful tax on the
  single thing this crate must never get wrong, paid to avoid a file copy.
- **Does `mini-static` still need `run_on`'s pre-bind check hook?** With tools gone, the
  only pre-bind failure left is the bind itself. Confirm nothing else depended on that
  ordering before deleting it in B1.
- **Version signalling.** 0.29.0 follows 0.x convention, but a reader skimming the
  changelog may not register a minor bump as breaking. The README's top-of-file migration
  section is doing the real advertising work here; make sure the crates.io description or
  the status blockquote carries it too, since that is what a consumer sees first.
- ~~**Async or sync for `mini-build`?**~~ **Resolved: sync — but converted after the move,
  not during it.** Once the pipeline is out of the server it spawns subprocesses and waits
  on them: process-level, CPU-bound parallelism, not task concurrency. `std::process` plus
  threads (or `rayon`) says that directly and drops `tokio` — the largest dependency — from
  a crate whose purpose is to be independently optimizable. What `tokio` was actually
  buying is `kill_on_drop` and an easy 30 s timeout; in std that is a watchdog thread per
  child, roughly twenty lines, and it gets cheaper once batching cuts concurrent children
  from one-per-file to about one-per-core.

  The sequencing correction matters more than the choice: deciding this *before* A1 would
  have merged a 1,200-line relocation with an async-to-sync rewrite into one diff, at the
  exact moment no equivalence harness exists. A1 therefore stays verbatim and async, A2
  builds the byte-identical harness, and A3 does the conversion behind it — where a
  mistranslation surfaces as a byte difference rather than as a bug in someone's site.

## Grill Verdict
Round: 1
Status: PASS

Findings resolved:
- [B2, G1] **The grill found a live bug, not a plan defect.** `watch_targets()`
  (`src/server.rs:931`) returns source, bundle, and asset folders and never the served
  root, so `Server::new(root).with_live_reload()` with no pipeline configured watches
  nothing and its SSE stream never emits — while `with_live_reload`'s doc comment states
  the watcher runs "over the server's root" and fires "whenever a served file is added,
  modified, or removed." No test covers it: the two broadcast tests
  (`tests/live_reload.rs:160,209`) both configure `with_source_folder`. Live-reload's
  headline behavior does not work for a plain static site. Promoted out of B2 into a
  standalone commit 0 that is independent of the extraction and should land immediately;
  B2 is reduced to deleting what commit 0 makes redundant.
- [Commit 0, G3] Adding the served root while the pipeline still writes there would
  recreate the feedback loop the current design avoids. **The grill's proposed remedy —
  "exclude the output dir" — was itself wrong, and implementation caught it:** the output
  dir defaults to the served root, so the exclusion would have excluded everything and
  fixed nothing. The shipped fix branches on `has_pipeline()` instead. Recorded rather
  than quietly amended, because a grill finding whose fix is also wrong is worth knowing
  about — the lens caught the risk, not the remedy.
- [A1, G3/G4] "Move verbatim" concealed the actual coupling: `source.rs` imports
  `reload::ChangeType` and `watcher::{Broadcaster, ChangeEvent}`, both of which *stay* in
  `mini-static`. Enumerated, with the replacement work (own change-type, own channel)
  costed into the commit.
- [A2, G3] The byte-identical check was specified in a way that could quietly prove
  nothing: pipeline output depends on the installed `lightningcss`/`esbuild` version, so
  committed goldens would fail spuriously on a tool upgrade and then be regenerated —
  destroying the evidence. Now specified as an in-run comparison against `mini-static`
  0.28.3 as a dev-dependency, same binaries, same process.
- [A5, G1] Publishing assumed the `mini-build` name is free. Moved the check to before A1
  and noted that a publish is the plan's one irreversible step.
- [B1, G1] Removing `StaticError::{PipelineSetup, Build}` breaks code naming those
  variants despite `#[non_exhaustive]`. Stated.
- [B1, G1] `mini-unified` needs a dependency bump to 0.29 with no code change — absent
  from the plan entirely. Noted.
- [B1, G2→kept] Added a grep assertion that `src/` contains no write call, so "the server
  never writes" becomes a checked property rather than a claim.
- [B2, G1] Watching the served root polls the whole built site every 500 ms, a larger walk
  than the source trees it replaces. Recorded, attributed to commit 0 where it actually
  lands, with `notify` named as the escape hatch.
- [C1, G3] "Revert by pinning back" assumed the source layout is untouched. Now says to
  keep a directory reorganization in its own commit or lose the cheap revert.
- [A1/A2, G2] Verbatim relocation would decide async-vs-sync for `mini-build` by default.
  Raised as an explicit decision due before A1.

Findings accepted as tradeoffs:
- [Whole plan, G2] Two 500 ms polling loops in series during development, roughly doubling
  worst-case reload latency. Reason: it is the price of decoupling, it is dev-only, and
  the fix (event-driven watching) belongs in `mini-build` where it can be tuned
  independently — which is the extraction's stated purpose.
- [A4, G2] `mini-build` gains watch mode with exactly one known consumer. Reason:
  confirmed as the development workflow rather than speculative generality; without it the
  decoupled dev loop does not exist.