bindery-cli 0.47.2__tar.gz → 0.48.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 (52) hide show
  1. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/PKG-INFO +1 -1
  2. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/patchnotes.md +66 -0
  3. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/pyproject.toml +1 -1
  4. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/roadmap.md +1 -0
  5. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/__init__.py +1 -1
  6. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/cli.py +274 -22
  7. bindery_cli-0.48.1/tests/test_help.py +208 -0
  8. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/uv.lock +1 -1
  9. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/.github/FUNDING.yml +0 -0
  10. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/.github/workflows/ci.yml +0 -0
  11. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/.github/workflows/publish.yml +0 -0
  12. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/.gitignore +0 -0
  13. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/AGENTS.md +0 -0
  14. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/CLAUDE.md +0 -0
  15. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/LICENSE +0 -0
  16. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/README.md +0 -0
  17. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/docs/screenshots/library-sweep.png +0 -0
  18. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/logo.svg +0 -0
  19. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/plugin/__init__.py +0 -0
  20. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/project.done +0 -0
  21. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/run_tests.sh +0 -0
  22. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/scripts/FastSweep.java +0 -0
  23. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/scripts/build_plugin.py +0 -0
  24. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/scripts/fast_sweep.py +0 -0
  25. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/spec.md +0 -0
  26. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/__main__.py +0 -0
  27. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/audit.py +0 -0
  28. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/epub.py +0 -0
  29. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/library.py +0 -0
  30. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/pagination.py +0 -0
  31. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/reserialize.py +0 -0
  32. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/transforms.py +0 -0
  33. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/validate.py +0 -0
  34. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/watermark.py +0 -0
  35. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_audit.py +0 -0
  36. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_cli.py +0 -0
  37. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_comment_hyphens.py +0 -0
  38. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_cover_tocdrift.py +0 -0
  39. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_epub.py +0 -0
  40. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_flags_real_core.py +0 -0
  41. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_flags_wiring.py +0 -0
  42. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_library.py +0 -0
  43. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_matcher_hardening.py +0 -0
  44. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_pagination.py +0 -0
  45. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_plugin.py +0 -0
  46. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_reserialize.py +0 -0
  47. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_space_renames.py +0 -0
  48. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_transforms.py +0 -0
  49. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_validate.py +0 -0
  50. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_version.py +0 -0
  51. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_watermark.py +0 -0
  52. {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_xml_hardening.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindery-cli
3
- Version: 0.47.2
3
+ Version: 0.48.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
@@ -1,5 +1,71 @@
1
1
  # bindery-cli Patch Notes
2
2
 
3
+ ## v0.48.1 (2026-10-06)
4
+
5
+ ### The 12-agent audit: the exit contracts tell the truth
6
+
7
+ Three waves of four lenses audited this help surface. The flag
8
+ inventory verified perfect (30/30 repair flags, zero misses, zero
9
+ fabrications); the defects were the exit contracts and the safety
10
+ silences:
11
+
12
+ - **repair --help stated an exit contract the verb has never honored**
13
+ (trouble returns 1, not 2; a PARTIAL repair writes its output and
14
+ returns 0). The page and --help-json now tell the code's truth, per
15
+ verb: repair 0/written + 1, audit's deliberate inverse contract
16
+ (0 clean; 1 findings; 2 usage/environment; 3 findings plus a failed
17
+ report write), library and the run slices on the unified 0/2/1.
18
+ calibre_gates names the third metadata.db door (run phase3, guarded
19
+ transitively), and the contract admits the missing-stack exit-2 and
20
+ the exit-3 class.
21
+ - **The library page states the no-backup default**: --apply without
22
+ --backup/--backup-inplace replaces in place and keeps no copy of the
23
+ original. --no-validate says the write still happens (status
24
+ unvalidated). --backup names the outside-the-library refusal.
25
+ --only fatals names its --audit/--sweep precondition.
26
+ - audit and doctor got real descriptions (the eight analyzers, the CWD
27
+ library resolution, the one-id --json rule; doctor's always-exit-0
28
+ contract); the --help-repairs preamble names the five always-on
29
+ fixes, defines the gate metric, enumerates the status vocabulary,
30
+ and states the no-worse classes; phase1's exit list gains
31
+ audit-flagged books; repair's positionals, default output name, and
32
+ never-modifies-the-input are stated; the --json envelopes name all
33
+ keys including books; the root help points at doctor/README/spec.
34
+ - One code fix: the no-backup lossy warning now covers
35
+ --strip-stub-docs (it warned for its three siblings only).
36
+
37
+ 15 pins updated/added (639 total).
38
+
39
+ ## v0.48.0 (2026-10-06)
40
+
41
+ ### The closed-Calibre guard, stated contracts, and --help-json
42
+
43
+ The AI-grokability probe (a fresh agent learning the CLI from help
44
+ alone, the same method CalibreQuarry ran) confirmed the repair-semantics
45
+ help is complete and found the cross-cutting contracts missing. Three
46
+ fixes:
47
+
48
+ - **The closed-Calibre gate is enforced, not just stated.** audit
49
+ --tag and library --apply --install-to-calibre both write
50
+ metadata.db, and nothing stopped that write under a live Calibre
51
+ (WritableCalibreDB waits on the sqlite lock and proceeds; a running
52
+ Calibre holds no persistent exclusive lock). Both doors now share a
53
+ fail-closed pgrep refusal (exit 1: an environment condition, not a
54
+ usage error), and --install-to-calibre's help says so.
55
+ - **The help states the contracts** the probe could not find: exit
56
+ codes on the repair, library, and run-slice pages (0 clean, 2
57
+ trouble, 1 invocation problem); neither mode ever prompts (--apply is
58
+ the only confirmation); an existing output without --force is exit 1;
59
+ NCX-001 defined (toc.ncx dtb:uid != the OPF unique-identifier); --id
60
+ works without --sweep; the repair and library --json envelopes name
61
+ their keys.
62
+ - **--help-json**: the complete surface (subcommands with every flag,
63
+ the run slices, the full repair reference, the exit contract, the
64
+ Calibre gates) as deterministic JSON, generated from the live parser
65
+ and _REPAIR_FLAG_TABLE, never colored.
66
+
67
+ 15 new tests (639 total).
68
+
3
69
  ## v0.47.2 (2026-10-06)
4
70
 
5
71
  ### The --help-repairs reference keeps argparse's color
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.47.2"
7
+ version = "0.48.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 = [
@@ -41,6 +41,7 @@ The plugin vendors the core and never gates (the epubcheck latency ruling).
41
41
  | 0.45 | Structured fix records (`fixes`, `ncx_uid_synced`, `watermark_refusals`) in every JSON payload; CalibreQuarry consumes them with the 0.45.0 floor | v0.45.0 |
42
42
  | 0.46 | RSC-016 `--fix-comment-double-hyphen` (the last open repair class) and the RepairFlags dataclass | v0.46.0 |
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
+ | 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 |
44
45
 
45
46
  ## Open work
46
47
 
@@ -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.47.2"
14
+ VERSION = "0.48.1"
15
15
  __version__ = VERSION
16
16
 
17
17
  from .epub import RepairReport, repair_epub # noqa: E402
@@ -395,8 +395,171 @@ def _epubs_for_ids(root: Path, id_csv: str) -> list[Path] | None:
395
395
  return epubs
396
396
 
397
397
 
398
+ class _HelpJsonAction(argparse.Action):
399
+ """The machine surface: dump the complete CLI surface as JSON and exit.
400
+ Like _HelpRepairsAction, deliberately not a store_true dest, and never
401
+ colored: the output must stay valid JSON under any environment."""
402
+
403
+ def __init__(
404
+ self,
405
+ option_strings,
406
+ dest=argparse.SUPPRESS,
407
+ default=argparse.SUPPRESS,
408
+ help=None,
409
+ ):
410
+ super().__init__(
411
+ option_strings=option_strings,
412
+ dest=dest,
413
+ default=default,
414
+ nargs=0,
415
+ help=help,
416
+ )
417
+
418
+ def __call__(self, parser, namespace, values, option_string=None):
419
+ payload = {
420
+ "tool": "bindery",
421
+ "version": __version__,
422
+ "exit_contract": {
423
+ "0": "clean (repair: also written, including partial)",
424
+ "1": "library/run slices: trouble found; repair: trouble "
425
+ "(refused/failed/unreadable) or invocation problem; audit: "
426
+ "FINDINGS (the inverse contract, per spec.md); "
427
+ "lock-class refusals (Calibre open at a metadata.db door)",
428
+ "2": "library/run slices: trouble found (rejected/error/"
429
+ "unreadable/partial books; unscoped phase3 sweep); audit: "
430
+ "usage/environment errors; argparse usage errors; missing "
431
+ "vir_tui/cquarry on Python < 3.14",
432
+ "3": "audit: findings PLUS a failed --json/--tag write",
433
+ "130": "interrupted",
434
+ "per_verb": {
435
+ "repair": "0 clean/written (partial writes, exit 0); "
436
+ "1 trouble or invocation problem; never 2",
437
+ "audit": "0 clean; 1 findings; 2 usage/environment; "
438
+ "3 findings + failed report write",
439
+ "library": "0 clean; 2 trouble (rejected/error/"
440
+ "unreadable/partial); 1 invocation problem",
441
+ "run phase1": "0 clean; 2 trouble (audit-flagged, "
442
+ "rejected, partial, error, unreadable); 1 invocation",
443
+ },
444
+ },
445
+ "calibre_gates": [
446
+ "audit --tag",
447
+ "library --apply --install-to-calibre",
448
+ "run phase3 (transitively: it wraps library --apply "
449
+ "--install-to-calibre)",
450
+ ],
451
+ "subcommands": [],
452
+ "repair_reference": [
453
+ {
454
+ "group": title,
455
+ "flags": [
456
+ {"dest": dest, "summary": summary, "reference": long}
457
+ for dest, summary, long in rows
458
+ ],
459
+ }
460
+ for title, rows in _REPAIR_FLAG_TABLE.items()
461
+ ],
462
+ }
463
+ subparsers = next(
464
+ (a for a in parser._actions if isinstance(a, argparse._SubParsersAction)),
465
+ None,
466
+ )
467
+ sub_helps = (
468
+ {pa.dest: pa.help for pa in subparsers._choices_actions}
469
+ if subparsers
470
+ else {}
471
+ )
472
+ for name, sub in subparsers.choices.items() if subparsers else []:
473
+
474
+ def sub_flags(subparser):
475
+ out = []
476
+ for group in subparser._action_groups:
477
+ for action in group._group_actions:
478
+ if action.dest in ("help",):
479
+ continue
480
+ entry = {
481
+ "flags": list(action.option_strings) or [action.dest],
482
+ "dest": action.dest,
483
+ "help": " ".join((action.help or "").split()),
484
+ }
485
+ if action.metavar:
486
+ met = action.metavar
487
+ entry["metavar"] = (
488
+ " ".join(met) if isinstance(met, tuple) else met
489
+ )
490
+ if action.choices:
491
+ entry["choices"] = [str(c) for c in action.choices]
492
+ out.append(entry)
493
+ return out
494
+
495
+ payload["subcommands"].append(
496
+ {
497
+ "name": name,
498
+ "help": " ".join((sub_helps.get(name) or "").split()),
499
+ "description": " ".join((sub.description or "").split()),
500
+ "flags": sub_flags(sub),
501
+ "flag_groups": [
502
+ {
503
+ "title": group.title,
504
+ "flags": [
505
+ " ".join((a.help or "").split())
506
+ for a in group._group_actions
507
+ if a.dest != "help"
508
+ ],
509
+ }
510
+ for group in sub._action_groups
511
+ if group.title
512
+ ],
513
+ }
514
+ )
515
+ # the run slices are one level deeper
516
+ run_choice = subparsers.choices.get("run") if subparsers else None
517
+ if run_choice is not None:
518
+ run_subs = next(
519
+ (
520
+ a
521
+ for a in run_choice._actions
522
+ if isinstance(a, argparse._SubParsersAction)
523
+ ),
524
+ None,
525
+ )
526
+ run_entry = next(
527
+ (s for s in payload["subcommands"] if s["name"] == "run"),
528
+ None,
529
+ )
530
+ if (
531
+ run_choice is not None
532
+ and run_subs is not None
533
+ and run_entry is not None
534
+ ):
535
+ slice_helps = {pa.dest: pa.help for pa in run_subs._choices_actions}
536
+ run_entry["slices"] = [
537
+ {
538
+ "name": name,
539
+ "help": " ".join((slice_helps.get(name) or "").split()),
540
+ "flags": [
541
+ {
542
+ "flags": list(a.option_strings) or [a.dest],
543
+ "dest": a.dest,
544
+ "help": " ".join((a.help or "").split()),
545
+ }
546
+ for group in sub._action_groups
547
+ for a in group._group_actions
548
+ if a.dest != "help"
549
+ ],
550
+ }
551
+ for name, sub in run_subs.choices.items()
552
+ ]
553
+ print(json.dumps(payload, indent=1, sort_keys=True))
554
+ raise SystemExit(0)
555
+
556
+
398
557
  def run_library(args) -> int:
399
558
  root = Path(args.path).expanduser()
559
+ if args.apply and args.install_to_calibre:
560
+ rc = _require_closed_calibre("--apply --install-to-calibre")
561
+ if rc:
562
+ return rc
400
563
  # cquarry-backed id resolution for --install-to-calibre: one lazy map
401
564
  # build per run, read-only against metadata.db.
402
565
  id_resolver = CalibreIdResolver(root)
@@ -485,7 +648,12 @@ def run_library(args) -> int:
485
648
  if (
486
649
  args.apply
487
650
  and not wants_backup
488
- and (args.strip_pagination or args.strip_broken_tags or args.strip_watermarks)
651
+ and (
652
+ args.strip_pagination
653
+ or args.strip_broken_tags
654
+ or args.strip_watermarks
655
+ or args.strip_stub_docs
656
+ )
489
657
  ):
490
658
  print(
491
659
  "WARNING: the --strip-* modes are lossy; strongly consider --backup DIR "
@@ -1694,12 +1862,21 @@ def _repair_flag_reference(color: bool | None = None) -> str:
1694
1862
  + " repair-flag reference (shared by `bindery repair` and "
1695
1863
  "`bindery library`)",
1696
1864
  "",
1697
- "The always-on core pass is exactly five well-formedness fixes, the NCX",
1698
- "pipeline, and the mimetype fix. Everything below is opt-in, and every",
1699
- "repair is epubcheck-gated: applied only when the measured result improved",
1700
- "(lossy strips: only when it did not get worse). Library mode is dry-run",
1701
- "by default; `--all` enables every flag below at once. The README and",
1702
- "spec.md carry the full rationale for each repair.",
1865
+ "The always-on core pass is exactly five well-formedness fixes",
1866
+ "(strip prolog junk, drop duplicate xmlns, escape bare ampersands,",
1867
+ "fix named entities, self-close void tags), the NCX pipeline, and",
1868
+ "the mimetype fix. Everything below is opt-in, and every repair is",
1869
+ "epubcheck-gated: applied only when the measured result improved",
1870
+ "(strictly fewer findings than before); the lossy strips, the",
1871
+ "cover wiring repairs, and the space-entry renames are held to the",
1872
+ "weaker no-worse bar (not more findings). Library mode is dry-run",
1873
+ "by default; `--all` enables every flag below at once. Statuses a",
1874
+ "record can carry: accept (gate-applied), partial (written but",
1875
+ "still failing epubcheck), reject (the gate refused), error,",
1876
+ "unreadable, nochange (nothing to do), equal (already as good as",
1877
+ "the result), unvalidated (applied under --no-validate, never",
1878
+ "gate-checked). The README and spec.md carry the full rationale",
1879
+ "for each repair.",
1703
1880
  ]
1704
1881
  for title, rows in _REPAIR_FLAG_TABLE.items():
1705
1882
  lines.append("")
@@ -1720,6 +1897,37 @@ def _repair_flag_reference(color: bool | None = None) -> str:
1720
1897
  return "\n".join(lines)
1721
1898
 
1722
1899
 
1900
+ def _calibre_running() -> bool:
1901
+ """pgrep -x calibre. Fail-closed: this gates a metadata.db write, and
1902
+ an unrunnable or timed-out pgrep means "can't tell", so the safe
1903
+ answer is running (refusing costs a closed-Calibre re-run; guessing
1904
+ wrong writes underneath a live Calibre)."""
1905
+ try:
1906
+ return (
1907
+ subprocess.run(
1908
+ ["pgrep", "-x", "calibre"], capture_output=True, timeout=10
1909
+ ).returncode
1910
+ == 0
1911
+ )
1912
+ except (OSError, subprocess.TimeoutExpired):
1913
+ return True
1914
+
1915
+
1916
+ def _require_closed_calibre(door: str) -> int:
1917
+ """The lock-class refusal shared by the metadata.db write doors
1918
+ (`audit --tag`, `library --apply --install-to-calibre`). Exit 1, not
1919
+ a usage error: an open Calibre is an environment condition, exactly
1920
+ like the companion tools' convention (usage problems exit 2)."""
1921
+ if _calibre_running():
1922
+ print(
1923
+ f"ERROR: Calibre is running; close it before {door} "
1924
+ "(the write goes through metadata.db).",
1925
+ file=sys.stderr,
1926
+ )
1927
+ return 1
1928
+ return 0
1929
+
1930
+
1723
1931
  class _HelpRepairsAction(argparse.Action):
1724
1932
  """The second help level: print the full repair-flag reference and exit.
1725
1933
 
@@ -1765,12 +1973,15 @@ def _add_repair_flags(p: argparse.ArgumentParser) -> None:
1765
1973
  g.add_argument(
1766
1974
  "--all",
1767
1975
  action="store_true",
1768
- help="enable every opt-in fix flag (safe, structural, and lossy)",
1976
+ help="enable every opt-in fix flag (safe, structural, and lossy); "
1977
+ "this IS the lossy consent here (run phase1's separate "
1978
+ "--apply-lossy door is the phase1 equivalent)",
1769
1979
  )
1770
1980
  g.add_argument(
1771
1981
  "--no-validate",
1772
1982
  action="store_true",
1773
- help="skip the epubcheck gate",
1983
+ help="skip the epubcheck gate: the repair still writes/appends "
1984
+ "with status unvalidated (no gate check runs)",
1774
1985
  )
1775
1986
  p.add_argument(
1776
1987
  "--help-repairs",
@@ -1781,6 +1992,10 @@ def _add_repair_flags(p: argparse.ArgumentParser) -> None:
1781
1992
 
1782
1993
 
1783
1994
  def run_audit_cmd(args: argparse.Namespace) -> int:
1995
+ if getattr(args, "tag", None):
1996
+ rc = _require_closed_calibre("audit --tag")
1997
+ if rc:
1998
+ return rc
1784
1999
  selected = list(ALL) if args.mode == "all" else [args.mode]
1785
2000
  max_doc = args.max_doc_chars
1786
2001
  if args.min_chars > args.thin_chars:
@@ -1853,6 +2068,12 @@ def build_parser() -> argparse.ArgumentParser:
1853
2068
  formatter_class=argparse.RawDescriptionHelpFormatter,
1854
2069
  )
1855
2070
  ap.add_argument("--version", action="version", version=f"bindery {__version__}")
2071
+ ap.add_argument(
2072
+ "--help-json",
2073
+ action=_HelpJsonAction,
2074
+ help="dump the complete CLI surface (subcommands, flags, the "
2075
+ "repair reference, the exit contract) as JSON and exit",
2076
+ )
1856
2077
  sub = ap.add_subparsers(dest="cmd", required=True)
1857
2078
 
1858
2079
  r = sub.add_parser(
@@ -1862,7 +2083,13 @@ def build_parser() -> argparse.ArgumentParser:
1862
2083
  description="Repair one EPUB into a new file with the epubcheck-gated "
1863
2084
  "core pass: the five well-formedness fixes, the NCX pipeline, and the "
1864
2085
  "mimetype fix run always; every other repair is opt-in (grouped "
1865
- "below) and applied only when epubcheck confirms the result improved.",
2086
+ "below) and applied only when epubcheck confirms the result improved. "
2087
+ "Never prompts. Exit codes: 0 clean or written (a PARTIAL repair "
2088
+ "writes its best output and exits 0; the --json record's status "
2089
+ "says so), 1 trouble (repair refused by the gate, epubcheck "
2090
+ "failure, or unreadable input) or invocation problem (bad args, "
2091
+ "or an existing output without --force). This verb does not use "
2092
+ "the tool-wide trouble=2 contract; library and the run slices do.",
1866
2093
  epilog="examples:\n"
1867
2094
  " bindery repair book.epub out.epub\n"
1868
2095
  " bindery repair book.epub out.epub --all\n"
@@ -1882,8 +2109,9 @@ def build_parser() -> argparse.ArgumentParser:
1882
2109
  "--json",
1883
2110
  dest="json_path",
1884
2111
  metavar="FILE",
1885
- help="write a machine-readable report (status, before/after counts, "
1886
- "fix summary) in the library --json per-book shape",
2112
+ help="write a machine-readable report: one record per file with "
2113
+ "mode, path, output, status, before, after, and the per-fix "
2114
+ "summary (the library --json per-book shape)",
1887
2115
  )
1888
2116
  _add_repair_flags(r)
1889
2117
  r.set_defaults(func=run_repair)
@@ -1892,12 +2120,25 @@ def build_parser() -> argparse.ArgumentParser:
1892
2120
  "doctor",
1893
2121
  help="check the environment: Python stack tier, epubcheck, Java, "
1894
2122
  "html5lib, and Calibre-library discovery",
2123
+ description="Check the environment and exit 0 ALWAYS: the output "
2124
+ "is the diagnosis, never a failure (run this first). Checks: the "
2125
+ "Python stack tier, epubcheck, Java, html5lib, and Calibre-library "
2126
+ "discovery.",
1895
2127
  )
1896
2128
  doc.set_defaults(func=run_doctor)
1897
2129
 
1898
2130
  audit = sub.add_parser(
1899
2131
  "audit",
1900
2132
  help="audit EPUB body text to detect non-schema content flaws (OCR damage, hardcoded page numbers, empty books, non-English text)",
2133
+ description="Read-only content audit (eight analyzers: monolithic "
2134
+ "scans, completeness, cover wiring, toc drift, and the body-text "
2135
+ "battery over OCR damage, hardcoded page numbers, empty/thin "
2136
+ "books, and non-English text). Library mode resolves the Calibre "
2137
+ "library from the CURRENT DIRECTORY's metadata.db, or pass a "
2138
+ "directory, or --id for specific books (--id plus --json takes "
2139
+ "exactly one id). Exit codes are the audit inverse: 0 clean, "
2140
+ "1 FINDINGS (flagged books), 2 usage or environment errors, "
2141
+ "3 findings plus a failed --json/--tag write. Never prompts.",
1901
2142
  )
1902
2143
  audit.add_argument(
1903
2144
  "mode",
@@ -1949,7 +2190,7 @@ def build_parser() -> argparse.ArgumentParser:
1949
2190
  "--id",
1950
2191
  metavar="BOOK_IDS",
1951
2192
  default=None,
1952
- help="audit library book(s) by Calibre id — one id or a comma-separated "
2193
+ help="audit library book(s) by Calibre id: one id or a comma-separated "
1953
2194
  "list (fetched via cquarry's single-entity get_book; cannot be "
1954
2195
  "combined with a directory)",
1955
2196
  )
@@ -1970,7 +2211,13 @@ def build_parser() -> argparse.ArgumentParser:
1970
2211
  description="Scan a Calibre library tree for repair candidates and "
1971
2212
  "repair them in place. Dry run by default; --apply atomically "
1972
2213
  "replaces accepted books. Shares the repair-flag set with `bindery "
1973
- "repair` (--all enables every opt-in).",
2214
+ "repair` (--all enables every opt-in). Never prompts: --apply is "
2215
+ "the only confirmation. --apply WITHOUT --backup or --backup-inplace "
2216
+ "replaces in place and keeps NO copy of the original. Exit codes: "
2217
+ "0 all clean, 2 any flagged/rejected/error/unreadable/partial book, "
2218
+ "1 invocation problem. --apply --install-to-calibre writes "
2219
+ "metadata.db and demands a closed Calibre (refused, exit 1, while "
2220
+ "it runs).",
1974
2221
  epilog="examples:\n"
1975
2222
  " bindery library ~/docs/Calibre\\ Library --sweep\n"
1976
2223
  " bindery library ~/docs/Calibre\\ Library --id 1234 --apply "
@@ -1988,7 +2235,9 @@ def build_parser() -> argparse.ArgumentParser:
1988
2235
  "--only",
1989
2236
  choices=("fatals", "ncx", "all"),
1990
2237
  default="all",
1991
- help="restrict to books with fatals, NCX-001 mismatch, or all (default)",
2238
+ help="restrict to books with fatals, the NCX-001 mismatch "
2239
+ "(toc.ncx dtb:uid != the OPF unique-identifier), or all (default); "
2240
+ "fatals requires --audit CSV or --sweep",
1992
2241
  )
1993
2242
  rc.add_argument(
1994
2243
  "--limit", type=int, help="process at most N candidates (for sampling)"
@@ -2021,9 +2270,9 @@ def build_parser() -> argparse.ArgumentParser:
2021
2270
  "--id",
2022
2271
  default="",
2023
2272
  metavar="IDS",
2024
- help="comma-separated Calibre book ids to scope the sweep to "
2025
- "(resolved via cquarry's get_format_path; mutually exclusive with "
2026
- "--audit)",
2273
+ help="comma-separated Calibre book ids to scope the run to "
2274
+ "(resolved via cquarry's get_format_path; works without --sweep, "
2275
+ "since ids replace the tree walk; mutually exclusive with --audit)",
2027
2276
  )
2028
2277
  bk = lib.add_argument_group("backups")
2029
2278
  bk.add_argument("--backup", help="directory to mirror backups into before --apply")
@@ -2044,7 +2293,7 @@ def build_parser() -> argparse.ArgumentParser:
2044
2293
  out.add_argument(
2045
2294
  "--json",
2046
2295
  metavar="FILE",
2047
- help="write a machine-readable JSON report of the run to FILE",
2296
+ help="write a machine-readable JSON report of the run to FILE. Top-level envelope keys: mode (apply|dry-run), root, only, validate, candidates, summary, books (one record per candidate)",
2048
2297
  )
2049
2298
  out.add_argument(
2050
2299
  "--manual-list",
@@ -2055,7 +2304,7 @@ def build_parser() -> argparse.ArgumentParser:
2055
2304
  out.add_argument(
2056
2305
  "--install-to-calibre",
2057
2306
  action="store_true",
2058
- help="with --apply, re-register the format natively in metadata.db "
2307
+ help="Calibre must be closed for this door (the row write goes through metadata.db; refused with exit 1 while it runs). with --apply, re-register the format natively in metadata.db "
2059
2308
  "through cquarry's write module (remove + add in one transaction) "
2060
2309
  "instead of a bare filesystem replace",
2061
2310
  )
@@ -2074,7 +2323,9 @@ def build_parser() -> argparse.ArgumentParser:
2074
2323
  "phase1",
2075
2324
  help="the EPUB pre-import vetting slice over a directory of loose "
2076
2325
  "files: corruption sweep, epubcheck, content battery, monolithic, "
2077
- "watermark detection, repairability. Read-only without --apply-lossy",
2326
+ "watermark detection, repairability. Read-only without --apply-lossy. "
2327
+ "Exit codes: 0 clean, 2 trouble (reject/partial/error/unreadable "
2328
+ "books; decisions_needed in --json), 1 invocation problem",
2078
2329
  )
2079
2330
  p1.add_argument("path")
2080
2331
  p1.add_argument(
@@ -2110,7 +2361,8 @@ def build_parser() -> argparse.ArgumentParser:
2110
2361
  help="the post-import scoped repair sweep: library --id <ids> --sweep "
2111
2362
  "--only all --apply --all --install-to-calibre with a pre/post "
2112
2363
  "summary. Refuses unscoped library-wide sweeps (exit 2); run from "
2113
- "the library directory",
2364
+ "the library directory. Exit codes follow library's: 0 clean, "
2365
+ "2 trouble, 1 invocation problem",
2114
2366
  )
2115
2367
  p3.add_argument(
2116
2368
  "--ids",
@@ -0,0 +1,208 @@
1
+ """Tests for the help-surface truth set and the machine surface: the
2
+ fail-closed Calibre guard on the metadata.db write doors, the contracts
3
+ the AI-grokability probe found missing from help (exit codes, the gate,
4
+ NCX-001, --id scoping, the json envelopes), and --help-json."""
5
+
6
+ import argparse
7
+ import contextlib
8
+ import io
9
+ import json
10
+ import unittest
11
+ from types import SimpleNamespace
12
+ from unittest import mock
13
+
14
+ from bindery import __version__, cli
15
+
16
+
17
+ class TestCalibreGuard(unittest.TestCase):
18
+ """The lock-class refusal on audit --tag and library --apply
19
+ --install-to-calibre: fail-closed pgrep, exit 1, before any work."""
20
+
21
+ def test_refuses_when_calibre_runs(self):
22
+ with mock.patch.object(cli, "_calibre_running", return_value=True):
23
+ rc = cli._require_closed_calibre("the door")
24
+ self.assertEqual(rc, 1)
25
+
26
+ def test_passes_when_calibre_closed(self):
27
+ with mock.patch.object(cli, "_calibre_running", return_value=False):
28
+ rc = cli._require_closed_calibre("the door")
29
+ self.assertEqual(rc, 0)
30
+
31
+ def test_pgrep_failure_is_fail_closed(self):
32
+ # an unrunnable pgrep means "can't tell": refuse
33
+ with mock.patch.object(
34
+ cli.subprocess,
35
+ "run",
36
+ side_effect=OSError("no pgrep"),
37
+ ):
38
+ self.assertTrue(cli._calibre_running())
39
+ with mock.patch.object(
40
+ cli.subprocess,
41
+ "run",
42
+ side_effect=cli.subprocess.TimeoutExpired(cmd="pgrep", timeout=10),
43
+ ):
44
+ self.assertTrue(cli._calibre_running())
45
+
46
+ def test_library_guard_fires_before_anything_else(self):
47
+ # a nonexistent path would fail "not a directory" (exit 1) anyway;
48
+ # the stderr message proves the guard spoke first
49
+ args = SimpleNamespace(
50
+ apply=True,
51
+ install_to_calibre=True,
52
+ path="/nonexistent/library",
53
+ )
54
+ err = io.StringIO()
55
+ with (
56
+ mock.patch.object(cli, "_calibre_running", return_value=True),
57
+ contextlib.redirect_stderr(err),
58
+ ):
59
+ rc = cli.run_library(args)
60
+ self.assertEqual(rc, 1)
61
+ self.assertIn("Calibre is running", err.getvalue())
62
+
63
+ def test_library_without_install_door_is_not_gated(self):
64
+ # a plain --apply (in-place) or dry run never consults the guard
65
+ args = SimpleNamespace(
66
+ apply=True,
67
+ install_to_calibre=False,
68
+ path="/nonexistent/library",
69
+ )
70
+ err = io.StringIO()
71
+ with (
72
+ mock.patch.object(cli, "_calibre_running", return_value=True),
73
+ contextlib.redirect_stderr(err),
74
+ ):
75
+ rc = cli.run_library(args)
76
+ self.assertEqual(rc, 1)
77
+ self.assertIn("not a directory", err.getvalue())
78
+
79
+ def test_audit_tag_guard_fires_first(self):
80
+ args = SimpleNamespace(tag="Flagged")
81
+ err = io.StringIO()
82
+ with (
83
+ mock.patch.object(cli, "_calibre_running", return_value=True),
84
+ contextlib.redirect_stderr(err),
85
+ ):
86
+ rc = cli.run_audit_cmd(args)
87
+ self.assertEqual(rc, 1)
88
+ self.assertIn("audit --tag", err.getvalue())
89
+
90
+
91
+ class TestHelpTruth(unittest.TestCase):
92
+ """The contracts the probe found missing, now stated where users and
93
+ agents read them."""
94
+
95
+ def _parser(self):
96
+ return cli.build_parser()
97
+
98
+ def _sub(self, parser, name):
99
+ subparsers = next(
100
+ a for a in parser._actions if isinstance(a, argparse._SubParsersAction)
101
+ )
102
+ return subparsers.choices[name]
103
+
104
+ def test_repair_page_states_the_contract(self):
105
+ text = self._sub(self._parser(), "repair").description
106
+ self.assertIn("Never prompts", text)
107
+ self.assertIn("PARTIAL repair writes its best output and exits 0", text)
108
+ self.assertIn("does not use", text)
109
+ self.assertIn("without --force", text)
110
+
111
+ def test_library_page_states_gate_and_codes(self):
112
+ text = self._sub(self._parser(), "library").description
113
+ self.assertIn("Never prompts", text)
114
+ self.assertIn("closed Calibre", text)
115
+ self.assertIn(
116
+ "0 all clean, 2 any flagged/rejected/error/unreadable/partial", text
117
+ )
118
+ self.assertIn("keeps NO copy of the original", text)
119
+
120
+ def test_install_to_calibre_names_the_gate(self):
121
+ parser = self._parser()
122
+ lib = self._sub(parser, "library")
123
+ helps = " ".join(
124
+ a.help or "" for g in lib._action_groups for a in g._group_actions
125
+ )
126
+ self.assertIn("Calibre must be closed", helps)
127
+
128
+ def test_ncx001_is_defined(self):
129
+ parser = self._parser()
130
+ lib = self._sub(parser, "library")
131
+ helps = " ".join(
132
+ a.help or "" for g in lib._action_groups for a in g._group_actions
133
+ )
134
+ self.assertIn("dtb:uid", helps)
135
+
136
+ def test_library_id_works_without_sweep(self):
137
+ parser = self._parser()
138
+ lib = self._sub(parser, "library")
139
+ helps = " ".join(
140
+ a.help or "" for g in lib._action_groups for a in g._group_actions
141
+ )
142
+ self.assertIn("works without --sweep", helps)
143
+
144
+ def test_run_pages_carry_exit_codes(self):
145
+ run = self._sub(self._parser(), "run")
146
+ run_subs = next(
147
+ a for a in run._actions if isinstance(a, argparse._SubParsersAction)
148
+ )
149
+ helps = {pa.dest: pa.help for pa in run_subs._choices_actions}
150
+ for name in ("phase1", "phase3"):
151
+ text = helps[name]
152
+ self.assertIn("Exit codes", text, name)
153
+
154
+
155
+ class TestHelpJson(unittest.TestCase):
156
+ def test_valid_complete_deterministic(self):
157
+ payload_text = self._dump()
158
+ data = json.loads(payload_text)
159
+ self.assertEqual(data["tool"], "bindery")
160
+ self.assertEqual(data["version"], __version__)
161
+ self.assertEqual(
162
+ [s["name"] for s in data["subcommands"]],
163
+ ["repair", "doctor", "audit", "library", "run"],
164
+ )
165
+ run = next(s for s in data["subcommands"] if s["name"] == "run")
166
+ self.assertEqual([s["name"] for s in run["slices"]], ["phase1", "phase3"])
167
+ self.assertEqual(
168
+ [g["group"] for g in data["repair_reference"]],
169
+ list(cli._REPAIR_FLAG_TABLE),
170
+ )
171
+ self.assertEqual(
172
+ data["calibre_gates"][0:2],
173
+ ["audit --tag", "library --apply --install-to-calibre"],
174
+ )
175
+ # the third door is phase3, guarded transitively through library
176
+ self.assertIn("run phase3", data["calibre_gates"][2])
177
+ # the contract now carries the per-verb truth
178
+ self.assertEqual(data["exit_contract"]["per_verb"]["repair"][-7:], "never 2")
179
+ self.assertIn("1 findings", data["exit_contract"]["per_verb"]["audit"])
180
+ self.assertIn("3", data["exit_contract"])
181
+ self.assertEqual(payload_text, self._dump())
182
+
183
+ def _dump(self):
184
+ out = io.StringIO()
185
+ with contextlib.redirect_stdout(out):
186
+ with self.assertRaises(SystemExit) as caught:
187
+ cli.main(["--help-json"])
188
+ self.assertEqual(caught.exception.code, 0)
189
+ return out.getvalue()
190
+
191
+ def test_covers_every_subcommand_flag_dest(self):
192
+ payload_text = self._dump()
193
+ parser = cli.build_parser()
194
+ subparsers = next(
195
+ a for a in parser._actions if isinstance(a, argparse._SubParsersAction)
196
+ )
197
+ for _name, sub in subparsers.choices.items():
198
+ for group in sub._action_groups:
199
+ for action in group._group_actions:
200
+ if action.dest == "help":
201
+ continue
202
+ for opt in action.option_strings:
203
+ self.assertIn(f'"{opt}"', payload_text, opt)
204
+
205
+ def test_exit_contract_present(self):
206
+ data = json.loads(self._dump())
207
+ self.assertIn("0", data["exit_contract"])
208
+ self.assertIn("2", data["exit_contract"])
@@ -8,7 +8,7 @@ resolution-markers = [
8
8
 
9
9
  [[package]]
10
10
  name = "bindery-cli"
11
- version = "0.47.2"
11
+ version = "0.48.1"
12
12
  source = { editable = "." }
13
13
  dependencies = [
14
14
  { name = "cquarry", marker = "python_full_version >= '3.14'" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes