bindery-cli 0.47.2__tar.gz → 0.48.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.47.2 → bindery_cli-0.48.0}/PKG-INFO +1 -1
  2. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/patchnotes.md +30 -0
  3. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/pyproject.toml +1 -1
  4. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/roadmap.md +1 -0
  5. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/__init__.py +1 -1
  6. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/cli.py +212 -12
  7. bindery_cli-0.48.0/tests/test_help.py +198 -0
  8. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/uv.lock +1 -1
  9. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/.github/FUNDING.yml +0 -0
  10. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/.github/workflows/ci.yml +0 -0
  11. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/.github/workflows/publish.yml +0 -0
  12. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/.gitignore +0 -0
  13. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/AGENTS.md +0 -0
  14. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/CLAUDE.md +0 -0
  15. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/LICENSE +0 -0
  16. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/README.md +0 -0
  17. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/docs/screenshots/library-sweep.png +0 -0
  18. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/logo.svg +0 -0
  19. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/plugin/__init__.py +0 -0
  20. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/project.done +0 -0
  21. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/run_tests.sh +0 -0
  22. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/scripts/FastSweep.java +0 -0
  23. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/scripts/build_plugin.py +0 -0
  24. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/scripts/fast_sweep.py +0 -0
  25. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/spec.md +0 -0
  26. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/__main__.py +0 -0
  27. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/audit.py +0 -0
  28. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/epub.py +0 -0
  29. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/library.py +0 -0
  30. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/pagination.py +0 -0
  31. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/reserialize.py +0 -0
  32. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/transforms.py +0 -0
  33. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/validate.py +0 -0
  34. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/watermark.py +0 -0
  35. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_audit.py +0 -0
  36. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_cli.py +0 -0
  37. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_comment_hyphens.py +0 -0
  38. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_cover_tocdrift.py +0 -0
  39. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_epub.py +0 -0
  40. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_flags_real_core.py +0 -0
  41. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_flags_wiring.py +0 -0
  42. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_library.py +0 -0
  43. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_matcher_hardening.py +0 -0
  44. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_pagination.py +0 -0
  45. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_plugin.py +0 -0
  46. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_reserialize.py +0 -0
  47. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_space_renames.py +0 -0
  48. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_transforms.py +0 -0
  49. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_validate.py +0 -0
  50. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_version.py +0 -0
  51. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_watermark.py +0 -0
  52. {bindery_cli-0.47.2 → bindery_cli-0.48.0}/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.0
4
4
  Summary: Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library.
5
5
  Project-URL: Homepage, https://github.com/VirInvictus/bindery-cli
6
6
  Project-URL: Repository, https://github.com/VirInvictus/bindery-cli
@@ -1,5 +1,35 @@
1
1
  # bindery-cli Patch Notes
2
2
 
3
+ ## v0.48.0 (2026-10-06)
4
+
5
+ ### The closed-Calibre guard, stated contracts, and --help-json
6
+
7
+ The AI-grokability probe (a fresh agent learning the CLI from help
8
+ alone, the same method CalibreQuarry ran) confirmed the repair-semantics
9
+ help is complete and found the cross-cutting contracts missing. Three
10
+ fixes:
11
+
12
+ - **The closed-Calibre gate is enforced, not just stated.** audit
13
+ --tag and library --apply --install-to-calibre both write
14
+ metadata.db, and nothing stopped that write under a live Calibre
15
+ (WritableCalibreDB waits on the sqlite lock and proceeds; a running
16
+ Calibre holds no persistent exclusive lock). Both doors now share a
17
+ fail-closed pgrep refusal (exit 1: an environment condition, not a
18
+ usage error), and --install-to-calibre's help says so.
19
+ - **The help states the contracts** the probe could not find: exit
20
+ codes on the repair, library, and run-slice pages (0 clean, 2
21
+ trouble, 1 invocation problem); neither mode ever prompts (--apply is
22
+ the only confirmation); an existing output without --force is exit 1;
23
+ NCX-001 defined (toc.ncx dtb:uid != the OPF unique-identifier); --id
24
+ works without --sweep; the repair and library --json envelopes name
25
+ their keys.
26
+ - **--help-json**: the complete surface (subcommands with every flag,
27
+ the run slices, the full repair reference, the exit contract, the
28
+ Calibre gates) as deterministic JSON, generated from the live parser
29
+ and _REPAIR_FLAG_TABLE, never colored.
30
+
31
+ 15 new tests (639 total).
32
+
3
33
  ## v0.47.2 (2026-10-06)
4
34
 
5
35
  ### 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.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 = [
@@ -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.0"
15
15
  __version__ = VERSION
16
16
 
17
17
  from .epub import RepairReport, repair_epub # noqa: E402
@@ -395,8 +395,155 @@ 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",
424
+ "1": "invocation problem (bad args; repair: existing output "
425
+ "without --force); lock-class refusals (Calibre open at a "
426
+ "metadata.db door)",
427
+ "2": "trouble found (rejected/error/unreadable/partial "
428
+ "books; unscoped phase3 sweep); argparse usage errors",
429
+ "130": "interrupted",
430
+ },
431
+ "calibre_gates": [
432
+ "audit --tag",
433
+ "library --apply --install-to-calibre",
434
+ ],
435
+ "subcommands": [],
436
+ "repair_reference": [
437
+ {
438
+ "group": title,
439
+ "flags": [
440
+ {"dest": dest, "summary": summary, "reference": long}
441
+ for dest, summary, long in rows
442
+ ],
443
+ }
444
+ for title, rows in _REPAIR_FLAG_TABLE.items()
445
+ ],
446
+ }
447
+ subparsers = next(
448
+ (a for a in parser._actions if isinstance(a, argparse._SubParsersAction)),
449
+ None,
450
+ )
451
+ sub_helps = (
452
+ {pa.dest: pa.help for pa in subparsers._choices_actions}
453
+ if subparsers
454
+ else {}
455
+ )
456
+ for name, sub in subparsers.choices.items() if subparsers else []:
457
+
458
+ def sub_flags(subparser):
459
+ out = []
460
+ for group in subparser._action_groups:
461
+ for action in group._group_actions:
462
+ if action.dest in ("help",):
463
+ continue
464
+ entry = {
465
+ "flags": list(action.option_strings) or [action.dest],
466
+ "dest": action.dest,
467
+ "help": " ".join((action.help or "").split()),
468
+ }
469
+ if action.metavar:
470
+ met = action.metavar
471
+ entry["metavar"] = (
472
+ " ".join(met) if isinstance(met, tuple) else met
473
+ )
474
+ if action.choices:
475
+ entry["choices"] = [str(c) for c in action.choices]
476
+ out.append(entry)
477
+ return out
478
+
479
+ payload["subcommands"].append(
480
+ {
481
+ "name": name,
482
+ "help": " ".join((sub_helps.get(name) or "").split()),
483
+ "description": " ".join((sub.description or "").split()),
484
+ "flags": sub_flags(sub),
485
+ "flag_groups": [
486
+ {
487
+ "title": group.title,
488
+ "flags": [
489
+ " ".join((a.help or "").split())
490
+ for a in group._group_actions
491
+ if a.dest != "help"
492
+ ],
493
+ }
494
+ for group in sub._action_groups
495
+ if group.title
496
+ ],
497
+ }
498
+ )
499
+ # the run slices are one level deeper
500
+ run_choice = subparsers.choices.get("run") if subparsers else None
501
+ if run_choice is not None:
502
+ run_subs = next(
503
+ (
504
+ a
505
+ for a in run_choice._actions
506
+ if isinstance(a, argparse._SubParsersAction)
507
+ ),
508
+ None,
509
+ )
510
+ run_entry = next(
511
+ (s for s in payload["subcommands"] if s["name"] == "run"),
512
+ None,
513
+ )
514
+ if (
515
+ run_choice is not None
516
+ and run_subs is not None
517
+ and run_entry is not None
518
+ ):
519
+ slice_helps = {pa.dest: pa.help for pa in run_subs._choices_actions}
520
+ run_entry["slices"] = [
521
+ {
522
+ "name": name,
523
+ "help": " ".join((slice_helps.get(name) or "").split()),
524
+ "flags": [
525
+ {
526
+ "flags": list(a.option_strings) or [a.dest],
527
+ "dest": a.dest,
528
+ "help": " ".join((a.help or "").split()),
529
+ }
530
+ for group in sub._action_groups
531
+ for a in group._group_actions
532
+ if a.dest != "help"
533
+ ],
534
+ }
535
+ for name, sub in run_subs.choices.items()
536
+ ]
537
+ print(json.dumps(payload, indent=1, sort_keys=True))
538
+ raise SystemExit(0)
539
+
540
+
398
541
  def run_library(args) -> int:
399
542
  root = Path(args.path).expanduser()
543
+ if args.apply and args.install_to_calibre:
544
+ rc = _require_closed_calibre("--apply --install-to-calibre")
545
+ if rc:
546
+ return rc
400
547
  # cquarry-backed id resolution for --install-to-calibre: one lazy map
401
548
  # build per run, read-only against metadata.db.
402
549
  id_resolver = CalibreIdResolver(root)
@@ -1720,6 +1867,37 @@ def _repair_flag_reference(color: bool | None = None) -> str:
1720
1867
  return "\n".join(lines)
1721
1868
 
1722
1869
 
1870
+ def _calibre_running() -> bool:
1871
+ """pgrep -x calibre. Fail-closed: this gates a metadata.db write, and
1872
+ an unrunnable or timed-out pgrep means "can't tell", so the safe
1873
+ answer is running (refusing costs a closed-Calibre re-run; guessing
1874
+ wrong writes underneath a live Calibre)."""
1875
+ try:
1876
+ return (
1877
+ subprocess.run(
1878
+ ["pgrep", "-x", "calibre"], capture_output=True, timeout=10
1879
+ ).returncode
1880
+ == 0
1881
+ )
1882
+ except (OSError, subprocess.TimeoutExpired):
1883
+ return True
1884
+
1885
+
1886
+ def _require_closed_calibre(door: str) -> int:
1887
+ """The lock-class refusal shared by the metadata.db write doors
1888
+ (`audit --tag`, `library --apply --install-to-calibre`). Exit 1, not
1889
+ a usage error: an open Calibre is an environment condition, exactly
1890
+ like the companion tools' convention (usage problems exit 2)."""
1891
+ if _calibre_running():
1892
+ print(
1893
+ f"ERROR: Calibre is running; close it before {door} "
1894
+ "(the write goes through metadata.db).",
1895
+ file=sys.stderr,
1896
+ )
1897
+ return 1
1898
+ return 0
1899
+
1900
+
1723
1901
  class _HelpRepairsAction(argparse.Action):
1724
1902
  """The second help level: print the full repair-flag reference and exit.
1725
1903
 
@@ -1781,6 +1959,10 @@ def _add_repair_flags(p: argparse.ArgumentParser) -> None:
1781
1959
 
1782
1960
 
1783
1961
  def run_audit_cmd(args: argparse.Namespace) -> int:
1962
+ if getattr(args, "tag", None):
1963
+ rc = _require_closed_calibre("audit --tag")
1964
+ if rc:
1965
+ return rc
1784
1966
  selected = list(ALL) if args.mode == "all" else [args.mode]
1785
1967
  max_doc = args.max_doc_chars
1786
1968
  if args.min_chars > args.thin_chars:
@@ -1853,6 +2035,12 @@ def build_parser() -> argparse.ArgumentParser:
1853
2035
  formatter_class=argparse.RawDescriptionHelpFormatter,
1854
2036
  )
1855
2037
  ap.add_argument("--version", action="version", version=f"bindery {__version__}")
2038
+ ap.add_argument(
2039
+ "--help-json",
2040
+ action=_HelpJsonAction,
2041
+ help="dump the complete CLI surface (subcommands, flags, the "
2042
+ "repair reference, the exit contract) as JSON and exit",
2043
+ )
1856
2044
  sub = ap.add_subparsers(dest="cmd", required=True)
1857
2045
 
1858
2046
  r = sub.add_parser(
@@ -1862,7 +2050,10 @@ def build_parser() -> argparse.ArgumentParser:
1862
2050
  description="Repair one EPUB into a new file with the epubcheck-gated "
1863
2051
  "core pass: the five well-formedness fixes, the NCX pipeline, and the "
1864
2052
  "mimetype fix run always; every other repair is opt-in (grouped "
1865
- "below) and applied only when epubcheck confirms the result improved.",
2053
+ "below) and applied only when epubcheck confirms the result improved. "
2054
+ "Never prompts. Exit codes: 0 clean, 2 trouble found (repairs refused "
2055
+ "or failed; --json carries the records), 1 invocation problem (bad "
2056
+ "args, or an existing output without --force).",
1866
2057
  epilog="examples:\n"
1867
2058
  " bindery repair book.epub out.epub\n"
1868
2059
  " bindery repair book.epub out.epub --all\n"
@@ -1882,8 +2073,9 @@ def build_parser() -> argparse.ArgumentParser:
1882
2073
  "--json",
1883
2074
  dest="json_path",
1884
2075
  metavar="FILE",
1885
- help="write a machine-readable report (status, before/after counts, "
1886
- "fix summary) in the library --json per-book shape",
2076
+ help="write a machine-readable report: one record per file with "
2077
+ "mode, path, output, status, before, after, and the per-fix "
2078
+ "summary (the library --json per-book shape)",
1887
2079
  )
1888
2080
  _add_repair_flags(r)
1889
2081
  r.set_defaults(func=run_repair)
@@ -1970,7 +2162,11 @@ def build_parser() -> argparse.ArgumentParser:
1970
2162
  description="Scan a Calibre library tree for repair candidates and "
1971
2163
  "repair them in place. Dry run by default; --apply atomically "
1972
2164
  "replaces accepted books. Shares the repair-flag set with `bindery "
1973
- "repair` (--all enables every opt-in).",
2165
+ "repair` (--all enables every opt-in). Never prompts: --apply is "
2166
+ "the only confirmation. Exit codes: 0 all clean, 2 any "
2167
+ "flagged/rejected/error/partial book, 1 invocation problem. "
2168
+ "--apply --install-to-calibre writes metadata.db and demands a "
2169
+ "closed Calibre (refused, exit 1, while it runs).",
1974
2170
  epilog="examples:\n"
1975
2171
  " bindery library ~/docs/Calibre\\ Library --sweep\n"
1976
2172
  " bindery library ~/docs/Calibre\\ Library --id 1234 --apply "
@@ -1988,7 +2184,8 @@ def build_parser() -> argparse.ArgumentParser:
1988
2184
  "--only",
1989
2185
  choices=("fatals", "ncx", "all"),
1990
2186
  default="all",
1991
- help="restrict to books with fatals, NCX-001 mismatch, or all (default)",
2187
+ help="restrict to books with fatals, the NCX-001 mismatch "
2188
+ "(toc.ncx dtb:uid != the OPF unique-identifier), or all (default)",
1992
2189
  )
1993
2190
  rc.add_argument(
1994
2191
  "--limit", type=int, help="process at most N candidates (for sampling)"
@@ -2021,9 +2218,9 @@ def build_parser() -> argparse.ArgumentParser:
2021
2218
  "--id",
2022
2219
  default="",
2023
2220
  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)",
2221
+ help="comma-separated Calibre book ids to scope the run to "
2222
+ "(resolved via cquarry's get_format_path; works without --sweep, "
2223
+ "since ids replace the tree walk; mutually exclusive with --audit)",
2027
2224
  )
2028
2225
  bk = lib.add_argument_group("backups")
2029
2226
  bk.add_argument("--backup", help="directory to mirror backups into before --apply")
@@ -2044,7 +2241,7 @@ def build_parser() -> argparse.ArgumentParser:
2044
2241
  out.add_argument(
2045
2242
  "--json",
2046
2243
  metavar="FILE",
2047
- help="write a machine-readable JSON report of the run to FILE",
2244
+ help="write a machine-readable JSON report of the run to FILE Top-level envelope keys: mode (apply|dry-run), root, only, validate, candidates, summary",
2048
2245
  )
2049
2246
  out.add_argument(
2050
2247
  "--manual-list",
@@ -2055,7 +2252,7 @@ def build_parser() -> argparse.ArgumentParser:
2055
2252
  out.add_argument(
2056
2253
  "--install-to-calibre",
2057
2254
  action="store_true",
2058
- help="with --apply, re-register the format natively in metadata.db "
2255
+ 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
2256
  "through cquarry's write module (remove + add in one transaction) "
2060
2257
  "instead of a bare filesystem replace",
2061
2258
  )
@@ -2074,7 +2271,9 @@ def build_parser() -> argparse.ArgumentParser:
2074
2271
  "phase1",
2075
2272
  help="the EPUB pre-import vetting slice over a directory of loose "
2076
2273
  "files: corruption sweep, epubcheck, content battery, monolithic, "
2077
- "watermark detection, repairability. Read-only without --apply-lossy",
2274
+ "watermark detection, repairability. Read-only without --apply-lossy. "
2275
+ "Exit codes: 0 clean, 2 trouble (reject/partial/error/unreadable "
2276
+ "books; decisions_needed in --json), 1 invocation problem",
2078
2277
  )
2079
2278
  p1.add_argument("path")
2080
2279
  p1.add_argument(
@@ -2110,7 +2309,8 @@ def build_parser() -> argparse.ArgumentParser:
2110
2309
  help="the post-import scoped repair sweep: library --id <ids> --sweep "
2111
2310
  "--only all --apply --all --install-to-calibre with a pre/post "
2112
2311
  "summary. Refuses unscoped library-wide sweeps (exit 2); run from "
2113
- "the library directory",
2312
+ "the library directory. Exit codes follow library's: 0 clean, "
2313
+ "2 trouble, 1 invocation problem",
2114
2314
  )
2115
2315
  p3.add_argument(
2116
2316
  "--ids",
@@ -0,0 +1,198 @@
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("0 clean, 2 trouble", text)
108
+ self.assertIn("without --force", text)
109
+
110
+ def test_library_page_states_gate_and_codes(self):
111
+ text = self._sub(self._parser(), "library").description
112
+ self.assertIn("Never prompts", text)
113
+ self.assertIn("closed Calibre", text)
114
+ self.assertIn("0 all clean, 2 any", text)
115
+
116
+ def test_install_to_calibre_names_the_gate(self):
117
+ parser = self._parser()
118
+ lib = self._sub(parser, "library")
119
+ helps = " ".join(
120
+ a.help or "" for g in lib._action_groups for a in g._group_actions
121
+ )
122
+ self.assertIn("Calibre must be closed", helps)
123
+
124
+ def test_ncx001_is_defined(self):
125
+ parser = self._parser()
126
+ lib = self._sub(parser, "library")
127
+ helps = " ".join(
128
+ a.help or "" for g in lib._action_groups for a in g._group_actions
129
+ )
130
+ self.assertIn("dtb:uid", helps)
131
+
132
+ def test_library_id_works_without_sweep(self):
133
+ parser = self._parser()
134
+ lib = self._sub(parser, "library")
135
+ helps = " ".join(
136
+ a.help or "" for g in lib._action_groups for a in g._group_actions
137
+ )
138
+ self.assertIn("works without --sweep", helps)
139
+
140
+ def test_run_pages_carry_exit_codes(self):
141
+ run = self._sub(self._parser(), "run")
142
+ run_subs = next(
143
+ a for a in run._actions if isinstance(a, argparse._SubParsersAction)
144
+ )
145
+ helps = {pa.dest: pa.help for pa in run_subs._choices_actions}
146
+ for name in ("phase1", "phase3"):
147
+ text = helps[name]
148
+ self.assertIn("Exit codes", text, name)
149
+
150
+
151
+ class TestHelpJson(unittest.TestCase):
152
+ def test_valid_complete_deterministic(self):
153
+ payload_text = self._dump()
154
+ data = json.loads(payload_text)
155
+ self.assertEqual(data["tool"], "bindery")
156
+ self.assertEqual(data["version"], __version__)
157
+ self.assertEqual(
158
+ [s["name"] for s in data["subcommands"]],
159
+ ["repair", "doctor", "audit", "library", "run"],
160
+ )
161
+ run = next(s for s in data["subcommands"] if s["name"] == "run")
162
+ self.assertEqual([s["name"] for s in run["slices"]], ["phase1", "phase3"])
163
+ self.assertEqual(
164
+ [g["group"] for g in data["repair_reference"]],
165
+ list(cli._REPAIR_FLAG_TABLE),
166
+ )
167
+ self.assertEqual(
168
+ data["calibre_gates"],
169
+ ["audit --tag", "library --apply --install-to-calibre"],
170
+ )
171
+ self.assertEqual(payload_text, self._dump())
172
+
173
+ def _dump(self):
174
+ out = io.StringIO()
175
+ with contextlib.redirect_stdout(out):
176
+ with self.assertRaises(SystemExit) as caught:
177
+ cli.main(["--help-json"])
178
+ self.assertEqual(caught.exception.code, 0)
179
+ return out.getvalue()
180
+
181
+ def test_covers_every_subcommand_flag_dest(self):
182
+ payload_text = self._dump()
183
+ parser = cli.build_parser()
184
+ subparsers = next(
185
+ a for a in parser._actions if isinstance(a, argparse._SubParsersAction)
186
+ )
187
+ for _name, sub in subparsers.choices.items():
188
+ for group in sub._action_groups:
189
+ for action in group._group_actions:
190
+ if action.dest == "help":
191
+ continue
192
+ for opt in action.option_strings:
193
+ self.assertIn(f'"{opt}"', payload_text, opt)
194
+
195
+ def test_exit_contract_present(self):
196
+ data = json.loads(self._dump())
197
+ self.assertIn("0", data["exit_contract"])
198
+ 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.0"
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