bindery-cli 0.48.0__tar.gz → 0.48.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/CLAUDE.md +1 -1
  2. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/PKG-INFO +17 -7
  3. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/README.md +16 -6
  4. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/patchnotes.md +68 -0
  5. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/pyproject.toml +1 -1
  6. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/spec.md +5 -27
  7. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/src/bindery/__init__.py +5 -5
  8. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/src/bindery/cli.py +77 -25
  9. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/src/bindery/validate.py +2 -1
  10. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_help.py +13 -3
  11. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_version.py +11 -0
  12. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/uv.lock +1 -1
  13. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/.github/FUNDING.yml +0 -0
  14. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/.github/workflows/ci.yml +0 -0
  15. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/.github/workflows/publish.yml +0 -0
  16. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/.gitignore +0 -0
  17. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/AGENTS.md +0 -0
  18. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/LICENSE +0 -0
  19. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/docs/screenshots/library-sweep.png +0 -0
  20. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/logo.svg +0 -0
  21. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/plugin/__init__.py +0 -0
  22. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/project.done +0 -0
  23. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/roadmap.md +0 -0
  24. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/run_tests.sh +0 -0
  25. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/scripts/FastSweep.java +0 -0
  26. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/scripts/build_plugin.py +0 -0
  27. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/scripts/fast_sweep.py +0 -0
  28. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/src/bindery/__main__.py +0 -0
  29. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/src/bindery/audit.py +0 -0
  30. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/src/bindery/epub.py +0 -0
  31. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/src/bindery/library.py +0 -0
  32. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/src/bindery/pagination.py +0 -0
  33. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/src/bindery/reserialize.py +0 -0
  34. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/src/bindery/transforms.py +0 -0
  35. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/src/bindery/watermark.py +0 -0
  36. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_audit.py +0 -0
  37. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_cli.py +0 -0
  38. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_comment_hyphens.py +0 -0
  39. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_cover_tocdrift.py +0 -0
  40. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_epub.py +0 -0
  41. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_flags_real_core.py +0 -0
  42. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_flags_wiring.py +0 -0
  43. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_library.py +0 -0
  44. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_matcher_hardening.py +0 -0
  45. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_pagination.py +0 -0
  46. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_plugin.py +0 -0
  47. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_reserialize.py +0 -0
  48. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_space_renames.py +0 -0
  49. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_transforms.py +0 -0
  50. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_validate.py +0 -0
  51. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_watermark.py +0 -0
  52. {bindery_cli-0.48.0 → bindery_cli-0.48.2}/tests/test_xml_hardening.py +0 -0
