diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,16 @@
 
 ### Bug fixes
 
+## 0.1.2 -- 2026-10-06
+
+### Bug fixes
+
+* Handle doxygen >= 1.18.0 anonymous struct/union compounds. Doxygen 1.18.0
+  emits a separate XML file for each anonymous compound instead of flattening
+  its fields into the enclosing struct. The parser now normalizes these compound
+  names so the `DoxygenKey` map is stable across doxygen versions.
+  ([hs-bindgen#2225](https://github.com/well-typed/hs-bindgen/issues/2225))
+
 ## 0.1.1 -- 2026-06-21
 
 ### New features
diff --git a/doxygen-parser.cabal b/doxygen-parser.cabal
--- a/doxygen-parser.cabal
+++ b/doxygen-parser.cabal
@@ -1,6 +1,6 @@
 cabal-version:   3.0
 name:            doxygen-parser
-version:         0.1.1
+version:         0.1.2
 license:         BSD-3-Clause
 license-file:    LICENSE
 copyright:       2024-2026 Well-Typed LLP and Anduril Industries Inc.
diff --git a/src-internal/Doxygen/Parser/Internal.hs b/src-internal/Doxygen/Parser/Internal.hs
--- a/src-internal/Doxygen/Parser/Internal.hs
+++ b/src-internal/Doxygen/Parser/Internal.hs
@@ -59,6 +59,7 @@
   , parseInlineChildren
   , normalizeWhitespace
   , trimEdges
+  , stripAnonymousSuffix
   , XMLFileResult(..)
   , extractEntity
   , nodeElementName
@@ -573,11 +574,15 @@
     -- For struct/union compounds, all members are fields of that struct.
     -- For other compounds (groups, files), fields are identified by their
     -- <qualifiedname> (e.g. "config_t::id" → KeyField "config_t" "id").
+    --
+    -- 'stripAnonymousSuffix' normalizes compound names so that doxygen
+    -- >= 1.18.0 anonymous-struct compounds (e.g. @outer::[struct].pos@)
+    -- produce the same keys as the flattened layout used by older versions.
     toFieldMap :: Text -> Maybe Text -> [MemberInfo] -> [(DoxygenKey, Comment DoxyRef)]
     toFieldMap k mEntityName ms
       | k `elem` ["struct", "union"]
       , Just sname <- mEntityName
-      = [(KeyField sname mi.miName, mi.miComment) | mi <- ms]
+      = [(KeyField (stripAnonymousSuffix sname) mi.miName, mi.miComment) | mi <- ms]
       | otherwise
       = [ (KeyField sname fname, mi.miComment)
         | mi <- ms
@@ -1187,6 +1192,42 @@
 isTitle c = case Cursor.node c of
     XML.NodeElement el -> XML.nameLocalName (XML.elementName el) == "title"
     _                  -> False
+
+-- | Strip the anonymous compound suffix added by doxygen >= 1.18.0.
+--
+-- Given the C declaration
+--
+-- @
+-- typedef struct {
+--     struct { int ua; int ub; } pos;
+-- } outer_t;
+-- @
+--
+-- doxygen < 1.18 flattened fields @ua@ and @ub@ into
+-- @structouter__t.xml@ and keyed them as @KeyField \"outer_t\" \"ua\"@.
+--
+-- doxygen >= 1.18 emits a separate @structouter__t_1_1...pos.xml@ with
+-- compound name @\"outer_t::[struct].pos\"@, so without normalization the
+-- fields would be keyed as @KeyField \"outer_t::[struct].pos\" \"ua\"@.
+--
+-- This function strips the suffix so the keys match the pre-1.18 layout:
+--
+-- @
+-- stripAnonymousSuffix \"outer_t::[struct].pos\"          == \"outer_t\"
+-- stripAnonymousSuffix \"outer_t::[struct].__unnamed0__\" == \"outer_t\"
+-- stripAnonymousSuffix \"a::b::[union].data\"             == \"a::b\"
+-- stripAnonymousSuffix \"plain_t\"                        == \"plain_t\"
+-- @
+--
+-- With doxygen < 1.18 these patterns never appear, so the function is
+-- the identity.
+stripAnonymousSuffix :: Text -> Text
+stripAnonymousSuffix name =
+    case Text.breakOn "::[struct]." name of
+      (before, after) | not (Text.null after) -> before
+      _ -> case Text.breakOn "::[union]." name of
+        (before, after) | not (Text.null after) -> before
+        _ -> name
 
 -- | Check if a @\<memberdef\>@ is a struct\/union field.
 --
diff --git a/test/Test/Doxygen/Parser/XMLFileResult.hs b/test/Test/Doxygen/Parser/XMLFileResult.hs
--- a/test/Test/Doxygen/Parser/XMLFileResult.hs
+++ b/test/Test/Doxygen/Parser/XMLFileResult.hs
@@ -115,4 +115,19 @@
         , "</doxygen>"
         ]) $ \result ->
           result.comments @?= Map.empty
+
+  , testCase "anonymous struct fields keyed under parent (doxygen >= 1.18)" $
+      withExtractedEntity
+        (mkDoxygen $ mkCompound "struct"
+            "structfoo_1_1anon" "foo_t::[struct].__unnamed0__" "" $
+            mkSection "public-attrib" $ Text.concat
+                [ mkMember "variable" "f_ua" "ua" "Unnamed inner a" ""
+                , mkMember "variable" "f_ub" "ub" "Unnamed inner b" ""
+                ]) $ \result -> do
+          assertBool "field ua keyed under parent" $
+            Map.member (KeyField "foo_t" "ua") result.comments
+          assertBool "field ub keyed under parent" $
+            Map.member (KeyField "foo_t" "ub") result.comments
+          assertBool "NOT keyed under anonymous compound" $
+            not (Map.member (KeyField "foo_t::[struct].__unnamed0__" "ua") result.comments)
   ]
