bindery-cli 0.49.0__tar.gz → 0.51.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 (52) hide show
  1. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/AGENTS.md +20 -6
  2. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/PKG-INFO +6 -1
  3. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/README.md +5 -0
  4. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/patchnotes.md +101 -0
  5. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/pyproject.toml +1 -1
  6. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/roadmap.md +2 -0
  7. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/spec.md +42 -6
  8. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/src/bindery/__init__.py +1 -1
  9. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/src/bindery/audit.py +453 -37
  10. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/src/bindery/cli.py +35 -5
  11. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/src/bindery/epub.py +20 -0
  12. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/src/bindery/transforms.py +160 -0
  13. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_cli.py +42 -0
  14. bindery_cli-0.51.0/tests/test_contamination.py +396 -0
  15. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_epub.py +63 -0
  16. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_flags_real_core.py +22 -0
  17. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_flags_wiring.py +1 -0
  18. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_matcher_hardening.py +57 -0
  19. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_transforms.py +184 -0
  20. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/uv.lock +1 -1
  21. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/.github/FUNDING.yml +0 -0
  22. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/.github/workflows/ci.yml +0 -0
  23. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/.github/workflows/publish.yml +0 -0
  24. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/.gitignore +0 -0
  25. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/LICENSE +0 -0
  26. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/docs/screenshots/library-sweep.png +0 -0
  27. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/logo.svg +0 -0
  28. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/plugin/__init__.py +0 -0
  29. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/project.done +0 -0
  30. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/run_tests.sh +0 -0
  31. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/scripts/FastSweep.java +0 -0
  32. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/scripts/build_plugin.py +0 -0
  33. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/scripts/fast_sweep.py +0 -0
  34. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/src/bindery/__main__.py +0 -0
  35. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/src/bindery/library.py +0 -0
  36. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/src/bindery/pagination.py +0 -0
  37. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/src/bindery/reserialize.py +0 -0
  38. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/src/bindery/validate.py +0 -0
  39. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/src/bindery/watermark.py +0 -0
  40. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_audit.py +0 -0
  41. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_comment_hyphens.py +0 -0
  42. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_cover_tocdrift.py +0 -0
  43. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_help.py +0 -0
  44. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_library.py +0 -0
  45. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_pagination.py +0 -0
  46. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_plugin.py +0 -0
  47. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_reserialize.py +0 -0
  48. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_space_renames.py +0 -0
  49. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_validate.py +0 -0
  50. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_version.py +0 -0
  51. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_watermark.py +0 -0
  52. {bindery_cli-0.49.0 → bindery_cli-0.51.0}/tests/test_xml_hardening.py +0 -0