@@ -120,7 +120,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
120
120
  - `src/bindery/library.py`: Calibre walk, atomic replace, backups, and native format installation. Since v0.19.0 `CalibreIdResolver` resolves the book id from `metadata.db` through cquarry (one lazy path→id map per run; since v0.20.0 the map comes from `CalibreDB.format_path_index()`, re-normalized with `resolve().lower()` for the resolver's symlinked-directory and case-insensitive matching); the `(id)` directory-name regex is only the no-catalog fallback. Since v0.24.0 `install_format()` places the repaired file atomically and updates the `data` row through `cquarry.write.WritableCalibreDB` (since v0.31.0 via `set_format`, cquarry 1.17's sanctioned remove+add in one transaction); the external `calibredb` CLI is gone. Since v0.32.0 a guessed id drives a row update only when metadata.db corroborates it (`_verify_guess`: books row exists, file in the book's own directory, catalogued `data.name` when a row exists); anything else saves in place and leaves the catalog untouched: never reintroduce directory-name guessing as the primary source: renamed/mismatched directories would replace the wrong book. Since 2026-09-15 `make_backup` takes the opt-in `--backup-keep N` ring: at most N backup files per book, the author original `.bak` never deleted, newest content at the highest name; default remains an unbounded rotation.
121
121
  - `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`; `repair --json` emits the library per-book record vocabulary (2026-09-15; since v0.45.0 every per-book record, the phase1 payload included, also carries the structured fix breakdown: the `fixes` dict plus `ncx_uid_synced`/`watermark_refusals` off the `Outcome`, and `_phase1_decisions` reads that data instead of substring-matching the rendered `summary`, which stays for human eyes; CalibreQuarry's lossy-consent mirror is the waiting consumer, floor `bindery>=0.45.0`); since v0.29.0 also the `run` verbs: `phase1` (pre-import vetting over loose files: the audit battery composed with one gated repair sweep; read-only until `--apply-lossy`, which IS the lossy-strip consent) and `phase3` (the scoped post-import apply step: `library --id --sweep --only all --apply --all --install-to-calibre` plus a pre/post summary; mechanically refuses unscoped sweeps with exit 2; since 2026-09-15 the summary sums applied/equal states only and carries rejected projections as `rejected_projection`). Plus `bindery doctor` (2026-09-15): the environment self-check that imports none of the VirInvictus stack, never raises, and always exits 0. Both run verbs take `--non-interactive` and surface open questions as `decisions_needed` in their JSON, never prompts. They drive the shipped `run_library` through the real parser, so no flag can drift between the verb and the subcommand it wraps. Since v0.46.0 `cli._flags_from_args(args)` is the one place the argparse names map onto `RepairFlags` fields (each `or --all`), shared by `run_library` and `run_repair`; the old per-verb ~25-kwarg blocks are gone. Since v0.47.1 the help is two-level (the --help-repairs reference is
122
122
  custom-rendered and colors itself with the CPython 3.14 argparse theme,
123
- gated by argparse's own _colorize detection so the levels always agree) and the repair-flag inventory is one table: `_REPAIR_FLAG_TABLE` (dest + one-line short help + long reference text) registers the compact grouped `--help` (argparse argument groups, hand-short usage line) and `--help-repairs` renders the long descriptions (a custom argparse Action, deliberately NOT store_true so the wiring test's selection count ignores it); adding a flag means adding a table row. `library --workers N` (v0.30.0) parallelizes only the sweep's candidate pass, in windows of N consumed in order so `--limit` stays lazy; the repair phase is never parallel (shared workdir, atomic-replacement contract). Since v0.40.0 `main()` guards the VirInvictus stack: a `ModuleNotFoundError` naming `vir_tui`/`cquarry` (marker-gated to 3.14+ on the 3.12 floor) becomes one stderr line and trouble exit 2 instead of a traceback; anything else missing still raises.
123
+ gated by argparse's own _colorize detection so the levels always agree) and the repair-flag inventory is one table: `_REPAIR_FLAG_TABLE` (dest + one-line short help + long reference text) registers the compact grouped `--help` (argparse argument groups, hand-short usage line) and `--help-repairs` renders the long descriptions (a custom argparse Action, deliberately NOT store_true so the wiring test's selection count ignores it); adding a flag means adding a table row. Since v0.48.0 the metadata.db doors are enforced: `_require_closed_calibre` (pgrep fail-closed, exit 1) guards `audit --tag` and `library --apply --install-to-calibre` (run phase3 transitively), and `--help-json` (a custom argparse Action like --help-repairs, never colored) dumps the per-verb exit contract and the door list; since v0.48.1 the per-verb exit contracts tell the code's truth (repair: 0 written incl. partial / 1 trouble, never 2; audit: 0/1/2 plus 3 for findings+write-failure; library and the run slices: the unified 0/2/1). `library --workers N` (v0.30.0) parallelizes only the sweep's candidate pass, in windows of N consumed in order so `--limit` stays lazy; the repair phase is never parallel (shared workdir, atomic-replacement contract). Since v0.40.0 `main()` guards the VirInvictus stack: a `ModuleNotFoundError` naming `vir_tui`/`cquarry` (marker-gated to 3.14+ on the 3.12 floor) becomes one stderr line and trouble exit 2 instead of a traceback; anything else missing still raises.
124
124
  - `plugin/__init__.py` + `scripts/build_plugin.py`: the Bindery Repair Calibre plugin (v0.37.0; identity `Bindery Repair` / `bindery_repair`, approved and built per the scoped spec in roadmap.md Phase 3). The builder vendors `transforms.py`, `epub.py`, `pagination.py`, `watermark.py`, `reserialize.py` byte-identically into the zip (the zip root is a package, so their relative imports resolve unchanged; the suite's drift test pins the equality) with the version tuple substituted from the single-source VERSION; `publish.yml` attaches `BinderyRepair-v<VERSION>.zip` to each release. The plugin runs ONLY the default pass (epubcheck cannot gate inside Calibre), never raises, is byte-idempotent (zero fixes returns the original path), testzip()-verifies its output, logs one line per book, and takes JSON config via `site_customization` (`log`, `log_path`, `max_size_mb` default 150, `max_log_mb` rotation cap default 2, `epubcheck_path` experimental validation default OFF). Load it in tests through the `tests/test_plugin.py` stubs: the calibre namespace is installed in sys.modules and the package loads with a two-dir `__path__` (plugin dir first, then src/bindery) exactly like calibre's zipplugin loader shape. **Plugin/interpreter compatibility (v0.40.0):** v0.39.0 shipped PEP 758 unparenthesized except tuples, and the plugin died with a bare SyntaxError on every released Calibre (7.x and the whole 8.x series embed Python 3.11, 9.0+ embeds 3.14; from calibre's bypy sources.json, checked against the upstream clone). The core is now grammar-pinned: parenthesized except tuples everywhere, `ruff`'s `target-version = "py312"` pin (inference from requires-python would rewrite the parens back off), `tests/test_version.py`'s parse guard, and CI's `plugin-compat` job byte-compiling the built zip under 3.11/3.12/3.13/3.14. `minimum_calibre_version` is (7, 0, 0): the oldest series whose interpreter CI covers, replacing the hollow (2, 0, 0); `supported_platforms` stays `["linux"]` (developed and tested on Linux only). Any new syntax in the vendored modules must clear the guard before it ships.
125
125
  - `tests/`: transforms, end-to-end repair, atomic replace, pagination, watermarks, the audit analyzers (content/pagenumbers/emptytext/ocr/monolithic/completeness, archive integrity, spine classification), the library sweep and `CalibreIdResolver`, validate, reserialize, CLI wiring, the version pin and the syntax-floor grammar guard, and the Calibre plugin (build drift + run behavior under calibre stubs). Since the v0.46.0 follow-ups: `tests/test_flags_wiring.py` reflectively pins the RepairFlags inventory to the argparse dests and `_flags_from_args` mapping (a flag added without a field, a field without a flag, or a remapped line fails there), the all-off defaults, and the version gate's caller-immutability contract (test_epub's `TestVersionGateKeepsFlagsIntact`); `tests/test_comment_hyphens.py` carries the RSC-016 fix; `tests/test_flags_real_core.py` drives every repair flag from argv through the unmocked core (--no-validate; the gate itself stays pinned by the mocked-oracle tests and runs for real wherever epubcheck exists). All three ride CI's core-compat 3.12/3.13 leg; the hand-listed module list there must gain an entry for every new stack-free test module.
126
126
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindery-cli
3
- Version: 0.48.0
3
+ Version: 0.48.2
4
4
  Summary: Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library.
5
5
  Project-URL: Homepage, https://github.com/VirInvictus/bindery-cli
6
6
  Project-URL: Repository, https://github.com/VirInvictus/bindery-cli
@@ -206,7 +206,9 @@ The sections below take each in turn.
206
206
  Help is two-level: `bindery repair --help` (or `library --help`) lists the
207
207
  shared repair flags grouped with one-line descriptions, and
208
208
  `bindery repair --help-repairs` prints the full reference carrying every
209
- flag's long rationale. On a terminal both are ANSI-colored with the same
209
+ flag's long rationale, and `bindery --help-json` dumps the complete
210
+ surface (every subcommand and flag, the repair reference, the per-verb
211
+ exit contract, the Calibre gates) as machine-readable JSON. On a terminal both are ANSI-colored with the same
210
212
  theme Python 3.14's argparse uses; piped output stays plain, and `NO_COLOR`,
211
213
  `FORCE_COLOR`, and `TERM=dumb` are honored exactly as `python --help` honors
212
214
  them.
@@ -236,7 +238,11 @@ bindery audit monolithic ~/docs/Calibre\ Library
236
238
  # Spot-check completeness: prose coverage, first/middle/last excerpts, trailing ToC
237
239
  bindery audit completeness ~/docs/Calibre\ Library
238
240
 
239
- # Run all audits and generate a comprehensive CSV
241
+ # Cover-art quality and ToC-drift audits (advisory; never move the exit code)
242
+ bindery audit cover ~/docs/Calibre\ Library
243
+ bindery audit tocdrift ~/docs/Calibre\ Library
244
+
245
+ # Run all audits and report to the console (--json FILE for machine output)
240
246
  cd ~/docs/Calibre\ Library && bindery audit all
241
247
  ```
242
248
 
@@ -265,7 +271,7 @@ With `--id`, `--json` accepts exactly one book id (each single-book run writes t
265
271
 
266
272
  **Archive integrity:** every audit fully reads each archive entry (CRC + decompression), so a damaged download is reported CORRUPT, with the first broken entry named, instead of being mislabeled EMPTY by `emptytext`. `library --sweep` splits its `unreadable` bucket into `not_a_zip` / `truncated` / `encrypted` / `corrupt_entry`, so the right disease is visible without leaving the sweep.
267
273
 
268
- ### Tagging flagged books (opt-in)
274
+ ### Tagging flagged books (opt-in; the write is refused while Calibre runs)
269
275
 
270
276
  By default an audit writes nothing. With `--tag TAG` (library mode only), every book the audit
271
277
  flags is tagged in `metadata.db` through cquarry's trigger-safe write module, useful for piping
@@ -276,12 +282,14 @@ cd ~/docs/Calibre\ Library && bindery audit content --tag "Audit Flagged"
276
282
  ```
277
283
 
278
284
  The audit itself stays read-only; only the final tagging pass writes, it skips books that already
279
- carry the tag, and THIN emptytext advisories are never tagged. Close Calibre first so the write
280
- does not fight its lock. Tagged books are recorded in Calibre's `metadata_dirtied` queue, so the
285
+ carry the tag, and THIN emptytext advisories are never tagged. The write is REFUSED with exit 1
286
+ while Calibre is running (a fail-closed pgrep guard: close Calibre and re-run). Tagged books are recorded in Calibre's `metadata_dirtied` queue, so the
281
287
  desktop app regenerates their sidecar `.opf`s (and re-pushes metadata to wireless readers) on its
282
288
  next startup: no manual resync needed.
283
289
 
284
290
 
291
+ ## Repairing a book
292
+
285
293
  Repair one book to a new file (gated; writes only if it is an improvement):
286
294
 
287
295
  ```sh
@@ -297,6 +305,8 @@ vocabulary (`status`, `applied`, `before`/`after` counts, the fix `summary`),
297
305
  on every processing outcome including nochange and reject: a calling agent
298
306
  gets the same facts the console lines carry.
299
307
 
308
+ ## Sweeping a library
309
+
300
310
  Scan a Calibre library and see what would be fixed, writing nothing:
301
311
 
302
312
  ```sh
@@ -328,7 +338,7 @@ bindery library ~/docs/Calibre\ Library --only all --apply --all --install-to-ca
328
338
  - `--all` automatically turns on all opt-in non-fatal fixes and lossy strips (pagination, watermarks, bad attributes, unknown entities, image alt tags, etc.) in a single run.
329
339
  - Only the `.epub` is replaced. `metadata.opf`, `cover.jpg`, and `metadata.db` are left for Calibre's Quality Check sync to reconcile.
330
340
  - A per-book progress line goes to stderr (stdout stays a clean report); `--quiet` suppresses it. A corrupt or unreadable book is reported and skipped, never aborting the sweep.
331
- - Exit codes for `repair`, `library`, and `run`: 0 for a clean sweep, 1 for a usage error, 2 when any book was rejected, unreadable, or failed epubcheck (for scripts and cron). Note that argparse-level misuse (an unknown flag or a malformed argument) exits with 2 before any validation runs; the tool's own usage validations exit 1. The `audit` verb runs its own contract, the inverse shape: 0 clean, 1 when any book was flagged (a flag is the audit's trouble), 2 for its own usage errors (a path that is not a directory, a directory with no EPUBs, an unknown book id).
341
+ - Exit codes, per verb (for scripts and cron; `--help-json`'s `exit_contract` carries this machine-side): `repair` exits 0 when clean OR written (a PARTIAL repair writes its best output and exits 0; the `--json` record's status says so) and 1 on trouble (gate-rejected, epubcheck failure, unreadable input) or invocation problems (bad args, an existing output without `--force`) — it never exits 2. `library`, `run phase1`, and `run phase3` use the unified contract: 0 clean, 2 when any book was flagged, rejected, unreadable, errored, or partial, 1 for invocation problems. `audit` runs the inverse contract: 0 clean, 1 when any book was flagged (a flag is the audit's trouble), 2 for its own usage errors, 3 when findings coexist with a failed `--json`/`--tag` write. `audit --tag` and `library --apply --install-to-calibre` are lock-class doors: they are REFUSED with exit 1 while Calibre is running. Argparse-level misuse exits 2 everywhere before any validation runs.
332
342
  - A `partial` book (improved but still unable to open) is reported for manual follow-up and fails the run with exit 2, in `library`, `run phase1`, and `run phase3` alike: a book that still needs a human is trouble.
333
343
  - `repair` refuses to overwrite an existing output file unless `--force` is given.
334
344
 
@@ -179,7 +179,9 @@ The sections below take each in turn.
179
179
  Help is two-level: `bindery repair --help` (or `library --help`) lists the
180
180
  shared repair flags grouped with one-line descriptions, and
181
181
  `bindery repair --help-repairs` prints the full reference carrying every
182
- flag's long rationale. On a terminal both are ANSI-colored with the same
182
+ flag's long rationale, and `bindery --help-json` dumps the complete
183
+ surface (every subcommand and flag, the repair reference, the per-verb
184
+ exit contract, the Calibre gates) as machine-readable JSON. On a terminal both are ANSI-colored with the same
183
185
  theme Python 3.14's argparse uses; piped output stays plain, and `NO_COLOR`,
184
186
  `FORCE_COLOR`, and `TERM=dumb` are honored exactly as `python --help` honors
185
187
  them.
@@ -209,7 +211,11 @@ bindery audit monolithic ~/docs/Calibre\ Library
209
211
  # Spot-check completeness: prose coverage, first/middle/last excerpts, trailing ToC
210
212
  bindery audit completeness ~/docs/Calibre\ Library
211
213
 
212
- # Run all audits and generate a comprehensive CSV
214
+ # Cover-art quality and ToC-drift audits (advisory; never move the exit code)
215
+ bindery audit cover ~/docs/Calibre\ Library
216
+ bindery audit tocdrift ~/docs/Calibre\ Library
217
+
218
+ # Run all audits and report to the console (--json FILE for machine output)
213
219
  cd ~/docs/Calibre\ Library && bindery audit all
214
220
  ```
215
221
 
@@ -238,7 +244,7 @@ With `--id`, `--json` accepts exactly one book id (each single-book run writes t
238
244
 
239
245
  **Archive integrity:** every audit fully reads each archive entry (CRC + decompression), so a damaged download is reported CORRUPT, with the first broken entry named, instead of being mislabeled EMPTY by `emptytext`. `library --sweep` splits its `unreadable` bucket into `not_a_zip` / `truncated` / `encrypted` / `corrupt_entry`, so the right disease is visible without leaving the sweep.
240
246
 
241
- ### Tagging flagged books (opt-in)
247
+ ### Tagging flagged books (opt-in; the write is refused while Calibre runs)
242
248
 
243
249
  By default an audit writes nothing. With `--tag TAG` (library mode only), every book the audit
244
250
  flags is tagged in `metadata.db` through cquarry's trigger-safe write module, useful for piping
@@ -249,12 +255,14 @@ cd ~/docs/Calibre\ Library && bindery audit content --tag "Audit Flagged"
249
255
  ```
250
256
 
251
257
  The audit itself stays read-only; only the final tagging pass writes, it skips books that already
252
- carry the tag, and THIN emptytext advisories are never tagged. Close Calibre first so the write
253
- does not fight its lock. Tagged books are recorded in Calibre's `metadata_dirtied` queue, so the
258
+ carry the tag, and THIN emptytext advisories are never tagged. The write is REFUSED with exit 1
259
+ while Calibre is running (a fail-closed pgrep guard: close Calibre and re-run). Tagged books are recorded in Calibre's `metadata_dirtied` queue, so the
254
260
  desktop app regenerates their sidecar `.opf`s (and re-pushes metadata to wireless readers) on its
255
261
  next startup: no manual resync needed.
256
262
 
257
263
 
264
+ ## Repairing a book
265
+
258
266
  Repair one book to a new file (gated; writes only if it is an improvement):
259
267
 
260
268
  ```sh
@@ -270,6 +278,8 @@ vocabulary (`status`, `applied`, `before`/`after` counts, the fix `summary`),
270
278
  on every processing outcome including nochange and reject: a calling agent
271
279
  gets the same facts the console lines carry.
272
280
 
281
+ ## Sweeping a library
282
+
273
283
  Scan a Calibre library and see what would be fixed, writing nothing:
274
284
 
275
285
  ```sh
@@ -301,7 +311,7 @@ bindery library ~/docs/Calibre\ Library --only all --apply --all --install-to-ca
301
311
  - `--all` automatically turns on all opt-in non-fatal fixes and lossy strips (pagination, watermarks, bad attributes, unknown entities, image alt tags, etc.) in a single run.
302
312
  - Only the `.epub` is replaced. `metadata.opf`, `cover.jpg`, and `metadata.db` are left for Calibre's Quality Check sync to reconcile.
303
313
  - A per-book progress line goes to stderr (stdout stays a clean report); `--quiet` suppresses it. A corrupt or unreadable book is reported and skipped, never aborting the sweep.
304
- - Exit codes for `repair`, `library`, and `run`: 0 for a clean sweep, 1 for a usage error, 2 when any book was rejected, unreadable, or failed epubcheck (for scripts and cron). Note that argparse-level misuse (an unknown flag or a malformed argument) exits with 2 before any validation runs; the tool's own usage validations exit 1. The `audit` verb runs its own contract, the inverse shape: 0 clean, 1 when any book was flagged (a flag is the audit's trouble), 2 for its own usage errors (a path that is not a directory, a directory with no EPUBs, an unknown book id).
314
+ - Exit codes, per verb (for scripts and cron; `--help-json`'s `exit_contract` carries this machine-side): `repair` exits 0 when clean OR written (a PARTIAL repair writes its best output and exits 0; the `--json` record's status says so) and 1 on trouble (gate-rejected, epubcheck failure, unreadable input) or invocation problems (bad args, an existing output without `--force`) — it never exits 2. `library`, `run phase1`, and `run phase3` use the unified contract: 0 clean, 2 when any book was flagged, rejected, unreadable, errored, or partial, 1 for invocation problems. `audit` runs the inverse contract: 0 clean, 1 when any book was flagged (a flag is the audit's trouble), 2 for its own usage errors, 3 when findings coexist with a failed `--json`/`--tag` write. `audit --tag` and `library --apply --install-to-calibre` are lock-class doors: they are REFUSED with exit 1 while Calibre is running. Argparse-level misuse exits 2 everywhere before any validation runs.
305
315
  - A `partial` book (improved but still unable to open) is reported for manual follow-up and fails the run with exit 2, in `library`, `run phase1`, and `run phase3` alike: a book that still needs a human is trouble.
306
316
  - `repair` refuses to overwrite an existing output file unless `--force` is given.
307
317
 
@@ -1,5 +1,73 @@
1
1
  # bindery-cli Patch Notes
2
2
 
3
+ ## v0.48.2 (2026-10-06)
4
+
5
+ ### The 12-agent docs audit: README/spec/CLAUDE tell the v0.48 truth
6
+
7
+ v0.48.0/v0.48.1 truthed the help and `--help-json` but touched only
8
+ cli.py; the four-lens documentation audit converged on that lag. This
9
+ propagates it (docs and two docstrings only, no behavior changes):
10
+
11
+ - **README's exit-contract bullet taught repair's dead contract**
12
+ (trouble=2, usage=1) and omitted partial-writes-at-0, audit's exit 3,
13
+ and the lock-class refusals: it is per-verb now, mirroring
14
+ `--help-json`'s exit_contract block.
15
+ - **The enforced closed-Calibre gate is stated where trust is earned**:
16
+ README's tagging section (advice became enforcement) and the
17
+ install-to-calibre story name the three doors and exit 1; spec's
18
+ Library replacement, Native installation, and audit sections name
19
+ their refusals; CLAUDE.md carries the v0.48 pair
20
+ (`_require_closed_calibre`, `--help-json`) plus the per-verb exit
21
+ truth.
22
+ - spec's audit exit vocabulary gains the exit-3 class and the repair
23
+ carve-out is named where the inverse shape is attributed; the
24
+ partial-repair rule gets repair's writes-the-output exception;
25
+ spec's plugin config list gains `max_log_mb`.
26
+ - README: Repairing a book / Sweeping a library section headers (the
27
+ usage docs were buried headerless inside the tagging subsection),
28
+ cover/tocdrift audit examples, `--help-json` in the help paragraph.
29
+ - Two stale code docstrings the audit flagged: `__init__` (four lossy
30
+ strips, eight analyzers) and validate.py's `no_worse` (stub strips
31
+ and the rename half included).
32
+ - One pin added (640 total): the newest patchnotes heading must carry
33
+ VERSION.
34
+
35
+ ## v0.48.1 (2026-10-06)
36
+
37
+ ### The 12-agent audit: the exit contracts tell the truth
38
+
39
+ Three waves of four lenses audited this help surface. The flag
40
+ inventory verified perfect (30/30 repair flags, zero misses, zero
41
+ fabrications); the defects were the exit contracts and the safety
42
+ silences:
43
+
44
+ - **repair --help stated an exit contract the verb has never honored**
45
+ (trouble returns 1, not 2; a PARTIAL repair writes its output and
46
+ returns 0). The page and --help-json now tell the code's truth, per
47
+ verb: repair 0/written + 1, audit's deliberate inverse contract
48
+ (0 clean; 1 findings; 2 usage/environment; 3 findings plus a failed
49
+ report write), library and the run slices on the unified 0/2/1.
50
+ calibre_gates names the third metadata.db door (run phase3, guarded
51
+ transitively), and the contract admits the missing-stack exit-2 and
52
+ the exit-3 class.
53
+ - **The library page states the no-backup default**: --apply without
54
+ --backup/--backup-inplace replaces in place and keeps no copy of the
55
+ original. --no-validate says the write still happens (status
56
+ unvalidated). --backup names the outside-the-library refusal.
57
+ --only fatals names its --audit/--sweep precondition.
58
+ - audit and doctor got real descriptions (the eight analyzers, the CWD
59
+ library resolution, the one-id --json rule; doctor's always-exit-0
60
+ contract); the --help-repairs preamble names the five always-on
61
+ fixes, defines the gate metric, enumerates the status vocabulary,
62
+ and states the no-worse classes; phase1's exit list gains
63
+ audit-flagged books; repair's positionals, default output name, and
64
+ never-modifies-the-input are stated; the --json envelopes name all
65
+ keys including books; the root help points at doctor/README/spec.
66
+ - One code fix: the no-backup lossy warning now covers
67
+ --strip-stub-docs (it warned for its three siblings only).
68
+
69
+ 3 help pins updated to the new truths (639 total, unchanged).
70
+
3
71
  ## v0.48.0 (2026-10-06)
