本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

TEI(Text Encoding Initiative)でテキストを構造化していると、RELAX NG では表現できない種類の規則が出てきます。「本文中の人名には必ず典拠 ID を付ける」「@ref="#foo" の参照先がヘッダに存在すること」「@from@to より後であってはならない」といった規則です。

RELAX NG は決して非力ではなく、属性の値によって内容モデルを切り替える(co-occurrence constraint)くらいまでは書けますし、DTD Compatibility の ID / IDREF による参照検査も持っています(jing は既定で有効)。ただし「値の大小を比べる」「参照先が <person> 要素であることまで確かめる」「祖先の文脈によって規則を出し分ける」となると手が出ません。

こうした規則は Schematron で書きます。そして TEI では、Schematron を独立したファイルに置くのではなく、スキーマの設計図である ODD(One Document Does it All)の中に埋め込めます。ODD から RELAX NG と Schematron の両方を生成すれば、スキーマの正本が 1 ファイルに収まります。

この記事では、oXygen XML Editor を使わず、macOS + VS Code + コマンドラインだけでその流れを一通り動かします。動作を確認したサンプル一式を組み立てながら、途中で踏んだ落とし穴も記録しています。

サンプルは GitHub に置いてあります。brew install 3 つとスクリプト 2 本で同じ状態を再現できます。

全体の流れ

flowchart LR
    A["letters.odd<br/>(カスタマイズ ODD)"] -->|odd2odd.xsl| B["compiled.odd<br/>(P5 と合成)"]
    B -->|odd2relax.xsl| C["letters.rng<br/>(RELAX NG)"]
    B -->|extract-isosch.xsl| D["letters.sch<br/>(ISO Schematron)"]
    D -->|SchXslt2 transpile.xsl| E["letters.sch.xsl<br/>(XSLT 3.0)"]
    C -->|jing| F["構造の検証"]
    E -->|Saxon| G["SVRL(検証レポート)<br/>→ 規則の検証"]

ODD をそのまま変換するのではなく、いったん TEI P5 の全仕様と合成した「展開済み ODD」を作るのがポイントです。カスタマイズ ODD には「core モジュールを使う」としか書いていないので、実際の要素定義を P5 から引き込む工程が要ります。

環境構築

必要なものは Java、Saxon、jing の 3 つです。

brew install openjdk saxon jing-trang

Homebrew の openjdk/Library/Java/JavaVirtualMachines/ にシンボリックリンクを張らない構成のことがあり、その場合 java が PATH に乗りません。以下で確認できます。

java -version                              # 見つからないと言われることがある
/opt/homebrew/opt/openjdk/bin/java -version # こちらは動く

見つからない場合は、スクリプト側で PATH を通す方が副作用が少なく済みます(後述のスクリプトはそうしています)。

次の 3 つはダウンロードして展開します。

名前役割入手元
TEI StylesheetsODD を RELAX NG / Schematron に変換する XSLT 群TEIC/Stylesheets のリリース
SchXslt2Schematron を XSLT 3.0 に変換するトランスパイラSchXslt/schxslt2(Codeberg)
p5subset.xmlTEI P5 の全仕様を 1 ファイルにまとめたものTEI Vault

scripts/setup-tools.zsh としてまとめておきます。

#!/usr/bin/env zsh
set -euo pipefail

ROOT=${0:A:h:h}
cd "$ROOT"

TEI_XSL_VERSION=7.61.0
SCHXSLT_VERSION=1.11.2

mkdir -p tools
cd tools

if [[ ! -d tei-xsl ]]; then
  curl -fsSL -o tei-xsl.zip "https://github.com/TEIC/Stylesheets/releases/download/v${TEI_XSL_VERSION}/tei-xsl-${TEI_XSL_VERSION}.zip"
  unzip -q -o tei-xsl.zip -d tei-xsl
  rm tei-xsl.zip
fi

if [[ ! -d schxslt2 ]]; then
  curl -fsSL -o schxslt2.zip "https://codeberg.org/SchXslt/schxslt2/releases/download/v${SCHXSLT_VERSION}/schxslt2-${SCHXSLT_VERSION}.zip"
  unzip -q -o schxslt2.zip -d schxslt2
  rm schxslt2.zip
fi

[[ -f p5subset.xml ]] || curl -fsSL -o p5subset.xml "https://www.tei-c.org/Vault/P5/current/xml/tei/odd/p5subset.xml"

SchXslt2 の配布物は transpile.xsl を含むごく小さな zip(18 KB)です。TEI Stylesheets は 12 MB、p5subset.xml は 4.1 MB でした。

ODD に制約を書く

書簡コーパス用の小さなカスタマイゼーションを作ります。全体は次のような構造です。

<schemaSpec ident="letters" start="TEI" prefix="tei_" docLang="ja" targetLang="ja">

  <constraintDecl scheme="schematron" queryBinding="xslt2"/>

  <moduleRef key="tei"/>
  <moduleRef key="core"/>
  <moduleRef key="header"/>
  <moduleRef key="textstructure"/>
  <moduleRef key="namesdates"/>
  <moduleRef key="corpus"/>

  <!-- ここに constraintSpec を並べる -->

</schemaSpec>

<constraintDecl> で「Schematron を使うこと」と「XPath のバージョン」を宣言します。queryBinding="xslt2" にしておくと matches()xs:date() のような XPath 2.0 以降の関数が使えます。指定しない場合も TEI Stylesheets が xslt2 を補います(extract-isosch.xsl( //tei:constraintDecl/@queryBinding, 'xslt2')[1] というハードコードがあります)。ただし ISO Schematron 本体の既定値は xslt(XPath 1.0)なので、生成した .sch を別の処理系に渡すことも考えると明示しておく方が安全です。TEI Guidelines 上も @queryBinding は「推奨(rec)」扱いです。

要素固有の制約は elementSpec の中に置く

<persName> に対する 2 つの規則です。

<elementSpec ident="persName" module="namesdates" mode="change">

  <constraintSpec ident="persName-needs-ref" scheme="schematron" mode="add">
    <desc>本文中の &lt;persName> には @ref が必要。</desc>
    <constraint>
      <sch:rule context="tei:body//tei:persName">
        <sch:assert test="@ref"
          >本文中の &lt;persName> には @ref(人物典拠への参照)が必要です。対象:
          「<sch:value-of select="normalize-space(.)"/></sch:assert>
      </sch:rule>
    </constraint>
  </constraintSpec>

  <constraintSpec ident="persName-ref-resolves" scheme="schematron" mode="add">
    <desc>@ref="#foo" は同一文書内の &lt;person xml:id="foo"> を指していること。</desc>
    <constraint>
      <sch:rule context="tei:persName[starts-with(@ref, '#')]">
        <sch:let name="target" value="substring-after(@ref, '#')"/>
        <sch:assert test="//tei:person[@xml:id eq $target]"
          >@ref の参照先が見つかりません: <sch:value-of select="@ref"/></sch:assert>
      </sch:rule>
    </constraint>
  </constraintSpec>

</elementSpec>

1 つめは、RELAX NG でも @ref を必須にはできますが、「本文中のものだけ」という条件は付けられません。ヘッダの <person><persName>森鴎外</persName></person> には @ref を要求したくないので、contexttei:body//tei:persName に絞っています。

2 つめは参照整合性です。RELAX NG の ID / IDREF でも「その値を持つ xml:id がどこかにある」ことまでは言えますが、「参照先が <person> 要素であること」まで縛ることはできません。ここは Schematron の領分です。<sch:let> で変数を作り、<sch:value-of> でエラーメッセージに実際の値を埋め込んでいます。

なお、Schematron の変数要素は <sch:let> です。<sch:variable> ではありません(XSLT の xsl:variable と混同しやすい箇所です)。

assert と report の使い分け

<assert> は「この条件が真であるべき」、<report> は「この条件が真なら困る」という向きです。同じことを書けますが、否定を重ねずに済む方を選ぶと読みやすくなります。

<elementSpec ident="pb" module="core" mode="change">
  <constraintSpec ident="pb-not-first-in-div" scheme="schematron" mode="add">
    <desc>&lt;pb> を &lt;div> の先頭に置かない。</desc>
    <constraint>
      <sch:rule context="tei:pb">
        <sch:report test="parent::tei:div and not(preceding-sibling::*)"
          >&lt;pb> が &lt;div> の先頭にあります。&lt;div> の直前に移動してください。
          (n=<sch:value-of select="@n"/></sch:report>
      </sch:rule>
    </constraint>
  </constraintSpec>
</elementSpec>

文書全体にかかる制約は schemaSpec 直下に置く

特定の要素の定義に属さない規則は、<schemaSpec> の直下に <constraintSpec> を置きます。

<constraintSpec ident="isbn-format" scheme="schematron">
  <desc>&lt;idno type="ISBN"> はハイフン入り 13 桁 ISBN であること。</desc>
  <constraint>
    <sch:rule context="tei:idno[@type eq 'ISBN']">
      <sch:assert test="matches(normalize-space(.), '^97[89]-\d{1,5}-\d{1,7}-\d{1,7}-\d$')"
        >ISBN の書式が不正です: 「<sch:value-of select="normalize-space(.)"/>」
        期待する形式: 978-4-12-345678-9</sch:assert>
    </sch:rule>
  </constraint>
</constraintSpec>

<constraintSpec ident="date-range-order" scheme="schematron">
  <desc>@from は @to より後であってはならない。</desc>
  <constraint>
    <sch:rule context="tei:date[@from][@to]">
      <sch:assert test="xs:date(@from) le xs:date(@to)"
        >日付の前後が逆です: @from=<sch:value-of select="@from"/> /
        @to=<sch:value-of select="@to"/></sch:assert>
    </sch:rule>
  </constraint>
</constraintSpec>

matches() による正規表現と、xs:date() による日付比較です。どちらも queryBinding="xslt2" があって初めて使えます。

スキーマを生成する

4 ステップです。

export PATH="/opt/homebrew/opt/openjdk/bin:$PATH"   # java が PATH に無い場合
XSL=tools/tei-xsl/xml/tei/stylesheet/odds

# 1. カスタマイズ ODD を P5 と合成して展開済み ODD を作る
saxon -s:odd/letters.odd -xsl:$XSL/odd2odd.xsl \
      -o:build/letters.compiled.odd defaultSource="$PWD/tools/p5subset.xml"

# 2. RELAX NG を生成
saxon -s:build/letters.compiled.odd -xsl:$XSL/odd2relax.xsl -o:build/letters.rng

# 3. ISO Schematron を抽出
saxon -s:build/letters.compiled.odd -xsl:$XSL/extract-isosch.xsl -o:build/letters.sch

# 4. Schematron を実行可能な XSLT 3.0 に変換
saxon -s:build/letters.sch -xsl:tools/schxslt2/schxslt2-1.11.2/transpile.xsl \
      -o:build/letters.sch.xsl

(zsh に貼り付けるときは行継続の \ が落ちて引数が次行に渡らない事故があるので、実際に使うときは \ を外して 1 コマンド 1 行にしてください。)

defaultSource を指定しないと、変換のたびに tei-c.org から p5subset.xml を取りに行きます。ローカルに置いた方が速く、オフラインでも動きます。

手元で生成された成果物のサイズは次のとおりでした。

letters.compiled.odd   2.1M
letters.rng            494K  (10,968 行)
letters.sch             13K
letters.sch.xsl        100K

生成された letters.sch には 36 個の <pattern> が入っていました。自分で書いたのは 5 個だけで、残る 31 個は TEI P5 が Guidelines 自体に持っている制約です。

実際の id には schematron-constraint- という接頭辞が付きます。以下はそれを除いた部分です。

CMC_generatedBy_within_post-1
att-datable-w3c-when-2
att-datable-w3c-from-3
att-datable-w3c-to-4
only_1_ODD_source-5
targetLang-6
spanTo-points-to-following-7
...

@when は他の日付属性と併用できない」「@spanTo は後方を指していること」といった規則です。RELAX NG だけで検証していると、これらは一切チェックされません。ODD から Schematron を生成する価値は、自分で書いた規則よりむしろこちら側にあるかもしれません。

検証する

RELAX NG は jing、Schematron は生成した XSLT を Saxon で実行します。

jing build/letters.rng data/invalid.xml
saxon -s:data/invalid.xml -xsl:build/letters.sch.xsl -o:report.svrl

Schematron の出力は SVRL(Schematron Validation Report Language)という XML です。違反は 2 種類の要素で表現されます。

  • svrl:failed-assert<assert> の test が false だった箇所
  • svrl:successful-report<report> の test が true だった箇所

名前は対照的ですが、どちらも「直すべき箇所」です。

SVRL は違反箇所を XPath で示します。エディタが必要とするのは行番号なので、変換する必要があります。当初は XSLT(XSL Transformations)で書こうとしましたが、saxon:line-number() は Saxon-HE(Home Edition)では使えませんでした。

XPST0017  Cannot find a 1-argument function named Q{http://saxon.sf.net/}line-number().
Saxon extension functions are not available under Saxon-HE

Saxon の拡張関数は PE(Professional Edition)以上の機能です。代わりに、検証対象の XML を Python の expat で舐めて「要素パス → 行番号」の対応表を作り、SVRL の @location と突き合わせることにしました。標準ライブラリだけで済みます。

import xml.parsers.expat

def build_line_map(path):
    """要素パス (uri, local, index) のタプル列 → (行, 桁) の辞書を作る。"""
    line_map, stack, counters = {}, [], [{}]

    def start(name, _attrs):
        uri, _, local = name.rpartition("|") if "|" in name else ("", "", name)
        counters[-1][(uri, local)] = counters[-1].get((uri, local), 0) + 1
        stack.append((uri, local, counters[-1][(uri, local)]))
        counters.append({})
        line_map[tuple(stack)] = (parser.CurrentLineNumber,
                                  parser.CurrentColumnNumber + 1)

    def end(_name):
        counters.pop()
        stack.pop()

    parser = xml.parsers.expat.ParserCreate(namespace_separator="|")
    parser.StartElementHandler = start
    parser.EndElementHandler = end
    with open(path, "rb") as fh:
        parser.ParseFile(fh)
    return line_map

SVRL の @location は EQName(expanded QName、名前空間 URI を展開した修飾名)形式(/Q{http://www.tei-c.org/ns/1.0}TEI[1]/...)なので、正規表現で分解して同じ形のタプル列にすれば照合できます。

出力形式は jing に合わせて 絶対パス:行:桁: severity: メッセージ にしました。これで RELAX NG と Schematron の結果を同じ問題マッチャで拾えます。

検証結果

RELAX NG では妥当だが Schematron では 5 か所違反する、というサンプルを用意して実行しました。

── data/invalid.xml ──────────────────────────────────────
  ✅ RELAX NG: 構造は妥当
/path/to/data/invalid.xml:23:11: error: ISBN の書式が不正です: 「4-12-345678-X」 期待する形式: 978-4-12-345678-9 [schematron-constraint-isbn-format-35]
/path/to/data/invalid.xml:30:9: error: 日付の前後が逆です: @from=1911-12-31 / @to=1911-01-01 [schematron-constraint-date-range-order-36]
/path/to/data/invalid.xml:45:9: error: <pb> が <div> の先頭にあります。<div> の直前に移動してください。 (n=1) [schematron-constraint-pb-not-first-in-div-20]
/path/to/data/invalid.xml:50:15: error: 本文中の <persName> には @ref(人物典拠への参照)が必要です。対象: 「正岡子規」 [schematron-constraint-persName-needs-ref-30]
/path/to/data/invalid.xml:53:12: error: @ref の参照先が見つかりません: #soseki (<particDesc> に xml:id="soseki" の <person> を追加してください) [schematron-constraint-persName-ref-resolves-31]
  ❌ Schematron 違反(上記)

── data/valid.xml ──────────────────────────────────────
  ✅ RELAX NG: 構造は妥当
  ✅ Schematron: 制約もすべて満たしている

RELAX NG は両方のファイルを「妥当」と判定しています。5 つの違反はいずれも構造としては正しく、値や参照関係だけが誤っているためです。この差が Schematron を併用する理由そのものです。

VS Code に組み込む

RELAX NG はインラインで検証される

Red Hat の XML 拡張redhat.vscode-xml、内部は LemMinX)が RELAX NG に対応しています。公式ドキュメント上は experimental 扱い(v0.22.0 以降)ですが、TEI の RNG では問題なく動きました。

code --install-extension redhat.vscode-xml

TEI 文書の冒頭に <?xml-model?> 処理命令を書いておけば、拡張がそれを解決して構造エラーを波線で表示します。

<?xml-model href="../build/letters.rng" type="application/xml"
            schematypens="http://relaxng.org/ns/structure/1.0"?>

相対パスも解決されます。実際に効いているかを確かめるため、拡張が同梱している言語サーバ(LemMinX)を直接起動し、Language Server Protocol(LSP)の textDocument/didOpen を送って診断を受け取ってみました。エディタを開かずに「拡張が何を検証しているか」を確認できます。

<p> の中に <opener> を入れて構造を壊したファイルを渡すと、次のようになります。

$ ./scripts/lsp-probe.py probe/broken-structure.xml
diagnostics: 1 件
  L48:17 [xml/out_of_context_element] element "opener" not allowed here; expected
  the element end-tag, text or element "abbr", "add", "addName", "ad...

行番号・列番号とも正しく、生成した letters.rng が参照されていることが確認できました。

Schematron はタスクで回す

この拡張は Schematron には対応していません。対応要望は issue #451 として 2021 年に立てられて以来 open のままで、<?xml-model schematypens="http://purl.oclc.org/dsdl/schematron"?> を書いても無視されます。同じ言語サーバに、Schematron 制約に 5 か所違反するファイル(RELAX NG としては妥当)を渡すと、診断は 0 件でした。

$ ./scripts/lsp-probe.py data/invalid.xml
diagnostics: 0 件

Schematron の <?xml-model?> は oXygen XML Editor では効くので、書いておくこと自体に意味はあります。VS Code では別経路が必要、ということです。

代わりに、検証スクリプトを VS Code のタスクとして登録し、problemMatcher で結果を Problems パネルに流します。

{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "TEI: スキーマ生成 (ODD → RNG + SCH)",
      "type": "shell",
      "command": "./scripts/build-schema.zsh",
      "group": "build",
      "problemMatcher": []
    },
    {
      "label": "TEI: 開いているファイルを検証",
      "type": "shell",
      "command": "./scripts/validate.zsh '${relativeFile}'",
      "group": { "kind": "test", "isDefault": true },
      "problemMatcher": {
        "owner": "tei",
        "source": "jing / SchXslt2",
        "fileLocation": "absolute",
        "pattern": {
          "regexp": "^(/.+?):(\\d+):(\\d+):\\s+(error|warning|fatal):\\s+(.*)$",
          "file": 1, "line": 2, "column": 3, "severity": 4, "message": 5
        }
      }
    }
  ]
}

jing の出力も 絶対パス:行:桁: error: メッセージ 形式なので、この 1 つの正規表現で RELAX NG と Schematron の両方の違反を拾えます。Problems パネルの行をクリックすれば該当箇所にジャンプします。

.vscode/settings.json では、ODD ファイルの扱いを決めておきます。

{
  "files.associations": { "*.odd": "xml" },
  "xml.fileAssociations": [
    {
      "pattern": "odd/**.odd",
      "systemId": "https://www.tei-c.org/release/xml/tei/custom/schema/relaxng/tei_odds.rng"
    }
  ],
  "files.exclude": { "build": true, "tools": true }
}

拡張子 .odd は既定では XML と認識されないので明示します。ODD 自身も TEI 文書なので、tei_odds.rng を当てておくと ODD を書いている最中に構造エラーが出ます。

踏んだ落とし穴

<constraint> の直下に <assert> を書くと黙って消える

これが一番わかりにくい挙動でした。<sch:rule> を省略して <constraint> の直下に <sch:assert> を置くと、生成された .sch からその制約が消えます。

WARNING: Ignoring invalid sch:assert found directly within &lt;constraint&gt;.

警告は標準エラー出力に出るだけで、変換自体は成功します。終了コードも 0 です。生成された .sch を grep すると該当パターンは 0 件でした。

grep -c "persName-needs-ref" build/letters.sch
# → 0

<constraint> の中には必ず <sch:rule context="..."> を書き、context を自分で指定する必要があります(<sch:pattern> は省略でき、TEI Stylesheets が自動で補います)。ビルドスクリプトの出力を捨てずに眺めておく方が安全です。

これは Stylesheets の気まぐれではなく TEI の仕様です。TEI P5 の <constraintSpec> 自身が context-required という Schematron 制約を持っていて、「<sch:assert> / <sch:report>@context を持つ <sch:rule> の子孫でなければならない」と定めています。extract-isosch.xsl はそれを前提に、条件を満たさないものを警告つきで捨てているだけです。

やっかいなのは、<constraint> の内容モデルが (text | anyElement)* なので、ODD を RELAX NG で検証しても何も言われない点です。この規則自体は tei_odds.rng に Schematron として埋め込まれているのですが、後述の LemMinX は Schematron を実行しないため、VS Code 上では検知できません。ODD 自身の Schematron まで効かせたければ、oXygen を使うか、tei_odds.rng から Schematron を抽出して ODD にも同じパイプラインを通すことになります。

jing 内蔵の Schematron は使えない

jing は ISO Schematron のスキーマリーダを内蔵していますが(例外のスタックトレースに com.thaiopensource.validate.schematron.ISOSchemaReaderImpl が出てきます)、.sch を直接渡すと Saxon の初期化で落ちました。

Exception in thread "main" java.lang.IllegalArgumentException: Unknown XPath version 0
	at net.sf.saxon.Configuration.newExpressionParser(Configuration.java:2804)
	at com.thaiopensource.validate.schematron.ISOSchemaReaderImpl.createSchema(...)

最初は queryBinding="xslt2" が原因かと思いましたが、queryBinding を書かない最小の Schematron でも同じ例外が出るので、これは違いました。

== queryBinding なし
Exception in thread "main" java.lang.IllegalArgumentException: Unknown XPath version 0
== queryBinding="xslt2"
Exception in thread "main" java.lang.IllegalArgumentException: Unknown XPath version 0

jing 20241231 が同梱している Saxon は 9.3.0.4(jing-20241231/bin/saxon9.jar)で、この組み合わせでは ISO Schematron のリーダがそもそも動かないようです。付属ドキュメント(doc/jing.html)も「Schematron 1.5 に対応」という記述のまま止まっています。jing は RELAX NG 専用と割り切り、Schematron は SchXslt2 + Saxon で回すのが確実でした。

<particDesc> は corpus モジュール

人物一覧をヘッダに置こうとして、namesdates モジュールだけを取り込んだところ次のエラーになりました。

error: element "particDesc" not allowed anywhere; expected the element end-tag or
element "abstract", "calendarDesc", "correspDesc", "creation", "langUsage" or "textClass"

<listPerson><person> は namesdates ですが、それらを収める <particDesc> は corpus モジュールにあります。<moduleRef key="corpus"/> を足して解決しました。モジュールを絞ったカスタマイゼーションでは、要素そのものと「置き場所」が別モジュールに分かれている場合があります。

Saxon-HE には拡張関数がない

前述のとおり saxon:line-number() は使えません。SVRL から行番号を得たい場合は、Saxon-PE 以上を使うか、別の手段で解決する必要があります。SVRL 自体は XPath ロケーションを持っているので、情報が失われているわけではありません。

手元の構成

最終的なディレクトリ構成です。

schematron-odd/
├── .gitignore               build/ と tools/ を除外
├── README.md                セットアップ手順と注意点
├── .vscode/
│   ├── extensions.json      redhat.vscode-xml を推奨
│   ├── settings.json        .odd の関連付け・ODD 自身の検証
│   └── tasks.json           生成・検証タスク + problemMatcher
├── odd/
│   └── letters.odd          スキーマの正本(Schematron 込み)
├── data/
│   ├── valid.xml            すべての規則を満たすサンプル
│   └── invalid.xml          RELAX NG は通るが Schematron で 5 か所違反
├── probe/
│   └── broken-structure.xml 構造を壊したファイル(LSP 診断の確認用)
├── scripts/
│   ├── setup-tools.zsh      ツール取得(初回のみ)
│   ├── build-schema.zsh     ODD → RNG + SCH + SCH.XSL
│   ├── validate.zsh         jing + Saxon で検証
│   ├── svrl-report.py       SVRL → ファイル:行:桁 形式
│   └── lsp-probe.py         LemMinX に直接 LSP を喋って診断を確認
├── build/                   生成物(.gitignore)
└── tools/                   ダウンロードしたツール(.gitignore)

build/tools/.gitignore に入れています。setup-tools.zshbuild-schema.zsh で再現できるので、リポジトリに 2.1 MB の展開済み ODD を置く必要はありません(リポジトリは 16 ファイル・約 1,100 行に収まっています)。

validate.zsh は違反が 1 件でもあれば終了コード 1 を返すので、そのまま CI に載せられます。

動作を確認した環境

項目バージョン
macOSDarwin 25.5.0 (Apple Silicon)
OpenJDK25.0.2 (Homebrew)
Saxon-HE12.9 (Homebrew saxon)
jing20241231 (Homebrew jing-trang)
TEI Stylesheets7.61.0
SchXslt21.11.2
VS Code1.129.1
redhat.vscode-xml0.29.3 (LemMinX 0.31.2)

oXygen XML Editor があれば ODD の編集から RELAX NG / Schematron の生成・検証まで GUI で完結しますが、コマンドラインで組んでおくと CI に載せられること、どの工程で何が起きているかが見えることが利点になります。ODD に書いた <constraint> が最終的にどんな XSLT になるかまで追えるので、規則が意図どおりに効かないときの原因も絞りやすくなります。