claricle 0.1.0 → 0.2.0

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +10 -1
  3. data/LICENSE +25 -0
  4. data/README.adoc +439 -157
  5. data/TODO.plan/00-overview.md +502 -0
  6. data/TODO.plan/01-core.md +296 -0
  7. data/TODO.plan/02-inspect.md +163 -0
  8. data/TODO.plan/03-conform.md +208 -0
  9. data/TODO.plan/04-convert.md +216 -0
  10. data/exe/claricle +1 -1
  11. data/lib/claricle/batch.rb +240 -0
  12. data/lib/claricle/cli.rb +514 -40
  13. data/lib/claricle/conformance_options.rb +49 -0
  14. data/lib/claricle/conversion_engine.rb +21 -0
  15. data/lib/claricle/convert.rb +21 -0
  16. data/lib/claricle/detector.rb +696 -0
  17. data/lib/claricle/emf_header.rb +33 -0
  18. data/lib/claricle/errors.rb +73 -0
  19. data/lib/claricle/fault.rb +69 -0
  20. data/lib/claricle/handlers/base.rb +169 -0
  21. data/lib/claricle/handlers/extension_points.rb +124 -0
  22. data/lib/claricle/handlers/metafile.rb +1011 -0
  23. data/lib/claricle/handlers/pdf.rb +961 -0
  24. data/lib/claricle/handlers/pdf_arlington/definitions.rb +54 -0
  25. data/lib/claricle/handlers/pdf_arlington/kind.rb +62 -0
  26. data/lib/claricle/handlers/pdf_arlington/predicate_gate.rb +39 -0
  27. data/lib/claricle/handlers/pdf_arlington/rules.rb +86 -0
  28. data/lib/claricle/handlers/pdf_arlington/walker.rb +131 -0
  29. data/lib/claricle/handlers/pdf_arlington.rb +38 -0
  30. data/lib/claricle/handlers/pdf_profiles.rb +66 -0
  31. data/lib/claricle/handlers/png.rb +1107 -0
  32. data/lib/claricle/handlers/postscript.rb +1357 -0
  33. data/lib/claricle/handlers/svg.rb +787 -0
  34. data/lib/claricle/image.rb +272 -0
  35. data/lib/claricle/lossiness/levels.rb +13 -0
  36. data/lib/claricle/lossiness.rb +864 -0
  37. data/lib/claricle/models/base.rb +460 -0
  38. data/lib/claricle/models/batch_item.rb +127 -0
  39. data/lib/claricle/models/conversion.rb +167 -0
  40. data/lib/claricle/models/format_capability.rb +40 -0
  41. data/lib/claricle/models/free_form_hash.rb +77 -0
  42. data/lib/claricle/models/inspection.rb +188 -0
  43. data/lib/claricle/models/issue.rb +23 -0
  44. data/lib/claricle/models/location.rb +51 -0
  45. data/lib/claricle/models/report.rb +54 -0
  46. data/lib/claricle/models/uncoerced_integer.rb +38 -0
  47. data/lib/claricle/postscript_fidelity/verdict.rb +37 -0
  48. data/lib/claricle/postscript_fidelity/view_box.rb +82 -0
  49. data/lib/claricle/postscript_fidelity.rb +160 -0
  50. data/lib/claricle/registry.rb +125 -0
  51. data/lib/claricle/version.rb +1 -1
  52. data/lib/claricle/writer.rb +468 -0
  53. data/lib/claricle.rb +244 -7
  54. metadata +201 -19
  55. data/claricle.gemspec +0 -42
  56. data/sig/claricle.rbs +0 -4