4
72
 
5
73
  ### The closed-Calibre guard, stated contracts, and --help-json
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.48.0"
7
+ version = "0.48.2"
8
8
  description = "Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library."
9
9
  readme = "README.md"
10
10
  classifiers = [
@@ -433,32 +433,7 @@ book can never be auto-applied through the lossy path.
433
433
 
434
434
  For a Calibre library (`Author/Title (id)/Title - Author.epub`):
435
435
 
436
- - Work is done on a temporary copy; the original is read-only until the gate accepts.
437
- - Replacement is **atomic**: the repaired bytes are written to a temp file in the same
438
- directory, fsynced, then `os.replace()`d over the original. The filename and path
439
- Calibre expects never change; no partial file is ever visible.
440
- - The original file mode is preserved. Only the `.epub` is touched; `metadata.opf`,
441
- `cover.jpg`, and `metadata.db` are left for Calibre's Quality Check sync.
442
- - Writing requires `--apply` (default is a dry run). A backup is taken first when
443
- `--backup DIR` or `--backup-inplace` is given.
444
- - A book that cannot be read at all (not a zip, truncated, encrypted entries) is
445
- reported and counted as `unreadable`; it never aborts the rest of the sweep.
446
- - The `library` exit code is 0 for a clean sweep, 1 for a usage error, and 2 when any
447
- book was rejected, unreadable, or failed epubcheck, so scripts can detect trouble.
448
- Argparse-level misuse (unknown flag, malformed argument) exits 2 before the tool's
449
- own validation runs; the tool's own usage validations exit 1. A `partial` book
450
- (improved but still fatal) is reported for manual follow-up and counts as trouble:
451
- `library`, `run phase1`, and `run phase3` all exit 2 on it (unified 2026-09-10).
452
- The `audit` verb is the deliberate exception, its own contract in the inverse
453
- shape: 0 clean, 1 when any book was flagged (a flag is the audit's trouble, so it
454
- takes the trouble slot), 2 for its own usage errors (a path that is not a
455
- directory, a directory with no EPUBs, an unknown book id) and for tagging-setup
456
- failure.
457
- - With `--sweep`, `--workers N` runs the candidate-selection epubcheck pass through N
458
- concurrent workers (default 1: serial, unchanged). Books are checked in windows of N
459
- consumed in input order, so the candidate set and the before-measurements are identical
460
- to the serial sweep; the repair phase is never parallel (the shared workdir and the
461
- atomic-replacement contract live there).
436
+ `--apply` is a filesystem write door and the install door is a metadata.db write door: `--apply --install-to-calibre` is REFUSED with exit 1 while Calibre is running (a fail-closed pgrep guard). `--backup-keep N` (minimum 2) caps the ring of same-named per-book backups.
462
437
 
463
438
  ### Native format installation (`--install-to-calibre`)
464
439
  Optionally, bindery-cli installs the repaired EPUB as the book's format through cquarry's write module (`WritableCalibreDB`): the file is placed atomically (an in-place replace over the catalogued file when one exists, same path and `data.name`; a fresh placement under the repaired file's stem otherwise) and the `data` row follows through `set_format` (cquarry 1.17's sanctioned remove+add in one transaction), keeping the size truthful and queuing the book in `metadata_dirtied` so Calibre regenerates its sidecar .opf. The external `calibredb` CLI is no longer used (the v0.23.1 `--replace` crash class is gone with it). It automatically falls back to atomic filesystem replacement if a valid Calibre ID cannot be extracted, and a database failure degrades to the in-place save with a warning rather than losing the repair.
@@ -472,6 +447,8 @@ wrote the stray's size over the catalogued entry, the 2026-09-08 stray-size inci
472
447
  and a stale `(N)` directory whose book no longer exists saves in place instead of
473
448
  crashing the sweep.
474
449
 
450
+ This door is lock-class: `--install-to-calibre` is REFUSED with exit 1 while Calibre is running (a fail-closed pgrep guard; close Calibre and re-run).
451
+
475
452
  ## Audit subcommand (read-only)
476
453
 
477
454
  `bindery audit {content,pagenumbers,emptytext,ocr,monolithic,completeness,cover,tocdrift,all} [PATH] [--max-doc-chars N]
@@ -518,7 +495,7 @@ storage-layout logic is not duplicated here. The scan itself still writes nothin
518
495
  separate trigger-safe write module (it registers Calibre's `title_sort`/`uuid4` SQL functions,
519
496
  bumps `books.last_modified`, and cleans link tables before tag deletion). THIN emptytext
520
497
  advisories stay untagged; already-tagged books are skipped; a missing file is a scan error, not a
521
- silent skip.
498
+ silent skip. The tag write is lock-class: REFUSED with exit 1 while Calibre is running.
522
499
 
523
500
  `--json FILE` (v0.29.0) writes the same verdicts machine-readably, in the `library --json`
524
501
  shape: one record per file with a `status` (`clean`, `problem`, or `error`) and per-analyzer
@@ -630,6 +607,7 @@ Behavior contract:
630
607
  the user's epubcheck binary under the `no_worse` bar and refused on a
631
608
  regression; an unanswered measurement never refuses. The mode is off by
632
609
  default (the latency ruling stands; the default pass is safe ungated).
610
+ - **`max_log_mb`** (default 2): the plugin log's size cap in megabytes, one `.old` generation kept on rotation; `0` disables logging.
633
611
 
634
612
  ## Out of scope (non-goals)
635
613
 
@@ -1,17 +1,17 @@
1
1
  """bindery-cli: deterministic, epubcheck-gated EPUB repair and content auditing.
2
2
 
3
3
  The repair side: an always-on core of five well-formedness fixes plus the NCX
4
- pipeline and the mimetype fix, with the structural repairs, the three lossy
5
- strips (page numbers, broken tags, watermarks), and the safe opt-ins behind
6
- explicit flags. Around it: the read-only `audit` verb (six body-text
7
- analyzers), the `library` sweep with atomic in-place replacement in a Calibre
4
+ pipeline and the mimetype fix, with the structural repairs, the four lossy
5
+ strips (page numbers, broken tags, watermarks, stub documents), and the safe opt-ins behind
6
+ explicit flags. Around it: the read-only `audit` verb (eight body-text and
7
+ structural analyzers), the `library` sweep with atomic in-place replacement in a Calibre
8
8
  library, the `run phase1`/`phase3` acquisition slices, and the Bindery Repair
9
9
  Calibre plugin built from this core.
10
10
 
11
11
  Single source of truth for the version. pyproject.toml must match VERSION below.
12
12
  """
13
13
 
14
- VERSION = "0.48.0"
14
+ VERSION = "0.48.2"
15
15
  __version__ = VERSION
16
16
 
17
17
  from .epub import RepairReport, repair_epub # noqa: E402
@@ -420,17 +420,33 @@ class _HelpJsonAction(argparse.Action):
420
420
  "tool": "bindery",
421
421
  "version": __version__,
422
422
  "exit_contract": {
423
- "0": "clean",
424
- "1": "invocation problem (bad args; repair: existing output "
425
- "without --force); lock-class refusals (Calibre open at a "
426
- "metadata.db door)",
427
- "2": "trouble found (rejected/error/unreadable/partial "
428
- "books; unscoped phase3 sweep); argparse usage errors",
423
+ "0": "clean (repair: also written, including partial)",
424
+ "1": "library/run slices: trouble found; repair: trouble "
425
+ "(refused/failed/unreadable) or invocation problem; audit: "
426
+ "FINDINGS (the inverse contract, per spec.md); "
427
+ "lock-class refusals (Calibre open at a metadata.db door)",
428
+ "2": "library/run slices: trouble found (rejected/error/"
429
+ "unreadable/partial books; unscoped phase3 sweep); audit: "
430
+ "usage/environment errors; argparse usage errors; missing "
431
+ "vir_tui/cquarry on Python < 3.14",
432
+ "3": "audit: findings PLUS a failed --json/--tag write",
429
433
  "130": "interrupted",
434
+ "per_verb": {
435
+ "repair": "0 clean/written (partial writes, exit 0); "
436
+ "1 trouble or invocation problem; never 2",
437
+ "audit": "0 clean; 1 findings; 2 usage/environment; "
438
+ "3 findings + failed report write",
439
+ "library": "0 clean; 2 trouble (rejected/error/"
440
+ "unreadable/partial); 1 invocation problem",
441
+ "run phase1": "0 clean; 2 trouble (audit-flagged, "
442
+ "rejected, partial, error, unreadable); 1 invocation",
443
+ },
430
444
  },
431
445
  "calibre_gates": [
432
446
  "audit --tag",
433
447
  "library --apply --install-to-calibre",
448
+ "run phase3 (transitively: it wraps library --apply "
449
+ "--install-to-calibre)",
434
450
  ],
435
451
  "subcommands": [],
436
452
  "repair_reference": [
@@ -632,7 +648,12 @@ def run_library(args) -> int:
632
648
  if (
633
649
  args.apply
634
650
  and not wants_backup
635
- and (args.strip_pagination or args.strip_broken_tags or args.strip_watermarks)
651
+ and (
652
+ args.strip_pagination
653
+ or args.strip_broken_tags
654
+ or args.strip_watermarks
655
+ or args.strip_stub_docs
656
+ )
636
657
  ):
637
658
  print(
638
659
  "WARNING: the --strip-* modes are lossy; strongly consider --backup DIR "
@@ -1841,12 +1862,21 @@ def _repair_flag_reference(color: bool | None = None) -> str:
1841
1862
  + " repair-flag reference (shared by `bindery repair` and "
1842
1863
  "`bindery library`)",
1843
1864
  "",
1844
- "The always-on core pass is exactly five well-formedness fixes, the NCX",
1845
- "pipeline, and the mimetype fix. Everything below is opt-in, and every",
1846
- "repair is epubcheck-gated: applied only when the measured result improved",
1847
- "(lossy strips: only when it did not get worse). Library mode is dry-run",
1848
- "by default; `--all` enables every flag below at once. The README and",
1849
- "spec.md carry the full rationale for each repair.",
1865
+ "The always-on core pass is exactly five well-formedness fixes",
1866
+ "(strip prolog junk, drop duplicate xmlns, escape bare ampersands,",
1867
+ "fix named entities, self-close void tags), the NCX pipeline, and",
1868
+ "the mimetype fix. Everything below is opt-in, and every repair is",
1869
+ "epubcheck-gated: applied only when the measured result improved",
1870
+ "(strictly fewer findings than before); the lossy strips, the",
1871
+ "cover wiring repairs, and the space-entry renames are held to the",
1872
+ "weaker no-worse bar (not more findings). Library mode is dry-run",
1873
+ "by default; `--all` enables every flag below at once. Statuses a",
1874
+ "record can carry: accept (gate-applied), partial (written but",
1875
+ "still failing epubcheck), reject (the gate refused), error,",
1876
+ "unreadable, nochange (nothing to do), equal (already as good as",
1877
+ "the result), unvalidated (applied under --no-validate, never",
1878
+ "gate-checked). The README and spec.md carry the full rationale",
1879
+ "for each repair.",
1850
1880
  ]
1851
1881
  for title, rows in _REPAIR_FLAG_TABLE.items():
1852
1882
  lines.append("")
@@ -1943,12 +1973,15 @@ def _add_repair_flags(p: argparse.ArgumentParser) -> None:
1943
1973
  g.add_argument(
1944
1974
  "--all",
1945
1975
  action="store_true",
1946
- help="enable every opt-in fix flag (safe, structural, and lossy)",
1976
+ help="enable every opt-in fix flag (safe, structural, and lossy); "
1977
+ "this IS the lossy consent here (run phase1's separate "
1978
+ "--apply-lossy door is the phase1 equivalent)",
1947
1979
  )
1948
1980
  g.add_argument(
1949
1981
  "--no-validate",
1950
1982
  action="store_true",
1951
- help="skip the epubcheck gate",
1983
+ help="skip the epubcheck gate: the repair still writes/appends "
1984
+ "with status unvalidated (no gate check runs)",
1952
1985
  )
1953
1986
  p.add_argument(
1954
1987
  "--help-repairs",
@@ -2051,9 +2084,12 @@ def build_parser() -> argparse.ArgumentParser:
2051
2084
  "core pass: the five well-formedness fixes, the NCX pipeline, and the "
2052
2085
  "mimetype fix run always; every other repair is opt-in (grouped "
2053
2086
  "below) and applied only when epubcheck confirms the result improved. "
2054
- "Never prompts. Exit codes: 0 clean, 2 trouble found (repairs refused "
2055
- "or failed; --json carries the records), 1 invocation problem (bad "
2056
- "args, or an existing output without --force).",
2087
+ "Never prompts. Exit codes: 0 clean or written (a PARTIAL repair "
2088
+ "writes its best output and exits 0; the --json record's status "
2089
+ "says so), 1 trouble (repair refused by the gate, epubcheck "
2090
+ "failure, or unreadable input) or invocation problem (bad args, "
2091
+ "or an existing output without --force). This verb does not use "
2092
+ "the tool-wide trouble=2 contract; library and the run slices do.",
2057
2093
  epilog="examples:\n"
2058
2094
  " bindery repair book.epub out.epub\n"
2059
2095
  " bindery repair book.epub out.epub --all\n"
@@ -2084,12 +2120,25 @@ def build_parser() -> argparse.ArgumentParser:
2084
2120
  "doctor",
2085
2121
  help="check the environment: Python stack tier, epubcheck, Java, "
2086
2122
  "html5lib, and Calibre-library discovery",
2123
+ description="Check the environment and exit 0 ALWAYS: the output "
2124
+ "is the diagnosis, never a failure (run this first). Checks: the "
2125
+ "Python stack tier, epubcheck, Java, html5lib, and Calibre-library "
2126
+ "discovery.",
2087
2127
  )
2088
2128
  doc.set_defaults(func=run_doctor)
2089
2129
 
2090
2130
  audit = sub.add_parser(
2091
2131
  "audit",
2092
2132
  help="audit EPUB body text to detect non-schema content flaws (OCR damage, hardcoded page numbers, empty books, non-English text)",
2133
+ description="Read-only content audit (eight analyzers: monolithic "
2134
+ "scans, completeness, cover wiring, toc drift, and the body-text "
2135
+ "battery over OCR damage, hardcoded page numbers, empty/thin "
2136
+ "books, and non-English text). Library mode resolves the Calibre "
2137
+ "library from the CURRENT DIRECTORY's metadata.db, or pass a "
2138
+ "directory, or --id for specific books (--id plus --json takes "
2139
+ "exactly one id). Exit codes are the audit inverse: 0 clean, "
2140
+ "1 FINDINGS (flagged books), 2 usage or environment errors, "
2141
+ "3 findings plus a failed --json/--tag write. Never prompts.",
2093
2142
  )
2094
2143
  audit.add_argument(
2095
2144
  "mode",
@@ -2141,7 +2190,7 @@ def build_parser() -> argparse.ArgumentParser:
2141
2190
  "--id",
2142
2191
  metavar="BOOK_IDS",
2143
2192
  default=None,
2144
- help="audit library book(s) by Calibre id — one id or a comma-separated "
2193
+ help="audit library book(s) by Calibre id: one id or a comma-separated "
2145
2194
  "list (fetched via cquarry's single-entity get_book; cannot be "
2146
2195
  "combined with a directory)",
2147
2196
  )
@@ -2163,10 +2212,12 @@ def build_parser() -> argparse.ArgumentParser:
2163
2212
  "repair them in place. Dry run by default; --apply atomically "
2164
2213
  "replaces accepted books. Shares the repair-flag set with `bindery "
2165
2214
  "repair` (--all enables every opt-in). Never prompts: --apply is "
2166
- "the only confirmation. Exit codes: 0 all clean, 2 any "
2167
- "flagged/rejected/error/partial book, 1 invocation problem. "
2168
- "--apply --install-to-calibre writes metadata.db and demands a "
2169
- "closed Calibre (refused, exit 1, while it runs).",
2215
+ "the only confirmation. --apply WITHOUT --backup or --backup-inplace "
2216
+ "replaces in place and keeps NO copy of the original. Exit codes: "
2217
+ "0 all clean, 2 any flagged/rejected/error/unreadable/partial book, "
2218
+ "1 invocation problem. --apply --install-to-calibre writes "
2219
+ "metadata.db and demands a closed Calibre (refused, exit 1, while "
2220
+ "it runs).",
2170
2221
  epilog="examples:\n"
2171
2222
  " bindery library ~/docs/Calibre\\ Library --sweep\n"
2172
2223
  " bindery library ~/docs/Calibre\\ Library --id 1234 --apply "
@@ -2185,7 +2236,8 @@ def build_parser() -> argparse.ArgumentParser:
2185
2236
  choices=("fatals", "ncx", "all"),
2186
2237
  default="all",
2187
2238
  help="restrict to books with fatals, the NCX-001 mismatch "
2188
- "(toc.ncx dtb:uid != the OPF unique-identifier), or all (default)",
2239
+ "(toc.ncx dtb:uid != the OPF unique-identifier), or all (default); "
2240
+ "fatals requires --audit CSV or --sweep",
2189
2241
  )