@@ -30,7 +30,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
30
30
  `xmlns`, bare `&`, named entities, void self-closing), the NCX pipeline, and the
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
- visible content. The canonical opt-in inventory lives in spec.md: twenty-one structural
33
+ visible content. The canonical opt-in inventory lives in spec.md: twenty-two structural
34
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`,
@@ -38,7 +38,8 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
38
38
  `--prune-missing-resources`, `--strip-broken-anchors`, `--encode-url-spaces`,
39
39
  `--fix-container`, `--fix-media-types`, `--fix-cover`,
40
40
  `--fix-comment-double-hyphen`, `--fix-svg-dup-ids`, `--fix-cdata-terminator`,
41
- `--fix-misnested-inline`, `--fix-stray-close`, `--fix-unterminated-attr`;
41
+ `--fix-misnested-inline`, `--fix-stray-close`, `--fix-unterminated-attr`,
42
+ `--fix-unbound-prefix`;
42
43
  transforms.py, threaded through epub.py). The v0.47.0 five (the 2026-10-06
43
44
  issue batch): `--fix-svg-dup-ids` renames duplicate glyph ids inside
44
45
  standalone `.svg` entries (first keeps its name, references resolve to the
@@ -60,7 +61,15 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
60
61
  never percent-encoding (epubcheck decodes references before entry lookup,
61
62
  verified against 5.3), ambiguous renames refused per entry (`space_rename_map`),
62
63
  accepted under `no_worse` because PKG-010 sits on the warning axis the gate
63
- does not measure.
64
+ does not measure. `--fix-unbound-prefix` (v0.51.0, issue #9, the Calibre
65
+ embed scar) binds an attribute prefix the OPF never declares inline on the
66
+ element that uses it (`<dc:identifier id="ID1" ns0:scheme="calibre">`
67
+ whose `xmlns:ns0` sits on a sibling, out of XML scope; two RSC-016 fatals
68
+ plus OPF-030): URI evidence is the same prefix declared elsewhere in the
69
+ document (all declarations must agree) or the OPF namespace for a DC
70
+ element under an OPF-namespaced package, anything else refuses the whole
71
+ document; scope-tracked over a comment/CDATA-aware tokenizer, OPF only
72
+ (content docs have the `--strip-bad-attrs` drop); normal `gate`.
64
73
  They alter markup structure or fabricate minimal content. v0.14–v0.16 ran these unconditionally, which broke this rule;
65
74
  v0.17.0 restored it. `--unwrap-illegal-tags` additionally protects any illegal-tag
66
75
  name that an EPUB stylesheet styles as an element selector (`css_protected_tags`,
@@ -101,7 +110,12 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
101
110
  gain IS visible, so a run with no measurable improvement is a noop and nothing is
102
111
  applied) except the two whose gains sit on axes epubcheck does not measure:
103
112
  `--fix-cover` and `--encode-url-spaces`, accepted under the same `no_worse` bar with
104
- the partial rule intact. Changing either bar means re-running the library dry run.
113
+ the partial rule intact. One masked-axis rule (v0.51.0, issue #9): when the gate
114
+ answers noop but `report.ncx_uid_synced` fired on a book that started with fatals,
115
+ `process_book` reclassifies the verdict to `partial` (a fatal package halts the
116
+ metadata checks, so the dtb:uid comparison never ran in either measurement and the
117
+ real sync measures as no gain); application behavior is unchanged, since still-fatal
118
+ books are never applied. Changing either bar means re-running the library dry run.
105
119
  - **Library writes are sacred.** Replacement must stay atomic (temp in same dir, then
106
120
  `os.replace`), touch only the `.epub`, preserve mode, and be dry-run by default.
107
121
  Calibre format installation goes through cquarry's write module (`--install-to-calibre`;
@@ -114,7 +128,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
114
128
  - `src/bindery/pagination.py`: the opt-in lossy page-number strip (runhead detection, page-layer decision, block-centric removal/merge, safety nets).
115
129
  - `src/bindery/watermark.py`: the opt-in lossy watermark strip (anchored and anchorless signature removal).
116
130
  - `src/bindery/reserialize.py`: structural repair via `html5lib`.
117
- - `src/bindery/audit.py`: read-only body-text audits (`content`, `pagenumbers`, `emptytext`, `ocr`, `monolithic` since v0.21.0 with `--max-doc-chars N`, and `completeness` since v0.36.0) behind the `audit` subcommand (v0.15.0). Since v0.18.0 library mode resolves EPUB paths through `cquarry.get_format_path()` and can apply a tag to flagged books via `cquarry.write.WritableCalibreDB` (only with explicit `--tag`; the only sanctioned write path). Since v0.19.0 `audit --id BOOK_ID` audits a single library book through cquarry's single-entity `get_book()` fetch (no library-wide cache; supports `--tag`; incompatible with the directory argument; comma lists since v0.23.0). Since v0.22.0 every archive entry is fully read (CRC + decompression) before analysis (a damaged entry reports its own CORRUPT verdict instead of feeding emptytext) and manifest/NCX references to absent files classify as `convention` (bloated ToC, consecutive span) or `fragment` (broken span). Hazard: the scan loop reuses `tag` as its per-book display column, so the `--tag` argument is captured as `audit_tag` at function entry; do not collapse them again. Since v0.29.0 `audit --json FILE` writes per-file analyzer verdicts in the `library --json` shape (directory, library, and single-book modes; with `--id`, exactly one id; emptytext omitted from a record when the archive verdict owns the body-text story). Since v0.44.0 two more advisory analyzers ride the single pass: `cover` (the EPUB3 `properties~="cover-image"` slice of the ruled hybrid: dangling EPUB2 meta, EPUB3 declaration, cover-file presence) and `tocdrift` (the NCX navMap vs EPUB3 nav toc structural diff, fragments included; audit-only, ToC synthesis out of charter permanently); the loader now exposes the parsed OPF, the NCX text, and the nav HTML on `Book`. Since v0.36.0 the completeness analyzer (Phase 15) reports the phase-1 spot-check per book: spine/prose-doc counts (prose = >= 400 visible chars), first/middle/last prose-doc opening/closing excerpts, trailing-ToC classification (`_trailing_toc`: short link lines dominate, no paragraph-length blocks), and the unreadable fraction. It is ADVISORY by contract (problem always False; ADVISORY on a trailing ToC or unreadable fraction >= 10%), never moves the exit code, and is skipped like emptytext when the archive verdict owns the book. Since v0.38.0 the archive verdict distinguishes font obfuscation from DRM (`OBFUSCATION_ALGOS`: IDPF 2008/embedding + both Adobe URIs; readable obfuscated entries are the benign OBFUSCATED advisory, an unreadable one is CORRUPT, and only non-obfuscation algorithms give ENCRYPTED). Since v0.43.0 untrusted metadata XML is parsed through `_safe_xml_parse` (DOCTYPE/ENTITY declarations refused before xml.etree sees them; the stdlib shape of defusedxml's entity protection) and single entries above `_MAX_ENTRY_BYTES` (32 MB) are refused before decompression; a refused metadata file returns the shell book. Since v0.40.0 the vir-tui console renderer loads through the module-level `_LazyUI` proxy (a plain `import vir_tui as ui` at module level would make 3.12/3.13 installs unimportable now that the stack is marker-gated; LOAD_GLOBAL never consults module `__getattr__`, so a proxy object is the lazy shape that works).
131
+ - `src/bindery/audit.py`: read-only body-text audits (`content`, `pagenumbers`, `emptytext`, `ocr`, `monolithic` since v0.21.0 with `--max-doc-chars N`, and `completeness` since v0.36.0) behind the `audit` subcommand (v0.15.0). Since v0.18.0 library mode resolves EPUB paths through `cquarry.get_format_path()` and can apply a tag to flagged books via `cquarry.write.WritableCalibreDB` (only with explicit `--tag`; the only sanctioned write path). Since v0.19.0 `audit --id BOOK_ID` audits a single library book through cquarry's single-entity `get_book()` fetch (no library-wide cache; supports `--tag`; incompatible with the directory argument; comma lists since v0.23.0). Since v0.22.0 every archive entry is fully read (CRC + decompression) before analysis (a damaged entry reports its own CORRUPT verdict instead of feeding emptytext) and manifest/NCX references to absent files classify as `convention` (bloated ToC, consecutive span) or `fragment` (broken span). Hazard: the scan loop reuses `tag` as its per-book display column, so the `--tag` argument is captured as `audit_tag` at function entry; do not collapse them again. Since v0.29.0 `audit --json FILE` writes per-file analyzer verdicts in the `library --json` shape (directory, library, and single-book modes; with `--id`, exactly one id; emptytext omitted from a record when the archive verdict owns the body-text story). Since v0.44.0 two more advisory analyzers ride the single pass: `cover` (the EPUB3 `properties~="cover-image"` slice of the ruled hybrid: dangling EPUB2 meta, EPUB3 declaration, cover-file presence) and `tocdrift` (the NCX navMap vs EPUB3 nav toc structural diff, fragments included; audit-only, ToC synthesis out of charter permanently); the loader now exposes the parsed OPF, the NCX text, and the nav HTML on `Book`. Since v0.50.0 a ninth analyzer, `contamination` (issue #8, the judge-and-import class, advisory by contract like completeness/cover/tocdrift, never moves the exit code): foreign-title template stamps (head `<title>` repeats or a volume/book marker with named words in front of it, judged against the OPF's own title/creator/publisher/series with a marker-stripped comparison; a part label whose marker leads never flags), fan-bundle ad signatures (jnovels, kokoro connect, zerobooks, a light-novel plug), and review-paste fingerprints (strong flags alone, weak needs corroboration) in the first five content docs; its corpus is spine PLUS `Book.extra_docs` (the archive's non-spine html, retained by the loader in archive order under a 64 MiB cap, because a broken manifest can leave the junk entirely off the spine: Hothouse). Since v0.50.0 `run_directory`/`run_single` dispatch analyzers through `_dir_dispatch` (one entry per ALL key; the old if/elif chain's `else` ran ocr for cover/tocdrift in those modes since v0.44.0) and `tests/test_contamination.py` pins the map's key set to ALL. Since v0.36.0 the completeness analyzer (Phase 15) reports the phase-1 spot-check per book: spine/prose-doc counts (prose = >= 400 visible chars), first/middle/last prose-doc opening/closing excerpts, trailing-ToC classification (`_trailing_toc`: short link lines dominate, no paragraph-length blocks), and the unreadable fraction. It is ADVISORY by contract (problem always False; ADVISORY on a trailing ToC or unreadable fraction >= 10%), never moves the exit code, and is skipped like emptytext when the archive verdict owns the book. Since v0.38.0 the archive verdict distinguishes font obfuscation from DRM (`OBFUSCATION_ALGOS`: IDPF 2008/embedding + both Adobe URIs; readable obfuscated entries are the benign OBFUSCATED advisory, an unreadable one is CORRUPT, and only non-obfuscation algorithms give ENCRYPTED). Since v0.43.0 untrusted metadata XML is parsed through `_safe_xml_parse` (DOCTYPE/ENTITY declarations refused before xml.etree sees them; the stdlib shape of defusedxml's entity protection) and single entries above `_MAX_ENTRY_BYTES` (32 MB) are refused before decompression; a refused metadata file returns the shell book. Since v0.40.0 the vir-tui console renderer loads through the module-level `_LazyUI` proxy (a plain `import vir_tui as ui` at module level would make 3.12/3.13 installs unimportable now that the stack is marker-gated; LOAD_GLOBAL never consults module `__getattr__`, so a proxy object is the lazy shape that works).
118
132
  - `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.46.0 the opt-in selection is the `RepairFlags` dataclass (one field per flag, all off by default; the all-off instance IS the default pass): `repair_epub(src, dst, flags)` and `process_book(..., flags)` take one object instead of a ~25-kwarg signature, the EPUB2-targeted fixes' version gate swaps in a `dataclasses.replace` copy (never mutating the caller's instance), and the plugin's bare `repair_epub(src, dst)` call constructs exactly the all-off default pass. 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).
119
133
  - `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
120
134
  - `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.
@@ -122,7 +136,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
122
136
  custom-rendered and colors itself with the CPython 3.14 argparse theme,
123
137
  gated by argparse's own _colorize detection so the levels always agree) and the repair-flag inventory is one table: `_REPAIR_FLAG_TABLE` (dest + one-line short help + long reference text) registers the compact grouped `--help` (argparse argument groups, hand-short usage line) and `--help-repairs` renders the long descriptions (a custom argparse Action, deliberately NOT store_true so the wiring test's selection count ignores it); adding a flag means adding a table row. Since v0.48.0 the metadata.db doors are enforced: `_require_closed_calibre` (pgrep fail-closed, exit 1) guards `audit --tag` and `library --apply --install-to-calibre` (run phase3 transitively), and `--help-json` (a custom argparse Action like --help-repairs, never colored) dumps the per-verb exit contract and the door list; since v0.48.1 the per-verb exit contracts tell the code's truth (repair: 0 probed/written incl. partial / 1 trouble, never 2; audit: 0/1/2 plus 3 for findings+write-failure; library and the run slices: the unified 0/2/1); since v0.49.0 the single-file verb's no-output form is a probe (the gate runs, the record's `output` is null and `applied` false, nothing is written anywhere; the old implicit `(repaired).epub` sibling was issue #7's import litter in a vetted tree, and writing now needs an explicit DST). `library --workers N` (v0.30.0) parallelizes only the sweep's candidate pass, in windows of N consumed in order so `--limit` stays lazy; the repair phase is never parallel (shared workdir, atomic-replacement contract). Since v0.40.0 `main()` guards the VirInvictus stack: a `ModuleNotFoundError` naming `vir_tui`/`cquarry` (marker-gated to 3.14+ on the 3.12 floor) becomes one stderr line and trouble exit 2 instead of a traceback; anything else missing still raises.
124
138
  - `plugin/__init__.py` + `scripts/build_plugin.py`: the Bindery Repair Calibre plugin (v0.37.0; identity `Bindery Repair` / `bindery_repair`, approved and built per the scoped spec in roadmap.md Phase 3). The builder vendors `transforms.py`, `epub.py`, `pagination.py`, `watermark.py`, `reserialize.py` byte-identically into the zip (the zip root is a package, so their relative imports resolve unchanged; the suite's drift test pins the equality) with the version tuple substituted from the single-source VERSION; `publish.yml` attaches `BinderyRepair-v<VERSION>.zip` to each release. The plugin runs ONLY the default pass (epubcheck cannot gate inside Calibre), never raises, is byte-idempotent (zero fixes returns the original path), testzip()-verifies its output, logs one line per book, and takes JSON config via `site_customization` (`log`, `log_path`, `max_size_mb` default 150, `max_log_mb` rotation cap default 2, `epubcheck_path` experimental validation default OFF). Load it in tests through the `tests/test_plugin.py` stubs: the calibre namespace is installed in sys.modules and the package loads with a two-dir `__path__` (plugin dir first, then src/bindery) exactly like calibre's zipplugin loader shape. **Plugin/interpreter compatibility (v0.40.0):** v0.39.0 shipped PEP 758 unparenthesized except tuples, and the plugin died with a bare SyntaxError on every released Calibre (7.x and the whole 8.x series embed Python 3.11, 9.0+ embeds 3.14; from calibre's bypy sources.json, checked against the upstream clone). The core is now grammar-pinned: parenthesized except tuples everywhere, `ruff`'s `target-version = "py312"` pin (inference from requires-python would rewrite the parens back off), `tests/test_version.py`'s parse guard, and CI's `plugin-compat` job byte-compiling the built zip under 3.11/3.12/3.13/3.14. `minimum_calibre_version` is (7, 0, 0): the oldest series whose interpreter CI covers, replacing the hollow (2, 0, 0); `supported_platforms` stays `["linux"]` (developed and tested on Linux only). Any new syntax in the vendored modules must clear the guard before it ships.
125
- - `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.
139
+ - `tests/`: transforms, end-to-end repair, atomic replace, pagination, watermarks, the audit analyzers (content/pagenumbers/emptytext/ocr/monolithic/completeness, archive integrity, spine classification, contamination since v0.50.0), 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). `tests/test_contamination.py` (v0.50.0) carries the three contamination sweeps, the advisory contract, the `_dir_dispatch` key pin, and the cover-routing regression. The v0.46.0 trio (test_flags_wiring, test_comment_hyphens, test_flags_real_core) rides 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 (audit-side test modules like test_contamination stay on the 3.14 leg by convention).
126
140
 
127
141
  ## Conventions
128
142
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindery-cli
3
- Version: 0.49.0
3
+ Version: 0.51.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
@@ -242,12 +242,17 @@ bindery audit completeness ~/docs/Calibre\ Library
242
242
  bindery audit cover ~/docs/Calibre\ Library
243
243
  bindery audit tocdrift ~/docs/Calibre\ Library
244
244
 
245
+ # Fan-bundle contamination: foreign-title template stamps, ad pages, review paste
246
+ bindery audit contamination ~/docs/Calibre\ Library
247
+
245
248
  # Run all audits and report to the console (--json FILE for machine output)
246
249
  cd ~/docs/Calibre\ Library && bindery audit all
247
250
  ```
248
251
 
249
252
  **Completeness spot-check:** the `completeness` audit answers the pre-import question "does this book reach its end?" It reports, per book: the prose-document count against the spine (docs with 400+ visible characters), the opening and closing text of the first/middle/last prose doc, a trailing-ToC classification of the final spine doc (a link-dense chapter list after the real back matter), and the fraction of unreadable docs. A trailing ToC is reported as an advisory, never a failure: the closing excerpt always comes from the real back matter, so a human can confirm the book ends where the author ended it.
250
253
 
254
+ **Contamination scan:** the `contamination` audit is advisory too: fan-bundle cosmetics are the judge-and-import class. Three sweeps over the archive's content documents (spine plus the non-spine html a broken manifest can strand): another series' name where the book's own identity belongs, judged against the package's own metadata (the head-`<title>` template stamps a sloppy fan bundle leaves on every doc, a volume marker with named words in front of it, a repeated foreign running header); known fan-bundle ad signatures (jnovels, kokoro connect, zerobooks, light-novel download plugs); and pasted review-site text in the first few content docs (helpful counts, "was this review helpful", "out of 5 stars", verified purchase). Every finding is a report a human judges, never a failure.
255
+
251
256
  Audits can also be run on a directory of loose `.epub` files by passing the path as the second argument:
252
257
  ```sh
253
258
  bindery audit pagenumbers /path/to/loose/epubs
@@ -215,12 +215,17 @@ bindery audit completeness ~/docs/Calibre\ Library
215
215
  bindery audit cover ~/docs/Calibre\ Library
216
216
  bindery audit tocdrift ~/docs/Calibre\ Library
217
217
 
218
+ # Fan-bundle contamination: foreign-title template stamps, ad pages, review paste
219
+ bindery audit contamination ~/docs/Calibre\ Library
220
+
218
221
  # Run all audits and report to the console (--json FILE for machine output)
219
222
  cd ~/docs/Calibre\ Library && bindery audit all
220
223
  ```
221
224
 
222
225
  **Completeness spot-check:** the `completeness` audit answers the pre-import question "does this book reach its end?" It reports, per book: the prose-document count against the spine (docs with 400+ visible characters), the opening and closing text of the first/middle/last prose doc, a trailing-ToC classification of the final spine doc (a link-dense chapter list after the real back matter), and the fraction of unreadable docs. A trailing ToC is reported as an advisory, never a failure: the closing excerpt always comes from the real back matter, so a human can confirm the book ends where the author ended it.
223
226
 
227
+ **Contamination scan:** the `contamination` audit is advisory too: fan-bundle cosmetics are the judge-and-import class. Three sweeps over the archive's content documents (spine plus the non-spine html a broken manifest can strand): another series' name where the book's own identity belongs, judged against the package's own metadata (the head-`<title>` template stamps a sloppy fan bundle leaves on every doc, a volume marker with named words in front of it, a repeated foreign running header); known fan-bundle ad signatures (jnovels, kokoro connect, zerobooks, light-novel download plugs); and pasted review-site text in the first few content docs (helpful counts, "was this review helpful", "out of 5 stars", verified purchase). Every finding is a report a human judges, never a failure.
228
+
224
229
  Audits can also be run on a directory of loose `.epub` files by passing the path as the second argument:
225
230
  ```sh
226
231
  bindery audit pagenumbers /path/to/loose/epubs
@@ -1,5 +1,106 @@
1
1
  # bindery-cli Patch Notes
2
2
 
3
+ ## v0.51.0 (2026-10-17)
4
+
5
+ ### The unbound-prefix repair: the Calibre embed scar comes off the OPF (issue #9)
6
+
7
+ Century Rain (library book #11104) carried an OPF that repeated
8
+ `calibredb embed_metadata` rounds had scarred: one `dc:identifier`, the package
9
+ unique-identifier itself, carried an `ns0:scheme` attribute whose `xmlns:ns0`
10
+ declaration sat on a sibling element, out of XML scope. epubcheck answered with
11
+ two RSC-016 fatals (unbound prefix) plus OPF-030 (unique-identifier not found),
12
+ and because a fatal package halts the metadata checks, the stale NCX `dtb:uid`
13
+ was invisible to the oracle on both sides of the gate. The old probe report said
14
+ it all: `1f/1e/0w -> 1f/1e/0w, ncx_uid_synced (no measurable gain)`, the ncx
15
+ fix refused with the OPF half.
16
+
17
+ - **`--fix-unbound-prefix`** (new structural repair, twenty-second) binds an
18
+ attribute prefix the OPF never declares inline on the element that uses it,
19
+ the exact shape the file's own healthy siblings already use. The URI is
20
+ evidence-based, never guessed: the same prefix declared elsewhere in the
21
+ document (every declaration must agree, or the document is refused), or the
22
+ OPF namespace for a Dublin Core element under an OPF-namespaced package (the
23
+ canonical EPUB 2 `opf:scheme` shape); anything else refuses the whole
24
+ document untouched. Scope tracking walks the document with a
25
+ comment/CDATA-aware tokenizer over a lenient element stack, so declarations
26
+ that genuinely cover a use satisfy it without an edit and clean documents
27
+ are byte-identical. OPF only: content documents already have the
28
+ invalid-attribute drop (`--strip-bad-attrs`) for this defect class. Normal
29
+ `gate` (clearing the fatal is the measurable gain). On the fixture:
30
+ `1f/1e/0w -> 0f/0e/0w`, epubcheck fully clean.
31
+ - **The masked ncx gain** (the issue's second half) stops lying in reports:
32
+ when the gate answers noop but the NCX uid sync actually fired on a book
33
+ that started with fatals, `process_book` reclassifies the verdict to
34
+ `partial` instead of `equal`/`no measurable gain`. The sync is real,
35
+ deterministic work the oracle could not see; the still-fatal rule is intact,
36
+ so the library sweep still refuses to apply such a book and the single-file
37
+ verb still writes it under the honest PARTIAL label when given an explicit
38
+ destination.
39
+
40
+ Twenty-seven tests joined the suite (691 total): the transform unit tests
41
+ (scope, evidence, refusal, idempotence), the archive-level e2e, the masked-gain
42
+ verdict rules, tokenizer spin guards beside the other matchers, and the argv
43
+ wiring rows.
44
+
45
+ ## v0.50.0 (2026-10-14)
46
+
47
+ ### The contamination analyzer: fan-bundle cosmetics get reported (issue #8)
48
+
49
+ Three fixtures from the 2026-10-14 phase-1 wave slipped past the content
50
+ analyzer's ad-notice detection (the importknig class): two Grandmaster of
51
+ Demonic Cultivation volumes packaged by a sloppy fan bundle, carrying
52
+ "Full Metal Panic! Volume 5" in every content document's head `<title>` (the
53
+ bundle template's original book) plus a whole Jnovels/Zerobooks ad page whose
54
+ own head title names a third series; and Hothouse, whose front matter is
55
+ pasted Amazon review text ("3 of 4 people found the following review
56
+ helpful"). All three were approved for import regardless, so the new
57
+ detection is advisory by contract, like completeness/cover/tocdrift: it
58
+ never flags a book and never moves an exit code. The disposition for this
59
+ class is judge-and-import.
60
+
61
+ - **`bindery audit contamination`** (new analyzer, ninth; rides in `all`)
62
+ runs three sweeps over the archive's content documents:
63
+ - *foreign titles*: a title or series named where the book's own identity
64
+ belongs, judged against the package's own metadata (dc:title, creator,
65
+ publisher, series). Two triggers, both tuned on the fixtures: a
66
+ volume/book marker with named words in front of it ("Full Metal Panic!
67
+ Volume 5"; a part label whose marker leads, "BOOK ONE: THE RING SETS
68
+ OUT", never flags), or a head `<title>` repeated across the book. The
69
+ head-`<title>` channel matters because the MDZS stamps are invisible to
70
+ rendered text; a marker-stripped own-name comparison keeps a book's own
71
+ running headers clean when the metadata spells the volume differently.
72
+ - *ad signatures*: jnovels, kokoro connect, zerobooks, and a generic
73
+ light-novel download plug, in visible body text.
74
+ - *review paste*: review-site fingerprints (helpful counts, "was this
75
+ review helpful", "out of 5 stars", verified purchase) in the first five
76
+ content docs; a strong fingerprint flags a doc alone, weak ones
77
+ (reviewer bylines, star rows) need corroboration.
78
+ - **The corpus is the archive's content documents, not just the spine.**
79
+ Hothouse's manifest is broken: 52 of its 53 content docs sit in no
80
+ manifest item, the spine resolves to one document, and a spine-only sweep
81
+ would have read the book clean. The loader now retains the archive's
82
+ non-spine html on `Book.extra_docs` (archive order, 64 MiB retention cap;
83
+ a pathological archive stops feeding the corpus there and stays the
84
+ corruption/completeness verdicts' business), and the contamination sweeps
85
+ run over spine plus extras.
86
+ - **Directory and single-book mode actually run cover and tocdrift now.**
87
+ `run_directory`/`run_single` dispatched analyzers through an if/elif chain
88
+ whose terminal `else` ran the ocr analyzer, so since v0.44.0 an
89
+ `audit all` over a directory (or `audit all --id`) reported ocr verdicts
90
+ under the cover and tocdrift keys and never analyzed cover wiring or ToC
91
+ drift. Library mode was unaffected. Both modes now share a dispatch map
92
+ keyed on the analyzer names (`_dir_dispatch`), a missing key is a loud
93
+ KeyError instead of a mislabeled verdict, and `tests/test_contamination.py`
94
+ pins the map's key set to the analyzer list. Advisory analyzers never
95
+ answer problem, so `audit all` and multi-analyzer runs cannot change their
96
+ verdicts; the one behavioral corner is a single-key `audit cover DIR` (or
97
+ `--id`) run, which at HEAD actually executed the ocr analyzer and could
98
+ exit 1 on an ocr-damaged book under the cover key, and now runs cover
99
+ wiring and exits on cover's own (always-advisory) verdict.
100
+ - CalibreQuarry-style `--json` consumers see the new analyzer's verdicts as
101
+ an additive `contamination` key (problem always false); nothing else in
102
+ the audit record shape moved.
103
+
3
104
  ## v0.49.0 (2026-10-13)
4
105
 
5
106
  ### The single-file repair probe writes nothing (issue #7)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.49.0"
7
+ version = "0.51.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 = [
@@ -43,6 +43,8 @@ The plugin vendors the core and never gates (the epubcheck latency ruling).
43
43
  | 0.47 | The intake-wave batch (five open issues): the stub classifier's body-pool + image-carrier exemption, the NCName-complete `--fix-ids`, and `--fix-svg-dup-ids`, `--fix-cdata-terminator`, `--fix-misnested-inline`, `--fix-stray-close`, `--fix-unterminated-attr` | v0.47.0 |
44
44
  | 0.48 | The AI-probe batch: the enforced closed-Calibre gate on both metadata.db doors (`audit --tag`, `library --apply --install-to-calibre`), the stated contracts (exit codes, no-prompt, NCX-001, --id scoping, json envelope keys), and `--help-json` | v0.48.0 |
45
45
  | 0.49 | The probe form (issue #7): single-file repair without an output path runs the gate and writes nothing; writing needs an explicit DST | v0.49.0 |
46
+ | 0.50 | The contamination analyzer (issue #8): foreign-title template stamps, fan-bundle ad signatures, review-paste front matter; advisory by contract, archive-wide corpus (`Book.extra_docs`, capped), and the `_dir_dispatch` map that fixes the v0.44.0 cover/tocdrift ocr misroute in directory/single mode | v0.50.0 |
47
+ | 0.51 | The unbound-prefix repair (issue #9): `--fix-unbound-prefix` binds the Calibre embed scar's undeclared attribute prefix inline (evidence-based URI, scope-tracked, refuse over guess), plus the masked-ncx-gain rule that stops a real dtb:uid sync reading as no measurable gain under a fatal package | v0.51.0 |
46
48
 
47
49
  ## Open work
48
50
 
@@ -13,14 +13,14 @@ restyle, re-compress, or restructure content, and it does not attempt to fix arb
13
13
  schema (RSC-005) violations, which are usually harmless to readers and not safely
14
14
  mechanizable.
15
15
 
16
- The deliberate exceptions to "semantics-preserving" come in opt-in groups. The twenty-one
16
+ The deliberate exceptions to "semantics-preserving" come in opt-in groups. The twenty-two
17
17
  **structural repairs** (`--fix-empty-body`, `--fix-missing-title`, `--fix-id-colons`,
18
18
  `--fix-page-map`, `--strip-epub3-attrs`, `--downgrade-epub3-tags`,
19
19
  `--unwrap-block-in-inline`, `--strip-invalid-value`, `--unwrap-illegal-tags`,
20
20
  `--prune-missing-resources`, `--strip-broken-anchors`, `--encode-url-spaces`,
21
21
  `--fix-container`, `--fix-media-types`, `--fix-cover`, `--fix-comment-double-hyphen`,
22
22
  `--fix-svg-dup-ids`, `--fix-cdata-terminator`, `--fix-misnested-inline`,
23
- `--fix-stray-close`, `--fix-unterminated-attr`) alter
23
+ `--fix-stray-close`, `--fix-unterminated-attr`, `--fix-unbound-prefix`) alter
24
24
  markup structure or fabricate minimal content; the four
25
25
  **lossy modes** (`--strip-pagination`, `--strip-broken-tags`, `--strip-watermarks`,
26
26
  `--strip-stub-docs`) remove
@@ -280,8 +280,22 @@ use, with the `partial` rule intact.
280
280
  quote before the next `<` (a real multi-line value closes quote-first), and only
281
281
  double-quoted values are judged. It runs before the other tag-shape readers, which an
282
282
  open value otherwise lies to.
283
-
284
- Nineteen of the twenty-one are evaluated by the normal `gate`: unlike the lossy strips,
283
+ - **`--fix-unbound-prefix`**: bind an attribute prefix the OPF never declares inline on
284
+ the element that uses it (the Calibre embed scar, issue #9: one dc: element carries
285
+ `ns0:scheme` while the `xmlns:ns0` declaration sits on a sibling, out of XML scope;
286
+ the RSC-016 "prefix is not bound" fatals make the whole package unparseable, which
287
+ also costs OPF-030 on the unique-identifier and masks the NCX-001 comparison). The
288
+ declaration is inserted on the user, the shape the file's own healthy siblings
289
+ already use. The URI is evidence-based, never guessed: the same prefix declared
290
+ elsewhere in the document with every declaration agreeing, or the OPF namespace for
291
+ a Dublin Core element under an OPF-namespaced package (the canonical EPUB 2
292
+ `opf:scheme` shape); anything else refuses the document untouched. Scope is tracked
293
+ with a lenient element stack over a comment/CDATA-aware tokenizer, so declarations
294
+ that genuinely cover a use satisfy it without an edit and clean documents are
295
+ byte-identical. Runs on the OPF only: content documents have the
296
+ invalid-attribute drop (`--strip-bad-attrs`) for the same defect class.
297
+
298
+ Twenty of the twenty-two are evaluated by the normal `gate`: unlike the lossy strips,
285
299
  their benefit is visible to epubcheck (they clear errors), so a run with no measurable
286
300
  improvement is a noop and nothing is applied. The two exceptions are `--fix-cover` and
287
301
  `--encode-url-spaces`, whose gains sit on axes epubcheck does not measure (cover
@@ -451,8 +465,8 @@ This door is lock-class: `--install-to-calibre` is REFUSED with exit 1 while Cal
451
465
 
452
466
  ## Audit subcommand (read-only)
453
467
 
454
- `bindery audit {content,pagenumbers,emptytext,ocr,monolithic,completeness,cover,tocdrift,all} [PATH] [--max-doc-chars N]
455
- [--tag TAG] [--id IDs]` (v0.15.0, `audit.py`; `--tag` since v0.18.0; `monolithic` since v0.21.0, `--max-doc-chars N`; `--id` since v0.19.0, comma-lists in v0.23.0; `completeness` since v0.36.0; `cover` and `tocdrift` since v0.44.0) inspects
468
+ `bindery audit {content,pagenumbers,emptytext,ocr,monolithic,completeness,cover,tocdrift,contamination,all} [PATH] [--max-doc-chars N]
469
+ [--tag TAG] [--id IDs]` (v0.15.0, `audit.py`; `--tag` since v0.18.0; `monolithic` since v0.21.0, `--max-doc-chars N`; `--id` since v0.19.0, comma-lists in v0.23.0; `completeness` since v0.36.0; `cover` and `tocdrift` since v0.44.0; `contamination` since v0.50.0) inspects
456
470
  EPUB body text for flaws epubcheck cannot see: non-English script blocks, baked-in page-number
457
471
  layers (sliding-window density heuristics), empty or thin books, systemic OCR damage, and
458
472
  single oversized content documents (one spine doc at or above 300k characters: readers refuse
@@ -477,6 +491,28 @@ entries each side is missing and labels that disagree; it feeds decisions, never
477
491
  rewrite (ToC synthesis is out of the repair charter permanently). Both never flag a
478
492
  book and never move the exit code.
479
493
 
494
+ The contamination analyzer (v0.50.0, issue #8) is advisory by the same contract:
495
+ fan-bundle cosmetics are the judge-and-import class, so it never flags a book and
496
+ never moves an exit code. Three sweeps run over the archive's content documents
497
+ (the spine plus the non-spine html the loader retains on `Book.extra_docs`,
498
+ archive order, 64 MiB retention cap): *foreign titles* name a title or series that
499
+ matches none of the package's own metadata (dc:title, creator, publisher, series),
500
+ flagged either by a volume/book marker with named words in front of it or by a
501
+ head `<title>` repeated across the book (a part label whose marker leads, "BOOK
502
+ ONE: ...", never flags; the comparison also runs on the volume-marker-stripped
503
+ form so a book's own running headers stay clean); *ad signatures* (jnovels,
504
+ kokoro connect, zerobooks, a generic light-novel download plug) are searched in
505
+ visible body text, with a signature that matches the book's own metadata
506
+ skipped outright (a legitimate Kokoro Connect volume names its own series on
507
+ every doc); *review paste* (helpful counts, review-helpful prompts,
508
+ "out of 5 stars", verified purchase, with reviewer bylines and star rows as
509
+ corroborating-only weak fingerprints) is scoped to the first five content docs.
510
+ The archive-wide corpus exists because fan-bundle junk is package furniture a
511
+ broken manifest can leave entirely off the spine (Hothouse: 52 of 53 content
512
+ docs in no manifest item, a resolved spine of one). On a shell book (no package
513
+ metadata) the analyzer reports nothing: there is no "own" to judge foreignness
514
+ against.
515
+
480
516
  The completeness analyzer is the phase-1 spot-check and is advisory by contract: it never
481
517
  flags a book and never moves the exit code. Per book it reports the spine doc count, the
482
518
  prose-doc count (spine docs carrying at least 400 visible characters), the opening and closing
@@ -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.49.0"
14
+ VERSION = "0.51.0"
15
15
  __version__ = VERSION
16
16
 
17
17
  from .epub import RepairReport, repair_epub # noqa: E402