bindery-cli 0.46.0__tar.gz → 0.46.1__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 (51) hide show
  1. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/.github/workflows/ci.yml +6 -3
  2. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/CLAUDE.md +11 -9
  3. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/PKG-INFO +3 -3
  4. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/README.md +2 -2
  5. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/patchnotes.md +31 -3
  6. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/plugin/__init__.py +5 -3
  7. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/pyproject.toml +1 -1
  8. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/roadmap.md +8 -2
  9. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/spec.md +10 -7
  10. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/src/bindery/__init__.py +1 -1
  11. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/src/bindery/cli.py +1 -1
  12. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/src/bindery/transforms.py +5 -2
  13. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_epub.py +57 -0
  14. bindery_cli-0.46.1/tests/test_flags_real_core.py +487 -0
  15. bindery_cli-0.46.1/tests/test_flags_wiring.py +113 -0
  16. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/.github/FUNDING.yml +0 -0
  17. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/.github/workflows/publish.yml +0 -0
  18. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/.gitignore +0 -0
  19. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/AGENTS.md +0 -0
  20. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/LICENSE +0 -0
  21. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/docs/screenshots/library-sweep.png +0 -0
  22. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/logo.svg +0 -0
  23. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/project.done +0 -0
  24. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/run_tests.sh +0 -0
  25. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/scripts/FastSweep.java +0 -0
  26. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/scripts/build_plugin.py +0 -0
  27. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/scripts/fast_sweep.py +0 -0
  28. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/src/bindery/__main__.py +0 -0
  29. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/src/bindery/audit.py +0 -0
  30. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/src/bindery/epub.py +0 -0
  31. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/src/bindery/library.py +0 -0
  32. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/src/bindery/pagination.py +0 -0
  33. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/src/bindery/reserialize.py +0 -0
  34. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/src/bindery/validate.py +0 -0
  35. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/src/bindery/watermark.py +0 -0
  36. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_audit.py +0 -0
  37. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_cli.py +0 -0
  38. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_comment_hyphens.py +0 -0
  39. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_cover_tocdrift.py +0 -0
  40. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_library.py +0 -0
  41. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_matcher_hardening.py +0 -0
  42. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_pagination.py +0 -0
  43. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_plugin.py +0 -0
  44. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_reserialize.py +0 -0
  45. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_space_renames.py +0 -0
  46. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_transforms.py +0 -0
  47. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_validate.py +0 -0
  48. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_version.py +0 -0
  49. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_watermark.py +0 -0
  50. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/tests/test_xml_hardening.py +0 -0
  51. {bindery_cli-0.46.0 → bindery_cli-0.46.1}/uv.lock +0 -0
@@ -44,9 +44,10 @@ jobs:
44
44
  # The install floor (decision 63): vir-tui/cquarry are marker-gated
45
45
  # to 3.14 there, so these legs install the core (tqdm only) and run
46
46
  # exactly the test modules that never touch the gated stack: the
47
- # repair core, the plugin under calibre stubs, validate, and the
48
- # version/grammar pins. audit/library/CLI-stack tests stay on the
49
- # 3.14 leg above.
47
+ # repair core, the plugin under calibre stubs, validate, the
48
+ # version/grammar pins, the RSC-016 comment-hyphen fix, the
49
+ # RepairFlags wiring pins, and the flags-driven real-core run.
50
+ # audit/library/CLI-stack tests stay on the 3.14 leg above.
50
51
  python-version: ["3.12", "3.13"]
51
52
  steps:
52
53
  - uses: actions/checkout@v5
@@ -59,6 +60,8 @@ jobs:
59
60
  tests.test_transforms tests.test_epub tests.test_pagination
60
61
  tests.test_watermark tests.test_reserialize tests.test_version
61
62
  tests.test_plugin tests.test_validate
63
+ tests.test_comment_hyphens tests.test_flags_wiring
64
+ tests.test_flags_real_core
62
65
  plugin-compat:
63
66
  name: Parse-check the plugin zip under the interpreters Calibre embeds
64
67
  runs-on: ubuntu-latest