2190
2242
  rc.add_argument(
2191
2243
  "--limit", type=int, help="process at most N candidates (for sampling)"
@@ -2241,7 +2293,7 @@ def build_parser() -> argparse.ArgumentParser:
2241
2293
  out.add_argument(
2242
2294
  "--json",
2243
2295
  metavar="FILE",
2244
- help="write a machine-readable JSON report of the run to FILE Top-level envelope keys: mode (apply|dry-run), root, only, validate, candidates, summary",
2296
+ help="write a machine-readable JSON report of the run to FILE. Top-level envelope keys: mode (apply|dry-run), root, only, validate, candidates, summary, books (one record per candidate)",
2245
2297
  )
2246
2298
  out.add_argument(
2247
2299
  "--manual-list",
@@ -2,7 +2,8 @@
2
2
 
3
3
  epubcheck is the external oracle. The `gate` demands measured improvement
4
4
  (fewer fatals, or fewer errors on a clean book) and `no_worse` accepts the
5
- lossy strips and cover repairs when nothing regressed. There is no degrade
5
+ lossy strips (including stub-document strips), the cover wiring repairs, and
6
+ the space-entry renames when nothing regressed. There is no degrade
6
7
  path: if epubcheck is not installed, callers refuse (exit 1, "epubcheck not
7
8
  found") unless the user explicitly passes --no-validate, which skips the gate
8
9
  and trusts the RepairReport alone.
@@ -104,14 +104,18 @@ class TestHelpTruth(unittest.TestCase):
104
104
  def test_repair_page_states_the_contract(self):
105
105
  text = self._sub(self._parser(), "repair").description
106
106
  self.assertIn("Never prompts", text)
107
- self.assertIn("0 clean, 2 trouble", text)
107
+ self.assertIn("PARTIAL repair writes its best output and exits 0", text)
108
+ self.assertIn("does not use", text)
108
109
  self.assertIn("without --force", text)
109
110
 
110
111
  def test_library_page_states_gate_and_codes(self):
111
112
  text = self._sub(self._parser(), "library").description
112
113
  self.assertIn("Never prompts", text)
113
114
  self.assertIn("closed Calibre", text)
114
- self.assertIn("0 all clean, 2 any", text)
115
+ self.assertIn(
116
+ "0 all clean, 2 any flagged/rejected/error/unreadable/partial", text
117
+ )
118
+ self.assertIn("keeps NO copy of the original", text)
115
119
 
116
120
  def test_install_to_calibre_names_the_gate(self):
117
121
  parser = self._parser()
@@ -165,9 +169,15 @@ class TestHelpJson(unittest.TestCase):
165
169
  list(cli._REPAIR_FLAG_TABLE),
166
170
  )
167
171
  self.assertEqual(
168
- data["calibre_gates"],
172
+ data["calibre_gates"][0:2],
169
173
  ["audit --tag", "library --apply --install-to-calibre"],
170
174
  )
175
+ # the third door is phase3, guarded transitively through library
176
+ self.assertIn("run phase3", data["calibre_gates"][2])
177
+ # the contract now carries the per-verb truth
178
+ self.assertEqual(data["exit_contract"]["per_verb"]["repair"][-7:], "never 2")
179
+ self.assertIn("1 findings", data["exit_contract"]["per_verb"]["audit"])
180
+ self.assertIn("3", data["exit_contract"])
171
181
  self.assertEqual(payload_text, self._dump())
172
182
 
173
183
  def _dump(self):
@@ -111,3 +111,14 @@ class TestSyntaxFloor(unittest.TestCase):
111
111
  # the tree has dozens of modules; a broken path glob must not
112
112
  # let the guard pass vacuously
113
113
  self.assertGreaterEqual(checked, 20)
114
+
115
+ def test_patchnotes_newest_heading_matches(self):
116
+ # the docs-audit lesson: the release log's newest heading is a
117
+ # version carrier like any other
118
+ patchnotes = (
119
+ Path(__file__).resolve().parent.parent / "patchnotes.md"
120
+ ).read_text()
121
+ heading = next(
122
+ line.strip() for line in patchnotes.splitlines() if line.startswith("## v")
123
+ )
124
+ self.assertIn(CODE_VERSION, heading)
@@ -8,7 +8,7 @@ resolution-markers = [
8
8
 
9
9
  [[package]]
10
10
  name = "bindery-cli"
11
- version = "0.48.0"
11
+ version = "0.48.2"
12
12
  source = { editable = "." }
13
13
  dependencies = [
14
14
  { name = "cquarry", marker = "python_full_version >= '3.14'" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes