diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml
new file mode 100644
index 0000000..52263c2
--- /dev/null
+++ b/.github/workflows/parity.yml
@@ -0,0 +1,99 @@
+name: Parity
+
+# Scheduled conformance report: neokapi's Go ports head-to-head against this
+# bridge, using a released kapi and a plugin built from this checkout. The
+# suite lives under parity/ and compiles against a released neokapi module.
+#
+# This workflow reports; it gates nothing. It has no pull_request trigger, so
+# it never blocks a change here, and nothing in neokapi/neokapi consults it.
+#
+# Security: the dispatch inputs are consumed only through env vars, never
+# inlined into `run:` strings.
+
+on:
+ schedule:
+ # Weekly, Monday 05:00 UTC.
+ - cron: "0 5 * * 1"
+ workflow_dispatch:
+ inputs:
+ okapi_version:
+ description: "Okapi Framework version to build the plugin for"
+ required: false
+ default: "1.48.0"
+ kapi_version:
+ description: "kapi release to test ('latest' = newest stable, or e.g. 1.3.0-rc9)"
+ required: false
+ default: "latest"
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+jobs:
+ parity:
+ name: parity-test
+ runs-on: ubuntu-latest
+ timeout-minutes: 90
+ env:
+ OKAPI_VERSION: ${{ inputs.okapi_version || '1.48.0' }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up Java 17
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "17"
+ cache: maven
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: parity/go.mod
+ cache-dependency-path: parity/go.sum
+
+ - name: Install a released kapi
+ uses: neokapi/setup-kapi@v1
+ with:
+ version: ${{ inputs.kapi_version || 'latest' }}
+ plugins: ""
+ cache-tm: "false"
+
+ - name: Regenerate the version-specific POM
+ # okapi-releases//pom.xml references the parent version at the time
+ # it was committed; regenerate so it matches the root POM at HEAD.
+ run: make generate-pom V="$OKAPI_VERSION"
+
+ - name: Install the parent POM into the local Maven repository
+ # The version module declares neokapi:okapi-bridge-parent as its
+ # parent and `mvn package` does not install it first.
+ run: mvn -B install -N -DskipTests -q
+
+ - name: Build the plugin tarball
+ run: |
+ make build V="$OKAPI_VERSION"
+ make plugin-v2 V="$OKAPI_VERSION"
+
+ - name: Run the parity suite
+ run: make parity-test
+
+ - name: Write the per-filter summary
+ if: always()
+ run: |
+ if [ -f .parity/test-comparison.json ]; then
+ cd parity && go run ./cmd/testcompare -in ../.parity/test-comparison.json -out ../.parity/parity-report.json
+ fi
+
+ - name: Upload the parity report
+ if: always()
+ uses: actions/upload-artifact@v6
+ with:
+ name: parity-report
+ path: |
+ .parity/test-comparison.json
+ .parity/parity-report.json
+ if-no-files-found: warn
+ retention-days: 90
diff --git a/.gitignore b/.gitignore
index 06258d1..d3de921 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,6 @@ dist/
# Transient fleet-authoring manifest (per-id gap list)
.gap-manifest.json
+
+# Parity sandbox (make parity-sandbox)
+.parity/
diff --git a/CLAUDE.md b/CLAUDE.md
index d51060f..d2735f9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -155,3 +155,25 @@ The parent `pom.xml` declares all infrastructure dependencies (gRPC, Gson, Snake
- Schema `version` is an integer N that increments on content change; `$id` includes the version
- CI on push to main: regenerates composites and verifies schemas are up-to-date (fails if out of date)
- CI on tag push: matrix build across all 11 Okapi versions
+
+## Parity suite (`parity/`)
+
+`parity/` is a separate Go module (`github.com/neokapi/okapi-bridge/parity`)
+that runs neokapi's Go ports head-to-head against this bridge. It requires a
+released `github.com/neokapi/neokapi` and nothing from neokapi's `host` or
+`cli` modules; the daemon launcher is the plugin protocol's Mode C handshake
+implemented locally. Format specs (`core/formats//spec.yaml`) are read from
+that module's source directory (`go list -m`), so specs and readers come from
+one version. Per-fixture divergence annotations live in `parity/annotations/`.
+
+```bash
+make parity-vet # compile + vet the suite, no sandbox needed
+make parity-test # sandbox (released kapi + fresh plugin tarball) + full suite
+make parity-publish # suite + per-filter summary under .parity/
+```
+
+The suite is behind the `parity` build tag, so `make test-go` never runs it.
+`.github/workflows/parity.yml` runs it weekly and on dispatch, installs kapi
+with `neokapi/setup-kapi@v1`, and uploads the report as an artifact. It is a
+report, not a gate: it has no pull_request trigger, and nothing in the neokapi
+repository consults it. See `parity/README.md`.
diff --git a/Makefile b/Makefile
index f8cedc5..ceac472 100644
--- a/Makefile
+++ b/Makefile
@@ -60,6 +60,14 @@ help:
@echo "Release:"
@echo " make release Release current version (bump, tag, push, snapshot)"
@echo ""
+ @echo "Parity (parity/, neokapi's Go ports against this bridge):"
+ @echo " make parity-sandbox Build .parity/ from a released kapi + a fresh plugin tarball"
+ @echo " make parity-test Run the parity suite against the sandbox"
+ @echo " make parity-publish Suite + per-filter summary (.parity/parity-report.json)"
+ @echo " make parity-vet Compile and vet the suite without a sandbox"
+ @echo " make parity-clean Remove the sandbox"
+ @echo " make regen-okapi-fixtures OKAPI_REPO=... Re-harvest fixtures from the Okapi Java tests"
+ @echo ""
@echo "Supported versions: $(SUPPORTED_VERSIONS)"
@echo "Latest version: $(LATEST_VERSION)"
@@ -417,3 +425,6 @@ release:
@echo "Release complete: v$(CURRENT_VERSION)"
@echo " Tag: v$(CURRENT_VERSION)"
@echo " Next dev: $(NEXT)-SNAPSHOT"
+
+# The parity suite (parity/): neokapi's Go ports against this bridge.
+include parity/parity.mk
diff --git a/parity/README.md b/parity/README.md
new file mode 100644
index 0000000..3c2518c
--- /dev/null
+++ b/parity/README.md
@@ -0,0 +1,112 @@
+# Parity suite
+
+This module runs neokapi's Go ports of Okapi filters and steps head-to-head
+against the Okapi Framework, through this repository's bridge plugin and, when
+a build is reachable, the tikal CLI. It answers one question per filter: given
+the same input and the same semantic configuration, do the two implementations
+produce the same content?
+
+The suite is a scheduled conformance report, not a gate. It runs weekly from
+`.github/workflows/parity.yml`, which installs a released kapi, builds the
+plugin from this checkout, runs the suite and uploads the report as an
+artifact. No pull request here or in `neokapi/neokapi` waits on it.
+
+## Layout
+
+| Path | Contents |
+| --- | --- |
+| `*.go` | The harness: sandbox resolution, daemon launch, native and bridge runners, the canonical comparison, the report collector |
+| `spec/` | `ParityRunner`, which drives a format's `spec.yaml` through both implementations |
+| `formats/` | One spec test per filter, the harvested fixtures, and the `bridge_config` translators |
+| `roundtrip/` | The three-engine round-trip harness (see `docs/roundtrip-testing.md`) |
+| `tools/` | The step table: every bridge step run for stability |
+| `annotations/` | Per-format fixture annotations: severity, issue link and skip directives for known divergences |
+| `cmd/testcompare` | Turns the raw report into the per-filter summary |
+| `cmd/okapi-test-scan` | Harvests `@Test` snippets from the Okapi Java sources into `formats/fixtures_*_generated.go` |
+| `pseudobench/` | Pseudo-translation throughput of kapi with and without the bridge over the Okapi corpus |
+| `scripts/sandbox.sh` | Builds the sandbox the suite runs against |
+
+## Dependencies
+
+`go.mod` requires a released `github.com/neokapi/neokapi` and nothing from the
+`host` or `cli` modules: the suite compiles from the module proxy, and the
+daemon launcher is the plugin protocol's Mode C handshake implemented here.
+Format specs (`core/formats//spec.yaml`) are read from that module's
+source directory, which `NeokapiDir` resolves with `go list -m`, so the specs
+and the readers under test come from one version.
+
+Bump the pin when a new kapi release is out:
+
+```bash
+cd parity && go get github.com/neokapi/neokapi@v1.3.0 && go mod tidy
+```
+
+## Running locally
+
+```bash
+make parity-test # sandbox + full suite
+make parity-publish # suite + per-filter summary under .parity/
+KAPI_BIN=/path/to/kapi make parity-test
+OKAPI_VERSION=1.47.0 make parity-test
+PARITY_FORCE=1 make parity-sandbox # rebuild the sandbox
+```
+
+The sandbox lives under `.parity/` (gitignored):
+
+```
+.parity/
+├── bin/kapi the kapi under test (copied from $KAPI_BIN or PATH)
+├── plugins/okapi-bridge/ the plugin tarball built by `make plugin-v2`, unpacked
+├── okapi-testdata// the okapi-testdata release (binary fixtures)
+└── test-comparison.json the raw report
+```
+
+Tests resolve the sandbox from `KAPI_PARITY_SANDBOX`, or by walking up from
+the working directory to a `.parity/bin/kapi`, and fail when there is none.
+`KAPI_PARITY_SKIP=1` turns that failure into a skip for runs that do not care
+about parity. Nothing in the suite reads the user's installed plugins.
+
+One format:
+
+```bash
+cd parity && KAPI_PARITY_SANDBOX=$PWD/../.parity \
+ go test -tags "fts5,parity" -count=1 -run TestParityHtmlSpec ./formats/
+```
+
+Every tag the suite needs is spelled in one `-tags` flag; `go` keeps only the
+last occurrence of the flag.
+
+## Reading the report
+
+`test-comparison.json` holds one row per filter or step and pass:
+
+- `format`: the native reader against the bridge reader on the same input,
+ compared on the canonical projection (part types, block ids, translatable
+ flag, source and target text with structural placeholders for inline codes).
+- `format-roundtrip`: read then write, compared on bytes, where both sides
+ declare a writer.
+- `format-tikal`: the native round trip against an extract-then-merge run of
+ the Okapi CLI, when `OKAPI_TIKAL` points at a launcher.
+- `format-fixture`: one row per harvested upstream `@Test` fixture.
+- `step`: a bridge step run against a fixed input for stability.
+
+`expected_fail` and `parity_warn` are documented divergences, not regressions;
+the annotation under `annotations/.yaml` or the `expected_fail` entry
+in the format's `spec.yaml` says why. `cmd/testcompare` folds the rows into
+`parity-report.json`: one row per filter or step with totals.
+
+## Adding a parity case
+
+1. Add or extend the format's `spec.yaml` in neokapi (`core/formats//`):
+ the bridge filter class, the tikal corner and the parity skips are declared
+ there.
+2. Add `formats/_spec_test.go` here, wiring the native reader for a
+ head-to-head comparison or leaving it unset for a bridge-only snapshot. Add
+ `_bridge_config.go` when the bridge's parameter shape differs.
+3. For a step, add a row to the table in `tools/spec.go`.
+4. Run the suite. A pure default-only difference is converged with explicit
+ configuration in the bridge config translator, not recorded as an expected
+ failure.
+
+The harvested fixtures are regenerated with `make regen-okapi-fixtures
+OKAPI_REPO=/path/to/Okapi`; review the diff before committing.
diff --git a/parity/annotations/csv.yaml b/parity/annotations/csv.yaml
new file mode 100644
index 0000000..112a751
--- /dev/null
+++ b/parity/annotations/csv.yaml
@@ -0,0 +1,20 @@
+format: csv
+fixtures:
+ computer_science_article.csv:
+ severity: fixture-bug
+ summary: "Source contains Windows-1252 byte 0x96 (en-dash) with no explicit charset marker. Native preserves the raw byte; okapi treats it as invalid UTF-8 and replaces with U+FFFD. Neither is 'right' for parity without an explicit encoding annotation."
+ skip:
+ engines: [okapi]
+ reason: "fixture has Windows-1252 0x96 with no charset marker; engines disagree on invalid-byte handling"
+ some_blank_columns.csv:
+ severity: cosmetic
+ summary: "Source rows have layout `one,,four,,seven` (empty translatable column). With sourceColumns=2 okapi falls back to writing column-1's translation into the empty column-2 slot; native leaves the empty cell empty. Two defensible behaviours."
+ skip:
+ engines: [okapi]
+ reason: "okapi writes col-1 translation into adjacent empty cell; native leaves empty cells empty"
+ test2cols.csv:
+ severity: fixture-bug
+ summary: "Fixture is intentionally malformed CSV (unmatched double-quotes spanning newlines, embedded `\"\"` inside quoted regions). Native and okapi recover from the broken quote state differently — both are defensible readings of broken input."
+ skip:
+ engines: [okapi]
+ reason: "fixture is malformed CSV (quotes spanning lines); engines recover from broken state differently"
diff --git a/parity/annotations/html.yaml b/parity/annotations/html.yaml
new file mode 100644
index 0000000..0e02893
--- /dev/null
+++ b/parity/annotations/html.yaml
@@ -0,0 +1,6 @@
+format: html
+fixtures:
+ France_Culture_fr.html:
+ severity: cosmetic
+ summary: "Non-deterministic html-canonical divergence, PRE-EXISTING and unrelated to the openxml faithful-writer work (html reader/writer + html-canonical normalizer untouched). The fixture flips between canonical-equal and divergent across identical-code runs: when divergent, native emits ~837 extra bytes around the document's Open Graph tags (e.g. article:publisher) in word/head reconstruction. The difference is document metadata only — it renders identically — so it is cosmetic. The non-determinism (native html-writer head/meta emission ordering) is a separate test-stability issue to investigate; annotated here so the dashboard documents it whichever way the flake lands."
+ spec_ref: "HTML5 §4.2.5 (the meta element); §13 (parsing)"
diff --git a/parity/annotations/json.yaml b/parity/annotations/json.yaml
new file mode 100644
index 0000000..b428b8e
--- /dev/null
+++ b/parity/annotations/json.yaml
@@ -0,0 +1,8 @@
+format: json
+fixtures:
+ invalid_by_most_processors.json:
+ severity: fixture-bug
+ summary: "Intentionally-malformed JSON5 fixture with bare keys and single-quoted strings. Both engines emit lenient output; json-canonical normalizer can't parse either side, so there's no useful tier comparison."
+ skip:
+ engines: [okapi]
+ reason: "JSON5 fixture; both engines emit lenient output, json-canonical can't parse either side"
diff --git a/parity/annotations/odf.yaml b/parity/annotations/odf.yaml
new file mode 100644
index 0000000..32b8c3b
--- /dev/null
+++ b/parity/annotations/odf.yaml
@@ -0,0 +1,20 @@
+format: odf
+# Measured over the 15 upstream okapi/filters/openoffice fixtures:
+#
+# odf | native | 15 total | 0 byte | 15 canon (15 faithful) | 0 divergent
+# odf | bridge | 15 total | 15 byte | 0 canon | 0 divergent
+#
+# Native reaches canonical-equal rather than byte-equal because okapi
+# re-serializes every stream on round-trip — reordering attributes, rewriting
+# the declaration, expanding empty elements — while native writes each stream
+# from the source's own bytes. Native is the more faithful side, which is why
+# the format is classified CanonFaithful and why driving these to byte-equal
+# against okapi would be a regression: an untranslated .odt/.ods/.odp/.odg
+# comes back byte for byte against the SOURCE, on all 15
+# (core/formats/odf/package_roundtrip_test.go).
+#
+# Bridge is byte-equal on all 15 (it literally invokes Okapi's
+# OpenOfficeFilter via okapi-bridge#11's two-pass pipeline).
+#
+# No per-fixture divergences.
+fixtures: {}
diff --git a/parity/annotations/openxml.yaml b/parity/annotations/openxml.yaml
new file mode 100644
index 0000000..a2e715f
--- /dev/null
+++ b/parity/annotations/openxml.yaml
@@ -0,0 +1,264 @@
+# Per-fixture parity annotations for openxml.
+#
+# Consumed by parity/roundtrip's annotation loader to enrich the
+# /parity/fixtures dashboard with severity, summary, issue link, and
+# spec citations. The annotation system is read-only metadata — it does
+# NOT change tier comparison results. See PARITY_NOTES.md for the full
+# spec/Okapi/neokapi three-way framing.
+#
+# severity:
+# bug Real data-loss or correctness bug in neokapi.
+# Must be fixed; fixture currently fails.
+# cosmetic Output differs only in attribute placement,
+# run granularity, or other choices that render
+# identically. Spec-permissible on both sides.
+# native-more-correct Native output is more spec-compliant than the
+# Okapi reference. Divergence is a "win" — the
+# comparison flags it only because Okapi is the
+# reference, not because native is wrong.
+# fixture-bug The upstream fixture itself is malformed or
+# otherwise unusable for parity comparison.
+# unknown Not yet triaged.
+#
+# skip: optional. When present, the parity harness skips the listed
+# engines for this fixture. Replaces the legacy fileSkip map.
+format: openxml
+fixtures:
+ 830-7.docx:
+ severity: cosmetic
+ summary: "#598 RESOLVED. Both complex-field data-loss gaps are fixed: (a) pre-fldChar body text in the field-OPENING run (#598 preFieldBody guard), and (b) #598a — translatable body text BETWEEN a and a following in one source is split out as a translatable run while field markers stay opaque (parseRunWithFieldState flushOpaqueSegment/reengageRawCapture; the writer re-fuses into the original single ; confirmed via TestComplexFieldEndTextBeginBodyText + round-trip byte-identity). The #598b run-level strip is also gone (faithful-writer default preserves source rPr). Only residual byte-divergence is the §17.3.2.26 content-category class shared by the other cosmetic fixtures: native faithfully preserves moot complex-script rFonts attrs (e.g. cs=\"Arial\" on a run with no complex-script text) that Okapi clarifies away — both render identically; not reconstructable symmetrically in the comparator without the deferred RunFonts content-category port."
+ spec_ref: "ECMA-376-1 §17.3.2.1 (CT_R), §17.16.5 (complex fields), §17.3.2.26 (rFonts content categories)"
+ notes_anchor: residual-7-divergent-fixtures
+
+ # 847-3.docx is now CANON: the faithful-writer default (OptimiseWordStyles
+ # =false) removes WSO's monotonic synth-style ID counter — the documented
+ # "architectural blocker" — and the effective-rPr parity normalizer
+ # resolves the style indirection symmetrically. Annotation removed because
+ # the divergence is cleared (per the fail-new gate's grandfathering rule).
+
+ 851.docx:
+ severity: cosmetic
+ summary: "East-Asian segment rFonts inheritance differs; same translatable content. Per §17.3.2.26, rFonts categories only apply to characters in their script range, so attribute placement is moot for the rendered text."
+ spec_ref: "ECMA-376-1 §17.3.2.26 (CT_Fonts)"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ StartsWithLineSeparator.docx:
+ severity: cosmetic
+ summary: "Bare-run rFonts strip + synth on hAnsi remainder. Native splits one source run into two — same content, more granular markup."
+ spec_ref: "ECMA-376-1 §17.3.2.26"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ br2.docx:
+ severity: cosmetic
+ summary: "Font-subset rename (ONGPN A+ Garamond) via style chain. Native emits the embedded-subset alias unresolved; reference resolves to the public family name. Both render identically."
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ multiple_tabs.docx:
+ severity: cosmetic
+ summary: "Font-category fragmentation + tab fusion. Native and reference both carry the same 4 translatable text spans — rFonts placement differs."
+ spec_ref: "ECMA-376-1 §17.3.2.26"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ delTextAmp.docx:
+ severity: cosmetic
+ summary: "#597 (lost ; injected ) is RESOLVED by the faithful-writer default (OptimiseWordStyles=false): the source rPr is preserved verbatim, so the single-char run keeps and no docDefaults rFonts overlay is applied (verified: source spacing count 1 → output 1; source rFonts cs=Helvetica count 1 → output 1, no over-injection). The residual divergence vs Okapi is the §17.3.2.26 content-category rFonts/szCs class shared with 851/multiple_tabs/br2 — Okapi drops complex-script properties (rFonts cs, szCs) that are moot for the runs' Latin text; native preserves them. Both render identically; not style indirection."
+ spec_ref: "ECMA-376-1 §17.3.2.26 (CT_Fonts content categories); §17.3.2.35 (w:spacing — now preserved)"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ 956.docx:
+ severity: native-more-correct
+ summary: "Native preserves translatable text before in the same source ; Okapi drops it. Functionally a win — flagged as divergent only because Okapi is the byte-equality reference."
+ spec_ref: "ECMA-376-1 §17.16.5"
+ notes_anchor: status-2026-05-16
+
+ N_001_Auswertung_Part2.docx:
+ severity: native-more-correct
+ summary: "Native preserves + text before in the same source ; Okapi drops them. Functionally a win."
+ spec_ref: "ECMA-376-1 §17.16.5"
+ notes_anchor: status-2026-05-16
+
+ neverendingloop.docx:
+ severity: native-more-correct
+ summary: "Native preserves translatable text before in the same source ; Okapi drops it. Functionally a win."
+ spec_ref: "ECMA-376-1 §17.16.5"
+ notes_anchor: status-2026-05-16
+
+ # ── Faithful-writer residual class (faithful default + effective-rPr
+ # parity normalizer, 2026-05-22) ────────────────────────────────────────
+ #
+ # With OptimiseWordStyles=false (native is faithful) and the parity
+ # effective-rPr normalizer (parity/roundtrip/normalizers_openxml.go)
+ # resolving ECMA-376-1 §17.7 style indirection symmetrically, every pure
+ # style-indirection fixture (pStyle/rStyle/docDefaults → effective rPr)
+ # now reaches CANON. The fixtures below remain divergent on Okapi's
+ # §17.3.2.26 content-category clarification (dropping complex-script
+ # bCs/iCs/szCs/rFonts-cs on runs whose text has no complex-script
+ # characters, and the non-complex b/i/sz mirror) — a text-driven
+ # RunFonts decision Okapi applies BEFORE building its synth styles, which
+ # cannot be reconstructed symmetrically in the comparator without dropping
+ # properties Okapi keeps as genuine overrides. These render identically
+ # (the dropped properties govern only characters the run does not contain)
+ # and are NOT style indirection. See PARITY_NOTES.md and the design note
+ # docs/internals/research/openxml-faithful-writer-design.md.
+
+ 1341-textbox-with-a-hyperlink.docx:
+ severity: cosmetic
+ summary: "Content-category §17.3.2.26: native preserves on a Latin-text textbox run; Okapi drops it (complex-script bold is moot for non-complex text). Style indirection (pStyle NF…) resolves to canon; this residual is the RunFonts content-category class."
+ spec_ref: "ECMA-376-1 §17.3.2.26 (CT_Fonts); §17.3.2.16 (CT_OnOff bCs)"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ large-attribute.docx:
+ severity: cosmetic
+ summary: "Content-category §17.3.2.26: native preserves on a non-complex-script run; Okapi drops it as moot. Renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.16"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ MissingPara.docx:
+ severity: cosmetic
+ summary: "Content-category §17.3.2.26: native preserves (complex-script font size) on Latin-text runs; Okapi drops it as moot. Renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.38 (CT_HpsMeasure szCs)"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ 888.docx:
+ severity: cosmetic
+ summary: "Content-category §17.3.2.26: native preserves the source while Okapi clarifies complex-script font size to the regular for the run's non-complex text. The szCs governs only complex-script glyphs the run does not contain; renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.38"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ AlternateContentTest.docx:
+ severity: cosmetic
+ summary: "Content-category §17.3.2.26: native preserves source / values while Okapi clarifies them per the run's detected content category. Same translatable content; renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.38"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ EndGroup.docx:
+ severity: cosmetic
+ summary: "Content-category §17.3.2.26: rFonts attribute placement differs (native preserves source ascii/cs/eastAsia/hAnsi; Okapi emits hint=eastAsia). rFonts categories apply only to characters in their script range, so the difference is moot for the rendered text."
+ spec_ref: "ECMA-376-1 §17.3.2.26 (CT_Fonts)"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ graphicdata.docx:
+ severity: cosmetic
+ summary: "Content-category §17.3.2.26: rFonts attribute placement differs between native (source-preserved) and Okapi (content-category resolved). Renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.26"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ Hangs.docx:
+ severity: cosmetic
+ summary: "Content-category §17.3.2.26: rFonts/kern/sz and b placement differ between native (source-preserved inline) and Okapi (content-category resolved). Same translatable content; renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.26"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ NoStylesXml.docx:
+ severity: cosmetic
+ summary: "Content-category §17.3.2.26: with no styles.xml, native preserves the source run's rFonts/sz inline while Okapi resolves the run's content-category fonts. Renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.26"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ 899.docx:
+ severity: cosmetic
+ summary: "Content-category §17.3.2.26: native preserves on runs whose text has no complex-script (RTL) characters; Okapi drops it as moot. only affects bidirectional layout of complex-script glyphs the run does not contain; renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.30 (CT_OnOff rtl)"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ 947-cs.docx:
+ severity: cosmetic
+ summary: "Content-category §17.3.2.26: for complex-script text Okapi drops the non-complex toggles / (moot for complex-script glyphs) and keeps ; native preserves the source's inline toggles. Renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.1 (CT_R)"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ 947-non-cs-and-cs.docx:
+ severity: cosmetic
+ summary: "Content-category §17.3.2.26: Okapi drops non-complex toggles / on complex-script runs as moot; native preserves them. Same translatable content; renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.1"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ content_category_test.docx:
+ severity: cosmetic
+ summary: "Content-category §17.3.2.26: native preserves / and the source form; Okapi resolves the content category and clarifies color auto→000000 (the parity normalizer folds auto→000000 but the residual b/sz content-category drop remains). Renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.6 (CT_Color)"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ 992.docx:
+ severity: cosmetic
+ summary: "Content-category §17.3.2.26: native preserves source // while Okapi resolves them per the run's content category. Same translatable content; renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.38; §17.3.2.40 (CT_Underline)"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ Practice2.docx:
+ severity: cosmetic
+ summary: "Content-category §17.3.2.26: native preserves a redundant that Okapi drops (the run's pre-combined chain already bolds it, so the explicit toggle is moot). Renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.1"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ 1311.docx:
+ severity: cosmetic
+ summary: "Color/theme-color clarification + content-category: native preserves the source token and inline / while Okapi resolves the theme color to its concrete RGB and drops the moot complex-script bold. Both denote the same rendered colour and weight."
+ spec_ref: "ECMA-376-1 §17.3.2.6 (CT_Color); §17.3.2.26"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ 834.docx:
+ severity: cosmetic
+ summary: "Underline resolution §17.3.2.40: native preserves the source while Okapi resolves the style chain to for this footnote run. A genuine style-chain underline resolution difference; the dominant content compares clean and renders per each engine's resolution."
+ spec_ref: "ECMA-376-1 §17.3.2.40 (CT_Underline)"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ TestLTinsideBoxFails.docx:
+ severity: cosmetic
+ summary: "Underline resolution §17.3.2.40: native preserves while Okapi resolves to inside a textbox run. Style-chain underline resolution difference; same translatable content."
+ spec_ref: "ECMA-376-1 §17.3.2.40"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ 830-2.docx:
+ severity: cosmetic
+ summary: "Highlight clarification §17.3.2.15: native preserves a source on an empty placeholder run inside a complex field; Okapi drops it (white highlight ≡ no highlight). Renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.15 (CT_Highlight)"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ 830-3.docx:
+ severity: cosmetic
+ summary: "Highlight clarification §17.3.2.15: native preserves on a field placeholder run; Okapi drops it. Renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.15"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ 830-4.docx:
+ severity: cosmetic
+ summary: "Highlight clarification §17.3.2.15: native preserves on a field placeholder run; Okapi drops it. Renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.15"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ 830-5.docx:
+ severity: cosmetic
+ summary: "Highlight clarification §17.3.2.15: native preserves on a field placeholder run; Okapi drops it. Renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.15"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ 830-6.docx:
+ severity: cosmetic
+ summary: "Highlight clarification §17.3.2.15: native preserves on a field placeholder run; Okapi drops it. Renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.15"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ highlights_block.docx:
+ severity: cosmetic
+ summary: "Highlight + content-category §17.3.2.15/§17.3.2.26: native and Okapi differ on a placement and the moot / toggles for the run's text. Same translatable content; renders identically."
+ spec_ref: "ECMA-376-1 §17.3.2.15; §17.3.2.26"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ 859.docx:
+ severity: native-more-correct
+ summary: "Okapi auto-injects onto a drawing-bearing paragraph mark (suppressing spellcheck for the anchored shape); native faithfully omits it (the source did not author it). is a proofing hint with no rendered effect; native preserves the source verbatim per §17.3.2.1."
+ spec_ref: "ECMA-376-1 §17.3.2.24 (CT_OnOff noProof); §17.3.2.1 (CT_R)"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ document-style-definitions.docx:
+ severity: cosmetic
+ summary: "Run-granularity §17.3.2.1: native splits a styled span into more elements than Okapi (preserving the source's per-run rPr inline after §17.7 resolution), and the byte shape of the styled text differs accordingly. Same translatable content; both are spec-valid run partitions that render identically."
+ spec_ref: "ECMA-376-1 §17.3.2.1 (CT_R); §17.7 (style resolution)"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
+
+ HiddenExcluded.docx:
+ severity: native-more-correct
+ summary: "Hidden-text extraction policy: native extracts and pseudo-translates text carrying (and resolves the source's hidden-style runs) while Okapi leaves the hidden text untranslated and re-emits a the native faithful path resolves differently. Native surfaces the hidden translatable content; the divergence is an extraction-policy difference, not style indirection."
+ spec_ref: "ECMA-376-1 §17.3.2.42 (CT_OnOff vanish)"
+ notes_anchor: verification-of-cosmetic-divergences-2026-05-16
diff --git a/parity/annotations/tmx.yaml b/parity/annotations/tmx.yaml
new file mode 100644
index 0000000..98faf8c
--- /dev/null
+++ b/parity/annotations/tmx.yaml
@@ -0,0 +1,14 @@
+format: tmx
+fixtures:
+ code_fail.tmx:
+ severity: fixture-bug
+ summary: "Intentionally-malformed test fixture; okf_tmx rejects with 'no set to source language'."
+ skip:
+ engines: [okapi]
+ reason: "intentionally-malformed test fixture; okf_tmx rejects with 'no set to source language'"
+ code_id_difference.tmx:
+ severity: fixture-bug
+ summary: "Intentionally-malformed test fixture for code-id mismatch detection."
+ skip:
+ engines: [okapi]
+ reason: "intentionally-malformed test fixture for code-id mismatch detection"
diff --git a/parity/annotations/ts.yaml b/parity/annotations/ts.yaml
new file mode 100644
index 0000000..20b24a2
--- /dev/null
+++ b/parity/annotations/ts.yaml
@@ -0,0 +1,8 @@
+format: ts
+fixtures:
+ issue531.ts:
+ severity: fixture-bug
+ summary: "okapi's pseudo round-trip emits malformed XML (a element closed by instead of ). Likely an okapi-side bug in the regression fixture for okapi GitHub issue #531. Native parses fine."
+ skip:
+ engines: [okapi]
+ reason: "okapi pseudo round-trip emits malformed XML (unclosed ); reference unusable"
diff --git a/parity/annotations/xliff.yaml b/parity/annotations/xliff.yaml
new file mode 100644
index 0000000..4b3c726
--- /dev/null
+++ b/parity/annotations/xliff.yaml
@@ -0,0 +1,8 @@
+format: xliff
+fixtures:
+ lqiTest.xlf:
+ severity: fixture-bug
+ summary: "okf_xliff needs lqiTestIssues.xml in the same dir; harness now copies companions but okapi still rejects this fixture."
+ skip:
+ engines: [okapi]
+ reason: "okf_xliff needs lqiTestIssues.xml in the same dir; harness now copies companions but okapi still rejects this fixture"
diff --git a/parity/annotations/yaml.yaml b/parity/annotations/yaml.yaml
new file mode 100644
index 0000000..3fb8cbc
--- /dev/null
+++ b/parity/annotations/yaml.yaml
@@ -0,0 +1,20 @@
+format: yaml
+fixtures:
+ no-children-1-pretty.yaml:
+ severity: fixture-bug
+ summary: "Okapi YAML parser rejects !!timestamp tag."
+ skip:
+ engines: [okapi]
+ reason: "Okapi YAML parser rejects !!timestamp tag"
+ Test03.yml:
+ severity: fixture-bug
+ summary: "Okapi YAML parser rejects !!timestamp tag."
+ skip:
+ engines: [okapi]
+ reason: "Okapi YAML parser rejects !!timestamp tag"
+ emoji1.yaml:
+ severity: fixture-bug
+ summary: "Okapi YAML writer emits 4-byte UTF-8 emoji (U+1F60E 😎) as a lone low surrogate \\ude0e (missing high surrogate \\ud83d) — invalid UTF-16. yaml-canonical fails to parse the okapi reference."
+ skip:
+ engines: [okapi]
+ reason: "okapi YAML writer emits lone low surrogate for 4-byte emoji; invalid UTF-16"
diff --git a/parity/bridge.go b/parity/bridge.go
new file mode 100644
index 0000000..b074664
--- /dev/null
+++ b/parity/bridge.go
@@ -0,0 +1,407 @@
+//go:build parity
+
+package parity
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/neokapi/neokapi/core/model"
+ pb "github.com/neokapi/neokapi/core/plugin/proto/v2"
+ "github.com/neokapi/neokapi/core/plugin/protoconvert"
+)
+
+// BridgeRequest configures one Process RPC against the okapi-bridge
+// daemon. Either InputBytes or InputPath must be set.
+type BridgeRequest struct {
+ // FilterClass is the Java filter class name (e.g. okf_html, okf_json).
+ FilterClass string
+
+ // InputBytes is the inline document content. Mutually exclusive with
+ // InputPath.
+ InputBytes []byte
+
+ // InputPath is an absolute path the daemon can read directly. Mutually
+ // exclusive with InputBytes.
+ InputPath string
+
+ // SourceLocale and TargetLocale default to "en" / "fr" when empty.
+ SourceLocale string
+ TargetLocale string
+
+ // Encoding defaults to "UTF-8" when empty.
+ Encoding string
+
+ // MimeType is optional.
+ MimeType string
+
+ // FilterParams maps the filter's flat parameters (Okapi convention)
+ // to string values; the bridge applies suffixes (.b, .i) based on
+ // schema metadata.
+ FilterParams map[string]string
+
+ // ConfigID names a built-in Okapi filter configuration to apply before
+ // opening (e.g. "okf_xmlstream-dita"). The bridge loads that config's
+ // parameters; FilterParams override on top. Empty = filter defaults.
+ ConfigID string
+
+ // SubscribeParts narrows which PartType values flow back over the
+ // RPC. Empty (the default) streams every event.
+ SubscribeParts []int32
+
+ // Transform is an optional hook that mutates each Block before
+ // it's echoed back to the daemon during a round-trip. Only used
+ // by RunBridgeRoundTrip; ignored by RunBridge (read-only). When
+ // nil the round-trip echoes parts unchanged. Non-Block parts
+ // (Layer, Data, etc.) always echo through untouched.
+ Transform func(b *model.Block)
+}
+
+// RunBridge drives a read-only Process RPC against the okapi-bridge
+// daemon and returns the streamed parts. Failures are fatal — the
+// caller is asserting parity. Use TryRunBridge when failures should
+// surface as errors instead (Informational fixtures).
+func RunBridge(t *testing.T, req BridgeRequest) []*model.Part {
+ t.Helper()
+ parts, err := TryRunBridge(t, req)
+ if err != nil {
+ t.Fatalf("RunBridge: %v", err)
+ }
+ return parts
+}
+
+// TryRunBridge is the non-fatal variant of RunBridge. The daemon must
+// still be acquireable (set-up failures stay fatal — every parity
+// test needs the daemon) but per-call gRPC errors come back as
+// `error` so the caller can record an outcome instead of failing the
+// test. Used by Informational fixtures so a daemon-side error on one
+// fixture doesn't break the whole CI run.
+func TryRunBridge(t *testing.T, req BridgeRequest) ([]*model.Part, error) {
+ t.Helper()
+ if req.FilterClass == "" {
+ return nil, errors.New("FilterClass is required")
+ }
+ if len(req.InputBytes) == 0 && req.InputPath == "" {
+ return nil, errors.New("InputBytes or InputPath must be set")
+ }
+ client := AcquireBridgeDaemon(t)
+ bridgeClient := pb.NewBridgeServiceClient(client.Conn)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ stream, err := bridgeClient.Process(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("open Process stream: %w", err)
+ }
+
+ header := &pb.ProcessHeader{
+ FilterClass: req.FilterClass,
+ SourceLocale: defaultStr(req.SourceLocale, "en"),
+ TargetLocale: defaultStr(req.TargetLocale, "fr"),
+ Encoding: defaultStr(req.Encoding, "UTF-8"),
+ MimeType: req.MimeType,
+ FilterParams: req.FilterParams,
+ ConfigId: req.ConfigID,
+ SubscribeParts: req.SubscribeParts,
+ }
+ if req.InputPath != "" {
+ header.Input = &pb.ContentRef{Location: &pb.ContentRef_Path{Path: req.InputPath}}
+ } else {
+ header.Input = &pb.ContentRef{Location: &pb.ContentRef_Inline{Inline: req.InputBytes}}
+ }
+ if err := stream.Send(&pb.ProcessRequest{Request: &pb.ProcessRequest_Header{Header: header}}); err != nil {
+ return nil, fmt.Errorf("send header: %w", err)
+ }
+ if err := stream.CloseSend(); err != nil {
+ return nil, fmt.Errorf("CloseSend: %w", err)
+ }
+
+ parts, err := drainProcessResponses(stream)
+ if err != nil {
+ return nil, fmt.Errorf("drain stream: %w", err)
+ }
+ return parts, nil
+}
+
+// BridgeRoundTripResult holds both the events streamed during the
+// round-trip and the final output bytes the daemon wrote.
+type BridgeRoundTripResult struct {
+ Parts []*model.Part
+ Output []byte
+}
+
+// RunBridgeRoundTrip drives a read+write Process RPC: feeds the source
+// into the daemon, echoes every part back unchanged so the writer
+// applies them, and returns both the part stream and the rewritten
+// document bytes.
+func RunBridgeRoundTrip(t *testing.T, req BridgeRequest) BridgeRoundTripResult {
+ t.Helper()
+ if req.FilterClass == "" {
+ t.Fatal("RunBridgeRoundTrip: FilterClass is required")
+ }
+ if len(req.InputBytes) == 0 && req.InputPath == "" {
+ t.Fatal("RunBridgeRoundTrip: InputBytes or InputPath must be set")
+ }
+ client := AcquireBridgeDaemon(t)
+ bridgeClient := pb.NewBridgeServiceClient(client.Conn)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
+ defer cancel()
+
+ stream, err := bridgeClient.Process(ctx)
+ if err != nil {
+ t.Fatalf("RunBridgeRoundTrip: open Process stream: %v", err)
+ }
+ // The daemon's GenericFilterWriter expects a real path; an empty
+ // string trips a FileNotFoundException. Use a per-test temp file
+ // and read the bytes back when ProcessComplete arrives.
+ outPath := filepath.Join(t.TempDir(), "bridge-roundtrip.out")
+ target := defaultStr(req.TargetLocale, "fr")
+ header := &pb.ProcessHeader{
+ FilterClass: req.FilterClass,
+ SourceLocale: defaultStr(req.SourceLocale, "en"),
+ TargetLocale: target,
+ OutputLocale: target,
+ Encoding: defaultStr(req.Encoding, "UTF-8"),
+ MimeType: req.MimeType,
+ FilterParams: req.FilterParams,
+ ConfigId: req.ConfigID,
+ SubscribeParts: req.SubscribeParts,
+ Output: &pb.OutputRef{Destination: &pb.OutputRef_Path{Path: outPath}},
+ }
+ if req.InputPath != "" {
+ header.Input = &pb.ContentRef{Location: &pb.ContentRef_Path{Path: req.InputPath}}
+ } else {
+ header.Input = &pb.ContentRef{Location: &pb.ContentRef_Inline{Inline: req.InputBytes}}
+ }
+ if err := stream.Send(&pb.ProcessRequest{Request: &pb.ProcessRequest_Header{Header: header}}); err != nil {
+ t.Fatalf("RunBridgeRoundTrip: send header: %v", err)
+ }
+
+ // Echo every read part back so the daemon's writer thread has
+ // something to apply. We capture each read part for caller
+ // inspection.
+ var parts []*model.Part
+ var output []byte
+ for {
+ resp, err := stream.Recv()
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ t.Fatalf("RunBridgeRoundTrip: recv: %v", err)
+ }
+ // EOF from Send means the daemon already half-closed the stream
+ // (it has a Complete{Error} or ReadDone in flight that we will
+ // pick up on the next Recv). Stop trying to send echoes — keep
+ // receiving so the trailing message reaches the Complete handler
+ // and surfaces the real diagnostic instead of a generic EOF.
+ sendEcho := func(req *pb.ProcessRequest) {
+ if err := stream.Send(req); err != nil && !errors.Is(err, io.EOF) {
+ t.Fatalf("RunBridgeRoundTrip: echo: %v", err)
+ }
+ }
+ switch m := resp.Response.(type) {
+ case *pb.ProcessResponse_Part:
+ parts = append(parts, protoconvert.ProtoToPart(m.Part))
+ outPart := maybeTransformPart(m.Part, req.Transform)
+ sendEcho(&pb.ProcessRequest{Request: &pb.ProcessRequest_Part{Part: outPart}})
+ case *pb.ProcessResponse_PartBatch:
+ for _, p := range m.PartBatch.Parts {
+ parts = append(parts, protoconvert.ProtoToPart(p))
+ outPart := maybeTransformPart(p, req.Transform)
+ sendEcho(&pb.ProcessRequest{Request: &pb.ProcessRequest_Part{Part: outPart}})
+ }
+ case *pb.ProcessResponse_ContentBatch:
+ for _, cb := range m.ContentBatch.Blocks {
+ parts = append(parts, protoconvert.ContentBlockToPart(cb))
+ outCb := maybeTransformContentBlock(cb, req.Transform)
+ sendEcho(&pb.ProcessRequest{Request: &pb.ProcessRequest_ContentBlock{ContentBlock: outCb}})
+ }
+ case *pb.ProcessResponse_ReadDone:
+ if err := stream.CloseSend(); err != nil {
+ t.Fatalf("RunBridgeRoundTrip: CloseSend after ReadDone: %v", err)
+ }
+ case *pb.ProcessResponse_Complete:
+ if m.Complete.Error != "" {
+ t.Fatalf("RunBridgeRoundTrip: daemon error: %s", m.Complete.Error)
+ }
+ // Inline output is empty when OutputRef is used (per
+ // neokapi_bridge.proto). Fall back to reading the on-disk
+ // artifact the daemon wrote at outPath.
+ output = m.Complete.Output
+ if len(output) == 0 {
+ bytes, err := os.ReadFile(outPath)
+ if err != nil {
+ t.Fatalf("RunBridgeRoundTrip: read output %s: %v", outPath, err)
+ }
+ output = bytes
+ }
+ }
+ }
+ return BridgeRoundTripResult{Parts: parts, Output: output}
+}
+
+// maybeTransformPart applies the BridgeRequest.Transform hook to a
+// PartMessage's Block payload (if present) and returns a fresh
+// PartMessage to echo back. When transform is nil the original
+// proto is returned unchanged — the daemon-side writer thread sees
+// exactly what its reader emitted.
+func maybeTransformPart(in *pb.PartMessage, transform func(*model.Block)) *pb.PartMessage {
+ if transform == nil || in == nil || in.Block == nil {
+ return in
+ }
+ part := protoconvert.ProtoToPart(in)
+ if part == nil {
+ return in
+ }
+ block, ok := part.Resource.(*model.Block)
+ if !ok || block == nil {
+ return in
+ }
+ transform(block)
+ return protoconvert.PartToProto(part)
+}
+
+// maybeTransformContentBlock is the lightweight-encoding sibling of
+// maybeTransformPart for the bridge's ContentBlock fast path.
+func maybeTransformContentBlock(in *pb.ContentBlock, transform func(*model.Block)) *pb.ContentBlock {
+ if transform == nil || in == nil {
+ return in
+ }
+ part := protoconvert.ContentBlockToPart(in)
+ if part == nil {
+ return in
+ }
+ block, ok := part.Resource.(*model.Block)
+ if !ok || block == nil {
+ return in
+ }
+ transform(block)
+ return protoconvert.PartToContentBlock(part)
+}
+
+// StepRequest configures one ProcessStep RPC.
+type StepRequest struct {
+ StepClass string
+ StepParams map[string]string
+ ParamTypes map[string]string
+ SourceLocale string
+ TargetLocale string
+ // Parts is the input stream to feed into the step. The harness
+ // sends them after the StepHeader.
+ Parts []*model.Part
+}
+
+// RunBridgeStep drives a ProcessStep RPC, feeding `Parts` into the
+// daemon's pipeline step and returning whatever the step emits.
+func RunBridgeStep(t *testing.T, req StepRequest) []*model.Part {
+ t.Helper()
+ if req.StepClass == "" {
+ t.Fatal("RunBridgeStep: StepClass is required")
+ }
+ client := AcquireBridgeDaemon(t)
+ bridgeClient := pb.NewBridgeServiceClient(client.Conn)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ stream, err := bridgeClient.ProcessStep(ctx)
+ if err != nil {
+ t.Fatalf("RunBridgeStep: open ProcessStep stream: %v", err)
+ }
+
+ header := &pb.StepHeader{
+ StepClass: req.StepClass,
+ StepParams: req.StepParams,
+ SourceLocale: defaultStr(req.SourceLocale, "en"),
+ TargetLocale: defaultStr(req.TargetLocale, "fr"),
+ ParamTypes: req.ParamTypes,
+ }
+ if err := stream.Send(&pb.StepRequest{Request: &pb.StepRequest_Header{Header: header}}); err != nil {
+ t.Fatalf("RunBridgeStep: send header: %v", err)
+ }
+
+ // Fan parts in concurrently so the daemon can pipeline.
+ sendErr := make(chan error, 1)
+ go func() {
+ defer func() { _ = stream.CloseSend() }()
+ for _, p := range req.Parts {
+ if err := stream.Send(&pb.StepRequest{Request: &pb.StepRequest_Part{Part: protoconvert.PartToProto(p)}}); err != nil {
+ sendErr <- fmt.Errorf("send part: %w", err)
+ return
+ }
+ }
+ sendErr <- nil
+ }()
+
+ var out []*model.Part
+ for {
+ resp, err := stream.Recv()
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ t.Fatalf("RunBridgeStep: recv: %v", err)
+ }
+ switch m := resp.Response.(type) {
+ case *pb.StepResponse_Part:
+ out = append(out, protoconvert.ProtoToPart(m.Part))
+ case *pb.StepResponse_Complete:
+ // Continue draining until the stream ends.
+ _ = m
+ }
+ }
+ if err := <-sendErr; err != nil {
+ t.Fatalf("RunBridgeStep: %v", err)
+ }
+ return out
+}
+
+// drainProcessResponses pulls every part off a Process stream until the
+// daemon emits ProcessComplete or the stream ends.
+func drainProcessResponses(stream pb.BridgeService_ProcessClient) ([]*model.Part, error) {
+ var out []*model.Part
+ for {
+ resp, err := stream.Recv()
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ return out, nil
+ }
+ return out, err
+ }
+ switch m := resp.Response.(type) {
+ case *pb.ProcessResponse_Part:
+ out = append(out, protoconvert.ProtoToPart(m.Part))
+ case *pb.ProcessResponse_PartBatch:
+ for _, p := range m.PartBatch.Parts {
+ out = append(out, protoconvert.ProtoToPart(p))
+ }
+ case *pb.ProcessResponse_ContentBatch:
+ for _, cb := range m.ContentBatch.Blocks {
+ out = append(out, protoconvert.ContentBlockToPart(cb))
+ }
+ case *pb.ProcessResponse_ReadDone:
+ // Read-only mode closes after the next ProcessComplete.
+ case *pb.ProcessResponse_Complete:
+ if m.Complete.Error != "" {
+ return out, fmt.Errorf("daemon: %s", m.Complete.Error)
+ }
+ return out, nil
+ }
+ }
+}
+
+func defaultStr(v, fallback string) string {
+ if v == "" {
+ return fallback
+ }
+ return v
+}
diff --git a/parity/cmd/okapi-test-scan/main.go b/parity/cmd/okapi-test-scan/main.go
new file mode 100644
index 0000000..c6bd56a
--- /dev/null
+++ b/parity/cmd/okapi-test-scan/main.go
@@ -0,0 +1,380 @@
+// Command okapi-test-scan extracts inline-string fixture material from
+// Okapi filter test sources so the parity harness can drive every
+// extractable Java @Test through the bridge with a known input.
+//
+// The scanner is intentionally lossy: it walks Java with regular
+// expressions, not an AST, and only recovers tests whose input shape
+// matches one of the dominant patterns in upstream Okapi:
+//
+// @Test
+// public void testFoo() {
+// String snippet = "..."; // single literal
+// String snippet = "..." // multi-line string concat
+// + "...";
+// ...
+// }
+//
+// Tests that build inputs programmatically, load resource files, or
+// whose snippet variable is named something other than `snippet` are
+// skipped — the survey upstream put that population at ~10–15% per
+// filter, which is acceptable for a coverage pass that reports its
+// own gaps.
+//
+// Output is a build-tagged Go file (see -out) carrying one
+// []formats.FormatInput per scanned test class. Hand-curated fixtures
+// in spec.go remain authoritative; the auto-generated set is
+// referenced explicitly per FormatSpec.
+//
+// Usage:
+//
+// go run ./cmd/okapi-test-scan \
+// -src /path/to/okapi-java/okapi/filters/html/src/test/java \
+// -class HtmlSnippetsTest \
+// -package html \
+// -out formats/fixtures_html_generated.go
+package main
+
+import (
+ "bytes"
+ "flag"
+ "fmt"
+ "go/format"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+func main() {
+ src := flag.String("src", "", "Java test source root (directory walked recursively)")
+ classFilter := flag.String("class", "", "Comma-separated short class names to include (e.g. HtmlSnippetsTest). Empty = all *Test.java.")
+ pkg := flag.String("package", "formats", "Go package to write into")
+ out := flag.String("out", "", "Output Go file path (required)")
+ flag.Parse()
+
+ if *src == "" || *out == "" {
+ die("must set -src and -out")
+ }
+ classes := splitCSV(*classFilter)
+ classMatch := func(name string) bool {
+ if len(classes) == 0 {
+ return true
+ }
+ for _, c := range classes {
+ if c == name {
+ return true
+ }
+ }
+ return false
+ }
+
+ var scanned []scannedClass
+ err := filepath.WalkDir(*src, func(path string, d os.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if d.IsDir() || !strings.HasSuffix(path, "Test.java") {
+ return nil
+ }
+ short := strings.TrimSuffix(filepath.Base(path), ".java")
+ if !classMatch(short) {
+ return nil
+ }
+ c, err := scanFile(path, short)
+ if err != nil {
+ return fmt.Errorf("%s: %w", path, err)
+ }
+ if len(c.Tests) > 0 {
+ scanned = append(scanned, c)
+ }
+ return nil
+ })
+ if err != nil {
+ die("scan: %v", err)
+ }
+
+ sort.SliceStable(scanned, func(i, j int) bool { return scanned[i].ClassName < scanned[j].ClassName })
+
+ body, err := emit(*pkg, scanned)
+ if err != nil {
+ die("emit: %v", err)
+ }
+ if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
+ die("mkdir: %v", err)
+ }
+ if err := os.WriteFile(*out, body, 0o644); err != nil {
+ die("write: %v", err)
+ }
+
+ totalTests := 0
+ totalScanned := 0
+ for _, c := range scanned {
+ totalTests += c.SeenAtTests
+ totalScanned += len(c.Tests)
+ fmt.Fprintf(os.Stderr, " %s: %d/%d tests extracted (%d had unsupported input shapes)\n",
+ c.ClassName, len(c.Tests), c.SeenAtTests, c.SkippedReasons["unsupported"])
+ }
+ fmt.Fprintf(os.Stderr, "okapi-test-scan: %d/%d tests across %d classes → %s\n",
+ totalScanned, totalTests, len(scanned), *out)
+}
+
+// scannedClass aggregates per-test results for one Java test class.
+type scannedClass struct {
+ ClassName string // short class name (e.g. HtmlSnippetsTest)
+ SourceFile string // path to the .java file (relative to scan root)
+ Tests []scannedTest // successfully extracted tests
+ SeenAtTests int // total @Test methods seen
+ SkippedReasons map[string]int // reason → count
+}
+
+// scannedTest is one extractable @Test method.
+type scannedTest struct {
+ Method string // testFoo
+ Snippet string // inline content (already unescaped from Java string literal form)
+}
+
+var (
+ // `@Test` with optional parameter list, followed by the next
+ // `public void (...)` declaration. We intentionally accept
+ // `@Test(expected = ...)` because the input still matters even if
+ // the assertion is "throws".
+ testHeaderRE = regexp.MustCompile(`(?m)^\s*@Test(?:\([^)]*\))?\s*$`)
+ methodDeclRE = regexp.MustCompile(`^\s*public\s+void\s+(\w+)\s*\(`)
+ // `String snippet = "..."`, optionally with `+ "..."` continuation
+ // lines until a terminating `;`. We extract the concatenation as
+ // one logical literal.
+ snippetStartRE = regexp.MustCompile(`^\s*(?:final\s+)?String\s+snippet\s*=\s*(.+)$`)
+)
+
+func scanFile(path, className string) (scannedClass, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return scannedClass{}, err
+ }
+ c := scannedClass{
+ ClassName: className,
+ SourceFile: path,
+ SkippedReasons: map[string]int{},
+ }
+ lines := strings.Split(string(data), "\n")
+
+ for i := 0; i < len(lines); i++ {
+ if !testHeaderRE.MatchString(lines[i]) {
+ continue
+ }
+ // Find the next public-void declaration (allowing other
+ // annotations or whitespace in between).
+ j := i + 1
+ var method string
+ for j < len(lines) && j < i+10 {
+ if m := methodDeclRE.FindStringSubmatch(lines[j]); m != nil {
+ method = m[1]
+ break
+ }
+ j++
+ }
+ if method == "" {
+ c.SkippedReasons["no-method"]++
+ continue
+ }
+ c.SeenAtTests++
+
+ // Find the end of the method body (matching brace) so we can
+ // scan only that region for the snippet declaration. Cheap
+ // brace counter starting from the `{` after the signature.
+ bodyStart := j
+ for bodyStart < len(lines) && !strings.Contains(lines[bodyStart], "{") {
+ bodyStart++
+ }
+ bodyEnd := scanForMethodEnd(lines, bodyStart)
+
+ snippet, ok := extractSnippet(lines[bodyStart : bodyEnd+1])
+ if !ok {
+ c.SkippedReasons["unsupported"]++
+ continue
+ }
+ c.Tests = append(c.Tests, scannedTest{Method: method, Snippet: snippet})
+ i = bodyEnd
+ }
+ return c, nil
+}
+
+// scanForMethodEnd finds the closing `}` of the method body whose
+// opening brace is on or after `start`. Returns the index of the line
+// containing the closing brace.
+func scanForMethodEnd(lines []string, start int) int {
+ depth := 0
+ for k := start; k < len(lines); k++ {
+ for _, ch := range lines[k] {
+ switch ch {
+ case '{':
+ depth++
+ case '}':
+ depth--
+ if depth == 0 {
+ return k
+ }
+ }
+ }
+ }
+ return len(lines) - 1
+}
+
+// extractSnippet pulls the body of `String snippet = "..."` from a
+// method body, supporting multi-line concatenation. Returns the
+// unescaped snippet content and a boolean indicating success.
+func extractSnippet(body []string) (string, bool) {
+ for i, line := range body {
+ m := snippetStartRE.FindStringSubmatch(line)
+ if m == nil {
+ continue
+ }
+ // Stitch continuation lines until we find the terminating `;`.
+ raw := m[1]
+ for !strings.HasSuffix(strings.TrimSpace(raw), ";") && i+1 < len(body) {
+ i++
+ raw += " " + strings.TrimSpace(body[i])
+ }
+ raw = strings.TrimSuffix(strings.TrimSpace(raw), ";")
+ return parseJavaConcat(raw)
+ }
+ return "", false
+}
+
+// parseJavaConcat joins a Java string-concatenation expression like
+//
+// "a" + "b" + "c"
+//
+// into the single decoded literal `abc`. Returns false if any term
+// isn't a simple double-quoted Java string (e.g. method calls, vars).
+func parseJavaConcat(expr string) (string, bool) {
+ var out strings.Builder
+ i := 0
+ for i < len(expr) {
+ // Skip whitespace.
+ for i < len(expr) && (expr[i] == ' ' || expr[i] == '\t' || expr[i] == '+' || expr[i] == '\n') {
+ i++
+ }
+ if i >= len(expr) {
+ break
+ }
+ if expr[i] != '"' {
+ return "", false // non-literal term
+ }
+ end, lit, ok := readJavaString(expr[i:])
+ if !ok {
+ return "", false
+ }
+ out.WriteString(lit)
+ i += end
+ }
+ return out.String(), true
+}
+
+// readJavaString reads a Java double-quoted string literal starting at
+// s[0]=='"'. Returns the consumed length, the decoded contents, and
+// success.
+func readJavaString(s string) (int, string, bool) {
+ if len(s) == 0 || s[0] != '"' {
+ return 0, "", false
+ }
+ var buf strings.Builder
+ i := 1
+ for i < len(s) {
+ c := s[i]
+ if c == '"' {
+ return i + 1, buf.String(), true
+ }
+ if c == '\\' && i+1 < len(s) {
+ switch s[i+1] {
+ case 'n':
+ buf.WriteByte('\n')
+ case 'r':
+ buf.WriteByte('\r')
+ case 't':
+ buf.WriteByte('\t')
+ case '"':
+ buf.WriteByte('"')
+ case '\\':
+ buf.WriteByte('\\')
+ case '\'':
+ buf.WriteByte('\'')
+ case 'u':
+ if i+5 >= len(s) {
+ return 0, "", false
+ }
+ code, err := strconv.ParseInt(s[i+2:i+6], 16, 32)
+ if err != nil {
+ return 0, "", false
+ }
+ buf.WriteRune(rune(code))
+ i += 4
+ default:
+ buf.WriteByte(s[i+1])
+ }
+ i += 2
+ continue
+ }
+ buf.WriteByte(c)
+ i++
+ }
+ return 0, "", false // unterminated
+}
+
+// emit produces a build-tagged Go file declaring one
+// []formats.FormatInput per class.
+func emit(pkg string, classes []scannedClass) ([]byte, error) {
+ var b bytes.Buffer
+ fmt.Fprintln(&b, "//go:build parity")
+ fmt.Fprintln(&b)
+ fmt.Fprintln(&b, "// Code generated by cmd/okapi-test-scan. DO NOT EDIT.")
+ fmt.Fprintln(&b)
+ fmt.Fprintf(&b, "package %s\n\n", pkg)
+
+ for _, c := range classes {
+ varName := "Generated" + c.ClassName + "Inputs"
+ fmt.Fprintf(&b, "// %s holds %d auto-extracted @Test fixtures from %s.\n",
+ varName, len(c.Tests), c.ClassName)
+ fmt.Fprintf(&b, "var %s = []FormatInput{\n", varName)
+ for _, t := range c.Tests {
+ fmt.Fprintf(&b, "\t{Name: %q, Content: ttext(%s), OkapiTest: %q, Informational: true},\n",
+ "gen-"+t.Method, goRawOrQuoted(t.Snippet), c.ClassName+"#"+t.Method)
+ }
+ fmt.Fprintln(&b, "}")
+ fmt.Fprintln(&b)
+ }
+ return format.Source(b.Bytes())
+}
+
+// goRawOrQuoted picks the most readable Go literal form for the
+// snippet content. Backtick-quoted is preferred when the input has no
+// backticks of its own; otherwise we fall back to %q which handles
+// arbitrary bytes including embedded newlines and quotes.
+func goRawOrQuoted(s string) string {
+ if !strings.ContainsRune(s, '`') {
+ return "`" + s + "`"
+ }
+ return strconv.Quote(s)
+}
+
+func splitCSV(s string) []string {
+ if s == "" {
+ return nil
+ }
+ parts := strings.Split(s, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func die(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, "okapi-test-scan: "+format+"\n", args...)
+ os.Exit(1)
+}
diff --git a/parity/cmd/testcompare/main.go b/parity/cmd/testcompare/main.go
new file mode 100644
index 0000000..e9fd252
--- /dev/null
+++ b/parity/cmd/testcompare/main.go
@@ -0,0 +1,148 @@
+// Command testcompare reads .parity/test-comparison.json (the raw
+// parity report written by the parity test packages) and emits a
+// narrower per-filter summary at .parity/parity-report.json.
+//
+// The summary is a local maintainer artifact, excluded from the documentation
+// site. It contains one row per filter or step, plus totals.
+//
+// {
+// "generated_at": "2026-04-29T...",
+// "totals": {
+// "format": {"pass": 51, "fail": 1, "skip": 0, "total": 52},
+// "step": {"pass": 119, "fail": 0, "skip": 3, "total": 122}
+// },
+// "rows": [
+// {"kind": "format", "id": "okf_html", "status": "pass", "mode": "head-to-head", "duration_ms": 412, "detail": ""},
+// ...
+// ]
+// }
+//
+// Usage:
+//
+// go run ./cmd/testcompare \
+// -in .parity/test-comparison.json \
+// -out .parity/parity-report.json
+package main
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "time"
+)
+
+type rawRow struct {
+ Kind string `json:"kind"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Mode string `json:"mode,omitempty"`
+ Detail string `json:"detail,omitempty"`
+ DurationMS int64 `json:"duration_ms"`
+ Timestamp string `json:"timestamp"`
+}
+
+type publishedRow struct {
+ Kind string `json:"kind"`
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Mode string `json:"mode,omitempty"`
+ Detail string `json:"detail,omitempty"`
+ DurationMS int64 `json:"duration_ms"`
+}
+
+type totals struct {
+ Pass int `json:"pass"`
+ Fail int `json:"fail"`
+ Skip int `json:"skip"`
+ Error int `json:"error"`
+ Total int `json:"total"`
+}
+
+type published struct {
+ GeneratedAt string `json:"generated_at"`
+ Totals map[string]*totals `json:"totals"`
+ Rows []publishedRow `json:"rows"`
+}
+
+func main() {
+ in := flag.String("in", ".parity/test-comparison.json", "input parity report path")
+ out := flag.String("out", ".parity/parity-report.json", "output summary JSON path")
+ flag.Parse()
+
+ data, err := os.ReadFile(*in)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "testcompare: read %s: %v\n", *in, err)
+ os.Exit(1)
+ }
+ var rows []rawRow
+ if err := json.Unmarshal(data, &rows); err != nil {
+ fmt.Fprintf(os.Stderr, "testcompare: parse %s: %v\n", *in, err)
+ os.Exit(1)
+ }
+
+ out_, totals_ := transform(rows)
+ doc := published{
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
+ Totals: totals_,
+ Rows: out_,
+ }
+ body, err := json.MarshalIndent(doc, "", " ")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "testcompare: marshal: %v\n", err)
+ os.Exit(1)
+ }
+ if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
+ fmt.Fprintf(os.Stderr, "testcompare: mkdir %s: %v\n", filepath.Dir(*out), err)
+ os.Exit(1)
+ }
+ if err := os.WriteFile(*out, body, 0o644); err != nil {
+ fmt.Fprintf(os.Stderr, "testcompare: write %s: %v\n", *out, err)
+ os.Exit(1)
+ }
+ fmt.Fprintf(os.Stderr, "testcompare: %d rows → %s\n", len(rows), *out)
+}
+
+// transform reduces the raw rows to the published shape and computes
+// per-kind totals. Rows are sorted by (kind, id) so the dashboard
+// output is deterministic across runs.
+func transform(rows []rawRow) ([]publishedRow, map[string]*totals) {
+ out := make([]publishedRow, 0, len(rows))
+ tot := map[string]*totals{}
+ for _, r := range rows {
+ out = append(out, publishedRow{
+ Kind: r.Kind,
+ ID: r.ID,
+ Status: r.Status,
+ Mode: r.Mode,
+ Detail: r.Detail,
+ DurationMS: r.DurationMS,
+ })
+ t, ok := tot[r.Kind]
+ if !ok {
+ t = &totals{}
+ tot[r.Kind] = t
+ }
+ t.Total++
+ switch r.Status {
+ case "pass":
+ t.Pass++
+ case "fail":
+ t.Fail++
+ case "skip":
+ t.Skip++
+ case "error":
+ t.Error++
+ }
+ }
+ sort.SliceStable(out, func(i, j int) bool {
+ if out[i].Kind != out[j].Kind {
+ return out[i].Kind < out[j].Kind
+ }
+ return out[i].ID < out[j].ID
+ })
+ return out, tot
+}
diff --git a/parity/cmd/testcompare/main_test.go b/parity/cmd/testcompare/main_test.go
new file mode 100644
index 0000000..80cc99d
--- /dev/null
+++ b/parity/cmd/testcompare/main_test.go
@@ -0,0 +1,36 @@
+package main
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestTransformSortsAndAggregates(t *testing.T) {
+ rows := []rawRow{
+ {Kind: "format", ID: "okf_xml", Status: "pass", Mode: "head-to-head", DurationMS: 100},
+ {Kind: "format", ID: "okf_html", Status: "fail", Mode: "head-to-head", Detail: "diverged on x", DurationMS: 200},
+ {Kind: "step", ID: "word-count", Status: "skip", Mode: "bridge-only", Detail: "needs testdata"},
+ {Kind: "format", ID: "okf_json", Status: "pass", Mode: "head-to-head"},
+ {Kind: "step", ID: "char-count", Status: "pass", Mode: "head-to-head"},
+ }
+
+ got, tot := transform(rows)
+
+ wantOrder := []string{"okf_html", "okf_json", "okf_xml", "char-count", "word-count"}
+ if len(got) != len(wantOrder) {
+ t.Fatalf("got %d rows, want %d", len(got), len(wantOrder))
+ }
+ for i, want := range wantOrder {
+ if got[i].ID != want {
+ t.Errorf("row[%d].ID = %q, want %q", i, got[i].ID, want)
+ }
+ }
+
+ wantTotals := map[string]*totals{
+ "format": {Pass: 2, Fail: 1, Skip: 0, Error: 0, Total: 3},
+ "step": {Pass: 1, Fail: 0, Skip: 1, Error: 0, Total: 2},
+ }
+ if !reflect.DeepEqual(tot, wantTotals) {
+ t.Errorf("totals mismatch:\n got: %#v\nwant: %#v", tot, wantTotals)
+ }
+}
diff --git a/parity/compare.go b/parity/compare.go
new file mode 100644
index 0000000..99ac223
--- /dev/null
+++ b/parity/compare.go
@@ -0,0 +1,205 @@
+//go:build parity
+
+package parity
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/neokapi/neokapi/core/model"
+)
+
+// CompareEvents asserts that two part streams agree on their canonical
+// projection (see CanonicalPart). It logs a structured diff on mismatch
+// and returns whether the streams matched, so callers can choose
+// between t.Fatal and t.Error.
+func CompareEvents(t *testing.T, native, bridge []*model.Part) bool {
+ t.Helper()
+ gotNative := Canonicalize(native)
+ gotBridge := Canonicalize(bridge)
+
+ if diff := canonicalDiff(gotBridge, gotNative); diff != "" {
+ t.Errorf("event-level parity mismatch (-bridge +native):\n%s\n%s", diff, partSummary(native, bridge))
+ return false
+ }
+ return true
+}
+
+// canonicalDiff returns a multi-line diff between two CanonicalPart
+// slices. Returns "" when they match.
+func canonicalDiff(want, got []CanonicalPart) string {
+ if len(want) != len(got) {
+ var buf strings.Builder
+ fmt.Fprintf(&buf, "len mismatch: bridge=%d native=%d\n", len(want), len(got))
+ n := max(len(got), len(want))
+ for i := range n {
+ var w, g string
+ if i < len(want) {
+ w = canonicalLine(want[i])
+ } else {
+ w = ""
+ }
+ if i < len(got) {
+ g = canonicalLine(got[i])
+ } else {
+ g = ""
+ }
+ marker := " "
+ if w != g {
+ marker = "* "
+ }
+ fmt.Fprintf(&buf, "%s[%d]\n - %s\n + %s\n", marker, i, w, g)
+ }
+ return buf.String()
+ }
+ var buf strings.Builder
+ mismatched := 0
+ for i := range want {
+ w, g := canonicalLine(want[i]), canonicalLine(got[i])
+ if w != g {
+ mismatched++
+ fmt.Fprintf(&buf, "[%d]\n - %s\n + %s\n", i, w, g)
+ }
+ }
+ if mismatched == 0 {
+ return ""
+ }
+ return buf.String()
+}
+
+// canonicalLine renders a CanonicalPart on a single line for diff output.
+func canonicalLine(c CanonicalPart) string {
+ var fields []string
+ add := func(name, value string) {
+ if value == "" {
+ return
+ }
+ fields = append(fields, fmt.Sprintf("%s=%q", name, value))
+ }
+ addBool := func(name string, value bool) {
+ if !value {
+ return
+ }
+ fields = append(fields, fmt.Sprintf("%s=%t", name, value))
+ }
+ addInt := func(name string, value int) {
+ if value == 0 {
+ return
+ }
+ fields = append(fields, fmt.Sprintf("%s=%d", name, value))
+ }
+ add("BlockID", c.BlockID)
+ addBool("Translatable", c.Translatable)
+ add("Source", c.Source)
+ add("Targets", c.Targets)
+ add("GroupID", c.GroupID)
+ add("GroupType", c.GroupType)
+ add("LayerID", c.LayerID)
+ add("LayerName", c.LayerName)
+ add("DataID", c.DataID)
+ add("MediaMime", c.MediaMime)
+ addInt("MediaSize", c.MediaSize)
+ if len(fields) == 0 {
+ return c.Type.String()
+ }
+ return c.Type.String() + " " + strings.Join(fields, " ")
+}
+
+// CompareBytes asserts byte-exact equality, with a hex-style diff on
+// mismatch.
+func CompareBytes(t *testing.T, want, got []byte) bool {
+ t.Helper()
+ if bytes.Equal(want, got) {
+ return true
+ }
+ t.Errorf("byte-level parity mismatch:\n want: %d bytes (sha=%s)\n got: %d bytes (sha=%s)\n first diff: %s",
+ len(want), shortHash(want), len(got), shortHash(got), firstDiff(want, got))
+ return false
+}
+
+// CompareBlockText asserts that the two streams contain the same
+// rendered translatable text in the same order, ignoring everything
+// else. Useful when implementations differ on segmentation but must
+// agree on extracted content.
+func CompareBlockText(t *testing.T, native, bridge []*model.Part) bool {
+ t.Helper()
+ wantText := joinBlockText(native)
+ gotText := joinBlockText(bridge)
+ if wantText == gotText {
+ return true
+ }
+ t.Errorf("block-text parity mismatch:\n bridge: %q\n native: %q", gotText, wantText)
+ return false
+}
+
+// CompareBlockTextSoft is the non-failing variant of CompareBlockText.
+// It logs the mismatch but doesn't call t.Errorf, so the surrounding
+// subtest still passes as far as `go test` is concerned. Use for
+// auto-generated fixtures that explore divergence without blocking
+// CI; the boolean return drives the parity-report status so the
+// dashboard still shows the truth.
+func CompareBlockTextSoft(t *testing.T, native, bridge []*model.Part) bool {
+ t.Helper()
+ wantText := joinBlockText(native)
+ gotText := joinBlockText(bridge)
+ if wantText == gotText {
+ return true
+ }
+ t.Logf("block-text parity mismatch (informational):\n bridge: %q\n native: %q", gotText, wantText)
+ return false
+}
+
+// joinBlockText concatenates rendered block text in stream order.
+func joinBlockText(parts []*model.Part) string {
+ var buf strings.Builder
+ for _, p := range parts {
+ if p.Type != model.PartBlock {
+ continue
+ }
+ b, ok := p.Resource.(*model.Block)
+ if !ok || !b.Translatable {
+ continue
+ }
+ buf.WriteString(renderBlockSource(b))
+ buf.WriteByte('\n')
+ }
+ return buf.String()
+}
+
+func partSummary(native, bridge []*model.Part) string {
+ var buf strings.Builder
+ buf.WriteString("native parts:\n")
+ for i, p := range native {
+ fmt.Fprintf(&buf, " [%d] %s\n", i, p.Type)
+ }
+ buf.WriteString("bridge parts:\n")
+ for i, p := range bridge {
+ fmt.Fprintf(&buf, " [%d] %s\n", i, p.Type)
+ }
+ return buf.String()
+}
+
+func firstDiff(a, b []byte) string {
+ n := min(len(b), len(a))
+ for i := range n {
+ if a[i] != b[i] {
+ lo := max(i-8, 0)
+ hi := min(i+24, n)
+ return fmt.Sprintf("offset %d: want %q got %q", i, a[lo:hi], b[lo:hi])
+ }
+ }
+ return fmt.Sprintf("length differs at %d", n)
+}
+
+func shortHash(b []byte) string {
+ const fnvOffset uint64 = 14695981039346656037
+ const fnvPrime uint64 = 1099511628211
+ h := fnvOffset
+ for _, c := range b {
+ h ^= uint64(c)
+ h *= fnvPrime
+ }
+ return fmt.Sprintf("%016x", h)
+}
diff --git a/parity/daemon.go b/parity/daemon.go
new file mode 100644
index 0000000..0a9e79b
--- /dev/null
+++ b/parity/daemon.go
@@ -0,0 +1,257 @@
+//go:build parity
+
+package parity
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/connectivity"
+ "google.golang.org/grpc/credentials/insecure"
+)
+
+// daemonStartupTimeout bounds the wait for the daemon's handshake line and
+// for its socket to accept connections. A cold JVM start on a CI runner is
+// well inside this.
+const daemonStartupTimeout = 60 * time.Second
+
+// maxGRPCMsgSize is the per-message limit for the daemon connection. The
+// bridge streams whole documents inline, so the default 4 MB cap is too low.
+const maxGRPCMsgSize = 256 << 20
+
+// Handshake is the JSON envelope a plugin daemon prints as its first stdout
+// line (plugin protocol v1, Mode C). The daemon keeps stdout open afterwards
+// and writes only logs to it.
+type Handshake struct {
+ // Socket is the absolute path to the Unix socket the daemon bound to.
+ Socket string `json:"socket"`
+ // Version is the daemon's own version string.
+ Version string `json:"version"`
+}
+
+// DaemonClient is a running okapi-bridge daemon and the gRPC connection to it.
+type DaemonClient struct {
+ // Conn is the gRPC client connection to the daemon's Unix socket.
+ Conn *grpc.ClientConn
+ // Socket is the path the daemon announced in its handshake.
+ Socket string
+ // Version is the version string from the handshake.
+ Version string
+
+ cmd *exec.Cmd
+ drainDone chan struct{}
+}
+
+// PID returns the daemon's process id.
+func (c *DaemonClient) PID() int {
+ if c.cmd == nil || c.cmd.Process == nil {
+ return 0
+ }
+ return c.cmd.Process.Pid
+}
+
+// Close terminates the daemon: SIGTERM, a grace period, then SIGKILL.
+func (c *DaemonClient) Close() {
+ if c.Conn != nil {
+ _ = c.Conn.Close()
+ }
+ if c.cmd == nil || c.cmd.Process == nil {
+ return
+ }
+ _ = c.cmd.Process.Signal(os.Interrupt)
+ done := make(chan struct{})
+ go func() {
+ <-c.drainDone
+ _ = c.cmd.Wait()
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ _ = c.cmd.Process.Kill()
+ <-done
+ }
+ if c.Socket != "" {
+ _ = os.Remove(c.Socket)
+ }
+}
+
+// daemonState owns the singleton daemon shared by every parity test in one
+// binary invocation.
+type daemonState struct {
+ once sync.Once
+ client *DaemonClient
+ initErr error
+ shutdown sync.Once
+}
+
+var bridgeDaemon = &daemonState{}
+
+// AcquireBridgeDaemon returns the shared okapi-bridge daemon client. The
+// daemon is spawned from the sandbox the first time this is called and reused
+// by every later caller in the same test binary.
+func AcquireBridgeDaemon(t *testing.T) *DaemonClient {
+ t.Helper()
+ s := RequireSandbox(t)
+
+ bridgeDaemon.once.Do(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), daemonStartupTimeout)
+ defer cancel()
+ client, err := StartDaemon(ctx, s)
+ if err != nil {
+ bridgeDaemon.initErr = err
+ return
+ }
+ bridgeDaemon.client = client
+ if testing.Verbose() {
+ fmt.Fprintf(os.Stderr, "[parity-daemon] spawned okapi-bridge at %s (pid %d)\n", client.Socket, client.PID())
+ }
+ })
+ if bridgeDaemon.initErr != nil {
+ t.Fatalf("acquire bridge daemon: %v", bridgeDaemon.initErr)
+ }
+ return bridgeDaemon.client
+}
+
+// ShutdownBridgeDaemon stops the shared daemon. Each test package's TestMain
+// calls it after m.Run so the JVM is released between `go test` invocations.
+func ShutdownBridgeDaemon() {
+ bridgeDaemon.shutdown.Do(func() {
+ if bridgeDaemon.client != nil {
+ bridgeDaemon.client.Close()
+ }
+ })
+}
+
+// StartDaemon launches the okapi-bridge launcher installed in the sandbox with
+// the `daemon` argument, reads its handshake, and dials the announced socket.
+// The subprocess is started without a context so the daemon outlives the
+// startup deadline; Close tears it down.
+func StartDaemon(ctx context.Context, s *Sandbox) (*DaemonClient, error) {
+ if _, err := os.Stat(s.OkapiBridgeBinary); err != nil {
+ return nil, fmt.Errorf("okapi-bridge launcher missing at %s: %w", s.OkapiBridgeBinary, err)
+ }
+ cmd := exec.Command(s.OkapiBridgeBinary, "daemon") //nolint:noctx // long-lived daemon subprocess
+ cmd.Env = append(os.Environ(),
+ "KAPI_PLUGIN_DIR="+s.OkapiBridgeDir,
+ "KAPI_PLUGIN_NAME=okapi-bridge",
+ )
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, fmt.Errorf("daemon stdout pipe: %w", err)
+ }
+ cmd.Stderr = os.Stderr
+ if err := cmd.Start(); err != nil {
+ return nil, fmt.Errorf("start %s: %w", s.OkapiBridgeBinary, err)
+ }
+
+ type handshakeResult struct {
+ hs Handshake
+ err error
+ }
+ br := bufio.NewReader(stdout)
+ hsCh := make(chan handshakeResult, 1)
+ go func() {
+ line, err := br.ReadString('\n')
+ if err != nil && line == "" {
+ hsCh <- handshakeResult{err: fmt.Errorf("read handshake: %w", err)}
+ return
+ }
+ var hs Handshake
+ if err := json.Unmarshal([]byte(strings.TrimSpace(line)), &hs); err != nil {
+ hsCh <- handshakeResult{err: fmt.Errorf("parse handshake %q: %w", strings.TrimSpace(line), err)}
+ return
+ }
+ hsCh <- handshakeResult{hs: hs}
+ }()
+
+ kill := func() {
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ }
+ var hs Handshake
+ select {
+ case <-ctx.Done():
+ kill()
+ return nil, fmt.Errorf("okapi-bridge daemon: handshake timeout after %s", daemonStartupTimeout)
+ case res := <-hsCh:
+ if res.err != nil {
+ kill()
+ return nil, fmt.Errorf("okapi-bridge daemon: %w", res.err)
+ }
+ hs = res.hs
+ }
+ if hs.Socket == "" {
+ kill()
+ return nil, errors.New("okapi-bridge daemon: handshake missing socket")
+ }
+
+ // Everything the daemon prints after the handshake is a log line.
+ drainDone := make(chan struct{})
+ go func() {
+ defer close(drainDone)
+ for {
+ line, err := br.ReadString('\n')
+ if line != "" {
+ fmt.Fprintf(os.Stderr, "[okapi-bridge] %s", line)
+ }
+ if err != nil {
+ return
+ }
+ }
+ }()
+
+ conn, err := dialUnixSocket(ctx, hs.Socket)
+ if err != nil {
+ kill()
+ return nil, fmt.Errorf("okapi-bridge daemon: dial %s: %w", hs.Socket, err)
+ }
+ return &DaemonClient{
+ Conn: conn,
+ Socket: hs.Socket,
+ Version: hs.Version,
+ cmd: cmd,
+ drainDone: drainDone,
+ }, nil
+}
+
+// dialUnixSocket opens a gRPC connection over a Unix socket and waits until
+// it is ready, so a daemon that announced a socket it does not serve fails
+// here rather than on the first RPC.
+func dialUnixSocket(ctx context.Context, socket string) (*grpc.ClientConn, error) {
+ conn, err := grpc.NewClient("unix://"+socket,
+ grpc.WithTransportCredentials(insecure.NewCredentials()),
+ grpc.WithDefaultCallOptions(
+ grpc.MaxCallRecvMsgSize(maxGRPCMsgSize),
+ grpc.MaxCallSendMsgSize(maxGRPCMsgSize),
+ ),
+ )
+ if err != nil {
+ return nil, err
+ }
+ conn.Connect()
+ for {
+ st := conn.GetState()
+ switch st {
+ case connectivity.Ready:
+ return conn, nil
+ case connectivity.TransientFailure, connectivity.Shutdown:
+ _ = conn.Close()
+ return nil, fmt.Errorf("connection entered %s", st)
+ }
+ if !conn.WaitForStateChange(ctx, st) {
+ _ = conn.Close()
+ return nil, ctx.Err()
+ }
+ }
+}
diff --git a/parity/doc.go b/parity/doc.go
new file mode 100644
index 0000000..288f942
--- /dev/null
+++ b/parity/doc.go
@@ -0,0 +1,58 @@
+// Package parity is the conformance suite that runs neokapi's Go ports of
+// Okapi filters and steps head-to-head against the Okapi Framework, through
+// this repository's bridge plugin and, where a build is reachable, the tikal
+// CLI.
+//
+// The suite compiles against a released github.com/neokapi/neokapi module and
+// drives a released kapi binary. It is gated behind the build tag "parity"
+// and needs a sandbox:
+// - a kapi binary at $KAPI_PARITY_SANDBOX/bin/kapi
+// - the okapi-bridge plugin unpacked at $KAPI_PARITY_SANDBOX/plugins/okapi-bridge
+// - optionally, the okapi-testdata release tree under $KAPI_PARITY_SANDBOX/okapi-testdata/
+//
+// `make parity-sandbox` in this repository builds that layout from the kapi
+// on PATH (or $KAPI_BIN) and a freshly built plugin tarball; `make
+// parity-test` runs the suite against it. Tests called outside this
+// orchestration fail with an explanatory message rather than measuring a
+// system-installed plugin.
+//
+// # Architecture
+//
+// ┌─────────────────────────────────────────────────────────────────────┐
+// │ parity test (build tag: parity) │
+// │ │
+// │ RunNative(...) ──────────────► neokapi format reader (in-process) │
+// │ │ │
+// │ []model.Part │
+// │ ▼ │
+// │ RunBridge(...) ──────────────► daemon Process RPC │
+// │ │ │ │
+// │ └─► StartDaemon ─────────► kapi-okapi-bridge daemon (JVM) │
+// │ []model.Part │
+// │ ▼ │
+// │ CompareEvents(t, native, bridge) │
+// │ CompareBytes(t, want, got) │
+// └─────────────────────────────────────────────────────────────────────┘
+//
+// # Environment isolation
+//
+// The harness never reads the user's $XDG_DATA_HOME/kapi/plugins/ or honours
+// an inherited $KAPI_PLUGINS_DIR. Every test resolves the sandbox through
+// RequireSandbox and fails when there is none, so a run always measures the
+// plugin that was just built.
+//
+// # Format specs
+//
+// The per-format parity knowledge (bridge filter class, tikal corner, skips)
+// lives in core/formats//spec.yaml inside the neokapi module. NeokapiDir
+// locates that module's source directory, so the specs and the readers under
+// test come from one version. Per-fixture divergence annotations live in this
+// module under annotations/.yaml.
+//
+// # Reporting
+//
+// Tests opt in to the JSON report via the [Report] helper. The collector
+// writes one record per filter or step into $KAPI_PARITY_REPORT (default
+// $KAPI_PARITY_SANDBOX/test-comparison.json); cmd/testcompare turns that into
+// the per-filter summary.
+package parity
diff --git a/parity/docs/roundtrip-testing.md b/parity/docs/roundtrip-testing.md
new file mode 100644
index 0000000..bca11d9
--- /dev/null
+++ b/parity/docs/roundtrip-testing.md
@@ -0,0 +1,482 @@
+# Three-Engine Round-Trip Testing
+
+This note describes the round-trip test harness in
+`parity/roundtrip/`, which exercises every neokapi format end-to-end
+through three different filter engines and asserts they all agree on
+the result.
+
+## Why this exists
+
+The `spec.yaml` runners in `core/format/spec/` and `parity/spec/`
+verify reader contracts: how many Blocks come out of an input, what
+text each one carries, what their IDs are. They never run a writer.
+That coverage proves "we can read these formats" but not "we can read,
+modify, and write them back coherently".
+
+Round-trip testing closes that gap. It's the canonical Okapi
+integration-test pattern (cf. upstream's `IFilterRoundTripIt` /
+`RoundTripUtils`) and is what "extract → translate → merge" reduces to
+in practice. The harness exercises:
+
+ - The reader (parses the source).
+ - The model layer (carries Source + Target through the pipeline).
+ - The writer (re-serializes the document with translations applied).
+ - The skeleton path (preserves untranslatable structure verbatim).
+
+Anything that breaks one of those (broken whitespace handling, a
+writer that drops attributes, a skeleton that misses a region)
+surfaces as a divergence between the engines or a re-extraction that
+doesn't yield the expected text.
+
+## The three engines
+
+Three engines drive the same input through their own toolchain. Each
+implements the `Engine` interface in `parity/roundtrip/engine.go`:
+
+```
+Engine interface {
+ Name() string
+ Available() error
+ RoundTrip(t *testing.T, in Input, spec PseudoSpec) []byte
+}
+```
+
+### `NativeEngine` (always runs)
+
+Wires the format's registered `DataFormatReader` through an inline
+pseudo-translation goroutine into the format's registered
+`DataFormatWriter`. All in-process. No subprocess, no gRPC, no
+external binary. This is the same plumbing as `kapi pseudo-translate`
+on the CLI.
+
+### `BridgeEngine` (runs when okapi-bridge daemon is reachable)
+
+Drives the okapi-bridge daemon's `Process` RPC in **read-write mode**
+(`OutputRef` set in `ProcessHeader`). The daemon reads the input via
+the requested `okf_` filter, streams every Block back over
+gRPC, this side overwrites the target (via the `Transform` hook on
+`parity.BridgeRequest`), and the daemon's writer thread merges the
+modified parts back into the output document at the configured
+`OutputRef.Path`.
+
+The transform hook is engine-agnostic: it receives a `*model.Block`
+and is free to write its target editions. The bridge engine wires it to the
+same `applyPseudoToBlock` helper the native engine uses (in
+`parity/roundtrip/pseudo.go`).
+
+### `OkapiEngine` (the comparator, hard-required)
+
+Shells out to the okapi-bridge launcher's `pseudo` subcommand
+(implemented at `bridge-core/.../PseudoCommand.java`), which composes
+upstream Okapi's pipeline in-process:
+
+```
+RawDocumentToFilterEventsStep → TextModificationStep → FilterEventsToRawDocumentStep
+ (input via okf_) (TYPE_EXTREPLACE, (write via the same filter)
+ SCRIPT_EXT_LATIN)
+```
+
+`TextModificationStep` skips inline-code marker chars during
+substitution, so paired codes (``/``), placeholders
+(``), and arbitrary inline runs survive the round-trip
+unchanged. The Go side mirrors the same Latin-extended substitution
+rune-by-rune in `pseudo.go::applyPseudoToBlock`, leaving
+non-`Text` runs (Ph / PcOpen / PcClose / Sub / Plural / Select)
+verbatim. Same transform, three engines.
+
+When a fixture sets `okapiParamConfig` (e.g. VTT/TTML disabling
+`mergeCaptions`), the harness passes it as `--fprm ` to the
+launcher, where Okapi loads it via `IParameters.fromString()`.
+
+We deliberately avoid `tikal -psd` (Okapi's built-in pseudo-translate
+configured through tikal's CLI). That step has its own driver
+parameters and the tikal CLI's two-pass extract→XLIFF→merge
+architecture mishandles inline ``/`` markers when the
+harness rewrites XLIFF targets by hand. Driving
+`TextModificationStep` directly via the bridge launcher is one
+in-process pipeline with no XLIFF round-trip in between.
+
+The okapi engine is the **comparator**, not a tested engine. The
+harness runs the okapi engine once per fixture to obtain the live
+reference output, then byte-compares native and bridge against its
+bytes. There is no "okapi" subtest, because asserting
+okapi-against-itself would be circular.
+
+## The pseudo-translation transform
+
+The single transform every engine applies, defined in
+`parity/roundtrip/pseudo.go`. For each translatable Block, the
+target is built as a copy of the source's segments where every
+`TextRun` has its text rune-substituted via the Latin-extended
+table; non-text runs (Ph / PcOpen / PcClose / Sub / Plural / Select)
+are copied as-is so inline codes survive intact.
+
+The substitution table mirrors Okapi's `TextModificationStep`
+(`SCRIPT_EXT_LATIN`):
+
+```
+A→À a→à B→ß b→ƀ C→Ć c→ć D→Ď d→ď E→Ē e→ē F→Ƒ f→ƒ
+G→Ĝ g→ĝ H→Ĥ h→ĥ I→Ĩ i→ĩ J→ĵ j→Ĵ K→Ķ k→ķ L→Ĺ l→ĺ
+N→Ń n→ń O→Ō o→ō P→Ƥ p→ƥ Q→Ǫ q→ǫ R→Ŕ r→ŕ S→Ś s→ś
+T→Ţ t→ţ U→Ũ u→ũ W→Ŵ w→ŵ Y→Ŷ y→ŷ Z→Ź z→ź
+```
+
+(M, V, X have no entries and pass through unchanged, the same quirks as
+upstream `oldChars[0]`.)
+
+The accented variants are deliberate:
+
+ - Survives every text-bearing format (no XML/JSON/CSV/YAML escape
+ issues, since every replacement codepoint is in the Latin-Extended
+ Unicode block).
+ - Visually distinct from the source so failure dumps clearly show
+ "this is the translated text".
+ - Locale-agnostic: no MT, no model dependency, deterministic
+ bytes.
+
+## Comparison strategy
+
+For each fixture the harness runs the **okapi engine end-to-end
+inline** to obtain the live reference output (Okapi's pipeline:
+read → `TextModificationStep` → write), then runs each tested
+engine (native, bridge) and byte-compares its output against the
+okapi reference. The okapi engine is the comparator; native and
+bridge are the engines under test. There are no committed golden
+files. Upstream Okapi's behavior IS the reference, captured fresh
+every run, so there is no risk of "the golden is from Okapi v1.47,
+but we're on v1.48 now" drift.
+
+For formats where the okapi engine itself can't produce a usable
+reference (intentionally-malformed test fixtures, files referencing
+sibling resources that aren't copied to the harness's tempdir,
+read-only filters like `okf_xliff2`), per-file `skip` entries with
+`Engines: ["okapi"]` skip the whole sub-test: no reference, no
+engine assertions. We deliberately do not fall back to the native
+engine here: that would make native judge itself, the exact
+circularity this whole approach was built to remove. These rows are
+placeholders documenting that the format exists; the case starts
+asserting once the upstream pipeline can handle the input.
+
+Compound zip formats (idml, openxml, epub) compare per-entry:
+byte-equal across uncompressed entry contents, ignoring zip
+metadata (mtime, central-directory order, compression level) that
+two correct round-trippers can legitimately differ on. Mark a
+fixture with `isZip: true` to opt into per-entry comparison.
+
+This approach avoids the trap of using one engine's reader as the
+judge of another engine's writer. Every engine is held to the same
+external yardstick. A divergence is concrete and actionable: "your
+writer produced different bytes from the reference at offset 251."
+
+## Hard requirements
+
+The **parity sandbox** is the only mandatory dependency, with
+no "skip if missing" path:
+
+ - The `okapi-bridge` launcher binary inside the sandbox is checked
+ at `TestMain` (it backs both the bridge daemon engine AND the
+ okapi reference engine via the `pseudo` subcommand).
+ - The bridge daemon process itself is acquired through
+ `parity.AcquireBridgeDaemon`, which `t.Fatal`s if it can't
+ spawn.
+
+Build the sandbox from the repo root:
+
+```bash
+make parity-test
+# or to force a rebuild including a fresh okapi-bridge plugin tarball:
+PARITY_FORCE=1 make parity-test
+```
+
+Failing fast at startup means a missing dependency surfaces as one
+loud error, not a swarm of identical "engine unavailable" skips.
+
+## Test wiring
+
+All test invocations come from `coverage_test.go`, which scans
+upstream Okapi fixtures and constructs one `RunThreeWay` call per
+file. The shape of one call:
+
+```go
+roundtrip.RunThreeWay(t, roundtrip.Case{
+ Name: base, // file basename
+ FormatID: "plaintext", // neokapi registry key
+ Input: roundtrip.Input{Bytes: body, Filename: base},
+},
+ &roundtrip.NativeEngine{FormatID: "plaintext"},
+ &roundtrip.BridgeEngine{FilterClass: "okf_plaintext"},
+ &roundtrip.OkapiEngine{FilterClass: "okf_plaintext"},
+)
+```
+
+`RunThreeWay` runs each engine in its own sub-test. Engines listed
+in `c.ExpectedSkipped` are recorded as `t.Skip`; the remaining
+engines must produce output equivalent to the okapi reference. The
+harness reports every disagreement at once instead of bailing on the
+first.
+
+For formats where the bridge filter expects different parameter names
+than neokapi's canonical config, supply `BridgeEngine.FilterParams`
+in already-translated form, the same approach as the spec runner's
+`BridgeConfig` hook (see `parity/formats/csv_bridge_config.go`).
+For Okapi-side parameter overrides on the reference engine (e.g.
+disabling `mergeCaptions` on subtitle filters), supply
+`OkapiEngine.ParamConfig` as raw `.fprm` content.
+
+## File layout
+
+```
+parity/roundtrip/
+├── doc.go # Package overview
+├── engine.go # Engine interface, Input, Result, PseudoSpec
+├── pseudo.go # applyPseudoToBlock — shared Latin-extended substitution
+├── native.go # NativeEngine — in-process reader → transform → writer
+├── bridge.go # BridgeEngine — gRPC read-write with Transform hook
+├── okapi.go # OkapiEngine — bridge launcher's `pseudo` subcommand (the comparator)
+├── compare.go # Byte / per-zip-entry comparators + Divergence
+├── harness.go # RunThreeWay — runs the okapi engine as comparator
+├── main_test.go # TestMain — hard-requires the okapi-bridge launcher
+├── coverage_test.go # Per-format scans (formatScan) + discovery loop
+└── coverage_skips_test.go # Per-file bridge skip maps (idml, openxml, html, markdown, po, csv, ts, mif, icml)
+```
+
+All files build-tagged `parity` to match the surrounding harness.
+
+The `pseudo` subcommand itself lives in okapi-bridge:
+`bridge-core/src/main/java/neokapi/bridge/PseudoCommand.java`. It's
+wired into `OkapiBridgeServer.main`'s subcommand dispatcher
+(alongside `daemon`, `version`, `--list-filters`, …).
+
+## Build and run
+
+Build the parity sandbox once from the repo root:
+
+```bash
+make parity-test
+# or, after editing okapi-bridge sources:
+PARITY_FORCE=1 make parity-test
+```
+
+Then run the suite:
+
+```bash
+KAPI_PARITY_SANDBOX="$(pwd)/.parity" \
+ go test -tags parity ./parity/roundtrip/ -v
+```
+
+Without the sandbox, `TestMain` aborts the binary immediately with
+a clear error, and no test runs. The bridge daemon is acquired through
+the same `parity.AcquireBridgeDaemon` used by the spec runner; a
+single daemon process is shared across the whole `go test` run and
+torn down in `TestMain`.
+
+## Adding a new format
+
+The harness mirrors upstream Okapi: each `formatScan` points at the
+same resource directory the corresponding `RoundTripIT.java` uses,
+and every file with a matching extension becomes one sub-test.
+
+1. Add a `formatScan` entry to `coverage_test.go::coverageScans()`:
+
+ ```go
+ {
+ formatID: "myformat", // neokapi registry key
+ filterClass: "okf_myformat", // upstream Okapi filter class
+ sources: []string{"integration-tests/okapi/src/test/resources/myformat"},
+ extensions: []string{".myext"},
+ },
+ ```
+
+ The harness scans `sources` for files matching `extensions` and
+ creates one sub-test per file (`TestRoundTrip_Coverage/myformat/`).
+ Use `explicitFiles` instead of `sources` when the format's
+ fixtures are mixed in with sibling-filter fixtures and need to be
+ cherry-picked (see splicedlines / paraplaintext / tsv / fixedwidth
+ for examples).
+
+2. If the bridge filter takes parameters with different names than
+ neokapi's spec config, populate `bridgeParams` (see
+ `parity/formats/csv_bridge_config.go` for the translation
+ pattern).
+
+3. If the okapi reference engine needs non-default filter
+ parameters (e.g. disabling `mergeCaptions` on subtitle filters),
+ set `okapiParamConfig` to the raw `.fprm` content:
+
+ ```go
+ okapiParamConfig: `#v1
+ mergeCaptions.b=false
+ `,
+ ```
+
+ The harness forwards it to the `pseudo` subcommand as
+ `--fprm `, where Okapi loads it via
+ `IParameters.fromString()` against the configured filter. See
+ `okapi/filters/` in the Okapi source for which fields each
+ filter accepts.
+
+4. If the format is a zip-based compound (idml, openxml, epub, …),
+ set `isZip: true` to switch the comparator to per-entry mode.
+
+5. Run the test. The first run will surface every divergence as a
+ real failure. For each one, characterize the cause and add a
+ `skip` entry:
+
+ - **Format-default skip** (most cases): if the same root cause
+ affects most/all files, set `formatDefaultSkip` with the
+ engines + a one-line reason. Per-file overrides extend it.
+ - **Per-file skip**: keyed by basename in the `skip` map. Use
+ `Engines: ["okapi"]` to mark a file where the okapi reference
+ engine can't produce a usable output (intentionally-malformed
+ test fixture, missing linked resource, upstream parser bug);
+ the whole sub-test then skips with the reason.
+ - For long per-file maps (idml: 46 entries, openxml: 124),
+ extract them into a helper in `coverage_skips_test.go` to
+ keep `coverage_test.go` readable; see `idmlBridgeSkips()`.
+
+ Treat every skip the way `expected_fail:` is treated in
+ spec.yaml: a tracked, real divergence, not a workaround for a
+ config you forgot to set.
+
+## Relationship to the spec runner
+
+The spec runner (`spec.yaml` + `NativeRunner` / `ParityRunner`) and
+the round-trip harness are complementary, not redundant:
+
+| Aspect | Spec runner | Round-trip harness |
+| ------------------- | --------------------------------- | ---------------------------------------------------- |
+| Phase covered | Read only | Read → modify → write → re-read |
+| Engines | Native + Bridge | Native + Bridge (okapi engine is the inline comparator) |
+| What it asserts | Block count / text / IDs | Engine output matches okapi reference byte-for-byte |
+| Driver format | Per-format `spec.yaml` (declarative) | Per-format Go test row (programmatic) |
+| Failure granularity | Per-example | Per-engine |
+| Catches | Reader bugs, parity-of-reads | Writer bugs, skeleton bugs, target-merge bugs |
+
+Both should pass before a format is considered shipped. A format that
+passes spec parity but fails round-trip almost certainly has a writer
+or skeleton bug.
+
+## Coverage status
+
+The harness mirrors upstream's `RoundTripIT.java` set: **30
+formats × ~25 files each ≈ 1100+ sub-tests** in ~10 minutes. With
+the parity sandbox built the suite is fully green (0 fail, 765
+engine assertions pass, ~990 documented engine skips).
+
+After the okapi-bridge per-field code-hydrate fix (the daemon
+clones source `Code` metadata across the wire, `outerData`,
+`originalId` and `referenceFlag`, instead of rebuilding from the
+FragmentDTO), inline-code-bearing formats fully recovered: idml
+70/70 bridge, openxml 185/185, mif 41/41, icml 9/9, xml 199/199.
+The remaining 51 known divergences cluster in three buckets: PO/TS
+property round-trip + target-vs-source pseudo (26), CSV
+segmented-cell handling (8), HTML/markdown code-id reconstruction
+on the way back (17). See `coverage_skips_test.go` for the per-file
+list.
+
+Highlights of the upstream-mirroring discovery:
+
+ - **html** runs against all 69 fixtures from
+ `integration-tests/okapi/src/test/resources/html/`, the same set as
+ upstream's `RoundTripHtmlIT`.
+ - **json** 70 (all pass bridge), **idml** 70, **openxml** 185, the
+ same fixtures upstream roundtrips with their own `RoundTripIT`.
+ json got bumped from "all skipped" to "all bridge passes" as a
+ direct result of switching the comparator to the in-process
+ okapi pipeline.
+ - **xml** uses the ITS-based `okf_xml` filter (it lives in
+ `okapi/filters/its/.../XMLFilter.java`, not a dedicated `xml`
+ filter directory) and roundtrips the 22 files from
+ `integration-tests/okapi/src/test/resources/xml/`. Bridge passes
+ 14 of 22, with 8 inline-code marker divergences flagged
+ per-file.
+ - **paraplaintext** / **splicedlines** / **mosestext** are
+ sub-filters of `okf_plaintext` with no dedicated upstream
+ integration-test directory; cherry-picked via `explicitFiles`
+ against the canonical fixtures referenced by their unit tests
+ (`combined_lines.txt`, `test_paragraphs1.txt`, `Test01.txt`).
+ - **vtt** / **ttml** use `okapiParamConfig` to disable
+ `mergeCaptions` (matching the bridge's `mergeCaptions:false`
+ knob), avoiding the upstream default that mangles cue text on
+ round-trip.
+
+Formats intentionally **omitted** from coverage:
+
+ - `txml`, `rtf`, `epub`: upstream Okapi can't produce a usable
+ reference (txml NPE on merge, rtf only ships as `okf_tradosrtf`,
+ no `okf_epub` in this distribution).
+ - `jsx-kbf`, `versifiedtext`, `messageformat`: neokapi-only
+ formats with no upstream Okapi peer to compare against.
+ - `srt`: needs SRT-specific regex rules loaded as a sizable
+ `.fprm` against the bridge's `okf_regex` filter; wire that in
+ when there's a real signal worth catching.
+ - `odf`: no test fixtures committed in the framework yet.
+
+These are covered by per-format unit tests under `core/formats//`
+instead.
+
+### Reading the skip map
+
+Each `formatScan.skip` entry is **one tracked engine bug**, not a
+workaround. The skip mechanism has three layers:
+
+ - `formatDefaultSkip` applies to every file in the scan. Use it
+ when the same root cause affects most/all files (e.g. native
+ yaml writer doesn't preserve quoting style → every yaml file
+ skips native).
+ - Per-file `skip` entries extend the format default with file-
+ specific engines/reasons. Use them for outliers (a single file
+ where the okapi reference also fails, or where bridge has a
+ different bug).
+ - `Engines: ["okapi"]` on a per-file entry means the okapi
+ reference engine itself can't produce a usable reference for
+ that file, so the whole sub-test skips. Common causes:
+ intentionally-malformed test fixtures (`tmx/code_fail.tmx`),
+ files referencing sibling resources Okapi can't find when
+ invoked on a single file (`xml/Translate2.xml`,
+ `xliff/lqiTest.xlf`), upstream parser rejects (`yaml/Test03.yml`
+ `!!timestamp`), read-only filters (`okf_xliff2`).
+
+Common bug classes the skips encode:
+
+ - **Native writer byte-shape divergence** (most formats):
+ serialization choices differ from upstream Okapi in XML
+ attribute order, YAML quoting style, properties escape case,
+ trailing newlines.
+ - **Native skips target on merge** (html, openxml): writer doesn't
+ splice the translated text back into the document.
+ - **Bridge inline-code marker divergence** (xml, xliff, html,
+ markdown, po, mif, …): the bridge daemon's
+ `StreamingTranslationApplier` rebuilds `TextFragment` from Go-
+ side fragments via `OkapiCodeConverter`, and the result diverges
+ from what an in-process Okapi pipeline would have written for
+ the same source, usually around inline-code id assignment,
+ paired-code restoration, or alt-trans/extension element
+ placement.
+ - **Native YAML reader infinite loop** (4 files with self-
+ referencing anchors): a real reader bug worth fixing.
+
+When an engine bug gets fixed, the corresponding skip entry can be
+deleted, and the next run then asserts against the okapi reference
+byte-for-byte, catching any regression.
+
+## Formats not yet covered
+
+Some registered formats don't fit the generic harness today:
+
+ - **regex**: needs an explicit rule set; native + bridge configs
+ don't line up enough to share a fixture.
+ - **vignette**: requires the `` shape with
+ a paired source/target instance via `SOURCE_ID` / `LOCALE_ID`,
+ which is too verbose for inline fixtures and doesn't work without
+ config aliasing.
+ - **ttx**: UTF-16-encoded; the harness's re-extraction pass uses
+ Go's `encoding/xml` without a CharsetReader.
+ - **odf**: no `core/formats/odf/testdata/*.odt` fixture committed
+ to the framework yet.
+ - **pdf**: no native writer (extract-only).
+ - **mo**: write-only stub on the read side.
+
+Adding any of these is a follow-up, usually one fixture row plus,
+for the harder cases, a small bit of per-format wiring (charset
+reader for ttx, BridgeConfig hook for regex, fixture commit for odf).
diff --git a/parity/env.go b/parity/env.go
new file mode 100644
index 0000000..c985ebd
--- /dev/null
+++ b/parity/env.go
@@ -0,0 +1,210 @@
+//go:build parity
+
+package parity
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+)
+
+// Sandbox describes the locally built kapi + plugin install used by the
+// parity harness. It is populated once per test binary by Acquire, by
+// reading the KAPI_PARITY_SANDBOX env var that `make parity-test`
+// exports.
+type Sandbox struct {
+ // Root is the absolute path to the sandbox root (e.g. /.parity).
+ Root string
+
+ // KapiBinary is the absolute path to the locally built kapi binary.
+ KapiBinary string
+
+ // PluginsDir is the absolute path to the plugin install root that
+ // pluginhost.Discover should scan. Same as Root + "/plugins".
+ PluginsDir string
+
+ // OkapiBridgeDir is the absolute path to the okapi-bridge plugin
+ // install (PluginsDir + "/okapi-bridge").
+ OkapiBridgeDir string
+
+ // OkapiBridgeBinary is the absolute path to the daemon launcher
+ // inside OkapiBridgeDir, derived from manifest.binary.
+ OkapiBridgeBinary string
+
+ // OkapiTestDataDir is the absolute path to the unpacked Okapi
+ // test resources tarball published as the
+ // `okapi-testdata-` GitHub release on
+ // neokapi/okapi-bridge (built by that repo's
+ // `scripts/publish-okapi-testdata.sh`). The tree preserves the
+ // original Okapi layout: `okapi/filters//src/test/
+ // resources/...`. Round-trip tests resolve upstream Okapi binary
+ // fixtures from here so neokapi doesn't have to vendor them.
+ // May be empty if the fetch failed at sandbox-build time.
+ OkapiTestDataDir string
+}
+
+var (
+ sandboxOnce sync.Once
+ sandbox *Sandbox
+ sandboxErr error
+)
+
+// LoadSandbox returns the parity sandbox or an error explaining why it
+// could not be loaded. The lookup order is:
+//
+// 1. $KAPI_PARITY_SANDBOX (set by `make parity-test` and CI).
+// 2. Auto-discovery: walk up from cwd looking for a `.parity/` dir
+// containing `bin/kapi`. Lets `go test -tags parity ./parity/...`
+// work from inside any subdirectory of a repo whose sandbox is
+// already built, with no env var.
+//
+// Callers usually go through RequireSandbox, which fails the test on a
+// missing sandbox by default — silent skips on missing sandbox have
+// caused multiple incidents where local agent runs claimed parity-test
+// passed while CI failed.
+func LoadSandbox() (*Sandbox, error) {
+ sandboxOnce.Do(func() {
+ root := os.Getenv("KAPI_PARITY_SANDBOX")
+ if root == "" {
+ discovered, err := discoverSandbox()
+ if err != nil {
+ sandboxErr = fmt.Errorf("KAPI_PARITY_SANDBOX is not set and no `.parity/` directory was found in any parent of cwd. Run `make parity-test` from the repo root: %w", err)
+ return
+ }
+ root = discovered
+ }
+ root, err := filepath.Abs(root)
+ if err != nil {
+ sandboxErr = fmt.Errorf("resolve KAPI_PARITY_SANDBOX %q: %w", root, err)
+ return
+ }
+ s := &Sandbox{
+ Root: root,
+ KapiBinary: filepath.Join(root, "bin", "kapi"),
+ PluginsDir: filepath.Join(root, "plugins"),
+ OkapiBridgeDir: filepath.Join(root, "plugins", "okapi-bridge"),
+ }
+ if _, err := os.Stat(s.KapiBinary); err != nil {
+ sandboxErr = fmt.Errorf("sandbox kapi binary missing at %s: %w", s.KapiBinary, err)
+ return
+ }
+ manifestPath := filepath.Join(s.OkapiBridgeDir, "manifest.json")
+ bin, err := bridgeBinaryFromManifest(manifestPath)
+ if err != nil {
+ sandboxErr = fmt.Errorf("read bridge manifest %s: %w", manifestPath, err)
+ return
+ }
+ s.OkapiBridgeBinary = filepath.Join(s.OkapiBridgeDir, bin)
+ if _, err := os.Stat(s.OkapiBridgeBinary); err != nil {
+ sandboxErr = fmt.Errorf("sandbox bridge binary missing at %s: %w", s.OkapiBridgeBinary, err)
+ return
+ }
+ // OkapiTestDataDir is optional — only populated when
+ // parity/scripts/sandbox.sh successfully fetched and
+ // extracted the okapi-testdata- release tarball.
+ // Tests that need it should fail loudly via
+ // RequireOkapiTestData rather than silently skipping.
+ // Probe both the versioned dir and a stable "current"
+ // alias; we use the versioned path because the sandbox
+ // script extracts there.
+ entries, _ := os.ReadDir(filepath.Join(root, "okapi-testdata"))
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ candidate := filepath.Join(root, "okapi-testdata", e.Name())
+ // The tarball preserves the upstream "okapi/" prefix on
+ // every path (okapi/filters/..., okapi/integration-tests/...);
+ // confirm by checking for that subdir.
+ if _, err := os.Stat(filepath.Join(candidate, "okapi")); err == nil {
+ s.OkapiTestDataDir = candidate
+ break
+ }
+ }
+ sandbox = s
+ })
+ return sandbox, sandboxErr
+}
+
+// discoverSandbox walks up from cwd looking for a `.parity/bin/kapi`,
+// returning the absolute path to the `.parity` dir. Returns an error
+// when no candidate is found before hitting the filesystem root. Lets
+// `go test -tags parity ./parity/...` find an already-built sandbox
+// without requiring callers to set $KAPI_PARITY_SANDBOX.
+func discoverSandbox() (string, error) {
+ dir, err := os.Getwd()
+ if err != nil {
+ return "", fmt.Errorf("getwd: %w", err)
+ }
+ for {
+ candidate := filepath.Join(dir, ".parity")
+ kapi := filepath.Join(candidate, "bin", "kapi")
+ if _, err := os.Stat(kapi); err == nil {
+ return candidate, nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ return "", errors.New("no .parity/bin/kapi in any ancestor of cwd")
+ }
+ dir = parent
+ }
+}
+
+// skipEnv lets developers opt out of the loud failure when they
+// genuinely don't have or want a sandbox locally. CI never sets this.
+const skipEnv = "KAPI_PARITY_SKIP"
+
+// RequireSandbox loads the sandbox and either returns it, fails the
+// test (default), or skips (when KAPI_PARITY_SKIP=1).
+//
+// Why fail by default: the previous skip-by-default behavior caused
+// repeated incidents where local agent runs reported parity-test green
+// while CI surfaced real divergences, because the silent skip looked
+// indistinguishable from a passing run. Failing forces a build of the
+// sandbox (`make parity-test` from the repo root) before anyone — human
+// or agent — claims parity is passing locally.
+//
+// Set KAPI_PARITY_SKIP=1 to opt out (only useful for cross-cutting test
+// runs that don't care about parity, e.g. `go test ./...` from a fresh
+// checkout before the sandbox exists).
+// RequireOkapiTestData returns the absolute path to a fixture
+// inside the unpacked okapi-testdata- release tarball.
+// The path is in the original Okapi layout, e.g.
+// "okapi/filters/idml/src/test/resources/06-hello-world-12.idml".
+//
+// Use this for upstream Okapi binary fixtures the round-trip suite
+// needs but neokapi shouldn't vendor. The tarball is published by
+// okapi-bridge's scripts/publish-okapi-testdata.sh as the
+// `okapi-testdata-` GitHub release and fetched on demand
+// by parity/scripts/sandbox.sh. This lookup is a hard fail when
+// the tree is missing — a silent skip would mask the fact that
+// nothing is being asserted.
+func RequireOkapiTestData(t *testing.T, rel string) string {
+ t.Helper()
+ s := RequireSandbox(t)
+ if s.OkapiTestDataDir == "" {
+ t.Fatalf("okapi test resources tarball not present in sandbox: parity/scripts/sandbox.sh failed to fetch it (network? GitHub release missing?). Rerun with PARITY_FORCE=1 make parity-test.")
+ }
+ abs := filepath.Join(s.OkapiTestDataDir, rel)
+ if _, err := os.Stat(abs); err != nil {
+ t.Fatalf("okapi test resource %q not found at %s: %v", rel, abs, err)
+ }
+ return abs
+}
+
+func RequireSandbox(t *testing.T) *Sandbox {
+ t.Helper()
+ s, err := LoadSandbox()
+ if err != nil {
+ if os.Getenv(skipEnv) == "1" {
+ t.Skipf("parity harness unavailable (KAPI_PARITY_SKIP=1): %v", err)
+ }
+ t.Fatalf("parity harness unavailable: %v\n\n"+
+ "Build the sandbox with `make parity-test` (from the repo root) before running parity tests.\n"+
+ "Set KAPI_PARITY_SKIP=1 to skip these tests instead of failing.", err)
+ }
+ return s
+}
diff --git a/parity/env_test.go b/parity/env_test.go
new file mode 100644
index 0000000..e11eb98
--- /dev/null
+++ b/parity/env_test.go
@@ -0,0 +1,70 @@
+//go:build parity
+
+package parity
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestDiscoverSandbox verifies the upward-walk lookup finds a
+// `.parity/bin/kapi` ancestor and returns the absolute `.parity/`
+// path. Builds a tiny fake sandbox in a temp dir and chdir's into a
+// nested subdir so the walk has to traverse at least one parent.
+func TestDiscoverSandbox(t *testing.T) {
+ tmp := t.TempDir()
+ parityDir := filepath.Join(tmp, ".parity")
+ binDir := filepath.Join(parityDir, "bin")
+ if err := os.MkdirAll(binDir, 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(binDir, "kapi"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ t.Fatalf("write fake kapi: %v", err)
+ }
+ nested := filepath.Join(tmp, "a", "b", "c")
+ if err := os.MkdirAll(nested, 0o755); err != nil {
+ t.Fatalf("mkdir nested: %v", err)
+ }
+
+ orig, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ t.Cleanup(func() { _ = os.Chdir(orig) })
+ if err := os.Chdir(nested); err != nil {
+ t.Fatalf("chdir nested: %v", err)
+ }
+
+ got, err := discoverSandbox()
+ if err != nil {
+ t.Fatalf("discoverSandbox: %v", err)
+ }
+ wantSuffix := filepath.Base(parityDir)
+ if filepath.Base(got) != wantSuffix {
+ t.Fatalf("got %q, want a path ending in %q", got, wantSuffix)
+ }
+ // macOS resolves /private/var via /var so EvalSymlinks both sides.
+ gotResolved, _ := filepath.EvalSymlinks(got)
+ wantResolved, _ := filepath.EvalSymlinks(parityDir)
+ if gotResolved != wantResolved {
+ t.Fatalf("got %q, want %q", gotResolved, wantResolved)
+ }
+}
+
+// TestDiscoverSandboxNotFound asserts the walk returns an error when
+// no `.parity/bin/kapi` exists in any ancestor.
+func TestDiscoverSandboxNotFound(t *testing.T) {
+ tmp := t.TempDir()
+ orig, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ t.Cleanup(func() { _ = os.Chdir(orig) })
+ if err := os.Chdir(tmp); err != nil {
+ t.Fatalf("chdir tmp: %v", err)
+ }
+ if _, err := discoverSandbox(); err == nil {
+ t.Fatalf("discoverSandbox: expected error in empty tmp")
+ }
+}
diff --git a/parity/formats/bridge_config_test.go b/parity/formats/bridge_config_test.go
new file mode 100644
index 0000000..7cb92e5
--- /dev/null
+++ b/parity/formats/bridge_config_test.go
@@ -0,0 +1,244 @@
+//go:build parity
+
+package formats
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestCsvBridgeConfig pins the neokapi→Okapi csv config translation.
+// The translator emits one `fprmContent` Okapi ParametersString blob;
+// these assertions verify each spec key lands as the right field line.
+func TestCsvBridgeConfig(t *testing.T) {
+ t.Run("defaults_with_no_overrides", func(t *testing.T) {
+ out, err := csvBridgeConfig(map[string]any{})
+ require.NoError(t, err)
+ fprm := requireFprm(t, out)
+ // neokapi-canonical CSV defaults.
+ assert.Contains(t, fprm, "#v1\n")
+ assert.Contains(t, fprm, "fieldDelimiter=,\n")
+ assert.Contains(t, fprm, "textQualifier=\"\n")
+ assert.Contains(t, fprm, "columnNamesLineNum.i=1\n")
+ assert.Contains(t, fprm, "valuesStartLineNum.i=2\n")
+ assert.Contains(t, fprm, "sendHeaderMode.i=0\n")
+ assert.Contains(t, fprm, "sendColumnsMode.i=2\n") // SEND_COLUMNS_ALL
+ assert.Contains(t, fprm, "trimMode.i=0\n")
+ assert.Contains(t, fprm, "targetColumns=\n")
+ assert.Contains(t, fprm, "parametersClass=net.sf.okapi.filters.table.csv.Parameters\n")
+ })
+
+ t.Run("separator_to_fieldDelimiter", func(t *testing.T) {
+ out, err := csvBridgeConfig(map[string]any{"separator": "\t"})
+ require.NoError(t, err)
+ assert.Contains(t, requireFprm(t, out), "fieldDelimiter=\t\n")
+ })
+
+ t.Run("textQualifier_verbatim", func(t *testing.T) {
+ out, err := csvBridgeConfig(map[string]any{"textQualifier": "'"})
+ require.NoError(t, err)
+ assert.Contains(t, requireFprm(t, out), "textQualifier='\n")
+ })
+
+ t.Run("hasHeader_true", func(t *testing.T) {
+ out, err := csvBridgeConfig(map[string]any{"hasHeader": true})
+ require.NoError(t, err)
+ fprm := requireFprm(t, out)
+ assert.Contains(t, fprm, "columnNamesLineNum.i=1\n")
+ assert.Contains(t, fprm, "valuesStartLineNum.i=2\n")
+ assert.Contains(t, fprm, "sendHeaderMode.i=0\n")
+ })
+
+ t.Run("hasHeader_false", func(t *testing.T) {
+ out, err := csvBridgeConfig(map[string]any{"hasHeader": false})
+ require.NoError(t, err)
+ fprm := requireFprm(t, out)
+ assert.Contains(t, fprm, "columnNamesLineNum.i=0\n")
+ assert.Contains(t, fprm, "valuesStartLineNum.i=1\n")
+ })
+
+ t.Run("columnNamesRow_and_valuesStartRow", func(t *testing.T) {
+ out, err := csvBridgeConfig(map[string]any{"columnNamesRow": 2, "valuesStartRow": 4})
+ require.NoError(t, err)
+ fprm := requireFprm(t, out)
+ assert.Contains(t, fprm, "columnNamesLineNum.i=2\n")
+ assert.Contains(t, fprm, "valuesStartLineNum.i=4\n")
+ })
+
+ t.Run("translatableColumns_zero_based_to_one_based_listed", func(t *testing.T) {
+ out, err := csvBridgeConfig(map[string]any{"translatableColumns": []any{0, 2}})
+ require.NoError(t, err)
+ fprm := requireFprm(t, out)
+ assert.Contains(t, fprm, "sourceColumns=1,3\n")
+ assert.Contains(t, fprm, "sendColumnsMode.i=1\n") // SEND_COLUMNS_LISTED
+ })
+
+ t.Run("keyColumns_to_sourceIdColumns", func(t *testing.T) {
+ out, err := csvBridgeConfig(map[string]any{"keyColumns": []any{0, 1}})
+ require.NoError(t, err)
+ assert.Contains(t, requireFprm(t, out), "sourceIdColumns=1,2\n")
+ })
+
+ t.Run("commentColumns_one_based", func(t *testing.T) {
+ out, err := csvBridgeConfig(map[string]any{"commentColumns": []any{1}})
+ require.NoError(t, err)
+ assert.Contains(t, requireFprm(t, out), "commentColumns=2\n")
+ })
+
+ t.Run("trimValues_true", func(t *testing.T) {
+ out, err := csvBridgeConfig(map[string]any{"trimValues": true})
+ require.NoError(t, err)
+ fprm := requireFprm(t, out)
+ assert.Contains(t, fprm, "trimMode.i=2\n")
+ assert.Contains(t, fprm, "trimLeading.b=true\n")
+ assert.Contains(t, fprm, "trimTrailing.b=true\n")
+ })
+
+ t.Run("unknown_key_errors", func(t *testing.T) {
+ _, err := csvBridgeConfig(map[string]any{"bogus": true})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown spec key")
+ })
+
+ t.Run("wrong_type_errors", func(t *testing.T) {
+ _, err := csvBridgeConfig(map[string]any{"hasHeader": "yes"})
+ require.Error(t, err)
+ })
+}
+
+// TestXmlstreamBridgeConfig pins the neokapi→Okapi xmlstream config
+// translation: shorthand camelCase keys expand into the long-form
+// Okapi YAML keys (elements / attributes / exclude_by_default /
+// preserve_whitespace) that the bridge's YAML deep-merge applies.
+func TestXmlstreamBridgeConfig(t *testing.T) {
+ t.Run("translatableElements_to_include_with_exclude_by_default", func(t *testing.T) {
+ out, err := xmlstreamBridgeConfig(map[string]any{
+ "translatableElements": []any{"title", "description"},
+ })
+ require.NoError(t, err)
+ assert.Equal(t, true, out["exclude_by_default"],
+ "translatableElements is an opt-in whitelist → exclude_by_default")
+ elements, ok := out["elements"].(map[string]any)
+ require.True(t, ok, "elements map present")
+ assert.Equal(t, ruleTypes("INCLUDE"), elements["title"])
+ assert.Equal(t, ruleTypes("INCLUDE"), elements["description"])
+ })
+
+ t.Run("excludeByDefault_to_underscore", func(t *testing.T) {
+ out, err := xmlstreamBridgeConfig(map[string]any{"excludeByDefault": true})
+ require.NoError(t, err)
+ assert.Equal(t, true, out["exclude_by_default"])
+ })
+
+ t.Run("excludeByDefault_false_is_emitted", func(t *testing.T) {
+ out, err := xmlstreamBridgeConfig(map[string]any{"excludeByDefault": false})
+ require.NoError(t, err)
+ v, ok := out["exclude_by_default"]
+ require.True(t, ok, "explicit false must be emitted")
+ assert.Equal(t, false, v)
+ })
+
+ t.Run("explicit_excludeByDefault_wins_over_translatableElements_implication", func(t *testing.T) {
+ out, err := xmlstreamBridgeConfig(map[string]any{
+ "translatableElements": []any{"title"},
+ "excludeByDefault": false,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, false, out["exclude_by_default"],
+ "an explicit excludeByDefault must override the translatableElements implication")
+ })
+
+ t.Run("preserveWhitespace_to_underscore", func(t *testing.T) {
+ out, err := xmlstreamBridgeConfig(map[string]any{"preserveWhitespace": true})
+ require.NoError(t, err)
+ assert.Equal(t, true, out["preserve_whitespace"])
+ })
+
+ t.Run("parser_preserveWhitespace_subkey", func(t *testing.T) {
+ out, err := xmlstreamBridgeConfig(map[string]any{
+ "parser": map[string]any{"preserveWhitespace": true, "assumeWellformed": true},
+ })
+ require.NoError(t, err)
+ assert.Equal(t, true, out["preserve_whitespace"])
+ // assumeWellformed is intentionally dropped.
+ _, hasAssume := out["assumeWellformed"]
+ assert.False(t, hasAssume)
+ })
+
+ t.Run("element_shorthands_map_to_rule_types", func(t *testing.T) {
+ out, err := xmlstreamBridgeConfig(map[string]any{
+ "inlineElements": []any{"b"},
+ "excludedElements": []any{"pre"},
+ "preserveWhitespaceElements": []any{"code"},
+ "groupElements": []any{"ul"},
+ })
+ require.NoError(t, err)
+ elements, ok := out["elements"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, ruleTypes("INLINE"), elements["b"])
+ assert.Equal(t, ruleTypes("EXCLUDE"), elements["pre"])
+ assert.Equal(t, ruleTypes("PRESERVE_WHITESPACE"), elements["code"])
+ assert.Equal(t, ruleTypes("GROUP"), elements["ul"])
+ // excludeByDefault is NOT implied by these shorthands.
+ _, hasExclude := out["exclude_by_default"]
+ assert.False(t, hasExclude)
+ })
+
+ t.Run("translatableAttributes_to_attribute_trans", func(t *testing.T) {
+ out, err := xmlstreamBridgeConfig(map[string]any{
+ "translatableAttributes": []any{"title", "alt"},
+ })
+ require.NoError(t, err)
+ attrs, ok := out["attributes"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, ruleTypes("ATTRIBUTE_TRANS"), attrs["title"])
+ assert.Equal(t, ruleTypes("ATTRIBUTE_TRANS"), attrs["alt"])
+ })
+
+ t.Run("explicit_elements_and_attributes_pass_through", func(t *testing.T) {
+ elementRule := map[string]any{"ruleTypes": []any{"EXCLUDE"}}
+ out, err := xmlstreamBridgeConfig(map[string]any{
+ "elements": map[string]any{"pre": elementRule},
+ })
+ require.NoError(t, err)
+ elements, ok := out["elements"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, elementRule, elements["pre"])
+ })
+
+ t.Run("unknown_key_errors", func(t *testing.T) {
+ _, err := xmlstreamBridgeConfig(map[string]any{"bogus": true})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown spec key")
+ })
+
+ t.Run("wrong_type_errors", func(t *testing.T) {
+ _, err := xmlstreamBridgeConfig(map[string]any{"preserveWhitespace": "yes"})
+ require.Error(t, err)
+ })
+}
+
+// TestRegexBridgeConfig pins the neokapi→Okapi okf_regex config
+// translation: the rule list is serialised to the reserved
+// `regexRulesJson` parameter, the escape discriminator folds into the
+// two bridge booleans, and regexOptions is forced to 0 (RE2-equivalent,
+// no global DOTALL/MULTILINE).
+func requireFprm(t *testing.T, out map[string]any) string {
+ t.Helper()
+ require.Len(t, out, 1, "csvBridgeConfig emits exactly one key")
+ v, ok := out["fprmContent"]
+ require.True(t, ok, "fprmContent key present")
+ s, ok := v.(string)
+ require.True(t, ok, "fprmContent is a string")
+ require.True(t, strings.HasPrefix(s, "#v1\n"), "fprm starts with #v1 header")
+ return s
+}
+
+// ruleTypes builds the {ruleTypes:[t]} rule object the xmlstream
+// translator emits for a shorthand element/attribute rule.
+func ruleTypes(t string) map[string]any {
+ return map[string]any{"ruleTypes": []any{t}}
+}
diff --git a/parity/formats/csv_bridge_config.go b/parity/formats/csv_bridge_config.go
new file mode 100644
index 0000000..d2490e9
--- /dev/null
+++ b/parity/formats/csv_bridge_config.go
@@ -0,0 +1,269 @@
+//go:build parity
+
+package formats
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// csvBridgeConfig translates a neokapi-keyed csv spec config map into
+// the parameter shape the okapi-bridge daemon's CommaSeparatedValuesFilter
+// (okf_commaseparatedvalues) expects.
+//
+// The upstream okf_table family (csv/tsv/fwc) reads its behaviour from
+// the public fields of its Okapi `Parameters` object
+// (`net.sf.okapi.filters.table.csv.Parameters` extending
+// `…table.base.Parameters`). Those fields are populated ONLY by
+// `Parameters.load(buffer)`, which runs inside `fromString(...)`. The
+// bridge's per-key `params.setString("sourceColumns", "1")` path writes
+// the StringParameters buffer but never re-runs `load()`, so the public
+// field stays stale and the filter ignores the override (see #530 and
+// the identical quirk documented in fixedwidth_bridge_config.go).
+//
+// To sidestep this, the translator renders the ENTIRE configuration as
+// a single Okapi `#v1` ParametersString blob and ships it under the
+// reserved `fprmContent` key. The bridge calls
+// `filterParameters.fromString(fprmContent)` once, which clears the
+// buffer and re-runs `load()` — syncing every field in one shot. All
+// per-feature convergence forces collapse into that single payload.
+//
+// The blob always carries the neokapi-canonical CSV defaults (comma
+// delimiter, double-quote qualifier, header on row 1 + data on row 2 +
+// header not extracted, every column translatable, no trim); the spec
+// config overrides individual fields on top. This matches the parity
+// contract "same semantic config → same results": examples that depend
+// on neokapi defaults MUST set explicit config (they do).
+//
+// Field/value mappings (verified against
+// net.sf.okapi.filters.table.{base,csv}.Parameters):
+//
+// separator (string) → fieldDelimiter (string, verbatim)
+// textQualifier (string) → textQualifier (string, verbatim)
+// hasHeader=true (bool) → columnNamesLineNum=1, valuesStartLineNum=2,
+// sendHeaderMode=0 (SEND_HEADER_NONE)
+// hasHeader=false → columnNamesLineNum=0, valuesStartLineNum=1,
+// sendHeaderMode=0
+// columnNamesRow (int) → columnNamesLineNum (1-based, verbatim)
+// valuesStartRow (int) → valuesStartLineNum (1-based, verbatim)
+// translatableColumns ([]int0) → sourceColumns ("1,3" 1-based CSV) +
+// sendColumnsMode=1 (SEND_COLUMNS_LISTED)
+// keyColumns ([]int0) → sourceIdColumns ("1,2" 1-based CSV)
+// commentColumns ([]int0) → commentColumns ("2" 1-based CSV)
+// trimValues=true (bool) → trimMode=2 (TRIM_ALL) + trimLeading/
+// trimTrailing=true
+// trimValues=false → trimMode=0 (TRIM_NONE)
+//
+// targetColumns is forced empty: the upstream default ("2") would treat
+// the second column as a localised target (multilingual mode) rather
+// than translatable source, which neokapi has no notion of.
+//
+// The translator never mutates its input; it returns a fresh map.
+func csvBridgeConfig(cfg map[string]any) (map[string]any, error) {
+ // Defaults mirror neokapi's CSV semantics (see core/formats/csv/
+ // config.go Reset()): comma delimiter, double-quote qualifier,
+ // header present (row 1, data row 2, header not extracted), every
+ // column translatable, no trim.
+ p := csvParams{
+ fieldDelimiter: ",",
+ textQualifier: "\"",
+ removeQualifiers: true,
+ escapingMode: 1, // ESCAPING_MODE_DUPLICATION
+ columnNamesLineNum: 1, // header on row 1
+ valuesStartLineNum: 2, // data on row 2
+ sendHeaderMode: 0, // SEND_HEADER_NONE — header is skeleton, not a Block
+ sendColumnsMode: 2, // SEND_COLUMNS_ALL — every cell is source
+ trimMode: 0, // TRIM_NONE
+ trimLeading: false,
+ trimTrailing: false,
+ preserveWS: true,
+ useCodeFinder: false,
+ }
+
+ for key, val := range cfg {
+ switch key {
+ case "separator":
+ s, ok := val.(string)
+ if !ok {
+ return nil, fmt.Errorf("csvBridgeConfig: separator: expected string, got %T", val)
+ }
+ p.fieldDelimiter = s
+
+ case "textQualifier":
+ s, ok := val.(string)
+ if !ok {
+ return nil, fmt.Errorf("csvBridgeConfig: textQualifier: expected string, got %T", val)
+ }
+ p.textQualifier = s
+
+ case "hasHeader":
+ b, ok := val.(bool)
+ if !ok {
+ return nil, fmt.Errorf("csvBridgeConfig: hasHeader: expected bool, got %T", val)
+ }
+ if b {
+ p.columnNamesLineNum = 1
+ p.valuesStartLineNum = 2
+ } else {
+ p.columnNamesLineNum = 0
+ p.valuesStartLineNum = 1
+ }
+ p.sendHeaderMode = 0
+
+ case "columnNamesRow":
+ n, err := asInt(val, "columnNamesRow")
+ if err != nil {
+ return nil, err
+ }
+ p.columnNamesLineNum = n
+
+ case "valuesStartRow":
+ n, err := asInt(val, "valuesStartRow")
+ if err != nil {
+ return nil, err
+ }
+ p.valuesStartLineNum = n
+
+ case "translatableColumns":
+ cols, err := intSliceToOneBasedCSV(val)
+ if err != nil {
+ return nil, fmt.Errorf("csvBridgeConfig: translatableColumns: %w", err)
+ }
+ p.sourceColumns = cols
+ p.sendColumnsMode = 1 // SEND_COLUMNS_LISTED — only listed columns
+
+ case "keyColumns":
+ cols, err := intSliceToOneBasedCSV(val)
+ if err != nil {
+ return nil, fmt.Errorf("csvBridgeConfig: keyColumns: %w", err)
+ }
+ p.sourceIDColumns = cols
+
+ case "commentColumns":
+ cols, err := intSliceToOneBasedCSV(val)
+ if err != nil {
+ return nil, fmt.Errorf("csvBridgeConfig: commentColumns: %w", err)
+ }
+ p.commentColumns = cols
+
+ case "trimValues":
+ b, ok := val.(bool)
+ if !ok {
+ return nil, fmt.Errorf("csvBridgeConfig: trimValues: expected bool, got %T", val)
+ }
+ if b {
+ p.trimMode = 2 // TRIM_ALL — trim qualified + non-qualified
+ p.trimLeading = true
+ p.trimTrailing = true
+ } else {
+ p.trimMode = 0
+ p.trimLeading = false
+ p.trimTrailing = false
+ }
+
+ case "useCodeFinder":
+ b, ok := val.(bool)
+ if !ok {
+ return nil, fmt.Errorf("csvBridgeConfig: useCodeFinder: expected bool, got %T", val)
+ }
+ p.useCodeFinder = b
+
+ default:
+ return nil, fmt.Errorf("csvBridgeConfig: unknown spec key %q", key)
+ }
+ }
+
+ return map[string]any{"fprmContent": p.toFprm()}, nil
+}
+
+// csvParams is the in-translator view of the bridge filter's CSV
+// Parameters object. Mirrors the public-field names of
+// net.sf.okapi.filters.table.csv.Parameters (and its base classes).
+type csvParams struct {
+ fieldDelimiter string
+ textQualifier string
+ removeQualifiers bool
+ escapingMode int
+ columnNamesLineNum int
+ valuesStartLineNum int
+ sendHeaderMode int
+ sendColumnsMode int
+ trimMode int
+ trimLeading bool
+ trimTrailing bool
+ preserveWS bool
+ useCodeFinder bool
+ sourceColumns string
+ sourceIDColumns string
+ commentColumns string
+}
+
+// toFprm renders the params as an Okapi ParametersString `#v1` blob.
+// targetColumns is forced empty so the upstream filter does not enter
+// multilingual (source/target) mode.
+func (p csvParams) toFprm() string {
+ var b strings.Builder
+ b.WriteString("#v1\n")
+ // plaintext base + table base booleans / ints.
+ fmt.Fprintf(&b, "preserveWS.b=%t\n", p.preserveWS)
+ fmt.Fprintf(&b, "useCodeFinder.b=%t\n", p.useCodeFinder)
+ fmt.Fprintf(&b, "trimLeading.b=%t\n", p.trimLeading)
+ fmt.Fprintf(&b, "trimTrailing.b=%t\n", p.trimTrailing)
+ fmt.Fprintf(&b, "columnNamesLineNum.i=%d\n", p.columnNamesLineNum)
+ fmt.Fprintf(&b, "valuesStartLineNum.i=%d\n", p.valuesStartLineNum)
+ fmt.Fprintf(&b, "sendHeaderMode.i=%d\n", p.sendHeaderMode)
+ fmt.Fprintf(&b, "sendColumnsMode.i=%d\n", p.sendColumnsMode)
+ fmt.Fprintf(&b, "trimMode.i=%d\n", p.trimMode)
+ fmt.Fprintf(&b, "sourceColumns=%s\n", p.sourceColumns)
+ fmt.Fprintf(&b, "sourceIdColumns=%s\n", p.sourceIDColumns)
+ fmt.Fprintf(&b, "commentColumns=%s\n", p.commentColumns)
+ b.WriteString("targetColumns=\n")
+ // csv subclass fields.
+ fmt.Fprintf(&b, "fieldDelimiter=%s\n", p.fieldDelimiter)
+ fmt.Fprintf(&b, "textQualifier=%s\n", p.textQualifier)
+ fmt.Fprintf(&b, "removeQualifiers.b=%t\n", p.removeQualifiers)
+ fmt.Fprintf(&b, "escapingMode.i=%d\n", p.escapingMode)
+ b.WriteString("parametersClass=net.sf.okapi.filters.table.csv.Parameters\n")
+ return b.String()
+}
+
+// intSliceToOneBasedCSV converts a 0-based int column list (from the
+// spec) into a 1-based comma-separated string (Okapi's
+// sourceColumns / sourceIdColumns / commentColumns shape).
+func intSliceToOneBasedCSV(v any) (string, error) {
+ var ints []int
+ switch x := v.(type) {
+ case []int:
+ ints = x
+ case []any:
+ ints = make([]int, len(x))
+ for i, item := range x {
+ n, err := asInt(item, "column index")
+ if err != nil {
+ return "", err
+ }
+ ints[i] = n
+ }
+ default:
+ return "", fmt.Errorf("expected int slice, got %T", v)
+ }
+ parts := make([]string, len(ints))
+ for i, n := range ints {
+ parts[i] = strconv.Itoa(n + 1)
+ }
+ return strings.Join(parts, ","), nil
+}
+
+func asInt(v any, label string) (int, error) {
+ switch x := v.(type) {
+ case int:
+ return x, nil
+ case int64:
+ return int(x), nil
+ case float64:
+ return int(x), nil
+ default:
+ return 0, fmt.Errorf("%s: expected int, got %T", label, v)
+ }
+}
diff --git a/parity/formats/csv_spec_test.go b/parity/formats/csv_spec_test.go
new file mode 100644
index 0000000..44d0471
--- /dev/null
+++ b/parity/formats/csv_spec_test.go
@@ -0,0 +1,30 @@
+//go:build parity
+
+package formats
+
+import (
+ "testing"
+
+ "github.com/neokapi/neokapi/core/format"
+ "github.com/neokapi/neokapi/core/formats/csv"
+ parityspec "github.com/neokapi/okapi-bridge/parity/spec"
+)
+
+// TestParityCsvSpec drives the CSV spec.yaml through the bridge AND
+// the native reader, validating both against the spec contract and
+// against each other. The same spec file drives the always-on native
+// test (core/formats/csv/spec_test.go), so a single source of truth
+// describes CSV behavior across implementations.
+func TestParityCsvSpec(t *testing.T) {
+ specPath := nativeSpecPath(t, "csv")
+ s, err := parityspec.LoadSpec(specPath)
+ if err != nil {
+ t.Fatalf("load spec %s: %v", specPath, err)
+ }
+ r := &parityspec.ParityRunner{
+ Spec: s,
+ NewReader: func(_ string) format.DataFormatReader { return csv.NewReader() },
+ BridgeConfig: csvBridgeConfig,
+ }
+ r.Run(t)
+}
diff --git a/parity/formats/epub_spec_test.go b/parity/formats/epub_spec_test.go
new file mode 100644
index 0000000..8a8a0b8
--- /dev/null
+++ b/parity/formats/epub_spec_test.go
@@ -0,0 +1,32 @@
+//go:build parity
+
+package formats
+
+import (
+ "testing"
+
+ "github.com/neokapi/neokapi/core/format"
+ "github.com/neokapi/neokapi/core/formats/epub"
+ parityspec "github.com/neokapi/okapi-bridge/parity/spec"
+)
+
+// TestParityEpubSpec drives the EPUB spec.yaml through the parity
+// runner. Every example carries `expected_fail:` because the
+// okapi-bridge does NOT ship the okf_epub filter — the bridge v2
+// manifest has no `okf_epub` entry, so dispatch errors with
+// "FilterClass: okf_epub not found". Native still satisfies the
+// assertions; the runner records each example as expected_fail rather
+// than green parity. Drop the `expected_fail:` blocks once the bridge
+// adds EPUB coverage.
+func TestParityEpubSpec(t *testing.T) {
+ specPath := nativeSpecPath(t, "epub")
+ s, err := parityspec.LoadSpec(specPath)
+ if err != nil {
+ t.Fatalf("load spec %s: %v", specPath, err)
+ }
+ r := &parityspec.ParityRunner{
+ Spec: s,
+ NewReader: func(_ string) format.DataFormatReader { return epub.NewReader() },
+ }
+ r.Run(t)
+}
diff --git a/parity/formats/fixtures_html_generated.go b/parity/formats/fixtures_html_generated.go
new file mode 100644
index 0000000..2806c2f
--- /dev/null
+++ b/parity/formats/fixtures_html_generated.go
@@ -0,0 +1,184 @@
+//go:build parity
+
+// Code generated by cmd/okapi-test-scan. DO NOT EDIT.
+
+package formats
+
+// GeneratedHtmlConfigurationSupportTestInputs holds 20 auto-extracted @Test fixtures from HtmlConfigurationSupportTest.
+var GeneratedHtmlConfigurationSupportTestInputs = []FormatInput{
+ {Name: "gen-test_collapse_whitespace", Content: ttext(`
`), OkapiTest: "HtmlSnippetsTest#testNestedInlineTranslateAttribute6", Informational: true},
+ {Name: "gen-testFreeMarker", Content: ttext(` this is a bolded text between html strong tags <#if contactInfo??> or ${contactInfo}#if>.`), OkapiTest: "HtmlSnippetsTest#testFreeMarker", Informational: true},
+ {Name: "gen-testPlaceholderOnlySegments", Content: ttext(`
+`), OkapiTest: "HtmlSnippetsTest#testDivBlockWithPTranslateAttribute", Informational: true},
+ {Name: "gen-testInlineTranslateNo", Content: ttext(`Shopping cart contains two items These two items are without any discount and summer sale is also not applicable full amount will be charged.`), OkapiTest: "HtmlSnippetsTest#testInlineTranslateNo", Informational: true},
+ {Name: "gen-testTagLowerCaseFix", Content: ttext(`our accomplishments`), OkapiTest: "HtmlSnippetsTest#testTagLowerCaseFix", Informational: true},
+ {Name: "gen-testInlineCdata", Content: ttext(`Here is some &]]> for you.`), OkapiTest: "HtmlSnippetsTest#testInlineCdata", Informational: true},
+ {Name: "gen-testEmptyGroupAtEnd", Content: ttext(`Empty group at the end `), OkapiTest: "HtmlSnippetsTest#testEmptyGroupAtEnd", Informational: true},
+ {Name: "gen-testASPXComment", Content: ttext(`<%-- comment --%>Text`), OkapiTest: "HtmlSnippetsTest#testASPXComment", Informational: true},
+ {Name: "gen-testASPXEmbeddedTag", Content: ttext(`Text`), OkapiTest: "HtmlSnippetsTest#testASPXEmbeddedTag", Informational: true},
+ {Name: "gen-testUlWithScriptTag", Content: ttext(`
`), OkapiTest: "MarkdownFilterTest#testHtmlAttributeExpression", Informational: true},
+ {Name: "gen-testEmphasis", Content: ttext(`_Italic and _Bold__`), OkapiTest: "MarkdownFilterTest#testEmphasis", Informational: true},
+ {Name: "gen-testCodeAndEmphasis", Content: ttext("`_Not Italic but Code_`"), OkapiTest: "MarkdownFilterTest#testCodeAndEmphasis", Informational: true},
+ {Name: "gen-testEmphasisAcrossLines", Content: ttext(`Here comes _emphasized part of text that
+includes a soft line break_.`), OkapiTest: "MarkdownFilterTest#testEmphasisAcrossLines", Informational: true},
+ {Name: "gen-testFencedCodeBlock", Content: ttext("```{java}\nexport const pi = 3.14\nLine 2 in a fenced code block\nLine 3 in a fenced code block\n```\n\n"), OkapiTest: "MarkdownFilterTest#testFencedCodeBlock", Informational: true},
+ {Name: "gen-testFencedCodeBlockWithHtmlEntity", Content: ttext("```{java}\nLine 1 in a fenced code block\nLine 2 in a fenced code block\nLine 3 in a fenced code block\n— &\n```\n\n"), OkapiTest: "MarkdownFilterTest#testFencedCodeBlockWithHtmlEntity", Informational: true},
+ {Name: "gen-testHeadingPrefix", Content: ttext(`# Heading
+`), OkapiTest: "MarkdownFilterTest#testHeadingPrefix", Informational: true},
+ {Name: "gen-testHeadingPrefixWithoutSpace", Content: ttext(`#Heading 1
+
+##Heading 2
+
+###Heading 3`), OkapiTest: "MarkdownFilterTest#testHeadingPrefixWithoutSpace", Informational: true},
+ {Name: "gen-testHeadingUnderline", Content: ttext(`Heading
+=======
+
+`), OkapiTest: "MarkdownFilterTest#testHeadingUnderline", Informational: true},
+ {Name: "gen-testHtmlTable", Content: ttext(`
Test
+
+`), OkapiTest: "MarkdownFilterTest#testHtmlTable", Informational: true},
+ {Name: "gen-testMixedHtmlInlineAndMarkdown", Content: ttext(`- Lorem **TEST** sit amet consectetuer adipiscing elit **eiusmod tempor**:`), OkapiTest: "MarkdownFilterTest#testMixedHtmlInlineAndMarkdown", Informational: true},
+ {Name: "gen-testEscapedHtmlBlockWithMarkdown", Content: ttext("The only restrictions are that block-level HTML elements -- e.g. `
`,\n`
`, `
`, `
`, etc. -- must be separated from surrounding\ncontent by blank lines, and the start and end tags of the block should\nnot be indented with tabs or spaces. Markdown is smart enough not\nto add extra (unwanted) `
voluptate velit esse cillum dolore, eu, fugiat, nulla pariatur, Excepteur, sint occaecat
+
+
cupidatat: non proident sunt in culpa qui officia deserunt mollit anim id est.
+
laborum: Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt, ut labore et dolore magna aliqua Ut enim ad minim veniam quis nostrud exercitation ullamco laboris nisi.
+
ut aliquip ex ea commodo consequat. Duis aute.
]]>
+ <p>Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut. labore et dolore magna aliqua Ut enim, ad minim veniam quis nostrud exercitation ullamco laboris nisi ut aliquip. ex ea commodo consequat Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla.</p>
+<ul>
+<li>pariatur% Excepteur</li>
+<li>sint occaecat cupidatat non proident sunt in culpa% qui officia deserunt% mollit</li>
+<li>anim id est laborum Lorem ipsum dolor sit% amet consectetur adipiscing% elit</li>
+<li>sed do eiusmod tempor incididunt ut labore% et dolore magna% aliqua</li>
+<li>Ut enim ad: minimveniamquis nostrud/exercitationullamco (laborisnisiut aliquip/exea)</li>
+<li>commodo consequat</li>
+<li>Duis aute irure dolor</li>
+<li>in reprehenderit</li>
+<li>in voluptate velit esse cillum</li>
+<li>dolore eu fugiat nulla pariatur Excepteur sint</li>
+<li>occaecat cupidatat</li>
+<li>non proident sunt in culpa qui officia deserunt, mollit, anim, id est, laborum Lorem ipsum</li>
+</ul>
+<p><strong>dolor<span style="font-weight:400;">: sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore, et dolore magna aliqua Ut enim ad minim veniam.</span></strong></p>
+<p><strong>quis</strong>: nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat Duis aute irure, dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur Excepteur sint occaecat.</p>
+<p>cupidatat non proident sunt in culpa. qui officia.</p>
+
+ Lorem ipsum dolor sit amet consectetur adipiscing elit
+
+
+
+`), OkapiTest: "XLIFFFilterTest#testNonEmptyTargetCDATABug", Informational: true},
+ {Name: "gen-testSkeletonIwsXliffAllFinished", Content: ttext(`
+
+Hello
+
+
+
+
+Test 4
+`), OkapiTest: "XLIFFFilterTest#testSkeletonIwsXliffAllFinished", Informational: true},
+ {Name: "gen-testSkeletonIwsXliffAllPending", Content: ttext(`
+
+Hello
+
+
+
+
+Test 4
+`), OkapiTest: "XLIFFFilterTest#testSkeletonIwsXliffAllPending", Informational: true},
+ {Name: "gen-testSkeletonIwsXliffKeepTmOrigin", Content: ttext(`
+
+Hello
+
+
+
+
+Test 4
+`), OkapiTest: "XLIFFFilterTest#testSkeletonIwsXliffKeepTmOrigin", Informational: true},
+ {Name: "gen-testSkeletonIwsXliffTranslatableRemoveTmOrigin", Content: ttext(`
+
+Hello
+
+
+
+
+Test 4
+`), OkapiTest: "XLIFFFilterTest#testSkeletonIwsXliffTranslatableRemoveTmOrigin", Informational: true},
+ {Name: "gen-testSkeletonIwsXliffBlockedFinishedNotRemoveTmOrigin", Content: ttext(`
+
+Hello
+
+
+
+
+Test 4
+`), OkapiTest: "XLIFFFilterTest#testSkeletonIwsXliffBlockedFinishedNotRemoveTmOrigin", Informational: true},
+ {Name: "gen-testSkeletonIwsXliffTuApproved", Content: ttext(`
+
+Hello
+
+
+
+
+Test 4
+`), OkapiTest: "XLIFFFilterTest#testSkeletonIwsXliffTuApproved", Informational: true},
+ {Name: "gen-testSkeletonIwsXliffAddsTranslationType", Content: ttext(`
+
+Hello
+
+
+
+
+Test 4
+`), OkapiTest: "XLIFFFilterTest#testSkeletonIwsXliffAddsTranslationType", Informational: true},
+ {Name: "gen-testSkeletonIwsXliffNoTranslationStatusInput", Content: ttext(`
+
+Hello
+
+
+
+
+Test 4
+`), OkapiTest: "XLIFFFilterTest#testSkeletonIwsXliffNoTranslationStatusInput", Informational: true},
+ {Name: "gen-testSkeletonIwsXliffNoTranslationTypeInput", Content: ttext(`
+
+Hello
+
+
+
+
+Test 4
+`), OkapiTest: "XLIFFFilterTest#testSkeletonIwsXliffNoTranslationTypeInput", Informational: true},
+ {Name: "gen-testContextGroupAfterTarget", Content: ttext(`
+This is the source.1`), OkapiTest: "XLIFFFilterTest#testContextGroupAfterTarget", Informational: true},
+ {Name: "gen-testOkapiMarkers", Content: ttext(`
+abcAB`), OkapiTest: "XLIFFFilterTest#testOkapiMarkers", Informational: true},
+}
+
+// GeneratedXLIFFFilterXtmPropTestInputs holds 1 auto-extracted @Test fixtures from XLIFFFilterXtmPropTest.
+var GeneratedXLIFFFilterXtmPropTestInputs = []FormatInput{
+ {Name: "gen-testXtmDetection", Content: ttext(`
+BASE64dataContext match segment.Context match segment (translated).Context match segment.Context match segment (translated).`), OkapiTest: "XLIFFFilterXtmPropTest#testXtmDetection", Informational: true},
+}
diff --git a/parity/formats/fixtures_yaml_generated.go b/parity/formats/fixtures_yaml_generated.go
new file mode 100644
index 0000000..6b35a26
--- /dev/null
+++ b/parity/formats/fixtures_yaml_generated.go
@@ -0,0 +1,46 @@
+//go:build parity
+
+// Code generated by cmd/okapi-test-scan. DO NOT EDIT.
+
+package formats
+
+// GeneratedYamlFilterTestInputs holds 4 auto-extracted @Test fixtures from YamlFilterTest.
+var GeneratedYamlFilterTestInputs = []FormatInput{
+ {Name: "gen-testInlineCodeFinderNewLineCharacterDoubleQuotedString", Content: ttext(`"This is \n code test"`), OkapiTest: "YamlFilterTest#testInlineCodeFinderNewLineCharacterDoubleQuotedString", Informational: true},
+ {Name: "gen-testInlineCodeFinderNewLineCharacterSingleQuotedString", Content: ttext(`'This is \n code test'`), OkapiTest: "YamlFilterTest#testInlineCodeFinderNewLineCharacterSingleQuotedString", Informational: true},
+ {Name: "gen-testInlineCodeFinderNewLineCharacterStringWithoutQuotes", Content: ttext(`This is \n code test`), OkapiTest: "YamlFilterTest#testInlineCodeFinderNewLineCharacterStringWithoutQuotes", Informational: true},
+ {Name: "gen-testInlineCodeFinderWithQuoteInCode", Content: ttext(`"This is code test"`), OkapiTest: "YamlFilterTest#testInlineCodeFinderWithQuoteInCode", Informational: true},
+}
+
+// GeneratedYamlParserTestInputs holds 3 auto-extracted @Test fixtures from YamlParserTest.
+var GeneratedYamlParserTestInputs = []FormatInput{
+ {Name: "gen-singleString", Content: ttext(`one # test`), OkapiTest: "YamlParserTest#singleString", Informational: true},
+ {Name: "gen-singleArrayNoSpace", Content: ttext(`t: ['v1','v4','v5']`), OkapiTest: "YamlParserTest#singleArrayNoSpace", Informational: true},
+ {Name: "gen-singleArrayWithSpace", Content: ttext(`t: ['v1', 'v4', 'v5']`), OkapiTest: "YamlParserTest#singleArrayWithSpace", Informational: true},
+}
+
+// GeneratedYmlFilterTestInputs holds 14 auto-extracted @Test fixtures from YmlFilterTest.
+var GeneratedYmlFilterTestInputs = []FormatInput{
+ {Name: "gen-testSimpleYaml", Content: ttext(`config:
+ title: "My Rails Website"`), OkapiTest: "YmlFilterTest#testSimpleYaml", Informational: true},
+ {Name: "gen-testSimplePlaceholders", Content: ttext(`config:
+ title: "My {{count}} Rails Website"`), OkapiTest: "YmlFilterTest#testSimplePlaceholders", Informational: true},
+ {Name: "gen-emptyKey", Content: ttext(`- test`), OkapiTest: "YmlFilterTest#emptyKey", Informational: true},
+ {Name: "gen-nonEmptyKey", Content: ttext(`test: test`), OkapiTest: "YmlFilterTest#nonEmptyKey", Informational: true},
+ {Name: "gen-list", Content: ttext(`test: ["test1", "test2", "test3"]`), OkapiTest: "YmlFilterTest#list", Informational: true},
+ {Name: "gen-listSingleQuote", Content: ttext(`test: ['test1','test2','test3']`), OkapiTest: "YmlFilterTest#listSingleQuote", Informational: true},
+ {Name: "gen-map", Content: ttext(`- {1: test1, 2: test2, 3: test3}`), OkapiTest: "YmlFilterTest#map", Informational: true},
+ {Name: "gen-mapWithEmptyKeys", Content: ttext(`order: [ :day, :month, :year ]`), OkapiTest: "YmlFilterTest#mapWithEmptyKeys", Informational: true},
+ {Name: "gen-mapWithEmptyKeysQuoted", Content: ttext(`order: [ ":day", ":month", ":year" ]`), OkapiTest: "YmlFilterTest#mapWithEmptyKeysQuoted", Informational: true},
+ {Name: "gen-issue555", Content: ttext(`test: "'s house"`), OkapiTest: "YmlFilterTest#issue555", Informational: true},
+ {Name: "gen-issue556", Content: ttext(`html: "Visit Google"`), OkapiTest: "YmlFilterTest#issue556", Informational: true},
+ {Name: "gen-testMultilineValue", Content: ttext(`long_line: |-
+ This is a
+ very long line.`), OkapiTest: "YmlFilterTest#testMultilineValue", Informational: true},
+ {Name: "gen-commentsAfterPlainScalarsPreserved", Content: ttext(`a plain scalar with # a true comment
+`), OkapiTest: "YmlFilterTest#commentsAfterPlainScalarsPreserved", Informational: true},
+ {Name: "gen-commentsAfterPlainScalarMappingValuesPreserved", Content: ttext(`map:
+ - key1: "value1"
+ key2: value2 # a comment after a plain scalar mapping value
+`), OkapiTest: "YmlFilterTest#commentsAfterPlainScalarMappingValuesPreserved", Informational: true},
+}
diff --git a/parity/formats/html_spec_test.go b/parity/formats/html_spec_test.go
new file mode 100644
index 0000000..bf6828a
--- /dev/null
+++ b/parity/formats/html_spec_test.go
@@ -0,0 +1,29 @@
+//go:build parity
+
+package formats
+
+import (
+ "testing"
+
+ "github.com/neokapi/neokapi/core/format"
+ htmlfmt "github.com/neokapi/neokapi/core/formats/html"
+ parityspec "github.com/neokapi/okapi-bridge/parity/spec"
+)
+
+// TestParityHtmlSpec drives the HTML spec.yaml through bridge AND
+// native readers, validating both against the spec contract and
+// against each other. The same spec file drives the always-on native
+// test (core/formats/html/spec_test.go) — one source of truth for the
+// format's behavior across implementations.
+func TestParityHtmlSpec(t *testing.T) {
+ specPath := nativeSpecPath(t, "html")
+ s, err := parityspec.LoadSpec(specPath)
+ if err != nil {
+ t.Fatalf("load spec %s: %v", specPath, err)
+ }
+ r := &parityspec.ParityRunner{
+ Spec: s,
+ NewReader: func(_ string) format.DataFormatReader { return htmlfmt.NewReader() },
+ }
+ r.Run(t)
+}
diff --git a/parity/formats/json_spec_test.go b/parity/formats/json_spec_test.go
new file mode 100644
index 0000000..d0f3cfc
--- /dev/null
+++ b/parity/formats/json_spec_test.go
@@ -0,0 +1,27 @@
+//go:build parity
+
+package formats
+
+import (
+ "testing"
+
+ "github.com/neokapi/neokapi/core/format"
+ jsonfmt "github.com/neokapi/neokapi/core/formats/json"
+ parityspec "github.com/neokapi/okapi-bridge/parity/spec"
+)
+
+// TestParityJSONSpec drives the JSON spec.yaml through bridge AND
+// native readers, validating both against the spec contract and
+// against each other.
+func TestParityJSONSpec(t *testing.T) {
+ specPath := nativeSpecPath(t, "json")
+ s, err := parityspec.LoadSpec(specPath)
+ if err != nil {
+ t.Fatalf("load spec %s: %v", specPath, err)
+ }
+ r := &parityspec.ParityRunner{
+ Spec: s,
+ NewReader: func(_ string) format.DataFormatReader { return jsonfmt.NewReader() },
+ }
+ r.Run(t)
+}
diff --git a/parity/formats/main_test.go b/parity/formats/main_test.go
new file mode 100644
index 0000000..74579df
--- /dev/null
+++ b/parity/formats/main_test.go
@@ -0,0 +1,20 @@
+//go:build parity
+
+package formats
+
+import (
+ "fmt"
+ "os"
+ "testing"
+
+ "github.com/neokapi/okapi-bridge/parity"
+)
+
+func TestMain(m *testing.M) {
+ code := m.Run()
+ parity.ShutdownBridgeDaemon()
+ if err := parity.FlushReport(); err != nil {
+ fmt.Fprintf(os.Stderr, "parity: flush report: %v\n", err)
+ }
+ os.Exit(code)
+}
diff --git a/parity/formats/markdown_spec_test.go b/parity/formats/markdown_spec_test.go
new file mode 100644
index 0000000..1302205
--- /dev/null
+++ b/parity/formats/markdown_spec_test.go
@@ -0,0 +1,27 @@
+//go:build parity
+
+package formats
+
+import (
+ "testing"
+
+ "github.com/neokapi/neokapi/core/format"
+ "github.com/neokapi/neokapi/core/formats/markdown"
+ parityspec "github.com/neokapi/okapi-bridge/parity/spec"
+)
+
+// TestParityMarkdownSpec drives the markdown spec.yaml through bridge
+// AND native readers, validating both against the spec contract and
+// against each other.
+func TestParityMarkdownSpec(t *testing.T) {
+ specPath := nativeSpecPath(t, "markdown")
+ s, err := parityspec.LoadSpec(specPath)
+ if err != nil {
+ t.Fatalf("load spec %s: %v", specPath, err)
+ }
+ r := &parityspec.ParityRunner{
+ Spec: s,
+ NewReader: func(_ string) format.DataFormatReader { return markdown.NewReader() },
+ }
+ r.Run(t)
+}
diff --git a/parity/formats/messageformat_spec_test.go b/parity/formats/messageformat_spec_test.go
new file mode 100644
index 0000000..a404006
--- /dev/null
+++ b/parity/formats/messageformat_spec_test.go
@@ -0,0 +1,29 @@
+//go:build parity
+
+package formats
+
+import (
+ "testing"
+
+ "github.com/neokapi/neokapi/core/format"
+ mffmt "github.com/neokapi/neokapi/core/formats/messageformat"
+ parityspec "github.com/neokapi/okapi-bridge/parity/spec"
+)
+
+// TestParityMessageformatSpec drives the ICU MessageFormat spec.yaml
+// through bridge AND native readers, validating both against the spec
+// contract and against each other. The same spec file drives the
+// always-on native test (core/formats/messageformat/spec_test.go) —
+// one source of truth for the format's behavior across implementations.
+func TestParityMessageformatSpec(t *testing.T) {
+ specPath := nativeSpecPath(t, "messageformat")
+ s, err := parityspec.LoadSpec(specPath)
+ if err != nil {
+ t.Fatalf("load spec %s: %v", specPath, err)
+ }
+ r := &parityspec.ParityRunner{
+ Spec: s,
+ NewReader: func(_ string) format.DataFormatReader { return mffmt.NewReader() },
+ }
+ r.Run(t)
+}
diff --git a/parity/formats/odf_spec_test.go b/parity/formats/odf_spec_test.go
new file mode 100644
index 0000000..ad7429c
--- /dev/null
+++ b/parity/formats/odf_spec_test.go
@@ -0,0 +1,34 @@
+//go:build parity
+
+package formats
+
+import (
+ "testing"
+
+ "github.com/neokapi/neokapi/core/format"
+ "github.com/neokapi/neokapi/core/formats/odf"
+ parityspec "github.com/neokapi/okapi-bridge/parity/spec"
+)
+
+// TestParityOdfSpec drives the odf spec.yaml through the bridge AND
+// the native reader, comparing both to the spec contract and to each
+// other (block-text equivalence). The same spec file drives the
+// always-on native test (core/formats/odf/spec_test.go), so a single
+// source of truth describes ODF's behavior across implementations.
+//
+// The spec binds to bridge filter `okf_openoffice` (the ZIP-handling
+// OpenDocument filter), not `okf_odf` (which is the inner content.xml
+// filter). The native reader consumes full .odt/.ods/.odp/.odg ZIP
+// archives, matching what `okf_openoffice` accepts.
+func TestParityOdfSpec(t *testing.T) {
+ specPath := nativeSpecPath(t, "odf")
+ s, err := parityspec.LoadSpec(specPath)
+ if err != nil {
+ t.Fatalf("load spec %s: %v", specPath, err)
+ }
+ r := &parityspec.ParityRunner{
+ Spec: s,
+ NewReader: func(_ string) format.DataFormatReader { return odf.NewReader() },
+ }
+ r.Run(t)
+}
diff --git a/parity/formats/openxml_spec_test.go b/parity/formats/openxml_spec_test.go
new file mode 100644
index 0000000..14fd4d0
--- /dev/null
+++ b/parity/formats/openxml_spec_test.go
@@ -0,0 +1,30 @@
+//go:build parity
+
+package formats
+
+import (
+ "testing"
+
+ "github.com/neokapi/neokapi/core/format"
+ "github.com/neokapi/neokapi/core/formats/openxml"
+ parityspec "github.com/neokapi/okapi-bridge/parity/spec"
+)
+
+// TestParityOpenXMLSpec drives the openxml spec.yaml through the
+// bridge AND the native reader, comparing both to the spec contract
+// and to each other (block-text equivalence). The same spec file
+// drives the always-on native test (core/formats/openxml/spec_test.go),
+// so a single source of truth describes openxml's behavior across
+// implementations.
+func TestParityOpenXMLSpec(t *testing.T) {
+ specPath := nativeSpecPath(t, "openxml")
+ s, err := parityspec.LoadSpec(specPath)
+ if err != nil {
+ t.Fatalf("load spec %s: %v", specPath, err)
+ }
+ r := &parityspec.ParityRunner{
+ Spec: s,
+ NewReader: func(_ string) format.DataFormatReader { return openxml.NewReader() },
+ }
+ r.Run(t)
+}
diff --git a/parity/formats/parity_migration_test.go b/parity/formats/parity_migration_test.go
new file mode 100644
index 0000000..aff3bd6
--- /dev/null
+++ b/parity/formats/parity_migration_test.go
@@ -0,0 +1,79 @@
+//go:build parity
+
+package formats
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestParityKnowledgeFromSpecYAML is the migration guard for #852: it proves
+// the per-format parity knowledge (bridge filter class, tikal corner, parity
+// skips) now lives in spec.yaml and resolves onto every formatSpecs row,
+// WITHOUT needing a live okapi-bridge daemon. It runs as a plain unit test so
+// the migration is verifiable on a developer machine (the full head-to-head
+// run still needs `make parity-sandbox`).
+func TestParityKnowledgeFromSpecYAML(t *testing.T) {
+ // Load-all probe: every core/formats/*/spec.yaml must load + validate.
+ idx, err := loadSpecIndex()
+ require.NoError(t, err, "every core/formats/*/spec.yaml must load and validate")
+ require.GreaterOrEqual(t, len(idx), 30, "spec index should cover the format corpus (33 native formats since the 2026-07 legacy purge)")
+
+ // Every formatSpecs row resolves cleanly and yields a non-empty bridge
+ // filter class (the dispatch key sent to BridgeService.Process).
+ for _, fs := range formatSpecs {
+ resolved, err := resolveParity(fs)
+ require.NoErrorf(t, err, "resolveParity(%s)", fs.ID)
+ assert.NotEmptyf(t, bridgeClass(resolved), "%s: bridge class", fs.ID)
+ }
+
+ // Value-preservation: the knowledge migrated out of the Go table now
+ // arrives via spec.yaml exactly as before.
+ t.Run("tikal_corner", func(t *testing.T) {
+ cases := map[string]struct{ ext, cfg string }{
+ "okf_properties": {".properties", "okf_properties"},
+ "okf_po": {".po", "okf_po"},
+ "okf_plaintext": {".txt", "okf_plaintext"},
+ }
+ for id, want := range cases {
+ got, err := resolveParity(FormatSpec{ID: id})
+ require.NoError(t, err)
+ assert.Equalf(t, want.ext, got.TikalExt, "%s tikal ext", id)
+ assert.Equalf(t, want.cfg, got.TikalConfig, "%s tikal config", id)
+ assert.NotNilf(t, got.NewWriter, "%s writer from parityWriters registry", id)
+ }
+ })
+
+ t.Run("po_roundtrip_and_tikal_skips", func(t *testing.T) {
+ got, err := resolveParity(FormatSpec{ID: "okf_po"})
+ require.NoError(t, err)
+ assert.NotEmpty(t, got.SkipRoundTrip, "po round-trip skip")
+ assert.NotEmpty(t, got.SkipTikal, "po tikal skip")
+ })
+
+ t.Run("filter_level_skips", func(t *testing.T) {
+ // Formats whose spec.yaml carries a parity.skip — the divergence-453
+ // readers and the binary-corpus filters with a native Go port. okf_pdf
+ // is NOT here: PDF lost its in-core reader (now the kapi-pdfium plugin),
+ // so it has no spec.yaml and keeps an inline SkipBinary on its
+ // formatSpecs row, like the other bridge-only rows (okf_odf, okf_archive).
+ for _, id := range []string{
+ "okf_commaseparatedvalues", "okf_openxml", "okf_openoffice",
+ } {
+ got, err := resolveParity(FormatSpec{ID: id})
+ require.NoError(t, err)
+ assert.NotEmptyf(t, got.Skip, "%s: parity.skip should resolve from spec.yaml", id)
+ }
+ })
+
+ t.Run("residual_rows_keep_inline_fields", func(t *testing.T) {
+ // The okf_xml/okf_dita config-preset rows have no spec.yaml (xml's
+ // spec.yaml is okf_xmlstream); their inline bridge class survives.
+ got, err := resolveParity(FormatSpec{ID: "okf_dita", BridgeFilterClass: "okf_xmlstream", ConfigID: "okf_xmlstream-dita"})
+ require.NoError(t, err)
+ assert.Equal(t, "okf_xmlstream", got.BridgeFilterClass)
+ assert.Equal(t, "okf_xmlstream-dita", got.ConfigID)
+ })
+}
diff --git a/parity/formats/plaintext_spec_test.go b/parity/formats/plaintext_spec_test.go
new file mode 100644
index 0000000..8d88d3c
--- /dev/null
+++ b/parity/formats/plaintext_spec_test.go
@@ -0,0 +1,29 @@
+//go:build parity
+
+package formats
+
+import (
+ "testing"
+
+ "github.com/neokapi/neokapi/core/format"
+ "github.com/neokapi/neokapi/core/formats/plaintext"
+ parityspec "github.com/neokapi/okapi-bridge/parity/spec"
+)
+
+// TestParityPlaintextSpec drives the plaintext spec.yaml through bridge AND
+// native readers, validating both against the spec contract and
+// against each other. The same spec file drives the always-on native
+// test (core/formats/plaintext/spec_test.go) — one source of truth for
+// the format's behavior across implementations.
+func TestParityPlaintextSpec(t *testing.T) {
+ specPath := nativeSpecPath(t, "plaintext")
+ s, err := parityspec.LoadSpec(specPath)
+ if err != nil {
+ t.Fatalf("load spec %s: %v", specPath, err)
+ }
+ r := &parityspec.ParityRunner{
+ Spec: s,
+ NewReader: func(_ string) format.DataFormatReader { return plaintext.NewReader() },
+ }
+ r.Run(t)
+}
diff --git a/parity/formats/po_spec_test.go b/parity/formats/po_spec_test.go
new file mode 100644
index 0000000..90af047
--- /dev/null
+++ b/parity/formats/po_spec_test.go
@@ -0,0 +1,27 @@
+//go:build parity
+
+package formats
+
+import (
+ "testing"
+
+ "github.com/neokapi/neokapi/core/format"
+ "github.com/neokapi/neokapi/core/formats/po"
+ parityspec "github.com/neokapi/okapi-bridge/parity/spec"
+)
+
+// TestParityPoSpec drives the PO spec.yaml through bridge AND native
+// readers, validating both against the spec contract and against each
+// other.
+func TestParityPoSpec(t *testing.T) {
+ specPath := nativeSpecPath(t, "po")
+ s, err := parityspec.LoadSpec(specPath)
+ if err != nil {
+ t.Fatalf("load spec %s: %v", specPath, err)
+ }
+ r := &parityspec.ParityRunner{
+ Spec: s,
+ NewReader: func(_ string) format.DataFormatReader { return po.NewReader() },
+ }
+ r.Run(t)
+}
diff --git a/parity/formats/properties_spec_test.go b/parity/formats/properties_spec_test.go
new file mode 100644
index 0000000..3320aef
--- /dev/null
+++ b/parity/formats/properties_spec_test.go
@@ -0,0 +1,29 @@
+//go:build parity
+
+package formats
+
+import (
+ "testing"
+
+ "github.com/neokapi/neokapi/core/format"
+ propertiesfmt "github.com/neokapi/neokapi/core/formats/properties"
+ parityspec "github.com/neokapi/okapi-bridge/parity/spec"
+)
+
+// TestParityPropertiesSpec drives the properties spec.yaml through
+// bridge AND native readers, validating both against the spec contract
+// and against each other. The same spec file drives the always-on
+// native test (core/formats/properties/spec_test.go) — one source of
+// truth for the format's behavior across implementations.
+func TestParityPropertiesSpec(t *testing.T) {
+ specPath := nativeSpecPath(t, "properties")
+ s, err := parityspec.LoadSpec(specPath)
+ if err != nil {
+ t.Fatalf("load spec %s: %v", specPath, err)
+ }
+ r := &parityspec.ParityRunner{
+ Spec: s,
+ NewReader: func(_ string) format.DataFormatReader { return propertiesfmt.NewReader() },
+ }
+ r.Run(t)
+}
diff --git a/parity/formats/spec.go b/parity/formats/spec.go
new file mode 100644
index 0000000..00458af
--- /dev/null
+++ b/parity/formats/spec.go
@@ -0,0 +1,704 @@
+//go:build parity
+
+// Package formats holds the per-filter parity tests that run every
+// Okapi filter present in the okapi-bridge v2 manifest against its
+// neokapi Go counterpart (when one exists) or as a bridge-only
+// stability snapshot (when no Go port exists).
+//
+// Each entry in `formatSpecs` declares one filter. The fields that
+// can't live in YAML stay inline; the per-format parity KNOWLEDGE
+// lives in the format's spec.yaml (#852, see resolveParity):
+//
+// ID okf_ — the manifest id and the default
+// FilterClass sent to BridgeService.Process.
+// MimeType mime hint passed to both bridge and native readers.
+// Inputs list of named sample inputs (small inline strings,
+// curated, plus the harvested fixtures_*_generated.go
+// batches). Inputs and NewReader are the Go-irreducible
+// part — a func() and bulk Go literals can't move to YAML.
+// NewReader constructs the in-process Go reader. Nil means
+// bridge-only — the test asserts the bridge runs and
+// produces a non-empty stream against `Inputs`, but
+// does not compare against a native implementation.
+//
+// Bridge filter class, bridge config id, the tikal corner, the parity
+// skips, and the writer factory are NO LONGER set here. They are sourced
+// from the format's spec.yaml (`bridge_filter_class`, `bridge_config_id`,
+// `tikal:`, `parity:`) and the parityWriters registry (writers.go) by
+// resolveParity at run time, making spec.yaml the single source of truth.
+// Rows whose id has no spec.yaml (bridge-manifest-completeness entries
+// with no native Go port) keep those fields inline as a residue.
+package formats
+
+import (
+ "fmt"
+ "path/filepath"
+ "sync"
+
+ "github.com/neokapi/neokapi/core/format"
+ formatspec "github.com/neokapi/neokapi/core/format/spec"
+ csvfmt "github.com/neokapi/neokapi/core/formats/csv"
+ htmlfmt "github.com/neokapi/neokapi/core/formats/html"
+ jsonfmt "github.com/neokapi/neokapi/core/formats/json"
+ markdownfmt "github.com/neokapi/neokapi/core/formats/markdown"
+ odffmt "github.com/neokapi/neokapi/core/formats/odf"
+ openxmlfmt "github.com/neokapi/neokapi/core/formats/openxml"
+ plaintextfmt "github.com/neokapi/neokapi/core/formats/plaintext"
+ pofmt "github.com/neokapi/neokapi/core/formats/po"
+ propertiesfmt "github.com/neokapi/neokapi/core/formats/properties"
+ srtfmt "github.com/neokapi/neokapi/core/formats/srt"
+ tmxfmt "github.com/neokapi/neokapi/core/formats/tmx"
+ tsfmt "github.com/neokapi/neokapi/core/formats/ts"
+ vttfmt "github.com/neokapi/neokapi/core/formats/vtt"
+ xlifffmt "github.com/neokapi/neokapi/core/formats/xliff"
+ xliff2fmt "github.com/neokapi/neokapi/core/formats/xliff2"
+ xmlfmt "github.com/neokapi/neokapi/core/formats/xml"
+ yamlfmt "github.com/neokapi/neokapi/core/formats/yaml"
+ "github.com/neokapi/okapi-bridge/parity"
+)
+
+// SkipBinary is the standard skip reason for filters whose only
+// realistic input is a binary container (DOCX, IDML, MIF, ICML, PDF,
+// archive zips, SDL packages, …). We do not commit binary corpus to
+// the neokapi repo per the constraint on parity test data; these
+// rows still get a row in the report so the dashboard surfaces the
+// gap. Resolution path: ship a tiny corpus inside okapi-bridge's
+// plugin tarball (under testdata/) and adapt the spec to read from
+// there.
+//
+// Since #852 the binary filters WITH a native Go port carry this reason
+// in their spec.yaml `parity.skip` (resolveParity reads it). This const
+// now serves only the residual no-spec.yaml rows (okf_odf alias,
+// okf_archive, okf_sdlpackage, okf_pensieve, …).
+const SkipBinary = "binary corpus not in repo (rely on okapi-bridge testdata/ when available)"
+
+// SkipDivergence453 marks filters whose Go port and Okapi filter
+// agree the file is parseable but disagree on which segments are
+// translatable. Each row has a per-filter line in #453 explaining
+// the gap. Flip Skip back to "" once aligned.
+//
+// Since #852 the formats WITH a spec.yaml carry this reason in their
+// spec.yaml `parity.skip`; this const now serves only the residual
+// no-spec.yaml okf_table family rows (okf_tabseparatedvalues,
+// okf_basetable, okf_table).
+const SkipDivergence453 = "documented divergence, see #453"
+
+// SkipBridgeConfig marks the okf_xml/okf_xmlstream config-preset formats
+// (DITA, DocBook, ResX) whose native side is wired (xml reader + the Go
+// config preset) but whose bridge side can't yet load the equivalent named
+// Okapi config: the okapi-bridge okf_xml/okf_xmlstream schema exposes no
+// configId/rules parameter, so a head-to-head comparison would run the
+// bridge with default rules against the native preset (a false divergence).
+// Head-to-head enables once okapi-bridge gains config-by-name support (#613).
+const SkipBridgeConfig = "native xml config preset wired; bridge config-by-name not yet supported, see okapi-bridge #613"
+
+// FormatInput is one named sample input.
+//
+// OkapiTest is optional. When set ("ClassName#methodName", short class
+// form like "HtmlSnippetsTest#testEscapes"), the harness reports each
+// fixture run under format-fixture parity rows keyed on the Java test
+// id. The contract-audit dashboard then joins per-test bridge/native
+// status against Surefire's own row for that test, giving true 3-way
+// per-test granularity instead of one filter-level badge.
+//
+// Informational marks a fixture as exploratory: comparison failures
+// are logged and reported to the parity dashboard, but they don't
+// fail the Go test (so CI stays green on auto-generated fixtures
+// that surface known divergences). Hand-curated fixtures leave it
+// false to act as strict regression gates.
+type FormatInput struct {
+ Name string
+ Content []byte
+ OkapiTest string
+ Informational bool
+}
+
+// FormatSpec describes one parity test row.
+//
+// Per-format parity KNOWLEDGE — the bridge filter class, the tikal corner,
+// and the skip directives — is no longer hand-set here. It lives in the
+// format's spec.yaml (bridge_filter_class / bridge_config_id / tikal / parity)
+// and is overlaid onto the row at run time by resolveParity (#852), making
+// spec.yaml the single source of truth. The fields below remain because (a)
+// resolveParity fills them and the runner reads them, and (b) the residual
+// bridge-manifest-completeness rows with no spec.yaml still set them inline.
+//
+// NewReader / NewWriter are `func()` factories and therefore cannot live in
+// YAML. NewReader stays inline; NewWriter is sourced from the parityWriters
+// registry (writers.go) by resolveParity — the one irreducibly-Go piece.
+//
+// - When NewWriter is set (and neither Skip nor SkipRoundTrip fires) the
+// harness drives a read→write round-trip pass on both sides and compares
+// the output bytes (Kind="format-roundtrip").
+// - SkipRoundTrip / SkipTikal skip just those passes, leaving read parity
+// intact; sourced from spec.yaml `parity.skip_roundtrip` / `skip_tikal`.
+// - TikalExt / TikalConfig wire the third reference corner (tikal -x + -m vs
+// the native round-trip, Kind="format-tikal"); sourced from spec.yaml
+// `tikal.ext` / `tikal.config`.
+// - BridgeFilterClass / ConfigID dispatch config-preset formats to a base
+// filter plus a named Okapi config; sourced from spec.yaml
+// `bridge_filter_class` / `bridge_config_id`.
+type FormatSpec struct {
+ ID string
+ MimeType string
+ Inputs []FormatInput
+ NewReader func() format.DataFormatReader
+ NewWriter func() format.DataFormatWriter // from parityWriters registry (writers.go)
+ Skip string // from spec.yaml parity.skip
+ SkipRoundTrip string // from spec.yaml parity.skip_roundtrip
+ SkipTikal string // from spec.yaml parity.skip_tikal
+
+ // BridgeFilterClass overrides the FilterClass sent to the bridge when it
+ // differs from ID (from spec.yaml bridge_filter_class). Config-preset
+ // formats use the manifest id as ID (e.g. "okf_dita") for the dashboard
+ // join, but dispatch to the base filter (e.g. "okf_xmlstream") plus
+ // ConfigID. Empty = use ID.
+ BridgeFilterClass string
+
+ // ConfigID names a built-in Okapi filter configuration the bridge loads
+ // before opening (e.g. "okf_xmlstream-dita"; from spec.yaml
+ // bridge_config_id). Native side configures via NewReader+SetConfig; the
+ // bridge applies the same named config so the comparison is head-to-head.
+ // Empty = filter defaults.
+ ConfigID string
+ TikalExt string // from spec.yaml tikal.ext; empty disables tikal.
+ TikalConfig string // from spec.yaml tikal.config; optional -fc filter id.
+
+ // Params holds the configuration applied to both sides of the
+ // comparison. Native filters receive these via the existing
+ // DataFormatConfig.ApplyMap path (typed Go config stays the
+ // in-memory representation); the bridge receives the same keys
+ // stringified into FilterParams. Empty means each side runs with
+ // its own defaults.
+ //
+ // Use the flat names from the bridge schema's
+ // `x-okapi-flatten-path` annotations — they line up with the
+ // camelCase keys native ApplyMap implementations recognise.
+ Params map[string]any
+}
+
+func ttext(s string) []byte { return []byte(s) }
+
+// bridgeClass is the FilterClass sent to the bridge: BridgeFilterClass when
+// set (config-preset formats dispatch to a base filter), else the spec ID.
+func bridgeClass(s FormatSpec) string {
+ if s.BridgeFilterClass != "" {
+ return s.BridgeFilterClass
+ }
+ return s.ID
+}
+
+// specIndex maps a bridge filter id (the spec.yaml `format:` field) to its
+// loaded Spec. Built once from core/formats/*/spec.yaml. spec.yaml is the
+// single source of truth for per-format parity knowledge — bridge filter
+// class, tikal config, and parity skips (#852); resolveParity overlays it
+// onto each formatSpecs row at run time so that knowledge is no longer
+// hand-maintained twice.
+var (
+ specIndexOnce sync.Once
+ specIndexMap map[string]*formatspec.Spec
+ specIndexErr error
+)
+
+// loadSpecIndex globs and loads every core/formats//spec.yaml of the
+// neokapi module this suite is compiled against, indexing by the spec's
+// `format:` id. The specs and the readers under test come from one module
+// version by construction.
+func loadSpecIndex() (map[string]*formatspec.Spec, error) {
+ specIndexOnce.Do(func() {
+ formatsDir, err := parity.NeokapiFormatsDir()
+ if err != nil {
+ specIndexErr = err
+ return
+ }
+ pattern := filepath.Join(formatsDir, "*", "spec.yaml")
+ paths, err := filepath.Glob(pattern)
+ if err != nil {
+ specIndexErr = fmt.Errorf("glob %s: %w", pattern, err)
+ return
+ }
+ m := make(map[string]*formatspec.Spec, len(paths))
+ for _, p := range paths {
+ s, err := formatspec.Load(p)
+ if err != nil {
+ specIndexErr = fmt.Errorf("load %s: %w", p, err)
+ return
+ }
+ if prev, dup := m[s.Format]; dup && prev != nil {
+ specIndexErr = fmt.Errorf("two spec.yaml declare format %q", s.Format)
+ return
+ }
+ m[s.Format] = s
+ }
+ specIndexMap = m
+ })
+ return specIndexMap, specIndexErr
+}
+
+// resolveParity overlays the spec.yaml-sourced parity knowledge (bridge filter
+// class, bridge config id, tikal corner, parity skips) and the Go writer
+// registry onto a formatSpecs row, then returns the augmented row. Rows whose
+// id has no spec.yaml — the bridge-manifest-completeness entries with no
+// native Go port (okf_archive, okf_pensieve, the okf_table abstract parents,
+// the okf_xml/okf_dita config presets, …) — keep their inline fields: that
+// residue is what genuinely can't move to spec.yaml.
+func resolveParity(fs FormatSpec) (FormatSpec, error) {
+ idx, err := loadSpecIndex()
+ if err != nil {
+ return fs, err
+ }
+ // The writer registry is independent of spec.yaml (a Go func() can't
+ // live in YAML); apply it regardless of whether a spec.yaml exists.
+ if w := parityWriterFor(fs.ID); w != nil {
+ fs.NewWriter = w
+ }
+ s := idx[fs.ID]
+ if s == nil {
+ return fs, nil // residual row — no spec.yaml to source from
+ }
+ if s.BridgeFilterClass != "" {
+ fs.BridgeFilterClass = s.BridgeFilterClass
+ }
+ if s.BridgeConfigID != "" {
+ fs.ConfigID = s.BridgeConfigID
+ }
+ if s.Tikal != nil {
+ fs.TikalExt = s.Tikal.Ext
+ fs.TikalConfig = s.Tikal.Config
+ }
+ if s.Parity != nil {
+ fs.Skip = s.Parity.Skip
+ fs.SkipRoundTrip = s.Parity.SkipRoundTrip
+ fs.SkipTikal = s.Parity.SkipTikal
+ }
+ return fs, nil
+}
+
+// mergeInputs concatenates curated fixtures with one or more
+// auto-generated batches. Lets each FormatSpec list its hand-curated
+// fixtures inline and append the scanner's output without writing the
+// same `append(append(...))` chain by hand.
+func mergeInputs(curated []FormatInput, generated ...[]FormatInput) []FormatInput {
+ out := make([]FormatInput, 0, len(curated))
+ out = append(out, curated...)
+ for _, g := range generated {
+ out = append(out, g...)
+ }
+ return out
+}
+
+// formatSpecs lists every okf_* filter declared in the okapi-bridge
+// v2 manifest at framework_version 1.48.0. Pinned by intent: when a
+// future release adds, removes, or renames a filter, update this
+// table (and the parity dashboard regenerates).
+//
+// IMPORTANT for contributors: when adding a row, choose the smallest
+// possible Inputs that still exercise the format's main read path. The
+// goal is regression safety, not exhaustive coverage — long fixtures
+// inflate CI time and obscure failure messages.
+var formatSpecs = []FormatSpec{
+ // ── Text / structured ────────────────────────────────────────────
+ {
+ ID: "okf_html",
+ MimeType: "text/html",
+ NewReader: func() format.DataFormatReader { return htmlfmt.NewReader() },
+ Inputs: mergeInputs(
+ []FormatInput{
+ {Name: "minimal", Content: ttext(`