Skip to content

Migrate the TypeScript reader to fast-xml-parser v5 - #59

Open
kogai wants to merge 10 commits into
mainfrom
claude/fast-xml-parser-v5
Open

kogai wants to merge 10 commits into
mainfrom
claude/fast-xml-parser-v5

Conversation

@kogai

@kogai kogai commented Sep 13, 2026 •

Copy link
Copy Markdown
Owner

fast-xml-parser の脆弱性 2 件を塞ぎます。API の破壊的変更と出力の挙動変更を伴うため、#57 から切り出した PR です。

base は #57 (claude/update-node-toolchain-and-npm-deps)。さらにその下に #56 があります。

動機

fast-xml-parser  <=5.6.0  (moderate)
  - Prototype Pollution through tag or attribute name   (GHSA-x3cc-x39p-42qx)
  - XMLBuilder: XML Comment and CDATA Injection         (GHSA-gh4j-gqv2-49f6)

最初の修正版は 5.7.0 です。3.17.6 に留まる限り塞げません。

挙動が変わります(重要)

当初この PR は「出力はバイト単位で一致する」と説明していましたが、それは誤りでした。レビューで、検証に使った fixtures/20201200.onix にエンティティ参照が 1 つも含まれていない(& はすべて CDATA の内側)ことが判明しました。一致は、差が出る条件を入力が満たしていなかったから成立していたにすぎません。

v3            : {"r":{"t":"Marks &amp; Spencer"}}
v5            : {"r":{"t":"Marks & Spencer"}}

v3 は &amp; を展開せず文字列のまま返し、v5 はデコードします。ONIX の実データでは書名や著者略歴に &amp; が日常的に現れるため、利用者が確実に踏む差です。

processEntities: false で v3 を再現する道は採りません

v3 の挙動は仕様として正しくありません。&amp; は & のエスケープであり、展開せずに返すのは利用者に二重デコードを強いることです。後方互換性はこのリポジトリの中心ですが、それは間違った出力を永続させることではありません。差分を消すために processEntities: false を指定すると、脆弱性の修正だけを取り込んで、同じリリースのバグ修正を打ち消すことになります。

判断とその理由は ADR-0005 に記録しました。TypeScript サポートが README 上まだ "Not yet" 段階であることも判断材料です。

変更内容

const parser = new XMLParser({ ignoreDeclaration: true, ignorePiTags: true });
  • ignoreDeclaration: v5 は XML 宣言を ?xml キーとして返すため抑制
  • ignorePiTags: 宣言以外の処理命令も ?name キーで漏れるため抑制(レビュー指摘)

npm audit は found 0 vulnerabilities になりました。

generated/ を手で書き換えている点

このファイルに限っては生成器が恒等写像です。reader テンプレートに mustache タグは 1 つも無く、Lib.hs も substitute t () とスキーマを渡さずに描画します((Right t, Reader) -> unpack $ substitute t ())。テンプレートと生成物はバイト一致し、再生成した場合の出力と同一です。再生成自体はこの環境では実行できません(ADR-0003 / #58)。

既知の未解決問題(この PR の範囲外)

  • "01" のような先頭ゼロの ONIX コードが数値 1 に変換される。v3/v5 どちらでも同じ挙動で、この移行が持ち込む問題ではありませんが実データを壊すバグです。ADR-0005 に記録し、別 PR で対応します
  • 17 桁以上の数値は v3 が number(精度欠落)、v5 が string。ISBN-13 / GTIN-13 は 13 桁なので影響なし
  • TypeScript reader は CI で一切実行されていません。今回の比較は一度きりの手作業で、回帰を検出する仕組みはありません

🤖 Generated with Claude Code

https://claude.ai/code/session_01P8rZakwAiz1jpViUX34x1Z

kogai and others added 8 commits September 13, 2026 06:49
Both CI jobs currently fail two seconds after starting, before any build
step runs:

    ##[error]This request has been automatically failed because it uses a
    deprecated version of `actions/cache: v2`.

GitHub has closed down actions/cache v1 and v2 and now auto-fails runs
that reference them at action-download time, so the workflow cannot get
as far as `make test` or the bazel e2e target.

Bump actions/cache to v4 to unblock that, and bump the remaining actions
still on the Node 12 runtime at the same time:

- actions/checkout v2 -> v4
- actions/setup-node v2.1.4 -> v4
- haskell/actions/setup v1 -> haskell-actions/setup v2 (repository moved)

Tool versions are left as they are (GHC 8.8.3, stack 2.5.1, Node 12.x) so
this change is limited to action versions. haskell-actions/setup v2 still
exposes the stack-path output the cache step depends on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8rZakwAiz1jpViUX34x1Z
With the deprecated actions bumped, the Haskell job now reaches `make
test` and fails there instead: `make test` depends on the `schema`
target, which fetches the EDItEUR archives, and editeur.org now answers
bazel's request with `202 Accepted` instead of the zip.

The test suite does not need those archives. Everything under test/ reads
fixtures/test_*.xsd; the only reader of ./schema is schemaRoot in
src/Lib.hs, which is the code-generation path. The dependency was an
over-specification that tied the whole feedback loop for the parser to
the availability of an external service.

Drop it from `test` and keep it on `build`, where generating code really
does need the schema. This does not fix the 202 itself: generation and
tracking new schema releases still need the download.

Also introduce docs/adr/ to record decisions like this one, with a
template, an index, and the two decisions made here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8rZakwAiz1jpViUX34x1Z
- @bazel/bazelisk 1.7.3 -> 1.28.1
- @types/node 14.14.25 -> 22.20.2

fast-xml-parser is deliberately left at 3.17.6 here. It has two open
advisories (GHSA-x3cc-x39p-42qx, GHSA-gh4j-gqv2-49f6) that are only fixed
in 5.6.1+, and that major bump changes the parser API the TypeScript
reader template uses, so it needs its own change with the template
migration.

The lockfile is regenerated with --lockfile-version 1 rather than being
allowed to move to v3. rules_nodejs 3.1.0 runs npm_install with its own
bundled npm, and the CI job still runs Node 12.x; neither reads a v3
lockfile's `packages` key, so they would silently re-resolve instead of
honouring the lock. The lockfile version can move once the Node
toolchain and rules_nodejs are updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8rZakwAiz1jpViUX34x1Z
The previous commit dropped the `schema` prerequisite from `make test` on
the claim that nothing under test/ reads schema/. That claim was wrong,
and review caught it: fixtures/test_mixed_html.xsd included
../schema/v2/ONIX_XHTML_Subset.xsd directly. Xsd.getSchema follows
includes recursively and resolves that relative path with readFile, which
throws when the file is absent, and schema/ is gitignored — so on a clean
checkout the suite would have failed instead of running.

Commit 9352123 confirms the dependency was deliberate: it added
`test: schema` in the same change that deleted the vendored
2_1_rev03_schema/ tree and repointed this fixture at ../schema/v2/.

Rather than restore the prerequisite, move what the fixture needs into
the repository. test_mixed_html_xhtml_subset.xsd declares the 40 element
names the fixture refers to, each as a mixed complex type. That is the
property the assertions actually rest on: TestModel expects
Model.collectElements to come back empty, and that filter keeps only
elements with complexMixed = False, so the test means "XHTML elements do
not leak into models". Deleting the include instead would have made the
assertion vacuous.

The stand-in is written here rather than copied from the EDItEUR
distribution, so it raises no redistribution question.

ADR-0002 is rewritten around what is actually true, including why the
dependency was real and why a stand-in is enough.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8rZakwAiz1jpViUX34x1Z
Keeps this stacked branch's CI running the corrected test suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8rZakwAiz1jpViUX34x1Z
3.17.6 carries two open advisories (GHSA-x3cc-x39p-42qx prototype
pollution via tag or attribute name, GHSA-gh4j-gqv2-49f6 comment and
CDATA injection). Both are fixed only from 5.6.1, and the major bump
replaces the module-level `parse` with an `XMLParser` instance, so the
reader template has to move with it.

v5 reports the XML declaration as a top-level `?xml` key, which v3 did
not, so the parsed object would have gained a key the ONIXMessage type
does not describe. Passing ignoreDeclaration restores the previous shape.
Checked by parsing fixtures/20201200.onix with both versions installed
side by side and comparing the serialised results: v3 and v5 differ by
default (53110 vs 53124 bytes, `?xml` present only in v5) and are
byte-identical with ignoreDeclaration set. `npm audit` now reports 0
vulnerabilities.

generated/typescript/v2/reader.ts is updated alongside the template. The
reader templates contain no mustache tags, so the generator copies them
through unchanged and the two files are byte-identical by construction —
verified with diff both before and after this change. That keeps the
committed output the same as what regenerating would produce, which
cannot be run here because it needs the EDItEUR schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8rZakwAiz1jpViUX34x1Z
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8rZakwAiz1jpViUX34x1Z
Review pointed out that nothing actually enforced keeping the lockfile at
version 1: the previous commit produced it by passing
--lockfile-version 1 by hand, so the next plain `npm install` on a modern
npm would have quietly rewritten it to v3. An .npmrc makes the format a
property of the repository instead of of whoever ran the command.
Verified: with this file, `npm install` on npm 10.9.7 leaves
package-lock.json unchanged at v1; without it the same command rewrites
it to v3.

node_modules/ was never listed in .gitignore, which makes committing it
by accident easy once anyone runs npm install in a checkout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8rZakwAiz1jpViUX34x1Z
@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown

レビュー結果

移行そのものの作り方は正しいです。ignoreDeclaration が本当に必要であること(付けないと ?xml キーが増える)、テンプレートと生成物がバイト単位で同一で src/Lib.hs が Reader を substitute t () = スキーマデータなしでレンダリングしていること、lock ファイルが registry の実物と integrity まで完全一致すること、リポジトリ内に v3 API や古い peer range の残存がないこと、tsconfig.json で型チェックが通ることは、すべて手元で再現・確認できました。fixtures/20201200.onix のパリティも再現でき、v3 デフォルトと v5 + ignoreDeclaration の出力は 37342 バイト・同一ハッシュで完全一致します。ただし「出力が同一」という主張が成立するのはこの 1 ファイルに対してだけです。このフィクスチャには XML 実体参照が 1 つも含まれておらず(& はすべて CDATA の中)、v5 は v3 と違って &amp; をデコードします。この差はフィクスチャでは原理的に検出できません。この 1 点の扱いを決めていただければ、他は推奨レベルの調整です。


必須

1. 実体参照のデコード差分 — template/typescript/v2/reader.mustache / generated/typescript/v2/reader.ts

v3 はデフォルトで実体参照をデコードせず、v5 は processEntities: true がデフォルトでデコードします。

入力: <r><b203>Ann &amp; Bob&apos;s &lt;Guide&gt;</b203></r>
v3 : {"r":{"b203":"Ann &amp; Bob&apos;s &lt;Guide&gt;"}}
v5 : {"r":{"b203":"Ann & Bob's <Guide>"}}

fixtures/20201200.onix に実体参照は 0 件(grep -c '&[A-Za-z#][A-Za-z0-9]*;' → 0)なので、今回の検証では出ません。しかし実運用の ONIX は書名・著者略歴・件名に &amp; を多用するため、生成された reader を使っている下流のコードは全員この差分を踏みます。

なぜ問題か: v5 の挙動のほうが XML 仕様として正しく、v3 のほうがバグでした。つまり直すべきではありません。問題は、PR 本文が「出力はバイト単位で同一」と書いていることで、これは事実上の破壊的変更を隠してしまっています。

修正案(どちらか):

  • 推奨: processEntities はデフォルトのまま(= デコードする)にし、PR 本文の「同一」という主張を「このフィクスチャでは同一。ただし実体参照のデコードは v3 のバグ修正として意図的に挙動が変わる」に書き換える。reader のコメントにも 1 行追記する。
  • 厳密なパリティを優先するなら new XMLParser({ ignoreDeclaration: true, processEntities: false })。手元で fixtures/20201200.onix 以外の実体参照ケースも v3 と完全一致することを確認済みですが、既知のバグを維持することになるので非推奨です。

推奨

2. ignoreDeclaration は XML 宣言しか止めない — 他の PI が漏れる

入力: <?xml version="1.0"?><?pi x?><r><a>1</a></r>
v3                             : {"r":{"a":1}}
v5 + ignoreDeclaration         : {"?pi":"","r":{"a":1}}   ← 余分なキー
v5 + ignorePiTags:true         : {"r":{"a":1}}            ← 一致(宣言も同時に消える)

ONIX に PI が入ることは稀ですが、送信側が挿入すると ONIXMessage に想定外のキーが増えます。ignorePiTags: true は XML 宣言も同時に処理するので、{ ignoreDeclaration: true, ignorePiTags: true } にしておくのが安全です(ignorePiTags 単独でも ?xml は消えます)。

3. 数値変換の差分(先頭ゼロは無害、桁数と e 記法は差が出る)

まず結論として、ONIX コードの "01" / "02" は v3 も v5 も同じく数値 1 / 2 に変換します。ここに差はなく、この PR による退行はありません(v3 は parseTrueNumberOnly: false、v5 は numberParseOptions.leadingZeros: true がそれぞれデフォルト)。なお両バージョンとも ONIX コードを数値に潰してしまうのは既存の設計上の問題で、本 PR の範囲外です。

差が出るのは以下です:

入力 v3 v5
12345678901234567(17 桁) 12345678901234568(number, 精度欠落) "12345678901234567"(string)
1e3 1 1000
Infinity null "Infinity"

16 桁までは一致するので ISBN-13 / GTIN-13(9781680506365)は影響なし。ただし 17 桁以上で number → string と型が変わるため、独自の長い識別子を持つデータでは下流の typeof 判定が壊れる可能性があります。挙動としては v5 のほうが正しい(精度を失わない)ので、修正するというより PR 本文に「17 桁以上の数値は number ではなく string になる」と明記しておくのが妥当です。

4. CI の Node が 12.x のまま — .github/workflows/test.yml

node-version: 12.x は本 PR でも #57 でも変わっていません。fast-xml-parser@5.11.1 は engines を宣言していないため npm install は警告すら出しませんが、実装は nullish coalescing (??) を使っており Node 14 未満では読み込み時に SyntaxError になります(upstream は Node 18+ を前提)。CI の e2e ジョブは npm install の後 //e2e/go:snapshot_test(Go 側)しか回さず TS reader を実行しないため CI は緑のままですが、この PR は peerDependencies を ^5.11.1 に上げており、生成コードの利用者に対する Node の下限を実質的に引き上げています。node-version を 18.x 以上に上げるか、下限を README/package.json の engines に明記してください。

5. TS reader を触るテストが 1 つもない

CI の e2e は //e2e/go:snapshot_test だけで、generated/typescript/v2/reader.ts はビルドも実行もされません。今回のパリティは手動で 1 回確認されただけで、以後は誰も再検証しません。Go 側と同じ形(fixtures/20201200.onix を読んで fixtures/20201200.json と突き合わせるスナップショットテスト)を TS 側にも 1 本追加しておくと、次にパーサを上げるときにこの手作業が不要になります。上の 1〜3 の差分も、実体参照を含むフィクスチャを 1 つ足せば自動で守れます。


任意

6. v5 の新しい上限で例外になるケースがある

  • ネスト 100 段超で Maximum nested tags exceeded を throw(v3 は上限なしでパース成功)。maxNestedTags のデフォルトが 100 です。ONIX 2.1 のネストは 10 段未満なので実害はまず出ませんが、v3 で「成功」だったものが v5 で「例外」になる方向の変化です。
  • <__proto__> などの予約名タグで [SECURITY] Invalid name: ... を throw(v3 は {"r":""})。ONIX のタグ名は b221 形式なので到達しません。むしろセキュリティ上の改善です。

いずれも修正不要ですが、把握しておく価値はあります。

7. 混在コンテンツでのキー順が変わる

入力: <r><a>text<b>1</b>tail</a></r>
v3 : {"r":{"a":{"#text":"texttail","b":1}}}
v5 : {"r":{"a":{"b":1,"#text":"texttail"}}}

deep equal では同じで、#text の位置だけが違います。JSON.stringify の結果を突き合わせるスナップショットテストを書くときだけ効いてきます。

8. try { ... } catch (error) { throw error }

指摘するに値はしますが、優先度は低いです。この try/catch は完全な no-op で、書かなくても呼び出し側から見た挙動は 1 ビットも変わりません。本 PR が持ち込んだものではなく元からあるコードなので、この PR で必ず消すべきとは思いません。ただ reader を触るのは今回が良い機会ではあります。消す場合はテンプレートと生成物の両方を同時に直してください(下記のとおり 2 つは完全に同一である必要があります)。

9. 既存の別問題(本 PR の範囲外、参考まで)

  • fixtures/20201200.onix は encoding="ISO-8859-1" を宣言していますが、reader は file.toString() で常に UTF-8 として解釈します。今回のフィクスチャは ASCII なので問題になりませんが、アクセント付き文字を含む実データでは化けます。
  • generated/typescript/v2/model.ts の interface はすべて空(export interface ONIXMessage {})なので、parsed as ONIXMessage は型として何も保証していません。上記 1〜3 の型・値の変化を TypeScript は一切検出できない状態です。

検証した内容

すべてリポジトリ外の一時ディレクトリで、v3 と v5 を並列インストールして実施しました。

A. フィクスチャのパリティ(再現できました)

$ npm i fast-xml-parser@3.17.6   # v3/
$ npm i fast-xml-parser@5.11.1   # v5/
$ node cmp.js fixtures/20201200.onix
v3@3.17.6 default  : bytes=37342 sha256=41d4f3090dd31c21
v5@5.11.1 ignoreDec: bytes=37342 sha256=41d4f3090dd31c21
identical: true

(JSON.stringify をインデントなしで比較。インデント 2 で比較した場合は両者 53110 バイトで、こちらも diff の差分ゼロ。)

ignoreDeclaration が実際に必要であることも確認:

v5 WITHOUT ignoreDeclaration identical to v3? false
  v5 top-level keys: [ '?xml', 'ONIXmessage' ]

B. フィクスチャにない入力での網羅比較(15 ケース)

SAME = v3 と v5 + ignoreDeclaration の JSON.stringify が完全一致。

SAME  arrays        兄弟要素の繰り返し(配列化)、単一要素、ネストした繰り返し
SAME  attrs         textcase="02" language="eng"(両者とも ignoreAttributes デフォルト true で破棄)
SAME  boolattr      値なし属性
SAME  cdata         CDATA、CDATA 内の生の & と <p><b>、前後に空白のある CDATA
SAME  comments      <!-- ... -->
SAME  doctype       外部 DTD 参照つき DOCTYPE
SAME  selfclose     <n338 />, <n339/>, <n340></n340>, <n341> </n341>
SAME  whitespace    前後空白、複数行、タブ(両者とも trimValues デフォルト true)
SAME  ns            名前空間つきタグ
SAME  nodecl        XML 宣言なし
SAME  内部実体サブセット  <!DOCTYPE r [<!ENTITY co "...">]>(両者とも展開せず "&co;")
SAME  BOM
SAME  閉じ忘れタグ
DIFF  entities      → 上記 必須 1
DIFF  pi            → 上記 推奨 2
DIFF  numbers       → 上記 推奨 3(先頭ゼロは一致、17 桁以上 / e 記法 / Infinity が不一致)
DIFF  mixed         → 上記 任意 7(キー順のみ)

先頭ゼロについて実データで確認した結果(v3 / v5 とも完全同一):

"m185": 1        ← <m185>01</m185>
"a002": 2        ← <a002>02</a002>
"b221": 2, 3, 14, 15
"a001": 62124983 ← <a001>062124983</a001>

C. テンプレートと生成物の同一性(主張どおりでした)

$ git show origin/claude/fast-xml-parser-v5:template/typescript/v2/reader.mustache > tpl.txt
$ git show origin/claude/fast-xml-parser-v5:generated/typescript/v2/reader.ts      > gen.txt
$ cmp tpl.txt gen.txt && echo "BYTE IDENTICAL"
BYTE IDENTICAL
$ wc -c tpl.txt gen.txt
 582 tpl.txt
 582 gen.txt
$ grep -n "{{" tpl.txt
(該当なし)

src/Lib.hs 側も確認しました。compile の分岐が (Right t, Reader) -> unpack $ substitute t () となっており、Reader だけはスキーマデータを一切渡さず unit でレンダリングしています。したがってテンプレートに mustache タグがない限り、生成物はテンプレートのバイト単位のコピーになり、手編集は再生成結果と一致します。

D. v3 と v5 のデフォルトオプション比較

v5 の src/xmlparser/OptionsBuilder.js の defaultOptions を直接読んで v3 と突き合わせました。ignoreAttributes: true / trimValues: true / allowBooleanAttributes: false / parseAttributeValue: false は両者一致。parseNodeValue(v3)は parseTagValue(v5)に改名されただけでデフォルト true のまま。parseTrueNumberOnly: false(v3)に対応するのが numberParseOptions: { hex: true, leadingZeros: true, eNotation: true }(v5)で、leadingZeros が原因で先頭ゼロの挙動は一致、eNotation が原因で e 記法だけ差が出ます。v5 で新規追加され v3 に対応物がないのは ignoreDeclaration / ignorePiTags / maxNestedTags / strictReservedNames / onDangerousProperty / processEntities です。

E. リポジトリ全体の残存チェック(クリーンでした)

$ git grep -n -i "fast-xml-parser\|xml.parse\|XMLParser\|parseNodeValue\|parseTagValue" origin/claude/fast-xml-parser-v5
generated/typescript/v2/reader.ts:2:  import { XMLParser } from "fast-xml-parser";
generated/typescript/v2/reader.ts:7:  const parser = new XMLParser({ ignoreDeclaration: true });
package.json:13:    "fast-xml-parser": "5.11.1"
package.json:16:    "fast-xml-parser": "^5.11.1"
template/typescript/v2/reader.mustache:2:  import { XMLParser } from "fast-xml-parser";
template/typescript/v2/reader.mustache:7:  const parser = new XMLParser({ ignoreDeclaration: true });

v3 API の残りも古い peer range もありません。Go 側の reader / template は XML パーサに依存しないため無関係です。

F. lock ファイル

追加された 7 パッケージ(@nodable/entities@3.0.0, anynum@1.0.1, fast-xml-builder@1.3.1, is-unsafe@2.0.2, path-expression-matcher@1.6.2, strnum@2.4.2, xml-naming@0.3.0)は、実際に registry から入れた依存ツリーと過不足なく一致し、integrity ハッシュも 8 件すべて完全一致しました。lockfileVersion: 1 の形式も既存エントリと揃っています。

G. 型チェック

リポジトリの tsconfig.json(target: es5, module: commonjs, strict: true, esModuleInterop: true)に generated/typescript/v2/{reader,model}.ts と fast-xml-parser@5.11.1 + typescript@5 + @types/node@22 を置いて実行:

$ tsc --noEmit -p .
(出力なし、exit=0)

fast-xml-parser v5 は "type": "module" ですが exports の require 条件に ./lib/fxp.cjs と ./lib/fxp.d.cts があるため、CommonJS + 名前付き import でも解決できています。


検証できなかったこと

  • make test(Haskell 側)と実際の再生成の実行: このクローンは他のエージェントと共有しており作業ツリーを変更しない制約があるため、stack build / stack exec onix-exe を回して生成物を再生成し generated/typescript/v2/reader.ts と突き合わせることはしていません。代わりに C のとおり src/Lib.hs の読解 + テンプレートと生成物のバイト比較で間接的に確認しました。本 PR は Haskell コードを一切変更していないので、Haskell テストへの影響はないはずです。
  • Node 12 での実挙動: 手元は Node v22 のみで、Node 12 で require("fast-xml-parser") が実際に SyntaxError になるかは実行確認していません。lib/fxp.cjs と src/xmlparser/OptionsBuilder.js に ?? が含まれること(Node 14+ が必要)をソース上で確認したうえでの指摘です。
  • v3 → v5 の全オプション差分の網羅: 実際に比較したのは上記 B の 15 ケースと D で読んだデフォルト値です。ONIX 2.1 の全要素形状を尽くしたわけではありません。

Generated by Claude Code

Review showed the "byte-identical output" claim was vacuous. Parity held
on fixtures/20201200.onix because that file contains no entity references
at all — every & in it sits inside CDATA. v3 returns "&amp;" unexpanded
where v5 decodes it, and real ONIX data uses &amp; constantly in titles
and author biographies, so consumers of the generated reader will see the
difference.

Keep v5's behaviour rather than pinning processEntities: false. v3 was
wrong — a parser that hands back the escape instead of the character
forces callers to decode twice — and suppressing that fix to preserve a
byte-for-byte diff would take the security release while cancelling the
bug fix shipped with it. ADR-0005 records the decision and the reasoning.

ignoreDeclaration only hides the XML declaration; any other processing
instruction still came through as a `?name` key. Add ignorePiTags so the
parsed shape stays limited to the document's own elements. Reproduced
both differences directly:

  v3        {"r":{"t":"Marks &amp; Spencer"}}
  v5        {"r":{"t":"Marks & Spencer","?pi":""}}
  v5 +flag  {"r":{"t":"Marks & Spencer"}}

The ADR also records two things this change does not address: 17-digit
and longer numbers switch from number to string, and leading-zero ONIX
codes like "01" are coerced to 1 by both versions alike — a real bug, but
not one this migration introduces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8rZakwAiz1jpViUX34x1Z

kogai commented Sep 13, 2026

Copy link
Copy Markdown
Owner Author

レビューありがとうございます。必須 1 件と推奨 1 件を反映しました (fc66629)。特に必須の指摘は、こちらの検証設計の欠陥を突いています。

必須: 「バイト一致」は空振りで成立していた

ご指摘のとおりです。fixtures/20201200.onix にはエンティティ参照が 1 つも含まれていません(& はすべて CDATA の内側)。つまり一致は「差が出る条件を入力が満たしていなかった」から成立していただけで、検証として機能していませんでした。1 ファイルでの一致をもって「挙動は変わらない」と結論づけたのは誤りです。

再現しました。

v3            : {"r":{"t":"Marks &amp; Spencer","u":"caf&#233;"}}
v5 ignoreDecl : {"r":{"t":"Marks & Spencer","u":"caf&#233;","?pi":""}}

ONIX の実データでは書名や著者略歴に &amp; が日常的に現れるので、これは「理論上あり得る差」ではなく利用者が確実に踏む差です。

対応方針: processEntities: false にはしない

ご提案どおり、v5 の挙動を受け入れます。v3 の挙動は仕様として正しくありません。XML において &amp; は & のエスケープであり、パーサが展開せずに返すのは利用者に二重デコードを強いることになります。

後方互換性はこのリポジトリの中心的な価値ですが、それは間違った出力を永続させることではありません。processEntities: false を指定すれば差分は消えますが、それは「脆弱性の修正だけを取り込み、同じリリースに含まれるバグ修正を明示的に打ち消す」という選択になります。"Marks &amp; Spencer" が返り続けることに利用者側の価値はありません。

ただし黙って変えてよい話ではないので、ADR-0005 として明示的に記録しました。TypeScript サポートが README 上まだ "Not yet" 段階であることも判断材料にしています。PR 本文の「バイト一致」という説明も書き直します。

推奨: ignorePiTags

追加しました。ignoreDeclaration は XML 宣言しか抑えないという指摘のとおりで、上の再現でも ?pi が漏れていました。両方指定して、パース結果を文書自身の要素だけに限定します。

その他

  • 17 桁以上の数値の型変化 (number → string): ADR-0005 の「結果」に記録しました。ISBN-13 / GTIN-13 は 13 桁なので影響しない、という切り分けまで含めていただき助かりました。
  • 先頭ゼロの ONIX コードが "01" → 1 に壊れる: これが v3/v5 どちらでも同じ、という検証は重要でした。移行が持ち込む問題ではないので今回の範囲外としますが、実データを壊す実バグなので ADR-0005 に既知の問題として明記し、別 PR で対応します(parseTagValue: false 相当が要ると見ています)。
  • CI が TS reader を一切実行していない: そのとおりで、今回の比較は一度きりの手作業です。回帰検出の仕組みが無いこと自体も ADR に記録しました。
  • node-version: 12.x と fxp の ?? 構文: ツールチェーン更新の PR で Node のバージョンを上げる際に扱います。
  • try/catch の no-op、UTF-8 と ISO-8859-1 の不一致、model.ts の空 interface: いずれも既存の問題で、この PR の範囲外とします。特に最後の点(export interface ONIXMessage {} なので as ONIXMessage が何も保証しない)は、TypeScript サポートを実用段階に進めるときの本丸だと思います。

Generated by Claude Code

Without .npmrc on this branch, a plain npm install rewrites the lockfile
to version 3, which is exactly what that file exists to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8rZakwAiz1jpViUX34x1Z
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants