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,208 @@
1
+ # 03 — Conform: unified reports, `Claricle.conform?`, batch helper
2
+
3
+ Can start: after 02. D16 (PDF scope), D22 (EPS/PS) and D23 (pre-pass)
4
+ settle what this item builds.
5
+
6
+ ## Problem
7
+
8
+ Each delegate reports validity in a different shape (typed issues,
9
+ error strings, exceptions, or parse-error lists). The issue demands one
10
+ typed `Report` with uniform `Issue{severity, code, message, location}`
11
+ across every format that has a conformance basis, a module-level API,
12
+ and a batch-capable CLI
13
+ command that exits 1 on nonconformance.
14
+
15
+ ## Design
16
+
17
+ Mapping layer per handler — `location` is nullable throughout;
18
+ synthetic stable codes where upstream has none; never invent
19
+ coordinates:
20
+
21
+ | Delegate result | → Issue mapping |
22
+ |---|---|
23
+ | png_conform, **not** via `validate_file` | Build `Readers::FullLoadReader.new(path)`, pass it to `ValidationService.new(reader, path)`, call `validate`, then read **all three buckets — `context.all_errors`, `all_warnings`, `all_info`**. Location lives on the **context object only**: both `validate_file` and `result.validation_result.errors` return `chunk_type: nil, chunk_offset: nil` for the same input. `all_errors` alone silently drops warnings and info, which would break D8's tri-state and `--strict`. Measured error shape: `{chunk_type: "IDAT", message: "CRC error in IDAT chunk", severity: :error, offset: 33}`. Map `severity` as-is, `location: {chunk: chunk_type, byte_offset: offset}` (offset may be nil, e.g. a missing IEND). **`byte_length` has no source here** — png_conform reports a start offset only, so the issue's "byte range" is delivered as offset-plus-chunk for PNG, and `byte_length` stays nil unless the pre-pass (D23) computes it from the chunk header, which it can. `code` synthesized stably since there is no upstream type. **Never use `validate_file`** — it returns a `FileAnalysis` with chunk and offset discarded. See D23: this path misses duplicate `IHDR`/`IEND` and trailing bytes entirely |
24
+ | svg_conform `ValidationIssue` | Severity is `severity || type` — `ErrorTracker#add_error` hardcodes `type: :error`, and `severity` was measured **nil** on some issues, so neither field alone is reliable. Normalize `:validity_error` to `error`. Collect errors, warnings **and** validity errors, not just one bucket. `code: requirement_id`. `line` and `column` were nil on every issue measured, so `location` is usually nil; populate only when present. Run under the `base` profile (D21), and call `Profiles.clear_cache!` or enumerate eagerly — `available_profiles` collapses to just the last-loaded profile after any validation. Note UTF-32 input was measured passing every profile silently, so the structural pre-pass (D23) must settle encoding before profile validation runs — but note D23: `base` returns 0 errors for raw binary, so it cannot stand alone |
25
+ | emf | `Emf.parse` → `Metafile` exposing `ok?` and `errors`. **`ok?` is not sufficient on its own**, and truncation is the sharpest case: of the 164 truncations of `valid.emf` that parse without raising, **16 report `ok? == true`** (measured 2026-09-08, same sweep as the allowlist figures below). `after_eof.emf` shows it without truncation too — bytes past the EOF record, measuring `ok? == true, errors == [], records == 16`, the same numbers as the intact file. (Re-measured 2026-09-08. The earlier wording cited a removed-EOF file at `records == 15`; no fixture on disk reproduces that today, and both `short_eof` fixtures measure `ok? == false` with one error. The conclusion is unchanged, the example is now one that reproduces.) Require an EOF record on top of `ok?` (D23). **Compare the declared record count to the parsed body count plus one** — `EMR_HEADER` counts itself, while `Metafile#records` contains only the records that follow it; the handler enforces this invariant both after a successful parse and through an independent structural count when parsing fails. Map `errors` (message, offset, record_code) to `severity: "error"`, `code: "EMF_PARSE"`, `location: {byte_offset: offset}` — `byte_length` stays nil unless the pre-pass computes it. On severity generally: png_conform emits a SYMBOL (`:error`, measured), `Models::Issue::SEVERITIES` is `%w[error warning info]`, and "map as-is" is safe because lutaml coerces — `Issue.new(severity: :error)` stores the String `"error"` and round-trips, while `:nonsense` is still refused with `Lutaml::Model::ValidationError`. The enum guards either way; the coercion is what makes the symbol harmless, and it is worth knowing rather than rediscovering. Truncation raises **`IOError`** at some cut points and `Emf::FormatError` at others — both belong on the conform allowlist, or a corrupt file exits 4 instead of 1. Swept 2026-09-08 across all **364** cut points of `valid.emf`: `IOError` 96, `Emf::FormatError` 88, `EOFError` 16, no exception at 164. **`EOFError` is a subclass of `IOError`**, so rescuing that pair covers every truncation — verified by rescuing exactly the pair and finding nothing escapes. **But the pair is NOT the whole allowlist**: `bad_comment.emf`, already on disk, raises **`NoMethodError`** from inside the gem, which the pair does not catch. **Do not add it to the allowlist**: it is a gem defect, not a corrupt-input signal. In `emf-0.1.0/lib/emf/emr/parser.rb`, `Emr::Parser#extract_emf_plus` guards on the DECLARED `cb_data >= 4` and then ORs together `data.getbyte(0)`, `data.getbyte(1)`, `data.getbyte(2)` and `data.getbyte(3)` without checking the actual bytesize, so each returns nil and the shifts raise. It is called from `Emr::Parser#call`, OUTSIDE the `rescue StandardError` in `#read_record` that would otherwise have reported the same fault as a `ParseError`. Cited by method name rather than by line number, deliberately: this is another gem's source, so any line reference here rots on its next release. Reachable from ordinary corruption, not just this fixture: 40,000 mutated documents produced 4 `NoMethodError` hits, every one in that function. Report it upstream; do not widen the rescue to swallow it. And note the pre-pass is still needed once it is fixed — with a bytesize guard applied, `bad_comment.emf` comes back `ok? == true, errors == []`, because the gem has no way to say "a comment declares more data than it holds" |
26
+ | postscript | **No mapping — EPS/PS conform is unsupported in v1 (D22).** `Postscript.parse` was measured accepting an unmatched `}`, an undefined operator, a missing operand and raw binary; only an unterminated string raised. There is nothing here that answers "does this conform?", so the handler declares no `conform` capability and the registry raises `UnsupportedFormat` → exit 3 |
27
+ | pdfrb `Validator.validate` | Structural, class method taking a `Document`. Measured `[]` on a valid file. **Failure reporting is not uniform** — a catalog-less document raises `Pdfrb::Error`, a `/Pages` reference to a missing object raises `NoMethodError`, and a dangling unrelated reference comes back as an error string. The allowlist must cover the raising cases including `NoMethodError` from this call path specifically, which means wrapping the call rather than allowlisting `NoMethodError` globally. When it returns strings: `severity: "error"`, `code: "PDF_STRUCTURE"`, `message` the string, `location: nil` |
28
+ | pdfrb `Conformance::*` `Violation` | Only via `--profile`. Measured `PdfA.validate(doc, level: :a1b)` → `ValidationResult` whose violations are `Violation{rule_id, message, object, severity, spec_clause}`, e.g. `rule_id="6.1-2", message="PDF/A requires /Catalog/Metadata XMP stream", object="Catalog", severity=:error`. So `code: rule_id`, `message` as-is (it has real prose — no composition needed), severity as-is, `location: {node_path: object}` — note `object` was the string `"Catalog"`, not a path, so treat it as an opaque label. **`PdfA`/`PdfX`/`PdfVT` take `level:`; `PdfUA` does not** — the adapter is per-profile |
29
+
30
+ - **One `--profile` flag, two formats.** PDF and SVG both have a
31
+ generic check and a set of named stricter standards, so they share
32
+ one mechanism rather than inventing two.
33
+ - PDF: generic `conform` runs `Validator.validate` (structural).
34
+ `--profile pdf_a --level a1b` reaches `Pdfrb::Conformance::*`.
35
+ **A profile run must pass structural validation first** — measured
36
+ on a catalog-less file, most profiles raise a raw `NoMethodError`
37
+ while `Pdf2AF` returns zero violations and "passes". Never report a
38
+ profile pass for a document that is not structurally sound.
39
+ **Claricle validates the level itself**: `PdfA`, `PdfX`, `PdfVT`
40
+ and `Pades` accept `level:`; `PdfUA`, `Ltv`, `Pdf2AF` and
41
+ `TaggedPdf` do not. An invalid level is **silently ignored**
42
+ upstream — `level: :nonsense` returned the same result as
43
+ `level: :a1b` — so an unrecognised level must be rejected here with
44
+ `InvocationError`, and passing `--level` to a profile that takes
45
+ none is equally an error. Enumerate the accepted names and levels
46
+ in one table; do not pass user input through unchecked.
47
+ - SVG: generic `conform` runs the **`base`** profile (D21).
48
+ `--profile metanorma`, `svg_1_2_rfc`, `svg_1_2_rfc_with_rdf`,
49
+ `no_external_css`, `lucid_fix` reach the stricter sets.
50
+ - An unknown profile name, or `--profile` on a format with no profile
51
+ system, is an `InvocationError` → exit 2.
52
+ - `Report#profile` always records what actually ran, so no result is
53
+ ambiguous about which standard it was judged against.
54
+ "Valid PDF" and "valid PDF/A" are different claims, and so are
55
+ "well-formed SVG" and "SVG that satisfies the RFC colour rules".
56
+ Neither is silently substituted for the other.
57
+ - **Arlington is delivered by Claricle's document walk.** Released pdfrb
58
+ exposes `Arlington::Loader`, predicates and definitions — the grammar,
59
+ not a runner. `Handlers::PdfArlington` supplies the missing walk from
60
+ the Catalog down and maps required-key, type and enumerated-name
61
+ findings into Claricle issues (#55). Generic PDF conformance combines
62
+ those findings with the structural validator; named profiles remain
63
+ opt-in.
64
+ - **`conform` still means different things per format, so say so.**
65
+ PNG gets chunk-level validation, SVG the `base` requirement set, EMF
66
+ a parse plus `ok?`, PDF a structural check. Those are not comparable
67
+ claims even after D22 removed the worst offender. `Report#profile`
68
+ and the validator version record what actually ran, and the README
69
+ states it per format rather than implying uniformity.
70
+ - Module API: `Claricle.conform?(path = nil, pattern: nil,
71
+ strict: false, profile: nil)` — exactly one of positional path or
72
+ `pattern:` (`InvocationError` otherwise; the issue's examples use
73
+ both shapes); `pattern:` is true only if every matched file conforms.
74
+ `Claricle.conformance_report(path, profile: nil)` → `Models::Report`.
75
+ `profile:` is accepted for PDF and SVG (D16, D21); on any other
76
+ format, or with a name that format does not define, it raises
77
+ `InvocationError`. `Report#profile` records which one ran, so a
78
+ result is never ambiguous about what it checked.
79
+ - **A batch predicate loses information, so give callers the full
80
+ result too.** `Claricle.conformance_batch(pattern:)` returns ordered
81
+ per-file outcomes plus an aggregate status, so Ruby callers can get
82
+ what the CLI gets. Without it the CLI records per-file failures and
83
+ keeps going while the Ruby predicate aborts on the first one, and
84
+ there's no way to reach the complete picture from Ruby. The batch
85
+ predicate must collect every outcome — no `all?` short-circuiting —
86
+ and then deterministically raise the highest-severity operational
87
+ failure, so the same input always fails the same way.
88
+ - **`conform?` is a predicate, so it answers about conformance and
89
+ raises about everything else.** A nonconformant file returns `false`.
90
+ An operational failure — unknown format, unsupported format, missing
91
+ file, delegate crash — raises, and raises out of the batch shape too;
92
+ it never silently becomes `false`. `pattern:` matching zero files
93
+ raises `InvocationError` (matching the CLI's exit 2, not a vacuous
94
+ `true`).
95
+ - **The batch helper** (owned here, reused by 04): a positional
96
+ argument is a literal path when it names an existing file and a glob
97
+ otherwise; `--pattern` forces glob interpretation for a filename that
98
+ legitimately contains glob characters (D19). Expand
99
+ `Dir.glob(pattern).sort`; zero matches → exit 2; per-file failures
100
+ don't stop the batch; final exit = highest code.
101
+ - **One envelope type, never a mixed array.** Every slot is a
102
+ `Models::BatchItem{path, status, exit_code, result, error}` — one
103
+ homogeneous element type, with `result` holding the `Report` (or in
104
+ 04 the `Conversion`) and `error` holding code and message. A
105
+ heterogeneous `Array<Report | Failure>` would force consumers to
106
+ type-switch on a field's presence, and `jq '.[].result.valid'` would
107
+ return null for an operational failure, indistinguishable from a
108
+ genuine null. Human output prints one stderr line per failed file.
109
+ Because 04 needs the same helper with a different success predicate,
110
+ keep it operation-neutral — something like
111
+ `Batch.run(argument, classify:, &operation)` returning ordered
112
+ envelopes plus aggregate status — rather than baking conform's exit
113
+ 0/1 rule into it and forcing 04 to work around it.
114
+ - CLI: `claricle conform FILE... [--pattern GLOB] [--json] [--strict]`;
115
+ conformance failure returns 1 as an ordinary integer from the runner,
116
+ never a raised `SystemExit`; `--strict` requires tri-state `yes`
117
+ (D8). Batch-capable JSON
118
+ is **always an array**, single result included; a single-file failure
119
+ must produce the same JSON envelope as a batch one, so CI consumers
120
+ never get JSON in one case and bare stderr text in the other.
121
+ Human output prints, per file: the path, the tri-state verdict, and
122
+ every mapped issue as one line carrying severity, code, message, and
123
+ the location fields that are populated. A conformant file prints its
124
+ path and verdict, nothing more.
125
+ - **One stable JSON cardinality**: batch-capable commands always emit
126
+ an array of `BatchItem`, single result included. Shape can't follow
127
+ invocation syntax — a filename may legally contain glob characters,
128
+ and an unquoted glob is expanded by the shell before Claricle sees
129
+ the argument, so "which form did the user type" isn't knowable. 04's
130
+ `convert --json` uses the same rule.
131
+ - `capabilities` gains `conform` per handler **in the same commit that
132
+ implements that handler's `conformance_report`**, alongside the
133
+ `formats` output spec — so `formats` never claims an operation that
134
+ isn't working yet, not even between commits inside this item.
135
+ - Fixtures: one valid + one invalid sample per format, sourced from the
136
+ delegates' canonical corpora (PngSuite, svg_conform fixtures, emf
137
+ corpus, pdfrb specs).
138
+
139
+ ## Do
140
+
141
+ Same shape as 02 — command plumbing first, then one handler per commit,
142
+ so no commit declares a capability the CLI can't yet deliver.
143
+
144
+ 1. `Models::BatchItem` + operation-neutral batch helper + module API +
145
+ `conform` command, with the exit codes reachable now (2 for zero
146
+ matches, 3 for a format no handler conforms). Extend the exact command
147
+ inventory with `conform` in the same commit. No capability changes.
148
+ 2. Module API argument specs — both call shapes, `InvocationError`
149
+ cases, and `conformance_batch` collecting every outcome without
150
+ short-circuiting.
151
+ 3. One handler per commit: `conformance_report` TDD against valid and
152
+ invalid fixtures, the derived `conform` capability, and the `formats`
153
+ expected-output update, together. Exit codes 0 and 1 get their
154
+ end-to-end specs with the first handler.
155
+ 4. Per-delegate malformed-input allowlist, per operation and stage, so
156
+ a recognised-but-corrupt file exits 1 rather than 4 — **for the
157
+ formats that have conform at all**. EPS and PS are excluded by D22:
158
+ they exit 3 whatever their content, since there is no conformance
159
+ verdict to give. `Emf::FormatError`
160
+ raised while conforming a truncated EMF goes **on** that allowlist:
161
+ the delegate reports it as an exception before any `metafile.errors`
162
+ result exists, but the user-visible meaning is the same
163
+ nonconformance that malformed PostScript produces, and the exit code
164
+ must follow the meaning rather than the delegate's reporting style.
165
+ Then prove exit 4 with a deliberately faulting handler — an
166
+ off-allowlist exception — since every recognised-but-corrupt fixture
167
+ now correctly lands on 1.
168
+ 5. `--strict` + tri-state boundary specs (warnings-only file,
169
+ info-only file).
170
+ 6. README section for `conform` (D1), stating per-format what conform
171
+ actually checks.
172
+
173
+ ## Done when
174
+
175
+ - [x] `Claricle.conform?` correct for png, svg, emf and pdf, both call
176
+ shapes; eps/ps raise `UnsupportedFormat` per D22
177
+ (`spec/claricle/conformance/module_api_spec.rb`, examples
178
+ `answers true for a real conformant PNG through the positional call`,
179
+ `answers true for a real conformant PDF through the positional call`,
180
+ `answers true for a real conformant EMF and a real conformant SVG`,
181
+ `answers true for an all-conforming mixed-format pattern`, and
182
+ `raises rather than answering false when EPS or PS is unsupported`).
183
+ - [x] Invalid fixture of each format yields a `Report` with populated,
184
+ correctly-mapped issues; exit codes verified end-to-end. png, svg, pdf
185
+ (`spec/claricle/cli_spec.rb`); invalid EMF exits 1 with its
186
+ `emf.record_framing` error line (`spec/claricle/cli_spec.rb`, example
187
+ `exits 1 for a nonconformant EMF, with an error line on stdout`; #71).
188
+ - [x] Batch glob over mixed formats returns highest-code exit and a
189
+ positionally complete JSON array of `BatchItem`, failures included.
190
+ - [x] A single-file failure emits the same JSON envelope as a batch one.
191
+ - [x] Exit code 4 is reached end-to-end. A corrupt-but-recognised fixture
192
+ exits 1 for png, svg, emf and pdf; eps and ps exit 3 regardless of
193
+ content (D22). Exit 4 comes from a real faulting handler run through
194
+ the real executable (`spec/claricle/cli_spec.rb`, example
195
+ `exits 4 when the handler raises outside its allowlist, and says what`,
196
+ with `spec/fixtures/faulting_handler/boom.rb`; #63).
197
+ - [x] `formats` now reports conform, and its spec says so.
198
+ - [x] PDF named profiles map all eight planned pdfrb validators; `--level`
199
+ validates and maps the accepted PDF/A, PDF/X, PDF/VT and PAdES levels,
200
+ and structural/Arlington validation runs before a named profile.
201
+ - ~~Full Pre-Push Review Chain passed.~~ Superseded: Hassan 2026-10-09 requires TDD, only changed specs, and green GHA; no extra review chain.
202
+
203
+ ## Files
204
+
205
+ `lib/claricle.rb` (module API), `lib/claricle/handlers/*.rb`,
206
+ `lib/claricle/models/batch_item.rb`, `lib/claricle/cli.rb` + batch
207
+ helper file, `README.adoc`, `spec/claricle/conformance/*_spec.rb`,
208
+ `spec/fixtures/`.
@@ -0,0 +1,216 @@
1
+ # 04 — Convert: vectory routing, lossiness, README rewrite
2
+
3
+ Can start: after 03 (reuses its batch helper). D11 (round-trip
4
+ assertions) and D23 (lossiness classification) settled what it asserts.
5
+
6
+ ## Problem
7
+
8
+ No conversion exists. The issue requires EMF↔SVG, PS/EPS↔SVG and
9
+ SVG→EPS with explicit lossiness — no silent lossy conversions — plus
10
+ the final README describing the real library. All twelve edges between
11
+ svg/emf/eps/ps were measured working, so v1 ships the full matrix.
12
+
13
+ ## Design
14
+
15
+ - `Models::Conversion` (lutaml-model): `source_path`, `source_format`,
16
+ `target_format`, `lossiness`, `output_path`, `content` (excluded from
17
+ JSON serialization). `source_path` is populated whenever the image
18
+ came from disk, and nil for a content-born one — `Image#convert`
19
+ works in memory and has no path to report. It is required in every
20
+ path that batching can reach, since that is where a result must be
21
+ traceable to its input; never substitute a Tempfile path to fill it.
22
+ - Conversion routes through vectory: `Vectory::Emf.from_content(...)
23
+ .to_svg` and siblings. All twelve svg/emf/eps/ps edges succeed **on a
24
+ rect-and-line fixture** (D9) — that is what "the matrix works" means,
25
+ and no more. Richer content fails in ways the matrix cannot express:
26
+ gradients raise on SVG→EMF, and the SVG→EPS path raised
27
+ `NameError: uninitialized constant Postsvg::Model::UnknownOperator`
28
+ on a cold process for an embedded raster, then succeeded once an
29
+ unrelated conversion had run first. That load-order bug will present
30
+ as intermittent, so the spec suite must exercise each edge in a fresh
31
+ process, not only after other conversions have warmed the constants (done in #70: `spec/claricle/conversion/fresh_process_spec.rb`, one fresh
32
+ process per convert edge). WMF is out of v1 entirely (D14)
33
+ and never reaches a handler; png and pdf have no vectory class.
34
+ - **Lossiness is per-conversion, not per-edge (D10 superseded, D23).**
35
+ Measured: SVG→EPS silently turns a gradient solid black, silently
36
+ removes a clip path, and silently deletes an embedded raster, while
37
+ the same edge is perfectly clean for a rect. SVG→EMF *raises* on a
38
+ gradient (`Emfsvg::FormatError: unsupported SVG color`) but silently
39
+ makes a clipped object invisible. A static per-edge label would
40
+ therefore call SVG→EPS lossless on rect evidence and then destroy a
41
+ gradient without a word — the exact silent lossy conversion issue #1
42
+ forbids. So: inspect the source document for the features known to be
43
+ dropped on that edge, and classify **this** conversion. The
44
+ `:lossless` / `:lossy` / `:unknown` vocabulary stands; the static
45
+ table becomes a pessimistic floor, never the answer. Build the
46
+ feature list from the fixture corpus and grow it as more losses are
47
+ found. Human output discloses lossy/unknown conversions through its
48
+ warning or batch summary; JSON output carries the classification in
49
+ the result and deliberately keeps stderr clean. No consent-gate flag
50
+ — the issue requires disclosure, not consent.
51
+ - **Write lifecycle**: `Image#convert(to:)` never touches disk.
52
+ `Claricle.convert(src, to:, output: nil, force: false)` writes to the
53
+ given path; stdout for `"-"`; with `output: nil` derives the sibling
54
+ path (source name, target extension — the issue's
55
+ `convert("diagram.emf", to: :svg)` writes `diagram.svg`).
56
+ `Conversion#output_path` = actual written path, nil for
57
+ stdout/in-memory.
58
+ - **Same-format conversion is an invocation error, not a crash.**
59
+ `Vectory::Svg#to_svg` does not exist — every same-format pair raises
60
+ `NoMethodError`, which would exit 4 as an internal fault. Reject
61
+ `--to` equal to the detected source format up front with
62
+ `InvocationError` → exit 2, before any delegate is touched.
63
+ - **A successful `from_content` proves nothing about the content.**
64
+ Measured: EPS bytes into `Vectory::Emf`, EMF bytes into
65
+ `Vectory::Eps` and raw SVG into `Vectory::Emf` all construct without
66
+ complaint and only fail at the conversion call. Detection remains the
67
+ only authority on what a file is; never infer it from a delegate
68
+ accepting the bytes.
69
+ - **One overwrite rule, both layers**: an existing destination is never
70
+ overwritten without `force: true`/`--force`, and it makes no
71
+ difference whether the path was derived or given explicitly. Refusing
72
+ raises `InvocationError` at API level and exits 2 at CLI level. Two
73
+ rules here would be a trap — the derived path is the one the user
74
+ didn't type and so the one they're least likely to be watching.
75
+ - **Batch conversion must preflight the whole write set before writing
76
+ anything.** Derived sibling names plus batching plus `--force` is a
77
+ data-loss machine otherwise. Real cases: `a.emf` and `a.svg` in one
78
+ run targeting SVG, where converting the EMF overwrites a later input;
79
+ `a.eps` and `a.ps` both deriving `a.svg`, where one result destroys
80
+ the other; `Logo.eps` and `logo.ps` colliding on a case-insensitive
81
+ filesystem; and `convert logo.svg --to emf --output logo.svg --force`
82
+ destroying its own source. So: resolve every source/destination pair
83
+ up front, compare by **filesystem identity** rather than string
84
+ equality (symlinks, hardlinks, case folding), reject any destination
85
+ that is also a source, and reject non-unique destinations even under
86
+ `--force`. Note a **nonexistent** destination has no inode to compare
87
+ — `Logo.svg` and `logo.svg` collide on a case-insensitive filesystem
88
+ before either exists. So canonicalize planned destinations by probing
89
+ the target directory's case sensitivity once, and compare
90
+ canonicalized strings for paths that do not yet exist. A preflight
91
+ collision **fails the whole batch before any write**, rather than
92
+ producing per-item failures — half-written output is worse than none,
93
+ and the user can fix the input set and re-run. `--force` authorises replacing an unrelated existing file,
94
+ never clobbering an input.
95
+ - **Writes are atomic in both directions.** Without `--force`, stage to
96
+ a temp file in the destination's own directory and publish with a
97
+ no-replace atomic operation, so a concurrent writer can't slip
98
+ between the existence check and the write. With `--force`, stage the
99
+ same way and atomically rename over the target, so a serialization or
100
+ disk failure can't truncate a good existing file. For `--output -`,
101
+ generate the bytes fully before writing and use binary
102
+ `stdout.write` — never `puts`, which appends a newline and corrupts
103
+ binary output. Spec it byte-for-byte.
104
+ - CLI: `claricle convert SOURCE... [--pattern GLOB] [--to FORMAT]
105
+ [--output FILE|-] [--json] [--force]` — same invocation shape as
106
+ `conform` (D19): a positional source is a literal path when it names
107
+ an existing file and a glob otherwise, with `--pattern` forcing glob
108
+ interpretation. Batch runs via 03's helper, every file landing in a
109
+ uniform `Models::BatchItem` slot, failures included. `--output` names
110
+ a single destination, so it is rejected with exit 2 whenever more
111
+ than one source resolves; multi-source runs use derived names. `--to` may be omitted **only** when
112
+ `--output` names a file whose extension maps to a known format; that
113
+ extension then decides the target. Omitting both, or giving an
114
+ extension that maps to nothing, exits 2 with a message naming the
115
+ problem — there is no default target and no guessing from the source
116
+ format, drawn from the handlers' declared canonical extensions rather
117
+ than a central table. `--to svg --output result.emf` is a conflict,
118
+ not a preference — exit 2 rather than silently letting one win.
119
+ `--output -` puts bytes alone on stdout, and rejects `--json` and a
120
+ missing `--to` (exit 2, since there's no extension to infer from).
121
+ Everything else — the human summary, lossiness warnings, error text —
122
+ goes to stderr in that mode, so the stdout stream stays pipe-safe.
123
+ `--json` serializes `Conversion` minus content, always as an array of
124
+ `BatchItem` per 03's rule. Human output prints one line per file:
125
+ source, target format, written path (or `-`), and the lossiness
126
+ classification.
127
+ - `capabilities` gains `convert` plus that handler's target list in the
128
+ same commit that implements the edge (per 00's rule), with the
129
+ `formats` output spec moving alongside. The last such commit is what
130
+ finally makes `claricle formats` the full support matrix the issue
131
+ asks for.
132
+ - **There is no general round-trip invariant, and that is measured
133
+ (D11).** Byte identity against the original never held — the first
134
+ pass always rewrites. Idempotence held for a rect-and-line fixture
135
+ across the measured routes. With a single `<text>` element, the EMF
136
+ chain also stabilises after the first rewrite, but the EPS chain does
137
+ not: its viewBox grows `0 0 100 50` → `0 0 100 90` →
138
+ `0 0 100 180`, and its text y coordinate moves `-40` → `-90` →
139
+ `-180`. So do **not** write a general `cycle N == cycle N+1` spec:
140
+ one stable route does not establish the property across the supported
141
+ matrix.
142
+ What can honestly be asserted: conversion is **deterministic** (same
143
+ input, identical bytes — measured true); same-format parse→serialize
144
+ identity for EMF; and per-feature semantic properties over a fixture
145
+ corpus, each naming the property it checks. These are D11's replacement
146
+ for the issue's stated general backbone.
147
+ Also note "dimension preservation" is not assertable until D15
148
+ settles units — the same SVG reports `3×1` through vectory and
149
+ `96×48` as EPS, so an equality check would be testing a coincidence.
150
+ - Docs closure: README.adoc fully rewritten on top of 01's honesty
151
+ baseline — real API + CLI, the format × operation matrix, exit codes,
152
+ and the registry extension workflow. That workflow must describe what
153
+ adding a format **actually** costs: if handlers do not yet implement
154
+ their operations or declare sniffers, targets, extensions and lossiness,
155
+ then it is a handler class plus a detector edit plus a require plus a
156
+ registry entry plus a lossiness edge, and the README says so rather
157
+ than repeating the one-class claim. Gemspec description refreshed for
158
+ the operations that now exist; its URLs moved to the `claricle` org in
159
+ 01 and need no further edit. `sig/` stays deleted (01's docs rule).
160
+ Extend the exact command inventory with `convert` in the same commit
161
+ as the command.
162
+
163
+ ## Do
164
+
165
+ Plumbing first again, then one edge family per commit.
166
+
167
+ 1. `Models::Conversion` + the per-target feature-loss rules (D23) +
168
+ specs. Not a flat edge table — a rule says "this target discards
169
+ gradients / clip paths / embedded rasters", and the classifier
170
+ inspects the source for those features.
171
+ 2. Extract the write lifecycle as its own injectable service and spec
172
+ it directly — preflight collision set, filesystem-identity aliasing,
173
+ no-replace atomic create, force-mode temp-and-rename, binary stdout.
174
+ 01 freezes the registry and bans runtime mutation and test-only
175
+ APIs, so there is no honest route from a detected real format to a
176
+ stand-in handler; testing the service directly avoids needing one.
177
+ 3. `convert` command + batch + `--force`/`--output -`/`--json`
178
+ boundary specs + `--to` inference, its exit-2 cases, and the
179
+ `--to`/`--output` suffix conflict; `--output -` spec asserts stdout
180
+ carries bytes only, with no trailing newline. Extend the exact command
181
+ inventory with `convert` in the same commit.
182
+ 4. One edge family per commit: handler `convert` TDD against real
183
+ fixtures, override `convert`, declare only that handler's target-list
184
+ data, and update the `formats` expected output together.
185
+ 5. Semantic spec suite over the fixture corpus, each spec naming the
186
+ property it checks (D11 rejects a general invariant, so do not
187
+ write a general round-trip assertion). Add a feature-loss rule only
188
+ when a fixture demonstrates the loss.
189
+ 6. `formats` full-matrix spec once the last edge lands.
190
+ 7. README rewrite + gemspec metadata.
191
+
192
+ ## Done when
193
+
194
+ - [x] All acceptance-matrix conversions work via API and CLI with correct
195
+ exit codes and output-mode-appropriate lossiness disclosure.
196
+ - [x] Semantic suite green; every `:lossless` classification is
197
+ fixture-proven for the features present in that fixture, and the
198
+ gradient, clip-path and embedded-raster cases each produce a
199
+ `:lossy` classification disclosed in human or JSON output rather than
200
+ silent loss.
201
+ - [x] A batch that would collide or overwrite an input is refused before
202
+ anything is written, and specs cover the four concrete cases.
203
+ - [x] `claricle formats` prints the complete support matrix — every cell
204
+ matches what actually works.
205
+ - [x] README describes only verified behavior; issue #1 acceptance
206
+ checklist fully satisfiable, or the gaps are signed off (D11, D14, D18, D22).
207
+ - ~~Full Pre-Push Review Chain passed.~~ Superseded: Hassan 2026-10-09 requires TDD, only changed specs, and green GHA; no extra review chain.
208
+
209
+ ## Files
210
+
211
+ `lib/claricle/models/conversion.rb`, `lib/claricle/handlers/
212
+ {metafile,postscript,svg}.rb`, `lib/claricle/writer.rb` (write
213
+ lifecycle service), `lib/claricle/registry.rb`, `lib/claricle.rb`,
214
+ `lib/claricle/cli.rb`, `README.adoc`, `claricle.gemspec`,
215
+ `spec/claricle/conversion/*_spec.rb`, `spec/claricle/writer_spec.rb`,
216
+ `spec/claricle/cli_spec.rb`.
data/exe/claricle CHANGED
@@ -3,4 +3,4 @@
3
3
 
4
4
  require "claricle"
5
5
 
6
- Claricle::Cli.start(ARGV)
6
+ exit Claricle::Cli::Runner.run(ARGV)