@@ -31,7 +31,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
31
31
  mimetype fix (added/normalized/first-stored, epub.py's archive rewrite); every core
32
32
  fix must render identically to the author's intent: never add, remove, or reorder
33
33
  visible content. The canonical opt-in inventory lives in spec.md: sixteen structural
34
- repairs, three lossy strips, four safe opt-ins, plus `--reserialize`.
34
+ repairs, four lossy strips, four safe opt-ins, plus `--reserialize`.
35
35
  * **Structural repairs** (`--fix-empty-body`, `--fix-missing-title`, `--fix-id-colons`,
36
36
  `--fix-page-map`, `--strip-epub3-attrs`, `--downgrade-epub3-tags`,
37
37
  `--unwrap-block-in-inline`, `--strip-invalid-value`, `--unwrap-illegal-tags`,
@@ -80,11 +80,13 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
80
80
  - **The gate is the safety contract.** Never apply a repair epubcheck has not accepted.
81
81
  Respect the two-mode logic in `validate.gate` (fatal-fixing tolerates error unmasking;
82
82
  error-cleanup does not). The lossy strips (`--strip-pagination`, `--strip-broken-tags`,
83
- `--strip-watermarks`) are accepted by `validate.no_worse` instead (their gain is
84
- invisible to epubcheck, so it only forbids a regression, never demands a measured
85
- improvement). The structural opt-ins go through the normal `gate`: their gain IS
86
- visible (they clear errors), so a run with no measurable improvement is a noop and
87
- nothing is applied. Changing either bar means re-running the library dry run.
83
+ `--strip-watermarks`, `--strip-stub-docs`) are accepted by `validate.no_worse` instead
84
+ (their gain is invisible to epubcheck, so it only forbids a regression, never demands
85
+ a measured improvement). The structural opt-ins go through the normal `gate` (their
86
+ gain IS visible, so a run with no measurable improvement is a noop and nothing is
87
+ applied) except the two whose gains sit on axes epubcheck does not measure:
88
+ `--fix-cover` and `--encode-url-spaces`, accepted under the same `no_worse` bar with
89
+ the partial rule intact. Changing either bar means re-running the library dry run.
88
90
  - **Library writes are sacred.** Replacement must stay atomic (temp in same dir, then
89
91
  `os.replace`), touch only the `.epub`, preserve mode, and be dry-run by default.
90
92
  Calibre format installation goes through cquarry's write module (`--install-to-calibre`;
@@ -93,7 +95,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
93
95
 
94
96
  ## Layout
95
97
 
96
- - `src/bindery/transforms.py`: pure `str -> (str, int)` text transforms (including `strip_broken_tags`). Since v0.32.0: `strip_attrs_in_start_tags` anchors attribute edits on the quote-aware start-tag matcher, `_outside_protected` forwards arguments (context-carrying transforms can be decorated), `fix_id_colons` matches only the bare `id` attribute and only internal fragments (`fix_ncx_src_fragments` carries renames into the NCX), `fix_ncx_playorder` is anchored to `<navPoint>` start tags, and the css selector boundary covers namespaced and functional selector forms. HAZARD (the 0.41.0 spin): the quote-aware tag-prefix alternation (double-quoted | single-quoted | [^>]) OVERLAPS ([^>] matches quotes too), so a plain `*` re-partitions exponentially on failing candidates; every such greedy loop must carry the possessive `*+` (re 3.11+, the plugin's minimum Calibre). Since v0.42.0 the census is complete and pinned by tests/test_matcher_hardening.py: every greedy loop of this family is possessive (the three 0.41.1 rewrites plus _START_TAG_RE, _COVER_META_RE, _GUIDE_REF_RE, prune_dangling_edges' spine|item|itemref matcher, _ITEM_TAG_RE, _SPINE_ITEM_RE -- possessive is byte-identical on success because the loop is always followed by a literal `>` the [^>] branch cannot consume), and the five LAZY siblings (_VOID_RE, the transforms <img> matcher, and epub's link/a/img matchers) use the disjoint catch-all `[^>"']` instead: mutually exclusive branches cannot re-partition, and an unterminated quote inside a tag now fails to match rather than pairing across markup. NEW tag matchers: quote-aware possessive for greedy loops, the disjoint catch-all for lazy ones, and the tempered-quote idiom (["'])((?:(?!\1).)*)\1 for attribute values. Since v0.37.0: `fix_ncx_playorder` strips quotes before its already-correct comparison (regex groups carry quote characters; comparing quoted values against bare numbers counted phantom fixes on every sequential NCX and rewrote single-quoted correct attributes to double quotes).
98
+ - `src/bindery/transforms.py`: pure `str -> (str, int)` text transforms (including `strip_broken_tags`). Since v0.32.0: `strip_attrs_in_start_tags` anchors attribute edits on the quote-aware start-tag matcher, `_outside_protected` forwards arguments (context-carrying transforms can be decorated), `fix_id_colons` matches only the bare `id` attribute and only internal fragments (`fix_ncx_src_fragments` carries renames into the NCX), `fix_ncx_playorder` is anchored to `<navPoint>` start tags, and the css selector boundary covers namespaced and functional selector forms. HAZARD (the 0.41.0 spin): the quote-aware tag-prefix alternation (double-quoted | single-quoted | [^>]) OVERLAPS ([^>] matches quotes too), so a plain `*` re-partitions exponentially on failing candidates; every such greedy loop must carry the possessive `*+` (re 3.11+, the plugin's minimum Calibre). Since v0.42.0 the census is complete and pinned by tests/test_matcher_hardening.py: every greedy loop of this family is possessive (the three 0.41.1 rewrites plus _START_TAG_RE, _COVER_META_RE, _GUIDE_REF_RE, prune_dangling_edges' spine|item|itemref matcher, _ITEM_TAG_RE, _SPINE_ITEM_RE; possessive is byte-identical on success because the loop is always followed by a literal `>` the [^>] branch cannot consume), and the five LAZY siblings (_VOID_RE, the transforms <img> matcher, and epub's link/a/img matchers) use the disjoint catch-all `[^>"']` instead: mutually exclusive branches cannot re-partition, and an unterminated quote inside a tag now fails to match rather than pairing across markup. NEW tag matchers: quote-aware possessive for greedy loops, the disjoint catch-all for lazy ones, and the tempered-quote idiom (["'])((?:(?!\1).)*)\1 for attribute values. Since v0.37.0: `fix_ncx_playorder` strips quotes before its already-correct comparison (regex groups carry quote characters; comparing quoted values against bare numbers counted phantom fixes on every sequential NCX and rewrote single-quoted correct attributes to double quotes).
97
99
  - `src/bindery/pagination.py`: the opt-in lossy page-number strip (runhead detection, page-layer decision, block-centric removal/merge, safety nets).
98
100
  - `src/bindery/watermark.py`: the opt-in lossy watermark strip (anchored and anchorless signature removal).
99
101
  - `src/bindery/reserialize.py`: structural repair via `html5lib`.
@@ -102,8 +104,8 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
102
104
  - `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
103
105
  - `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.
104
106
  - `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. `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.
105
- - `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.
106
- - `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).
107
+ - `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.
108
+ - `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.
107
109
 
108
110
  ## Conventions
109
111
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindery-cli
3
- Version: 0.46.0
3
+ Version: 0.46.1
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
@@ -67,7 +67,7 @@ Everything below is off until its flag is passed (or all at once via `--all`). T
67
67
  - **`--unwrap-illegal-tags`**: strips completely invalid or deprecated HTML tags that break EPUB3 validation (`<st>`, `<sentence>`, `<o>`, `<w>`, `<pagebreak>`) while retaining their inner text. Any of those names styled as an *element selector* by an EPUB stylesheet (`.css` entries and inline `<style>` blocks alike; class/id selectors like `.st`/`#w` don't count) is protected for the whole book, guaranteeing format preservation.
68
68
  - **`--prune-missing-resources`**: removes references to files the archive does not contain (RSC-007/PKG-010): dead `<link>` elements, anchors' `href` to absent files (anchor text preserved), absent `<img>` sources (replaced by their alt text when they carry one), and orphaned non-spine OPF manifest items (with every package edge that pointed at them rewritten: `spine@toc`, `item@media-overlay`, `item@fallback`, and the EPUB2 cover meta). Spine documents are never pruned: a missing spine document is reported as a damaged fragment, not silently dropped.
69
69
  - **`--strip-broken-anchors`**: strips href attributes that cannot resolve, keeping the anchor text byte-for-byte: a `#fragment` the target document does not define (RSC-020/RSC-012; NCX navTargets fall back to the document target, so chapter navigation survives) and unresolvable URI schemes (`kindle:`, `file:`).
70
- - **`--encode-url-spaces`**: percent-encodes raw spaces in `src`/`href` attribute values across the package (OPF manifest, NCX, content documents). A URL with a literal space is not a valid URL (RSC-020) and unresolvable on strict readers; the encoded form denotes the same file.
70
+ - **`--encode-url-spaces`**: percent-encodes raw spaces in `src`/`href` attribute values across the package (OPF manifest, NCX, content documents). A URL with a literal space is not a valid URL (RSC-020) and unresolvable on strict readers; the encoded form denotes the same file. Since v0.44.0 the flag also renames archive entries whose names carry raw spaces to their underscore spellings and rewrites every reference to them (OPF, NCX, content, CSS `url()`), clearing the PKG-010 space-in-filename advisories: underscore, never percent-encoding, because epubcheck decodes references before entry lookup.
71
71
  - **`--fix-container`**: generates `META-INF/container.xml` at the located OPF when the container is missing or names a file the archive does not contain. This is the gateway defect: epubcheck stays fatal while the OPF is unfindable, so no repair can be gate-accepted on such a book until the container exists.
72
72
  - **`--fix-media-types`**: normalizes wrong manifest media-type declarations (OPF-029, e.g. a jpg stamped `image/png` by an aggregator). Attribute-only and quote-preserving; it fires only when the file's magic bytes confirm the extension, so a misnamed-but-consistent file (a PNG renamed .jpg) is never made worse.
73
73
  - **`--fix-cover`**: repairs dangling EPUB2 cover wiring. A `<meta name="cover">` whose content names no manifest item is re-pointed when the OPF guide's own cover reference names an existing item, and removed when nothing identifies it; EPUB3 `properties="cover-image"` is deliberately audit-only (guessing which image is the cover is not deterministic). Cover wiring is invisible to epubcheck, so cover-only repairs are accepted under the same no-worse bar the lossy strips use.
@@ -94,7 +94,7 @@ Introducing a net-new fatal is always rejected. If epubcheck itself fails to run
94
94
 
95
95
  Under the hood, the oracle can run two ways: the usual `epubcheck --json -` subprocess, or a persistent daemon that writes `FastDaemon.java` into a temp directory at runtime, compiles it in memory with the running JVM's own compiler (Java's single-file source launcher: no javac, no javac/java version skew), and drives the warm JVM over a pipe. The daemon reports the counts it reads from the JSON document that epubcheck's own `CheckingReport` serializes, so it measures exactly what the subprocess measures, just ~15x faster. It is a pool bounded by `--workers` (serial runs use one), every roundtrip is bounded by the caller's timeout, and any failure tears it down and falls back to the subprocess for good.
96
96
 
97
- The lossy modes (`--strip-pagination`, `--strip-broken-tags`, and `--strip-watermarks`) are the exception to the "must improve" rule. Since they remove visible markup rather than correcting XML schema violations, epubcheck counts often remain unchanged. They are accepted when the result is **no worse** (no net-new fatals or errors), relying on strict programmatic safety nets instead.
97
+ The lossy modes (`--strip-pagination`, `--strip-broken-tags`, `--strip-watermarks`, and `--strip-stub-docs`) are the exception to the "must improve" rule. Since they remove converter-injected content rather than correcting XML schema violations, epubcheck counts often remain unchanged. They are accepted when the result is **no worse** (no net-new fatals or errors), relying on strict programmatic safety nets instead.
98
98
 
99
99
  ## Install
100
100
 
@@ -40,7 +40,7 @@ Everything below is off until its flag is passed (or all at once via `--all`). T
40
40
  - **`--unwrap-illegal-tags`**: strips completely invalid or deprecated HTML tags that break EPUB3 validation (`<st>`, `<sentence>`, `<o>`, `<w>`, `<pagebreak>`) while retaining their inner text. Any of those names styled as an *element selector* by an EPUB stylesheet (`.css` entries and inline `<style>` blocks alike; class/id selectors like `.st`/`#w` don't count) is protected for the whole book, guaranteeing format preservation.
41
41
  - **`--prune-missing-resources`**: removes references to files the archive does not contain (RSC-007/PKG-010): dead `<link>` elements, anchors' `href` to absent files (anchor text preserved), absent `<img>` sources (replaced by their alt text when they carry one), and orphaned non-spine OPF manifest items (with every package edge that pointed at them rewritten: `spine@toc`, `item@media-overlay`, `item@fallback`, and the EPUB2 cover meta). Spine documents are never pruned: a missing spine document is reported as a damaged fragment, not silently dropped.
42
42
  - **`--strip-broken-anchors`**: strips href attributes that cannot resolve, keeping the anchor text byte-for-byte: a `#fragment` the target document does not define (RSC-020/RSC-012; NCX navTargets fall back to the document target, so chapter navigation survives) and unresolvable URI schemes (`kindle:`, `file:`).
43
- - **`--encode-url-spaces`**: percent-encodes raw spaces in `src`/`href` attribute values across the package (OPF manifest, NCX, content documents). A URL with a literal space is not a valid URL (RSC-020) and unresolvable on strict readers; the encoded form denotes the same file.
43
+ - **`--encode-url-spaces`**: percent-encodes raw spaces in `src`/`href` attribute values across the package (OPF manifest, NCX, content documents). A URL with a literal space is not a valid URL (RSC-020) and unresolvable on strict readers; the encoded form denotes the same file. Since v0.44.0 the flag also renames archive entries whose names carry raw spaces to their underscore spellings and rewrites every reference to them (OPF, NCX, content, CSS `url()`), clearing the PKG-010 space-in-filename advisories: underscore, never percent-encoding, because epubcheck decodes references before entry lookup.
44
44
  - **`--fix-container`**: generates `META-INF/container.xml` at the located OPF when the container is missing or names a file the archive does not contain. This is the gateway defect: epubcheck stays fatal while the OPF is unfindable, so no repair can be gate-accepted on such a book until the container exists.
45
45
  - **`--fix-media-types`**: normalizes wrong manifest media-type declarations (OPF-029, e.g. a jpg stamped `image/png` by an aggregator). Attribute-only and quote-preserving; it fires only when the file's magic bytes confirm the extension, so a misnamed-but-consistent file (a PNG renamed .jpg) is never made worse.
46
46
  - **`--fix-cover`**: repairs dangling EPUB2 cover wiring. A `<meta name="cover">` whose content names no manifest item is re-pointed when the OPF guide's own cover reference names an existing item, and removed when nothing identifies it; EPUB3 `properties="cover-image"` is deliberately audit-only (guessing which image is the cover is not deterministic). Cover wiring is invisible to epubcheck, so cover-only repairs are accepted under the same no-worse bar the lossy strips use.
@@ -67,7 +67,7 @@ Introducing a net-new fatal is always rejected. If epubcheck itself fails to run
67
67
 
68
68
  Under the hood, the oracle can run two ways: the usual `epubcheck --json -` subprocess, or a persistent daemon that writes `FastDaemon.java` into a temp directory at runtime, compiles it in memory with the running JVM's own compiler (Java's single-file source launcher: no javac, no javac/java version skew), and drives the warm JVM over a pipe. The daemon reports the counts it reads from the JSON document that epubcheck's own `CheckingReport` serializes, so it measures exactly what the subprocess measures, just ~15x faster. It is a pool bounded by `--workers` (serial runs use one), every roundtrip is bounded by the caller's timeout, and any failure tears it down and falls back to the subprocess for good.
69
69
 
70
- The lossy modes (`--strip-pagination`, `--strip-broken-tags`, and `--strip-watermarks`) are the exception to the "must improve" rule. Since they remove visible markup rather than correcting XML schema violations, epubcheck counts often remain unchanged. They are accepted when the result is **no worse** (no net-new fatals or errors), relying on strict programmatic safety nets instead.
70
+ The lossy modes (`--strip-pagination`, `--strip-broken-tags`, `--strip-watermarks`, and `--strip-stub-docs`) are the exception to the "must improve" rule. Since they remove converter-injected content rather than correcting XML schema violations, epubcheck counts often remain unchanged. They are accepted when the result is **no worse** (no net-new fatals or errors), relying on strict programmatic safety nets instead.
71
71
 
72
72
  ## Install
73
73
 
@@ -1,4 +1,32 @@
1
1
  # bindery-cli Patch Notes
2
+ ## v0.46.1 (2026-10-02)
3
+
4
+ ### The closing sweep: documentation truth after the 0.46 line
5
+
6
+ The 2026-10-02 verification of the four-lane run left four documentation
7
+ findings against the v0.46.0 tree; all four land here as text plus one
8
+ plugin string. No surface change beyond the plugin's help text.
9
+
10
+ * The safety contract's lossy enumeration names all four modes: the
11
+ paragraph listed three and omitted `--strip-stub-docs` (shipped
12
+ v0.41.0), while the mode list twenty lines up, spec.md, and CLAUDE.md
13
+ all carry four. Its rationale sentence now says converter-injected
14
+ content rather than visible markup, true of the document-dropping stub
15
+ mode too.
16
+ * CLAUDE.md's tests bullet loses its em-dash: the one straggler in the
17
+ v0.45.0..HEAD markdown delta, recast with a semicolon.
18
+ * ci.yml's core-compat comment describes every module its list runs: the
19
+ v0.46.0 follow-ups added comment-hyphens, flags-wiring, and
20
+ flags-real-core to the module list without touching the enumeration
21
+ above it, so a reader reconciling comment against list found three
22
+ orphans.
23
+ * The plugin's `customization_help` names `max_log_mb`: the rotation cap
24
+ is documented in the README and honored by the log writer, but the
25
+ in-Calibre customization dialog never mentioned the key. Rides the
26
+ release-attached plugin zip.
27
+ * Suite: 544 -> 575 tests since v0.46.0 (the two follow-up wiring-test
28
+ commits, unpinned in that entry's count), all green on this text-only
29
+ sweep.
2
30
  ## v0.46.0 (2026-10-02)
3
31
 
4
32
  ### The last open repair class closes: RSC-016, plus the RepairFlags refactor
@@ -26,7 +54,7 @@
26
54
 
27
55
  * **`--encode-url-spaces` gained its rename half**, the biggest real-prevalence repair class left (PKG-010: 8,237 occurrences in 284 books at the 2026-09-17 refresh). Archive entries whose names carry raw spaces are renamed to their underscore spellings and every reference is rewritten (OPF, NCX, content `src`/`href`, stylesheet `url()`). The design was decided by experiment, not by the roadmap box's plan: epubcheck 5.3 DECODES references before entry lookup, so percent-encoded entries are unfindable by the encoded references pointing at them (all four raw/percent/underscore combinations built and checked); underscore on both sides resolves and clears PKG-010. Ambiguous renames are refused per entry (a target that already exists, two names converging, a duplicated archive name), a book with any non-UTF-8 document refuses all of its renames, a renamed OPF regenerates a stale container, and the acceptance rides `no_worse` because the gain sits on the warning axis the improvement gate does not measure (same bar as `--fix-cover`).
28
56
  * **The matcher fix that explains Theaetetus** (the 2026-09-16 hand-rescue): the encode pass's value class `[^"']` stopped at apostrophes inside double-quoted hrefs, so the 270 raw-space OPF hrefs never matched and the fatal survived. The matcher now takes the tempered-quote idiom (possessive, house style), and an apostrophe in a double-quoted filename is ordinary.
29
- * **`bindery audit cover`** (advisory, the EPUB3 half of the ruled hybrid): dangling EPUB2 `<meta name="cover">` (the `--fix-cover` class), the EPUB3 `properties~="cover-image"` declaration, and whether the named cover file exists. **`bindery audit tocdrift`**: the structural diff between the NCX navMap and the EPUB3 nav toc, keyed on the full target including fragments -- entries each side is missing plus label mismatches. Both never flag a book and never move the exit code; ToC synthesis stays out of the repair charter permanently. The loader exposes the parsed OPF, the NCX text, and the nav HTML on `Book`.
57
+ * **`bindery audit cover`** (advisory, the EPUB3 half of the ruled hybrid): dangling EPUB2 `<meta name="cover">` (the `--fix-cover` class), the EPUB3 `properties~="cover-image"` declaration, and whether the named cover file exists. **`bindery audit tocdrift`**: the structural diff between the NCX navMap and the EPUB3 nav toc, keyed on the full target including fragments: entries each side is missing plus label mismatches. Both never flag a book and never move the exit code; ToC synthesis stays out of the repair charter permanently. The loader exposes the parsed OPF, the NCX text, and the nav HTML on `Book`.
30
58
  * **The audit-refresh deep-dive ran** (read-only FastSweep extract over the full 5,228-book library; raw report in the facility): RSC-005 164,302 -> 13,782 occurrences, RSC-007 3,219 -> 14,162 in 604 books (the classics-wave imports), RSC-020 9,953 in 337 books, PKG-010 8,237 in 284 books (population flat: the rename class was still live, hence this ship).
31
59
  * **The roman grammar is one copy again**: pagination.py is the canonical home (it vendors into the plugin; audit does not), audit.py imports `ROMAN_RE`/`ROMAN_MAX`/`roman_value`, and the two byte-identical copies cannot drift. The RepairFlags dataclass refactor stays boxed with its reason (pure churn on the signatures this release touched).
32
60
  * Suite: 497 -> 521 tests (tests/test_space_renames.py, tests/test_cover_tocdrift.py, and the updated `--encode-url-spaces` end-to-end pins).
@@ -35,13 +63,13 @@
35
63
 
36
64
  ### The stdlib XML hardening: untrusted metadata is bounded and entity-refused, no dependency
37
65
 
38
- * **`--audit` and every `load_book` caller no longer decompress blind.** Two protections, both stdlib-only (the defusedxml dependency stays declined): single entries whose declared size exceeds `_MAX_ENTRY_BYTES` (32 MB) are refused before decompression -- a bomb entry in a tiny zip cannot be downloaded into memory -- and the untrusted metadata files (container.xml, OPF, encryption.xml) parse through `_safe_xml_parse`, which refuses DOCTYPE/ENTITY declarations before xml.etree sees them. A refused or unparseable metadata file returns the shell book (the archive verdict is the book's whole story), never a hang or a traceback.
66
+ * **`--audit` and every `load_book` caller no longer decompress blind.** Two protections, both stdlib-only (the defusedxml dependency stays declined): single entries whose declared size exceeds `_MAX_ENTRY_BYTES` (32 MB) are refused before decompression (a bomb entry in a tiny zip cannot be downloaded into memory), and the untrusted metadata files (container.xml, OPF, encryption.xml) parse through `_safe_xml_parse`, which refuses DOCTYPE/ENTITY declarations before xml.etree sees them. A refused or unparseable metadata file returns the shell book (the archive verdict is the book's whole story), never a hang or a traceback.
39
67
  * Pinned by `tests/test_xml_hardening.py` (entity-bomb container returns the shell book; clean XML parses; the entry cap is sane). The defusedxml dependency ask is closed as declined with the stdlib shape delivered.
40
68
 
41
69
  ## v0.42.0 (2026-09-16)
42
70
 
43
71
  ### The matcher audit executed: every quote-aware tag matcher hardened
44
- The follow-up box the 0.41.1 hotfix opened is closed with a full per-matcher census, and the audit found the box's own enumeration incomplete. Six GREEDY patterns of the exponential overlap class were unhardened -- `_START_TAG_RE` (the quote-aware start-tag matcher anchoring `--strip-epub3-attrs` and `--strip-bad-attrs`, running under `repair --all` on every content doc; not even named in the box), `_COVER_META_RE`, `_GUIDE_REF_RE`, `prune_dangling_edges`' spine|item|itemref matcher, `_ITEM_TAG_RE`, and `_SPINE_ITEM_RE`. All six are possessive-bounded now: byte-identical on every successful match (the loop is always followed by a literal `>` that the `[^>]` branch cannot consume, so backtracking only ever happened on failure) and linear on failure. Five LAZY patterns of the quadratic class (`_VOID_RE`, the transforms `<img>` matcher, and epub's link/anchor/img matchers) took the disjoint catch-all `[^>"']` instead of a blind possessive swap: mutually exclusive branches cannot re-partition, so any quantifier is linear, and an unterminated quote inside a tag now fails to match instead of pairing with a distant quote and matching across markup. Every well-formed shape is byte-identical. The epub `_IMG_TAG_RE` is renamed `_PRUNE_IMG_TAG_RE` (it collided with the transforms matcher of the same name). Everything else in the package audited safe by construction: tempered-quote value matchers, two-branch quote alternations, `[^>]*` single-class prefixes (linear, not quote-aware -- accepted, documented), lazy paired-delimiter spans, and the analyzer tokenizers.
72
+ The follow-up box the 0.41.1 hotfix opened is closed with a full per-matcher census, and the audit found the box's own enumeration incomplete. Six GREEDY patterns of the exponential overlap class were unhardened: `_START_TAG_RE` (the quote-aware start-tag matcher anchoring `--strip-epub3-attrs` and `--strip-bad-attrs`, running under `repair --all` on every content doc; not even named in the box), `_COVER_META_RE`, `_GUIDE_REF_RE`, `prune_dangling_edges`' spine|item|itemref matcher, `_ITEM_TAG_RE`, and `_SPINE_ITEM_RE`. All six are possessive-bounded now: byte-identical on every successful match (the loop is always followed by a literal `>` that the `[^>]` branch cannot consume, so backtracking only ever happened on failure) and linear on failure. Five LAZY patterns of the quadratic class (`_VOID_RE`, the transforms `<img>` matcher, and epub's link/anchor/img matchers) took the disjoint catch-all `[^>"']` instead of a blind possessive swap: mutually exclusive branches cannot re-partition, so any quantifier is linear, and an unterminated quote inside a tag now fails to match instead of pairing with a distant quote and matching across markup. Every well-formed shape is byte-identical. The epub `_IMG_TAG_RE` is renamed `_PRUNE_IMG_TAG_RE` (it collided with the transforms matcher of the same name). Everything else in the package audited safe by construction: tempered-quote value matchers, two-branch quote alternations, `[^>]*` single-class prefixes (linear, not quote-aware; accepted, documented), lazy paired-delimiter spans, and the analyzer tokenizers.
45
73
 
46
74
  Pinned by `tests/test_matcher_hardening.py` (15 tests): alarm-guarded spin shapes per greedy site (OPF flavor, the truncated-OPF prune shape), unterminated-quote skips, and well-formed byte-identity. `CLAUDE.md`'s hazard note now carries the census and the rule for new matchers. The full suite is 491 tests.
47
75
 
@@ -270,7 +270,9 @@ class BinderyRepair(FileTypePlugin):
270
270
  def customization_help(self, gui=False):
271
271
  return (
272
272
  'Enter a JSON object: {"log": true, "log_path": ..., '
273
- '"max_size_mb": 150, "epubcheck_path": null}. '
274
- "epubcheck_path enables the experimental post-repair validation "
275
- "with the epubcheck binary (slow; off by default)."
273
+ '"max_size_mb": 150, "max_log_mb": 2, "epubcheck_path": null}. '
274
+ "max_log_mb is the log rotation cap in MB (default 2, one .old "
275
+ "generation kept, 0 disables). epubcheck_path enables the "
276
+ "experimental post-repair validation with the epubcheck binary "
277
+ "(slow; off by default)."
276
278
  )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.46.0"
7
+ version = "0.46.1"
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 = [
@@ -89,6 +89,12 @@ The plugin vendors the core and never gates (the epubcheck latency ruling).
89
89
  docs/screenshots/library-sweep.png against a synthetic testing_facility
90
90
  library (invented titles, real dry-run output); the current PNG shows 16
91
91
  real rows. Asked 2026-09-15, unanswered.
92
+ - [x] **The 2026-10-02 closing sweep** (the four-lane verification's doc
93
+ findings against the v0.46.0 tree): the safety contract's lossy
94
+ enumeration names all four modes, the core-compat em-dash is gone,
95
+ ci.yml's module comment covers all eleven stack-free modules, and the
96
+ plugin's `customization_help` names `max_log_mb`. *(SHIPPED v0.46.1:
97
+ text plus the one plugin string; no surface change.)*
92
98
 
93
99
  ## The repair domain in the ecosystem parity program (recorded 2026-09-29)
94
100
 
@@ -107,7 +113,7 @@ metadata.db registration and the Calibre plugin. Dispositions recorded under the
107
113
  embed/subset fonts, jacket, smarten punctuation, remove-unused-css, compress-images,
108
114
  upgrade-book, download-external-resources, the --opf rewrite, --cover. They are
109
115
  content-improving rather than repair, and dc: metadata editing is out of charter
110
- (spec.md:583). Recorded here so the parity ledger's bindery row names them declined,
116
+ (spec.md:597). Recorded here so the parity ledger's bindery row names them declined,
111
117
  not missing.
112
118
  - **The `data`-row waiver note**: bindery has updated `data` rows through
113
119
  `WritableCalibreDB.set_format` since v0.24.0 (the sanctioned install path);
@@ -134,7 +140,7 @@ metadata.db registration and the Calibre plugin. Dispositions recorded under the
134
140
  also fires on `add_format`, so the plugin is byte-idempotent. The vendor slice
135
141
  (transforms/epub/pagination/watermark/reserialize + a plugin `__init__`) is
136
142
  generated at release from the tagged tree with a byte-equality drift test. Active
137
- set = the gate-safe default pass only: every structural repair and the three
143
+ set = the gate-safe default pass only: every structural repair and the four
138
144
  lossy strips stay CLI-only, because epubcheck cannot gate inside Calibre (no jar/JVM
139
145
  and seconds-per-book latency). Config via `site_customization` JSON (`log`,
140
146
  `log_path`, `max_size_mb`, recorded default 150MB); `publish.yml` attaches
@@ -20,8 +20,9 @@ The deliberate exceptions to "semantics-preserving" come in opt-in groups. The s
20
20
  `--prune-missing-resources`, `--strip-broken-anchors`, `--encode-url-spaces`,
21
21
  `--fix-container`, `--fix-media-types`, `--fix-cover`,
22
22
  `--fix-comment-double-hyphen`) alter
23
- markup structure or fabricate minimal content; the three
24
- **lossy modes** (`--strip-pagination`, `--strip-broken-tags`, `--strip-watermarks`) remove
23
+ markup structure or fabricate minimal content; the four
24
+ **lossy modes** (`--strip-pagination`, `--strip-broken-tags`, `--strip-watermarks`,
25
+ `--strip-stub-docs`) remove
25
26
  content a converter injected rather than content the author wrote. A fourth group of
26
27
  **safe opt-ins** (`--fix-ids`, `--add-img-alt`, `--strip-bad-attrs`,
27
28
  `--escape-unknown-entities`) repairs without altering visible markup, and
@@ -233,11 +234,13 @@ use, with the `partial` rule intact.
233
234
  different fatal class and is left alone. It applies to content documents, the NCX, and
234
235
  the OPF alike, and every replaced sequence is counted.
235
236
 
236
- Fifteen of the sixteen are evaluated by the normal `gate`: unlike the lossy strips,
237
+ Fourteen of the sixteen are evaluated by the normal `gate`: unlike the lossy strips,
237
238
  their benefit is visible to epubcheck (they clear errors), so a run with no measurable
238
- improvement is a noop and nothing is applied. The exception is `--fix-cover`, whose
239
- gain is invisible to epubcheck: it is accepted under the same `no_worse` bar the
240
- lossy strips use, with the partial rule intact. CDATA sections are never rewritten
239
+ improvement is a noop and nothing is applied. The two exceptions are `--fix-cover` and
240
+ `--encode-url-spaces`, whose gains sit on axes epubcheck does not measure (cover
241
+ wiring is not a schema finding; the rename half's PKG-010 gain sits on the warning
242
+ axis): both are accepted under the same `no_worse` bar the lossy strips use, with
243
+ the partial rule intact. CDATA sections are never rewritten
241
244
  anywhere; comment bodies only by the double-hyphen fix above, and nothing else inside
242
245
  them.
243
246
 
@@ -445,7 +448,7 @@ Two more advisory analyzers ship in v0.44.0. `cover` reads the package's own cov
445
448
  wiring (the EPUB3 half of the ruled hybrid): a dangling EPUB2 `<meta name="cover">`
446
449
  (the `--fix-cover` repair class), an EPUB3 `properties~="cover-image"` declaration,
447
450
  and whether the named cover file exists. `tocdrift` diffs the two tables of contents
448
- an EPUB 3 book may carry -- the NCX navMap against the nav document -- reporting
451
+ an EPUB 3 book may carry (the NCX navMap against the nav document), reporting
449
452
  entries each side is missing and labels that disagree; it feeds decisions, never a
450
453
  rewrite (ToC synthesis is out of the repair charter permanently). Both never flag a
451
454
  book and never move the exit code.
@@ -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.46.0"
14
+ VERSION = "0.46.1"
15
15
  __version__ = VERSION
16
16
 
17
17
  from .epub import RepairReport, repair_epub # noqa: E402
@@ -1488,7 +1488,7 @@ def _add_repair_flags(p: argparse.ArgumentParser) -> None:
1488
1488
  action="store_true",
1489
1489
  help="replace `--` inside XML comments with an en-dash (RSC-016: "
1490
1490
  "`--` is not permitted within comments); comment bodies only, "
1491
- "text and CDATA never touched",
1491
+ "CDATA never touched",
1492
1492
  )
1493
1493
  p.add_argument(
1494
1494
  "--strip-pagination",
@@ -721,8 +721,11 @@ def fix_comment_double_hyphen(s: str) -> tuple[str, int]:
721
721
  in character data is legal XML), CDATA sections are never touched, and
722
722
  each comment's `-->` terminator is left intact (the span ends there, so
723
723
  the body replacement cannot reach it). Every replaced sequence is
724
- counted. An unclosed comment matches no span and is left for whatever
725
- reports it; the fix claims only the double-hyphen class.
724
+ counted. An unclosed `<!--` runs the span to the first later `-->`, which is
725
+ exactly what an XML parser sees (once the opener never closes, that whole
726
+ region is comment content), so the fix rewrites wherever a parser reads a
727
+ comment; CDATA is protected either way, and the fix claims only the
728
+ double-hyphen class.
726
729
  """
727
730
  count = 0
728
731
 
@@ -413,6 +413,63 @@ class TestPackageVersion(unittest.TestCase):
413
413
  self.assertNotIn("epub3_attrs_stripped", report.fixes)
414
414
 
415
415
 
416
+ class TestVersionGateKeepsFlagsIntact(unittest.TestCase):
417
+ """The package-version gate copies the flags (dataclasses.replace)
418
+ instead of mutating them: one EPUB3 book in a sweep must not switch
419
+ the EPUB2-targeted fixes off for every later book. The in-place
420
+ mutation regression class of 2026-09-08, pinned on the caller's
421
+ object because no report key can see it. Also pins both gated fields
422
+ decoupling: a regression that neutralizes only one of the pair must
423
+ still fail here (the downgrade half on an EPUB3 package)."""
424
+
425
+ EPUB3_OPF = (
426
+ '<?xml version="1.0"?>'
427
+ '<package xmlns="http://www.idpf.org/2007/opf" '
428
+ 'version="3.0" unique-identifier="bookid">'
429
+ "<metadata/><spine/></package>"
430
+ )
431
+ EPUB3_CONTENT = (
432
+ '<?xml version="1.0"?>'
433
+ '<html xmlns="http://www.w3.org/1999/xhtml">'
434
+ '<body epub:type="chapter" aria-label="c">'
435
+ "<figure><figcaption>cap</figcaption></figure><p>x</p></body></html>"
436
+ )
437
+
438
+ def _repair(self, dst):
439
+ src = dst.with_name("in.epub")
440
+ with zipfile.ZipFile(src, "w") as z:
441
+ z.writestr("mimetype", "application/epub+zip")
442
+ z.writestr("OEBPS/content.opf", self.EPUB3_OPF)
443
+ z.writestr("OEBPS/c1.xhtml", self.EPUB3_CONTENT)
444
+ return src
445
+
446
+ def test_gate_does_not_mutate_the_callers_flags(self):
447
+ with tempfile.TemporaryDirectory() as td:
448
+ dst = Path(td) / "out.epub"
449
+ src = self._repair(dst)
450
+ flags = RepairFlags(strip_epub3_attrs=True, downgrade_epub3=True)
451
+ repair_epub(src, dst, flags)
452
+ self.assertTrue(flags.strip_epub3_attrs, "gate mutated the caller's flags")
453
+ self.assertTrue(flags.downgrade_epub3, "gate mutated the caller's flags")
454
+
455
+ def test_both_epub2_fixes_stay_inert_on_an_epub3_package(self):
456
+ # the replace() line neutralizes the pair together; a regression
457
+ # that decouples them (one field replaced, one mutated to respect
458
+ # the caller's True) re-enables the mutated half on EPUB3 books
459
+ with tempfile.TemporaryDirectory() as td:
460
+ dst = Path(td) / "out.epub"
461
+ src = self._repair(dst)
462
+ report = repair_epub(
463
+ src, dst, RepairFlags(strip_epub3_attrs=True, downgrade_epub3=True)
464
+ )
465
+ self.assertNotIn("epub3_attrs_stripped", report.fixes)
466
+ self.assertNotIn("epub3_tags_downgraded", report.fixes)
467
+ with zipfile.ZipFile(dst) as z:
468
+ c_out = z.read("OEBPS/c1.xhtml").decode()
469
+ self.assertIn('epub:type="chapter"', c_out)
470
+ self.assertIn("<figure>", c_out)
471
+
472
+
416
473
  class TestDowngradeEpub3Tags(unittest.TestCase):
417
474
  """The element half of the RSC-005 verdict: EPUB3 semantic elements
418
475
  downgrade to their EPUB2 equivalents, classes kept, CSS-protected names
@@ -0,0 +1,487 @@
1
+ """Every repair flag, driven through the real command line and the real
2
+ repair core (no mocks). The 2026-10-02 test-gap sweep named this gap:
3
+ almost every CLI-level test mocks bindery.cli.repair_epub, so only a
4
+ couple of flags were ever proven to work from argv. Each row here runs
5
+ `bindery repair <fixture> <out> --<flag> --no-validate --json` through
6
+ main() against a minimal book carrying exactly that flag's defect, and
7
+ asserts the flag's own fix key fired in the JSON record.
8
+
9
+ --no-validate is deliberate: the gate itself is pinned by the
10
+ mocked-oracle tests and runs for real wherever epubcheck exists; CI has
11
+ no epubcheck, and this file pins the argv-to-fix path, which is
12
+ flag-independent of the oracle."""
13
+
14
+ import io
15
+ import json
16
+ import tempfile
17
+ import unittest
18
+ import zipfile
19
+ from contextlib import redirect_stdout
20
+ from pathlib import Path
21
+
22
+ from bindery.cli import main
23
+
24
+ try:
25
+ import html5lib # noqa: F401
26
+
27
+ HAVE_HTML5LIB = True
28
+ except ImportError:
29
+ HAVE_HTML5LIB = False
30
+
31
+ CONTAINER = (
32
+ '<?xml version="1.0"?><container version="1.0" '
33
+ 'xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
34
+ '<rootfiles><rootfile full-path="OEBPS/content.opf" '
35
+ 'media-type="application/oebps-package+xml"/></rootfiles></container>'
36
+ )
37
+
38
+ PNG_BYTES = b"\x89PNG\r\n\x1a\n"
39
+
40
+ LONG = (
41
+ "the quick brown fox jumped over the lazy dog and then kept on running for many "
42
+ "miles across the wide green fields under a bright and cloudless summer sky"
43
+ )
44
+
45
+ STUB_DOC = (
46
+ '<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml">'
47
+ "<body>This content is not available.</body></html>"
48
+ )
49
+
50
+
51
+ def _doc(body: str, *, title: str = "t") -> str:
52
+ return (
53
+ '<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml">'
54
+ f"<head><title>{title}</title></head><body>{body}</body></html>"
55
+ )
56
+
57
+
58
+ def _opf(items, refs, *, version: str = "2.0", meta: str = "", guide: str = "") -> str:
59
+ return (
60
+ '<?xml version="1.0"?>'
61
+ '<package xmlns="http://www.idpf.org/2007/opf" '
62
+ f'version="{version}" unique-identifier="id">'
63
+ f'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">{meta}</metadata>'
64
+ f"<manifest>{''.join(items)}</manifest>"
65
+ f"<spine>{''.join(refs)}</spine>{guide}</package>"
66
+ )
67
+
68
+
69
+ def _item(id_: str, href: str, mt: str = "application/xhtml+xml") -> str:
70
+ return f'<item id="{id_}" href="{href}" media-type="{mt}"/>'
71
+
72
+
73
+ def _ref(id_: str) -> str:
74
+ return f'<itemref idref="{id_}"/>'
75
+
76
+
77
+ def _fixtures() -> dict:
78
+ """flag option -> (entries, expected fix key). Each book is minimal and
79
+ carries exactly one flag's defect; the assertion is that the flag's own
80
+ fix key fires through the real core."""
81
+ c1 = _item("c1", "c1.xhtml")
82
+ r1 = _ref("c1")
83
+ plain = _doc("<p>word word word word</p>")
84
+ return {
85
+ "--fix-ids": (
86
+ {
87
+ "OEBPS/content.opf": _opf([_item("1bad", "c1.xhtml")], [_ref("1bad")]),
88
+ "OEBPS/c1.xhtml": plain,
89
+ },
90
+ "fix_manifest_ids",
91
+ ),
92
+ "--add-img-alt": (
93
+ {
94
+ "OEBPS/content.opf": _opf([c1, _item("i", "i.png", "image/png")], [r1]),
95
+ "OEBPS/c1.xhtml": _doc('<p>x</p><img src="i.png"/>'),
96
+ "OEBPS/i.png": PNG_BYTES,
97
+ },
98
+ "img_alt_added",
99
+ ),
100
+ "--reserialize": (
101
+ {
102
+ "OEBPS/content.opf": _opf([c1], [r1]),
103
+ "OEBPS/c1.xhtml": (
104
+ '<html xmlns="http://www.w3.org/1999/xhtml"><body>'
105
+ "<p>one<p>two</body></html>"
106
+ ),
107
+ },
108
+ "reserialized",
109
+ ),
110
+ "--strip-bad-attrs": (
111
+ {
112
+ "OEBPS/content.opf": _opf([c1], [r1]),
113
+ "OEBPS/c1.xhtml": _doc('<img v:shapes="x" src="i.png"/>'),
114
+ },
115
+ "stripped_invalid_attrs",
116
+ ),
117
+ "--escape-unknown-entities": (
118
+ {
119
+ "OEBPS/content.opf": _opf([c1], [r1]),
120
+ "OEBPS/c1.xhtml": _doc("<p>a &foo; b</p>"),
121
+ },
122
+ "escape_unknown_entities",
123
+ ),
124
+ "--fix-empty-body": (
125
+ {
126
+ "OEBPS/content.opf": _opf([c1], [r1]),
127
+ "OEBPS/c1.xhtml": (
128
+ '<html xmlns="http://www.w3.org/1999/xhtml"><body></body></html>'
129
+ ),
130
+ },
131
+ "fix_empty_body",
132
+ ),
133
+ "--fix-missing-title": (
134
+ {
135
+ "OEBPS/content.opf": _opf([c1], [r1]),
136
+ "OEBPS/c1.xhtml": (
137
+ '<html xmlns="http://www.w3.org/1999/xhtml">'
138
+ "<head></head><body><p>x</p></body></html>"
139
+ ),
140
+ },
141
+ "fix_missing_title",
142
+ ),
143
+ "--fix-id-colons": (
144
+ {
145
+ "OEBPS/content.opf": _opf([c1], [r1]),
146
+ "OEBPS/c1.xhtml": _doc('<h2 id="s:1">t</h2><a href="#s:1">go</a>'),
147
+ },
148
+ "fix_id_colons",
149
+ ),
150
+ "--unwrap-block-in-inline": (
151
+ {
152
+ "OEBPS/content.opf": _opf([c1], [r1]),
153
+ "OEBPS/c1.xhtml": _doc('<span><div class="c">block</div></span>'),
154
+ },
155
+ "unwrap_block_in_inline",
156
+ ),
157
+ "--strip-invalid-value": (
158
+ {
159
+ "OEBPS/content.opf": _opf([c1], [r1]),
160
+ "OEBPS/c1.xhtml": _doc('<div value="1">x</div>'),
161
+ },
162
+ "strip_invalid_value",
163
+ ),
164
+ "--unwrap-illegal-tags": (
165
+ {
166
+ "OEBPS/content.opf": _opf([c1], [r1]),
167
+ "OEBPS/c1.xhtml": _doc("<w>hidden</w><p>x</p>"),
168
+ },
169
+ "unwrap_illegal_tags",
170
+ ),
171
+ "--fix-page-map": (
172
+ {
173
+ "OEBPS/content.opf": (
174
+ '<?xml version="1.0"?>'
175
+ '<package xmlns="http://www.idpf.org/2007/opf" '
176
+ 'version="2.0" unique-identifier="id"><metadata/>'
177
+ f"<manifest>{c1}{_item('n', 'toc.ncx', 'application/x-dtbncx+xml')}"
178
+ f'</manifest><spine page-map="pmap" toc="n">{r1}</spine></package>'
179
+ ),
180
+ "OEBPS/c1.xhtml": plain,
181
+ "OEBPS/toc.ncx": (
182
+ '<?xml version="1.0"?>'
183
+ '<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">'
184
+ "<head/><docTitle><text>t</text></docTitle><navMap>"
185
+ '<pageList><navTarget id="pg1" playOrder="1">'
186
+ "<navLabel><text>1</text></navLabel>"
187
+ '<content src="c1.xhtml"/></navTarget></pageList>'
188
+ "</navMap></ncx>"
189
+ ),
190
+ },
191
+ "page_map_stripped",
192
+ ),
193
+ "--strip-epub3-attrs": (
194
+ {
195
+ "OEBPS/content.opf": _opf([c1], [r1]),
196
+ "OEBPS/c1.xhtml": _doc('<p epub:type="chapter">x</p>'),
197
+ },
198
+ "epub3_attrs_stripped",
199
+ ),
200
+ "--downgrade-epub3-tags": (
201
+ {
202
+ "OEBPS/content.opf": _opf([c1], [r1]),
203
+ "OEBPS/c1.xhtml": _doc("<figure><figcaption>cap</figcaption></figure>"),
204
+ },
205
+ "epub3_tags_downgraded",
206
+ ),
207
+ "--prune-missing-resources": (
208
+ {
209
+ "OEBPS/content.opf": _opf([c1, _item("orphan", "absent2.xhtml")], [r1]),
210
+ "OEBPS/c1.xhtml": _doc(
211
+ '<link href="gone.css" rel="stylesheet" type="text/css"/>'
212
+ '<img src="absent.png"/>'
213
+ ),
214
+ },
215
+ "dead_links_pruned",
216
+ ),
217
+ "--strip-broken-anchors": (
218
+ {
219
+ "OEBPS/content.opf": _opf(
220
+ [c1, _item("c2", "c2.xhtml")], [r1, _ref("c2")]
221
+ ),
222
+ "OEBPS/c1.xhtml": _doc('<a href="c2.xhtml#nope">text</a>'),
223
+ "OEBPS/c2.xhtml": _doc("<p>no ids here</p>"),
224
+ },
225
+ "broken_fragment_hrefs_stripped",
226
+ ),
227
+ "--encode-url-spaces": (
228
+ {
229
+ "OEBPS/content.opf": _opf(
230
+ [c1, _item("i", "raw name.png", "image/png")], [r1]
231
+ ),
232
+ "OEBPS/c1.xhtml": _doc('<img src="raw name.png"/>'),
233
+ "OEBPS/raw name.png": PNG_BYTES,
234
+ },
235
+ "entries_renamed",
236
+ ),
237
+ "--fix-container": (
238
+ {
239
+ "META-INF/container.xml": (
240
+ '<?xml version="1.0"?><container version="1.0" '
241
+ 'xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
242
+ '<rootfiles><rootfile full-path="OEBPS/missing.opf" '
243
+ 'media-type="application/oebps-package+xml"/>'
244
+ "</rootfiles></container>"
245
+ ),
246
+ "OEBPS/content.opf": _opf([c1], [r1]),
247
+ "OEBPS/c1.xhtml": plain,
248
+ },
249
+ "container_generated",
250
+ ),
251
+ "--fix-media-types": (
252
+ {
253
+ "OEBPS/content.opf": _opf(
254
+ [c1, _item("i", "i.png", "image/jpeg")], [r1]
255
+ ),
256
+ "OEBPS/c1.xhtml": _doc("<p>x</p>"),
257
+ "OEBPS/i.png": PNG_BYTES,
258
+ },
259
+ "media_types_normalized",
260
+ ),
261
+ "--fix-cover": (
262
+ {
263
+ "OEBPS/content.opf": _opf(
264
+ [c1, _item("cov", "cover.png", "image/jpeg")],
265
+ [r1],
266
+ meta='<meta name="cover" content="nope"/>',
267
+ guide='<guide><reference type="cover" href="cover.png"/></guide>',
268
+ ),
269
+ "OEBPS/c1.xhtml": plain,
270
+ "OEBPS/cover.png": PNG_BYTES,
271
+ },
272
+ "cover_meta_repointed",
273
+ ),
274
+ "--fix-comment-double-hyphen": (
275
+ {
276
+ "OEBPS/content.opf": _opf([c1], [r1]),
277
+ "OEBPS/c1.xhtml": _doc("<!-- a -- b --><p>x</p>"),
278
+ },
279
+ "fix_comment_double_hyphen",
280
+ ),
281
+ "--strip-pagination": (
282
+ {
283
+ "OEBPS/content.opf": _opf([c1], [r1]),
284
+ "OEBPS/c1.xhtml": _doc(
285
+ "".join(
286
+ f"<p>{LONG} and</p><p>{n}</p><p>kept {LONG}.</p>"
287
+ for n in range(1, 25)
288
+ )
289
+ ),
290
+ },
291
+ "stripped_pagination",
292
+ ),
293
+ "--strip-broken-tags": (
294
+ {
295
+ "OEBPS/content.opf": _opf([c1], [r1]),
296
+ "OEBPS/c1.xhtml": _doc("<p>text /div&gt; more</p>"),
297
+ },
298
+ "stripped_broken_tags",
299
+ ),
300
+ "--strip-watermarks": (
301
+ {
302
+ "OEBPS/content.opf": _opf([c1], [r1]),
303
+ "OEBPS/c1.xhtml": plain,
304
+ "oceanofpdf.com": "",
305
+ },
306
+ "dropped_marker",
307
+ ),
308
+ "--strip-stub-docs": (
309
+ {
310
+ "OEBPS/content.opf": _opf(
311
+ [_item(f"s{i}", f"s{i}.xhtml") for i in range(3)]
312
+ + [_item(f"c{i}", f"c{i}.xhtml") for i in range(2)],
313
+ [_ref(f"s{i}") for i in range(3)]
314
+ + [_ref(f"c{i}") for i in range(2)],
315
+ ),
316
+ "OEBPS/s0.xhtml": STUB_DOC,
317
+ "OEBPS/s1.xhtml": STUB_DOC,
318
+ "OEBPS/s2.xhtml": STUB_DOC,
319
+ "OEBPS/c0.xhtml": _doc(
320
+ "<p>Chapter one begins here with plenty of real prose so the "
321
+ "book has honest chapters beside its placeholder pages.</p>"
322
+ ),
323
+ "OEBPS/c1.xhtml": _doc(
324
+ "<p>Chapter two continues the real text with its own words, "
325
+ "long enough that no analyzer would call it a placeholder.</p>"
326
+ ),
327
+ },
328
+ "stub_docs_dropped",
329
+ ),
330
+ }
331
+
332
+
333
+ class FlagEndToEndTests(unittest.TestCase):
334
+ def _fixes_for(self, flag: str) -> dict:
335
+ entries, _ = _fixtures()[flag]
336
+ with tempfile.TemporaryDirectory() as td:
337
+ src = Path(td) / "in.epub"
338
+ dst = Path(td) / "out.epub"
339
+ out = Path(td) / "out.json"
340
+ with zipfile.ZipFile(src, "w") as z:
341
+ z.writestr("mimetype", "application/epub+zip", zipfile.ZIP_STORED)
342
+ if "META-INF/container.xml" not in entries:
343
+ z.writestr("META-INF/container.xml", CONTAINER)
344
+ for entry, data in entries.items():
345
+ z.writestr(entry, data)
346
+ buf = io.StringIO()
347
+ with redirect_stdout(buf):
348
+ rc = main(
349
+ [
350
+ "repair",
351
+ str(src),
352
+ str(dst),
353
+ flag,
354
+ "--no-validate",
355
+ "--json",
356
+ str(out),
357
+ ]
358
+ )
359
+ self.assertEqual(rc, 0, f"{flag}: main() returned {rc}: {buf.getvalue()}")
360
+ self.assertTrue(dst.exists(), f"{flag}: nothing written")
361
+ record = json.loads(out.read_text())
362
+ self.assertEqual(record["status"], "unvalidated", flag)
363
+ return record["fixes"]
364
+
365
+ def test_fix_ids(self):
366
+ self.assertGreaterEqual(self._fixes_for("--fix-ids")["fix_manifest_ids"], 1)
367
+
368
+ def test_add_img_alt(self):
369
+ self.assertGreaterEqual(self._fixes_for("--add-img-alt")["img_alt_added"], 1)
370
+
371
+ @unittest.skipUnless(HAVE_HTML5LIB, "html5lib not installed")
372
+ def test_reserialize(self):
373
+ self.assertGreaterEqual(self._fixes_for("--reserialize")["reserialized"], 1)
374
+
375
+ def test_strip_bad_attrs(self):
376
+ self.assertGreaterEqual(
377
+ self._fixes_for("--strip-bad-attrs")["stripped_invalid_attrs"], 1
378
+ )
379
+
380
+ def test_escape_unknown_entities(self):
381
+ self.assertGreaterEqual(
382
+ self._fixes_for("--escape-unknown-entities")["escape_unknown_entities"], 1
383
+ )
384
+
385
+ def test_fix_empty_body(self):
386
+ self.assertGreaterEqual(
387
+ self._fixes_for("--fix-empty-body")["fix_empty_body"], 1
388
+ )
389
+
390
+ def test_fix_missing_title(self):
391
+ self.assertGreaterEqual(
392
+ self._fixes_for("--fix-missing-title")["fix_missing_title"], 1
393
+ )
394
+
395
+ def test_fix_id_colons(self):
396
+ self.assertGreaterEqual(self._fixes_for("--fix-id-colons")["fix_id_colons"], 1)
397
+
398
+ def test_unwrap_block_in_inline(self):
399
+ self.assertGreaterEqual(
400
+ self._fixes_for("--unwrap-block-in-inline")["unwrap_block_in_inline"], 1
401
+ )
402
+
403
+ def test_strip_invalid_value(self):
404
+ self.assertGreaterEqual(
405
+ self._fixes_for("--strip-invalid-value")["strip_invalid_value"], 1
406
+ )
407
+
408
+ def test_unwrap_illegal_tags(self):
409
+ self.assertGreaterEqual(
410
+ self._fixes_for("--unwrap-illegal-tags")["unwrap_illegal_tags"], 1
411
+ )
412
+
413
+ def test_fix_page_map(self):
414
+ self.assertGreaterEqual(
415
+ self._fixes_for("--fix-page-map")["page_map_stripped"], 1
416
+ )
417
+
418
+ def test_strip_epub3_attrs(self):
419
+ self.assertGreaterEqual(
420
+ self._fixes_for("--strip-epub3-attrs")["epub3_attrs_stripped"], 1
421
+ )
422
+
423
+ def test_downgrade_epub3_tags(self):
424
+ self.assertGreaterEqual(
425
+ self._fixes_for("--downgrade-epub3-tags")["epub3_tags_downgraded"], 1
426
+ )
427
+
428
+ def test_prune_missing_resources(self):
429
+ self.assertGreaterEqual(
430
+ self._fixes_for("--prune-missing-resources")["dead_links_pruned"], 1
431
+ )
432
+
433
+ def test_strip_broken_anchors(self):
434
+ self.assertGreaterEqual(
435
+ self._fixes_for("--strip-broken-anchors")["broken_fragment_hrefs_stripped"],
436
+ 1,
437
+ )
438
+
439
+ def test_encode_url_spaces(self):
440
+ self.assertGreaterEqual(
441
+ self._fixes_for("--encode-url-spaces")["entries_renamed"], 1
442
+ )
443
+
444
+ def test_fix_container(self):
445
+ self.assertGreaterEqual(
446
+ self._fixes_for("--fix-container")["container_generated"], 1
447
+ )
448
+
449
+ def test_fix_media_types(self):
450
+ self.assertGreaterEqual(
451
+ self._fixes_for("--fix-media-types")["media_types_normalized"], 1
452
+ )
453
+
454
+ def test_fix_cover(self):
455
+ self.assertGreaterEqual(
456
+ self._fixes_for("--fix-cover")["cover_meta_repointed"], 1
457
+ )
458
+
459
+ def test_fix_comment_double_hyphen(self):
460
+ self.assertGreaterEqual(
461
+ self._fixes_for("--fix-comment-double-hyphen")["fix_comment_double_hyphen"],
462
+ 1,
463
+ )
464
+
465
+ def test_strip_pagination(self):
466
+ self.assertGreaterEqual(
467
+ self._fixes_for("--strip-pagination")["stripped_pagination"], 20
468
+ )
469
+
470
+ def test_strip_broken_tags(self):
471
+ self.assertGreaterEqual(
472
+ self._fixes_for("--strip-broken-tags")["stripped_broken_tags"], 1
473
+ )
474
+
475
+ def test_strip_watermarks(self):
476
+ self.assertGreaterEqual(
477
+ self._fixes_for("--strip-watermarks")["dropped_marker"], 1
478
+ )
479
+
480
+ def test_strip_stub_docs(self):
481
+ self.assertGreaterEqual(
482
+ self._fixes_for("--strip-stub-docs")["stub_docs_dropped"], 3
483
+ )
484
+
485
+
486
+ if __name__ == "__main__":
487
+ unittest.main()
@@ -0,0 +1,113 @@
1
+ """The RepairFlags wiring invariants (added after the v0.46.0 refactor,
2
+ from the 2026-10-02 test-gap sweep): the flag inventory lives in three
3
+ places that must never drift apart silently -- the RepairFlags dataclass
4
+ fields, the argparse dests in cli._add_repair_flags, and the
5
+ dest-to-field mapping in cli._flags_from_args. Before this module, no
6
+ test failed when a flag was added without a field, a field without a
7
+ flag, or a mapping line was forgotten; a disconnected flag shipped
8
+ silently. The dest-to-field pairs are spelled here on purpose: an
9
+ intentional remap updates both copies, and an accidental one fails
10
+ here."""
11
+
12
+ import unittest
13
+ from argparse import ArgumentParser, _StoreTrueAction
14
+ from dataclasses import fields
15
+ from pathlib import Path
16
+ from unittest import mock
17
+
18
+ from bindery import cli
19
+ from bindery.epub import RepairFlags, RepairReport
20
+
21
+ # argparse dest on the left, RepairFlags field on the right.
22
+ DEST_TO_FIELD = {
23
+ "fix_ids": "fix_ids",
24
+ "add_img_alt": "img_alt",
25
+ "reserialize": "reserialize",
26
+ "strip_bad_attrs": "strip_attrs",
27
+ "escape_unknown_entities": "escape_entities",
28
+ "fix_empty_body": "empty_body",
29
+ "fix_missing_title": "missing_title",
30
+ "fix_id_colons": "id_colons",
31
+ "unwrap_block_in_inline": "block_in_inline",
32
+ "strip_invalid_value": "invalid_value",
33
+ "unwrap_illegal_tags": "illegal_tags",
34
+ "fix_page_map": "page_map",
35
+ "strip_epub3_attrs": "strip_epub3_attrs",
36
+ "downgrade_epub3_tags": "downgrade_epub3",
37
+ "prune_missing_resources": "prune_missing",
38
+ "strip_broken_anchors": "strip_anchors",
39
+ "encode_url_spaces": "url_spaces",
40
+ "fix_container": "fix_container",
41
+ "fix_media_types": "fix_media_types",
42
+ "fix_cover": "fix_cover",
43
+ "fix_comment_double_hyphen": "comment_double_hyphens",
44
+ "strip_pagination": "strip_pagination",
45
+ "strip_broken_tags": "strip_brokentags",
46
+ "strip_watermarks": "strip_watermarks",
47
+ "strip_stub_docs": "strip_stub_docs",
48
+ }
49
+
50
+ # The two store_true dests in _add_repair_flags that are not repair flags.
51
+ NON_SELECTION_DESTS = {"all", "no_validate"}
52
+
53
+
54
+ def _repair_flag_dests() -> set[str]:
55
+ """The dests cli._add_repair_flags defines: every flag it registers is
56
+ a store_true opt-in (the two non-selection dests are filtered by the
57
+ caller). Checked by action class, not the .action attribute: 3.14's
58
+ argparse actions do not carry it."""
59
+ dummy = ArgumentParser(add_help=False)
60
+ cli._add_repair_flags(dummy)
61
+ return {
62
+ a.dest
63
+ for a in dummy._actions
64
+ if isinstance(a, _StoreTrueAction) and a.dest != "help"
65
+ }
66
+
67
+
68
+ class DefaultsTests(unittest.TestCase):
69
+ def test_every_field_defaults_false(self):
70
+ # The all-off instance IS the default pass, and the plugin's bare
71
+ # repair_epub(src, dst) call constructs exactly it: one field
72
+ # defaulting True would ship an ungated fix inside Calibre.
73
+ wrong = [
74
+ f.name
75
+ for f in fields(RepairFlags)
76
+ if getattr(RepairFlags(), f.name) is not False
77
+ ]
78
+ self.assertEqual(wrong, [])
79
+
80
+
81
+ class WiringTests(unittest.TestCase):
82
+ def _flags_for_argv(self, *option_flags: str) -> RepairFlags:
83
+ """The RepairFlags object repair_epub receives for the given argv
84
+ (repair_epub mocked to a falsy report, so process_book stops
85
+ before the epubcheck leg)."""
86
+ args = cli.build_parser().parse_args(["repair", "x.epub", *option_flags])
87
+ with mock.patch(
88
+ "bindery.cli.repair_epub", return_value=RepairReport()
89
+ ) as repair:
90
+ cli.process_book(
91
+ Path("x.epub"),
92
+ Path("."),
93
+ validate=False,
94
+ flags=cli._flags_from_args(args),
95
+ )
96
+ return repair.call_args.kwargs["flags"]
97
+
98
+ def test_dest_inventory_matches_the_dataclass(self):
99
+ self.assertEqual(_repair_flag_dests() - NON_SELECTION_DESTS, set(DEST_TO_FIELD))
100
+
101
+ def test_each_dest_maps_to_its_pinned_field(self):
102
+ for dest, field in sorted(DEST_TO_FIELD.items()):
103
+ flags = self._flags_for_argv(f"--{dest.replace('_', '-')}")
104
+ truthy = [f.name for f in fields(RepairFlags) if getattr(flags, f.name)]
105
+ self.assertEqual(truthy, [field], f"--{dest.replace('_', '-')}")
106
+
107
+ def test_all_enables_every_field(self):
108
+ flags = self._flags_for_argv("--all")
109
+ self.assertTrue(all(getattr(flags, f.name) for f in fields(RepairFlags)))
110
+
111
+
112
+ if __name__ == "__main__":
113
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes