openom-cli 0.1.0__py3-none-any.whl

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.
openom_cli/main.py ADDED
@@ -0,0 +1,1064 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """The ``om`` CLI over openom-core (spec §5a). Thin, deterministic, zero inference.
3
+
4
+ Commands: embed · embed-batch · buildout-pull · buildout-manifest · mirror · read · inspect ·
5
+ validate · check · extract · conformance · version. JSON goes
6
+ to stdout (``--format pretty|compact``; ``--quiet`` suppresses it). A path of ``-`` means stdin
7
+ (input) or stdout (``embed --out -``) for pipe-friendly use. Exit codes: 0 ok · 1 validation/
8
+ conformance failure · 2 usage (typer) · 3 data/IO error (bad PDF/JSON, OM-IO-*). Warnings never
9
+ affect the exit code.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import datetime
16
+ import functools
17
+ import json
18
+ import sys
19
+ import time
20
+ from collections.abc import Callable
21
+ from concurrent.futures import ProcessPoolExecutor
22
+ from importlib.metadata import PackageNotFoundError
23
+ from importlib.metadata import version as _pkg_version
24
+ from pathlib import Path
25
+ from typing import Annotated, Any, TypeVar, cast
26
+
27
+ import pikepdf
28
+ import typer
29
+ from openom_core import SPEC_VERSION
30
+ from openom_core.canonical import canonicalize, hash_bytes
31
+ from openom_core.embed import embed as _embed
32
+ from openom_core.embed import input_encrypted as _input_encrypted
33
+ from openom_core.embed import read as _read
34
+ from openom_core.embed import reembed_warnings as _reembed_warnings
35
+ from openom_core.errors import CanonicalizationError, PayloadTooLargeError, SignedEmbedError
36
+ from openom_core.images import extract_images as _extract_images
37
+ from openom_core.inspect import inspect as _inspect
38
+ from openom_core.validate import validate as _validate
39
+
40
+ from openom_cli import profile as _profile
41
+ from openom_cli import scaffold as _scaffold
42
+ from openom_cli.buildout import listing_to_payload, payload_coverage
43
+ from openom_cli.humanize import footer as _err_footer
44
+ from openom_cli.humanize import humanize_finding as _humanize
45
+
46
+
47
+ def _force_utf8(stream: object) -> None:
48
+ """Emit UTF-8 on a text stream regardless of the OS console codepage (#18).
49
+
50
+ On Windows with a legacy codepage (cp1252) - the default on Python < 3.15 or under
51
+ ``PYTHONUTF8=0`` - the em-dashes / middots in help text and any non-ASCII in JSON output
52
+ mojibake or raise UnicodeEncodeError. Reconfiguring to UTF-8 fixes it everywhere. Text layer
53
+ only: the binary ``embed --out -`` path writes ``sys.stdout.buffer`` and is unaffected.
54
+ """
55
+ reconfigure = getattr(stream, "reconfigure", None)
56
+ if reconfigure is None:
57
+ return # e.g. a test harness replaced stdout with a plain object
58
+ try:
59
+ reconfigure(encoding="utf-8")
60
+ except (ValueError, OSError): # detached / already-written stream - best-effort
61
+ pass
62
+
63
+
64
+ for _std in (sys.stdout, sys.stderr):
65
+ _force_utf8(_std)
66
+
67
+ app = typer.Typer(
68
+ help=(
69
+ "openOM - embed broker-asserted, hash-verified deal data into your OM PDF (and read it "
70
+ "back). Deterministic, zero AI, nothing leaves your machine.\n\n"
71
+ "NOT A DEVELOPER? You don't need this terminal. If you just have an OM PDF and want to "
72
+ "embed your deal, do it in your browser - no install, bytes never leave your device: "
73
+ "https://openom.app/embed/\n\n"
74
+ "Using the CLI? Start here:\n"
75
+ " om init # writes a ready-to-edit deal.json (no more 'no deal.json')\n"
76
+ " om profile set ... # save your name/brokerage/license once - never retype it\n"
77
+ " om validate deal.json # plain-English check before you embed\n"
78
+ " om embed listing.pdf --payload deal.json --out out.pdf --asserted-date <today>\n\n"
79
+ "Global options (--format, --quiet, --version) go BEFORE the command."
80
+ )
81
+ )
82
+
83
+ _F = TypeVar("_F", bound=Callable[..., Any])
84
+ _DATA_ERRORS = (
85
+ CanonicalizationError,
86
+ PayloadTooLargeError,
87
+ SignedEmbedError,
88
+ json.JSONDecodeError,
89
+ pikepdf.PdfError,
90
+ FileNotFoundError,
91
+ UnicodeDecodeError,
92
+ )
93
+
94
+
95
+ def _guard(fn: _F) -> _F:
96
+ """Turn expected data/IO failures into a clean stderr message + exit 3 (never a traceback)."""
97
+
98
+ @functools.wraps(fn)
99
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
100
+ try:
101
+ return fn(*args, **kwargs)
102
+ except typer.Exit:
103
+ raise
104
+ except _DATA_ERRORS as exc:
105
+ code = getattr(exc, "code", None)
106
+ typer.echo(f"error: {f'{code}: ' if code else ''}{exc}", err=True)
107
+ raise typer.Exit(3) from exc
108
+
109
+ return cast("_F", wrapper)
110
+
111
+
112
+ @dataclasses.dataclass
113
+ class _Output:
114
+ """Global output state set by the top-level callback (--format / --quiet)."""
115
+
116
+ fmt: str = "pretty"
117
+ quiet: bool = False
118
+
119
+
120
+ _output = _Output()
121
+
122
+
123
+ def _tool_version() -> str:
124
+ try:
125
+ return _pkg_version("openom-cli")
126
+ except PackageNotFoundError: # pragma: no cover - source tree
127
+ return "0.0.0-dev"
128
+
129
+
130
+ def _version_callback(value: bool) -> None:
131
+ if value:
132
+ typer.echo(f"openom-cli {_tool_version()} (spec {SPEC_VERSION})")
133
+ raise typer.Exit(0)
134
+
135
+
136
+ @app.callback()
137
+ def _main(
138
+ output_format: Annotated[
139
+ str, typer.Option("--format", help="stdout JSON format: pretty | compact")
140
+ ] = "pretty",
141
+ quiet: Annotated[
142
+ bool, typer.Option("--quiet", help="Suppress stdout (exit code + stderr only)")
143
+ ] = False,
144
+ _version: Annotated[
145
+ bool,
146
+ typer.Option(
147
+ "--version",
148
+ "-V",
149
+ help="Print the tool + spec version and exit.",
150
+ is_eager=True,
151
+ callback=_version_callback,
152
+ ),
153
+ ] = False,
154
+ ) -> None:
155
+ """openOM CLI. NOTE: global options (--format, --quiet, --version) go BEFORE the command,
156
+ e.g. `om --format compact read x.pdf`."""
157
+ if output_format not in ("pretty", "compact"):
158
+ typer.echo(f"error: --format must be pretty|compact, got {output_format!r}", err=True)
159
+ raise typer.Exit(2)
160
+ _output.fmt = output_format
161
+ _output.quiet = quiet
162
+
163
+
164
+ def _read_bytes(path: Path) -> bytes:
165
+ """Read a file, or stdin when the path is ``-`` (binary-safe, for piping)."""
166
+ return sys.stdin.buffer.read() if str(path) == "-" else path.read_bytes()
167
+
168
+
169
+ def _write_bytes(path: Path, data: bytes) -> None:
170
+ """Write a file, or stdout when the path is ``-`` (binary-safe)."""
171
+ if str(path) == "-":
172
+ sys.stdout.buffer.write(data)
173
+ sys.stdout.buffer.flush()
174
+ else:
175
+ path.write_bytes(data)
176
+
177
+
178
+ def _load_json(path: Path) -> dict[str, Any]:
179
+ text = sys.stdin.read() if str(path) == "-" else path.read_text(encoding="utf-8")
180
+ return cast("dict[str, Any]", json.loads(text))
181
+
182
+
183
+ def _emit(obj: Any) -> None:
184
+ if _output.quiet:
185
+ return
186
+ if _output.fmt == "compact":
187
+ typer.echo(json.dumps(obj, separators=(",", ":"), ensure_ascii=False))
188
+ else:
189
+ typer.echo(json.dumps(obj, indent=2, ensure_ascii=False))
190
+
191
+
192
+ def _echo_human_errors(errors: list[Any]) -> None:
193
+ """Plain-English error coaching to stderr; stdout keeps the JSON contract (--quiet mutes)."""
194
+ if not errors or _output.quiet:
195
+ return
196
+ for f in errors:
197
+ typer.echo(_humanize(f.code, f.path, f.message), err=True)
198
+ typer.echo(_err_footer(), err=True)
199
+
200
+
201
+ def _echo_embed_usage() -> None:
202
+ """The friendly stand-in for typer's bare 'Missing option' - what to do + the browser escape."""
203
+ typer.echo(
204
+ "error: `embed` needs the OM PDF, the deal data, an output path, and an assertion date.\n"
205
+ " Don't have a deal file yet? om init # writes a ready-to-edit deal.json\n"
206
+ " Then: om embed listing.pdf --payload deal.json --out listing.openom.pdf "
207
+ "--asserted-date <today>\n"
208
+ " Just have a PDF and you're not a developer? Skip the terminal - embed in your browser "
209
+ "(nothing leaves your machine): https://openom.app/embed/",
210
+ err=True,
211
+ )
212
+
213
+
214
+ def _require_input_pdf(pdf: Path) -> None:
215
+ if str(pdf) == "-" or pdf.exists():
216
+ return
217
+ typer.echo(f"error: input PDF not found: {pdf}. Check the path and try again.", err=True)
218
+ raise typer.Exit(3)
219
+
220
+
221
+ def _require_payload(payload: Path) -> None:
222
+ if str(payload) == "-" or payload.exists():
223
+ return
224
+ typer.echo(
225
+ f"error: payload file not found: {payload} - this is the deal data to embed, and it "
226
+ f"doesn't exist yet. Create it first: om init {payload} (writes a ready-to-edit "
227
+ f"template), edit the values, then re-run. Not a developer with just a PDF? Embed in your "
228
+ f"browser instead: https://openom.app/embed/",
229
+ err=True,
230
+ )
231
+ raise typer.Exit(3)
232
+
233
+
234
+ @app.command()
235
+ @_guard
236
+ def embed(
237
+ pdf: Annotated[Path | None, typer.Argument(help="Input OM PDF (required)")] = None,
238
+ payload: Annotated[
239
+ Path | None,
240
+ typer.Option(help="Deal-data JSON to embed (required; create one with `om init`)"),
241
+ ] = None,
242
+ out: Annotated[Path | None, typer.Option(help="Output PDF path (required)")] = None,
243
+ asserted_date: Annotated[
244
+ str | None, typer.Option(help="ISO 8601 assertion date (required), e.g. 2026-08-24")
245
+ ] = None,
246
+ mirror: Annotated[
247
+ bool,
248
+ typer.Option(
249
+ help="Also write the JSON-LD web mirror (<out>.jsonld) - the exact bytes the "
250
+ "domain-origin badge verifies against ([M2])"
251
+ ),
252
+ ] = False,
253
+ validate: Annotated[
254
+ bool,
255
+ typer.Option(
256
+ help="Validate the payload against the 0.1 schema first and REFUSE (exit 1) on schema "
257
+ "errors, like embed-batch (recommended; off by default so drafts can be piped)."
258
+ ),
259
+ ] = False,
260
+ ) -> None:
261
+ """Embed deal data into an OM PDF. Not a developer with just a PDF? Skip the terminal - embed in
262
+ your browser (nothing leaves your machine): https://openom.app/embed/"""
263
+ if pdf is None or payload is None or out is None or asserted_date is None:
264
+ _echo_embed_usage()
265
+ raise typer.Exit(2)
266
+ _require_input_pdf(pdf)
267
+ _require_payload(payload)
268
+ src = _read_bytes(pdf)
269
+ data = _load_json(payload)
270
+ if _input_encrypted(src) and not _output.quiet:
271
+ typer.echo(
272
+ "warning: this OM is permission-encrypted; the embedded copy will be UNENCRYPTED "
273
+ "(open/print/copy restrictions removed). Keep the original if you need those.",
274
+ err=True,
275
+ )
276
+ if _profile.merge_into(data) and not _output.quiet:
277
+ typer.echo("note: filled assertedBy from your saved profile (`om profile show`)", err=True)
278
+ if validate:
279
+ report = _validate(data) # defaults to the bundled 0.1 schema
280
+ if report.errors:
281
+ for f in report.errors:
282
+ typer.echo(_humanize(f.code, f.path, f.message), err=True)
283
+ typer.echo(_err_footer(), err=True)
284
+ raise typer.Exit(1)
285
+ for w in _reembed_warnings(src, data, asserted_date=asserted_date):
286
+ typer.echo(f"warning {w.code} {w.path}: {w.message}", err=True)
287
+ embedded = _embed(src, data, asserted_date=asserted_date)
288
+ _write_bytes(out, embedded)
289
+ if mirror and str(out) == "-":
290
+ typer.echo("warning: --mirror ignored with `--out -` (stdout); pass a file path", err=True)
291
+ if mirror and str(out) != "-":
292
+ mpath = out.with_suffix(".jsonld")
293
+ # The mirror MUST be the canonical (JCS) preimage bytes so its hash == the embedded
294
+ # payloadHash; read the payload back from the PDF so it reflects exactly what was stamped.
295
+ mpath.write_bytes(canonicalize(_read(embedded).payload or data))
296
+ if not _output.quiet:
297
+ typer.echo(f"wrote web mirror -> {mpath}", err=True)
298
+ # Status to stderr so `--out -` keeps a clean binary PDF on stdout for piping.
299
+ if not _output.quiet:
300
+ typer.echo(f"embedded om.json -> {'<stdout>' if str(out) == '-' else out}", err=True)
301
+
302
+
303
+ @app.command()
304
+ @_guard
305
+ def init(
306
+ out: Annotated[Path, typer.Argument(help="Where to write the starter payload")] = Path(
307
+ "deal.json"
308
+ ),
309
+ template: Annotated[
310
+ str, typer.Option(help="Which shape to start from: stnl | multifamily | proforma")
311
+ ] = "stnl",
312
+ force: Annotated[bool, typer.Option(help="Overwrite an existing file")] = False,
313
+ ) -> None:
314
+ """Write a ready-to-edit starter deal.json, so 'no deal.json' can never happen.
315
+
316
+ The file is schema-valid EXAMPLE data - swap in your deal's numbers, then `om embed`. Your saved
317
+ broker profile (`om profile set`) fills assertedBy automatically."""
318
+ if template not in _scaffold.TEMPLATES:
319
+ typer.echo(
320
+ f"error: unknown template {template!r} - choose one of: "
321
+ f"{', '.join(_scaffold.TEMPLATES)}",
322
+ err=True,
323
+ )
324
+ raise typer.Exit(2)
325
+ if out.exists() and not force:
326
+ typer.echo(
327
+ f"error: {out} already exists - pass --force to overwrite, or `om init my-deal.json`",
328
+ err=True,
329
+ )
330
+ raise typer.Exit(3)
331
+ saved = _profile.profile_asserted_by()
332
+ doc = _scaffold.build_skeleton(
333
+ template, today=datetime.date.today().isoformat(), profile_asserted_by=saved
334
+ )
335
+ out.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
336
+ if not _output.quiet:
337
+ for line in _scaffold.guidance_lines(template, str(out), has_profile=bool(saved)):
338
+ typer.echo(line, err=True)
339
+
340
+
341
+ profile_app = typer.Typer(help="Save your broker identity once so you never retype it.")
342
+ app.add_typer(profile_app, name="profile")
343
+
344
+
345
+ @profile_app.command("set")
346
+ def profile_set(
347
+ broker: Annotated[str | None, typer.Option(help='Your name, e.g. "Jane Broker"')] = None,
348
+ brokerage: Annotated[str | None, typer.Option(help="Your brokerage")] = None,
349
+ license: Annotated[
350
+ str | None, typer.Option(help='Your license id, e.g. "MI 6501-000000"')
351
+ ] = None,
352
+ ) -> None:
353
+ """Save your name / brokerage / license to this device; `om init` and `om embed` reuse them."""
354
+ if broker is None and brokerage is None and license is None:
355
+ typer.echo(
356
+ "nothing to set - pass --broker/--brokerage/--license, e.g.\n"
357
+ ' om profile set --broker "Jane Broker" --brokerage "Acme" --license "MI 6501-000000"',
358
+ err=True,
359
+ )
360
+ raise typer.Exit(2)
361
+ prof = _profile.save_profile(broker=broker, brokerage=brokerage, license=license)
362
+ ab = prof["assertedBy"]
363
+ typer.echo(
364
+ f"Saved your broker profile -> {_profile.profile_path()} "
365
+ "(on this device; you won't retype it)",
366
+ err=True,
367
+ )
368
+ for key in ("broker", "brokerage", "license"):
369
+ if ab.get(key):
370
+ typer.echo(f" {key + ':':11}{ab[key]}", err=True)
371
+ typer.echo("`om init` and `om embed` will fill assertedBy from this automatically.", err=True)
372
+
373
+
374
+ @profile_app.command("show")
375
+ def profile_show() -> None:
376
+ """Print your saved broker profile (or how to set one)."""
377
+ prof = _profile.load_profile()
378
+ if not prof.get("assertedBy"):
379
+ typer.echo(
380
+ "No profile saved yet. Set one so you never retype it:\n"
381
+ ' om profile set --broker "Your Name" --brokerage "Your Co" --license "..."',
382
+ err=True,
383
+ )
384
+ return
385
+ _emit(prof)
386
+
387
+
388
+ @profile_app.command("path")
389
+ def profile_path_cmd() -> None:
390
+ """Print the file where your profile is stored (edit it directly if you like)."""
391
+ typer.echo(str(_profile.profile_path()))
392
+
393
+
394
+ def _embed_one(task: dict[str, Any]) -> dict[str, Any]:
395
+ """Process one batch item. Top-level + picklable so it runs in a worker process (--jobs). Pure
396
+ given resolved paths; re-imports the core so ProcessPoolExecutor spawn works everywhere."""
397
+ from openom_core.embed import embed as _e
398
+ from openom_core.embed import reembed_warnings as _rw
399
+ from openom_core.validate import validate as _v
400
+
401
+ rec: dict[str, Any] = {"index": task["index"], "pdf": task["pdf"], "out": task["out"]}
402
+ try:
403
+ payload = json.loads(Path(task["payload"]).read_text(encoding="utf-8"))
404
+ date = task["date"]
405
+ report = _v(payload, schema=task["schema"], as_of=date)
406
+ rec["warnings"] = [f.code for f in report.warnings]
407
+ if not report.ok: # schema errors block this item (Rule 6)
408
+ rec["status"] = "skipped"
409
+ rec["errors"] = [f"{f.code}: {f.path}" for f in report.errors]
410
+ return rec
411
+ src = Path(task["pdf"]).read_bytes()
412
+ # supersedes / backwards-date notes surfaced per item
413
+ rec["reembed"] = [w.code for w in _rw(src, payload, asserted_date=date)]
414
+ out = Path(task["out"])
415
+ if out.exists() and task["skip_existing"]:
416
+ rec["status"] = "skipped-existing"
417
+ return rec
418
+ if out.exists() and not task["force"]:
419
+ rec["status"] = "error"
420
+ rec["errors"] = ["output exists (use --force, or --skip-existing to resume)"]
421
+ return rec
422
+ if task["dry_run"]:
423
+ rec["status"] = "would-embed"
424
+ return rec
425
+ out.parent.mkdir(parents=True, exist_ok=True)
426
+ out.write_bytes(_e(src, payload, asserted_date=date))
427
+ rec["status"] = "embedded"
428
+ except Exception as e: # one bad item never aborts the batch # noqa: BLE001
429
+ rec["status"] = "error"
430
+ rec.setdefault("errors", []).append(str(e))
431
+ return rec
432
+
433
+
434
+ @app.command(name="embed-batch")
435
+ @_guard
436
+ def embed_batch( # noqa: C901 - a linear orchestrator (resolve -> dispatch -> report), read top-down
437
+ manifest: Annotated[
438
+ Path | None, typer.Option(help="JSON array of {pdf, payload, out?, assertedDate?} items")
439
+ ] = None,
440
+ dir: Annotated[ # noqa: A002 - the user-facing flag name
441
+ Path | None,
442
+ typer.Option(help="Directory of *.pdf, each paired with a sibling *.om.json (or *.json)"),
443
+ ] = None,
444
+ out_dir: Annotated[
445
+ Path, typer.Option(help="Output dir for items without an explicit 'out'")
446
+ ] = Path("openom-out"),
447
+ asserted_date: Annotated[
448
+ str | None, typer.Option(help="Default ISO 8601 assertion date for items without one")
449
+ ] = None,
450
+ schema: Annotated[
451
+ Path | None, typer.Option(help="JSON Schema; schema-invalid payloads are skipped")
452
+ ] = None,
453
+ dry_run: Annotated[bool, typer.Option(help="Validate + report only; write nothing")] = False,
454
+ skip_existing: Annotated[
455
+ bool, typer.Option(help="Skip items whose output already exists (resume a large run)")
456
+ ] = False,
457
+ force: Annotated[bool, typer.Option(help="Overwrite existing outputs")] = False,
458
+ jobs: Annotated[int, typer.Option(help="Parallel workers (processes) for large catalogs")] = 1,
459
+ report: Annotated[Path | None, typer.Option(help="Write the JSON summary to this file")] = None,
460
+ ) -> None:
461
+ """Embed openOM payloads into many OMs in one run - back-catalog seeding (adoption).
462
+
463
+ Source the batch from a --manifest (JSON array; paths relative to the manifest) OR a --dir of
464
+ PDFs each with a sibling <name>.om.json payload. Deterministic, non-destructive, idempotent
465
+ (re-embed replaces + records ``supersedes``; those notes are surfaced per item). Schema errors
466
+ skip that item (Rule 6); consistency warnings never block. --dry-run previews, --skip-existing
467
+ resumes, --jobs parallelizes. Emits a JSON summary (counts + per-item results); exits non-zero
468
+ if any item errored or was schema-skipped.
469
+ """
470
+ if bool(manifest) == bool(dir):
471
+ typer.echo("error: pass exactly one of --manifest or --dir", err=True)
472
+ raise typer.Exit(2)
473
+ if dir:
474
+ base = dir.resolve()
475
+ raw_items: list[dict[str, Any]] = []
476
+ for p in sorted(base.glob("*.pdf")):
477
+ names = (f"{p.stem}.om.json", f"{p.stem}.json")
478
+ sidecar = next((s for s in names if (base / s).exists()), None)
479
+ raw_items.append({"pdf": p.name, "payload": sidecar} if sidecar else {"pdf": p.name})
480
+ else:
481
+ assert manifest is not None
482
+ loaded = json.loads(_read_bytes(manifest).decode("utf-8"))
483
+ if not isinstance(loaded, list):
484
+ typer.echo("error: OM-IO manifest must be a JSON array", err=True)
485
+ raise typer.Exit(3)
486
+ raw_items = loaded
487
+ base = manifest.resolve().parent
488
+
489
+ schema_obj = _load_json(schema) if schema is not None else None
490
+ tasks: list[dict[str, Any]] = []
491
+ errors: list[dict[str, Any]] = [] # normalization failures, kept out of the worker pool
492
+ seen_out: dict[str, int] = {}
493
+ for i, item in enumerate(raw_items):
494
+ try:
495
+ if not isinstance(item, dict) or not item.get("pdf") or not item.get("payload"):
496
+ raise ValueError("item needs 'pdf' and a resolvable 'payload' (sidecar missing?)")
497
+ pdf_path = (base / str(item["pdf"])).resolve()
498
+ date = str(item.get("assertedDate") or asserted_date or "")
499
+ if not date:
500
+ raise ValueError("no assertedDate (set it on the item or pass --asserted-date)")
501
+ out_path = (
502
+ (base / str(item["out"])).resolve()
503
+ if item.get("out")
504
+ else (out_dir.resolve() / f"{pdf_path.stem}.pdf")
505
+ )
506
+ if out_path == pdf_path:
507
+ raise ValueError("output would overwrite the input PDF")
508
+ if str(out_path) in seen_out:
509
+ raise ValueError(f"two items target the same output ({out_path})")
510
+ seen_out[str(out_path)] = i
511
+ tasks.append({
512
+ "index": i, "pdf": str(pdf_path),
513
+ "payload": str((base / str(item["payload"])).resolve()),
514
+ "out": str(out_path), "date": date, "schema": schema_obj,
515
+ "dry_run": dry_run, "skip_existing": skip_existing, "force": force,
516
+ })
517
+ except Exception as e: # noqa: BLE001
518
+ errors.append({"index": i, "pdf": str(item.get("pdf", "?")),
519
+ "status": "error", "errors": [str(e)]})
520
+
521
+ if jobs > 1 and len(tasks) > 1 and not dry_run:
522
+ with ProcessPoolExecutor(max_workers=jobs) as ex:
523
+ done = list(ex.map(_embed_one, tasks))
524
+ else:
525
+ done = [_embed_one(t) for t in tasks]
526
+
527
+ results = sorted([*errors, *done], key=lambda r: r["index"])
528
+ counts: dict[str, int] = {}
529
+ for r in results:
530
+ counts[r["status"]] = counts.get(r["status"], 0) + 1
531
+ summary = {"total": len(results), "counts": counts, "dryRun": dry_run, "results": results}
532
+ if report is not None:
533
+ report.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
534
+ if not _output.quiet:
535
+ ok = counts.get("embedded", 0) + counts.get("would-embed", 0)
536
+ tally = " ".join(f"{k}={v}" for k, v in counts.items())
537
+ typer.echo(f"embed-batch: {ok}/{len(results)} ok - {tally}", err=True)
538
+ _emit(summary)
539
+ failed = counts.get("error", 0) + counts.get("skipped", 0)
540
+ raise typer.Exit(code=1 if failed else 0)
541
+
542
+
543
+ @app.command(name="buildout-manifest")
544
+ @_guard
545
+ def buildout_manifest(
546
+ listings_dir: Annotated[
547
+ Path, typer.Option(help="Dir of Buildout get_listing JSON files (named <id>.json)")
548
+ ],
549
+ pdf_dir: Annotated[
550
+ Path, typer.Option(help="Dir with the OM PDFs (named <id>.pdf) for those listings")
551
+ ],
552
+ out_dir: Annotated[
553
+ Path, typer.Option(help="Where payload sidecars + manifest.json are written")
554
+ ],
555
+ broker: Annotated[str, typer.Option(help="assertedBy.broker (who is asserting)")],
556
+ brokerage: Annotated[str, typer.Option(help="assertedBy.brokerage")],
557
+ license: Annotated[str, typer.Option(help="assertedBy.license")], # noqa: A002
558
+ asserted_date: Annotated[str, typer.Option(help="ISO 8601 assertion date")],
559
+ noi_type: Annotated[str, typer.Option(help="in-place | pro-forma (a required assertion)")],
560
+ noi_as_of: Annotated[
561
+ str | None, typer.Option(help="deal.noiAsOfDate (default: --asserted-date)")
562
+ ] = None,
563
+ overrides: Annotated[
564
+ Path | None,
565
+ typer.Option(
566
+ help="JSON {listing-id: {broker,brokerage,license,noiType,noiAsOfDate}} - per-listing "
567
+ "assertion identity overriding the flags (a catalog spans many brokers / NOI types)"
568
+ ),
569
+ ] = None,
570
+ min_fields: Annotated[
571
+ int, typer.Option(help="Flag any mapped payload with fewer than this many tracked fields")
572
+ ] = 3,
573
+ ) -> None:
574
+ """Bridge: turn fetched Buildout listings into an ``om embed-batch`` manifest (catalog seed).
575
+
576
+ Deterministic, zero inference: each get_listing JSON is mapped to a schema-valid openOM payload
577
+ (assertion identity from flags, or per-listing via ``--overrides``, never inferred), written
578
+ as <id>.om.json, and paired with <id>.pdf in a manifest. A ``coverage.json`` reports each
579
+ listing's filled/omitted fields so you can triage BEFORE a bulk embed (Rule 6: review at scale).
580
+ Review/edit the payloads (the assertion gate), then run
581
+ ``om embed-batch --manifest <out-dir>/manifest.json``. Listings with no OM PDF are skipped, each
582
+ with a reason.
583
+ """
584
+ default_by = {"broker": broker, "brokerage": brokerage, "license": license}
585
+ ov: dict[str, dict[str, str]] = (
586
+ json.loads(overrides.read_text(encoding="utf-8")) if overrides else {}
587
+ )
588
+ out_dir.mkdir(parents=True, exist_ok=True)
589
+ manifest: list[dict[str, str]] = []
590
+ skipped: list[dict[str, str]] = []
591
+ coverage: list[dict[str, Any]] = []
592
+ sparse: list[str] = []
593
+ for jf in sorted(listings_dir.glob("*.json")):
594
+ stem = jf.stem
595
+ listing = json.loads(jf.read_text(encoding="utf-8"))
596
+ pdf = pdf_dir / f"{stem}.pdf"
597
+ if not pdf.exists():
598
+ skipped.append({"id": stem, "reason": f"no OM PDF at {pdf.name}"})
599
+ continue
600
+ row = ov.get(stem, {})
601
+ asserted_by = {
602
+ "broker": row.get("broker", default_by["broker"]),
603
+ "brokerage": row.get("brokerage", default_by["brokerage"]),
604
+ "license": row.get("license", default_by["license"]),
605
+ }
606
+ payload = listing_to_payload(
607
+ listing, asserted_by=asserted_by, asserted_date=asserted_date,
608
+ noi_type=row.get("noiType", noi_type),
609
+ noi_as_of=row.get("noiAsOfDate", noi_as_of),
610
+ )
611
+ sidecar = out_dir / f"{stem}.om.json"
612
+ sidecar.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
613
+ manifest.append(
614
+ {"pdf": str(pdf.resolve()), "payload": str(sidecar.resolve()),
615
+ "assertedDate": asserted_date}
616
+ )
617
+ cov = payload_coverage(payload)
618
+ coverage.append({"id": stem, **cov})
619
+ if cov["filled"] < min_fields:
620
+ sparse.append(stem)
621
+ (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
622
+ (out_dir / "coverage.json").write_text(
623
+ json.dumps({"listings": coverage, "sparse": sparse, "minFields": min_fields}, indent=2),
624
+ encoding="utf-8",
625
+ )
626
+ if not _output.quiet:
627
+ note = f", {len(sparse)} sparse (<{min_fields} fields)" if sparse else ""
628
+ typer.echo(
629
+ f"buildout-manifest: {len(manifest)} mapped, {len(skipped)} skipped{note} -> {out_dir}",
630
+ err=True,
631
+ )
632
+ _emit({
633
+ "mapped": len(manifest), "skipped": skipped, "sparse": sparse,
634
+ "manifest": str(out_dir / "manifest.json"), "coverage": str(out_dir / "coverage.json"),
635
+ })
636
+
637
+
638
+ @app.command(name="buildout-pull")
639
+ @_guard
640
+ def buildout_pull(
641
+ endpoint: Annotated[str, typer.Option(help="Buildout MCP Streamable-HTTP endpoint")],
642
+ out_dir: Annotated[
643
+ Path, typer.Option(help="Output dir; writes listings/<id>.json + pdfs/<id>.pdf")
644
+ ],
645
+ ids: Annotated[
646
+ str | None, typer.Option(help="Comma-separated listing ids to pull")
647
+ ] = None,
648
+ ids_file: Annotated[
649
+ Path | None, typer.Option(help="File of listing ids, one per line (alternative to --ids)")
650
+ ] = None,
651
+ search: Annotated[
652
+ str | None,
653
+ typer.Option(help="Enumerate ids via the search tool with this query (whole-catalog pull)"),
654
+ ] = None,
655
+ token: Annotated[
656
+ str | None,
657
+ typer.Option(
658
+ help="Bearer token (or set OPENOM_BUILDOUT_TOKEN)", envvar="OPENOM_BUILDOUT_TOKEN"
659
+ ),
660
+ ] = None,
661
+ listing_tool: Annotated[
662
+ str, typer.Option(help="MCP tool that returns a listing by ref")
663
+ ] = "get_listing",
664
+ search_tool: Annotated[
665
+ str, typer.Option(help="MCP tool that searches/enumerates listings")
666
+ ] = "search_listings",
667
+ skip_existing: Annotated[
668
+ bool, typer.Option(help="Skip listings already pulled (resume a run)")
669
+ ] = False,
670
+ jobs: Annotated[int, typer.Option(help="Concurrent downloads (I/O-bound)")] = 1,
671
+ report: Annotated[
672
+ Path | None, typer.Option(help="Write the JSON summary to this file")
673
+ ] = None,
674
+ ) -> None:
675
+ """Pull a Buildout back-catalog: each listing + its OM PDF in one authenticated pass (#B3).
676
+
677
+ Deterministic, zero inference (a data fetch). Writes ``listings/<id>.json`` + ``pdfs/<id>.pdf``,
678
+ ready for ``om buildout-manifest --listings-dir <out>/listings --pdf-dir <out>/pdfs``. Give ids
679
+ (``--ids``/``--ids-file``) or ``--search <query>`` to enumerate the whole catalog.
680
+ ``--skip-existing`` resumes; ``--jobs`` downloads concurrently. Needs a Buildout MCP endpoint +
681
+ token; listings with no discoverable OM PDF are recorded (``no-om``), the JSON still written.
682
+ """
683
+ from openom_cli.buildout_pull import (
684
+ http_fetch_pdf,
685
+ ids_from_search_result,
686
+ mcp_http_call_tool,
687
+ pull,
688
+ )
689
+
690
+ def get_listing(tool: str, args: dict[str, Any]) -> dict[str, Any]:
691
+ return mcp_http_call_tool(endpoint, token, tool, args)
692
+
693
+ id_list: list[str] = []
694
+ if ids:
695
+ id_list += [s.strip() for s in ids.split(",") if s.strip()]
696
+ if ids_file:
697
+ lines = ids_file.read_text(encoding="utf-8").splitlines()
698
+ id_list += [ln.strip() for ln in lines if ln.strip()]
699
+ if search:
700
+ id_list += ids_from_search_result(get_listing(search_tool, {"query": search}))
701
+ # de-dupe, preserve order
702
+ id_list = list(dict.fromkeys(id_list))
703
+ if not id_list:
704
+ typer.echo("buildout-pull: no listing ids (use --ids, --ids-file, or --search)", err=True)
705
+ raise typer.Exit(code=2)
706
+
707
+ summary = pull(
708
+ id_list,
709
+ get_listing=get_listing,
710
+ fetch_pdf=http_fetch_pdf,
711
+ out_listings_dir=out_dir / "listings",
712
+ out_pdf_dir=out_dir / "pdfs",
713
+ listing_tool=listing_tool,
714
+ skip_existing=skip_existing,
715
+ jobs=jobs,
716
+ )
717
+ if report is not None:
718
+ report.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
719
+ if not _output.quiet:
720
+ tally = " ".join(f"{k}={v}" for k, v in summary["counts"].items())
721
+ typer.echo(
722
+ f"buildout-pull: {summary['pulled']}/{summary['of']} pulled - {tally} -> {out_dir}",
723
+ err=True,
724
+ )
725
+ _emit(summary)
726
+ failed = sum(1 for r in summary["results"] if r["status"] in ("listing-error", "pdf-error"))
727
+ raise typer.Exit(code=1 if failed else 0)
728
+
729
+
730
+ @app.command()
731
+ @_guard
732
+ def mirror(
733
+ src: Annotated[Path, typer.Argument(help="An embedded openOM PDF, or a payload JSON")],
734
+ out: Annotated[
735
+ Path | None,
736
+ typer.Option(help="Output .jsonld path (default: alongside the input; '-' for stdout)"),
737
+ ] = None,
738
+ ) -> None:
739
+ """Emit the JSON-LD web mirror: the EXACT canonical (JCS) preimage bytes ([M2]).
740
+
741
+ Host this next to a listing at a same-domain HTTPS URL and point the badge's ``mirror=`` at it;
742
+ its byte hash equals the embedded ``payloadHash``, so the badge can show domain-origin.
743
+ Reads the payload from an embedded PDF, or canonicalizes a payload JSON directly. Deterministic.
744
+ """
745
+ raw = _read_bytes(src)
746
+ if raw[:5] == b"%PDF-" or (b"%PDF-" in raw[:1024]):
747
+ payload = _read(raw).payload
748
+ if payload is None:
749
+ # [Mi11] "no embedded payload" is a data/IO condition -> exit 3, matching `check`
750
+ # (was exit 1), so a script gets one consistent code for "not an openOM PDF".
751
+ typer.echo("mirror: OM-IO-ABSENT: no openOM payload found in that PDF", err=True)
752
+ raise typer.Exit(code=3)
753
+ else:
754
+ payload = json.loads(raw)
755
+ bytes_out = canonicalize(payload)
756
+ if out is not None and str(out) == "-":
757
+ sys.stdout.buffer.write(bytes_out)
758
+ else:
759
+ dest = out if out is not None else src.with_suffix(".jsonld")
760
+ dest.write_bytes(bytes_out)
761
+ if not _output.quiet:
762
+ typer.echo(f"wrote web mirror ({hash_bytes(bytes_out)}) -> {dest}", err=True)
763
+
764
+
765
+ @app.command()
766
+ @_guard
767
+ def read(pdf: Annotated[Path, typer.Argument(help="PDF to read")]) -> None:
768
+ result = _read(_read_bytes(pdf))
769
+ _emit(
770
+ {
771
+ "present": result.present,
772
+ "payload": result.payload,
773
+ "sourceDocHash": result.source_doc_hash, # #5: provenance of the underlying source PDF
774
+ "verification": {
775
+ "hashValid": result.hash_valid,
776
+ "originVerified": result.origin_verified,
777
+ "signatureValid": result.signature_valid,
778
+ },
779
+ }
780
+ )
781
+
782
+
783
+ @app.command()
784
+ @_guard
785
+ def inspect(pdf: Annotated[Path, typer.Argument(help="PDF to inspect")]) -> None:
786
+ _emit(_inspect(_read_bytes(pdf)))
787
+
788
+
789
+ @app.command()
790
+ @_guard
791
+ def validate(
792
+ payload: Annotated[Path, typer.Argument(help="Payload JSON to validate")],
793
+ schema: Annotated[Path | None, typer.Option(help="JSON Schema (enables error tier)")] = None,
794
+ ) -> None:
795
+ schema_obj = _load_json(schema) if schema is not None else None
796
+ report = _validate(_load_json(payload), schema=schema_obj)
797
+ _emit(
798
+ {
799
+ "errors": [dataclasses.asdict(f) for f in report.errors],
800
+ "warnings": [dataclasses.asdict(f) for f in report.warnings],
801
+ "info": [dataclasses.asdict(f) for f in report.info],
802
+ "ok": report.ok,
803
+ }
804
+ )
805
+ _echo_human_errors(report.errors)
806
+ raise typer.Exit(code=0 if report.ok else 1)
807
+
808
+
809
+ @app.command()
810
+ @_guard
811
+ def check(
812
+ input: Annotated[
813
+ Path, typer.Argument(help="A payload JSON file OR a PDF with an embedded om.json")
814
+ ],
815
+ schema: Annotated[
816
+ Path | None, typer.Option(help="JSON Schema (enables the error tier)")
817
+ ] = None,
818
+ as_of: Annotated[
819
+ str | None, typer.Option(help="Processing date (YYYY-MM-DD) for term/future checks")
820
+ ] = None,
821
+ strict: Annotated[
822
+ bool, typer.Option(help="Exit non-zero on consistency warnings, not just schema errors")
823
+ ] = False,
824
+ ) -> None:
825
+ """Standalone consistency check on a payload OR an embedded-PDF payload (§9 / M1.x).
826
+
827
+ Runs the deterministic consistency tier with no network and no inference. Schema is optional:
828
+ without it, only the internal-consistency (OMW-W###) + info (OMI-I###) tiers run.
829
+ """
830
+ raw = _read_bytes(input)
831
+ source: dict[str, Any] = {"input": str(input)}
832
+ if raw[:5] == b"%PDF-":
833
+ result = _read(raw)
834
+ if not result.present or result.payload is None:
835
+ typer.echo(f"error: OM-IO-ABSENT: no om.json embedded in {input}", err=True)
836
+ raise typer.Exit(3)
837
+ payload = result.payload
838
+ source |= {"kind": "pdf", "hashValid": result.hash_valid}
839
+ else:
840
+ payload = json.loads(raw.decode("utf-8")) # reuse bytes; do not re-read stdin
841
+ source["kind"] = "payload"
842
+
843
+ schema_obj = _load_json(schema) if schema is not None else None
844
+ report = _validate(payload, schema=schema_obj, as_of=as_of)
845
+ _emit(
846
+ {
847
+ "source": source,
848
+ "errors": [dataclasses.asdict(f) for f in report.errors],
849
+ "warnings": [dataclasses.asdict(f) for f in report.warnings],
850
+ "info": [dataclasses.asdict(f) for f in report.info],
851
+ "ok": report.ok,
852
+ }
853
+ )
854
+ _echo_human_errors(report.errors)
855
+ failed = not report.ok or (strict and bool(report.warnings))
856
+ raise typer.Exit(code=1 if failed else 0)
857
+
858
+
859
+ def _embed_pair(
860
+ stem: str,
861
+ pdf: Path,
862
+ payload_path: Path,
863
+ out_dir: Path,
864
+ asserted_date: str,
865
+ schema_obj: dict[str, Any] | None,
866
+ ) -> dict[str, Any]:
867
+ """Validate (if a schema is given) then embed one <name>.pdf + <name>.json pair.
868
+
869
+ A pair with schema ERRORS is skipped and never embedded (schema errors block, §6); warnings do
870
+ not block. Deterministic, zero inference. Returns a per-pair record for the summary.
871
+ """
872
+ data = _load_json(payload_path)
873
+ if schema_obj is not None:
874
+ report = _validate(data, schema=schema_obj)
875
+ if report.errors:
876
+ return {"name": stem, "action": "skipped", "reason": "schema-errors",
877
+ "codes": [e.code for e in report.errors]}
878
+ src = pdf.read_bytes()
879
+ warnings = [w.code for w in _reembed_warnings(src, data, asserted_date=asserted_date)]
880
+ out_path = out_dir / f"{stem}.openom.pdf"
881
+ out_path.write_bytes(_embed(src, data, asserted_date=asserted_date))
882
+ return {"name": stem, "action": "embedded", "out": str(out_path), "warnings": warnings}
883
+
884
+
885
+ def _scan_once(
886
+ in_dir: Path,
887
+ out_dir: Path,
888
+ asserted_date: str,
889
+ schema_obj: dict[str, Any] | None,
890
+ seen: dict[str, tuple[float, float]],
891
+ ) -> list[dict[str, Any]]:
892
+ """One pass: embed each <name>.pdf with a sibling <name>.json that changed since last seen."""
893
+ events: list[dict[str, Any]] = []
894
+ for pdf in sorted(in_dir.glob("*.pdf")):
895
+ payload_path = pdf.with_suffix(".json")
896
+ if not payload_path.is_file():
897
+ continue # not a complete pair yet
898
+ sig = (pdf.stat().st_mtime, payload_path.stat().st_mtime)
899
+ if seen.get(pdf.stem) == sig:
900
+ continue # unchanged since we last processed it
901
+ record = _embed_pair(pdf.stem, pdf, payload_path, out_dir, asserted_date, schema_obj)
902
+ seen[pdf.stem] = sig
903
+ events.append(record)
904
+ return events
905
+
906
+
907
+ @app.command()
908
+ @_guard
909
+ def watch(
910
+ in_dir: Annotated[Path, typer.Argument(help="Folder watched for <name>.pdf+<name>.json pairs")],
911
+ out: Annotated[Path, typer.Option(help="Output folder for <name>.openom.pdf")],
912
+ asserted_date: Annotated[str, typer.Option(help="ISO 8601 assertion date stamped on embeds")],
913
+ schema: Annotated[
914
+ Path | None,
915
+ typer.Option(help="JSON Schema; a payload with schema errors is skipped, not embedded"),
916
+ ] = None,
917
+ once: Annotated[
918
+ bool,
919
+ typer.Option(help="Process the current backlog once and exit (for cron/CI); no polling"),
920
+ ] = False,
921
+ interval: Annotated[
922
+ float, typer.Option(help="Poll interval in seconds (ignored with --once)")
923
+ ] = 2.0,
924
+ ) -> None:
925
+ """Watch a folder and auto-embed each <name>.pdf with a sibling <name>.json (server-side path).
926
+
927
+ Deterministic, zero inference. A pair is (re)processed when its pdf/json changes; the produced
928
+ <name>.openom.pdf lands in --out. With --schema, a payload with schema errors is logged and
929
+ skipped (never embedded). --once drains the current backlog and exits; otherwise it polls every
930
+ --interval seconds until interrupted (Ctrl-C).
931
+ """
932
+ out.mkdir(parents=True, exist_ok=True)
933
+ schema_obj = _load_json(schema) if schema is not None else None
934
+ seen: dict[str, tuple[float, float]] = {}
935
+
936
+ if once:
937
+ events = _scan_once(in_dir, out, asserted_date, schema_obj, seen)
938
+ _emit({"watched": str(in_dir), "out": str(out), "events": events})
939
+ return
940
+
941
+ if not _output.quiet:
942
+ typer.echo(f"watching {in_dir} -> {out} (every {interval}s; Ctrl-C to stop)", err=True)
943
+ try:
944
+ while True:
945
+ for ev in _scan_once(in_dir, out, asserted_date, schema_obj, seen):
946
+ typer.echo(json.dumps(ev, ensure_ascii=False), err=True)
947
+ time.sleep(interval)
948
+ except KeyboardInterrupt: # pragma: no cover - interactive stop
949
+ if not _output.quiet:
950
+ typer.echo("stopped", err=True)
951
+
952
+
953
+ @app.command()
954
+ @_guard
955
+ def extract(
956
+ pdf: Annotated[Path, typer.Argument(help="PDF to extract images from")],
957
+ out_dir: Annotated[Path, typer.Option(help="Directory to write images into")],
958
+ render_vector_pages: Annotated[
959
+ bool, typer.Option(help="Also rasterize pages that have no raster images (vector-only)")
960
+ ] = False,
961
+ ) -> None:
962
+ # Written filenames are img_<xref>.png / page_<n>.png (both integers), so no untrusted path
963
+ # component can escape out_dir - path traversal is not reachable from payload/PDF content.
964
+ data = _read_bytes(pdf)
965
+ _emit(_extract_images(data, out_dir=out_dir, render_vector_pages=render_vector_pages))
966
+
967
+
968
+ @app.command()
969
+ def version() -> None:
970
+ """Print the tool + spec versions."""
971
+ _emit({"tool": "openom-cli", "toolVersion": _tool_version(), "specVersion": SPEC_VERSION})
972
+
973
+
974
+ @app.command()
975
+ @_guard
976
+ def conformance(
977
+ spec_dir: Annotated[Path, typer.Option(help="Path to the spec/ directory")] = Path("spec"),
978
+ role: Annotated[str | None, typer.Option(help="Filter vectors by role")] = None,
979
+ level: Annotated[str | None, typer.Option(help="Filter vectors by level")] = None,
980
+ impl_dir: Annotated[
981
+ Path | None,
982
+ typer.Option(
983
+ help="Certify a THIRD-PARTY implementation: a dir with <name>.canonical (JCS bytes) "
984
+ "and/or <name>.pdf (embedded output) per vector, compared to the goldens. Without "
985
+ "it, the LOCAL implementation is checked."
986
+ ),
987
+ ] = None,
988
+ ) -> None:
989
+ """Run the conformance suite (§T [OM-REF-002]): reproduce the published vectors + sample
990
+ outcomes. Without --impl-dir it checks the local implementation; with --impl-dir it certifies an
991
+ external implementation's produced bytes/PDFs. Exit 0 if all checks pass, 1 otherwise."""
992
+ checks: list[dict[str, Any]] = []
993
+ vectors = spec_dir / "vectors"
994
+ # [Mi8] The conformance vectors are NOT shipped in the wheel (they're the repo's /spec tree), so
995
+ # a plain `pip install openom-cli && om conformance` can't find them. Fail with a clear hint
996
+ # instead of a raw FileNotFoundError.
997
+ if not (vectors / "manifest.json").is_file():
998
+ typer.echo(
999
+ f"error: conformance vectors not found under {spec_dir}/ "
1000
+ "(this command runs from a repo checkout; pass --spec-dir <path-to>/spec)",
1001
+ err=True,
1002
+ )
1003
+ raise typer.Exit(3)
1004
+ manifest = _load_json(vectors / "manifest.json")
1005
+ for vec in manifest["vectors"]:
1006
+ dims = vec.get("dimensions", {})
1007
+ if role is not None and role not in dims.get("role", []):
1008
+ continue
1009
+ if level is not None and level not in dims.get("level", []):
1010
+ continue
1011
+ payload = _load_json(vectors / vec["payload"])
1012
+ expected = _load_json(vectors / vec["expected"])
1013
+ if impl_dir is not None:
1014
+ # Certify the third party: hash THEIR canonical bytes; read THEIR PDF. A missing output
1015
+ # is a FAILED check, not a skip - else an empty --impl-dir would falsely pass.
1016
+ cbytes = impl_dir / f"{vec['name']}.canonical"
1017
+ checks.append(
1018
+ {
1019
+ "check": f"vector:{vec['name']}:jcs",
1020
+ "ok": cbytes.is_file()
1021
+ and hash_bytes(cbytes.read_bytes()) == expected["jcs_sha256"],
1022
+ }
1023
+ )
1024
+ impl_pdf = impl_dir / f"{vec['name']}.pdf"
1025
+ if impl_pdf.is_file():
1026
+ res = _read(impl_pdf.read_bytes())
1027
+ checks.append(
1028
+ {
1029
+ "check": f"vector:{vec['name']}:pdf",
1030
+ "ok": res.present and res.hash_valid is True,
1031
+ }
1032
+ )
1033
+ continue
1034
+ got = hash_bytes(canonicalize(payload))
1035
+ checks.append({"check": f"vector:{vec['name']}:jcs", "ok": got == expected["jcs_sha256"]})
1036
+ pdf_path = vectors / vec["pdf"]
1037
+ if pdf_path.exists():
1038
+ res = _read(pdf_path.read_bytes())
1039
+ ok = res.present and res.hash_valid is True
1040
+ checks.append({"check": f"vector:{vec['name']}:pdf", "ok": ok})
1041
+
1042
+ sample_manifest = spec_dir / "samples" / "manifest.json"
1043
+ if sample_manifest.exists():
1044
+ schema = _load_json(spec_dir / "om-0.1.schema.json")
1045
+ for s in _load_json(sample_manifest)["samples"]:
1046
+ payload = _load_json(spec_dir / "samples" / f"{s['name']}.json")
1047
+ report = _validate(payload, schema=schema)
1048
+ codes = {f.code for f in report.errors}
1049
+ if s["valid"]:
1050
+ ok = report.ok
1051
+ else:
1052
+ ok = not report.ok and all(c in codes for c in s["errorCodes"])
1053
+ checks.append({"check": f"sample:{s['name']}", "ok": ok})
1054
+
1055
+ failures = [c["check"] for c in checks if not c["ok"]]
1056
+ _emit(
1057
+ {"total": len(checks), "passed": len(checks) - len(failures),
1058
+ "failed": len(failures), "failures": failures, "ok": not failures}
1059
+ )
1060
+ raise typer.Exit(code=0 if not failures else 1)
1061
+
1062
+
1063
+ if __name__ == "__main__":
1064
+ app()