@@ -0,0 +1,502 @@
1
+ # Claricle Unified Library Plan
2
+
3
+ **Date**: 2026-08-11. Restructured from the reviewed single-file plan
4
+ (git history: `docs/plans/issue-1-unified-library.md`, removed in this
5
+ restructure — these files are self-contained).
6
+ **Status**: complete as of 2026-10-09. Every acceptance item below is
7
+ implemented or covered by a signed-off scope decision.
8
+ **Goal**: turn `claricle` from a placeholder shim into the real umbrella
9
+ library for issue #1 — one Ruby API and one CLI providing **inspect**,
10
+ **conformance check**, and **conversion** over PNG, SVG, EMF/WMF, PS/EPS,
11
+ and PDF, delegating to the org's specialist gems.
12
+
13
+ > Local working docs under `docs/plans/*` live only on the maintainer's
14
+ > machine and are never committed. The plan files here are the
15
+ > authoritative, self-contained copy.
16
+
17
+ ## Architecture
18
+
19
+ ```mermaid
20
+ flowchart TD
21
+ API["Claricle.conform? / Claricle.convert / Claricle::Image"]
22
+ DET["Detector<br/>magic-byte sniffer"]
23
+ REG["Registry<br/>frozen format→handler map"]
24
+ API --> DET
25
+ DET --> REG
26
+ REG --> HPNG[Handlers::Png]
27
+ REG --> HSVG[Handlers::Svg]
28
+ REG --> HMET["Handlers::Metafile<br/>(:emf only — D14)"]
29
+ REG --> HPS["Handlers::Postscript<br/>(:eps, :ps)"]
30
+ REG --> HPDF[Handlers::Pdf]
31
+ HPNG --> png_conform
32
+ HSVG --> svg_conform
33
+ HSVG --> vectory
34
+ HMET --> emf
35
+ HMET --> vectory
36
+ HPS --> postscript
37
+ HPS --> vectory
38
+ HPDF --> pdfrb
39
+ HPNG -. maps results into .-> MOD["Models::Inspection / Report /<br/>Issue / Location / Conversion<br/>(lutaml-model)"]
40
+ HSVG -.-> MOD
41
+ HMET -.-> MOD
42
+ HPS -.-> MOD
43
+ HPDF -.-> MOD
44
+ ```
45
+
46
+ Every handler is a plain subclass declaring its formats once. As built,
47
+ `registry.rb` loads every file in `handlers/` and derives
48
+ `HANDLER_CLASSES` from `Base.subclasses`, so adding a format costs ONE
49
+ file: a handler class that declares its formats, its `detect` block and,
50
+ for an inbound conversion, its target list and feature-loss rules. (The
51
+ original design here — a `require_relative`, a list entry and a detector
52
+ probe per format — was replaced; `spec/claricle/one_class_per_format_spec.rb`
53
+ proves the one-file claim in a fresh process.)
54
+
55
+ ## Item topology
56
+
57
+ Strictly sequential — each item builds on the previous one, and an item
58
+ may span several stacked PRs. Every PR is independently green and
59
+ shippable. 04 depends on 03 because the CLI batch helper is built in 03
60
+ and reused verbatim in 04.
61
+
62
+ ```mermaid
63
+ flowchart LR
64
+ SO["issue #1 sign-off"] --> I01[01 core]
65
+ I01 --> I02[02 inspect] --> I03[03 conform] --> I04[04 convert]
66
+ SO -. D14 D18 .-> I02
67
+ SO -. D16 D22 D23 .-> I03
68
+ SO -. D11 .-> I04
69
+ ```
70
+
71
+ ## Decision record
72
+
73
+ No decision blocks implementation — all twenty-four are settled. Items
74
+ run in sequence for the ordinary reason that each builds on the last.
75
+
76
+ The six reported narrowings are posted to issue #1 for visibility. If
77
+ the author objects to one, the affected item is revisited then; we do
78
+ not hold work waiting for a response to a report.
79
+
80
+ ## Items
81
+
82
+ | # | Item | Delivers | Can start |
83
+ |---|------|----------|-----------|
84
+ | 01 | Core | errors, models, detector, registry, `Image`, CLI runner + exit codes, stub removal, Ruby 3.3 floor, honesty baseline | now |
85
+ | 02 | Inspect | five handlers' `inspection`, `claricle inspect`, `claricle formats`, `--json` | after 01 |
86
+ | 03 | Conform | conformance mappings for png/svg/emf/pdf, `--profile`, `Claricle.conform?`, `claricle conform`, batch helper, `--strict` | after 02 |
87
+ | 04 | Convert | vectory-backed conversion, lossiness classification, `claricle convert`, README rewrite | after 03 |
88
+
89
+ ## Contracts falsified by execution (2026-08-12)
90
+
91
+ Adversarial execution rounds ran the delegates instead of reading
92
+ them. Ten "verified" facts in earlier drafts were false. They are
93
+ recorded because the plan asserted each one confidently, and the same
94
+ mistake is easy to repeat. The pattern is always the same: reading
95
+ agreed with reading, and only running the thing disagreed.
96
+
97
+ | Claim the plan made | What running it showed |
98
+ |---|---|
99
+ | `Emf.detect_format` returns `:emf`/`:wmf`/nil, never raises | Raises `Emf::FormatError` on **every** unrecognised input — empty, short, plain text, XML, PNG bytes. Never returned nil in five trials |
100
+ | WMF inspect + conform work through `emf` | `Emf.parse` on WMF bytes raises `WMF parser not yet implemented` in released 0.1.0 |
101
+ | png_conform gives `error_type`, chunk and offset | `validate_file` → `result_builder#add_context_messages` passes only `e[:message]`. Type, chunk and offset are all dropped |
102
+ | svg_conform's `type` carries severity | `ErrorTracker#add_error` hardcodes `type: :error` and keeps real severity in a separate field. Mapping from `type` flattens info and warning into error |
103
+ | A 4096-byte binary `<svg` regex is XML-tolerant | vectory accepts UTF-16LE+BOM, `<s:svg>` prefixed roots, internal DTD subsets, and a root at byte 5028. The regex matched none of the last three |
104
+ | SVG→EMF is the lossy direction (emfsvg "lossy matcher") | That phrase describes a 0.1px **comparison tolerance**, not a lossy direction. Issue #1 names EMF→SVG as the one that drops semantics |
105
+ | `postscript` and `pdfrb` are not installable here | Both install cleanly (`pdfrb` 0.7.10, `postscript` 0.2.0). The earlier draft confused "absent from every local gemset" with "unavailable" and treated it as blocking |
106
+ | `libpng` is unused, no v1 operation needs it | `vectory → emfsvg → libpng ~> 1.6`, and emfsvg uses it for embedded images in SVG→EMF. It is a live transitive dependency. Only Claricle's **direct** dependency is redundant |
107
+ | vectory's own round-trip specs are semantic | vectory 0.12.0 has no A→B→A suite at all — only one-way conversion and reference tests, some checking little more than a format signature. The claim was invented |
108
+ | Ruby floor 3.2 comes from pdfrb (asserted, unverified) | Only the delegate fact was true: confirmed 2026-08-13, `pdfrb` 0.7.10 requires `>= 3.2.0`; every other delegate tops out at 3.1 (`emf`, `svg_conform`, `vectory`, `emfsvg` 3.1; `lutaml-model`, `png_conform`, `postscript` 3.0). That establishes the highest delegate minimum, not Claricle's deliberate 3.3 project floor |
109
+
110
+ ## Measured delegate contracts (2026-08-13)
111
+
112
+ Everything below was **executed** against the installed gems using
113
+ generated fixtures, not read from source. Where an item file marks a
114
+ call ⚙ it points here. If you change a delegate version, re-run these
115
+ before trusting the plan.
116
+
117
+ **Reproducing these measurements.** The harness that produced them is
118
+ maintainer-local and deliberately not committed, so this section must
119
+ carry enough to rebuild it. Fixtures, all generated rather than vendored:
120
+
121
+ - a 100×50 SVG with a `rect` and a **red** stroke — the red matters, it
122
+ is what trips svg_conform's default profile;
123
+ - real EMF, EPS and PS produced from that SVG through vectory, so they
124
+ are genuine files rather than hand-forged headers;
125
+ - that EMF truncated to a third, and separately with only its final 20
126
+ bytes (the EOF record) removed;
127
+ - a hand-built 1×1 RGBA PNG plus variants: CRC-corrupt IDAT, duplicate
128
+ `IHDR`, duplicate `IEND`, trailing bytes after `IEND`, no `IDAT`;
129
+ - both WMF headers — standard `0100 0900` and placeable `d7cdc69a`;
130
+ - a `Pdfrb::Composer`-built PDF and a catalog-less broken one;
131
+ - malformed SVG variants: unclosed element, two roots, trailing
132
+ garbage, raw binary, and a *valid* SVG with no width/height/viewBox.
133
+
134
+ Run each delegate call in the tables below against those and compare.
135
+ A delegate version bump invalidates this section until it is re-run.
136
+
137
+ **Detection**
138
+
139
+ | Probe | Measured |
140
+ |---|---|
141
+ | PNG / PDF / EPS / PS signatures | `89504e470d0a1a0a`, `%PDF-1.4`, `%!PS-Adobe-3.0 EPSF-3.0`, `%!PS-Adobe-3.0` — the EPSF token on line 1 is the only eps/ps discriminator |
142
+ | `Emf.detect_format` | `:emf` for valid **and truncated** EMF; `:wmf` for both the standard (`0100 0900`) and placeable (`d7cdc69a`) headers; **raises `Emf::FormatError` for all six other fixtures** |
143
+
144
+ **Models — lutaml-model `~> 0.8.32` (currently resolved to 0.8.97)**
145
+
146
+ | Contract | Measured |
147
+ |---|---|
148
+ | Invalid enum value | Constructs **and serializes** to `{"severity":"bogus"}`. Only an explicit `validate!` raises |
149
+ | Empty collection | **Omitted from JSON entirely** — `Report.new(issues: [])` emits `{"source_path":"x.png"}` with no `issues` key. Force it in the mapping |
150
+ | Nested round trip | `byte_offset` and `chunk` survive `to_json`/`from_json` |
151
+ | Post-build mutation | `report.issues << ...` is accepted, so a stored verdict goes stale. `valid` must derive on read |
152
+ | `Tempfile` | `defined?(Tempfile)` is nil without a require |
153
+
154
+ **Inspection**
155
+
156
+ | Delegate | Measured |
157
+ |---|---|
158
+ | png_conform | `Readers::FullLoadReader` exposes `each_chunk`, `signature`, `png`, `file_size` — the metadata path, separate from validation |
159
+ | vectory SVG | `100x50` with width/height, `10x10` from viewBox alone. `#width` raises `Vectory::NotImplementedError` when **no** dimension source exists — including on a perfectly valid SVG — while a malformed SVG carrying `width="7"` returns 7. The error means "no dimensions", not "parse failed" |
160
+ | emf | `Emf.parse` → `Model::Metafile` with `ok?`, `errors`, `errors?`, `emf_plus`, `header`, `records`. Truncation does not have one exception contract: the current fixture matrix raises `Emf::FormatError`, `IOError`, or `EOFError` at different cut points |
161
+ | vectory EPS/PS | `100.0x50.0` — floats, not integers |
162
+ | pdfrb | `Document.open` → version `1.4`, 1 page. **Opens the broken PDF without complaint** — `open` does not validate |
163
+
164
+ **Conformance**
165
+
166
+ | Delegate | Measured |
167
+ |---|---|
168
+ | emf | `ok? == true`, `errors == []` on a clean parse — **but also on a file with its EOF record removed** (15 records vs 16). Not sufficient alone; see the coverage table |
169
+ | svg_conform | `validate(content, profile:)` → `ValidationResult`. **The default profile is the strict RFC one and rejects an ordinary red stroke** (`color_restrictions`). Under `base` the same file is clean. **`available_profiles` is cache-dependent** — it returns all six initially, then only the one most recently loaded until `Profiles.clear_cache!`, so never enumerate it lazily for the `formats` output. **UTF-32 input is silently certified clean**: a UTF-32LE document with a wrong namespace, no viewBox and a red stroke returned `valid? == true, errors == []`, while UTF-16 and ISO-8859-1 versions of the same document caught all three. vectory rejects the same bytes with `Nokogiri::XML::SyntaxError: Document is empty`. Profiles: `base`, `lucid_fix`, `metanorma`, `no_external_css`, `svg_1_2_rfc`, `svg_1_2_rfc_with_rdf`. `line` and `column` came back **nil** on every issue seen |
170
+ | png_conform | Location survives on **exactly one** path: `ValidationService.new(reader, path).validate` then `context.all_errors` → `{chunk_type: "IDAT", message: "CRC error in IDAT chunk", severity: :error, offset: 33}`. Both `validate_file` **and** `result.validation_result.errors` return `chunk_type: nil, chunk_offset: nil` for the same input, so it is the context object specifically, not the service generally |
171
+ | postscript | **No conformance basis.** `Postscript.parse` accepted an unmatched `}`, an undefined operator, a missing operand, and raw binary. Only an unterminated string raised |
172
+ | pdfrb structural | `Validator.validate` → `[]` on a valid doc. Failure modes are **not uniform**: a catalog-less document raises `Pdfrb::Error`, a `/Pages` pointing at a missing object raises `NoMethodError: undefined method '[]' for nil`, and a dangling unrelated reference returns an error *string*. So the allowlist cannot be a single class |
173
+ | vectory 0.12.0 re-verified | Re-measured 2026-08-18 on the installed line. All edges tried work (`svg→emf/eps/ps`, `emf→svg`, `eps→svg`); same-format conversion raises `NoMethodError`; `Emf.from_content` accepts SVG bytes and constructs happily. **D23's evidence reproduces exactly**: a linear gradient raises `Emfsvg::FormatError: unsupported SVG color: "url(#g)"` on `svg→emf`, and on `svg→eps` succeeds with the gradient silently dropped (the fill is emitted with no colour set, so it renders solid black, as in the table below) — the same feature, loud on one edge and silent on the other. That is why lossiness is per-conversion-from-content, not a per-edge label |
174
+ | svg_conform 0.2.2 result API | `SvgConform.validate_file(path, profile:)` returns a `ValidationResult` exposing `valid?`, `errors`, `warnings`, `error_count`, `warning_count`, `issue_count` and `profile`. There is **no `issues` method** — reading one raises `NoMethodError`. Each entry is a `SvgConform::Errors::ValidationIssue` with `message`, `element_name`, `line`, `column`, `fixable?`, `remediation`. Measured: `line` and `column` are **nil** on every issue, so SVG issues carry no position |
175
+ | svg_conform default profile (D21) | Re-confirmed on **0.2.2**: an ordinary red stroke fails the default profile with `"Color 'red' in attribute 'stroke' is not allowed in this profile"`, and passes under `base`. The same document with no stroke colour passes both |
176
+ | svg_conform wants a viewBox | Measured on 0.2.2: a valid SVG with `width`/`height` but no `viewBox` fails **both** the default and `base` profiles with `"SVG root element must have a viewBox attribute"`. Worth stating in the README — plenty of legitimate SVGs omit it, and `conform` will call them nonconformant |
177
+ | png_conform readers | `Services::ValidationService.new(reader, path)` needs a **`Readers::FullLoadReader.new(path)`**. Measured on 0.1.4: `FullLoadReader.new` takes a filepath **or IO**, so handing it raw bytes raises `ArgumentError: path name contains null byte`. `StreamingReader` fails on every input shape tried — `Errno::EINVAL` for a File in `"rb"`, `IOError` for a `StringIO`, `NoMethodError` for a path |
178
+ | **png_conform leaves a false verdict behind after a failed `validate`** | In all three `StreamingReader` cases above, `validate` raised **and** `context.all_errors` was left holding `{chunk_type: "SIGNATURE", message: "Invalid PNG signature"}` for a byte-perfect valid PNG. A handler that rescues the exception and reads the context gets a confident wrong answer rather than an error. Rescue must not fall through to reading the context |
179
+ | pdfrb profiles | `Conformance::PdfA.validate(doc, level: :a1b)` → `ValidationResult`. Read violations through **`.violations`** (or `.errors`/`.warnings`/`.infos`/`.violation_count`/`.passed?`), never by enumerating the result — see the trap below. Each is a `Violation` struct `{rule_id, message, object, severity, spec_clause}` plus `error?`/`warning?`, e.g. `6.1-2 "PDF/A requires /Catalog/Metadata XMP stream"`, severity `:error`. `PdfUA.validate(doc)` takes no level. Fully usable |
180
+ | **`ValidationResult` is Enumerable over its struct members, not its violations** | Measured on the original 0.7.10 line and the current 0.7.49 resolution: it is a keyword-init `Struct` including `Enumerable` with members `profile` and `violations`, so `result.size` is **2**, `result.first` is the String `"PDF/A-1"`, and `result.map(&:class)` is `[String, Array]`. On the fixture used, `size` happened to equal `violation_count`, so a naive `.size` looks right and is wrong everywhere else. Use `.violations` |
181
+ | pdfrb version drift | The contracts above were first measured on **0.7.10**; the gemspec now constrains `~> 0.7.23` and the current bundle resolves **0.7.49**. The focused PDF specs exercise `Document.open`, structural validation, mapped `Violation` objects, named profiles and their levels against that resolution. `VeraPdfBridge.validate` takes `(pdf_bytes, profile:)` — bytes, not a document. Ruby floor is still `>= 3.2.0` |
182
+ | pdfrb Arlington | `Arlington::Loader` offers only `list_object_names`, `object_definition`, `clear_cache!`. No upstream document runner or `Conformance` profile references it; Claricle now supplies the document walk described by D16 |
183
+
184
+ **What the delegates' conformance checks actually catch**
185
+
186
+ This is the most consequential measurement in the plan. Every delegate
187
+ was fed deliberately invalid files. **None of them is a complete
188
+ structural validator**, and three of them certify plainly broken input
189
+ as conformant.
190
+
191
+ | Format | Delegate check | Catches | **Misses — certifies as conformant** |
192
+ |---|---|---|---|
193
+ | PNG | png_conform | CRC errors (with chunk + offset), missing IDAT | **duplicate `IHDR`, duplicate `IEND`, trailing bytes after `IEND`** — all reported 0 errors, 0 warnings |
194
+ | SVG | svg_conform `base` | profile requirements on a well-formed document | **everything structural.** Unclosed elements, two root elements, trailing garbage, and raw binary (`\x00\x01\x02\xFF not xml`) each returned **0 errors** |
195
+ | EMF | `Emf#ok?` | some truncation points (raises `Emf::FormatError` at 77 B) | **almost everything else.** All measured `ok? == true, errors == []`: final EOF record removed; truncated to 116 B (1 record surviving against 16); declared record count rewritten to 0, 1, 16 or 4294967295; junk appended after the end. Note the intact file reports `header.n_records == 17` against 16 parsed records: the header counts itself, so the declared count is a usable cross-check as parsed records + 1, and Claricle makes it (`lib/claricle/handlers/metafile.rb:543-544`) |
196
+ | EPS/PS | `Postscript.parse` | unterminated strings only | unmatched `}`, undefined operators, missing operands, raw binary (D22) |
197
+ | PDF | `Validator.validate` | catalog/pages/MediaBox/references | it is a pre-write integrity check, not ISO 32000 conformance |
198
+
199
+ The pattern: these are **requirements checkers layered on an assumed-valid
200
+ parse**, not validators. They answer "does this document satisfy rule
201
+ set X", having already assumed it is a document. Claricle promised
202
+ users "is this file conformant", which is a different and stronger
203
+ question.
204
+
205
+ So `conform` could not be a thin delegation. Claricle now owns a
206
+ structural well-formedness layer per format ahead of the delegate and
207
+ documents precisely what each check covers. Decision **D23** settled
208
+ that design, and the SVG, PNG and EMF structural pre-passes are delivered.
209
+
210
+ **Lossiness is per-feature, not per-edge — D10 as written cannot work**
211
+
212
+ The same edge is lossless for one document and destructive for another,
213
+ so a static per-edge enum cannot express the truth. Measured on SVG→EMF
214
+ and SVG→EPS:
215
+
216
+ | SVG content | SVG→EMF | SVG→EPS |
217
+ |---|---|---|
218
+ | rect + line | clean, stable | clean, stable |
219
+ | linear gradient | **raises** `Emfsvg::FormatError: unsupported SVG color: "url(#grad)"` | succeeds — gradient **silently becomes solid black** |
220
+ | clip path | succeeds — clipped object **silently becomes invisible** | succeeds — clip **silently removed** |
221
+ | embedded raster | preserved but re-encoded | succeeds — image **silently deleted**. On a cold process it first raises `NameError: uninitialized constant Postsvg::Model::UnknownOperator`, then succeeds after an unrelated conversion warms it — a load-order bug that will look intermittent |
222
+ | text | succeeds; cycles are byte-stable after the first | succeeds, but geometry drifts on later cycles |
223
+
224
+ Issue #1 requires "no silent lossy conversions". Three of these lose
225
+ content with no error and no warning, which is exactly what it forbids.
226
+ A per-edge table would label SVG→EPS `:lossless` on the strength of a
227
+ rect and then silently discard a gradient. So lossiness has to be
228
+ decided per conversion by inspecting what the source actually contains,
229
+ or the edge label has to be pessimistic enough to be useless. **D23**
230
+ settled this with per-content feature-loss classification.
231
+
232
+ **Round-trip idempotence does not generalise**
233
+
234
+ The earlier claim — "every cycle after the first is byte-identical" —
235
+ was measured on one rect and one line. A repeated probe with a single
236
+ `<text>` element separates the two chains:
237
+
238
+ ```
239
+ via emf: 9768016f 9768016f 9768016f c1==c2: true c2==c3: true
240
+ via eps: 82e95046 c1895d8a 26b657e3 c1==c2: false c2==c3: false
241
+ ```
242
+
243
+ The EMF route stabilises after its first rewrite, including the text
244
+ geometry. The EPS route does not: its viewBox grows `0 0 100 50` →
245
+ `0 0 100 90` → `0 0 100 180`, while the text y coordinate moves
246
+ `-40` → `-90` → `-180`. One stable route does not establish a
247
+ general round-trip invariant when another supported route keeps
248
+ drifting, so idempotence still cannot serve as the correctness backbone
249
+ D11 proposed.
250
+
251
+ **Conversion from non-SVG sources, and two traps**
252
+
253
+ Measured with EMF, EPS and PS as sources (files produced by vectory
254
+ itself, so genuine):
255
+
256
+ - All nine cross-format edges from those three sources succeed on the
257
+ rect fixture, and `emf→svg→emf`, `eps→svg→eps`, `ps→svg→ps` are each
258
+ byte-stable across three cycles. The text probe above shows that this
259
+ does not generalise: its EMF route stabilises while its EPS route
260
+ drifts. Idempotence depends on both the content and the conversion
261
+ route.
262
+ - **Converting a file to the format it already is raises
263
+ `NoMethodError`** — `Vectory::Svg` has no `to_svg`, `Vectory::Emf` no
264
+ `to_emf`, and so on. `claricle convert x.svg --to svg` would surface
265
+ as an internal error and exit 4. It must be caught as an
266
+ `InvocationError` → exit 2, or defined as an explicit no-op copy.
267
+ - **`from_content` does not validate the content type.** Handing EPS
268
+ bytes to `Vectory::Emf`, or EMF bytes to `Vectory::Eps`, or SVG text
269
+ to `Vectory::Emf`, all construct successfully; the failure only
270
+ surfaces later at conversion. So a handler must never treat a
271
+ successful `from_content` as evidence the bytes are that format —
272
+ detection stays the sole authority.
273
+
274
+ **Conversion — all twelve vectory edges succeed, on a thin fixture**
275
+
276
+ | | → svg | → emf | → eps | → ps |
277
+ |---|---|---|---|---|
278
+ | **svg** | — | ok | ok | ok |
279
+ | **emf** | ok | — | ok | ok |
280
+ | **eps** | ok | ok | — | ok |
281
+ | **ps** | ok | ok | ok | — |
282
+
283
+ Round-trip behaviour over three cycles on both chains: conversion is
284
+ **deterministic** (same input, identical bytes) and the first pass
285
+ **rewrites** the file (213 B → 496 B). On the rect-and-line fixture
286
+ every later cycle was byte-identical to its predecessor — but that
287
+ property is fixture-specific and collapses on real content, as the
288
+ idempotence section above shows. Determinism is the only round-trip
289
+ property measured to hold generally.
290
+
291
+ ## Delegate verification record — RUN 2026-08-13
292
+
293
+ Both previously-unverified delegates were
294
+ installed and inspected. Findings:
295
+
296
+ - **`postscript` 0.2.0**, Ruby `>= 3.0`. Entry points `Postscript.parse`
297
+ / `.serialize` / `.tokenize`, plus `Postscript::Source.parse`. All
298
+ errors descend from `Postscript::Error` — `ParseError`, `LexError`,
299
+ `SyntaxError`, `UndefinedOperatorError`, `StackUnderflowError`,
300
+ `RecursionLimitError`, `SizeLimitError`. The exception-only reporting
301
+ the plan assumed is real.
302
+ - **`pdfrb` 0.7.10** was the originally verified release; the gemspec now
303
+ constrains `~> 0.7.23` and the current bundle resolves **0.7.49**. Its
304
+ Ruby floor remains `>= 3.2`, the highest delegate minimum; Claricle's
305
+ deliberate project floor is 3.3.
306
+ `Pdfrb::Document.open` exists as assumed. `Validator.validate` /
307
+ `validate!` are class methods and perform structural checks.
308
+ `Conformance` ships named standards: `PdfA` (A1–A4), `PdfUA`, `PdfX`,
309
+ `PdfVT`, `Pades`, `Ltv`, `Pdf2AF`, `TaggedPdf`, plus `Rule`,
310
+ `RuleSet`, `ValidationResult`, `Violation` and a `VeraPdfBridge`.
311
+ - **No upstream Arlington document runner exists.** `Pdfrb::Arlington`
312
+ ships `Loader`, `Predicate`, `ObjectDefinition`, `FieldDefinition`,
313
+ `Type`, `PdfVersion`, but `Arlington::Loader` offers only
314
+ `list_object_names` and `object_definition` — it loads the grammar,
315
+ it does not validate a document against it. No `Conformance` profile
316
+ references Arlington. Claricle therefore supplies that document walk
317
+ itself; D16 records the delivered scope.
318
+
319
+ Still true and still binding: **every delegate contract in the item
320
+ files is an assumption until executed.** Mark it ⚙ and run it before
321
+ writing a spec against it. Ten entries in the table above are what
322
+ happens otherwise.
323
+
324
+ ## Decisions of record (2026-08-10, revised 2026-10-09)
325
+
326
+ Twenty-four decisions, **all settled**. Issue #1 specified the goal in
327
+ detail and we have measured the ground it stands on; choosing scope,
328
+ design and validation strategy inside that goal is our job, not the
329
+ author's.
330
+
331
+ Six of them narrow something the issue explicitly named, so they are
332
+ **reported** in the issue rather than decided silently — the author can
333
+ object to any of them:
334
+
335
+ | Decision | What narrowed |
336
+ |---|---|
337
+ | D2 | `Image#inspect` renamed to `#inspection` (Ruby owns `inspect`) |
338
+ | D11 | Byte-identical round trips are unreachable; idempotence is route- and content-specific, not general |
339
+ | D14 | WMF unsupported — no upstream parser |
340
+ | D17 | `Inspection#valid` becomes `parse_status` |
341
+ | D18 | EMF+ payload never validated — no upstream parser |
342
+ | D22 | EPS/PS conform unsupported — the parser certifies raw binary |
343
+
344
+ Everything else is settled and needs no discussion:
345
+ D5 (pdfrb's 3.2 minimum and libpng transitivity were measured; Claricle's 3.3 floor is deliberate), D15 (dimensions
346
+ follow the shape the issue itself sketched), D19 (positional-or-glob
347
+ covers both the issue's examples and ambiguous filenames), D20 (PNG
348
+ locations turned out available), D21 (svg_conform's own default rejects
349
+ ordinary SVGs), D23 (the issue asks for canonical conformance, so we
350
+ own the structural pre-pass; v1 scope is engineering sequencing), and
351
+ D10 folded into D23.
352
+
353
+ | # | Decision | Status |
354
+ |---|----------|--------|
355
+ | D1 | Four vertical-slice items; every item documents the commands and API it ships in README.adoc. **Amended 2026-08-18: an item may be split across several stacked PRs** — item 01 reached ~3,100 lines, which reviews as a skim rather than a review, so the author chose reviewability over one-PR-per-item. The constraint that survives: any PR that removes a documented command must carry the README correction with it, so no PR lands describing something it just deleted. 04 still does the full rewrite | settled — amended |
356
+ | D2 | `Image#inspect` → `Image#inspection` (`Object#inspect` stays Ruby's debugging protocol; no module-level facade — it would shadow `Module#inspect`) | settled — report: renames an API the issue named |
357
+ | D3 | Plain handler subclasses + `formats` declaration macro; frozen derived registry, no runtime mutation, no self-registration | settled |
358
+ | D4 | All unified models are lutaml-model classes. The three-segment constraint is **`~> 0.8.32`**, currently resolved to **0.8.97** — `~> 0.8` would admit 0.9 through 0.99 and contradict D13. Model invariants (severity enum, non-nil message) are enforced at construction **and** deserialization. `Report#valid` is derived, not stored, so appending an issue can't leave it stale | settled |
359
+ | D5 | Hard deps on all delegates; Claricle's deliberate Ruby floor is **3.3**. The current pdfrb resolution is **0.7.49** and retains the highest delegate minimum at 3.2 (everything else tops out at 3.1). No **direct** `libpng` dependency — it is already in the tree via `vectory → emfsvg` and emfsvg uses it for embedded images, so a direct dep would be redundant, not an exclusion. Heavy gems required lazily inside handlers; `emf` (bindata-only, powers the detector) is the sole eager require | settled — no sign-off needed |
360
+ | D6 | Keep Thor at `>= 1.2, < 2`; 1.0 and 1.1 fail to load on supported Ruby 3.4 because they reference the removed `DidYouMean::SPELL_CHECKERS`. `Runner` dispatches directly so operation errors reach the exit-code map. The CLI treats EPIPE as success only around actual `help` and `version` output | settled |
361
+ | D7 | Hand-rolled detector (no marcel): PNG signature, `%PDF-`, `%!PS` + `EPSF` first-line split, `Emf.detect_format` **wrapped in a `rescue Emf::FormatError`** so an unrecognised file continues to the next probe, and encoding-aware XML root detection for SVG — decode the BOM/declaration, resolve the root QName, require the SVG namespace, and disable external entity and DTD expansion (XXE). The 4096-byte binary regex is dropped as proven insufficient | settled |
362
+ | D8 | Tri-state `valid`, decided in order: any `error` → `no`; else any `warning` → `suspicious`; else (`info` only, or no issues at all) → `yes`. `info` never downgrades validity. Non-strict `conform?` passes `yes` AND `suspicious`; `--strict`/`strict:` requires `yes` | settled |
363
+ | D9 | Conversion via vectory. **All twelve edges between svg/emf/eps/ps were measured working**, so the earlier "only these are verified" hedge is retired — v1 exposes the full matrix rather than an arbitrary subset. PNG/PDF stay inspect+conform only (vectory has no class for them). EMF+ handling is **not settled here** — see D18. What is measured is only that `Emf::EmfPlus::Parser.call` raises "EMF+ parser not yet implemented", which constrains the options without choosing among them | settled |
364
+ | D10 | **Static per-edge lossiness cannot work — measured.** The same edge is clean for a rect and destructive for a gradient, a clip path or an embedded image, and three of those lose content with no error at all — precisely the "silent lossy conversion" issue #1 forbids. Lossiness must be decided per conversion from what the source document actually contains, with a per-edge table serving only as a pessimistic floor. The `:lossless`/`:lossy`/`:unknown` vocabulary stands; the static-table mechanism does not. Folded into D23's scope | **superseded — see D23** |
365
+ | D11 | Round-trip: **byte identity is unreachable and idempotence is not a general invariant.** Byte identity against the original never held — the first pass always rewrites. The rect-and-line fixture stabilises on the measured routes. With a `<text>` element, the EMF route also stabilises after its first rewrite, but the EPS route changes on every cycle and its geometry drifts. One stable route cannot establish the issue's proposed correctness backbone across the supported matrix. What remains: determinism (same input → identical bytes, measured true), same-format parse→serialize identity for EMF, and per-feature semantic assertions over a fixture corpus. The author chooses what replaces the backbone | settled — report: the issue's stated general correctness backbone is unreachable |
366
+ | D12 | Batch argument handling is defined once, by D19 — a positional is a literal path when it names an existing file and a glob otherwise, with `--pattern` forcing glob interpretation. `--pattern` **adds to** any positionals rather than replacing them; the combined set is deduplicated by realpath and processed in sorted order. `Dir.glob(pattern).sort`; zero matches → 2; failures don't stop the batch; exit = highest code. Every batch-capable JSON output is **always an array**, including a single result. Each slot is one `Models::BatchItem{path, status, exit_code, result, error}` envelope — never a mixed `Report`/failure array, so `jq '.[].result.valid'` can't silently return null for an operational failure. `--output` single-source only; derived names, `--force` to overwrite; `--output -` = bytes-only stdout, rejects `--json`. ONE batch helper (built in 03, reused in 04) | settled |
367
+ | D13 | Constrain against released delegate versions and **stop calling `~>` a pin** — `~> 0.7` admits every 0.x below 1.0, and this gem commits no lockfile, so a clean build can install an unreviewed API. Use three-segment constraints on the reviewed line. Version floors are stated once, in the item that adds the dependency; D4 does not restate them | settled |
368
+ | D13a | **The measured conversion graph is not reproducible without pinning the engines.** vectory 0.12.0 permits any pre-1.0 `emfsvg` and `postsvg`, and this gem commits no lockfile, so a clean install can silently swap the code that produced every conversion measurement in this plan. Either constrain the measured engine versions directly or add a CI check that fails when resolved versions drift from the ones recorded here | settled |
369
+ | D14 | **WMF leaves v1 entirely — proposed, not forced.** Missing upstream support does not by itself decide the question; implementing a WMF parser, or waiting for one, are defensible alternatives, exactly as D22 acknowledges for PostScript. What is forced is that it cannot ship *through `emf` 0.1.0*. Released `emf` 0.1.0 raises `WMF parser not yet implemented`, so no WMF operation can ship through the chosen delegate. The detector still recognises `:wmf`; no handler registers it, so it raises `UnsupportedFormat` → exit 3, which is the honest answer. Note the issue *body* does ask for WMF conformance even though the acceptance checklist omits it | settled — report: narrows the format list, though the issue already prescribes UnsupportedFormat here |
370
+ | D15 | **Dimensions follow the shape issue #1 already sketched** — `#<Inspection format=:emf, width=800, height=600, dpi=96, meta={...}>`. So `width`/`height` are plain numbers in the format's own device or user units, `dpi` is a separate nullable field carrying physical resolution where the format records it, and everything format-native goes in `meta`. Sources measured: EMF `header.device_pixels` with `device_mm` (100×50 px against 26×13 mm, so dpi derives); PNG `ImageInfo` from IHDR plus pHYs; SVG declared width/height with the viewBox in `meta`; EPS/PS BoundingBox. Normalize to a consistent numeric type — vectory returns Integer for SVG and Float for EPS. **Cross-format dimension equality is never asserted**, because `3×1` as SVG and `96×48` as EPS are both correct answers about different things | settled |
371
+ | D16 | PDF conformance combines `Pdfrb::Validator.validate` structural findings with Claricle's Arlington document walk. `Handlers::PdfArlington` drives pdfrb's Arlington tables from the Catalog down, checking required keys, value types and enumerated names; this fills the upstream gap where pdfrb provides the grammar but no runner. Named standards remain opt-in via `--profile NAME`, mapping to `Pdfrb::Conformance::{PdfA,PdfUA,PdfX,PdfVT,Pades,Ltv,Pdf2AF,TaggedPdf}` and their measured `Violation{rule_id, message, object, severity, spec_clause}` results | settled — Arlington delivered in PR #55 |
372
+ | D17 | `Inspection#valid` becomes `parse_status` (`"ok"`/`"failed"`), and inspection stops making any validity claim — it would otherwise mean five different things across five delegates, and vectory parses SVG in Nokogiri RECOVER mode so a repaired file would read as valid. Issue #1 names a `valid` field on inspection, so this changes the public model | settled — report: renames a field the issue named |
373
+ | D18 | EMF+ is surfaced as inspection metadata only — **proposed, same reasoning as D14**: the released parser raises "not yet implemented", but writing one is a defensible alternative the author may prefer. its payload is never validated, because the released EMF+ parser is unimplemented. Issue #1 includes EMF+ conformance | settled — report: narrows conformance coverage |
374
+ | D19 | CLI batch accepts `FILE...` positionals **and** honours the issue's `PATTERN` examples: a positional is a literal path when it names an existing file, and a glob otherwise. `--pattern` forces glob interpretation for the ambiguous case (a filename legitimately containing glob characters). This satisfies the issue's CLI as written without the unquoted-glob trap | settled — supports both, so nothing is deviated from |
375
+ | D20 | PNG issues carry full `location` — `{chunk, byte_offset}` — via `ValidationService.new(reader, path)` then `context.all_errors`, measured returning `{chunk_type: "IDAT", severity: :error, offset: 33}`. Only the `validate_file` convenience wrapper discards them, so the handler must not use it. Issue #1's location contract is met for PNG | settled — an earlier draft wrongly escalated this |
376
+ | D21 | **SVG conform defaults to the `base` profile, not svg_conform's own default.** Measured: svg_conform's default rejects an ordinary red stroke under `color_restrictions`; the same file is clean under `base`. Shipping the delegate's default would tell users their valid SVG is broken on their first run. Other profiles (`metanorma`, `svg_1_2_rfc`, `svg_1_2_rfc_with_rdf`, `no_external_css`, `lucid_fix`) are opt-in through the same `--profile` flag as PDF. `Report#profile` records which ran | settled — the delegate's default is simply wrong for a generic tool |
377
+ | D22 | **PostScript/EPS conformance ships as unsupported in v1.** Measured: `Postscript.parse` accepted an unmatched `}`, an undefined operator, a missing operand and raw binary; only an unterminated string raised. There is no conformance check to delegate to, so "clean parse = conformant" would certify garbage. `conform` on `:eps`/`:ps` raises `UnsupportedFormat` → exit 3 until either upstream adds real checking or we write DSC structural checks ourselves. Inspection and conversion are unaffected | settled — report: removes an operation the issue listed |
378
+ | D23 | **Claricle owns a structural well-formedness pre-pass.** No delegate is a complete validator (see the coverage table) and svg_conform certifies raw binary as a conformant SVG. Issue #1 asks us to "validate that a file conforms to its format's canonical specification", so canonical conformance is the target and the delegates cannot reach it alone. **v1 covers the measured holes** — encoding and XML well-formedness for SVG, chunk-sequence rules for PNG, EOF and trailing-byte checks for EMF — and `conform` documents per format exactly what it checks. Fuller per-format validators are follow-up work. Sequencing is an engineering call, not a product one; what the author needs to know is the coverage gap, which the README will state | settled |
379
+
380
+ ## Exit codes (all commands)
381
+
382
+ | Code | Meaning |
383
+ |------|---------|
384
+ | 0 | success / conformance passed |
385
+ | 1 | conformance failed (issues found) |
386
+ | 2 | invocation error (bad args, file not found) |
387
+ | 3 | unsupported format or conversion |
388
+ | 4 | internal error / parser crash |
389
+
390
+ Mapping rules the runner must honor: a dedicated `InvocationError` maps
391
+ to 2 — do not blanket-map `ArgumentError`, since a delegate's accidental
392
+ `ArgumentError` is an internal defect, not bad user input. `LoadError`
393
+ maps to 4 and must be caught explicitly, because `ScriptError` is not a
394
+ `StandardError` and would otherwise escape as process status 1 — the
395
+ status reserved for nonconformance. Each delegate gets an allowlist of
396
+ expected malformed-input exceptions, per operation and per stage.
397
+ **What an allowlisted failure means depends on the operation**: under
398
+ `conform` it is nonconformance and exits 1; under `inspect` it is
399
+ `parse_status: "failed"` and exits 0, because reporting that a file
400
+ doesn't parse is a successful inspection. Anything off the allowlist is
401
+ an unexpected fault and exits 4 under either. Without that list, 1
402
+ versus 4 is decided by which delegate happens to report via a return
403
+ value and which via an exception. `Runner.run` returns an integer; only
404
+ `exe/claricle` calls `exit`. Nothing raises `SystemExit` through the
405
+ runner.
406
+
407
+ ## Format × operation target matrix
408
+
409
+ | Format | Inspect | Conform | Convert to |
410
+ |--------|---------|---------|------------|
411
+ | png | 02 | 03 | — |
412
+ | svg | 02 | 03 (`base` profile, D21) | emf, eps, ps (04) |
413
+ | emf | 02 | 03 | svg, eps, ps (04) |
414
+ | wmf | — | — | — (D14: recognised, never handled, exit 3) |
415
+ | eps / ps | 02 | — (D22: no basis exists) | svg, emf, and each other (04) |
416
+ | pdf | 02 | 03 (structural + Arlington; profiles via `--profile`, D16) | — |
417
+
418
+ Every cell above was executed against a real fixture on 2026-08-13 —
419
+ see the measured-contracts section. Two deliberate holes, both with a
420
+ decision behind them: WMF entirely (D14, no upstream parser) and
421
+ EPS/PS conform (D22, no conformance basis exists).
422
+
423
+ All twelve conversion edges ship, because all twelve were measured
424
+ working. The earlier subset was caution, not evidence.
425
+
426
+ **`capabilities` tracks what has shipped, never what is planned —
427
+ because it is derived, not declared.** A handler's capabilities are
428
+ read off the operations it overrides on `Handlers::Base`, so
429
+ `claricle formats` is truthful at every commit, including the commits
430
+ inside an item. Amended 2026-08-18: an earlier draft used a
431
+ `capabilities` class macro. A declaration is a second place to state
432
+ what the code already says, and it can advertise an operation that is
433
+ still `Base`'s raising stub — the exact lie the macro existed to
434
+ prevent. Deriving makes that unrepresentable.
435
+
436
+ So 02 gets `inspect` by implementing `inspection`; 03 gets `conform`
437
+ the same way, handler by handler; 04 gets `convert`. Item 04's
438
+ per-handler **target list stays a real declaration**, because a list of
439
+ targets is data rather than a boolean. The `formats` output spec still
440
+ moves with the handlers, since the command's expected output changes as
441
+ operations land.
442
+
443
+ ## Acceptance criteria → items
444
+
445
+ - [x] `Claricle::Image.from_path(...).inspection` for PNG, SVG, EMF, PS/EPS, PDF → 02 (WMF removed per D14)
446
+ - [x] `Claricle.conform?` delegates for png, svg, emf, pdf → 03; eps/ps refused per D22
447
+ - [x] `Claricle.convert` covers EMF↔SVG, PS/EPS↔SVG, SVG→EPS → 04
448
+ - [x] CLI inspect/conform/convert, human + JSON → 02/03/04
449
+ - [x] `claricle formats` support matrix → 02 (command, inspect only), grows in 03 and 04, complete at 04 (`spec/claricle/cli_spec.rb`, examples "prints what png can actually do", "claims conform and convert only where a handler implements them", and "emits a fixed row shape under --json")
450
+ - [x] Handler registry documented; adding a format costs ONE handler class in `handlers/` (`lib/claricle/registry.rb` loads that directory and derives `HANDLER_CLASSES`; README "Adding a format"; `spec/claricle/one_class_per_format_spec.rb`, example "picks up a dropped-in handler class for detect, dispatch, formats and convert"). → 01 (code), 01 step 7b (README)
451
+ - [x] Exit codes match the matrix → 01 (runner, all rows incl. 4), verified per command in 02/03/04; conform and convert each reach 4 through `spec/fixtures/faulting_handler/boom.rb` (`spec/claricle/cli_spec.rb`, examples "exits 4 when the handler raises outside its allowlist, and says what"), #63
452
+ - [x] Conformance specs on canonical fixtures → 03 (`spec/fixtures/canonical/**`, `canonical_fixtures_spec.rb`, #64); round-trip specs per D11 → 04 are done (`determinism_spec.rb`, `emf_identity_spec.rb`)
453
+ - [x] `compress` stub removed → 01
454
+ - [x] README.adoc and gemspec truthful → 01 (full baseline), extended per item, final rewrite 04 (`documentation_spec.rb`)
455
+
456
+ Two promises the plan must own rather than escalate:
457
+
458
+ - Issue #1 names `Claricle.detect(io)`, but 01 defines only
459
+ `Detector.detect(bytes)` / `detect_path`. **01 adds the public
460
+ `Claricle.detect` facade accepting an IO or a String.** No sign-off
461
+ needed — it is a missing method, not a design question.
462
+ - The byte **range** the issue asks for is delivered by `Location`'s
463
+ `byte_offset + byte_length` half-open pair (01). Done: measured on
464
+ main, `lib/claricle/models/location.rb` carries both, each refused if
465
+ negative or non-integer (`spec/claricle/models_spec.rb`, examples
466
+ "refuses a negative byte range at both doors" and "refuses a non-Integer range").
467
+
468
+ Promises narrowed by a signed-off decision, and traceable to it: WMF
469
+ support (D14), EMF+ payload conformance (D18), EPS/PS conformance (D22)
470
+ and cross-format round trips (D11). Arlington is delivered by D16, and
471
+ PNG locations are **not** in this list
472
+ — D20 restored them, so that promise is met rather than narrowed.
473
+
474
+ ## Global constraints (every item)
475
+
476
+ - Ruby >= 3.3; CI matrix `['3.3', '3.4', '4.0']`; RuboCop `TargetRubyVersion: 3.3`.
477
+ The gemspec admits Ruby 3.4+, so CI should cover the newest stable line
478
+ it actually allows.
479
+ - Claricle's own code is pure Ruby; delegates are not (vectory pulls in
480
+ Nokogiri, a native extension). The issue's "to the extent possible"
481
+ governs. Measured 2026-10-09 on be34132: 186 fixtures through conform and
482
+ convert made 0 subprocess calls (positive control 3/3).
483
+ - lutaml-model for every serialized model and CLI result shape — no
484
+ hand-rolled `to_h`/`from_h`. `formats --json` uses
485
+ `Models::FormatCapability` (#72).
486
+ - `frozen_string_literal: true` in every file. GHA runs the default task
487
+ on Ruby 3.3/3.4/4.0 before merge; local work runs only the specs changed
488
+ by that PR.
489
+ - Real specs, no `double()`; fixtures from the delegates' canonical corpora.
490
+ Met: `grep -rn "double(\|instance_double\|class_double" spec/` finds only a
491
+ comment (#63); canonical fixtures landed in #64. `allow(...).to receive`
492
+ partial stubs remain in `cli_spec.rb` (the EPIPE examples such as
493
+ "maps an inspect operation's broken pipe to 4"); they
494
+ are not `double()`.
495
+ - Commit subjects: one line, under 10 words. A forward rule only: 38 of
496
+ 268 commits already on main break it, and pushed history is not rewritten.
497
+ - ~~Every item passes the full Pre-Push Review Chain before push
498
+ (thermo-nuclear → dependency-contract-check → execution-diff → Codex → copilot-review).~~
499
+ Superseded by Hassan 2026-10-09: use TDD, run only changed specs, open
500
+ the PR, and merge after GHA is green. No extra review chain.
501
+ - Out of scope entirely: compression, rendering/rasterization, editing,
502
+ `arroolio`, WMF conversion, `libpng`, gem release (maintainer-driven, post-04).