bindery-cli 0.44.0__tar.gz → 0.45.0__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 (48) hide show
  1. bindery_cli-0.45.0/.github/FUNDING.yml +2 -0
  2. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/.gitignore +1 -0
  3. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/CLAUDE.md +2 -2
  4. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/PKG-INFO +1 -1
  5. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/patchnotes.md +14 -0
  6. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/pyproject.toml +1 -1
  7. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/roadmap.md +20 -1
  8. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/spec.md +7 -2
  9. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/__init__.py +1 -1
  10. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/cli.py +66 -10
  11. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_cli.py +76 -2
  12. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/uv.lock +4 -4
  13. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/.github/workflows/ci.yml +0 -0
  14. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/.github/workflows/publish.yml +0 -0
  15. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/AGENTS.md +0 -0
  16. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/LICENSE +0 -0
  17. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/README.md +0 -0
  18. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/docs/screenshots/library-sweep.png +0 -0
  19. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/logo.svg +0 -0
  20. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/plugin/__init__.py +0 -0
  21. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/project.done +0 -0
  22. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/run_tests.sh +0 -0
  23. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/scripts/FastSweep.java +0 -0
  24. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/scripts/build_plugin.py +0 -0
  25. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/scripts/fast_sweep.py +0 -0
  26. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/__main__.py +0 -0
  27. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/audit.py +0 -0
  28. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/epub.py +0 -0
  29. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/library.py +0 -0
  30. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/pagination.py +0 -0
  31. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/reserialize.py +0 -0
  32. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/transforms.py +0 -0
  33. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/validate.py +0 -0
  34. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/watermark.py +0 -0
  35. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_audit.py +0 -0
  36. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_cover_tocdrift.py +0 -0
  37. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_epub.py +0 -0
  38. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_library.py +0 -0
  39. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_matcher_hardening.py +0 -0
  40. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_pagination.py +0 -0
  41. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_plugin.py +0 -0
  42. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_reserialize.py +0 -0
  43. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_space_renames.py +0 -0
  44. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_transforms.py +0 -0
  45. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_validate.py +0 -0
  46. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_version.py +0 -0
  47. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_watermark.py +0 -0
  48. {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_xml_hardening.py +0 -0
@@ -0,0 +1,2 @@
1
+ github: VirInvictus
2
+ liberapay: bdkl
@@ -30,3 +30,4 @@ test_facility/*.epub
30
30
  venv/
31
31
  .venv_ci/
32
32
  testing_facility/top500candidates/*.txt
33
+ testing_facility/
@@ -14,7 +14,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
14
14
 
15
15
  - **Minimal Dependencies.** Runtime deps are exactly: `tqdm` (progress/output),
16
16
  `vir-tui` (shared TUI rendering) and `cquarry` (read-only Calibre `metadata.db` access,
17
- adopted in v0.16.0), pinned as PyPI ranges in pyproject.toml (`vir-tui>=2.2.0`,
17
+ adopted in v0.16.0), pinned as PyPI ranges in pyproject.toml (`vir-tui>=2.5.0`,
18
18
  `cquarry>=1.19.0`); `uv.lock` records the resolved versions. Since v0.40.0 both
19
19
  VirInvictus pins carry `; python_version >= '3.14'` markers (every release they
20
20
  ship declares requires-python >=3.14) and the package floor is 3.12: a 3.12/3.13
@@ -96,7 +96,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
96
96
  - `src/bindery/epub.py`: archive rewrite, NCX uid sync, RepairReport, mismatch detection, and the opt-in structural-repair plumbing (including the CSS precondition scan). Since v0.38.0/v0.39.0 (Phase 16): `generate_container` + the `--fix-container` gateway (insert after mimetype when missing, replace in-loop when stale; constant-epoch timestamp keeps bytes deterministic), `prune_dangling_edges` (prune_missing_manifest_items returns the pruned ids and the edges that pointed at them (spine@toc, media-overlay, fallback, EPUB2 cover meta) are rewritten so pruning cannot manufacture the regression the gate rejects), and `fix_manifest_media_types` (extension map + magic-byte confirmation via a `peek` callable over the open source zip; jpg/png/gif only), and `fix_cover_meta` (the ruling's deterministic half: dangling EPUB2 cover meta re-pointed from the guide's cover reference when exactly one manifest item carries that file, removed otherwise; accepted under no_worse like the lossy strips because cover wiring is invisible to epubcheck).
97
97
  - `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
98
98
  - `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.
99
- - `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.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. `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.
99
+ - `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. `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.
100
100
  - `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, `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.
101
101
  - `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).
102
102
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindery-cli
3
- Version: 0.44.0
3
+ Version: 0.45.0
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
@@ -1,4 +1,18 @@
1
1
  # bindery-cli Patch Notes
2
+ ## v0.45.0 (2026-09-21)
3
+
4
+ ### The structured fix breakdown in the JSON records
5
+
6
+ * **Every per-book record now carries the fix breakdown as data, not only the rendered `summary` string** (the roadmap box recorded 2026-09-18 from the CalibreQuarry side): `library --json` and `repair --json` records, and the `run phase1`/`phase3` payloads built on them, expose the RepairReport's `fixes` dict plus `ncx_uid_synced` and `watermark_refusals`. Consumers that need to know whether a repair was lossy (CalibreQuarry's lossy-consent mirror) can read fix classes as data instead of substring-matching the flattened `"k:v, k:v"` summary; the summary itself stays rendered for human eyes, byte-identical to before. Purely additive: no existing key changed meaning.
7
+ * **`run phase1`'s own decisions read the data now**: `_phase1_decisions` narrows the `apply_lossy` and `manual_watermark_repair` questions through the structured fields instead of matching summary substrings, and a book whose apply failed mid-run (the ENOSPC-class path) still reports the candidate's real fixes instead of an empty breakdown.
8
+ * CalibreQuarry adoption is a deliberate floor bump: `bindery>=0.45.0`.
9
+ * Suite: 521 -> 524 tests (`TestStructuredFixRecord`, plus the apply-failure and watermark-refusal records updated to the structured shape).
10
+ ## v0.44.1 (2026-09-18)
11
+
12
+ ### Doctor names its interpreter
13
+
14
+ * **`bindery doctor` prints `sys.executable` on its python line**, in the epubcheck line's `version [path]` shape. The version-only line hid which environment was probed, so an html5lib "not installed" read as wrong when the module sat in the system interpreter: a uv tool venv (`include-system-site-packages = false`) genuinely cannot see system site-packages, and `find_spec` only ever consults the running interpreter. The path makes the install shape self-diagnosing.
15
+ * Repo housekeeping with no runtime effect: `FUNDING.yml` (GitHub Sponsors + Liberapay).
2
16
  ## v0.44.0 (2026-09-17)
3
17
 
4
18
  ### The wave-2 batch: PKG-010 entry renames, the cover and ToC-drift audit slices, and the roman grammar unified
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.44.0"
7
+ version = "0.45.0"
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 = [
@@ -1950,7 +1950,7 @@ four interpreter families.
1950
1950
  quote skips, well-formed byte-identity, prune spin over the
1951
1951
  truncated-OPF shape). Never a third grammar copy.)*
1952
1952
 
1953
- - [ ] **`--encode-url-spaces` left an OPF-manifest RSC-020 FATAL standing
1953
+ - [x] **`--encode-url-spaces` left an OPF-manifest RSC-020 FATAL standing
1954
1954
  on the Theaetetus fixture** (observed 2026-09-16, classics wave
1955
1955
  phase 1): the Hackett Aristotle *Theaetetus* EPUB (McDowell) arrived
1956
1956
  with 270 raw-space zip entry names AND 270 raw-space hrefs in
@@ -1983,3 +1983,22 @@ four interpreter families.
1983
1983
  such books at all — until then the book parses in lenient readers
1984
1984
  but never passes epubcheck. Low frequency (one book in the wave),
1985
1985
  noted with the fixture reference above.
1986
+
1987
+ - [x] **The `run phase1` report's repair records carry no structured fix
1988
+ breakdown** (observed 2026-09-18 from the CalibreQuarry side, after
1989
+ its lossy_consent box): the phase-1 JSON exposes only the rendered
1990
+ `summary` string (fix keys flattened to "k:v, k:v"), so the CQ
1991
+ mirror has to substring-match the gate's lossy keys
1992
+ (`stripped_pagination`, `stripped_broken_tags`,
1993
+ `stripped_watermarks`, `dropped_marker`, `stub_docs_dropped`) to
1994
+ narrow consent to real strips. Exposing the RepairReport's `fixes`
1995
+ dict per book (the `ncx_uid_synced`/`watermark_refusals` fields
1996
+ already exist as data) would turn the lossy/structural split into
1997
+ data instead of string matching, and CalibreQuarry would adopt it
1998
+ with a deliberate floor bump. DONE in v0.45.0: every per-book
1999
+ record (library --json, repair --json, and the phase1 payload built
2000
+ on it) carries `fixes` plus `ncx_uid_synced` and
2001
+ `watermark_refusals` as data; `_phase1_decisions` reads those
2002
+ fields and no longer substring-matches the summary (which stays
2003
+ rendered for human eyes). CalibreQuarry can floor-bump to
2004
+ `bindery>=0.45.0` and drop its substring matching.
@@ -509,8 +509,13 @@ crash), never raises, and always exits 0: a diagnosis is not a failure.
509
509
 
510
510
  `bindery repair SRC [DST] [--json FILE]` writes its record in the
511
511
  `library --json` per-book vocabulary (`status`, `applied`, `before`/`after`,
512
- the fix `summary`) on every processing outcome, including nochange and
513
- reject; usage refusals (missing input, existing output) precede any record.
512
+ the fix `summary`, and the structured fix breakdown: the `fixes` dict plus
513
+ `ncx_uid_synced` and `watermark_refusals`) on every processing outcome,
514
+ including nochange and reject; usage refusals (missing input, existing
515
+ output) precede any record. The structured fields are the machine-readable
516
+ half of the same information: consumers narrow lossy consent by fix class
517
+ as data, never by substring-matching the rendered summary (v0.45.0; the
518
+ phase1 payload inherits them through its per-book repair records).
514
519
 
515
520
  ## The Calibre plugin (Bindery Repair)
516
521
 
@@ -11,7 +11,7 @@ Calibre plugin built from this core.
11
11
  Single source of truth for the version. pyproject.toml must match VERSION below.
12
12
  """
13
13
 
14
- VERSION = "0.44.0"
14
+ VERSION = "0.45.0"
15
15
  __version__ = VERSION
16
16
 
17
17
  from .epub import RepairReport, repair_epub # noqa: E402
@@ -11,7 +11,7 @@ import subprocess
11
11
  import sys
12
12
  import tempfile
13
13
  import zipfile
14
- from dataclasses import dataclass
14
+ from dataclasses import dataclass, field
15
15
  from itertools import islice
16
16
  from pathlib import Path
17
17
 
@@ -56,6 +56,13 @@ class Outcome:
56
56
  before: CheckResult | None
57
57
  after: CheckResult | None
58
58
  summary: str
59
+ # The structured half of `summary`: the RepairReport's own data, so
60
+ # consumers (run phase1's decisions, CalibreQuarry's lossy-consent
61
+ # mirror) read fix classes as data instead of substring-matching the
62
+ # rendered string.
63
+ fixes: dict[str, int] = field(default_factory=dict)
64
+ ncx_uid_synced: bool = False
65
+ watermark_refusals: int = 0
59
66
 
60
67
 
61
68
  def process_book(
@@ -133,7 +140,16 @@ def process_book(
133
140
  )
134
141
 
135
142
  if not validate:
136
- return Outcome(epub, "unvalidated", None, None, summary)
143
+ return Outcome(
144
+ epub,
145
+ "unvalidated",
146
+ None,
147
+ None,
148
+ summary,
149
+ fixes=report.fixes,
150
+ ncx_uid_synced=report.ncx_uid_synced,
151
+ watermark_refusals=report.watermark_refusals,
152
+ )
137
153
 
138
154
  if before is None:
139
155
  before = run_epubcheck(epub)
@@ -142,7 +158,16 @@ def process_book(
142
158
  # Validation was requested but the oracle failed (crash, timeout, unparsable
143
159
  # output). This is "error", not "unvalidated": the gate did not accept the
144
160
  # repair, so it must never be applied. Only --no-validate skips the gate.
145
- return Outcome(epub, "error", before, after, summary + " (epubcheck failed)")
161
+ return Outcome(
162
+ epub,
163
+ "error",
164
+ before,
165
+ after,
166
+ summary + " (epubcheck failed)",
167
+ fixes=report.fixes,
168
+ ncx_uid_synced=report.ncx_uid_synced,
169
+ watermark_refusals=report.watermark_refusals,
170
+ )
146
171
  verdict = gate(before, after)
147
172
  if (
148
173
  report.fixes.get("stripped_pagination")
@@ -193,7 +218,16 @@ def process_book(
193
218
  elif verdict == "noop":
194
219
  summary += " (no measurable gain)"
195
220
  status = "equal" if verdict == "noop" else verdict
196
- return Outcome(epub, status, before, after, summary)
221
+ return Outcome(
222
+ epub,
223
+ status,
224
+ before,
225
+ after,
226
+ summary,
227
+ fixes=report.fixes,
228
+ ncx_uid_synced=report.ncx_uid_synced,
229
+ watermark_refusals=report.watermark_refusals,
230
+ )
197
231
 
198
232
 
199
233
  def _load_audit(path: Path) -> dict[str, tuple[int, int, int]]:
@@ -660,8 +694,21 @@ def run_library(args) -> int:
660
694
  # record per path.
661
695
  errors += 1
662
696
  records.pop()
697
+ # Forward o's structured fields: the repair itself
698
+ # happened (its fixes are facts about the candidate),
699
+ # and the record contract promises them on every
700
+ # outcome, apply-failed included.
663
701
  records.append(
664
- Outcome(epub, "error", o.before, o.after, f"apply failed: {e}")
702
+ Outcome(
703
+ epub,
704
+ "error",
705
+ o.before,
706
+ o.after,
707
+ f"apply failed: {e}",
708
+ fixes=o.fixes,
709
+ ncx_uid_synced=o.ncx_uid_synced,
710
+ watermark_refusals=o.watermark_refusals,
711
+ )
665
712
  )
666
713
  tqdm.write(
667
714
  f" ERROR {rel}\n apply failed: {e}; not applied"
@@ -742,6 +789,9 @@ def run_library(args) -> int:
742
789
  "before": _counts_dict(o.before),
743
790
  "after": _counts_dict(o.after),
744
791
  "summary": o.summary,
792
+ "fixes": o.fixes,
793
+ "ncx_uid_synced": o.ncx_uid_synced,
794
+ "watermark_refusals": o.watermark_refusals,
745
795
  "applied": o.epub in applied_paths,
746
796
  }
747
797
  for o in records
@@ -833,6 +883,9 @@ def run_repair(args) -> int:
833
883
  "before": None,
834
884
  "after": None,
835
885
  "summary": "",
886
+ "fixes": {},
887
+ "ncx_uid_synced": False,
888
+ "watermark_refusals": 0,
836
889
  "error": f"{type(e).__name__}: {e}",
837
890
  }
838
891
  )
@@ -846,6 +899,9 @@ def run_repair(args) -> int:
846
899
  "before": _counts_dict(o.before),
847
900
  "after": _counts_dict(o.after),
848
901
  "summary": o.summary,
902
+ "fixes": o.fixes,
903
+ "ncx_uid_synced": o.ncx_uid_synced,
904
+ "watermark_refusals": o.watermark_refusals,
849
905
  }
850
906
  if o.status == "nochange":
851
907
  print("no applicable fixes; nothing written.")
@@ -951,10 +1007,10 @@ def _phase1_decisions(books: list[dict], apply: bool) -> list[dict]:
951
1007
  r = b["repair"]
952
1008
  if r is None:
953
1009
  continue
954
- summary = r.get("summary") or ""
955
- if "stripped_watermarks" in summary or "dropped_marker" in summary:
1010
+ fixes = r.get("fixes") or {}
1011
+ if fixes.get("stripped_watermarks") or fixes.get("dropped_marker"):
956
1012
  watermarked.append(b["path"])
957
- if "watermark_refusals" in summary:
1013
+ if r.get("watermark_refusals"):
958
1014
  manual_wm.append(b["path"])
959
1015
  if r["status"] in ("accept", "partial"):
960
1016
  pending.append(b["path"])
@@ -974,7 +1030,7 @@ def _phase1_decisions(books: list[dict], apply: bool) -> list[dict]:
974
1030
  else:
975
1031
  for b in books:
976
1032
  r = b["repair"]
977
- if r is not None and "watermark_refusals" in (r.get("summary") or ""):
1033
+ if r is not None and r.get("watermark_refusals"):
978
1034
  manual_wm.append(b["path"])
979
1035
  if manual_wm:
980
1036
  decisions.append(
@@ -1024,7 +1080,7 @@ def run_doctor(args) -> int:
1024
1080
  print()
1025
1081
 
1026
1082
  py = sys.version.split()[0]
1027
- print(f"python: {py} (floor 3.12)")
1083
+ print(f"python: {py} (floor 3.12) [{sys.executable}]")
1028
1084
 
1029
1085
  stack = []
1030
1086
  for mod in ("vir_tui", "cquarry"):
@@ -196,8 +196,9 @@ class TestRepairWritesGatedBytes(unittest.TestCase):
196
196
  class TestRepairJson(unittest.TestCase):
197
197
  """repair --json: the one machine-readable gap in the four-verb CLI. The
198
198
  record speaks the library --json per-book vocabulary (status, applied,
199
- before/after, summary) and lands on every processing outcome, including
200
- the refusals."""
199
+ before/after, summary, the structured fixes/ncx_uid_synced/
200
+ watermark_refusals fields) and lands on every processing outcome,
201
+ including the refusals."""
201
202
 
202
203
  def test_accepted_repair_writes_a_record(self):
203
204
  with tempfile.TemporaryDirectory() as td:
@@ -274,6 +275,68 @@ class TestRepairJson(unittest.TestCase):
274
275
  self.assertFalse(d2["applied"])
275
276
 
276
277
 
278
+ class TestStructuredFixRecord(unittest.TestCase):
279
+ """The per-book records carry the fix breakdown as data, not only the
280
+ rendered summary string: the structured-fixes roadmap box (2026-09-18),
281
+ so consumers like CalibreQuarry's lossy-consent mirror and run phase1's
282
+ own decisions read fix classes instead of substring-matching prose."""
283
+
284
+ def test_library_record_carries_fix_breakdown(self):
285
+ with tempfile.TemporaryDirectory() as td:
286
+ root = Path(td)
287
+ _phase1_book(root / "a.epub", broken=False, real_fix=True)
288
+ jout = root / "lib.json"
289
+ with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
290
+ rc = cli.run_library(
291
+ build_parser().parse_args(
292
+ ["library", str(root), "--no-validate", "--json", str(jout)]
293
+ )
294
+ )
295
+ data = json.loads(jout.read_text())
296
+ self.assertEqual(rc, 0)
297
+ (rec,) = data["books"]
298
+ self.assertEqual(rec["status"], "unvalidated")
299
+ self.assertEqual(rec["fixes"], {"fix_ncx_playorder": 1})
300
+ self.assertFalse(rec["ncx_uid_synced"])
301
+ self.assertEqual(rec["watermark_refusals"], 0)
302
+ # the rendered summary stays for human eyes
303
+ self.assertIn("fix_ncx_playorder:1", rec["summary"])
304
+
305
+ def test_repair_json_record_carries_fix_breakdown(self):
306
+ with tempfile.TemporaryDirectory() as td:
307
+ src = Path(td) / "in.epub"
308
+ dst = Path(td) / "out.epub"
309
+ _phase1_book(src, broken=False, real_fix=True)
310
+ jout = Path(td) / "repair.json"
311
+ with redirect_stdout(io.StringIO()):
312
+ rc = main(
313
+ ["repair", str(src), str(dst), "--no-validate", "--json", str(jout)]
314
+ )
315
+ data = json.loads(jout.read_text())
316
+ self.assertEqual(rc, 0)
317
+ self.assertEqual(data["status"], "unvalidated")
318
+ self.assertEqual(data["fixes"], {"fix_ncx_playorder": 1})
319
+ self.assertFalse(data["ncx_uid_synced"])
320
+ self.assertEqual(data["watermark_refusals"], 0)
321
+
322
+ def test_nochange_record_carries_empty_fixes(self):
323
+ with tempfile.TemporaryDirectory() as td:
324
+ src = Path(td) / "in.epub"
325
+ dst = Path(td) / "out.epub"
326
+ _phase1_book(src, broken=False)
327
+ jout = Path(td) / "repair.json"
328
+ with redirect_stdout(io.StringIO()):
329
+ rc = main(
330
+ ["repair", str(src), str(dst), "--no-validate", "--json", str(jout)]
331
+ )
332
+ data = json.loads(jout.read_text())
333
+ self.assertEqual(rc, 0)
334
+ self.assertEqual(data["status"], "nochange")
335
+ self.assertEqual(data["fixes"], {})
336
+ self.assertFalse(data["ncx_uid_synced"])
337
+ self.assertEqual(data["watermark_refusals"], 0)
338
+
339
+
277
340
  class TestDoctor(unittest.TestCase):
278
341
  """bindery doctor: the stranded stranger's first command. It must always
279
342
  run, never traceback, and always exit 0; the findings ARE the output."""
@@ -744,6 +807,11 @@ class TestApplyFailureIsolation(unittest.TestCase):
744
807
  self.assertEqual(len(paths), len(set(paths))) # one record per path
745
808
  statuses = [b["status"] for b in data["books"]]
746
809
  self.assertEqual(statuses, ["error"])
810
+ # the repair itself happened; its fixes are facts about the candidate
811
+ # and must survive the apply failure (the record contract promises
812
+ # the structured fields on every outcome)
813
+ (rec,) = data["books"]
814
+ self.assertEqual(rec["fixes"], {"fix_ncx_playorder": 1})
747
815
  self.assertEqual(rc, 2)
748
816
 
749
817
  def test_backup_dir_inside_library_root_is_refused(self):
@@ -1264,6 +1332,9 @@ class TestWatermarkRefusalDecision(unittest.TestCase):
1264
1332
  "repair": {
1265
1333
  "status": "equal",
1266
1334
  "summary": "watermark_refusals:1 (no measurable gain)",
1335
+ "fixes": {},
1336
+ "ncx_uid_synced": False,
1337
+ "watermark_refusals": 1,
1267
1338
  },
1268
1339
  }
1269
1340
 
@@ -1278,6 +1349,9 @@ class TestWatermarkRefusalDecision(unittest.TestCase):
1278
1349
  book["repair"] = {
1279
1350
  "status": "accept",
1280
1351
  "summary": "stripped_watermarks:1, watermark_refusals:1",
1352
+ "fixes": {"stripped_watermarks": 1},
1353
+ "ncx_uid_synced": False,
1354
+ "watermark_refusals": 1,
1281
1355
  }
1282
1356
  decisions = _phase1_decisions([book], apply=True)
1283
1357
  (d,) = decisions # apply recorded: only the manual-repair question remains
@@ -8,7 +8,7 @@ resolution-markers = [
8
8
 
9
9
  [[package]]
10
10
  name = "bindery-cli"
11
- version = "0.44.0"
11
+ version = "0.45.0"
12
12
  source = { editable = "." }
13
13
  dependencies = [
14
14
  { name = "cquarry", marker = "python_full_version >= '3.14'" },
@@ -41,11 +41,11 @@ wheels = [
41
41
 
42
42
  [[package]]
43
43
  name = "cquarry"
44
- version = "1.23.0"
44
+ version = "1.23.2"
45
45
  source = { registry = "https://pypi.org/simple" }
46
- sdist = { url = "https://files.pythonhosted.org/packages/94/bb/b124fc1988935b5e3420394841b3d8c89c3bbf331f4c8335c643b2f6136c/cquarry-1.23.0.tar.gz", hash = "sha256:ecc86a0144e1fe03620e236b588927bf52ca24979550c0e043f972b310911aa6", size = 279290, upload-time = "2026-09-15T20:00:01.202Z" }
46
+ sdist = { url = "https://files.pythonhosted.org/packages/ac/f3/68d8dea76897d2efb9db7c6ce5e3592b1b6e410656e6e6a727c3ddae0353/cquarry-1.23.2.tar.gz", hash = "sha256:cb7467b02dea4013189964528f389d825b23416d6c493912947e7bbf10238a84", size = 284386, upload-time = "2026-09-16T21:18:59.258Z" }
47
47
  wheels = [
48
- { url = "https://files.pythonhosted.org/packages/14/05/cb087280bec3de9bb63f04e67183c127a33a4153110145ee30930d8f7c60/cquarry-1.23.0-py3-none-any.whl", hash = "sha256:1f39ecd144a586e17d81513beca954364c90e086f4bb44505fa428aa0f9b0ff4", size = 96522, upload-time = "2026-09-15T19:59:59.472Z" },
48
+ { url = "https://files.pythonhosted.org/packages/a2/4b/40e5f2a45ccbcb228f4a4c6dfe4a3e0b191004249bd7de1dec1c5a68541f/cquarry-1.23.2-py3-none-any.whl", hash = "sha256:708884c454ef344202351e9633cfba856b83b3961a742eac18d4815e3f8b38ec", size = 96618, upload-time = "2026-09-16T21:18:57.906Z" },
49
49
  ]
50
50
 
51
51
  [[package]]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes