diff --git a/.lint-ignore.json b/.lint-ignore.json
new file mode 100644
index 0000000000..33742201b5
--- /dev/null
+++ b/.lint-ignore.json
@@ -0,0 +1,335 @@
+{
+ "v10": [
+ "add-bot",
+ "add-members-to-conversation",
+ "create-conversation-code-unqualified",
+ "create-group-conversation",
+ "create-one-to-one-conversation",
+ "create-self-conversation",
+ "get-conversation",
+ "get-mls-self-conversation",
+ "get-one-to-one-mls-conversation",
+ "get-user-clients-qualified",
+ "join-conversation-by-code-unqualified",
+ "mls-commit-bundle",
+ "mls-message",
+ "put_CellsConfig",
+ "remove-bot",
+ "remove-code-unqualified",
+ "remove-member",
+ "update-channel-add-permission",
+ "update-client",
+ "update-conversation-access",
+ "update-conversation-message-timer",
+ "update-conversation-name",
+ "update-conversation-protocol",
+ "update-conversation-receipt-mode"
+ ],
+ "v11": [
+ "add-bot",
+ "add-members-to-conversation",
+ "create-conversation-code-unqualified",
+ "create-group-conversation",
+ "create-one-to-one-conversation",
+ "create-self-conversation",
+ "get-conversation",
+ "get-mls-self-conversation",
+ "get-one-to-one-mls-conversation",
+ "get-user-clients-qualified",
+ "join-conversation-by-code-unqualified",
+ "mls-commit-bundle",
+ "mls-message",
+ "put_CellsConfig",
+ "remove-bot",
+ "remove-code-unqualified",
+ "remove-member",
+ "update-channel-add-permission",
+ "update-client",
+ "update-conversation-access",
+ "update-conversation-message-timer",
+ "update-conversation-name",
+ "update-conversation-protocol",
+ "update-conversation-receipt-mode"
+ ],
+ "v12": [
+ "add-bot",
+ "add-members-to-conversation",
+ "create-app",
+ "create-conversation-code-unqualified",
+ "create-group-conversation",
+ "create-one-to-one-conversation",
+ "create-self-conversation",
+ "get-conversation",
+ "get-mls-self-conversation",
+ "get-one-to-one-mls-conversation",
+ "get-user-clients-qualified",
+ "join-conversation-by-code-unqualified",
+ "mls-commit-bundle",
+ "mls-message",
+ "put_CellsConfig",
+ "remove-bot",
+ "remove-code-unqualified",
+ "remove-member",
+ "search-channels",
+ "update-channel-add-permission",
+ "update-client",
+ "update-conversation-access",
+ "update-conversation-message-timer",
+ "update-conversation-name",
+ "update-conversation-protocol",
+ "update-conversation-receipt-mode"
+ ],
+ "v13": [
+ "add-bot",
+ "add-members-to-conversation",
+ "create-app",
+ "create-conversation-code-unqualified",
+ "create-group-conversation",
+ "create-one-to-one-conversation",
+ "create-self-conversation",
+ "get-conversation",
+ "get-mls-self-conversation",
+ "get-one-to-one-mls-conversation",
+ "get-user-clients-qualified",
+ "join-conversation-by-code-unqualified",
+ "put_CellsConfig",
+ "remove-bot",
+ "remove-code-unqualified",
+ "remove-member",
+ "update-channel-add-permission",
+ "update-client",
+ "update-conversation-access",
+ "update-conversation-message-timer",
+ "update-conversation-name",
+ "update-conversation-protocol",
+ "update-conversation-receipt-mode"
+ ],
+ "v14": [
+ "add-bot",
+ "add-members-to-conversation",
+ "create-app",
+ "create-conversation-code-unqualified",
+ "create-group-conversation",
+ "create-one-to-one-conversation",
+ "create-self-conversation",
+ "get-app",
+ "get-conversation",
+ "get-mls-self-conversation",
+ "get-one-to-one-mls-conversation",
+ "get-user-clients-qualified",
+ "join-conversation-by-code-unqualified",
+ "remove-bot",
+ "remove-code-unqualified",
+ "remove-member",
+ "update-channel-add-permission",
+ "update-client",
+ "update-conversation-access",
+ "update-conversation-message-timer",
+ "update-conversation-name",
+ "update-conversation-protocol",
+ "update-conversation-receipt-mode"
+ ],
+ "v15": [
+ "get-app",
+ "get-user-clients-qualified"
+ ],
+ "v5": [
+ "DELETE /conversations/{_}/bots/{_}",
+ "DELETE /conversations/{_}/code",
+ "DELETE /conversations/{_}/{_}/members/{_}/{_}",
+ "DELETE /oauth/applications/{_}",
+ "DELETE /self/phone",
+ "GET /",
+ "GET /bot/client",
+ "GET /calls/config",
+ "GET /clients/{_}",
+ "GET /clients/{_}/capabilities",
+ "GET /conversations/one2one/{_}/{_}",
+ "GET /conversations/{_}/self",
+ "GET /providers/{_}/services/{_}",
+ "GET /sso/initiate-login/{_}",
+ "GET /users/{_}/{_}/clients",
+ "HEAD /users/handles/{_}",
+ "POST /activate/send",
+ "POST /assets",
+ "POST /bot/assets",
+ "POST /bot/messages",
+ "POST /bot/users/prekeys",
+ "POST /broadcast/otr/messages",
+ "POST /broadcast/proteus/messages",
+ "POST /clients",
+ "POST /connections/{_}/{_}",
+ "POST /conversations",
+ "POST /conversations/join",
+ "POST /conversations/one2one",
+ "POST /conversations/{_}/bots",
+ "POST /conversations/{_}/code",
+ "POST /conversations/{_}/otr/messages",
+ "POST /conversations/{_}/{_}/members",
+ "POST /conversations/{_}/{_}/proteus/messages",
+ "POST /login/send",
+ "POST /mls/commit-bundles",
+ "POST /mls/messages",
+ "POST /onboarding/v3",
+ "POST /password-reset/{_}",
+ "POST /provider/assets",
+ "POST /push/tokens",
+ "POST /register",
+ "POST /teams/{_}/invitations",
+ "POST /teams/{_}/legalhold/{_}",
+ "POST /users/handles",
+ "PUT /clients/{_}",
+ "PUT /connections/{_}/{_}",
+ "PUT /conversations/{_}",
+ "PUT /conversations/{_}/members/{_}",
+ "PUT /conversations/{_}/message-timer",
+ "PUT /conversations/{_}/name",
+ "PUT /conversations/{_}/receipt-mode",
+ "PUT /conversations/{_}/self",
+ "PUT /conversations/{_}/{_}/access",
+ "PUT /conversations/{_}/{_}/message-timer",
+ "PUT /conversations/{_}/{_}/name",
+ "PUT /conversations/{_}/{_}/protocol",
+ "PUT /conversations/{_}/{_}/receipt-mode",
+ "PUT /self/phone"
+ ],
+ "v6": [
+ "DELETE /conversations/{_}/bots/{_}",
+ "DELETE /conversations/{_}/code",
+ "DELETE /conversations/{_}/{_}/members/{_}/{_}",
+ "DELETE /oauth/applications/{_}",
+ "GET /",
+ "GET /bot/client",
+ "GET /calls/config",
+ "GET /clients/{_}",
+ "GET /clients/{_}/capabilities",
+ "GET /conversations/one2one/{_}/{_}",
+ "GET /conversations/{_}/self",
+ "GET /providers/{_}/services/{_}",
+ "GET /sso/initiate-login/{_}",
+ "GET /users/{_}/{_}/clients",
+ "HEAD /users/handles/{_}",
+ "POST /assets",
+ "POST /bot/assets",
+ "POST /clients",
+ "POST /conversations",
+ "POST /conversations/join",
+ "POST /conversations/one2one",
+ "POST /conversations/{_}/bots",
+ "POST /conversations/{_}/code",
+ "POST /conversations/{_}/{_}/members",
+ "POST /mls/commit-bundles",
+ "POST /mls/messages",
+ "POST /onboarding/v3",
+ "POST /password-reset/{_}",
+ "POST /provider/assets",
+ "POST /register",
+ "POST /teams/{_}/invitations",
+ "POST /teams/{_}/legalhold/{_}",
+ "POST /users/handles",
+ "PUT /clients/{_}",
+ "PUT /conversations/{_}",
+ "PUT /conversations/{_}/members/{_}",
+ "PUT /conversations/{_}/message-timer",
+ "PUT /conversations/{_}/name",
+ "PUT /conversations/{_}/receipt-mode",
+ "PUT /conversations/{_}/self",
+ "PUT /conversations/{_}/{_}/access",
+ "PUT /conversations/{_}/{_}/message-timer",
+ "PUT /conversations/{_}/{_}/name",
+ "PUT /conversations/{_}/{_}/protocol",
+ "PUT /conversations/{_}/{_}/receipt-mode"
+ ],
+ "v7": [
+ "add-bot",
+ "add-members-to-conversation",
+ "assets-upload",
+ "assets-upload-v3_bot",
+ "assets-upload-v3_provider",
+ "auth-req",
+ "create-conversation-code-unqualified",
+ "create-group-conversation",
+ "get-calls-config",
+ "get-conversation-self-unqualified",
+ "get-user-clients-qualified",
+ "join-conversation-by-code-unqualified",
+ "mls-commit-bundle",
+ "mls-message",
+ "onboarding",
+ "post-password-reset-key-deprecated",
+ "register",
+ "remove-bot",
+ "remove-code-unqualified",
+ "remove-member",
+ "update-client@v7",
+ "update-conversation-access",
+ "update-conversation-message-timer",
+ "update-conversation-message-timer-unqualified",
+ "update-conversation-name",
+ "update-conversation-name-deprecated",
+ "update-conversation-name-unqualified",
+ "update-conversation-protocol",
+ "update-conversation-receipt-mode",
+ "update-conversation-receipt-mode-unqualified",
+ "update-conversation-self-unqualified"
+ ],
+ "v8": [
+ "add-bot",
+ "add-members-to-conversation",
+ "auth-req",
+ "create-conversation-code-unqualified",
+ "create-group-conversation",
+ "create-one-to-one-conversation",
+ "create-self-conversation",
+ "get-conversation",
+ "get-domain-registration",
+ "get-mls-self-conversation",
+ "get-one-to-one-mls-conversation",
+ "get-user-clients-qualified",
+ "join-conversation-by-code-unqualified",
+ "mls-commit-bundle",
+ "mls-message",
+ "put_CellsConfig",
+ "register",
+ "remove-bot",
+ "remove-code-unqualified",
+ "remove-member",
+ "update-channel-add-permission",
+ "update-client",
+ "update-conversation-access",
+ "update-conversation-message-timer",
+ "update-conversation-name",
+ "update-conversation-protocol",
+ "update-conversation-receipt-mode",
+ "update-domain-redirect"
+ ],
+ "v9": [
+ "add-bot",
+ "add-members-to-conversation",
+ "create-conversation-code-unqualified",
+ "create-group-conversation@v9",
+ "create-one-to-one-conversation",
+ "create-self-conversation",
+ "get-conversation",
+ "get-domain-registration@v9",
+ "get-mls-self-conversation",
+ "get-one-to-one-mls-conversation",
+ "get-user-clients-qualified",
+ "join-conversation-by-code-unqualified",
+ "mls-commit-bundle",
+ "mls-message",
+ "put_CellsConfig",
+ "register",
+ "remove-bot",
+ "remove-code-unqualified",
+ "remove-member",
+ "update-channel-add-permission",
+ "update-client",
+ "update-conversation-access",
+ "update-conversation-message-timer",
+ "update-conversation-name",
+ "update-conversation-protocol",
+ "update-conversation-receipt-mode",
+ "update-domain-redirect@v9"
+ ]
+}
\ No newline at end of file
diff --git a/cabal.project b/cabal.project
index 1cf1aa8ecb..14b5bd9e75 100644
--- a/cabal.project
+++ b/cabal.project
@@ -60,6 +60,7 @@ packages:
, tools/mlsstats/
, tools/test-stats/
, tools/entreprise-provisioning/
+ , tools/lint-openapi-regression/
tests: True
benchmarks: True
diff --git a/nix/local-haskell-packages.nix b/nix/local-haskell-packages.nix
index 5562225f67..d65af06bc5 100644
--- a/nix/local-haskell-packages.nix
+++ b/nix/local-haskell-packages.nix
@@ -57,6 +57,7 @@ hsuper: hself: {
service-backfill = hself.callPackage ../tools/db/service-backfill/default.nix { };
team-info = hself.callPackage ../tools/db/team-info/default.nix { };
entreprise-provisioning = hself.callPackage ../tools/entreprise-provisioning/default.nix { };
+ lint-openapi-regression = hself.callPackage ../tools/lint-openapi-regression/default.nix { };
mlsstats = hself.callPackage ../tools/mlsstats/default.nix { };
rabbitmq-consumer = hself.callPackage ../tools/rabbitmq-consumer/default.nix { };
stern = hself.callPackage ../tools/stern/default.nix { };
diff --git a/tools/lint-openapi-regression/LICENSE b/tools/lint-openapi-regression/LICENSE
new file mode 100644
index 0000000000..dba13ed2dd
--- /dev/null
+++ b/tools/lint-openapi-regression/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/tools/lint-openapi-regression/README.md b/tools/lint-openapi-regression/README.md
new file mode 100644
index 0000000000..2767c7972e
--- /dev/null
+++ b/tools/lint-openapi-regression/README.md
@@ -0,0 +1,97 @@
+# lint-openapi-regression
+
+Detects backward-incompatible changes in OpenAPI JSON files by comparing a
+candidate spec against historical baseline versions (e.g. `swagger-v5.json`,
+`swagger-v6.json`).
+
+## What it checks
+
+| Check | Breaking direction |
+|------------------------------------|--------------------|
+| Route removed | Always |
+| Query parameter removed | Always |
+| Required query parameter added | Always |
+| Required body field added | Request |
+| Response field removed (required) | Response |
+| Enum value removed | Request |
+| Enum value added | Response |
+
+## Building
+
+```bash
+make c package=lint-openapi-regression
+```
+
+## Running tests
+
+```bash
+make c package=lint-openapi-regression test=1 \
+ | grep -vE 'Compiling|Linking|Preprocessing|Configuring|Building'
+```
+
+Or directly with Cabal:
+
+```bash
+cabal test lint-openapi-regression-tests \
+ | grep -vE 'Compiling|Linking|Preprocessing|Configuring|Building'
+```
+
+## CLI usage
+
+```
+lint-openapi-regression [OPTIONS] INPUT_FILE
+```
+
+### Options
+
+| Flag | Default | Description |
+|--------------------|------------------------|------------------------------------------------------|
+| `--baseline-dir` | `services/brig/docs` | Directory containing baseline `swagger-v*.json` files |
+| `--ignore FILE` | (none) | Path to a JSON ignore file |
+| `--update` | off | Update the ignore file with new breaking changes |
+
+### Examples
+
+Check a new spec against all baselines in the default directory:
+
+```bash
+lint-openapi-regression services/brig/docs/swagger.json
+```
+
+Check against baselines in a custom directory:
+
+```bash
+lint-openapi-regression --baseline-dir path/to/baselines new-spec.json
+```
+
+Use an ignore file to suppress known violations:
+
+```bash
+lint-openapi-regression --ignore .lint-ignore.json services/brig/docs/swagger.json
+```
+
+Auto-update the ignore file with any new violations found:
+
+```bash
+lint-openapi-regression --ignore .lint-ignore.json --update services/brig/docs/swagger.json
+```
+
+## Exit codes
+
+| Code | Meaning |
+|------|--------------------------------------------|
+| 0 | No breaking changes (or all are ignored) |
+| 1 | Breaking changes detected |
+| 2 | Input error (file not found, parse failure)|
+
+## Ignore file format
+
+The ignore file is a JSON object mapping baseline version keys to sets of route
+identifiers (either `operationId` or rendered route like `GET /users/{_}`):
+
+```json
+{
+ "v5": ["getUser", "POST /teams"],
+ "v6": ["GET /users/{_}"]
+}
+```
diff --git a/tools/lint-openapi-regression/default.nix b/tools/lint-openapi-regression/default.nix
new file mode 100644
index 0000000000..6da480ab6a
--- /dev/null
+++ b/tools/lint-openapi-regression/default.nix
@@ -0,0 +1,46 @@
+# WARNING: GENERATED FILE, DO NOT EDIT.
+# This file is generated by running hack/bin/generate-local-nix-packages.sh and
+# must be regenerated whenever local packages are added or removed, or
+# dependencies are added or removed.
+{ mkDerivation
+, aeson
+, aeson-pretty
+, base
+, bytestring
+, containers
+, filepath
+, hspec
+, imports
+, lib
+, optparse-applicative
+, text
+, vector
+}:
+mkDerivation {
+ pname = "lint-openapi-regression";
+ version = "0.1.0";
+ src = ./.;
+ isLibrary = true;
+ isExecutable = true;
+ libraryHaskellDepends = [
+ aeson
+ aeson-pretty
+ bytestring
+ containers
+ imports
+ text
+ vector
+ ];
+ executableHaskellDepends = [
+ base
+ containers
+ filepath
+ imports
+ optparse-applicative
+ text
+ ];
+ testHaskellDepends = [ aeson containers hspec imports text ];
+ description = "OpenAPI backward-compatibility linter";
+ license = lib.licenses.agpl3Only;
+ mainProgram = "lint-openapi-regression";
+}
diff --git a/tools/lint-openapi-regression/exe/Main.hs b/tools/lint-openapi-regression/exe/Main.hs
new file mode 100644
index 0000000000..3885e38875
--- /dev/null
+++ b/tools/lint-openapi-regression/exe/Main.hs
@@ -0,0 +1,153 @@
+module Main (main) where
+
+import Data.List qualified as List
+import Data.Map.Strict qualified as Map
+import Data.Text qualified as Text
+import Data.Text.IO qualified as Text.IO
+import Imports
+import LintOpenAPI.Compare (compareSpecs)
+import LintOpenAPI.Ignore (isIgnored, readIgnoreFile, updateIgnoreMap, writeIgnoreFile)
+import LintOpenAPI.Parse (parseOpenAPIFile)
+import LintOpenAPI.Report (formatViolations)
+import Options.Applicative
+import System.Exit (ExitCode (..), exitWith)
+import System.FilePath ((>))
+import System.IO qualified as IO
+
+-- | CLI configuration.
+data Options = Options
+ { baselineDir :: FilePath,
+ inputFile :: FilePath,
+ ignoreFile :: Maybe FilePath,
+ updateIgnore :: Bool
+ }
+
+-- | Parse CLI options.
+optionsParser :: Parser Options
+optionsParser =
+ Options
+ <$> strOption
+ ( long "baseline-dir"
+ <> metavar "DIR"
+ <> value "services/brig/docs"
+ <> showDefault
+ <> help "Directory containing baseline swagger-v*.json files"
+ )
+ <*> strArgument
+ ( metavar "INPUT_FILE"
+ <> help "Path to the new OpenAPI JSON file to check"
+ )
+ <*> optional
+ ( strOption
+ ( long "ignore"
+ <> metavar "FILE"
+ <> help "Path to the JSON ignore file"
+ )
+ )
+ <*> switch
+ ( long "update"
+ <> help "Update the ignore file with unignored breaking changes"
+ )
+
+-- | Top-level CLI parser with help text.
+optionsParserInfo :: ParserInfo Options
+optionsParserInfo =
+ info
+ (optionsParser <**> helper)
+ ( fullDesc
+ <> progDesc "Check an OpenAPI JSON file for backward-incompatible changes"
+ <> header "lint-openapi-regression - OpenAPI backward-compatibility linter"
+ )
+
+main :: IO ()
+main = do
+ opts <- execParser optionsParserInfo
+ result <- runLint opts
+ exitWith result
+
+-- | Main lint workflow. Returns appropriate exit code.
+runLint :: Options -> IO ExitCode
+runLint opts = do
+ -- Parse the input file
+ inputExists <- doesFileExist opts.inputFile
+ unless inputExists $ do
+ Text.IO.hPutStrLn IO.stderr $ "Error: Input file not found: " <> Text.pack opts.inputFile
+ exitWith (ExitFailure 2)
+
+ inputResult <- parseOpenAPIFile opts.inputFile
+ candidate <- case inputResult of
+ Left err -> do
+ Text.IO.hPutStrLn IO.stderr $ "Error parsing input file: " <> Text.pack err
+ exitWith (ExitFailure 2)
+ Right spec -> pure spec
+
+ -- Discover and parse baseline files
+ baselineFiles <- discoverBaselineFiles opts.baselineDir
+ when (null baselineFiles) $ do
+ Text.IO.hPutStrLn IO.stderr $
+ "Warning: No baseline swagger-v*.json files found in " <> Text.pack opts.baselineDir
+
+ baselines <- forM baselineFiles $ \fp -> do
+ result <- parseOpenAPIFile fp
+ case result of
+ Left err -> do
+ Text.IO.hPutStrLn IO.stderr $ "Warning: Failed to parse " <> Text.pack fp <> ": " <> Text.pack err
+ pure Nothing
+ Right spec -> pure (Just spec)
+
+ -- Load ignore map if specified
+ ignoreMap <- case opts.ignoreFile of
+ Just f -> readIgnoreFile f
+ Nothing -> pure Map.empty
+
+ let validBaselines = catMaybes baselines
+ allViolations = concatMap (`compareSpecs` candidate) validBaselines
+ (ignoredViolations, unignoredViolations) = List.partition (isIgnored ignoreMap) allViolations
+ report = formatViolations (length ignoredViolations) unignoredViolations
+
+ Text.IO.hPutStrLn IO.stderr report
+
+ when opts.updateIgnore $ do
+ case opts.ignoreFile of
+ Just f -> do
+ let newMap = updateIgnoreMap ignoreMap unignoredViolations
+ writeIgnoreFile f newMap
+ Text.IO.hPutStrLn IO.stderr $ "Updated ignore file: " <> Text.pack f
+ Nothing -> Text.IO.hPutStrLn IO.stderr "Warning: --update specified but no --ignore file provided."
+
+ if null unignoredViolations
+ then pure ExitSuccess
+ else pure (ExitFailure 1)
+
+-- | Discover baseline swagger-v*.json files in a directory.
+-- Only includes OpenAPI 3.0 files (v5+).
+discoverBaselineFiles :: FilePath -> IO [FilePath]
+discoverBaselineFiles dir = do
+ exists <- doesFileExist (dir > "swagger-v5.json")
+ if not exists
+ then pure []
+ else do
+ entries <- listDirectory dir
+ let swaggerFiles =
+ List.sort
+ [ dir > e
+ | e <- entries,
+ "swagger-v" `List.isPrefixOf` e,
+ ".json" `List.isSuffixOf` e,
+ isOpenAPI3File e
+ ]
+ pure swaggerFiles
+
+-- | Check if a swagger filename is v5 or higher (OpenAPI 3.0).
+isOpenAPI3File :: String -> Bool
+isOpenAPI3File name =
+ case extractVersionNum name of
+ Just n -> n >= 5
+ Nothing -> False
+
+-- | Extract the version number from a filename like "swagger-v5.json".
+extractVersionNum :: String -> Maybe Int
+extractVersionNum name = do
+ rest <- List.stripPrefix "swagger-v" name
+ let numStr = takeWhile (/= '.') rest
+ readMaybe numStr
diff --git a/tools/lint-openapi-regression/lint-openapi-regression.cabal b/tools/lint-openapi-regression/lint-openapi-regression.cabal
new file mode 100644
index 0000000000..065c5b77a1
--- /dev/null
+++ b/tools/lint-openapi-regression/lint-openapi-regression.cabal
@@ -0,0 +1,218 @@
+cabal-version: >=1.10
+name: lint-openapi-regression
+version: 0.1.0
+synopsis: OpenAPI backward-compatibility linter
+description:
+ Detects non-backward-compatible changes in OpenAPI JSON files
+ by comparing against historical baseline swagger versions.
+
+category: Tool
+author: Wire Swiss GmbH
+maintainer: Wire Swiss GmbH
+license: AGPL-3
+license-file: LICENSE
+build-type: Simple
+
+flag static
+ description: Enable static linking
+ default: False
+
+library
+ exposed-modules:
+ LintOpenAPI.Compare
+ LintOpenAPI.Ignore
+ LintOpenAPI.Normalize
+ LintOpenAPI.Parse
+ LintOpenAPI.Report
+ LintOpenAPI.Types
+
+ hs-source-dirs: src
+ default-extensions:
+ AllowAmbiguousTypes
+ BangPatterns
+ ConstraintKinds
+ DataKinds
+ DefaultSignatures
+ DeriveFunctor
+ DeriveGeneric
+ DeriveLift
+ DeriveTraversable
+ DerivingStrategies
+ DerivingVia
+ DuplicateRecordFields
+ EmptyCase
+ FlexibleContexts
+ FlexibleInstances
+ FunctionalDependencies
+ GADTs
+ InstanceSigs
+ KindSignatures
+ LambdaCase
+ MultiParamTypeClasses
+ MultiWayIf
+ NamedFieldPuns
+ NoImplicitPrelude
+ OverloadedRecordDot
+ OverloadedStrings
+ PackageImports
+ PatternSynonyms
+ PolyKinds
+ QuasiQuotes
+ RankNTypes
+ ScopedTypeVariables
+ StandaloneDeriving
+ TupleSections
+ TypeApplications
+ TypeFamilies
+ TypeFamilyDependencies
+ TypeOperators
+ UndecidableInstances
+ ViewPatterns
+
+ ghc-options:
+ -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates
+ -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path
+ -Wredundant-constraints -Wunused-packages
+
+ build-depends:
+ aeson
+ , aeson-pretty
+ , bytestring
+ , containers
+ , imports
+ , text
+ , vector
+
+ default-language: GHC2021
+
+executable lint-openapi-regression
+ main-is: Main.hs
+ hs-source-dirs: exe
+ default-extensions:
+ AllowAmbiguousTypes
+ BangPatterns
+ ConstraintKinds
+ DataKinds
+ DefaultSignatures
+ DeriveFunctor
+ DeriveGeneric
+ DeriveLift
+ DeriveTraversable
+ DerivingStrategies
+ DerivingVia
+ DuplicateRecordFields
+ EmptyCase
+ FlexibleContexts
+ FlexibleInstances
+ FunctionalDependencies
+ GADTs
+ InstanceSigs
+ KindSignatures
+ LambdaCase
+ MultiParamTypeClasses
+ MultiWayIf
+ NamedFieldPuns
+ NoImplicitPrelude
+ OverloadedRecordDot
+ OverloadedStrings
+ PackageImports
+ PatternSynonyms
+ PolyKinds
+ QuasiQuotes
+ RankNTypes
+ ScopedTypeVariables
+ StandaloneDeriving
+ TupleSections
+ TypeApplications
+ TypeFamilies
+ TypeFamilyDependencies
+ TypeOperators
+ UndecidableInstances
+ ViewPatterns
+
+ ghc-options:
+ -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates
+ -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path
+ -threaded -rtsopts -with-rtsopts=-T -Wredundant-constraints
+ -Wunused-packages
+
+ build-depends:
+ base
+ , containers
+ , filepath
+ , imports
+ , lint-openapi-regression
+ , optparse-applicative
+ , text
+
+ if flag(static)
+ ld-options: -static
+
+ default-language: GHC2021
+
+test-suite lint-openapi-regression-tests
+ type: exitcode-stdio-1.0
+ main-is: Spec.hs
+ other-modules:
+ LintOpenAPI.CompareSpec
+ LintOpenAPI.IgnoreSpec
+ LintOpenAPI.NormalizeSpec
+ LintOpenAPI.ParseSpec
+
+ hs-source-dirs: test
+ default-extensions:
+ AllowAmbiguousTypes
+ BangPatterns
+ ConstraintKinds
+ DataKinds
+ DefaultSignatures
+ DeriveFunctor
+ DeriveGeneric
+ DeriveLift
+ DeriveTraversable
+ DerivingStrategies
+ DerivingVia
+ DuplicateRecordFields
+ EmptyCase
+ FlexibleContexts
+ FlexibleInstances
+ FunctionalDependencies
+ GADTs
+ InstanceSigs
+ KindSignatures
+ LambdaCase
+ MultiParamTypeClasses
+ MultiWayIf
+ NamedFieldPuns
+ NoImplicitPrelude
+ OverloadedRecordDot
+ OverloadedStrings
+ PackageImports
+ PatternSynonyms
+ PolyKinds
+ QuasiQuotes
+ RankNTypes
+ ScopedTypeVariables
+ StandaloneDeriving
+ TupleSections
+ TypeApplications
+ TypeFamilies
+ TypeFamilyDependencies
+ TypeOperators
+ UndecidableInstances
+ ViewPatterns
+
+ ghc-options:
+ -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates
+ -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path
+ -threaded -Wredundant-constraints -Wunused-packages
+
+ build-depends:
+ aeson
+ , containers
+ , hspec
+ , imports
+ , lint-openapi-regression
+ , text
+
+ default-language: GHC2021
diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Compare.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Compare.hs
new file mode 100644
index 0000000000..6b4ac7dfc2
--- /dev/null
+++ b/tools/lint-openapi-regression/src/LintOpenAPI/Compare.hs
@@ -0,0 +1,130 @@
+module LintOpenAPI.Compare
+ ( compareSpecs,
+ compareRouteInfo,
+ )
+where
+
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Imports
+import LintOpenAPI.Types
+
+-- | Compare a candidate spec against a baseline spec.
+-- Returns violations where the candidate breaks backward compatibility.
+compareSpecs :: OpenAPISpec -> OpenAPISpec -> [ViolationContext]
+compareSpecs baseline candidate =
+ concatMap (uncurry $ compareRoute baseline.version candidate.routes) (Map.toList baseline.routes)
+
+-- | Compare a single baseline route against the candidate route map.
+compareRoute ::
+ Maybe Int ->
+ Map.Map RouteKey RouteInfo ->
+ RouteKey ->
+ RouteInfo ->
+ [ViolationContext]
+compareRoute ver candidateRoutes rk baselineInfo =
+ case Map.lookup rk candidateRoutes of
+ Nothing ->
+ [mkCtx RouteRemoved]
+ Just candidateInfo ->
+ map mkCtx (compareRouteInfo baselineInfo candidateInfo)
+ where
+ mkCtx v =
+ ViolationContext
+ { baselineVersion = ver,
+ routeKey = rk,
+ routeId = baselineInfo.operationId,
+ violation = v
+ }
+
+-- | Compare route info for backward-incompatible changes.
+compareRouteInfo :: RouteInfo -> RouteInfo -> [Violation]
+compareRouteInfo baseline candidate =
+ compareQueryParams baseline candidate
+ <> compareRequestBody baseline candidate
+ <> compareResponseBodies baseline candidate
+
+-- | Check for removed query params and new required query params.
+compareQueryParams :: RouteInfo -> RouteInfo -> [Violation]
+compareQueryParams baseline candidate =
+ let removedParams = Set.difference baseline.queryParams candidate.queryParams
+ newRequiredParams = Set.difference candidate.requiredQueryParams baseline.queryParams
+ in map QueryParamRemoved (Set.toList removedParams)
+ <> map RequiredQueryParamAdded (Set.toList newRequiredParams)
+
+-- | Check for new required fields in the request body.
+compareRequestBody :: RouteInfo -> RouteInfo -> [Violation]
+compareRequestBody baseline candidate =
+ case (baseline.requestBody, candidate.requestBody) of
+ (_, Nothing) -> []
+ (Nothing, Just candSchema) ->
+ -- Candidate added a body where none existed; only flag required fields
+ map RequiredBodyFieldAdded (Set.toList candSchema.requiredFields)
+ (Just baseSchema, Just candSchema) ->
+ compareRequestSchema baseSchema candSchema
+
+-- | Compare request schemas for new required fields and removed enum values.
+compareRequestSchema :: ResolvedSchema -> ResolvedSchema -> [Violation]
+compareRequestSchema baseline candidate =
+ let newRequired = Set.difference candidate.requiredFields baseline.requiredFields
+ enumViolations = compareRequestEnumValues baseline candidate
+ nestedViolations = compareNestedRequestSchemas baseline candidate
+ in map RequiredBodyFieldAdded (Set.toList newRequired)
+ <> enumViolations
+ <> nestedViolations
+
+-- | Compare response bodies for removed required fields.
+compareResponseBodies :: RouteInfo -> RouteInfo -> [Violation]
+compareResponseBodies baseline candidate =
+ concatMap checkResponse (Map.toList baseline.responses)
+ where
+ checkResponse (code, baseSchema) =
+ case Map.lookup code candidate.responses of
+ Nothing -> []
+ Just candSchema -> compareResponseSchema baseSchema candSchema
+
+-- | Compare response schemas for removed fields and removed enum values.
+compareResponseSchema :: ResolvedSchema -> ResolvedSchema -> [Violation]
+compareResponseSchema baseline candidate =
+ let baseRequiredProps = Set.intersection baseline.requiredFields (Map.keysSet baseline.properties)
+ candProps = Map.keysSet candidate.properties
+ removedRequiredProps = Set.difference baseRequiredProps candProps
+ enumViolations = compareResponseEnumValues baseline candidate
+ nestedViolations = compareNestedResponseSchemas baseline candidate
+ in map ResponseFieldRemoved (Set.toList removedRequiredProps)
+ <> enumViolations
+ <> nestedViolations
+
+-- | In request schemas, removing an enum value is breaking (clients may send it).
+compareRequestEnumValues :: ResolvedSchema -> ResolvedSchema -> [Violation]
+compareRequestEnumValues baseline candidate =
+ case (baseline.enumValues, candidate.enumValues) of
+ (Just baseEnum, Just candEnum) ->
+ let removed = Set.difference baseEnum candEnum
+ in map (EnumValueRemoved "") (Set.toList removed)
+ _ -> []
+
+-- | In response schemas, adding an enum value is breaking (clients may not handle it).
+compareResponseEnumValues :: ResolvedSchema -> ResolvedSchema -> [Violation]
+compareResponseEnumValues baseline candidate =
+ case (baseline.enumValues, candidate.enumValues) of
+ (Just baseEnum, Just candEnum) ->
+ let added = Set.difference candEnum baseEnum
+ in map (EnumValueAdded "") (Set.toList added)
+ _ -> []
+
+-- | Recursively compare nested request schemas for properties that exist in both.
+compareNestedRequestSchemas :: ResolvedSchema -> ResolvedSchema -> [Violation]
+compareNestedRequestSchemas baseline candidate =
+ let commonProps = Map.intersectionWith (,) baseline.properties candidate.properties
+ in concatMap
+ (\(_, (baseProp, candProp)) -> compareRequestSchema baseProp candProp)
+ (Map.toList commonProps)
+
+-- | Recursively compare nested response schemas for properties that exist in both.
+compareNestedResponseSchemas :: ResolvedSchema -> ResolvedSchema -> [Violation]
+compareNestedResponseSchemas baseline candidate =
+ let commonProps = Map.intersectionWith (,) baseline.properties candidate.properties
+ in concatMap
+ (\(_, (baseProp, candProp)) -> compareResponseSchema baseProp candProp)
+ (Map.toList commonProps)
diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Ignore.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Ignore.hs
new file mode 100644
index 0000000000..5603592b96
--- /dev/null
+++ b/tools/lint-openapi-regression/src/LintOpenAPI/Ignore.hs
@@ -0,0 +1,70 @@
+module LintOpenAPI.Ignore
+ ( IgnoreMap,
+ readIgnoreFile,
+ writeIgnoreFile,
+ isIgnored,
+ updateIgnoreMap,
+ getRouteIdentifier,
+ )
+where
+
+import Data.Aeson (decodeStrict)
+import Data.Aeson.Encode.Pretty (encodePretty)
+import Data.ByteString qualified as BS
+import Data.ByteString.Lazy qualified as LBS
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text qualified as Text
+import Imports
+import LintOpenAPI.Report (renderRouteKey)
+import LintOpenAPI.Types
+
+-- | Maps a baseline version string (e.g. "v7") to a set of route identifiers.
+type IgnoreMap = Map.Map Text (Set.Set Text)
+
+-- | Read an ignore file. Returns an empty map if it doesn't exist or is invalid.
+readIgnoreFile :: FilePath -> IO IgnoreMap
+readIgnoreFile path = do
+ exists <- doesFileExist path
+ if not exists
+ then pure Map.empty
+ else do
+ bytes <- BS.readFile path
+ case decodeStrict bytes of
+ Just m -> pure m
+ Nothing -> pure Map.empty
+
+-- | Write an ignore file using pretty JSON formatting.
+writeIgnoreFile :: FilePath -> IgnoreMap -> IO ()
+writeIgnoreFile path imap = do
+ let bytes = encodePretty imap
+ LBS.writeFile path bytes
+
+-- | Extract the identifier for a route (either its operationId or rendered path).
+getRouteIdentifier :: ViolationContext -> Text
+getRouteIdentifier ctx =
+ fromMaybe (renderRouteKey ctx.routeKey) ctx.routeId
+
+-- | Format the version key for the ignore map.
+versionKey :: Maybe Int -> Text
+versionKey (Just v) = "v" <> Text.pack (show v)
+versionKey Nothing = "baseline"
+
+-- | Check if a violation is ignored by the given ignore map.
+isIgnored :: IgnoreMap -> ViolationContext -> Bool
+isIgnored imap ctx =
+ let vkey = versionKey ctx.baselineVersion
+ rkey = getRouteIdentifier ctx
+ in case Map.lookup vkey imap of
+ Just ignoredRoutes -> Set.member rkey ignoredRoutes
+ Nothing -> False
+
+-- | Update the ignore map with new unignored violations.
+updateIgnoreMap :: IgnoreMap -> [ViolationContext] -> IgnoreMap
+updateIgnoreMap imap newViolations =
+ foldl' addViolation imap newViolations
+ where
+ addViolation acc ctx =
+ let vkey = versionKey ctx.baselineVersion
+ rkey = getRouteIdentifier ctx
+ in Map.insertWith Set.union vkey (Set.singleton rkey) acc
diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Normalize.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Normalize.hs
new file mode 100644
index 0000000000..7b7a68a1ad
--- /dev/null
+++ b/tools/lint-openapi-regression/src/LintOpenAPI/Normalize.hs
@@ -0,0 +1,45 @@
+module LintOpenAPI.Normalize
+ ( normalizePath,
+ routeKeyFromPath,
+ )
+where
+
+import Data.Text qualified as Text
+import Imports
+import LintOpenAPI.Types
+
+-- | Normalize a URL path into a list of route segments.
+-- Placeholders like @{userId}@ become 'Placeholder', everything else 'Literal'.
+normalizePath :: Text -> NormalizedRoute
+normalizePath path =
+ NormalizedRoute
+ { segments = map normalizeSegment (filter (not . Text.null) (Text.splitOn "/" path))
+ }
+
+-- | Normalize a single path segment.
+normalizeSegment :: Text -> RouteSegment
+normalizeSegment seg
+ | Text.isPrefixOf "{" seg && Text.isSuffixOf "}" seg = Placeholder
+ | otherwise = Literal seg
+
+-- | Parse an HTTP method string into our enum.
+parseMethod :: Text -> Maybe HttpMethod
+parseMethod = \case
+ "get" -> Just GET
+ "post" -> Just POST
+ "put" -> Just PUT
+ "patch" -> Just PATCH
+ "delete" -> Just DELETE
+ "head" -> Just HEAD
+ "options" -> Just OPTIONS
+ _ -> Nothing
+
+-- | Build a 'RouteKey' from a path string and method string.
+routeKeyFromPath :: Text -> Text -> Maybe RouteKey
+routeKeyFromPath path methodText = do
+ m <- parseMethod (Text.toLower methodText)
+ pure
+ RouteKey
+ { method = m,
+ route = normalizePath path
+ }
diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Parse.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Parse.hs
new file mode 100644
index 0000000000..9ec34ded69
--- /dev/null
+++ b/tools/lint-openapi-regression/src/LintOpenAPI/Parse.hs
@@ -0,0 +1,242 @@
+module LintOpenAPI.Parse
+ ( parseOpenAPIFile,
+ parseOpenAPIValue,
+ )
+where
+
+import Data.Aeson (Value (..), eitherDecodeFileStrict')
+import Data.Aeson.Key qualified as Key
+import Data.Aeson.KeyMap qualified as KM
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text qualified as Text
+import Data.Vector qualified as Vector
+import Imports
+import LintOpenAPI.Normalize
+import LintOpenAPI.Types
+
+-- | Parse an OpenAPI JSON file from disk into an 'OpenAPISpec'.
+parseOpenAPIFile :: FilePath -> IO (Either String OpenAPISpec)
+parseOpenAPIFile fp = do
+ result <- eitherDecodeFileStrict' fp
+ pure $ result >>= parseOpenAPIValue
+
+-- | Parse an OpenAPI JSON 'Value' into an 'OpenAPISpec'.
+parseOpenAPIValue :: Value -> Either String OpenAPISpec
+parseOpenAPIValue val = case val of
+ Object top -> do
+ let schemas = extractSchemas top
+ ver = extractVersion top
+ rs <- extractRoutes schemas top
+ Right
+ OpenAPISpec
+ { version = ver,
+ routes = rs
+ }
+ _ -> Left "Top-level value must be a JSON object"
+
+-- | Extract the API version from servers[0].url (e.g., "/v5" -> 5).
+extractVersion :: KM.KeyMap Value -> Maybe Int
+extractVersion top = do
+ Array servers <- KM.lookup "servers" top
+ Object server <- servers Vector.!? 0
+ String url <- KM.lookup "url" server
+ vText <- Text.stripPrefix "/v" url
+ readMaybe (Text.unpack vText)
+
+-- | Extract the component schemas map for $ref resolution.
+extractSchemas :: KM.KeyMap Value -> Map.Map Text Value
+extractSchemas top = case KM.lookup "components" top of
+ Just (Object comps) -> case KM.lookup "schemas" comps of
+ Just (Object schemaMap) ->
+ Map.fromList
+ [(Key.toText k, v) | (k, v) <- KM.toList schemaMap]
+ _ -> Map.empty
+ _ -> Map.empty
+
+-- | Extract all routes from the paths object.
+extractRoutes ::
+ Map.Map Text Value ->
+ KM.KeyMap Value ->
+ Either String (Map.Map RouteKey RouteInfo)
+extractRoutes schemas top = case KM.lookup "paths" top of
+ Just (Object pathsObj) -> do
+ let pairs =
+ [ (rk, ri)
+ | (pathKey, pathVal) <- KM.toList pathsObj,
+ let path = Key.toText pathKey,
+ (rk, ri) <- extractPathMethods schemas path pathVal
+ ]
+ Right (Map.fromList pairs)
+ Just _ -> Left "'paths' must be an object"
+ Nothing -> Left "Missing 'paths' key"
+
+-- | Extract all methods from a single path item.
+extractPathMethods ::
+ Map.Map Text Value ->
+ Text ->
+ Value ->
+ [(RouteKey, RouteInfo)]
+extractPathMethods schemas path = \case
+ Object methodsObj ->
+ [ (rk, ri)
+ | (methodKey, methodVal) <- KM.toList methodsObj,
+ let methodText = Key.toText methodKey,
+ Just rk <- [routeKeyFromPath path methodText],
+ let ri = extractRouteInfo schemas methodVal
+ ]
+ _ -> []
+
+-- | Extract route info (params, body, responses) from an operation object.
+extractRouteInfo :: Map.Map Text Value -> Value -> RouteInfo
+extractRouteInfo schemas = \case
+ Object op ->
+ let params = extractParams op
+ body = extractRequestBody schemas op
+ resps = extractResponses schemas op
+ opId = case KM.lookup "operationId" op of
+ Just (String t) -> Just t
+ _ -> Nothing
+ in RouteInfo
+ { operationId = opId,
+ queryParams = fst params,
+ requiredQueryParams = snd params,
+ requestBody = body,
+ responses = resps
+ }
+ _ ->
+ RouteInfo
+ { operationId = Nothing,
+ queryParams = Set.empty,
+ requiredQueryParams = Set.empty,
+ requestBody = Nothing,
+ responses = Map.empty
+ }
+
+-- | Extract query parameters: (all query param names, required query param names).
+extractParams :: KM.KeyMap Value -> (Set.Set Text, Set.Set Text)
+extractParams op = case KM.lookup "parameters" op of
+ Just (Array arr) ->
+ let queryPs =
+ [ (name, isReq)
+ | Object p <- Vector.toList arr,
+ String loc <- [fromMaybe Null (KM.lookup "in" p)],
+ loc == "query",
+ String name <- [fromMaybe Null (KM.lookup "name" p)],
+ let isReq = case KM.lookup "required" p of
+ Just (Bool True) -> True
+ _ -> False
+ ]
+ in ( Set.fromList (map fst queryPs),
+ Set.fromList [n | (n, True) <- queryPs]
+ )
+ _ -> (Set.empty, Set.empty)
+
+-- | Extract and resolve the request body schema.
+extractRequestBody :: Map.Map Text Value -> KM.KeyMap Value -> Maybe ResolvedSchema
+extractRequestBody schemas op = do
+ Object rb <- KM.lookup "requestBody" op
+ Object content <- KM.lookup "content" rb
+ -- Take the first content type's schema
+ (_, contentVal) <- listToMaybe (KM.toList content)
+ case contentVal of
+ Object ct -> do
+ schemaVal <- KM.lookup "schema" ct
+ Just (resolveSchema schemas Set.empty schemaVal)
+ _ -> Nothing
+
+-- | Extract and resolve response schemas keyed by status code.
+extractResponses :: Map.Map Text Value -> KM.KeyMap Value -> Map.Map Text ResolvedSchema
+extractResponses schemas op = case KM.lookup "responses" op of
+ Just (Object respsObj) ->
+ Map.fromList
+ [ (Key.toText code, resolveSchema schemas Set.empty schemaVal)
+ | (code, respVal) <- KM.toList respsObj,
+ Object resp <- [respVal],
+ Object content <- [fromMaybe Null (KM.lookup "content" resp)],
+ (_, contentVal) <- take 1 (KM.toList content),
+ Object ct <- [contentVal],
+ schemaVal <- maybeToList (KM.lookup "schema" ct)
+ ]
+ _ -> Map.empty
+
+-- | Resolve a schema value, following $ref pointers and merging allOf.
+-- The 'visited' set prevents infinite recursion on circular references.
+resolveSchema :: Map.Map Text Value -> Set.Set Text -> Value -> ResolvedSchema
+resolveSchema schemas visited = \case
+ Object obj
+ | Just (String ref) <- KM.lookup "$ref" obj ->
+ resolveRef schemas visited ref
+ | Just (Array allOfArr) <- KM.lookup "allOf" obj ->
+ mergeAllOf schemas visited (Vector.toList allOfArr)
+ | Just (Array oneOfArr) <- KM.lookup "oneOf" obj ->
+ -- For oneOf, we take the union of all variants' properties.
+ -- This is conservative: if any variant has a field, we track it.
+ mergeAllOf schemas visited (Vector.toList oneOfArr)
+ | otherwise ->
+ resolveInlineSchema schemas visited obj
+ _ -> emptySchema
+
+-- | Resolve a $ref pointer like "#/components/schemas/Foo".
+resolveRef :: Map.Map Text Value -> Set.Set Text -> Text -> ResolvedSchema
+resolveRef schemas visited ref =
+ let schemaName = last (Text.splitOn "/" ref)
+ in if Set.member schemaName visited
+ then emptySchema
+ else case Map.lookup schemaName schemas of
+ Just val -> resolveSchema schemas (Set.insert schemaName visited) val
+ Nothing -> emptySchema
+
+-- | Merge multiple schemas from an allOf array.
+mergeAllOf :: Map.Map Text Value -> Set.Set Text -> [Value] -> ResolvedSchema
+mergeAllOf schemas visited vals =
+ let resolved = map (resolveSchema schemas visited) vals
+ in foldl' mergeSchemas emptySchema resolved
+
+-- | Merge two resolved schemas, combining required fields and properties.
+mergeSchemas :: ResolvedSchema -> ResolvedSchema -> ResolvedSchema
+mergeSchemas a b =
+ ResolvedSchema
+ { requiredFields = a.requiredFields <> b.requiredFields,
+ properties = Map.union a.properties b.properties,
+ enumValues = case (a.enumValues, b.enumValues) of
+ (Nothing, x) -> x
+ (x, Nothing) -> x
+ (Just x, Just y) -> Just (Set.union x y),
+ schemaType = a.schemaType <|> b.schemaType
+ }
+
+-- | Resolve an inline schema object (with properties, required, enum, type).
+resolveInlineSchema ::
+ Map.Map Text Value ->
+ Set.Set Text ->
+ KM.KeyMap Value ->
+ ResolvedSchema
+resolveInlineSchema schemas visited obj =
+ let reqFields = case KM.lookup "required" obj of
+ Just (Array arr) ->
+ Set.fromList [t | String t <- Vector.toList arr]
+ _ -> Set.empty
+
+ props = case KM.lookup "properties" obj of
+ Just (Object propsObj) ->
+ Map.fromList
+ [ (Key.toText k, resolveSchema schemas visited v)
+ | (k, v) <- KM.toList propsObj
+ ]
+ _ -> Map.empty
+
+ enumVals = case KM.lookup "enum" obj of
+ Just (Array arr) ->
+ Just (Set.fromList [t | String t <- Vector.toList arr])
+ _ -> Nothing
+
+ typ = case KM.lookup "type" obj of
+ Just (String t) -> Just t
+ _ -> Nothing
+ in ResolvedSchema
+ { requiredFields = reqFields,
+ properties = props,
+ enumValues = enumVals,
+ schemaType = typ
+ }
diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Report.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Report.hs
new file mode 100644
index 0000000000..1626bccafa
--- /dev/null
+++ b/tools/lint-openapi-regression/src/LintOpenAPI/Report.hs
@@ -0,0 +1,108 @@
+module LintOpenAPI.Report
+ ( formatViolations,
+ formatViolation,
+ summarize,
+ renderRoute,
+ renderRouteKey,
+ )
+where
+
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text qualified as Text
+import Imports
+import LintOpenAPI.Types
+
+formatViolations :: Int -> [ViolationContext] -> Text
+formatViolations ignoredCount [] =
+ if ignoredCount > 0
+ then "No breaking changes detected (" <> Text.pack (show ignoredCount) <> " ignored)."
+ else "No breaking changes detected."
+formatViolations ignoredCount ctxs =
+ let grouped = Map.fromListWith (++) [(ctx.baselineVersion, [ctx]) | ctx <- reverse ctxs]
+ sortedGroups = Map.toDescList grouped
+ formatGroup (ver, groupCtxs) =
+ let verLabel = case ver of
+ Just v -> "v" <> Text.pack (show v)
+ Nothing -> "baseline"
+ in "### Breaking changes against "
+ <> verLabel
+ <> "\n\n"
+ <> Text.unlines (map formatViolation groupCtxs)
+ in Text.intercalate "\n" (map formatGroup sortedGroups)
+ <> "\n"
+ <> summarize ignoredCount ctxs
+
+-- | Format a single violation with its context.
+formatViolation :: ViolationContext -> Text
+formatViolation ctx =
+ let routeLabel = renderRouteKey ctx.routeKey
+ opIdLabel = case ctx.routeId of
+ Just oid -> " (" <> oid <> ")"
+ Nothing -> ""
+ detail = renderViolation ctx.violation
+ in "- " <> routeLabel <> opIdLabel <> ": " <> detail
+
+-- | Render a route key as "METHOD /path/{param}".
+renderRouteKey :: RouteKey -> Text
+renderRouteKey rk =
+ Text.pack (show rk.method) <> " " <> renderRoute rk.route
+
+-- | Render a normalized route back to path format.
+renderRoute :: NormalizedRoute -> Text
+renderRoute nr =
+ "/" <> Text.intercalate "/" (map renderSegment nr.segments)
+
+-- | Render a single route segment.
+renderSegment :: RouteSegment -> Text
+renderSegment = \case
+ Literal t -> t
+ Placeholder -> "{_}"
+
+-- | Render a violation detail message.
+renderViolation :: Violation -> Text
+renderViolation = \case
+ RouteRemoved ->
+ "Route removed"
+ QueryParamRemoved name ->
+ "Query parameter removed: \"" <> name <> "\""
+ RequiredQueryParamAdded name ->
+ "Required query parameter added: \"" <> name <> "\""
+ RequiredBodyFieldAdded name ->
+ "Required body field added: \"" <> name <> "\""
+ ResponseFieldRemoved name ->
+ "Response field removed: \"" <> name <> "\" (was required)"
+ EnumValueRemoved field val ->
+ let fieldPart =
+ if Text.null field
+ then ""
+ else " from field \"" <> field <> "\""
+ in "Enum value removed" <> fieldPart <> ": \"" <> val <> "\""
+ EnumValueAdded field val ->
+ let fieldPart =
+ if Text.null field
+ then ""
+ else " to field \"" <> field <> "\""
+ in "Enum value added" <> fieldPart <> ": \"" <> val <> "\""
+
+-- | Generate a summary line.
+summarize :: Int -> [ViolationContext] -> Text
+summarize ignoredCount ctxs =
+ let count = length ctxs
+ versions =
+ Set.fromList
+ [v | ViolationContext {baselineVersion = Just v} <- ctxs]
+ verCount = Set.size versions
+ ignoredText =
+ if ignoredCount > 0
+ then " (" <> Text.pack (show ignoredCount) <> " ignored)"
+ else ""
+ in "Summary: "
+ <> Text.pack (show count)
+ <> " breaking change"
+ <> (if count == 1 then "" else "s")
+ <> ignoredText
+ <> " detected across "
+ <> Text.pack (show verCount)
+ <> " baseline version"
+ <> (if verCount == 1 then "" else "s")
diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Types.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Types.hs
new file mode 100644
index 0000000000..82aed1a5c6
--- /dev/null
+++ b/tools/lint-openapi-regression/src/LintOpenAPI/Types.hs
@@ -0,0 +1,101 @@
+module LintOpenAPI.Types
+ ( RouteSegment (..),
+ NormalizedRoute (..),
+ HttpMethod (..),
+ RouteKey (..),
+ ResolvedSchema (..),
+ RouteInfo (..),
+ Violation (..),
+ ViolationContext (..),
+ OpenAPISpec (..),
+ emptySchema,
+ )
+where
+
+import Imports
+
+-- | A segment of a URL path, either a literal string or a placeholder.
+data RouteSegment
+ = Literal Text
+ | Placeholder
+ deriving stock (Eq, Ord, Show, Generic)
+
+-- | A normalized route path where all placeholders are unified.
+newtype NormalizedRoute = NormalizedRoute
+ { segments :: [RouteSegment]
+ }
+ deriving stock (Eq, Ord, Show, Generic)
+
+-- | HTTP methods relevant for OpenAPI comparison.
+data HttpMethod
+ = GET
+ | POST
+ | PUT
+ | PATCH
+ | DELETE
+ | HEAD
+ | OPTIONS
+ deriving stock (Eq, Ord, Show, Generic, Enum, Bounded)
+
+-- | Unique identity of a route across API versions.
+data RouteKey = RouteKey
+ { method :: HttpMethod,
+ route :: NormalizedRoute
+ }
+ deriving stock (Eq, Ord, Show, Generic)
+
+-- | A resolved (flattened) schema with all $ref pointers followed.
+data ResolvedSchema = ResolvedSchema
+ { requiredFields :: Set Text,
+ properties :: Map Text ResolvedSchema,
+ enumValues :: Maybe (Set Text),
+ schemaType :: Maybe Text
+ }
+ deriving stock (Eq, Show, Generic)
+
+-- | An empty schema with no fields, no enum, no type.
+emptySchema :: ResolvedSchema
+emptySchema =
+ ResolvedSchema
+ { requiredFields = mempty,
+ properties = mempty,
+ enumValues = Nothing,
+ schemaType = Nothing
+ }
+
+-- | All information about a route needed for backward-compat checking.
+data RouteInfo = RouteInfo
+ { operationId :: Maybe Text,
+ queryParams :: Set Text,
+ requiredQueryParams :: Set Text,
+ requestBody :: Maybe ResolvedSchema,
+ responses :: Map Text ResolvedSchema
+ }
+ deriving stock (Eq, Show, Generic)
+
+-- | A parsed OpenAPI specification.
+data OpenAPISpec = OpenAPISpec
+ { version :: Maybe Int,
+ routes :: Map RouteKey RouteInfo
+ }
+ deriving stock (Eq, Show, Generic)
+
+-- | A specific backward-incompatible change detected.
+data Violation
+ = RouteRemoved
+ | QueryParamRemoved Text
+ | RequiredQueryParamAdded Text
+ | RequiredBodyFieldAdded Text
+ | ResponseFieldRemoved Text
+ | EnumValueRemoved Text Text
+ | EnumValueAdded Text Text
+ deriving stock (Eq, Show, Generic)
+
+-- | A violation together with its context for reporting.
+data ViolationContext = ViolationContext
+ { baselineVersion :: Maybe Int,
+ routeKey :: RouteKey,
+ routeId :: Maybe Text,
+ violation :: Violation
+ }
+ deriving stock (Eq, Show, Generic)
diff --git a/tools/lint-openapi-regression/test/LintOpenAPI/CompareSpec.hs b/tools/lint-openapi-regression/test/LintOpenAPI/CompareSpec.hs
new file mode 100644
index 0000000000..3f52981de6
--- /dev/null
+++ b/tools/lint-openapi-regression/test/LintOpenAPI/CompareSpec.hs
@@ -0,0 +1,199 @@
+module LintOpenAPI.CompareSpec (spec) where
+
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Imports
+import LintOpenAPI.Compare
+import LintOpenAPI.Types
+import Test.Hspec
+
+spec :: Spec
+spec = describe "LintOpenAPI.Compare" $ do
+ describe "compareSpecs" $ do
+ it "detects route removal" $ do
+ let baseline = mkOpenAPISpec (Just 5) [(getUsers, emptyRouteInfo)]
+ candidate = mkOpenAPISpec Nothing []
+ violations = compareSpecs baseline candidate
+ length violations `shouldBe` 1
+ case listToMaybe violations of
+ Just ctx -> ctx.violation `shouldBe` RouteRemoved
+ Nothing -> expectationFailure "Expected 1 violation"
+
+ it "passes when route is preserved" $ do
+ let baseline = mkOpenAPISpec (Just 5) [(getUsers, emptyRouteInfo)]
+ candidate = mkOpenAPISpec Nothing [(getUsers, emptyRouteInfo)]
+ violations = compareSpecs baseline candidate
+ violations `shouldBe` []
+
+ it "ignores new routes in candidate" $ do
+ let baseline = mkOpenAPISpec (Just 5) [(getUsers, emptyRouteInfo)]
+ candidate = mkOpenAPISpec Nothing [(getUsers, emptyRouteInfo), (postTeams, emptyRouteInfo)]
+ violations = compareSpecs baseline candidate
+ violations `shouldBe` []
+
+ describe "compareRouteInfo" $ do
+ it "detects query param removal" $ do
+ let baseline = emptyRouteInfo {queryParams = Set.fromList ["size", "start"]}
+ candidate = emptyRouteInfo {queryParams = Set.singleton "start"}
+ violations = compareRouteInfo baseline candidate
+ violations `shouldBe` [QueryParamRemoved "size"]
+
+ it "detects new required query param" $ do
+ let baseline = emptyRouteInfo {queryParams = Set.singleton "start"}
+ candidate = emptyRouteInfo {queryParams = Set.fromList ["start", "filter"], requiredQueryParams = Set.singleton "filter"}
+ violations = compareRouteInfo baseline candidate
+ violations `shouldBe` [RequiredQueryParamAdded "filter"]
+
+ it "allows new optional query param" $ do
+ let baseline = emptyRouteInfo {queryParams = Set.singleton "start"}
+ candidate = emptyRouteInfo {queryParams = Set.fromList ["start", "sort"]}
+ violations = compareRouteInfo baseline candidate
+ violations `shouldBe` []
+
+ it "detects new required body field" $ do
+ let baseBody = emptySchema {requiredFields = Set.singleton "name"}
+ candBody = emptySchema {requiredFields = Set.fromList ["name", "team_id"]}
+ baseline = emptyRouteInfo {requestBody = Just baseBody}
+ candidate = emptyRouteInfo {requestBody = Just candBody}
+ violations = compareRouteInfo baseline candidate
+ violations `shouldBe` [RequiredBodyFieldAdded "team_id"]
+
+ it "allows new optional body field" $ do
+ let baseBody =
+ emptySchema
+ { requiredFields = Set.singleton "name",
+ properties = Map.singleton "name" emptySchema
+ }
+ candBody =
+ emptySchema
+ { requiredFields = Set.singleton "name",
+ properties = Map.fromList [("name", emptySchema), ("nickname", emptySchema)]
+ }
+ baseline = emptyRouteInfo {requestBody = Just baseBody}
+ candidate = emptyRouteInfo {requestBody = Just candBody}
+ violations = compareRouteInfo baseline candidate
+ violations `shouldBe` []
+
+ it "detects removed required response field" $ do
+ let baseResp =
+ emptySchema
+ { requiredFields = Set.fromList ["id", "phone"],
+ properties = Map.fromList [("id", emptySchema), ("phone", emptySchema)]
+ }
+ candResp =
+ emptySchema
+ { requiredFields = Set.singleton "id",
+ properties = Map.singleton "id" emptySchema
+ }
+ baseline = emptyRouteInfo {responses = Map.singleton "200" baseResp}
+ candidate = emptyRouteInfo {responses = Map.singleton "200" candResp}
+ violations = compareRouteInfo baseline candidate
+ violations `shouldBe` [ResponseFieldRemoved "phone"]
+
+ it "allows removal of optional response field" $ do
+ let baseResp =
+ emptySchema
+ { requiredFields = Set.singleton "id",
+ properties = Map.fromList [("id", emptySchema), ("legacy", emptySchema)]
+ }
+ candResp =
+ emptySchema
+ { requiredFields = Set.singleton "id",
+ properties = Map.singleton "id" emptySchema
+ }
+ baseline = emptyRouteInfo {responses = Map.singleton "200" baseResp}
+ candidate = emptyRouteInfo {responses = Map.singleton "200" candResp}
+ violations = compareRouteInfo baseline candidate
+ violations `shouldBe` []
+
+ it "detects enum value removed in request body" $ do
+ let baseBody = emptySchema {enumValues = Just (Set.fromList ["active", "expired"])}
+ candBody = emptySchema {enumValues = Just (Set.singleton "active")}
+ baseline = emptyRouteInfo {requestBody = Just baseBody}
+ candidate = emptyRouteInfo {requestBody = Just candBody}
+ violations = compareRouteInfo baseline candidate
+ violations `shouldBe` [EnumValueRemoved "" "expired"]
+
+ it "allows enum value addition in request body" $ do
+ let baseBody = emptySchema {enumValues = Just (Set.singleton "active")}
+ candBody = emptySchema {enumValues = Just (Set.fromList ["active", "archived"])}
+ baseline = emptyRouteInfo {requestBody = Just baseBody}
+ candidate = emptyRouteInfo {requestBody = Just candBody}
+ violations = compareRouteInfo baseline candidate
+ violations `shouldBe` []
+
+ it "allows enum value removed from response body" $ do
+ let baseResp = emptySchema {enumValues = Just (Set.fromList ["active", "inactive"])}
+ candResp = emptySchema {enumValues = Just (Set.singleton "active")}
+ baseline = emptyRouteInfo {responses = Map.singleton "200" baseResp}
+ candidate = emptyRouteInfo {responses = Map.singleton "200" candResp}
+ violations = compareRouteInfo baseline candidate
+ violations `shouldBe` []
+
+ it "detects enum value added in response body" $ do
+ let baseResp = emptySchema {enumValues = Just (Set.singleton "active")}
+ candResp = emptySchema {enumValues = Just (Set.fromList ["active", "archived"])}
+ baseline = emptyRouteInfo {responses = Map.singleton "200" baseResp}
+ candidate = emptyRouteInfo {responses = Map.singleton "200" candResp}
+ violations = compareRouteInfo baseline candidate
+ violations `shouldBe` [EnumValueAdded "" "archived"]
+
+ it "handles placeholder name changes (treated as same route)" $ do
+ let rk1 =
+ RouteKey
+ { method = GET,
+ route = NormalizedRoute [Literal "users", Placeholder]
+ }
+ rk2 = rk1 -- Same structure, normalized
+ baseline = mkOpenAPISpec (Just 5) [(rk1, emptyRouteInfo)]
+ candidate = mkOpenAPISpec Nothing [(rk2, emptyRouteInfo)]
+ violations = compareSpecs baseline candidate
+ violations `shouldBe` []
+
+ it "handles body appearing where none existed" $ do
+ let candBody = emptySchema {requiredFields = Set.singleton "team"}
+ baseline = emptyRouteInfo {requestBody = Nothing}
+ candidate = emptyRouteInfo {requestBody = Just candBody}
+ violations = compareRouteInfo baseline candidate
+ violations `shouldBe` [RequiredBodyFieldAdded "team"]
+
+ it "handles body disappearing (no violation)" $ do
+ let baseBody = emptySchema {requiredFields = Set.singleton "name"}
+ baseline = emptyRouteInfo {requestBody = Just baseBody}
+ candidate = emptyRouteInfo {requestBody = Nothing}
+ violations = compareRouteInfo baseline candidate
+ violations `shouldBe` []
+
+-- | Helper route keys for tests.
+getUsers :: RouteKey
+getUsers =
+ RouteKey
+ { method = GET,
+ route = NormalizedRoute [Literal "users"]
+ }
+
+postTeams :: RouteKey
+postTeams =
+ RouteKey
+ { method = POST,
+ route = NormalizedRoute [Literal "teams"]
+ }
+
+-- | Helper to build an empty route info.
+emptyRouteInfo :: RouteInfo
+emptyRouteInfo =
+ RouteInfo
+ { operationId = Nothing,
+ queryParams = Set.empty,
+ requiredQueryParams = Set.empty,
+ requestBody = Nothing,
+ responses = Map.empty
+ }
+
+-- | Helper to build a spec from route key/info pairs.
+mkOpenAPISpec :: Maybe Int -> [(RouteKey, RouteInfo)] -> OpenAPISpec
+mkOpenAPISpec ver rs =
+ OpenAPISpec
+ { version = ver,
+ routes = Map.fromList rs
+ }
diff --git a/tools/lint-openapi-regression/test/LintOpenAPI/IgnoreSpec.hs b/tools/lint-openapi-regression/test/LintOpenAPI/IgnoreSpec.hs
new file mode 100644
index 0000000000..36f8948507
--- /dev/null
+++ b/tools/lint-openapi-regression/test/LintOpenAPI/IgnoreSpec.hs
@@ -0,0 +1,57 @@
+module LintOpenAPI.IgnoreSpec (spec) where
+
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text qualified as Text
+import Imports
+import LintOpenAPI.Ignore
+import LintOpenAPI.Types
+import Test.Hspec
+
+spec :: Spec
+spec = describe "LintOpenAPI.Ignore" $ do
+ describe "isIgnored" $ do
+ it "ignores a violation by operationId" $ do
+ let imap = Map.singleton "v5" (Set.singleton "my-route")
+ ctx = mkCtx (Just 5) (Just "my-route") GET "/path"
+ isIgnored imap ctx `shouldBe` True
+
+ it "ignores a violation by rendered route" $ do
+ let imap = Map.singleton "v5" (Set.singleton "GET /path/{_}")
+ ctx = mkCtx (Just 5) Nothing GET "/path/{_}"
+ isIgnored imap ctx `shouldBe` True
+
+ it "does not ignore if version mismatch" $ do
+ let imap = Map.singleton "v6" (Set.singleton "my-route")
+ ctx = mkCtx (Just 5) (Just "my-route") GET "/path"
+ isIgnored imap ctx `shouldBe` False
+
+ it "does not ignore if route mismatch" $ do
+ let imap = Map.singleton "v5" (Set.singleton "other-route")
+ ctx = mkCtx (Just 5) (Just "my-route") GET "/path"
+ isIgnored imap ctx `shouldBe` False
+
+ describe "updateIgnoreMap" $ do
+ it "adds new violations to the ignore map" $ do
+ let imap = Map.singleton "v5" (Set.singleton "old-route")
+ ctx1 = mkCtx (Just 5) (Just "new-route") GET "/path1"
+ ctx2 = mkCtx (Just 6) Nothing POST "/path2"
+ newMap = updateIgnoreMap imap [ctx1, ctx2]
+
+ Map.lookup "v5" newMap `shouldBe` Just (Set.fromList ["old-route", "new-route"])
+ Map.lookup "v6" newMap `shouldBe` Just (Set.singleton "POST /path2")
+
+-- | Helper to create a ViolationContext for testing
+mkCtx :: Maybe Int -> Maybe Text -> HttpMethod -> Text -> ViolationContext
+mkCtx ver opId method routeStr =
+ let rk =
+ RouteKey
+ { method = method,
+ route = NormalizedRoute [Literal (Text.drop 1 routeStr)] -- simplistic mock
+ }
+ in ViolationContext
+ { baselineVersion = ver,
+ routeKey = rk,
+ routeId = opId,
+ violation = RouteRemoved
+ }
diff --git a/tools/lint-openapi-regression/test/LintOpenAPI/NormalizeSpec.hs b/tools/lint-openapi-regression/test/LintOpenAPI/NormalizeSpec.hs
new file mode 100644
index 0000000000..3543da873b
--- /dev/null
+++ b/tools/lint-openapi-regression/test/LintOpenAPI/NormalizeSpec.hs
@@ -0,0 +1,59 @@
+module LintOpenAPI.NormalizeSpec (spec) where
+
+import Imports
+import LintOpenAPI.Normalize
+import LintOpenAPI.Types
+import Test.Hspec
+
+spec :: Spec
+spec = describe "LintOpenAPI.Normalize" $ do
+ describe "normalizePath" $ do
+ it "normalizes a simple path" $ do
+ normalizePath "/users"
+ `shouldBe` NormalizedRoute [Literal "users"]
+
+ it "normalizes a path with placeholders" $ do
+ normalizePath "/users/{userId}/clients/{clientId}"
+ `shouldBe` NormalizedRoute [Literal "users", Placeholder, Literal "clients", Placeholder]
+
+ it "normalizes root path" $ do
+ normalizePath "/"
+ `shouldBe` NormalizedRoute []
+
+ it "normalizes path without leading slash" $ do
+ normalizePath "users/{id}"
+ `shouldBe` NormalizedRoute [Literal "users", Placeholder]
+
+ it "normalizes path with multiple consecutive slashes" $ do
+ -- splitting on "/" with empty segments filtered out
+ normalizePath "/users//clients"
+ `shouldBe` NormalizedRoute [Literal "users", Literal "clients"]
+
+ it "treats different placeholder names as equal" $ do
+ normalizePath "/users/{userId}"
+ `shouldBe` normalizePath "/users/{uid}"
+
+ it "treats different literal segments as different" $ do
+ normalizePath "/users"
+ `shouldNotBe` normalizePath "/teams"
+
+ describe "routeKeyFromPath" $ do
+ it "creates a route key for GET" $ do
+ let result = routeKeyFromPath "/users/{id}" "get"
+ result `shouldBe` Just RouteKey {method = GET, route = NormalizedRoute [Literal "users", Placeholder]}
+
+ it "creates a route key for POST (case insensitive)" $ do
+ let result = routeKeyFromPath "/teams" "POST"
+ result `shouldBe` Just RouteKey {method = POST, route = NormalizedRoute [Literal "teams"]}
+
+ it "returns Nothing for unknown methods" $ do
+ routeKeyFromPath "/foo" "UNKNOWN" `shouldBe` Nothing
+
+ it "handles all HTTP methods" $ do
+ routeKeyFromPath "/x" "get" `shouldSatisfy` isJust
+ routeKeyFromPath "/x" "post" `shouldSatisfy` isJust
+ routeKeyFromPath "/x" "put" `shouldSatisfy` isJust
+ routeKeyFromPath "/x" "patch" `shouldSatisfy` isJust
+ routeKeyFromPath "/x" "delete" `shouldSatisfy` isJust
+ routeKeyFromPath "/x" "head" `shouldSatisfy` isJust
+ routeKeyFromPath "/x" "options" `shouldSatisfy` isJust
diff --git a/tools/lint-openapi-regression/test/LintOpenAPI/ParseSpec.hs b/tools/lint-openapi-regression/test/LintOpenAPI/ParseSpec.hs
new file mode 100644
index 0000000000..e4cb32936d
--- /dev/null
+++ b/tools/lint-openapi-regression/test/LintOpenAPI/ParseSpec.hs
@@ -0,0 +1,275 @@
+module LintOpenAPI.ParseSpec (spec) where
+
+import Data.Aeson (Value (..), object, (.=))
+import Data.Aeson.Key qualified as Key
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Imports
+import LintOpenAPI.Parse
+import LintOpenAPI.Types
+import Test.Hspec
+
+spec :: Spec
+spec = describe "LintOpenAPI.Parse" $ do
+ describe "parseOpenAPIValue" $ do
+ it "parses a minimal OpenAPI spec" $ do
+ let val =
+ object
+ [ "openapi" .= ("3.0.0" :: Text),
+ "paths" .= object [],
+ "info" .= object ["title" .= ("Test" :: Text), "version" .= ("" :: Text)]
+ ]
+ case parseOpenAPIValue val of
+ Left err -> expectationFailure $ "Parse failed: " <> err
+ Right spec' -> do
+ spec'.version `shouldBe` Nothing
+ Map.size spec'.routes `shouldBe` 0
+
+ it "extracts version from servers" $ do
+ let val =
+ object
+ [ "openapi" .= ("3.0.0" :: Text),
+ "paths" .= object [],
+ "servers" .= [object ["url" .= ("/v5" :: Text)]]
+ ]
+ case parseOpenAPIValue val of
+ Left err -> expectationFailure $ "Parse failed: " <> err
+ Right spec' -> spec'.version `shouldBe` Just 5
+
+ it "parses a route with query parameters" $ do
+ let val = mkSpec [("/users", "get", mkOperation [mkQueryParam "size" False, mkQueryParam "start" True] Nothing [])]
+ case parseOpenAPIValue val of
+ Left err -> expectationFailure $ "Parse failed: " <> err
+ Right spec' -> do
+ length spec'.routes `shouldBe` 1
+ ri <- headRoute spec'.routes
+ ri.queryParams `shouldBe` Set.fromList ["size", "start"]
+ ri.requiredQueryParams `shouldBe` Set.singleton "start"
+
+ it "parses a route with request body" $ do
+ let bodySchema =
+ object
+ [ "type" .= ("object" :: Text),
+ "required" .= (["name", "email"] :: [Text]),
+ "properties"
+ .= object
+ [ "name" .= object ["type" .= ("string" :: Text)],
+ "email" .= object ["type" .= ("string" :: Text)],
+ "nickname" .= object ["type" .= ("string" :: Text)]
+ ]
+ ]
+ val = mkSpec [("/users", "post", mkOperation [] (Just bodySchema) [])]
+ case parseOpenAPIValue val of
+ Left err -> expectationFailure $ "Parse failed: " <> err
+ Right spec' -> do
+ ri <- headRoute spec'.routes
+ case ri.requestBody of
+ Nothing -> expectationFailure "Expected request body"
+ Just schema -> do
+ schema.requiredFields `shouldBe` Set.fromList ["name", "email"]
+ Map.keys schema.properties `shouldSatisfy` ("nickname" `elem`)
+
+ it "parses a route with response body" $ do
+ let respSchema =
+ object
+ [ "type" .= ("object" :: Text),
+ "required" .= (["id", "name"] :: [Text]),
+ "properties"
+ .= object
+ [ "id" .= object ["type" .= ("string" :: Text)],
+ "name" .= object ["type" .= ("string" :: Text)],
+ "phone" .= object ["type" .= ("string" :: Text)]
+ ]
+ ]
+ val = mkSpec [("/self", "get", mkOperation [] Nothing [("200", respSchema)])]
+ case parseOpenAPIValue val of
+ Left err -> expectationFailure $ "Parse failed: " <> err
+ Right spec' -> do
+ ri <- headRoute spec'.routes
+ case Map.lookup "200" ri.responses of
+ Nothing -> expectationFailure "Expected 200 response"
+ Just schema -> do
+ schema.requiredFields `shouldBe` Set.fromList ["id", "name"]
+ Map.size schema.properties `shouldBe` 3
+
+ it "resolves $ref in request body" $ do
+ let val =
+ object
+ [ "openapi" .= ("3.0.0" :: Text),
+ "paths"
+ .= object
+ [ "/users"
+ .= object
+ [ "post"
+ .= object
+ [ "requestBody"
+ .= object
+ [ "content"
+ .= object
+ [ "application/json"
+ .= object
+ ["schema" .= object ["$ref" .= ("#/components/schemas/User" :: Text)]]
+ ]
+ ]
+ ]
+ ]
+ ],
+ "components"
+ .= object
+ [ "schemas"
+ .= object
+ [ "User"
+ .= object
+ [ "type" .= ("object" :: Text),
+ "required" .= (["name"] :: [Text]),
+ "properties"
+ .= object
+ [ "name" .= object ["type" .= ("string" :: Text)]
+ ]
+ ]
+ ]
+ ]
+ ]
+ case parseOpenAPIValue val of
+ Left err -> expectationFailure $ "Parse failed: " <> err
+ Right spec' -> do
+ ri <- headRoute spec'.routes
+ case ri.requestBody of
+ Nothing -> expectationFailure "Expected request body"
+ Just schema -> do
+ schema.requiredFields `shouldBe` Set.singleton "name"
+ Map.keys schema.properties `shouldBe` ["name"]
+
+ it "resolves allOf schemas" $ do
+ let val =
+ object
+ [ "openapi" .= ("3.0.0" :: Text),
+ "paths"
+ .= object
+ [ "/items"
+ .= object
+ [ "post"
+ .= object
+ [ "requestBody"
+ .= object
+ [ "content"
+ .= object
+ [ "application/json"
+ .= object
+ [ "schema"
+ .= object
+ [ "allOf"
+ .= [ object ["$ref" .= ("#/components/schemas/Base" :: Text)],
+ object
+ [ "type" .= ("object" :: Text),
+ "required" .= (["extra"] :: [Text]),
+ "properties" .= object ["extra" .= object ["type" .= ("string" :: Text)]]
+ ]
+ ]
+ ]
+ ]
+ ]
+ ]
+ ]
+ ]
+ ],
+ "components"
+ .= object
+ [ "schemas"
+ .= object
+ [ "Base"
+ .= object
+ [ "type" .= ("object" :: Text),
+ "required" .= (["id"] :: [Text]),
+ "properties" .= object ["id" .= object ["type" .= ("string" :: Text)]]
+ ]
+ ]
+ ]
+ ]
+ case parseOpenAPIValue val of
+ Left err -> expectationFailure $ "Parse failed: " <> err
+ Right spec' -> do
+ ri <- headRoute spec'.routes
+ case ri.requestBody of
+ Nothing -> expectationFailure "Expected request body"
+ Just schema -> do
+ schema.requiredFields `shouldBe` Set.fromList ["id", "extra"]
+ Map.size schema.properties `shouldBe` 2
+
+ it "handles enum values" $ do
+ let bodySchema =
+ object
+ [ "type" .= ("string" :: Text),
+ "enum" .= (["active", "inactive", "suspended"] :: [Text])
+ ]
+ val = mkSpec [("/status", "put", mkOperation [] (Just bodySchema) [])]
+ case parseOpenAPIValue val of
+ Left err -> expectationFailure $ "Parse failed: " <> err
+ Right spec' -> do
+ ri <- headRoute spec'.routes
+ case ri.requestBody of
+ Nothing -> expectationFailure "Expected request body"
+ Just schema ->
+ schema.enumValues `shouldBe` Just (Set.fromList ["active", "inactive", "suspended"])
+
+ it "rejects non-object top-level value" $ do
+ parseOpenAPIValue (String "not an object") `shouldSatisfy` isLeft
+
+-- | Helper to build a minimal OpenAPI spec value.
+mkSpec :: [(Text, Text, Value)] -> Value
+mkSpec routes =
+ object
+ [ "openapi" .= ("3.0.0" :: Text),
+ "paths" .= object (map mkPathEntry routes)
+ ]
+ where
+ mkPathEntry (path, method, op) =
+ Key.fromText path .= object [Key.fromText method .= op]
+
+-- | Helper to build an operation object.
+mkOperation :: [Value] -> Maybe Value -> [(Text, Value)] -> Value
+mkOperation params mBody resps =
+ object
+ $ ["parameters" .= params]
+ <> maybe [] (\b -> ["requestBody" .= mkRequestBody b]) mBody
+ <> ["responses" .= object (map mkResponse resps)]
+
+-- | Helper to build a query parameter.
+mkQueryParam :: Text -> Bool -> Value
+mkQueryParam name required =
+ object
+ [ "name" .= name,
+ "in" .= ("query" :: Text),
+ "required" .= required
+ ]
+
+-- | Helper to wrap a schema in a request body.
+mkRequestBody :: Value -> Value
+mkRequestBody schema =
+ object
+ [ "content"
+ .= object
+ [ "application/json"
+ .= object ["schema" .= schema]
+ ]
+ ]
+
+-- | Helper to wrap a schema in a response.
+mkResponse :: (Text, Value) -> (Key.Key, Value)
+mkResponse (code, schema) =
+ Key.fromText code
+ .= object
+ [ "content"
+ .= object
+ [ "application/json"
+ .= object ["schema" .= schema]
+ ]
+ ]
+
+-- | Helper to safely get the first route info.
+headRoute :: Map.Map RouteKey RouteInfo -> IO RouteInfo
+headRoute m = case listToMaybe (Map.elems m) of
+ Just ri -> pure ri
+ Nothing -> do
+ expectationFailure "Expected at least 1 route"
+ error "unreachable"
diff --git a/tools/lint-openapi-regression/test/Spec.hs b/tools/lint-openapi-regression/test/Spec.hs
new file mode 100644
index 0000000000..a824f8c30c
--- /dev/null
+++ b/tools/lint-openapi-regression/test/Spec.hs
@@ -0,0 +1 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}