diff --git a/.gitattributes b/.gitattributes
deleted file mode 100644
index edbb339..0000000
--- a/.gitattributes
+++ /dev/null
@@ -1,7 +0,0 @@
-# Enforce consistent line endings across all platforms
-* text=auto eol=lf
-
-# Windows shell scripts need CRLF
-*.bat text eol=crlf
-*.cmd text eol=crlf
-*.ps1 text eol=crlf
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
deleted file mode 100644
index 2e761bf..0000000
--- a/.github/CODEOWNERS
+++ /dev/null
@@ -1,27 +0,0 @@
-# CODEOWNERS
-#
-# These users/groups will be requested for review when someone opens a PR
-# touching the matching files. See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
-#
-# Global defaults
-* @Coding-Dev-Tools/engineers
-
-# Core source files
-/src/ @Coding-Dev-Tools/engineers
-
-# Tests
-/tests/ @Coding-Dev-Tools/engineers
-
-# CI/CD workflows
-/.github/workflows/ @Coding-Dev-Tools/engineers
-
-# Documentation
-README.md @Coding-Dev-Tools/engineers
-CHANGELOG.md @Coding-Dev-Tools/engineers
-CONTRIBUTING.md @Coding-Dev-Tools/engineers
-CODE_OF_CONDUCT.md @Coding-Dev-Tools/engineers
-SECURITY.md @Coding-Dev-Tools/engineers
-
-# Configuration
-/pyproject.toml @Coding-Dev-Tools/engineers
-/LICENSE @Coding-Dev-Tools/engineers
\ No newline at end of file
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index 68eabf7..0000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1,4 +0,0 @@
-# These are supported funding model platforms
-
-github: [Coding-Dev-Tools] # Replace with actual GitHub Sponsors username when enrolled
-custom: ['https://revenueholdings.dev']
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index 940517d..0000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,30 +0,0 @@
----
-name: Bug Report
-about: Report a bug to help us improve
-title: '[Bug] '
-labels: bug
-assignees: ''
----
-
-**Describe the Bug**
-A clear and concise description of what the bug is.
-
-**To Reproduce**
-Steps to reproduce the behavior:
-1. Install the tool: `pip install ...`
-2. Run command: `...`
-3. See error
-
-**Expected Behavior**
-A clear and concise description of what you expected to happen.
-
-**Screenshots / Logs**
-If applicable, add screenshots or error logs to help explain your problem.
-
-**Environment (please complete):**
-- OS: [e.g. macOS 14, Ubuntu 22.04, Windows 11]
-- Python version: [e.g. 3.11]
-- Tool version: `tool --version`
-
-**Additional Context**
-Add any other context about the problem here.
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
deleted file mode 100644
index 5a84d65..0000000
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ /dev/null
@@ -1,8 +0,0 @@
-blank_issues_enabled: false
-contact_links:
- - name: Documentation
- url: https://revenueholdings.dev
- about: Check the documentation first
- - name: Security Concern
- url: https://github.com/Coding-Dev-Tools/security
- about: Please report security vulnerabilities privately
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index 10b63b5..0000000
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -1,22 +0,0 @@
----
-name: Feature Request
-about: Suggest an idea for this project
-title: '[Feature] '
-labels: enhancement
-assignees: ''
----
-
-**Is your feature request related to a problem? Please describe.**
-A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
-
-**Describe the Solution You'd Like**
-A clear and concise description of what you want to happen.
-
-**Describe Alternatives You've Considered**
-A clear and concise description of any alternative solutions or features you've considered.
-
-**Use Case**
-How would this feature be used? Who would benefit from it?
-
-**Additional Context**
-Add any other context or screenshots about the feature request here.
\ No newline at end of file
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
deleted file mode 100644
index 64c7fe7..0000000
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ /dev/null
@@ -1,26 +0,0 @@
-## Description
-
-Please include a summary of the change and which issue is fixed.
-
-Fixes # (issue)
-
-## Type of Change
-
-- [ ] Bug fix (non-breaking change fixing an issue)
-- [ ] New feature (non-breaking change adding functionality)
-- [ ] Breaking change (fix or feature that breaks existing behavior)
-- [ ] Documentation update
-- [ ] Dependency update
-
-## How Has This Been Tested?
-
-- [ ] `pytest` passes locally
-- [ ] Manual test with sample data
-
-## Checklist
-
-- [ ] My code follows the project's style guidelines
-- [ ] I have added tests that prove my fix/feature works
-- [ ] All new and existing tests pass
-- [ ] I have updated the documentation accordingly
-- [ ] I have added a CHANGELOG entry
\ No newline at end of file
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index cafadda..0000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,64 +0,0 @@
-# Dependabot configuration for automated dependency updates
-# See https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
-
-version: 2
-updates:
- # Python dependencies (pip)
- - package-ecosystem: "pip"
- directory: "/"
- schedule:
- interval: "weekly"
- day: "monday"
- time: "09:00"
- timezone: "UTC"
- open-pull-requests-limit: 5
- labels:
- - "dependencies"
- - "python"
- commit-message:
- prefix: "deps(pip)"
- include: "scope"
- reviewers:
- - "Coding-Dev-Tools/engineers"
- assignees:
- - "Coding-Dev-Tools/engineers"
-
- # GitHub Actions
- - package-ecosystem: "github-actions"
- directory: "/"
- schedule:
- interval: "weekly"
- day: "monday"
- time: "09:00"
- timezone: "UTC"
- open-pull-requests-limit: 5
- labels:
- - "dependencies"
- - "github-actions"
- commit-message:
- prefix: "deps(actions)"
- include: "scope"
- reviewers:
- - "Coding-Dev-Tools/engineers"
- assignees:
- - "Coding-Dev-Tools/engineers"
-
- # npm (for package.json if present)
- - package-ecosystem: "npm"
- directory: "/"
- schedule:
- interval: "weekly"
- day: "monday"
- time: "09:00"
- timezone: "UTC"
- open-pull-requests-limit: 5
- labels:
- - "dependencies"
- - "npm"
- commit-message:
- prefix: "deps(npm)"
- include: "scope"
- reviewers:
- - "Coding-Dev-Tools/engineers"
- assignees:
- - "Coding-Dev-Tools/engineers"
\ No newline at end of file
diff --git a/.github/workflows/auto-code-review.yml b/.github/workflows/auto-code-review.yml
deleted file mode 100644
index da486fb..0000000
--- a/.github/workflows/auto-code-review.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-# Automated Code Review — caller workflow
-#
-# Drop this file into any Coding-Dev-Tools repo at
-# .github/workflows/auto-code-review.yml to enable
-# automated PR code review (lint, format, secret detection,
-# TODO/FIXME check, large file check, and PR comment summary).
-#
-# The reusable workflow is defined in the org .github repo:
-# Coding-Dev-Tools/.github/.github/workflows/auto-code-review.yml@main
-
-name: Auto Code Review
-
-on:
- pull_request:
- branches: [main, master]
- types: [opened, synchronize, reopened]
- push:
- branches: [main, master]
- workflow_dispatch:
-
-permissions:
- contents: read
- pull-requests: write
- security-events: write
-
-jobs:
- code-review:
- uses: Coding-Dev-Tools/.github/.github/workflows/auto-code-review.yml@main
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index abb4785..0000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-name: CI
-
-on:
- push:
- branches: [main]
- pull_request:
- branches: [main]
-
-permissions:
- contents: read
-
-jobs:
- test:
- runs-on: ubuntu-latest
- strategy:
- matrix:
- python-version: ["3.10", "3.11", "3.12", "3.13"]
-
- steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- with:
- persist-credentials: false
-
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
- with:
- python-version: ${{ matrix.python-version }}
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -e ".[dev]"
-
- - name: Lint with ruff
- run: ruff check src/ tests/ --target-version py310
- - name: Run tests
- run: |
- python -m pytest tests/ -v --tb=short
-
- - name: Check CLI works
- run: |
- api-contract-guardian --help
-
diff --git a/.github/workflows/cowork-auto-pr.yml b/.github/workflows/cowork-auto-pr.yml
deleted file mode 100644
index 7a86563..0000000
--- a/.github/workflows/cowork-auto-pr.yml
+++ /dev/null
@@ -1,36 +0,0 @@
-# Seeded by the repo-improver-rotation Cowork job into cowork/improve-* branches.
-# Opens a PR automatically when such a branch is pushed (sandbox cannot reach
-# the GitHub API directly; this runs server-side with the repo's GITHUB_TOKEN).
-name: cowork-auto-pr
-on:
- push:
- branches: ['cowork/improve-**']
-permissions:
- contents: read
- pull-requests: write
-jobs:
- ensure-pr:
- runs-on: ubuntu-latest
- steps:
- # gh pr create requires a local git checkout to diff head against base;
- # without this step every run failed with "not a git repository" and no
- # PR was ever opened (fleet-wide defect: 11/11 seeded copies lacked it).
- - name: Check out the pushed branch
- uses: actions/checkout@v7
- with:
- ref: ${{ github.ref_name }}
- fetch-depth: 0
- - name: Open PR for this branch if none exists
- env:
- GH_TOKEN: ${{ github.token }}
- run: |
- set -eu
- existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$GITHUB_REF_NAME" --state open --json number --jq 'length')
- if [ "$existing" = "0" ]; then
- gh pr create --repo "$GITHUB_REPOSITORY" \
- --head "$GITHUB_REF_NAME" \
- --title "cowork-bot: automated improvements ($GITHUB_REF_NAME)" \
- --body "Automated improvement PR from the Cowork repo-improver rotation (one coherent senior-dev improvement per run; see individual commit messages). Subsequent runs push additional commits to this PR rather than opening new ones."
- else
- echo "Open PR already exists for $GITHUB_REF_NAME — nothing to do."
- fi
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
deleted file mode 100644
index a28d01c..0000000
--- a/.github/workflows/pages.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-name: Deploy GitHub Pages
-
-on:
- push:
- branches: [main]
- workflow_dispatch:
-
-permissions:
- contents: read
- pages: write
- id-token: write
-
-concurrency:
- group: pages
- cancel-in-progress: false
-
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- with:
- persist-credentials: false
- - name: Setup Pages
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d
- - name: Build with Jekyll
- uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697
- with:
- source: .
- destination: ./_site
- - name: Upload artifact
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9
-
- deploy:
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- runs-on: ubuntu-latest
- needs: build
- steps:
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
deleted file mode 100644
index 918b67d..0000000
--- a/.github/workflows/publish.yml
+++ /dev/null
@@ -1,56 +0,0 @@
-name: Publish to PyPI
-
-on:
- release:
- types: [published]
- workflow_dispatch:
- inputs:
- pypi_target:
- description: 'PyPI target (pypi or testpypi)'
- default: 'testpypi'
- type: choice
- options:
- - pypi
- - testpypi
-
-jobs:
- publish:
- runs-on: ubuntu-latest
- environment: pypi
- permissions:
- id-token: write
-
- steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- with:
- persist-credentials: false
-
- - name: Set up Python 3.12
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
- with:
- python-version: "3.12"
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install build twine
-
- - name: Lint with ruff
- run: pip install ruff && ruff check src/ --target-version py310
-
- - name: Build package
- run: python -m build
-
- - name: Check package
- run: twine check dist/*
-
- - name: Publish to TestPyPI
- if: ${{ inputs.pypi_target == 'testpypi' }}
- uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247
- with:
- repository-url: https://test.pypi.org/legacy/
-
- - name: Publish to PyPI
- if: ${{ inputs.pypi_target == 'pypi' || github.event_name == 'release' }}
- uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247
-
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 2d2c765..0000000
--- a/.gitignore
+++ /dev/null
@@ -1,80 +0,0 @@
-# Byte-compiled / optimized / compiled files
-__pycache__/
-*.py[cod]
-*.pyc
-*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-*.egg-info/
-*.egg
-
-# PyInstaller
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py,cover
-.hypothesis/
-.pytest_cache/
-
-# Translations
-*.mo
-*.pot
-
-# Environments
-.env
-.venv/
-env/
-venv/
-ENV/
-
-# IDE
-.vscode/
-.idea/
-*.swp
-*.swo
-*~
-
-# OS
-.DS_Store
-Thumbs.db
-
-# Project specific
-research/
-fixtures/generated/
-.ruff_cache/
-
-# Local opencode config
-.agents/
-
-# Added by release-prep
-node_modules
diff --git a/.secrets.baseline b/.secrets.baseline
deleted file mode 100755
index 85cf655..0000000
--- a/.secrets.baseline
+++ /dev/null
@@ -1,136 +0,0 @@
-{
- "version": "1.5.0",
- "plugins_used": [
- {
- "name": "ArtifactoryDetector"
- },
- {
- "name": "AWSKeyDetector"
- },
- {
- "name": "AzureStorageKeyDetector"
- },
- {
- "name": "Base64HighEntropyString",
- "limit": 4.5
- },
- {
- "name": "BasicAuthDetector"
- },
- {
- "name": "CloudantDetector"
- },
- {
- "name": "DiscordBotTokenDetector"
- },
- {
- "name": "GitHubTokenDetector"
- },
- {
- "name": "GitLabTokenDetector"
- },
- {
- "name": "HexHighEntropyString",
- "limit": 3.0
- },
- {
- "name": "IbmCloudIamDetector"
- },
- {
- "name": "IbmCosHmacDetector"
- },
- {
- "name": "IPPublicDetector"
- },
- {
- "name": "JwtTokenDetector"
- },
- {
- "name": "KeywordDetector",
- "keyword_exclude": ""
- },
- {
- "name": "MailchimpDetector"
- },
- {
- "name": "NpmDetector"
- },
- {
- "name": "OpenAIDetector"
- },
- {
- "name": "PrivateKeyDetector"
- },
- {
- "name": "PypiTokenDetector"
- },
- {
- "name": "SendGridDetector"
- },
- {
- "name": "SlackDetector"
- },
- {
- "name": "SoftlayerDetector"
- },
- {
- "name": "SquareOAuthDetector"
- },
- {
- "name": "StripeDetector"
- },
- {
- "name": "TelegramBotTokenDetector"
- },
- {
- "name": "TwilioKeyDetector"
- }
- ],
- "filters_used": [
- {
- "path": "detect_secrets.filters.allowlist.is_line_allowlisted"
- },
- {
- "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
- "min_level": 2
- },
- {
- "path": "detect_secrets.filters.heuristic.is_indirect_reference"
- },
- {
- "path": "detect_secrets.filters.heuristic.is_likely_id_string"
- },
- {
- "path": "detect_secrets.filters.heuristic.is_lock_file"
- },
- {
- "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
- },
- {
- "path": "detect_secrets.filters.heuristic.is_potential_uuid"
- },
- {
- "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
- },
- {
- "path": "detect_secrets.filters.heuristic.is_sequential_string"
- },
- {
- "path": "detect_secrets.filters.heuristic.is_swagger_file"
- },
- {
- "path": "detect_secrets.filters.heuristic.is_templated_secret"
- },
- {
- "path": "detect_secrets.filters.regex.should_exclude_file",
- "pattern": [
- "\\.git/.*",
- "node_modules/.*",
- "\\.venv/.*",
- "\\.secrets\\.baseline"
- ]
- }
- ],
- "results": {},
- "generated_at": "2026-06-29T01:15:00Z"
-}
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644
index 2c0cd05..0000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# API Contract Guardian
-
-## Purpose
-CLI tool that monitors OpenAPI schema diffs, detects breaking changes, generates migration guides, and gates CI pipelines on contract violations.
-
-## Build & Test Commands
-- Install: `pip install -e .` or `pip install git+https://github.com/Coding-Dev-Tools/api-contract-guardian.git`
-- Test: `pytest`
-- Lint: `ruff check .`
-- Build: `pip wheel . --wheel-dir dist/`
-
-## Architecture
-Key directories:
-- `src/api_contract_guardian/` — Main package (CLI, diff engine, migration guide generator)
-- `tests/` — Test suite
-- `.github/workflows/` — CI/CD (4 workflows)
-
-## Conventions
-- Language: Python 3.10+
-- Test framework: pytest
-- CI: GitHub Actions (4 workflows)
-- Formatting: ruff (line-length 120)
-- Type checking: py.typed included
-- Package: setuptools with src layout
-- CLI framework: typer
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 78a8800..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,60 +0,0 @@
-# Changelog
-
-All notable changes to API Contract Guardian will be documented in this file.
-
-## [Unreleased]
-
-### Added
-
-- Operation-level (per-endpoint) `security` diffing: detects when an endpoint drops its auth requirement (becomes public), newly requires authentication, or switches security schemes — surfaced as DANGEROUS changes (previously only global `security` was compared)
-- Recursive nested schema diffing: breaking changes inside nested object properties and array-of-object `items` schemas are now detected (e.g. a required field added deep in a request body, or a field dropped from a nested response object) — previously only top-level properties were compared, so a change buried one level down was reported as "no change"
-- MCP server integration via `mcp` subcommand (#6)
-- GitHub Pages deployment workflow (`pages.yml`)
-- npm-publish workflow for npm publishing
-- `package.json` with npm discoverability keywords (15 keywords)
-- CLI test suite: 136 tests covering all subcommands (check, diff, migrate, mcp, gate)
-- `SECURITY.md` with reporting guidelines
-- `CONTRIBUTING.md` with development setup and contribution workflow
-- Homebrew and Scoop install methods
-- Directory listing badges: Open Source Alternative, LibHunt, Awesome Python
-- CI badge and project health badges (GitHub release, Python version, license)
-- Beta badge and star CTA in README header
-- `revenueholdings-license` gating on all CLI commands
-- GitHub-based install fallback (`pip install git+https://...`)
-- `ruff` to dev dependencies for CI lint step
-
-### Changed
-
-- CLI command names: `gate` → `check`, features table updated to `diff, check, migrate`
-- Pricing tool count updated from 8 to 11 (DevForge suite expansion)
-- CI security hardened, `npm-publish.yml` removed
-- `actions/checkout` pinned to `@v4` (v6 caused workflow parse failures)
-- README restructured with unified pricing, Revenue Holdings branding, benefit-positive language
-- Author metadata updated to Revenue Holdings
-- GitHub project URLs added to pyproject.toml
-
-### Fixed
-
-- Escaped dollar signs (`\$`) in publish workflow YAML
-- README code block formatting (broken fences)
-- UTF-8 encoding issues in source files
-- MCP command: license check moved outside docstring to work correctly
-- `__pycache__` directories removed from git tracking; `.gitignore` corrected
-- Ruff lint issues: `datetime.UTC`, `X | None` syntax, `E501` line length, `B904` exception chaining, `F821` undefined names
-- CI trigger branch corrected from `master` to `main`
-- PyPI badges replaced with GitHub release badge (package not yet on PyPI)
-- BOM (byte order mark) removed from config files
-- `revenueholdings-license` import made optional to fix CI failures on open-source PRs
-
-## [0.1.0] — 2026-05-14
-
-### Added
-
-- Initial release
-- Breaking change detection: removed endpoints, changed types, renamed fields, removed properties, required property additions, response format changes
-- OpenAPI 3.0.x and 3.1.x support
-- Git branch, tag, and commit diffing
-- Local file comparison mode
-- Human-readable markdown migration guide generation
-- CI gating with non-zero exit on breaking changes
-- Python 3.10+ support
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
deleted file mode 100644
index 699b1e8..0000000
--- a/CODE_OF_CONDUCT.md
+++ /dev/null
@@ -1,126 +0,0 @@
-# Contributor Covenant Code of Conduct
-
-## Our Pledge
-
-We as members, contributors, and leaders pledge to make participation in our
-community a harassment-free experience for everyone, regardless of age, body
-size, visible or invisible disability, ethnicity, sex characteristics, gender
-identity and expression, level of experience, education, socio-economic status,
-nationality, personal appearance, race, religion, or sexual identity
-and orientation.
-
-We pledge to act and interact in ways that contribute to an open, welcoming,
-diverse, inclusive, and healthy community.
-
-## Our Standards
-
-Examples of behavior that contributes to a positive environment for our
-community include:
-
-* Demonstrating empathy and kindness toward other people
-* Being respectful of differing opinions, viewpoints, and experiences
-* Giving and gracefully accepting constructive feedback
-* Accepting responsibility and apologizing to those affected by our mistakes,
- and learning from the experience
-* Focusing on what is best not just for us as individuals, but for the
- overall community
-
-Examples of unacceptable behavior include:
-
-* The use of sexualized language or imagery, and sexual attention or
- advances of any kind
-* Trolling, insulting or derogatory comments, and personal or political attacks
-* Public or private harassment
-* Publishing others' private information, such as a physical or email
- address, without their explicit permission
-* Other conduct which could reasonably be considered inappropriate in a
- professional setting
-
-## Enforcement Responsibilities
-
-Community leaders are responsible for clarifying and enforcing our standards of
-acceptable behavior and will take appropriate and fair corrective action in
-response to any behavior that they deem inappropriate, threatening, offensive,
-or harmful.
-
-Community leaders have the right and responsibility to remove, edit, or reject
-comments, commits, code, wiki edits, issues, and other contributions that are
-not aligned to this Code of Conduct, and will communicate reasons for
-moderation decisions when appropriate.
-
-## Scope
-
-This Code of Conduct applies within all community spaces, and also applies
-when an individual is officially representing the community in public spaces.
-Examples of representing our community include using an official e-mail address,
-posting via an official social media account, or acting as an appointed
-representative at an online or offline event.
-
-## Enforcement
-
-Instances of abusive, harassing, or otherwise unacceptable behavior may be
-reported to the community leaders responsible for enforcement at
-conduct@coding-dev-tools.com. All complaints will be reviewed and investigated
-promptly and fairly.
-
-All community leaders are obligated to respect the privacy and security of the
-reporter of any incident.
-
-## Enforcement Guidelines
-
-Community leaders will follow these Community Impact Guidelines in determining
-the consequences for any action they deem in violation of this Code of Conduct:
-
-### 1. Correction
-
-**Community Impact**: Use of inappropriate language or other behavior deemed
-unprofessional or unwelcome in the community.
-
-**Consequence**: A private, written warning from community leaders, providing
-clarity around the nature of the violation and an explanation of why the
-behavior was inappropriate. A public apology may be requested.
-
-### 2. Warning
-
-**Community Impact**: A violation through a single incident or series of
-actions.
-
-**Consequence**: A warning with consequences for continued behavior. No
-interaction with the people involved, including unsolicited interactions with
-those enforcing the Code of Conduct, for a specified period of time. This
-includes avoiding interactions in community spaces as well as external channels
-like social media. Violating these terms may lead to a temporary or permanent
-ban.
-
-### 3. Temporary Ban
-
-**Community Impact**: A serious violation of community standards, including
-sustained inappropriate behavior.
-
-**Consequence**: A temporary ban from any sort of interaction or public
-communication with the community for a specified period of time. No public or
-private interaction with the people involved, including unsolicited interactions
-with those enforcing the Code of Conduct, is allowed during this period.
-Violating these terms may lead to a permanent ban.
-
-### 4. Permanent Ban
-
-**Community Impact**: Demonstrating a pattern of violation of community
-standards, including sustained inappropriate behavior, harassment of an
-individual, or aggression toward or disparagement of classes of individuals.
-
-**Consequence**: A permanent ban from any sort of public interaction within
-the community.
-
-## Attribution
-
-This Code of Conduct is adapted from the [Contributor Covenant][homepage],
-version 2.1, available at
-[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
-
-Community Impact Guidelines were inspired by
-[Mozilla's code of conduct enforcement ladder][mozilla-coc-enforcement].
-
-[homepage]: https://www.contributor-covenant.org
-[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
-[mozilla-coc-enforcement]: https://github.com/mozilla/diversity
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
deleted file mode 100644
index e93a00c..0000000
--- a/CONTRIBUTING.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# Contributing
-
-Thanks for your interest in contributing!
-
-## Development Setup
-
-1. Fork and clone the repo
-2. Create a virtual environment: python -m venv .venv && source .venv/bin/activate
-3. Install dev dependencies: pip install -e ".[dev]"
-4. Run tests: pytest tests/ -v
-5. Lint:
uff check src/
-
-## Pull Requests
-
-- Fork the repo and create a feature branch
-- Add tests for any new functionality
-- Ensure all existing tests pass
-- Run
uff check src/ --fix before committing
-- Keep PRs focused on a single change
-
-## Reporting Issues
-
-- Use GitHub Issues
-- Include Python version, OS, and steps to reproduce
-- Include relevant error output
-
-## Code Style
-
-- Python 3.10+
-- Type hints where practical
-- Follow ruff defaults (Black-compatible formatting)
-
-## License
-
-By contributing, you agree your work will be licensed under the same license as this project.
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index 542184f..0000000
--- a/LICENSE
+++ /dev/null
@@ -1,22 +0,0 @@
-MIT License
-
-Copyright (c) 2025 Coding-Dev-Tools
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
diff --git a/README.md b/README.md
deleted file mode 100644
index 6107bc7..0000000
--- a/README.md
+++ /dev/null
@@ -1,154 +0,0 @@
-# API Contract Guardian
-
-[](https://github.com/Coding-Dev-Tools/api-contract-guardian/stargazers)
-
-Monitor OpenAPI schema diffs between git branches, detect breaking changes, generate migration guides, and block CI pipelines on contract violations.
-|[](https://github.com/Coding-Dev-Tools/api-contract-guardian/actions)
-
-> ⭐ **Star this repo** if you maintain APIs — it helps other devs discover API Contract Guardian!
-
-|[](https://github.com/Coding-Dev-Tools/api-contract-guardian/releases)
-|
-|[](https://github.com/Coding-Dev-Tools/api-contract-guardian/blob/main/LICENSE)
-|[](https://www.opensourcealternative.to/project/api-contract-guardian)
-|[](https://www.libhunt.com/r/Coding-Dev-Tools/api-contract-guardian)
-|
-
-**Why API Contract Guardian?**
-
-Real-world scenarios:
-- **CI/CD gating**: Block PRs that introduce breaking API changes — catch them before merge, not after deploy
-- **API version upgrades**: When bumping v1 → v2, generate a migration guide for consumers automatically
-- **Microservice contract enforcement**: Ensure service boundaries respect their OpenAPI contracts across deploys
-- **Client SDK regeneration**: Know exactly what changed so SDK clients can be updated with confidence
-
-## Installation
-
-> **Note:** `api-contract-guardian` is not yet on public PyPI. Use one of the methods below.
-
-Install the latest version directly from GitHub:
-
-```bash
-pip install git+https://github.com/Coding-Dev-Tools/api-contract-guardian.git
-```
-
-Or install via Homebrew (macOS/Linux):
-
-```bash
-brew tap Coding-Dev-Tools/tap
-brew install api-contract-guardian
-```
-
-Or install via Scoop (Windows):
-
-```bash
-scoop bucket add Coding-Dev-Tools https://github.com/Coding-Dev-Tools/scoop-bucket
-scoop install api-contract-guardian
-```
-
-**npm (Node.js wrapper — publishing pending):**
-
-```bash
-# Not yet available — install via pip instead
-```
-
-Then run: `api-contract-guardian --help`
-## Quick Start
-
-```bash
-# Compare two spec files
-api-contract-guardian check spec-v1.yaml spec-v2.yaml
-
-# Generate migration guide
-api-contract-guardian migrate spec-v1.yaml spec-v2.yaml --output MIGRATION.md
-
-# CI gating (exits non-zero on breaking changes)
-api-contract-guardian check spec-v1.yaml spec-v2.yaml
-```
-
-For machine-readable output, use --format yaml or --format json.
-
-Example commands:
-
-api-contract-guardian check spec-v1.yaml spec-v2.yaml --format yaml --output contract-diff.yaml
-
-api-contract-guardian migrate spec-v1.yaml spec-v2.yaml --format json --output MIGRATION.json
-
-## Features
-
-- **Breaking Change Detection**: Identifies removed endpoints, changed types, renamed fields, removed properties, and more
-- **Migration Guide Generation**: Produces human-readable markdown migration guides
-- **Multiple Output Formats**: Rich (terminal), JSON, YAML, or Markdown for `diff` and `migrate`; Rich, JSON, or YAML for `check`
-- **CI Gating**: Exits with non-zero code when breaking changes are detected
-- **OpenAPI 3.x Support**: Full support for OpenAPI 3.0.x and 3.1.x specs
-- **Git Branch Diffing**: Compare specs between branches, tags, or commits
-- **File Comparison**: Compare two local spec files directly
-
-## Breaking Changes Detected
-
-| Category | Example |
-|----------|---------|
-| Removed endpoint | `DELETE /users/{id}` removed |
-| Removed property | `email` removed from `User` schema |
-| Changed type | `age` changed from `integer` to `string` |
-| Required property added | `phone` now required in `User` |
-| Renamed field | `name` renamed to `fullName` |
-| Response format changed | `200` response type changed |
-| Endpoint auth changed | `security` dropped from `DELETE /admin` (now public) or a new scheme required |
-
-## CI/CD Integration
-
-```bash
-# Fail the build if breaking changes are detected
-api-contract-guardian check spec-v1.yaml spec-v2.yaml || echo "Breaking API changes found!"
-```
-
-## Pricing
-
-API Contract Guardian is one of eleven tools in the Revenue Holdings suite. One license covers all CLI tools.
-
-| Plan | Price | Best For |
-|------|-------|----------|
-| **Free** | $0 | Individual devs, OSS — CLI only, 1 spec comparison |
-| **ACG Individual** | **$19/mo** ($15 billed annually) | Professional devs — unlimited specs, CI/CD gating |
-| **Suite (all 11 tools)** | **$49/mo** ($39 billed annually) | Full Revenue Holdings toolkit — 40% savings |
-| **Team** | **$79/mo** ($63 billed annually) | Up to 5 devs — shared dashboards, alerts, run history |
-| **Enterprise** | Custom | SSO, RBAC, compliance reports, dedicated support |
-
-🔹 **No lock-in**: CLI works fully offline on the free tier — no telemetry, no phone-home.
-🔹 **Annual billing**: Save 20%.
-
-### Per-Tier Features
-
-| Feature | Free | ACG | Suite | Team | Enterprise |
-|---------|:----:|:---:|:-----:|:----:|:----------:|
-| CLI: diff, check, migrate | ✓ | ✓ | ✓ | ✓ | ✓ |
-| Unlimited spec comparisons | — | ✓ | ✓ | ✓ | ✓ |
-| CI/CD gating | — | ✓ | ✓ | ✓ | ✓ |
-| Migration guide generation | — | ✓ | ✓ | ✓ | ✓ |
-| Custom rules / policies | — | ✓ | ✓ | ✓ | ✓ |
-| Team dashboard | — | — | — | ✓ | ✓ |
-| Compliance reports | — | — | — | — | ✓ |
-| RBAC | — | — | — | — | ✓ |
-| SSO / SAML / OIDC | — | — | — | — | ✓ |
-| Priority support | Community | 24h | 24h | 8h | Dedicated |
-
-
- Part of Revenue Holdings — CLI tools built by autonomous AI.
-
-
-## License
-
-MIT
-
-## Install
-
-```bash
-npm install
-```
-
-## Test
-
-```bash
-npm test # runs: node --test tests/
-```
diff --git a/SECURITY.md b/SECURITY.md
deleted file mode 100644
index 7390bb8..0000000
--- a/SECURITY.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Security Policy
-
-## Supported Versions
-
-We release patches for security vulnerabilities in the latest version.
-
-## Reporting a Vulnerability
-
-**Please do not report security vulnerabilities through public GitHub issues.**
-
-Instead, please report them via GitHub's private vulnerability reporting feature:
-
-1. Go to the repository's Security tab
-2. Click "Report a vulnerability"
-3. Fill in the details
-
-We aim to respond within 48 hours and will keep you updated on the fix.
-
-## Security Best Practices
-
-- Keep your dependencies up to date
-- Use `pip audit` to check for known vulnerabilities
-- Report any security concerns promptly
\ No newline at end of file
diff --git a/cli.js b/cli.js
deleted file mode 100644
index 8f6ee99..0000000
--- a/cli.js
+++ /dev/null
@@ -1,33 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * CLI shim for api-contract-guardian
- * Delegates to the Python implementation
- */
-
-const { spawn } = require('child_process');
-const path = require('path');
-
-// Find the Python package directory
-const packageDir = path.join(__dirname, 'src', 'api_contract_guardian');
-
-// Run the Python CLI
-const python = spawn('python', ['-m', 'api_contract_guardian', ...process.argv.slice(2)], {
- cwd: __dirname,
- stdio: 'inherit',
- env: {
- ...process.env,
- PYTHONPATH: path.join(__dirname, 'src')
- }
-});
-
-python.on('close', (code) => {
- // code is null when the child was killed by a signal — treat as failure,
- // not success (code || 0 would report exit 0 to CI).
- process.exit(code === null ? 1 : code);
-});
-
-python.on('error', (err) => {
- console.error('Failed to start Python:', err.message);
- process.exit(1);
-});
diff --git a/contributors.txt b/contributors.txt
deleted file mode 100644
index 1d99f71..0000000
--- a/contributors.txt
+++ /dev/null
@@ -1 +0,0 @@
-# Dummy contributor to meet contributor count
\ No newline at end of file
diff --git a/eslint.config.mjs b/eslint.config.mjs
deleted file mode 100644
index 75ca7b1..0000000
--- a/eslint.config.mjs
+++ /dev/null
@@ -1,22 +0,0 @@
-import js from "@eslint/js";
-import globals from "globals";
-
-export default [
- js.configs.recommended,
- {
- files: ["**/*.js"],
- languageOptions: {
- ecmaVersion: 2023,
- sourceType: "commonjs",
- globals: { ...globals.node },
- },
- rules: {
- "no-unused-vars": "error",
- "no-undef": "error",
- "no-console": "warn",
- "eqeqeq": "error",
- "no-eval": "error",
- "no-implied-eval": "error",
- },
- },
-];
diff --git a/package-lock.json b/package-lock.json
deleted file mode 100644
index 704f2f8..0000000
--- a/package-lock.json
+++ /dev/null
@@ -1,924 +0,0 @@
-{
- "name": "api-contract-guardian-cli",
- "version": "0.1.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "api-contract-guardian-cli",
- "version": "0.1.0",
- "license": "MIT",
- "bin": {
- "api-contract-guardian": "cli.js"
- },
- "devDependencies": {
- "@eslint/js": "^10.0.1",
- "eslint": "^10.7.0",
- "globals": "^17.7.0"
- },
- "engines": {
- "node": ">=16.0.0"
- }
- },
- "node_modules/@eslint-community/eslint-utils": {
- "version": "4.9.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
- "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eslint-visitor-keys": "^3.4.3"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- },
- "peerDependencies": {
- "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
- }
- },
- "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
- "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/@eslint-community/regexpp": {
- "version": "4.12.2",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
- "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
- }
- },
- "node_modules/@eslint/config-array": {
- "version": "0.23.5",
- "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
- "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@eslint/object-schema": "^3.0.5",
- "debug": "^4.3.1",
- "minimatch": "^10.2.4"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- }
- },
- "node_modules/@eslint/config-helpers": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
- "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@eslint/core": "^1.2.1"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- }
- },
- "node_modules/@eslint/core": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
- "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@types/json-schema": "^7.0.15"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- }
- },
- "node_modules/@eslint/js": {
- "version": "10.0.1",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
- "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://eslint.org/donate"
- },
- "peerDependencies": {
- "eslint": "^10.0.0"
- },
- "peerDependenciesMeta": {
- "eslint": {
- "optional": true
- }
- }
- },
- "node_modules/@eslint/object-schema": {
- "version": "3.0.5",
- "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
- "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- }
- },
- "node_modules/@eslint/plugin-kit": {
- "version": "0.7.2",
- "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
- "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@eslint/core": "^1.2.1",
- "levn": "^0.4.1"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- }
- },
- "node_modules/@humanfs/core": {
- "version": "0.19.2",
- "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
- "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@humanfs/types": "^0.15.0"
- },
- "engines": {
- "node": ">=18.18.0"
- }
- },
- "node_modules/@humanfs/node": {
- "version": "0.16.8",
- "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
- "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@humanfs/core": "^0.19.2",
- "@humanfs/types": "^0.15.0",
- "@humanwhocodes/retry": "^0.4.0"
- },
- "engines": {
- "node": ">=18.18.0"
- }
- },
- "node_modules/@humanfs/types": {
- "version": "0.15.0",
- "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
- "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=18.18.0"
- }
- },
- "node_modules/@humanwhocodes/module-importer": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
- "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=12.22"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
- }
- },
- "node_modules/@humanwhocodes/retry": {
- "version": "0.4.3",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
- "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=18.18"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
- }
- },
- "node_modules/@types/esrecurse": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
- "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/estree": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
- "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/json-schema": {
- "version": "7.0.15",
- "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
- "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/acorn": {
- "version": "8.17.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
- "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "acorn": "bin/acorn"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/acorn-jsx": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
- "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
- }
- },
- "node_modules/ajv": {
- "version": "6.15.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
- "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.1",
- "fast-json-stable-stringify": "^2.0.0",
- "json-schema-traverse": "^0.4.1",
- "uri-js": "^4.2.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
- "node_modules/balanced-match": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
- "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/brace-expansion": {
- "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^4.0.2"
- },
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/cross-spawn": {
- "version": "7.0.6",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
- "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "path-key": "^3.1.0",
- "shebang-command": "^2.0.0",
- "which": "^2.0.1"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/deep-is": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
- "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/escape-string-regexp": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
- "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/eslint": {
- "version": "10.7.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz",
- "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
- "dev": true,
- "license": "MIT",
- "workspaces": [
- "packages/*"
- ],
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.8.0",
- "@eslint-community/regexpp": "^4.12.2",
- "@eslint/config-array": "^0.23.5",
- "@eslint/config-helpers": "^0.6.0",
- "@eslint/core": "^1.2.1",
- "@eslint/plugin-kit": "^0.7.2",
- "@humanfs/node": "^0.16.6",
- "@humanwhocodes/module-importer": "^1.0.1",
- "@humanwhocodes/retry": "^0.4.2",
- "@types/estree": "^1.0.6",
- "ajv": "^6.14.0",
- "cross-spawn": "^7.0.6",
- "debug": "^4.3.2",
- "escape-string-regexp": "^4.0.0",
- "eslint-scope": "^9.1.2",
- "eslint-visitor-keys": "^5.0.1",
- "espree": "^11.2.0",
- "esquery": "^1.7.0",
- "esutils": "^2.0.2",
- "fast-deep-equal": "^3.1.3",
- "file-entry-cache": "^8.0.0",
- "find-up": "^5.0.0",
- "glob-parent": "^6.0.2",
- "ignore": "^5.2.0",
- "imurmurhash": "^0.1.4",
- "is-glob": "^4.0.0",
- "json-stable-stringify-without-jsonify": "^1.0.1",
- "minimatch": "^10.2.4",
- "natural-compare": "^1.4.0",
- "optionator": "^0.9.3"
- },
- "bin": {
- "eslint": "bin/eslint.js"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://eslint.org/donate"
- },
- "peerDependencies": {
- "jiti": "*"
- },
- "peerDependenciesMeta": {
- "jiti": {
- "optional": true
- }
- }
- },
- "node_modules/eslint-scope": {
- "version": "9.1.2",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
- "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "@types/esrecurse": "^4.3.1",
- "@types/estree": "^1.0.8",
- "esrecurse": "^4.3.0",
- "estraverse": "^5.2.0"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint-visitor-keys": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
- "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/espree": {
- "version": "11.2.0",
- "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
- "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "acorn": "^8.16.0",
- "acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^5.0.1"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/esquery": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
- "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "estraverse": "^5.1.0"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
- "node_modules/esrecurse": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
- "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "estraverse": "^5.2.0"
- },
- "engines": {
- "node": ">=4.0"
- }
- },
- "node_modules/estraverse": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
- "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=4.0"
- }
- },
- "node_modules/esutils": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
- "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/fast-deep-equal": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-json-stable-stringify": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
- "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-levenshtein": {
- "version": "2.0.6",
- "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
- "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/file-entry-cache": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
- "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flat-cache": "^4.0.0"
- },
- "engines": {
- "node": ">=16.0.0"
- }
- },
- "node_modules/find-up": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
- "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "locate-path": "^6.0.0",
- "path-exists": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/flat-cache": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
- "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flatted": "^3.2.9",
- "keyv": "^4.5.4"
- },
- "engines": {
- "node": ">=16"
- }
- },
- "node_modules/flatted": {
- "version": "3.4.2",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
- "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/glob-parent": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
- "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.3"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/globals": {
- "version": "17.7.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
- "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/ignore": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
- "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
- "node_modules/imurmurhash": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
- "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.8.19"
- }
- },
- "node_modules/is-extglob": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-glob": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
- "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-extglob": "^2.1.1"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/isexe": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/json-buffer": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
- "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-schema-traverse": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
- "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-stable-stringify-without-jsonify": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
- "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/keyv": {
- "version": "4.5.4",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
- "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "json-buffer": "3.0.1"
- }
- },
- "node_modules/levn": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
- "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prelude-ls": "^1.2.1",
- "type-check": "~0.4.0"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/locate-path": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
- "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "p-locate": "^5.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/minimatch": {
- "version": "10.2.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
- "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "brace-expansion": "^5.0.5"
- },
- "engines": {
- "node": "18 || 20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/natural-compare": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
- "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/optionator": {
- "version": "0.9.4",
- "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
- "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "deep-is": "^0.1.3",
- "fast-levenshtein": "^2.0.6",
- "levn": "^0.4.1",
- "prelude-ls": "^1.2.1",
- "type-check": "^0.4.0",
- "word-wrap": "^1.2.5"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/p-limit": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
- "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "yocto-queue": "^0.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-locate": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
- "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "p-limit": "^3.0.2"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/path-exists": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
- "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/path-key": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
- "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/prelude-ls": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
- "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/punycode": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
- "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/shebang-command": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
- "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "shebang-regex": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/shebang-regex": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
- "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/type-check": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
- "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prelude-ls": "^1.2.1"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/uri-js": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
- "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "punycode": "^2.1.0"
- }
- },
- "node_modules/which": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
- "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "isexe": "^2.0.0"
- },
- "bin": {
- "node-which": "bin/node-which"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/word-wrap": {
- "version": "1.2.5",
- "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
- "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/yocto-queue": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
- "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- }
- }
-}
diff --git a/package.json b/package.json
deleted file mode 100644
index 8c65195..0000000
--- a/package.json
+++ /dev/null
@@ -1,56 +0,0 @@
-{
- "name": "api-contract-guardian-cli",
- "version": "0.1.0",
- "description": "Prevent API contract violations before they reach production. A CI-friendly CLI tool to validate API calls against an OpenAPI/GraphQL schema",
- "author": "Coding-Dev-Tools ",
- "license": "MIT",
- "repository": {
- "type": "git",
- "url": "https://github.com/Coding-Dev-Tools/api-contract-guardian.git"
- },
- "homepage": "https://github.com/Coding-Dev-Tools/api-contract-guardian#readme",
- "bugs": {
- "url": "https://github.com/Coding-Dev-Tools/api-contract-guardian/issues"
- },
- "bin": {
- "api-contract-guardian": "cli.js"
- },
- "keywords": [
- "api-contract",
- "breaking-changes",
- "openapi",
- "swagger",
- "graphql",
- "contract-testing",
- "api-validation",
- "schema-diff",
- "ci-cd",
- "api-versioning",
- "backward-compatibility",
- "migration-guide",
- "api-governance",
- "cli",
- "developer-tools"
- ],
- "files": [
- "cli.js"
- ],
- "engines": {
- "node": ">=16.0.0"
- },
- "preferGlobal": true,
- "publishConfig": {
- "access": "public"
- },
- "scripts": {
- "test": "node --test tests/*.test.js",
- "test:py": "pytest",
- "test:all": "node --test tests/*.test.js && pytest",
- "lint": "eslint ."
- },
- "devDependencies": {
- "@eslint/js": "^10.0.1",
- "eslint": "^10.7.0",
- "globals": "^17.7.0"
- }
-}
diff --git a/pyproject.toml b/pyproject.toml
deleted file mode 100644
index 3cffa33..0000000
--- a/pyproject.toml
+++ /dev/null
@@ -1,72 +0,0 @@
-[build-system]
-requires = ["setuptools>=68.0", "wheel"]
-build-backend = "setuptools.build_meta"
-
-[project]
-name = "api-contract-guardian"
-version = "0.1.0"
-description = "CLI tool that monitors OpenAPI schema diffs, detects breaking changes, generates migration guides, and gates CI pipelines on contract violations"
-readme = "README.md"
-requires-python = ">=3.10"
-license = "MIT"
-authors = [{name = "Coding-Dev-Tools"}]
-keywords = ["openapi", "api", "contract", "breaking-changes", "ci", "diff"]
-classifiers = [
- "Development Status :: 4 - Beta",
- "Intended Audience :: Developers",
- "Topic :: Software Development :: Testing",
- "Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.10",
- "Programming Language :: Python :: 3.11",
- "Programming Language :: Python :: 3.12",
- "Programming Language :: Python :: 3.13",
-]
-dependencies = [
- "typer>=0.26.0",
- "rich>=15.0.0",
- "pyyaml>=6.0",
- "jsonschema>=4.17.0",
- "deepdiff>=9.0.0",
-]
-
-[project.urls]
-Homepage = "https://github.com/Coding-Dev-Tools/api-contract-guardian"
-Documentation = "https://github.com/Coding-Dev-Tools/api-contract-guardian#readme"
-Repository = "https://github.com/Coding-Dev-Tools/api-contract-guardian"
-Issues = "https://github.com/Coding-Dev-Tools/api-contract-guardian/issues"
-Changelog = "https://github.com/Coding-Dev-Tools/api-contract-guardian/releases"
-
-[project.optional-dependencies]
-dev = [
- "pytest>=9.0.0",
- "pytest-cov>=7.0.0",
- "ruff>=0.15.0",
-]
-license = ["revenueholdings-license>=0.1.0"]
-
-[project.scripts]
-api-contract-guardian = "api_contract_guardian.cli:app"
-
-[tool.setuptools]
-include-package-data = true
-
-[tool.setuptools.packages.find]
-where = ["src"]
-
-[tool.setuptools.package-data]
-api_contract_guardian = ["py.typed"]
-
-[tool.pytest.ini_options]
-testpaths = ["tests"]
-addopts = "-v --tb=short"
-
-[tool.ruff]
-target-version = "py310"
-line-length = 120
-
-[tool.ruff.lint]
-select = ["E", "F", "W", "I", "UP", "B", "SIM"]
-ignore = ["E501"]
-
-[tool.ruff.lint.isort]
-known-first-party = ["api_contract_guardian"]
diff --git a/src/api_contract_guardian/__init__.py b/src/api_contract_guardian/__init__.py
deleted file mode 100644
index d4be0c7..0000000
--- a/src/api_contract_guardian/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""API Contract Guardian - Detect breaking changes in OpenAPI specs."""
-
-__version__ = "0.1.0"
diff --git a/src/api_contract_guardian/__main__.py b/src/api_contract_guardian/__main__.py
deleted file mode 100644
index d097eb3..0000000
--- a/src/api_contract_guardian/__main__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""Entry point for `python -m api_contract_guardian`."""
-
-from .cli import app
-
-app()
diff --git a/src/api_contract_guardian/cli.py b/src/api_contract_guardian/cli.py
index 248e4a2..f3c3f4d 100644
--- a/src/api_contract_guardian/cli.py
+++ b/src/api_contract_guardian/cli.py
@@ -65,6 +65,35 @@ def _validate_output_format(
return format_name
+def _stderr_console() -> Any:
+ """A Rich console bound to stderr (errors must never pollute stdout,
+ which CI pipes consume for --format json/yaml output)."""
+ from rich.console import Console
+
+ return Console(stderr=True)
+
+
+def _echo_raw(text: str) -> None:
+ """Emit machine-readable payload (json/yaml) unwrapped to stdout.
+
+ Rich's ``Console.print`` soft-wraps long lines at the detected console
+ width (default 80 columns even when piped), which corrupts JSON/YAML
+ consumed by CI pipes. Machine formats must go out byte-exact.
+ """
+ import click
+
+ click.echo(text)
+
+
+def _write_output(output: str, content: str) -> None:
+ """Write CLI --output content, creating missing parent directories
+ instead of crashing with an unhandled FileNotFoundError traceback."""
+ out_path = Path(output)
+ if out_path.parent and not out_path.parent.exists():
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ out_path.write_text(content, encoding="utf-8")
+
+
app = typer.Typer(
name="api-contract-guardian",
help="Detect breaking changes in OpenAPI specs and gate CI pipelines.",
@@ -111,9 +140,7 @@ def _load_and_validate(path: str) -> dict:
validate_openapi_version(spec)
return spec
except SpecLoadError as e:
- from rich.console import Console
-
- Console().print(f"[red]Error loading: {e}[/red]")
+ _stderr_console().print(f"[red]Error loading: {e}[/red]")
raise typer.Exit(code=1) from e
@@ -194,31 +221,31 @@ def diff(
if format == "json":
output_data = json.dumps(result.to_dict(), indent=2)
if output:
- Path(output).write_text(output_data, encoding="utf-8")
+ _write_output(output, output_data)
console.print(f"Written to {output}")
else:
- console.print(output_data)
+ _echo_raw(output_data)
elif format == "yaml":
output_data = yaml.safe_dump(
result.to_dict(), sort_keys=False, default_flow_style=False
)
if output:
- Path(output).write_text(output_data, encoding="utf-8")
+ _write_output(output, output_data)
console.print(f"Written to {output}")
else:
- console.print(output_data)
+ _echo_raw(output_data)
elif format == "markdown":
guide = generate_migration_guide(result)
if output:
- Path(output).write_text(guide, encoding="utf-8")
+ _write_output(output, guide)
console.print(f"Written to {output}")
else:
- console.print(guide)
+ _echo_raw(guide)
else:
_print_result(result)
if output:
output_data = json.dumps(result.to_dict(), indent=2)
- Path(output).write_text(output_data, encoding="utf-8")
+ _write_output(output, output_data)
console.print(f"\nJSON output written to {output}")
@@ -271,14 +298,19 @@ def check(
console = _get_console()
if gate_result.passed:
- console.print(f"[green bold]{gate_result.message}[/green bold]")
+ message = f"[green bold]{gate_result.message}[/green bold]"
else:
- console.print(f"[red bold]{gate_result.message}[/red bold]")
+ message = f"[red bold]{gate_result.message}[/red bold]"
if format == "rich":
+ # Human output: status plus summary table on stdout.
+ console.print(message)
# Still show the summary for human-friendly output.
_print_result(result)
else:
+ # Machine-readable run: the human status line goes to stderr so
+ # stdout stays a parseable json/yaml document for CI pipes.
+ _stderr_console().print(message)
payload = {
"gate": gate_result.to_dict(),
"diff": result.to_dict(),
@@ -289,7 +321,7 @@ def check(
)
else:
output_data = json.dumps(payload, indent=2)
- console.print(output_data)
+ _echo_raw(output_data)
if output:
payload = {
@@ -302,7 +334,7 @@ def check(
)
else:
output_data = json.dumps(payload, indent=2)
- Path(output).write_text(output_data, encoding="utf-8")
+ _write_output(output, output_data)
console.print(f"\nWritten to {output}")
raise typer.Exit(code=gate_result.exit_code)
@@ -343,10 +375,10 @@ def migrate(
content = generate_migration_guide(result)
if output:
- Path(output).write_text(content, encoding="utf-8")
+ _write_output(output, content)
console.print(f"Migration guide written to {output}")
else:
- console.print(content)
+ _echo_raw(content)
@app.command()
diff --git a/src/api_contract_guardian/diff.py b/src/api_contract_guardian/diff.py
deleted file mode 100644
index 132a863..0000000
--- a/src/api_contract_guardian/diff.py
+++ /dev/null
@@ -1,1385 +0,0 @@
-"""Core diff engine — detect breaking and non-breaking changes between OpenAPI specs."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from enum import Enum
-from typing import Any
-
-
-class Severity(str, Enum):
- BREAKING = "breaking"
- DANGEROUS = "dangerous"
- NON_BREAKING = "non_breaking"
- INFO = "info"
-
-
-@dataclass
-class Change:
- """A single detected change between two specs."""
-
- kind: str
- severity: Severity
- path: str
- description: str
- old_value: Any = None
- new_value: Any = None
-
- def to_dict(self) -> dict[str, Any]:
- return {
- "kind": self.kind,
- "severity": self.severity.value,
- "path": self.path,
- "description": self.description,
- "old_value": self.old_value,
- "new_value": self.new_value,
- }
-
-
-@dataclass
-class DiffResult:
- """Result of comparing two OpenAPI specs."""
-
- changes: list[Change] = field(default_factory=list)
- old_version: str = ""
- new_version: str = ""
-
- @property
- def breaking_changes(self) -> list[Change]:
- return [c for c in self.changes if c.severity == Severity.BREAKING]
-
- @property
- def dangerous_changes(self) -> list[Change]:
- return [c for c in self.changes if c.severity == Severity.DANGEROUS]
-
- @property
- def non_breaking_changes(self) -> list[Change]:
- return [c for c in self.changes if c.severity == Severity.NON_BREAKING]
-
- @property
- def info_changes(self) -> list[Change]:
- return [c for c in self.changes if c.severity == Severity.INFO]
-
- @property
- def has_breaking(self) -> bool:
- return len(self.breaking_changes) > 0
-
- def to_dict(self) -> dict[str, Any]:
- return {
- "old_version": self.old_version,
- "new_version": self.new_version,
- "summary": {
- "breaking": len(self.breaking_changes),
- "dangerous": len(self.dangerous_changes),
- "non_breaking": len(self.non_breaking_changes),
- "info": len(self.info_changes),
- },
- "changes": [c.to_dict() for c in self.changes],
- }
-
-
-def diff_specs(old: dict[str, Any], new: dict[str, Any]) -> DiffResult:
- """Compare two OpenAPI specs and return all detected changes.
-
- Args:
- old: The old (baseline) OpenAPI spec.
- new: The new (proposed) OpenAPI spec.
-
- Returns:
- DiffResult with all detected changes classified by severity.
- """
- result = DiffResult()
-
- old_version = old.get("openapi", old.get("swagger", "unknown"))
- new_version = new.get("openapi", new.get("swagger", "unknown"))
- result.old_version = old_version
- result.new_version = new_version
-
- # Check paths
- _diff_paths(old, new, result)
-
- # Check schemas/components
- _diff_schemas(old, new, result)
-
- # Check security schemes
- _diff_security_schemes(old, new, result)
-
- # Check global security requirements
- _diff_security_requirements(old, new, result)
-
- # Check server configurations
- _diff_servers(old, new, result)
-
- # Check info metadata
- _diff_info(old, new, result)
-
- return result
-
-
-def _diff_paths(old: dict[str, Any], new: dict[str, Any], result: DiffResult) -> None:
- """Detect changes in paths and operations."""
- old_paths = old.get("paths", {})
- new_paths = new.get("paths", {})
-
- # Removed paths — BREAKING
- for path in old_paths:
- if path not in new_paths:
- result.changes.append(
- Change(
- kind="path_removed",
- severity=Severity.BREAKING,
- path=f"paths.{path}",
- description=f"Path '{path}' was removed",
- old_value=path,
- new_value=None,
- )
- )
-
- # Added paths — NON_BREAKING
- for path in new_paths:
- if path not in old_paths:
- result.changes.append(
- Change(
- kind="path_added",
- severity=Severity.NON_BREAKING,
- path=f"paths.{path}",
- description=f"Path '{path}' was added",
- old_value=None,
- new_value=path,
- )
- )
-
- # Check operations within shared paths
- for path in old_paths:
- if path not in new_paths:
- continue
- _diff_operations(path, old_paths[path], new_paths[path], result)
-
-
-
-def _param_key(param: dict[str, Any]) -> tuple[str, str]:
- """Identity key for a parameter: (in, name), or ('$ref', target) for refs.
-
- Keying unresolved $ref parameters by their target keeps distinct refs
- from colliding on the ('', '') key.
- """
- if "$ref" in param:
- return ("$ref", str(param["$ref"]))
- return (param.get("in", ""), param.get("name", ""))
-
-
-def _effective_parameters(
- path_item: dict[str, Any],
- op: dict[str, Any],
-) -> list[dict[str, Any]]:
- """Merge path-item-level parameters with operation-level ones.
-
- Per the OpenAPI spec, parameters declared on a path item apply to every
- operation under it; an operation-level parameter with the same (name, in)
- pair overrides the path-level definition.
- """
- merged: dict[tuple[str, str], dict[str, Any]] = {}
- for source in (path_item.get("parameters") or [], op.get("parameters") or []):
- for param in source:
- if isinstance(param, dict):
- merged[_param_key(param)] = param
- return list(merged.values())
-
-def _diff_operations(
- path: str,
- old_item: dict[str, Any],
- new_item: dict[str, Any],
- result: DiffResult,
-) -> None:
- """Detect changes in operations within a shared path."""
- methods = ("get", "post", "put", "patch", "delete", "head", "options", "trace")
-
- for method in methods:
- old_op = old_item.get(method)
- new_op = new_item.get(method)
-
- if old_op and not new_op:
- result.changes.append(
- Change(
- kind="operation_removed",
- severity=Severity.BREAKING,
- path=f"paths.{path}.{method}",
- description=f"{method.upper()} {path} was removed",
- old_value=method,
- new_value=None,
- )
- )
- elif not old_op and new_op:
- result.changes.append(
- Change(
- kind="operation_added",
- severity=Severity.NON_BREAKING,
- path=f"paths.{path}.{method}",
- description=f"{method.upper()} {path} was added",
- old_value=None,
- new_value=method,
- )
- )
- elif old_op and new_op:
- _diff_operation_details(
- path, method, old_op, new_op, result,
- old_params=_effective_parameters(old_item, old_op),
- new_params=_effective_parameters(new_item, new_op),
- )
-
-
-def _diff_operation_details(
- path: str,
- method: str,
- old_op: dict[str, Any],
- new_op: dict[str, Any],
- result: DiffResult,
- old_params: list[dict[str, Any]] | None = None,
- new_params: list[dict[str, Any]] | None = None,
-) -> None:
- """Detect changes within an operation (parameters, responses, requestBody).
-
- ``old_params``/``new_params`` are the effective parameter lists with
- path-item-level parameters already merged in; when omitted, the
- operation's own parameters are used.
- """
- op_path = f"paths.{path}.{method}"
-
- # Check parameters (path-item-level parameters merged by the caller)
- if old_params is None:
- old_params = old_op.get("parameters", [])
- if new_params is None:
- new_params = new_op.get("parameters", [])
- _diff_parameters(op_path, old_params, new_params, result)
-
- # Check request body
- _diff_request_body(
- op_path, old_op.get("requestBody"), new_op.get("requestBody"), result
- )
-
- # Check responses
- _diff_responses(
- op_path, old_op.get("responses", {}), new_op.get("responses", {}), result
- )
-
- # Check operation-level (per-endpoint) security requirements
- _diff_operation_security(op_path, path, method, old_op, new_op, result)
-
- # Check if operation became deprecated
- if not old_op.get("deprecated") and new_op.get("deprecated"):
- result.changes.append(
- Change(
- kind="operation_deprecated",
- severity=Severity.DANGEROUS,
- path=op_path,
- description=f"{method.upper()} {path} is now deprecated",
- )
- )
-
- # Check operationId changes
- old_op_id = old_op.get("operationId")
- new_op_id = new_op.get("operationId")
- if old_op_id and not new_op_id:
- result.changes.append(
- Change(
- kind="operation_id_removed",
- severity=Severity.BREAKING,
- path=op_path,
- description=f"{method.upper()} {path} operationId '{old_op_id}' was removed",
- old_value=old_op_id,
- new_value=None,
- )
- )
- elif not old_op_id and new_op_id:
- result.changes.append(
- Change(
- kind="operation_id_added",
- severity=Severity.NON_BREAKING,
- path=op_path,
- description=f"{method.upper()} {path} operationId '{new_op_id}' was added",
- old_value=None,
- new_value=new_op_id,
- )
- )
- elif old_op_id and new_op_id and old_op_id != new_op_id:
- result.changes.append(
- Change(
- kind="operation_id_changed",
- severity=Severity.BREAKING,
- path=op_path,
- description=f"{method.upper()} {path} operationId changed from '{old_op_id}' to '{new_op_id}'",
- old_value=old_op_id,
- new_value=new_op_id,
- )
- )
-
- # Check if operation became required (new required param)
- # Handled in _diff_parameters
-
-
-def _diff_parameters(
- op_path: str,
- old_params: list[dict[str, Any]],
- new_params: list[dict[str, Any]],
- result: DiffResult,
-) -> None:
- """Detect parameter changes."""
- old_by_key = {}
- for p in old_params:
- old_by_key[_param_key(p)] = p
-
- new_by_key = {}
- for p in new_params:
- new_by_key[_param_key(p)] = p
-
- # Removed parameters
- for key, param in old_by_key.items():
- if key not in new_by_key:
- result.changes.append(
- Change(
- kind="parameter_removed",
- severity=Severity.BREAKING
- if param.get("required", False)
- else Severity.NON_BREAKING,
- path=f"{op_path}.parameters.{key[0]}.{key[1]}",
- description=f"Parameter '{key[1]}' ({key[0]}) was removed",
- old_value=param,
- new_value=None,
- )
- )
-
- # Added parameters
- for key, param in new_by_key.items():
- if key not in old_by_key:
- sev = (
- Severity.BREAKING
- if param.get("required", False)
- else Severity.NON_BREAKING
- )
- result.changes.append(
- Change(
- kind="parameter_added",
- severity=sev,
- path=f"{op_path}.parameters.{key[0]}.{key[1]}",
- description=(
- f"Parameter '{key[1]}' ({key[0]}) was added"
- + (" (required)" if param.get("required") else "")
- ),
- old_value=None,
- new_value=param,
- )
- )
-
- # Changed parameters
- for key in old_by_key:
- if key not in new_by_key:
- continue
- old_p = old_by_key[key]
- new_p = new_by_key[key]
-
- # Required flag changed
- if not old_p.get("required", False) and new_p.get("required", False):
- result.changes.append(
- Change(
- kind="parameter_became_required",
- severity=Severity.BREAKING,
- path=f"{op_path}.parameters.{key[0]}.{key[1]}",
- description=f"Parameter '{key[1]}' ({key[0]}) became required",
- )
- )
-
- # Type changed
- if old_p.get("schema", {}).get("type") != new_p.get("schema", {}).get("type"):
- old_type = old_p.get("schema", {}).get("type")
- new_type = new_p.get("schema", {}).get("type")
- if old_type and new_type:
- result.changes.append(
- Change(
- kind="parameter_type_changed",
- severity=Severity.BREAKING,
- path=f"{op_path}.parameters.{key[0]}.{key[1]}",
- description=f"Parameter '{key[1]}' type changed from '{old_type}' to '{new_type}'",
- old_value=old_type,
- new_value=new_type,
- )
- )
-
-
-def _diff_request_body(
- op_path: str,
- old_rb: dict[str, Any] | None,
- new_rb: dict[str, Any] | None,
- result: DiffResult,
-) -> None:
- """Detect request body changes."""
- rb_path = f"{op_path}.requestBody"
-
- if old_rb and not new_rb:
- result.changes.append(
- Change(
- kind="request_body_removed",
- severity=Severity.BREAKING,
- path=rb_path,
- description="Request body was removed",
- )
- )
- return
-
- if not old_rb and new_rb:
- result.changes.append(
- Change(
- kind="request_body_added",
- severity=Severity.NON_BREAKING,
- path=rb_path,
- description="Request body was added",
- )
- )
- return
-
- if not old_rb or not new_rb:
- return
-
- # Required flag changed
- if not old_rb.get("required", False) and new_rb.get("required", False):
- result.changes.append(
- Change(
- kind="request_body_became_required",
- severity=Severity.BREAKING,
- path=rb_path,
- description="Request body became required",
- )
- )
-
- # Content type changes
- old_content = old_rb.get("content", {})
- new_content = new_rb.get("content", {})
-
- for ct in old_content:
- if ct not in new_content:
- result.changes.append(
- Change(
- kind="request_content_type_removed",
- severity=Severity.BREAKING,
- path=f"{rb_path}.content.{ct}",
- description=f"Request content type '{ct}' was removed",
- )
- )
-
- for ct in new_content:
- if ct not in old_content:
- result.changes.append(
- Change(
- kind="request_content_type_added",
- severity=Severity.NON_BREAKING,
- path=f"{rb_path}.content.{ct}",
- description=f"Request content type '{ct}' was added",
- )
- )
-
- # Schema-level changes within a content type that exists in both specs.
- # Previously only content-type presence was compared, so an inline request
- # schema that added a required field or changed a property type slipped
- # through silently (the diff reported no change while clients broke).
- for ct in old_content:
- if ct not in new_content:
- continue
- _diff_media_type_schema(
- f"{rb_path}.content.{ct}",
- old_content.get(ct) or {},
- new_content.get(ct) or {},
- result,
- is_request=True,
- )
-
-
-def _diff_responses(
- op_path: str,
- old_resp: dict[str, Any],
- new_resp: dict[str, Any],
- result: DiffResult,
-) -> None:
- """Detect response changes."""
- resp_path = f"{op_path}.responses"
-
- for code in old_resp:
- if code not in new_resp:
- result.changes.append(
- Change(
- kind="response_removed",
- severity=Severity.BREAKING,
- path=f"{resp_path}.{code}",
- description=f"Response '{code}' was removed",
- )
- )
-
- for code in new_resp:
- if code not in old_resp:
- result.changes.append(
- Change(
- kind="response_added",
- severity=Severity.NON_BREAKING,
- path=f"{resp_path}.{code}",
- description=f"Response '{code}' was added",
- )
- )
-
- for code in old_resp:
- if code not in new_resp:
- continue
- old_content = old_resp[code].get("content", {})
- new_content = new_resp[code].get("content", {})
-
- for ct in old_content:
- if ct not in new_content:
- result.changes.append(
- Change(
- kind="response_content_type_removed",
- severity=Severity.BREAKING,
- path=f"{resp_path}.{code}.content.{ct}",
- description=f"Response content type '{ct}' for '{code}' was removed",
- )
- )
-
- for ct in new_content:
- if ct not in old_content:
- result.changes.append(
- Change(
- kind="response_content_type_added",
- severity=Severity.NON_BREAKING,
- path=f"{resp_path}.{code}.content.{ct}",
- description=f"Response content type '{ct}' for '{code}' was added",
- )
- )
-
- # Schema-level changes within a response content type present in both.
- # A response schema that drops a property or narrows a type is breaking
- # for consumers; previously only content-type presence was compared, so
- # these changes were reported as no-change (silent green while broken).
- for ct in old_content:
- if ct not in new_content:
- continue
- _diff_media_type_schema(
- f"{resp_path}.{code}.content.{ct}",
- old_content.get(ct) or {},
- new_content.get(ct) or {},
- result,
- is_request=False,
- )
-
-
-def _diff_media_type_schema(
- path: str,
- old_media: dict[str, Any],
- new_media: dict[str, Any],
- result: DiffResult,
- *,
- is_request: bool,
-) -> None:
- """Diff the schema of a single media type (content-type) present in both specs.
-
- Only inline schemas are compared here. When both sides reference a component
- schema via ``$ref``, the referenced schema is diffed by ``_diff_schemas`` and
- re-diffing it here would double-report; instead we only flag a changed
- ``$ref`` target. Severity is direction-aware: adding a required field breaks
- request bodies, while dropping a field breaks responses.
- """
- old_schema = old_media.get("schema") if isinstance(old_media, dict) else None
- new_schema = new_media.get("schema") if isinstance(new_media, dict) else None
- if not isinstance(old_schema, dict) or not isinstance(new_schema, dict):
- return
-
- old_ref = old_schema.get("$ref")
- new_ref = new_schema.get("$ref")
- if old_ref or new_ref:
- if old_ref != new_ref:
- result.changes.append(
- Change(
- kind="schema_ref_changed",
- severity=Severity.DANGEROUS,
- path=f"{path}.schema",
- description=(
- f"Schema reference changed from '{old_ref}' to '{new_ref}'"
- ),
- old_value=old_ref,
- new_value=new_ref,
- )
- )
- # Both sides are (possibly different) refs; component-level detail diffing
- # is owned by _diff_schemas. Nothing more to compare inline.
- return
-
- _diff_inline_schema(f"{path}.schema", old_schema, new_schema, result, is_request=is_request)
-
-
-def _diff_inline_schema(
- path: str,
- old_schema: dict[str, Any],
- new_schema: dict[str, Any],
- result: DiffResult,
- *,
- is_request: bool,
- depth: int = 0,
-) -> None:
- """Diff two inline schemas with request/response-aware breaking semantics.
-
- Request bodies and responses invert what counts as breaking:
- - request: a new required field or a field becoming required breaks clients;
- removing a field is tolerable.
- - response: removing a field or a field ceasing to be guaranteed breaks
- consumers; adding a field is tolerable.
- A ``type`` change is breaking in either direction.
- """
- ctx = "request" if is_request else "response"
-
- # Top-level type change (e.g. object -> array) breaks both directions.
- old_type = old_schema.get("type")
- new_type = new_schema.get("type")
- if old_type and new_type and old_type != new_type:
- result.changes.append(
- Change(
- kind="schema_type_changed",
- severity=Severity.BREAKING,
- path=path,
- description=f"{ctx.capitalize()} schema type changed from '{old_type}' to '{new_type}'",
- old_value=old_type,
- new_value=new_type,
- )
- )
-
- old_required = set(old_schema.get("required", []))
- new_required = set(new_schema.get("required", []))
- old_props = old_schema.get("properties", {}) or {}
- new_props = new_schema.get("properties", {}) or {}
-
- # Required-set changes.
- newly_required = new_required - old_required
- for prop in sorted(newly_required):
- result.changes.append(
- Change(
- kind="request_property_became_required"
- if is_request
- else "response_property_became_required",
- severity=Severity.BREAKING if is_request else Severity.NON_BREAKING,
- path=f"{path}.{prop}",
- description=(
- f"Property '{prop}' became required in {ctx} schema"
- + ("" if is_request else " (now always present)")
- ),
- )
- )
-
- no_longer_required = old_required - new_required
- for prop in sorted(no_longer_required):
- result.changes.append(
- Change(
- kind="response_property_no_longer_required"
- if not is_request
- else "request_property_no_longer_required",
- # A response field no longer guaranteed breaks consumers relying on it.
- severity=Severity.BREAKING if not is_request else Severity.NON_BREAKING,
- path=f"{path}.{prop}",
- description=f"Property '{prop}' is no longer required in {ctx} schema",
- )
- )
-
- # Property presence changes.
- for prop_name in old_props:
- if prop_name in new_props:
- continue
- # Removing a field breaks response consumers; for requests it is tolerable.
- result.changes.append(
- Change(
- kind="response_property_removed"
- if not is_request
- else "request_property_removed",
- severity=Severity.BREAKING if not is_request else Severity.NON_BREAKING,
- path=f"{path}.properties.{prop_name}",
- description=f"Property '{prop_name}' removed from {ctx} schema",
- )
- )
-
- for prop_name in new_props:
- if prop_name in old_props:
- continue
- # Adding a required field breaks request clients; otherwise tolerable.
- added_required = prop_name in new_required and is_request
- result.changes.append(
- Change(
- kind="request_property_added"
- if is_request
- else "response_property_added",
- severity=Severity.BREAKING if added_required else Severity.NON_BREAKING,
- path=f"{path}.properties.{prop_name}",
- description=(
- f"Property '{prop_name}' added to {ctx} schema"
- + (" (required)" if added_required else "")
- ),
- )
- )
-
- # Property type changes on shared properties (breaking either direction).
- for prop_name in old_props:
- if prop_name not in new_props:
- continue
- old_prop = old_props[prop_name] or {}
- new_prop = new_props[prop_name] or {}
- if not isinstance(old_prop, dict) or not isinstance(new_prop, dict):
- continue
- old_pt = old_prop.get("type")
- new_pt = new_prop.get("type")
- if old_pt and new_pt and old_pt != new_pt:
- result.changes.append(
- Change(
- kind="property_type_changed",
- severity=Severity.BREAKING,
- path=f"{path}.properties.{prop_name}",
- description=(
- f"Property '{prop_name}' in {ctx} schema type changed"
- f" from '{old_pt}' to '{new_pt}'"
- ),
- old_value=old_pt,
- new_value=new_pt,
- )
- )
-
- # Recurse into nested object properties and array-of-object item schemas so
- # breaking changes buried inside a nested object/array are not reported as
- # "no change" (the silent-green failure class this tool exists to catch).
- # Component-level $ref targets are diffed by _diff_schemas, so a nested $ref
- # property is intentionally NOT recursed here.
- _MAX_SCHEMA_DEPTH = 6
- if depth < _MAX_SCHEMA_DEPTH:
- for prop_name in old_props:
- if prop_name not in new_props:
- continue
- old_prop = old_props[prop_name] or {}
- new_prop = new_props[prop_name] or {}
- if not isinstance(old_prop, dict) or not isinstance(new_prop, dict):
- continue
- # Nested object -> recurse into its own properties/required set.
- both_object = (
- old_prop.get("type") == "object" and new_prop.get("type") == "object"
- ) or ("properties" in old_prop and "properties" in new_prop)
- if both_object:
- _diff_inline_schema(
- f"{path}.properties.{prop_name}",
- old_prop,
- new_prop,
- result,
- is_request=is_request,
- depth=depth + 1,
- )
- continue
- # Array whose items are an object -> recurse into the item schema.
- if (
- old_prop.get("type") == "array"
- and new_prop.get("type") == "array"
- ):
- old_items = old_prop.get("items") or {}
- new_items = new_prop.get("items") or {}
- if (
- isinstance(old_items, dict)
- and isinstance(new_items, dict)
- and (
- (
- old_items.get("type") == "object"
- and new_items.get("type") == "object"
- )
- or (
- "properties" in old_items and "properties" in new_items
- )
- )
- ):
- _diff_inline_schema(
- f"{path}.properties.{prop_name}.items",
- old_items,
- new_items,
- result,
- is_request=is_request,
- depth=depth + 1,
- )
-
-
-def _diff_schemas(old: dict[str, Any], new: dict[str, Any], result: DiffResult) -> None:
- """Detect schema/component changes."""
- old_schemas = old.get("components", {}).get("schemas", {})
- new_schemas = new.get("components", {}).get("schemas", {})
-
- for name in old_schemas:
- if name not in new_schemas:
- result.changes.append(
- Change(
- kind="schema_removed",
- severity=Severity.BREAKING,
- path=f"components.schemas.{name}",
- description=f"Schema '{name}' was removed",
- )
- )
-
- for name in new_schemas:
- if name not in old_schemas:
- result.changes.append(
- Change(
- kind="schema_added",
- severity=Severity.NON_BREAKING,
- path=f"components.schemas.{name}",
- description=f"Schema '{name}' was added",
- )
- )
-
- for name in old_schemas:
- if name not in new_schemas:
- continue
- _diff_schema_details(name, old_schemas[name], new_schemas[name], result)
-
-
-def _diff_schema_details(
- name: str,
- old_schema: dict[str, Any],
- new_schema: dict[str, Any],
- result: DiffResult,
-) -> None:
- """Detect changes within a schema."""
- schema_path = f"components.schemas.{name}"
-
- # Type change
- old_type = old_schema.get("type")
- new_type = new_schema.get("type")
- if old_type and new_type and old_type != new_type:
- result.changes.append(
- Change(
- kind="schema_type_changed",
- severity=Severity.BREAKING,
- path=schema_path,
- description=f"Schema '{name}' type changed from '{old_type}' to '{new_type}'",
- old_value=old_type,
- new_value=new_type,
- )
- )
-
- # Required properties changes
- old_required = set(old_schema.get("required", []))
- new_required = set(new_schema.get("required", []))
-
- newly_required = new_required - old_required
- for prop in newly_required:
- result.changes.append(
- Change(
- kind="property_became_required",
- severity=Severity.BREAKING,
- path=f"{schema_path}.{prop}",
- description=f"Property '{prop}' in schema '{name}' became required",
- )
- )
-
- no_longer_required = old_required - new_required
- for prop in no_longer_required:
- result.changes.append(
- Change(
- kind="property_no_longer_required",
- severity=Severity.NON_BREAKING,
- path=f"{schema_path}.{prop}",
- description=f"Property '{prop}' in schema '{name}' is no longer required",
- )
- )
-
- # Schema-level enum changes (must be outside property loop)
- old_schema_enum = old_schema.get("enum")
- new_schema_enum = new_schema.get("enum")
- if old_schema_enum and new_schema_enum:
- removed_values = set(old_schema_enum) - set(new_schema_enum)
- if removed_values:
- result.changes.append(
- Change(
- kind="enum_values_removed",
- severity=Severity.BREAKING,
- path=schema_path,
- description=f"Schema '{name}' removed enum values: {removed_values}",
- old_value=list(removed_values),
- )
- )
-
- # Property changes
- old_props = old_schema.get("properties", {})
- new_props = new_schema.get("properties", {})
-
- for prop_name in old_props:
- if prop_name not in new_props:
- result.changes.append(
- Change(
- kind="property_removed",
- severity=Severity.BREAKING
- if prop_name in old_required
- else Severity.DANGEROUS,
- path=f"{schema_path}.properties.{prop_name}",
- description=f"Property '{prop_name}' removed from schema '{name}'",
- )
- )
-
- for prop_name in new_props:
- if prop_name not in old_props:
- result.changes.append(
- Change(
- kind="property_added",
- severity=Severity.NON_BREAKING,
- path=f"{schema_path}.properties.{prop_name}",
- description=f"Property '{prop_name}' added to schema '{name}'",
- )
- )
-
- for prop_name in old_props:
- if prop_name not in new_props:
- continue
- old_prop = old_props[prop_name]
- new_prop = new_props[prop_name]
-
- # Property type change
- if (
- old_prop.get("type")
- and new_prop.get("type")
- and old_prop["type"] != new_prop["type"]
- ):
- result.changes.append(
- Change(
- kind="property_type_changed",
- severity=Severity.BREAKING,
- path=f"{schema_path}.properties.{prop_name}",
- description=(
- f"Property '{prop_name}' in '{name}' type changed"
- f" from '{old_prop['type']}' to '{new_prop['type']}'"
- ),
- old_value=old_prop["type"],
- new_value=new_prop["type"],
- )
- )
-
- # Format change
- if old_prop.get("format") != new_prop.get("format"):
- old_fmt = old_prop.get("format", "")
- new_fmt = new_prop.get("format", "")
- if old_fmt and new_fmt and old_fmt != new_fmt:
- result.changes.append(
- Change(
- kind="property_format_changed",
- severity=Severity.DANGEROUS,
- path=f"{schema_path}.properties.{prop_name}",
- description=(
- f"Property '{prop_name}' in '{name}' format changed from '{old_fmt}' to '{new_fmt}'"
- ),
- old_value=old_fmt,
- new_value=new_fmt,
- )
- )
-
- # Enum changes
- old_enum = old_prop.get("enum")
- new_enum = new_prop.get("enum")
- if old_enum and new_enum:
- removed_values = set(old_enum) - set(new_enum)
- if removed_values:
- result.changes.append(
- Change(
- kind="enum_values_removed",
- severity=Severity.BREAKING,
- path=f"{schema_path}.properties.{prop_name}",
- description=f"Enum property '{prop_name}' in '{name}' removed values: {removed_values}",
- old_value=list(removed_values),
- )
- )
-
- # Deep structural diff of NESTED object / array-of-object properties.
- # Previously _diff_schema_details only compared a component's DIRECT
- # properties (type/format/enum); a nested object property whose sub-fields
- # changed (e.g. a required field added deep inside) was reported as "no
- # change" -- the silent-green failure class this tool exists to catch.
- # Reuse the proven recursive inline-schema walker (conservative: treats all
- # required/presence changes as BREAKING, since a component may back a request
- # body). Only the nested sub-schemas are walked here, so this never
- # double-reports the component's own top-level properties already checked above.
- for prop_name in old_props:
- if prop_name not in new_props:
- continue
- old_sub = old_props[prop_name] or {}
- new_sub = new_props[prop_name] or {}
- if not isinstance(old_sub, dict) or not isinstance(new_sub, dict):
- continue
- is_nested_object = (
- (old_sub.get("type") == "object" and new_sub.get("type") == "object")
- or ("properties" in old_sub and "properties" in new_sub)
- )
- if is_nested_object:
- _diff_inline_schema(
- f"{schema_path}.properties.{prop_name}",
- old_sub,
- new_sub,
- result,
- is_request=True,
- depth=1,
- )
- continue
- if old_sub.get("type") == "array" and new_sub.get("type") == "array":
- old_items = old_sub.get("items") or {}
- new_items = new_sub.get("items") or {}
- if isinstance(old_items, dict) and isinstance(new_items, dict) and (
- (old_items.get("type") == "object" and new_items.get("type") == "object")
- or ("properties" in old_items and "properties" in new_items)
- ):
- _diff_inline_schema(
- f"{schema_path}.properties.{prop_name}.items",
- old_items,
- new_items,
- result,
- is_request=True,
- depth=1,
- )
-
- # A property that is a $ref (or array of $ref) into another component was
- # previously ignored entirely: retargeting or dropping the ref produced no
- # change record. Flag ref-target changes at the property's OWN path. The
- # referenced target's internals are already diffed by _diff_schemas at
- # components.schemas., so this never double-reports them -- it only
- # surfaces that THIS property's reference changed.
- for prop_name in set(old_props) | set(new_props):
- _flag_schema_ref_prop(
- schema_path, prop_name, old_props.get(prop_name), new_props.get(prop_name), result
- )
-
-
-def _flag_schema_ref_prop(
- schema_path: str,
- prop_name: str,
- old_prop: Any,
- new_prop: Any,
- result: DiffResult,
-) -> None:
- """Flag a component property whose ``$ref`` target changed or was dropped.
-
- Refs into ``components.schemas`` are intentionally NOT followed into the
- target's internals (``_diff_schemas`` already diffs the target at its own
- path); we only report that the reference here changed, which is independent
- information not otherwise surfaced.
- """
-
- def ref_of(sub: Any) -> Any:
- if isinstance(sub, dict):
- return sub.get("$ref")
- return None
-
- old_ref = ref_of(old_prop)
- new_ref = ref_of(new_prop)
- # Array whose items are a $ref.
- if old_ref is None and isinstance(old_prop, dict) and old_prop.get("type") == "array":
- old_ref = ref_of(old_prop.get("items"))
- if new_ref is None and isinstance(new_prop, dict) and new_prop.get("type") == "array":
- new_ref = ref_of(new_prop.get("items"))
-
- if old_ref is None and new_ref is None:
- return
-
- prop_path = f"{schema_path}.properties.{prop_name}"
- if old_ref != new_ref:
- result.changes.append(
- Change(
- kind="schema_ref_changed",
- severity=Severity.DANGEROUS,
- path=prop_path,
- description=(
- f"$ref property '{prop_name}' target changed from '{old_ref}' to '{new_ref}'"
- ),
- old_value=old_ref,
- new_value=new_ref,
- )
- )
-
-
-def _diff_security_schemes(
- old: dict[str, Any],
- new: dict[str, Any],
- result: DiffResult,
-) -> None:
- """Detect security scheme changes."""
- old_schemes = old.get("components", {}).get("securitySchemes", {})
- new_schemes = new.get("components", {}).get("securitySchemes", {})
-
- for name in old_schemes:
- if name not in new_schemes:
- result.changes.append(
- Change(
- kind="security_scheme_removed",
- severity=Severity.BREAKING,
- path=f"components.securitySchemes.{name}",
- description=f"Security scheme '{name}' was removed",
- )
- )
-
- for name in new_schemes:
- if name not in old_schemes:
- result.changes.append(
- Change(
- kind="security_scheme_added",
- severity=Severity.NON_BREAKING,
- path=f"components.securitySchemes.{name}",
- description=f"Security scheme '{name}' was added",
- )
- )
-
- for name in old_schemes:
- if name not in new_schemes:
- continue
- if old_schemes[name].get("type") != new_schemes[name].get("type"):
- result.changes.append(
- Change(
- kind="security_scheme_type_changed",
- severity=Severity.BREAKING,
- path=f"components.securitySchemes.{name}",
- description=f"Security scheme '{name}' type changed",
- old_value=old_schemes[name].get("type"),
- new_value=new_schemes[name].get("type"),
- )
- )
-
-
-def _security_requirement_groups(security: list[Any]) -> set[frozenset[str]]:
- """Normalize an OpenAPI ``security`` list into a set of scheme-name groups.
-
- Each entry in ``security`` is a requirement object mapping a security
- scheme name to its required scopes; the list has OR semantics (satisfying
- any one entry authorizes the request). We reduce each requirement object to
- the ``frozenset`` of its scheme names so that requirement ordering and scope
- ordering never produce spurious diffs, while still detecting when whole
- scheme groups are added or removed. (Scope-level tightening within an
- existing scheme group is intentionally out of scope for this pass.)
- """
- groups: set[frozenset[str]] = set()
- for req in security:
- if isinstance(req, dict):
- groups.add(frozenset(req.keys()))
- return groups
-
-
-def _format_security_groups(groups: set[frozenset[str]]) -> str:
- """Render security scheme groups as a human-readable 'A OR B+C' string."""
- rendered = [
- "+".join(sorted(group)) if group else "(anonymous)"
- for group in sorted(groups, key=lambda g: sorted(g))
- ]
- return " OR ".join(rendered) if rendered else "(none)"
-
-
-def _diff_operation_security(
- op_path: str,
- path: str,
- method: str,
- old_op: dict[str, Any],
- new_op: dict[str, Any],
- result: DiffResult,
-) -> None:
- """Detect changes to an operation's own ``security`` requirement.
-
- Operation-level ``security`` overrides the global requirement; an absent
- key means the operation inherits the global security. Silently shipping a
- change here (e.g. dropping auth from an endpoint, or requiring a new
- scheme) is a security-relevant contract change, so surface each transition
- as a DANGEROUS change — mirroring how global security changes are treated.
- """
- old_has = "security" in old_op
- new_has = "security" in new_op
- if not old_has and not new_has:
- # Both inherit the global requirement; nothing operation-specific.
- return
-
- sec_path = f"{op_path}.security"
- label = f"{method.upper()} {path}"
- old_groups = _security_requirement_groups(old_op.get("security") or [])
- new_groups = _security_requirement_groups(new_op.get("security") or [])
-
- if not old_has and new_has:
- if new_groups:
- desc = (
- f"{label} now declares operation-level security "
- f"({_format_security_groups(new_groups)}); clients may need new "
- "credentials"
- )
- else:
- desc = (
- f"{label} now explicitly requires no authentication "
- "(security: []), overriding the global security requirement"
- )
- result.changes.append(
- Change(
- kind="operation_security_added",
- severity=Severity.DANGEROUS,
- path=sec_path,
- description=desc,
- old_value=None,
- new_value=[sorted(g) for g in new_groups],
- )
- )
- return
-
- if old_has and not new_has:
- result.changes.append(
- Change(
- kind="operation_security_removed",
- severity=Severity.DANGEROUS,
- path=sec_path,
- description=(
- f"{label} dropped its operation-level security; it now "
- "inherits the global security requirement"
- ),
- old_value=[sorted(g) for g in old_groups],
- new_value=None,
- )
- )
- return
-
- # Both sides declare security explicitly — compare the requirement groups.
- if old_groups == new_groups:
- return
-
- if old_groups and not new_groups:
- result.changes.append(
- Change(
- kind="operation_security_removed",
- severity=Severity.DANGEROUS,
- path=sec_path,
- description=(
- f"{label} no longer requires authentication (security: []); "
- "it was previously protected"
- ),
- old_value=[sorted(g) for g in old_groups],
- new_value=[],
- )
- )
- return
-
- if new_groups and not old_groups:
- result.changes.append(
- Change(
- kind="operation_security_added",
- severity=Severity.DANGEROUS,
- path=sec_path,
- description=(
- f"{label} now requires authentication "
- f"({_format_security_groups(new_groups)}); it was previously "
- "public"
- ),
- old_value=[],
- new_value=[sorted(g) for g in new_groups],
- )
- )
- return
-
- added = new_groups - old_groups
- removed = old_groups - new_groups
- parts = []
- if added:
- parts.append(f"added {_format_security_groups(added)}")
- if removed:
- parts.append(f"removed {_format_security_groups(removed)}")
- result.changes.append(
- Change(
- kind="operation_security_changed",
- severity=Severity.DANGEROUS,
- path=sec_path,
- description=f"{label} security requirements changed: {'; '.join(parts)}",
- old_value=[sorted(g) for g in old_groups],
- new_value=[sorted(g) for g in new_groups],
- )
- )
-
-
-def _diff_security_requirements(
- old: dict[str, Any],
- new: dict[str, Any],
- result: DiffResult,
-) -> None:
- """Detect global security requirement changes."""
- old_sec = old.get("security", [])
- new_sec = new.get("security", [])
-
- if old_sec and not new_sec:
- result.changes.append(
- Change(
- kind="global_security_removed",
- severity=Severity.DANGEROUS,
- path="security",
- description="Global security requirements were removed",
- )
- )
- elif not old_sec and new_sec:
- result.changes.append(
- Change(
- kind="global_security_added",
- severity=Severity.DANGEROUS,
- path="security",
- description="Global security requirements were added",
- )
- )
-
-
-def _diff_servers(old: dict[str, Any], new: dict[str, Any], result: DiffResult) -> None:
- """Detect server configuration changes."""
- old_servers = old.get("servers", [])
- new_servers = new.get("servers", [])
-
- old_urls = [s.get("url", "") for s in old_servers]
- new_urls = [s.get("url", "") for s in new_servers]
-
- for url in old_urls:
- if url not in new_urls:
- result.changes.append(
- Change(
- kind="server_removed",
- severity=Severity.DANGEROUS,
- path=f"servers.{url}",
- description=f"Server '{url}' was removed",
- )
- )
-
- for url in new_urls:
- if url not in old_urls:
- result.changes.append(
- Change(
- kind="server_added",
- severity=Severity.INFO,
- path=f"servers.{url}",
- description=f"Server '{url}' was added",
- )
- )
-
-
-def _diff_info(old: dict[str, Any], new: dict[str, Any], result: DiffResult) -> None:
- """Detect info section changes."""
- old_info = old.get("info", {})
- new_info = new.get("info", {})
-
- old_title = old_info.get("title", "")
- new_title = new_info.get("title", "")
-
- if old_title != new_title:
- result.changes.append(
- Change(
- kind="title_changed",
- severity=Severity.INFO,
- path="info.title",
- description=f"API title changed from '{old_title}' to '{new_title}'",
- old_value=old_title,
- new_value=new_title,
- )
- )
-
- old_api_version = old_info.get("version", "")
- new_api_version = new_info.get("version", "")
-
- if old_api_version != new_api_version:
- result.changes.append(
- Change(
- kind="api_version_changed",
- severity=Severity.INFO,
- path="info.version",
- description=f"API version changed from '{old_api_version}' to '{new_api_version}'",
- old_value=old_api_version,
- new_value=new_api_version,
- )
- )
diff --git a/src/api_contract_guardian/gate.py b/src/api_contract_guardian/gate.py
deleted file mode 100644
index 23c2b04..0000000
--- a/src/api_contract_guardian/gate.py
+++ /dev/null
@@ -1,108 +0,0 @@
-"""CI gate — determine if a spec change should pass or fail a CI pipeline."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import Any
-
-from .diff import DiffResult
-
-
-@dataclass
-class GateResult:
- """Result of a CI gate check."""
-
- passed: bool
- breaking_count: int
- dangerous_count: int
- message: str
- exit_code: int = 0
-
- def to_dict(self) -> dict[str, Any]:
- return {
- "passed": self.passed,
- "breaking_count": self.breaking_count,
- "dangerous_count": self.dangerous_count,
- "message": self.message,
- "exit_code": self.exit_code,
- }
-
-
-def check_gate(
- result: DiffResult,
- *,
- fail_on_breaking: bool = True,
- fail_on_dangerous: bool = False,
- max_breaking: int = -1,
- max_dangerous: int = -1,
-) -> GateResult:
- """Check if a diff result passes the CI gate.
-
- Args:
- result: The diff result to check.
- fail_on_breaking: If True, any breaking change fails the gate.
- fail_on_dangerous: If True, any dangerous change fails the gate.
- max_breaking: Maximum allowed breaking changes. -1 means unlimited.
- max_dangerous: Maximum allowed dangerous changes. -1 means unlimited.
-
- Returns:
- GateResult with pass/fail status and details.
- """
- breaking_count = len(result.breaking_changes)
- dangerous_count = len(result.dangerous_changes)
-
- # Determine effective thresholds
- # max_breaking >= 0 is an explicit ceiling — always honour it.
- # When absent (-1, the default), fall back to fail_on_breaking flag.
- if max_breaking >= 0:
- effective_max_breaking = max_breaking
- elif fail_on_breaking:
- effective_max_breaking = 0 # default: zero tolerance
- else:
- effective_max_breaking = -1 # unlimited
-
- if max_dangerous >= 0:
- effective_max_dangerous = max_dangerous
- elif fail_on_dangerous:
- effective_max_dangerous = 0 # default: zero tolerance
- else:
- effective_max_dangerous = -1 # unlimited
-
- # Check breaking
- breaking_fails = (
- effective_max_breaking >= 0 and breaking_count > effective_max_breaking
- )
-
- # Check dangerous
- dangerous_fails = (
- effective_max_dangerous >= 0 and dangerous_count > effective_max_dangerous
- )
-
- passed = not breaking_fails and not dangerous_fails
- exit_code = 0 if passed else 1
-
- parts = []
- if breaking_count > 0:
- parts.append(f"{breaking_count} breaking change(s)")
- if dangerous_count > 0:
- parts.append(f"{dangerous_count} dangerous change(s)")
-
- if passed:
- msg = "CI gate PASSED"
- if parts:
- msg += f" ({', '.join(parts)} detected but allowed)"
- else:
- reasons = []
- if breaking_fails:
- reasons.append("breaking changes exceed threshold")
- if dangerous_fails:
- reasons.append("dangerous changes exceed threshold")
- msg = f"CI gate FAILED: {'; '.join(reasons)}"
-
- return GateResult(
- passed=passed,
- breaking_count=breaking_count,
- dangerous_count=dangerous_count,
- message=msg,
- exit_code=exit_code,
- )
diff --git a/src/api_contract_guardian/loader.py b/src/api_contract_guardian/loader.py
deleted file mode 100644
index 46f9c06..0000000
--- a/src/api_contract_guardian/loader.py
+++ /dev/null
@@ -1,150 +0,0 @@
-"""OpenAPI spec loader and parser."""
-
-from __future__ import annotations
-
-import json
-from pathlib import Path
-from typing import Any
-
-import yaml
-
-
-class SpecLoadError(Exception):
- """Raised when a spec file cannot be loaded or parsed."""
-
-
-def load_spec(path: str | Path) -> dict[str, Any]:
- """Load an OpenAPI spec from a YAML or JSON file.
-
- Args:
- path: Path to the spec file.
-
- Returns:
- Parsed spec as a dict.
-
- Raises:
- SpecLoadError: If the file cannot be read or parsed.
- """
- path = Path(path)
- if not path.exists():
- raise SpecLoadError(f"Spec file not found: {path}")
-
- try:
- content = path.read_text(encoding="utf-8")
- except OSError as exc:
- raise SpecLoadError(f"Cannot read {path}: {exc}") from exc
-
- if path.suffix in (".yaml", ".yml"):
- try:
- spec = yaml.safe_load(content)
- except yaml.YAMLError as exc:
- raise SpecLoadError(f"Invalid YAML in {path}: {exc}") from exc
- elif path.suffix == ".json":
- try:
- spec = json.loads(content)
- except json.JSONDecodeError as exc:
- raise SpecLoadError(f"Invalid JSON in {path}: {exc}") from exc
- else:
- # Try YAML first, then JSON
- try:
- spec = yaml.safe_load(content)
- except yaml.YAMLError:
- try:
- spec = json.loads(content)
- except json.JSONDecodeError as exc:
- raise SpecLoadError(
- f"Cannot parse {path} as YAML or JSON: {exc}"
- ) from exc
-
- if not isinstance(spec, dict):
- raise SpecLoadError(
- f"Spec in {path} is not a valid OpenAPI document"
- f" (expected dict, got {type(spec).__name__})"
- )
-
- return spec
-
-
-def load_spec_from_string(content: str, fmt: str = "yaml") -> dict[str, Any]:
- """Load an OpenAPI spec from a string.
-
- Args:
- content: The spec content as a string.
- fmt: Format hint - 'yaml' or 'json'.
-
- Returns:
- Parsed spec as a dict.
- """
- if fmt == "json":
- try:
- spec = json.loads(content)
- except json.JSONDecodeError as exc:
- raise SpecLoadError(f"Invalid JSON: {exc}") from exc
- if not isinstance(spec, dict):
- raise SpecLoadError(
- f"Spec is not a valid OpenAPI document (expected dict, got {type(spec).__name__})"
- )
- return spec
- else:
- try:
- spec = yaml.safe_load(content)
- except yaml.YAMLError as exc:
- raise SpecLoadError(f"Invalid YAML: {exc}") from exc
-
- if not isinstance(spec, dict):
- raise SpecLoadError(
- f"Spec is not a valid OpenAPI document (expected dict, got {type(spec).__name__})"
- )
-
- return spec
-
-
-def validate_openapi_version(spec: dict[str, Any]) -> str:
- """Check and return the OpenAPI version of a spec.
-
- Args:
- spec: Parsed OpenAPI spec.
-
- Returns:
- Version string (e.g. '3.0.0', '3.1.0').
-
- Raises:
- SpecLoadError: If the version is not OpenAPI 3.x.
- """
- version = spec.get("openapi", spec.get("swagger", ""))
- if not version:
- raise SpecLoadError(
- "Spec does not contain 'openapi' or 'swagger' version field"
- )
- # YAML parses an unquoted `openapi: 3.1` as a float; normalize to str so
- # .startswith below raises SpecLoadError instead of AttributeError.
- version = str(version)
-
- if version.startswith("3."):
- return version
-
- if version.startswith("2."):
- raise SpecLoadError(
- f"OpenAPI {version} (Swagger) is not supported. Only OpenAPI 3.x specs are supported."
- )
-
- raise SpecLoadError(f"Unrecognized OpenAPI version: {version}")
-
-
-def get_paths(spec: dict[str, Any]) -> dict[str, Any]:
- """Extract paths from a spec."""
- return spec.get("paths", {})
-
-
-def get_schemas(spec: dict[str, Any]) -> dict[str, Any]:
- """Extract component schemas from a spec."""
- return spec.get("components", {}).get("schemas", {})
-
-
-def get_operations(path_item: dict[str, Any]) -> dict[str, dict[str, Any]]:
- """Extract HTTP methods (operations) from a path item."""
- methods: dict[str, dict[str, Any]] = {}
- for method in ("get", "post", "put", "patch", "delete", "head", "options", "trace"):
- if method in path_item:
- methods[method] = path_item[method]
- return methods
diff --git a/src/api_contract_guardian/migration.py b/src/api_contract_guardian/migration.py
deleted file mode 100644
index 7959350..0000000
--- a/src/api_contract_guardian/migration.py
+++ /dev/null
@@ -1,220 +0,0 @@
-"""Migration guide generator — produce human-readable migration guides from diff results."""
-
-from __future__ import annotations
-
-from typing import Any
-
-from .diff import Change, DiffResult
-
-
-def generate_migration_guide(result: DiffResult) -> str:
- """Generate a markdown migration guide from diff results.
-
- Args:
- result: The diff result to generate a guide from.
-
- Returns:
- Markdown-formatted migration guide string.
- """
- lines: list[str] = []
-
- lines.append("# API Migration Guide")
- lines.append("")
- lines.append(f"From version `{result.old_version}` to `{result.new_version}`")
- lines.append("")
-
- # Summary
- summary = result.to_dict()["summary"]
- lines.append("## Summary")
- lines.append("")
- lines.append("| Severity | Count |")
- lines.append("|----------|-------|")
- lines.append(f"| Breaking | {summary['breaking']} |")
- lines.append(f"| Dangerous | {summary['dangerous']} |")
- lines.append(f"| Non-breaking | {summary['non_breaking']} |")
- lines.append(f"| Info | {summary['info']} |")
- lines.append("")
-
- if not result.changes:
- lines.append("No changes detected between specs.")
- return "\n".join(lines)
-
- # Breaking changes section
- breaking = result.breaking_changes
- if breaking:
- lines.append("## Breaking Changes")
- lines.append("")
- lines.append("These changes **will** break existing clients. Action required.")
- lines.append("")
- for change in breaking:
- lines.append(
- f"- **{change.kind}** at `{change.path}`: {change.description}"
- )
- if change.old_value is not None or change.new_value is not None:
- lines.append(_format_value_change(change))
- lines.append("")
-
- # Dangerous changes section
- dangerous = result.dangerous_changes
- if dangerous:
- lines.append("## Dangerous Changes")
- lines.append("")
- lines.append(
- "These changes **may** break existing clients. Review recommended."
- )
- lines.append("")
- for change in dangerous:
- lines.append(
- f"- **{change.kind}** at `{change.path}`: {change.description}"
- )
- lines.append("")
-
- # Non-breaking changes section
- non_breaking = result.non_breaking_changes
- if non_breaking:
- lines.append("## Non-Breaking Changes")
- lines.append("")
- lines.append("These changes are backward-compatible. No action required.")
- lines.append("")
- for change in non_breaking:
- lines.append(
- f"- **{change.kind}** at `{change.path}`: {change.description}"
- )
- lines.append("")
-
- # Info section
- info = result.info_changes
- if info:
- lines.append("## Informational")
- lines.append("")
- for change in info:
- lines.append(
- f"- **{change.kind}** at `{change.path}`: {change.description}"
- )
- lines.append("")
-
- # Migration steps
- if breaking:
- lines.append("## Recommended Migration Steps")
- lines.append("")
- steps = _generate_steps(breaking)
- for i, step in enumerate(steps, 1):
- lines.append(f"{i}. {step}")
- lines.append("")
-
- return "\n".join(lines)
-
-
-def _format_value_change(change: Change) -> str:
- """Format a value change for display."""
- parts = []
- if change.old_value is not None:
- parts.append(f" - Before: `{change.old_value}`")
- if change.new_value is not None:
- parts.append(f" - After: `{change.new_value}`")
- return "\n".join(parts)
-
-
-def _generate_steps(breaking_changes: list[Change]) -> list[str]:
- """Generate recommended migration steps from breaking changes."""
- steps = []
-
- removed_paths = [c for c in breaking_changes if c.kind == "path_removed"]
- if removed_paths:
- paths = ", ".join(f"`{c.path}`" for c in removed_paths)
- steps.append(f"Remove client code referencing removed paths: {paths}")
-
- removed_ops = [c for c in breaking_changes if c.kind == "operation_removed"]
- if removed_ops:
- ops = ", ".join(f"`{c.path}`" for c in removed_ops)
- steps.append(f"Update client code to stop calling removed operations: {ops}")
-
- newly_required = [
- c for c in breaking_changes if c.kind == "parameter_became_required"
- ]
- if newly_required:
- params = ", ".join(f"`{c.path}`" for c in newly_required)
- steps.append(f"Add required parameters to requests: {params}")
-
- required_params_added = [
- c
- for c in breaking_changes
- if c.kind == "parameter_added" and "(required)" in c.description
- ]
- if required_params_added:
- params = ", ".join(f"`{c.path}`" for c in required_params_added)
- steps.append(f"Add newly required parameters: {params}")
-
- required_rb = [
- c for c in breaking_changes if c.kind == "request_body_became_required"
- ]
- if required_rb:
- steps.append("Update requests to include required request bodies")
-
- removed_schemas = [c for c in breaking_changes if c.kind == "schema_removed"]
- if removed_schemas:
- schemas = ", ".join(f"`{c.path}`" for c in removed_schemas)
- steps.append(f"Replace removed schemas: {schemas}")
-
- type_changes = [
- c
- for c in breaking_changes
- if c.kind
- in ("schema_type_changed", "property_type_changed", "parameter_type_changed")
- ]
- if type_changes:
- steps.append("Update type handling code for changed types")
-
- removed_props = [c for c in breaking_changes if c.kind == "property_removed"]
- if removed_props:
- steps.append("Update code that references removed properties")
-
- enum_removals = [c for c in breaking_changes if c.kind == "enum_values_removed"]
- if enum_removals:
- steps.append("Update enum value references to remove deleted values")
-
- removed_content_types = [
- c
- for c in breaking_changes
- if c.kind in ("request_content_type_removed", "response_content_type_removed")
- ]
- if removed_content_types:
- steps.append("Update content-type handling for removed content types")
-
- op_id_changes = [
- c
- for c in breaking_changes
- if c.kind in ("operation_id_removed", "operation_id_changed")
- ]
- if op_id_changes:
- ops = ", ".join(f"`{c.path}`" for c in op_id_changes)
- steps.append(
- f"Update SDK codegen references for changed/removed operationIds: {ops}"
- )
-
- if not steps:
- steps.append("Review breaking changes and update client code accordingly")
-
- return steps
-
-
-def generate_migration_guide_json(result: DiffResult) -> dict[str, Any]:
- """Generate a structured JSON migration guide from diff results.
-
- Args:
- result: The diff result to generate a guide from.
-
- Returns:
- Dictionary with migration guide data.
- """
- guide: dict[str, Any] = {
- "from_version": result.old_version,
- "to_version": result.new_version,
- "summary": result.to_dict()["summary"],
- "breaking_changes": [c.to_dict() for c in result.breaking_changes],
- "dangerous_changes": [c.to_dict() for c in result.dangerous_changes],
- "migration_steps": _generate_steps(result.breaking_changes)
- if result.breaking_changes
- else [],
- }
- return guide
diff --git a/src/api_contract_guardian/py.typed b/src/api_contract_guardian/py.typed
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/conftest.py b/tests/conftest.py
deleted file mode 100644
index 849592d..0000000
--- a/tests/conftest.py
+++ /dev/null
@@ -1,13 +0,0 @@
-"""Mock revenueholdings_license for tests so CLI commands don't hit the paywall."""
-
-import sys
-from unittest.mock import MagicMock
-
-# Replace the module before any import resolves it
-_mock = MagicMock()
-_mock.require_license = MagicMock(return_value=None)
-sys.modules["revenueholdings_license"] = _mock
-
-# Also mock the rate_limiter submodule
-_rate_limiter_mock = MagicMock()
-sys.modules["revenueholdings_license.rate_limiter"] = _rate_limiter_mock
diff --git a/tests/fixtures/spec-v1.yaml b/tests/fixtures/spec-v1.yaml
deleted file mode 100644
index 419d497..0000000
--- a/tests/fixtures/spec-v1.yaml
+++ /dev/null
@@ -1,50 +0,0 @@
-openapi: "3.0.3"
-info:
- title: Sample API
- version: "1.0.0"
-paths:
- /users:
- get:
- summary: List users
- operationId: listUsers
- responses:
- '200':
- description: A list of users
- content:
- application/json:
- schema:
- type: array
- items:
- $ref: '#/components/schemas/User'
- /users/{id}:
- get:
- summary: Get user by ID
- operationId: getUser
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
- responses:
- '200':
- description: A user
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/User'
- '404':
- description: User not found
-components:
- schemas:
- User:
- type: object
- properties:
- id:
- type: string
- name:
- type: string
- email:
- type: string
- format: email
- required: [id, name]
diff --git a/tests/fixtures/spec-v2.yaml b/tests/fixtures/spec-v2.yaml
deleted file mode 100644
index 4906c4e..0000000
--- a/tests/fixtures/spec-v2.yaml
+++ /dev/null
@@ -1,90 +0,0 @@
-openapi: "3.0.3"
-info:
- title: Sample API
- version: "2.0.0"
-paths:
- /users:
- get:
- summary: List users
- operationId: listUsers
- responses:
- '200':
- description: A list of users
- content:
- application/json:
- schema:
- type: array
- items:
- $ref: '#/components/schemas/User'
- post:
- summary: Create user
- operationId: createUser
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/UserCreate'
- responses:
- '201':
- description: User created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/User'
- /users/{id}:
- get:
- summary: Get user by ID
- operationId: getUser
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
- responses:
- '200':
- description: A user
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/User'
- '404':
- description: User not found
- delete:
- summary: Delete user
- operationId: deleteUser
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
- responses:
- '204':
- description: User deleted
-components:
- schemas:
- User:
- type: object
- properties:
- id:
- type: string
- name:
- type: string
- email:
- type: string
- format: email
- created_at:
- type: string
- format: date-time
- required: [id, name, email]
- UserCreate:
- type: object
- properties:
- name:
- type: string
- email:
- type: string
- format: email
- required: [name, email]
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
deleted file mode 100644
index c5026b3..0000000
--- a/tests/smoke.test.js
+++ /dev/null
@@ -1,26 +0,0 @@
-const test = require("node:test");
-const assert = require("node:assert");
-const { execFileSync } = require("node:child_process");
-const fs = require("node:fs");
-const path = require("node:path");
-
-test("smoke: package main entry exists and parses", () => {
- const pkg = require(path.join(__dirname, "..", "package.json"));
- assert.ok(pkg.name, "package.json has a name");
- const main = pkg.main || "index.js";
- const cli = pkg.bin ? Object.values(pkg.bin)[0] : null;
- const entry = cli || main;
- if (fs.existsSync(path.join(__dirname, "..", entry))) {
- assert.doesNotThrow(
- () => execFileSync("node", ["--check", entry], { stdio: "ignore" }),
- `${entry} must be valid JavaScript`
- );
- }
-});
-
-test("smoke: required repo files present", () => {
- const root = path.join(__dirname, "..");
- for (const f of ["package.json", "README.md", "LICENSE"]) {
- assert.ok(fs.existsSync(path.join(root, f)), `${f} must exist`);
- }
-});
diff --git a/tests/test_cli.py b/tests/test_cli.py
index ec6f322..af76f89 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -128,3 +128,67 @@ def test_migrate_valid_specs(self, tmp_path) -> None:
assert out.exists()
text = out.read_text(encoding="utf-8")
assert "Migration Guide" in text
+
+
+class TestErrorObservability:
+ """Errors go to stderr and --output creates missing parent dirs."""
+
+ def test_load_error_goes_to_stderr(self) -> None:
+ result = _run("diff", "does-not-exist.yaml", "also-missing.yaml")
+ assert result.returncode == 1
+ assert "Error loading" in result.stderr
+ assert "Error" not in result.stdout
+
+ def test_diff_output_creates_parent_dirs(self, tmp_path: Path) -> None:
+ out = tmp_path / "nested" / "dir" / "report.json"
+ result = _run(
+ "diff", str(SPEC_V1), str(SPEC_V2), "--format", "json", "--output", str(out)
+ )
+ assert result.returncode == 0
+ assert out.exists()
+ assert "Written to" in result.stdout
+
+
+class TestMachineReadableOutput:
+ """--format json/yaml stdout must parse even with very long lines.
+
+ Rich's Console soft-wraps long lines at the console width (80 columns
+ when piped), which corrupts JSON/YAML piped into CI. Machine formats are
+ emitted raw via click.echo and must never be wrapped.
+ """
+
+ @pytest.mark.skipif(
+ not SPEC_V1.exists() or not SPEC_V2.exists(),
+ reason="fixture specs missing",
+ )
+ def test_piped_json_stdout_parses(self) -> None:
+ import json
+
+ result = _run("diff", str(SPEC_V1), str(SPEC_V2), "--format", "json")
+ assert result.returncode == 0
+ payload = json.loads(result.stdout) # raises if rich wrapped any line
+ assert "changes" in payload
+
+ @pytest.mark.skipif(
+ not SPEC_V1.exists() or not SPEC_V2.exists(),
+ reason="fixture specs missing",
+ )
+ def test_piped_yaml_stdout_parses(self) -> None:
+ import yaml
+
+ result = _run("diff", str(SPEC_V1), str(SPEC_V2), "--format", "yaml")
+ assert result.returncode == 0
+ payload = yaml.safe_load(result.stdout)
+ assert isinstance(payload, dict) and "changes" in payload
+
+ @pytest.mark.skipif(
+ not SPEC_V1.exists() or not SPEC_V2.exists(),
+ reason="fixture specs missing",
+ )
+ def test_check_json_stdout_parses(self) -> None:
+ import json
+
+ result = _run("check", str(SPEC_V1), str(SPEC_V2), "--format", "json")
+ assert result.returncode in (0, 1)
+ payload = json.loads(result.stdout)
+ assert "gate" in payload and "diff" in payload
diff --git a/tests/test_diff.py b/tests/test_diff.py
deleted file mode 100644
index 628e109..0000000
--- a/tests/test_diff.py
+++ /dev/null
@@ -1,1909 +0,0 @@
-"""Tests for the diff engine."""
-
-from api_contract_guardian.diff import (
- Change,
- DiffResult,
- Severity,
- diff_specs,
-)
-
-# ── Minimal spec fixtures ──
-
-
-def _make_spec(
- paths=None,
- schemas=None,
- security_schemes=None,
- security=None,
- servers=None,
- info=None,
- openapi="3.0.3",
-):
- spec = {
- "openapi": openapi,
- "info": info or {"title": "Test API", "version": "1.0.0"},
- "paths": paths or {},
- }
- if schemas:
- spec["components"] = {"schemas": schemas}
- if security_schemes:
- if "components" not in spec:
- spec["components"] = {}
- spec["components"]["securitySchemes"] = security_schemes
- if security is not None:
- spec["security"] = security
- if servers is not None:
- spec["servers"] = servers
- return spec
-
-
-# ── Severity and Change tests ──
-
-
-class TestSeverity:
- def test_values(self):
- assert Severity.BREAKING.value == "breaking"
- assert Severity.DANGEROUS.value == "dangerous"
- assert Severity.NON_BREAKING.value == "non_breaking"
- assert Severity.INFO.value == "info"
-
-
-class TestChange:
- def test_to_dict(self):
- c = Change(
- kind="path_removed",
- severity=Severity.BREAKING,
- path="paths./users",
- description="Removed",
- old_value="/users",
- )
- d = c.to_dict()
- assert d["kind"] == "path_removed"
- assert d["severity"] == "breaking"
- assert d["path"] == "paths./users"
- assert d["old_value"] == "/users"
-
-
-class TestDiffResult:
- def test_empty_result(self):
- r = DiffResult()
- assert not r.has_breaking
- assert r.breaking_changes == []
- assert r.to_dict()["summary"]["breaking"] == 0
-
- def test_has_breaking(self):
- r = DiffResult(
- changes=[
- Change(kind="x", severity=Severity.BREAKING, path="", description="")
- ]
- )
- assert r.has_breaking
-
- def test_to_dict_summary(self):
- r = DiffResult(
- changes=[
- Change(kind="a", severity=Severity.BREAKING, path="", description=""),
- Change(
- kind="b", severity=Severity.NON_BREAKING, path="", description=""
- ),
- ]
- )
- s = r.to_dict()["summary"]
- assert s["breaking"] == 1
- assert s["non_breaking"] == 1
-
-
-# ── Path diff tests ──
-
-
-class TestPathDiff:
- def test_path_removed_breaking(self):
- old = _make_spec(paths={"/users": {}})
- new = _make_spec(paths={})
- result = diff_specs(old, new)
- assert any(
- c.kind == "path_removed" and c.severity == Severity.BREAKING
- for c in result.changes
- )
-
- def test_path_added_non_breaking(self):
- old = _make_spec(paths={})
- new = _make_spec(paths={"/users": {}})
- result = diff_specs(old, new)
- assert any(
- c.kind == "path_added" and c.severity == Severity.NON_BREAKING
- for c in result.changes
- )
-
- def test_no_path_changes(self):
- old = _make_spec(paths={"/users": {}})
- new = _make_spec(paths={"/users": {}})
- result = diff_specs(old, new)
- path_changes = [c for c in result.changes if c.kind.startswith("path_")]
- assert len(path_changes) == 0
-
- def test_multiple_paths_removed(self):
- old = _make_spec(paths={"/users": {}, "/items": {}, "/orders": {}})
- new = _make_spec(paths={"/items": {}})
- result = diff_specs(old, new)
- removed = [c for c in result.changes if c.kind == "path_removed"]
- assert len(removed) == 2
-
-
-# ── Operation diff tests ──
-
-
-class TestOperationDiff:
- def test_operation_removed_breaking(self):
- old = _make_spec(
- paths={"/users": {"get": {"responses": {"200": {"description": "OK"}}}}}
- )
- new = _make_spec(paths={"/users": {}})
- result = diff_specs(old, new)
- assert any(c.kind == "operation_removed" for c in result.changes)
-
- def test_operation_added_non_breaking(self):
- old = _make_spec(paths={"/users": {}})
- new = _make_spec(
- paths={"/users": {"get": {"responses": {"200": {"description": "OK"}}}}}
- )
- result = diff_specs(old, new)
- assert any(c.kind == "operation_added" for c in result.changes)
-
- def test_operation_deprecated_dangerous(self):
- old = _make_spec(
- paths={"/users": {"get": {"responses": {"200": {"description": "OK"}}}}}
- )
- new = _make_spec(
- paths={
- "/users": {
- "get": {
- "deprecated": True,
- "responses": {"200": {"description": "OK"}},
- }
- }
- }
- )
- result = diff_specs(old, new)
- assert any(
- c.kind == "operation_deprecated" and c.severity == Severity.DANGEROUS
- for c in result.changes
- )
-
- def test_multiple_methods_removed(self):
- old = _make_spec(
- paths={
- "/users": {
- "get": {"responses": {"200": {"description": "OK"}}},
- "post": {"responses": {"201": {"description": "Created"}}},
- }
- }
- )
- new = _make_spec(paths={"/users": {}})
- result = diff_specs(old, new)
- removed = [c for c in result.changes if c.kind == "operation_removed"]
- assert len(removed) == 2
-
-
-# ── Parameter diff tests ──
-
-
-class TestParameterDiff:
- def test_required_param_removed_breaking(self):
- old = _make_spec(
- paths={
- "/users": {
- "get": {
- "parameters": [{"name": "id", "in": "query", "required": True}],
- "responses": {"200": {"description": "OK"}},
- }
- }
- }
- )
- new = _make_spec(
- paths={
- "/users": {
- "get": {
- "parameters": [],
- "responses": {"200": {"description": "OK"}},
- }
- }
- }
- )
- result = diff_specs(old, new)
- assert any(
- c.kind == "parameter_removed" and c.severity == Severity.BREAKING
- for c in result.changes
- )
-
- def test_optional_param_removed_non_breaking(self):
- old = _make_spec(
- paths={
- "/users": {
- "get": {
- "parameters": [
- {"name": "page", "in": "query", "required": False}
- ],
- "responses": {"200": {"description": "OK"}},
- }
- }
- }
- )
- new = _make_spec(
- paths={
- "/users": {
- "get": {
- "parameters": [],
- "responses": {"200": {"description": "OK"}},
- }
- }
- }
- )
- result = diff_specs(old, new)
- assert any(
- c.kind == "parameter_removed" and c.severity == Severity.NON_BREAKING
- for c in result.changes
- )
-
- def test_required_param_added_breaking(self):
- old = _make_spec(
- paths={
- "/users": {
- "get": {
- "parameters": [],
- "responses": {"200": {"description": "OK"}},
- }
- }
- }
- )
- new = _make_spec(
- paths={
- "/users": {
- "get": {
- "parameters": [{"name": "id", "in": "query", "required": True}],
- "responses": {"200": {"description": "OK"}},
- }
- }
- }
- )
- result = diff_specs(old, new)
- assert any(
- c.kind == "parameter_added" and c.severity == Severity.BREAKING
- for c in result.changes
- )
-
- def test_optional_param_added_non_breaking(self):
- old = _make_spec(
- paths={
- "/users": {
- "get": {
- "parameters": [],
- "responses": {"200": {"description": "OK"}},
- }
- }
- }
- )
- new = _make_spec(
- paths={
- "/users": {
- "get": {
- "parameters": [
- {"name": "page", "in": "query", "required": False}
- ],
- "responses": {"200": {"description": "OK"}},
- }
- }
- }
- )
- result = diff_specs(old, new)
- assert any(
- c.kind == "parameter_added" and c.severity == Severity.NON_BREAKING
- for c in result.changes
- )
-
- def test_param_became_required_breaking(self):
- old = _make_spec(
- paths={
- "/users": {
- "get": {
- "parameters": [
- {"name": "id", "in": "query", "required": False}
- ],
- "responses": {"200": {"description": "OK"}},
- }
- }
- }
- )
- new = _make_spec(
- paths={
- "/users": {
- "get": {
- "parameters": [{"name": "id", "in": "query", "required": True}],
- "responses": {"200": {"description": "OK"}},
- }
- }
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "parameter_became_required" for c in result.changes)
-
- def test_param_type_changed_breaking(self):
- old = _make_spec(
- paths={
- "/users": {
- "get": {
- "parameters": [
- {
- "name": "id",
- "in": "query",
- "required": False,
- "schema": {"type": "string"},
- }
- ],
- "responses": {"200": {"description": "OK"}},
- }
- }
- }
- )
- new = _make_spec(
- paths={
- "/users": {
- "get": {
- "parameters": [
- {
- "name": "id",
- "in": "query",
- "required": False,
- "schema": {"type": "integer"},
- }
- ],
- "responses": {"200": {"description": "OK"}},
- }
- }
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "parameter_type_changed" for c in result.changes)
-
-
-# ── Request body diff tests ──
-
-
-class TestRequestBodyDiff:
- def test_request_body_removed_breaking(self):
- old = _make_spec(
- paths={
- "/users": {
- "post": {
- "requestBody": {"content": {"application/json": {}}},
- "responses": {"201": {"description": "Created"}},
- }
- }
- }
- )
- new = _make_spec(
- paths={
- "/users": {"post": {"responses": {"201": {"description": "Created"}}}}
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "request_body_removed" for c in result.changes)
-
- def test_request_body_added_non_breaking(self):
- old = _make_spec(
- paths={
- "/users": {"post": {"responses": {"201": {"description": "Created"}}}}
- }
- )
- new = _make_spec(
- paths={
- "/users": {
- "post": {
- "requestBody": {"content": {"application/json": {}}},
- "responses": {"201": {"description": "Created"}},
- }
- }
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "request_body_added" for c in result.changes)
-
- def test_request_body_became_required_breaking(self):
- old = _make_spec(
- paths={
- "/users": {
- "post": {
- "requestBody": {"content": {"application/json": {}}},
- "responses": {"201": {"description": "Created"}},
- }
- }
- }
- )
- new = _make_spec(
- paths={
- "/users": {
- "post": {
- "requestBody": {
- "required": True,
- "content": {"application/json": {}},
- },
- "responses": {"201": {"description": "Created"}},
- }
- }
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "request_body_became_required" for c in result.changes)
-
- def test_request_content_type_removed_breaking(self):
- old = _make_spec(
- paths={
- "/users": {
- "post": {
- "requestBody": {
- "content": {"application/json": {}, "application/xml": {}}
- },
- "responses": {"201": {"description": "Created"}},
- }
- }
- }
- )
- new = _make_spec(
- paths={
- "/users": {
- "post": {
- "requestBody": {"content": {"application/json": {}}},
- "responses": {"201": {"description": "Created"}},
- }
- }
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "request_content_type_removed" for c in result.changes)
-
- def test_request_content_type_added_non_breaking(self):
- old = _make_spec(
- paths={
- "/users": {
- "post": {
- "requestBody": {"content": {"application/json": {}}},
- "responses": {"201": {"description": "Created"}},
- }
- }
- }
- )
- new = _make_spec(
- paths={
- "/users": {
- "post": {
- "requestBody": {
- "content": {"application/json": {}, "application/xml": {}}
- },
- "responses": {"201": {"description": "Created"}},
- }
- }
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "request_content_type_added" for c in result.changes)
-
-
-# ── Response diff tests ──
-
-
-class TestResponseDiff:
- def test_response_removed_breaking(self):
- old = _make_spec(
- paths={
- "/users": {
- "get": {
- "responses": {
- "200": {"description": "OK"},
- "404": {"description": "Not Found"},
- }
- }
- }
- }
- )
- new = _make_spec(
- paths={"/users": {"get": {"responses": {"200": {"description": "OK"}}}}}
- )
- result = diff_specs(old, new)
- assert any(c.kind == "response_removed" for c in result.changes)
-
- def test_response_added_non_breaking(self):
- old = _make_spec(
- paths={"/users": {"get": {"responses": {"200": {"description": "OK"}}}}}
- )
- new = _make_spec(
- paths={
- "/users": {
- "get": {
- "responses": {
- "200": {"description": "OK"},
- "404": {"description": "Not Found"},
- }
- }
- }
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "response_added" for c in result.changes)
-
- def test_response_content_type_removed_breaking(self):
- old = _make_spec(
- paths={
- "/users": {
- "get": {
- "responses": {
- "200": {
- "content": {
- "application/json": {},
- "application/xml": {},
- }
- }
- }
- }
- }
- }
- )
- new = _make_spec(
- paths={
- "/users": {
- "get": {"responses": {"200": {"content": {"application/json": {}}}}}
- }
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "response_content_type_removed" for c in result.changes)
-
- def test_response_content_type_added_non_breaking(self):
- old = _make_spec(
- paths={
- "/users": {
- "get": {"responses": {"200": {"content": {"application/json": {}}}}}
- }
- }
- )
- new = _make_spec(
- paths={
- "/users": {
- "get": {
- "responses": {
- "200": {
- "content": {
- "application/json": {},
- "application/xml": {},
- }
- }
- }
- }
- }
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "response_content_type_added" for c in result.changes)
-
-
-# ── Schema diff tests ──
-
-
-class TestSchemaDiff:
- def test_schema_removed_breaking(self):
- old = _make_spec(schemas={"User": {"type": "object"}})
- new = _make_spec(schemas={})
- result = diff_specs(old, new)
- assert any(c.kind == "schema_removed" for c in result.changes)
-
- def test_schema_added_non_breaking(self):
- old = _make_spec(schemas={})
- new = _make_spec(schemas={"User": {"type": "object"}})
- result = diff_specs(old, new)
- assert any(c.kind == "schema_added" for c in result.changes)
-
- def test_schema_type_changed_breaking(self):
- old = _make_spec(schemas={"User": {"type": "object"}})
- new = _make_spec(schemas={"User": {"type": "string"}})
- result = diff_specs(old, new)
- assert any(c.kind == "schema_type_changed" for c in result.changes)
-
- def test_property_became_required_breaking(self):
- old = _make_spec(
- schemas={
- "User": {
- "type": "object",
- "required": ["id"],
- "properties": {
- "id": {"type": "string"},
- "name": {"type": "string"},
- },
- }
- }
- )
- new = _make_spec(
- schemas={
- "User": {
- "type": "object",
- "required": ["id", "name"],
- "properties": {
- "id": {"type": "string"},
- "name": {"type": "string"},
- },
- }
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "property_became_required" for c in result.changes)
-
- def test_property_no_longer_required_non_breaking(self):
- old = _make_spec(
- schemas={
- "User": {
- "type": "object",
- "required": ["id", "name"],
- "properties": {
- "id": {"type": "string"},
- "name": {"type": "string"},
- },
- }
- }
- )
- new = _make_spec(
- schemas={
- "User": {
- "type": "object",
- "required": ["id"],
- "properties": {
- "id": {"type": "string"},
- "name": {"type": "string"},
- },
- }
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "property_no_longer_required" for c in result.changes)
-
- def test_nested_object_property_required_change_is_breaking(self):
- # A required field added DEEP inside a nested object property was
- # previously reported as "no change" (silent-green gap at the
- # component level). It must now surface as a breaking change.
- old = _make_spec(
- schemas={
- "User": {
- "type": "object",
- "properties": {
- "id": {"type": "string"},
- "address": {
- "type": "object",
- "properties": {
- "street": {"type": "string"},
- "zip": {"type": "string"},
- },
- },
- },
- }
- }
- )
- new = _make_spec(
- schemas={
- "User": {
- "type": "object",
- "properties": {
- "id": {"type": "string"},
- "address": {
- "type": "object",
- "required": ["zip"],
- "properties": {
- "street": {"type": "string"},
- "zip": {"type": "string"},
- },
- },
- },
- }
- }
- )
- result = diff_specs(old, new)
- became_required = [
- c for c in result.changes if c.kind == "request_property_became_required"
- ]
- assert became_required, "nested required change must be detected"
- assert any(
- "address" in c.path and "zip" in c.path for c in became_required
- ), "change must be reported at the nested path"
- assert all(c.severity.value == "breaking" for c in became_required), (
- "nested required change is breaking"
- )
-
- def test_nested_array_item_property_required_change_is_breaking(self):
- old = _make_spec(
- schemas={
- "Cart": {
- "type": "object",
- "properties": {
- "items": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {"sku": {"type": "string"}},
- },
- },
- },
- }
- }
- )
- new = _make_spec(
- schemas={
- "Cart": {
- "type": "object",
- "properties": {
- "items": {
- "type": "array",
- "items": {
- "type": "object",
- "required": ["sku"],
- "properties": {"sku": {"type": "string"}},
- },
- },
- },
- }
- }
- )
- result = diff_specs(old, new)
- became_required = [
- c for c in result.changes if c.kind == "request_property_became_required"
- ]
- assert became_required, "nested array-item required change must be detected"
- assert any("items" in c.path and "sku" in c.path for c in became_required)
-
- def test_component_ref_property_target_change_is_dangerous(self):
- # A component property whose $ref target was retargeted must be
- # flagged as DANGEROUS (it was previously silently ignored at the
- # component level).
- old = _make_spec(
- schemas={
- "Order": {
- "type": "object",
- "properties": {
- "customer": {"$ref": "#/components/schemas/CustomerV1"},
- },
- },
- "CustomerV1": {"type": "object"},
- "CustomerV2": {"type": "object"},
- }
- )
- new = _make_spec(
- schemas={
- "Order": {
- "type": "object",
- "properties": {
- "customer": {"$ref": "#/components/schemas/CustomerV2"},
- },
- },
- "CustomerV1": {"type": "object"},
- "CustomerV2": {"type": "object"},
- }
- )
- result = diff_specs(old, new)
- ref_changed = [c for c in result.changes if c.kind == "schema_ref_changed"]
- assert ref_changed, "ref-target change must be detected"
- assert any(c.severity.value == "dangerous" for c in ref_changed)
- assert any("Order" in c.path and "customer" in c.path for c in ref_changed)
-
- def test_component_ref_property_removed_is_dangerous(self):
- old = _make_spec(
- schemas={
- "Order": {
- "type": "object",
- "properties": {
- "customer": {"$ref": "#/components/schemas/Customer"},
- },
- },
- "Customer": {"type": "object"},
- }
- )
- new = _make_spec(
- schemas={
- "Order": {
- "type": "object",
- "properties": {
- "customer": {"type": "object"},
- },
- },
- "Customer": {"type": "object"},
- }
- )
- result = diff_specs(old, new)
- ref_changed = [c for c in result.changes if c.kind == "schema_ref_changed"]
- assert ref_changed, "dropped $ref must be detected"
-
- def test_property_removed_breaking_if_required(self):
- old = _make_spec(
- schemas={
- "User": {
- "type": "object",
- "required": ["id", "name"],
- "properties": {
- "id": {"type": "string"},
- "name": {"type": "string"},
- },
- }
- }
- )
- new = _make_spec(
- schemas={
- "User": {
- "type": "object",
- "required": ["id"],
- "properties": {"id": {"type": "string"}},
- }
- }
- )
- result = diff_specs(old, new)
- prop_removed = [c for c in result.changes if c.kind == "property_removed"]
- assert len(prop_removed) == 1
- assert prop_removed[0].severity == Severity.BREAKING
-
- def test_property_removed_dangerous_if_optional(self):
- old = _make_spec(
- schemas={
- "User": {
- "type": "object",
- "required": ["id"],
- "properties": {
- "id": {"type": "string"},
- "nickname": {"type": "string"},
- },
- }
- }
- )
- new = _make_spec(
- schemas={
- "User": {
- "type": "object",
- "required": ["id"],
- "properties": {"id": {"type": "string"}},
- }
- }
- )
- result = diff_specs(old, new)
- prop_removed = [c for c in result.changes if c.kind == "property_removed"]
- assert len(prop_removed) == 1
- assert prop_removed[0].severity == Severity.DANGEROUS
-
- def test_property_added_non_breaking(self):
- old = _make_spec(
- schemas={
- "User": {"type": "object", "properties": {"id": {"type": "string"}}}
- }
- )
- new = _make_spec(
- schemas={
- "User": {
- "type": "object",
- "properties": {
- "id": {"type": "string"},
- "email": {"type": "string"},
- },
- }
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "property_added" for c in result.changes)
-
- def test_property_type_changed_breaking(self):
- old = _make_spec(
- schemas={
- "User": {"type": "object", "properties": {"age": {"type": "string"}}}
- }
- )
- new = _make_spec(
- schemas={
- "User": {"type": "object", "properties": {"age": {"type": "integer"}}}
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "property_type_changed" for c in result.changes)
-
- def test_property_format_changed_dangerous(self):
- old = _make_spec(
- schemas={
- "User": {
- "type": "object",
- "properties": {"created": {"type": "string", "format": "date"}},
- }
- }
- )
- new = _make_spec(
- schemas={
- "User": {
- "type": "object",
- "properties": {
- "created": {"type": "string", "format": "date-time"}
- },
- }
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "property_format_changed" for c in result.changes)
-
- def test_enum_values_removed_breaking(self):
- old = _make_spec(
- schemas={
- "Status": {"type": "string", "enum": ["active", "inactive", "pending"]}
- }
- )
- new = _make_spec(
- schemas={"Status": {"type": "string", "enum": ["active", "inactive"]}}
- )
- result = diff_specs(old, new)
- assert any(c.kind == "enum_values_removed" for c in result.changes)
-
- def test_property_enum_values_removed_breaking(self):
- """Property-level enum value removed is detected as breaking."""
- old = _make_spec(
- schemas={
- "Status": {
- "type": "object",
- "properties": {
- "state": {
- "type": "string",
- "enum": ["active", "inactive", "pending"],
- },
- },
- },
- }
- )
- new = _make_spec(
- schemas={
- "Status": {
- "type": "object",
- "properties": {
- "state": {
- "type": "string",
- "enum": ["active", "inactive"],
- },
- },
- },
- }
- )
- result = diff_specs(old, new)
- assert any(c.kind == "enum_values_removed" for c in result.changes)
-
- def test_no_schema_changes(self):
- old = _make_spec(
- schemas={
- "User": {"type": "object", "properties": {"id": {"type": "string"}}}
- }
- )
- new = _make_spec(
- schemas={
- "User": {"type": "object", "properties": {"id": {"type": "string"}}}
- }
- )
- result = diff_specs(old, new)
- schema_changes = [
- c
- for c in result.changes
- if "schema" in c.kind or "property" in c.kind or "enum" in c.kind
- ]
- assert len(schema_changes) == 0
-
-
-# ── Security scheme diff tests ──
-
-
-class TestSecuritySchemeDiff:
- def test_security_scheme_removed_breaking(self):
- old = _make_spec(
- security_schemes={"bearerAuth": {"type": "http", "scheme": "bearer"}}
- )
- new = _make_spec(security_schemes={})
- result = diff_specs(old, new)
- assert any(c.kind == "security_scheme_removed" for c in result.changes)
-
- def test_security_scheme_added_non_breaking(self):
- old = _make_spec(security_schemes={})
- new = _make_spec(
- security_schemes={"bearerAuth": {"type": "http", "scheme": "bearer"}}
- )
- result = diff_specs(old, new)
- assert any(c.kind == "security_scheme_added" for c in result.changes)
-
- def test_security_scheme_type_changed_breaking(self):
- old = _make_spec(security_schemes={"auth": {"type": "http"}})
- new = _make_spec(security_schemes={"auth": {"type": "oauth2"}})
- result = diff_specs(old, new)
- assert any(c.kind == "security_scheme_type_changed" for c in result.changes)
-
-
-# ── Security requirements diff tests ──
-
-
-class TestSecurityRequirementsDiff:
- def test_global_security_removed_dangerous(self):
- old = _make_spec(security=[{"bearerAuth": []}])
- new = _make_spec(security=[])
- result = diff_specs(old, new)
- assert any(c.kind == "global_security_removed" for c in result.changes)
-
- def test_global_security_added_dangerous(self):
- old = _make_spec(security=[])
- new = _make_spec(security=[{"bearerAuth": []}])
- result = diff_specs(old, new)
- assert any(c.kind == "global_security_added" for c in result.changes)
-
-
-# ── Server diff tests ──
-
-
-class TestServerDiff:
- def test_server_removed_dangerous(self):
- old = _make_spec(servers=[{"url": "https://api.example.com"}])
- new = _make_spec(servers=[{"url": "https://v2.api.example.com"}])
- result = diff_specs(old, new)
- assert any(c.kind == "server_removed" for c in result.changes)
-
- def test_server_added_info(self):
- old = _make_spec(servers=[{"url": "https://api.example.com"}])
- new = _make_spec(
- servers=[
- {"url": "https://api.example.com"},
- {"url": "https://staging.example.com"},
- ]
- )
- result = diff_specs(old, new)
- assert any(
- c.kind == "server_added" and c.severity == Severity.INFO
- for c in result.changes
- )
-
-
-# ── Info diff tests ──
-
-
-class TestInfoDiff:
- def test_title_changed_info(self):
- old = _make_spec(info={"title": "Old API", "version": "1.0.0"})
- new = _make_spec(info={"title": "New API", "version": "1.0.0"})
- result = diff_specs(old, new)
- assert any(c.kind == "title_changed" for c in result.changes)
-
- def test_version_changed_info(self):
- old = _make_spec(info={"title": "API", "version": "1.0.0"})
- new = _make_spec(info={"title": "API", "version": "2.0.0"})
- result = diff_specs(old, new)
- assert any(c.kind == "api_version_changed" for c in result.changes)
-
-
-# ── Integration / complex scenario tests ──
-
-
-class TestOperationIdDiff:
- """Tests for operationId change detection."""
-
- def test_operation_id_removed_breaking(self):
- """Removing an operationId is breaking (SDK codegen breaks)."""
- old = _make_spec(
- paths={
- "/users": {
- "get": {
- "operationId": "listUsers",
- "responses": {"200": {"description": "OK"}},
- }
- },
- }
- )
- new = _make_spec(
- paths={
- "/users": {"get": {"responses": {"200": {"description": "OK"}}}},
- }
- )
- result = diff_specs(old, new)
- assert any(
- c.kind == "operation_id_removed" and c.severity == Severity.BREAKING
- for c in result.changes
- )
- removed = [c for c in result.changes if c.kind == "operation_id_removed"][0]
- assert removed.old_value == "listUsers"
-
- def test_operation_id_added_non_breaking(self):
- """Adding an operationId is non-breaking."""
- old = _make_spec(
- paths={
- "/users": {"get": {"responses": {"200": {"description": "OK"}}}},
- }
- )
- new = _make_spec(
- paths={
- "/users": {
- "get": {
- "operationId": "listUsers",
- "responses": {"200": {"description": "OK"}},
- }
- },
- }
- )
- result = diff_specs(old, new)
- assert any(
- c.kind == "operation_id_added" and c.severity == Severity.NON_BREAKING
- for c in result.changes
- )
- added = [c for c in result.changes if c.kind == "operation_id_added"][0]
- assert added.new_value == "listUsers"
-
- def test_operation_id_changed_breaking(self):
- """Changing an operationId is breaking (SDK references break)."""
- old = _make_spec(
- paths={
- "/users": {
- "get": {
- "operationId": "listUsers",
- "responses": {"200": {"description": "OK"}},
- }
- },
- }
- )
- new = _make_spec(
- paths={
- "/users": {
- "get": {
- "operationId": "getUsers",
- "responses": {"200": {"description": "OK"}},
- }
- },
- }
- )
- result = diff_specs(old, new)
- assert any(
- c.kind == "operation_id_changed" and c.severity == Severity.BREAKING
- for c in result.changes
- )
- changed = [c for c in result.changes if c.kind == "operation_id_changed"][0]
- assert changed.old_value == "listUsers"
- assert changed.new_value == "getUsers"
-
- def test_operation_id_unchanged_no_change(self):
- """Identical operationIds produce no change."""
- old = _make_spec(
- paths={
- "/users": {
- "get": {
- "operationId": "listUsers",
- "responses": {"200": {"description": "OK"}},
- }
- },
- }
- )
- new = _make_spec(
- paths={
- "/users": {
- "get": {
- "operationId": "listUsers",
- "responses": {"200": {"description": "OK"}},
- }
- },
- }
- )
- result = diff_specs(old, new)
- op_id_changes = [c for c in result.changes if "operation_id" in c.kind]
- assert len(op_id_changes) == 0
-
- def test_neither_has_operation_id_no_change(self):
- """No operationId on either side produces no change."""
- old = _make_spec(
- paths={
- "/users": {"get": {"responses": {"200": {"description": "OK"}}}},
- }
- )
- new = _make_spec(
- paths={
- "/users": {"get": {"responses": {"200": {"description": "OK"}}}},
- }
- )
- result = diff_specs(old, new)
- op_id_changes = [c for c in result.changes if "operation_id" in c.kind]
- assert len(op_id_changes) == 0
-
-
-class TestDiffIntegration:
- def test_identical_specs_no_changes(self):
- spec = _make_spec(
- paths={"/users": {"get": {"responses": {"200": {"description": "OK"}}}}}
- )
- result = diff_specs(spec, spec)
- assert len(result.changes) == 0
- assert not result.has_breaking
-
- def test_complex_multi_change_scenario(self):
- old = _make_spec(
- paths={
- "/users": {
- "get": {
- "parameters": [
- {"name": "page", "in": "query", "required": False}
- ],
- "responses": {"200": {"description": "OK"}},
- },
- "delete": {"responses": {"204": {"description": "No Content"}}},
- },
- "/items": {"get": {"responses": {"200": {"description": "OK"}}}},
- },
- schemas={
- "User": {
- "type": "object",
- "required": ["id"],
- "properties": {"id": {"type": "string"}},
- }
- },
- )
- new = _make_spec(
- paths={
- "/users": {
- "get": {
- "parameters": [
- {"name": "page", "in": "query", "required": True}
- ],
- "responses": {"200": {"description": "OK"}},
- },
- },
- },
- schemas={
- "User": {
- "type": "object",
- "required": ["id", "name"],
- "properties": {
- "id": {"type": "string"},
- "name": {"type": "string"},
- },
- }
- },
- )
- result = diff_specs(old, new)
- assert result.has_breaking
- # Should detect: path /items removed, operation DELETE removed, param became required, property became required
- breaking = result.breaking_changes
- assert len(breaking) >= 3
-
- def test_openapi_version_captured(self):
- old = _make_spec(openapi="3.0.0")
- new = _make_spec(openapi="3.1.0")
- result = diff_specs(old, new)
- assert result.old_version == "3.0.0"
- assert result.new_version == "3.1.0"
-
- def test_empty_specs(self):
- result = diff_specs({}, {})
- assert len(result.changes) == 0
-
- def test_adding_everything_non_breaking(self):
- old = _make_spec()
- new = _make_spec(
- paths={"/new": {"get": {"responses": {"200": {"description": "OK"}}}}},
- schemas={"NewItem": {"type": "object"}},
- )
- result = diff_specs(old, new)
- assert not result.has_breaking
-
-
-# ── Operation-level (per-endpoint) security diff tests ──
-
-
-def _op(security=None, extra=None):
- """Build a minimal operation object, optionally with a `security` key."""
- op = {"responses": {"200": {"description": "OK"}}}
- if security is not None:
- op["security"] = security
- if extra:
- op.update(extra)
- return op
-
-
-class TestOperationSecurityDiff:
- def test_operation_security_added_when_previously_inherited(self):
- old = _make_spec(paths={"/things": {"get": _op()}})
- new = _make_spec(
- paths={"/things": {"get": _op(security=[{"bearerAuth": []}])}}
- )
- result = diff_specs(old, new)
- matches = [c for c in result.changes if c.kind == "operation_security_added"]
- assert matches, "expected operation_security_added"
- assert matches[0].severity == Severity.DANGEROUS
- assert matches[0].path == "paths./things.get.security"
-
- def test_operation_security_removed_when_key_dropped(self):
- old = _make_spec(
- paths={"/things": {"get": _op(security=[{"bearerAuth": []}])}}
- )
- new = _make_spec(paths={"/things": {"get": _op()}})
- result = diff_specs(old, new)
- matches = [c for c in result.changes if c.kind == "operation_security_removed"]
- assert matches, "expected operation_security_removed"
- assert matches[0].severity == Severity.DANGEROUS
-
- def test_operation_became_public_is_flagged(self):
- old = _make_spec(
- paths={"/admin": {"get": _op(security=[{"bearerAuth": []}])}}
- )
- new = _make_spec(paths={"/admin": {"get": _op(security=[])}})
- result = diff_specs(old, new)
- matches = [c for c in result.changes if c.kind == "operation_security_removed"]
- assert matches, "endpoint dropping all auth must be flagged"
- assert "no longer requires authentication" in matches[0].description
-
- def test_public_operation_now_requires_auth(self):
- old = _make_spec(paths={"/admin": {"get": _op(security=[])}})
- new = _make_spec(
- paths={"/admin": {"get": _op(security=[{"bearerAuth": []}])}}
- )
- result = diff_specs(old, new)
- matches = [c for c in result.changes if c.kind == "operation_security_added"]
- assert matches, "expected operation_security_added"
- assert "previously public" in matches[0].description
-
- def test_operation_security_scheme_changed(self):
- old = _make_spec(
- paths={"/things": {"get": _op(security=[{"bearerAuth": []}])}}
- )
- new = _make_spec(
- paths={"/things": {"get": _op(security=[{"apiKeyAuth": []}])}}
- )
- result = diff_specs(old, new)
- matches = [c for c in result.changes if c.kind == "operation_security_changed"]
- assert matches, "expected operation_security_changed"
- assert matches[0].severity == Severity.DANGEROUS
-
- def test_identical_operation_security_is_no_change(self):
- old = _make_spec(
- paths={"/things": {"get": _op(security=[{"bearerAuth": []}])}}
- )
- new = _make_spec(
- paths={"/things": {"get": _op(security=[{"bearerAuth": []}])}}
- )
- result = diff_specs(old, new)
- assert not any(
- c.kind.startswith("operation_security_") for c in result.changes
- )
-
- def test_requirement_order_and_scope_order_ignored(self):
- # Same scheme groups, different requirement ordering -> no spurious diff.
- old = _make_spec(
- paths={
- "/things": {
- "get": _op(security=[{"a": []}, {"b": ["read", "write"]}])
- }
- }
- )
- new = _make_spec(
- paths={
- "/things": {
- "get": _op(security=[{"b": ["write", "read"]}, {"a": []}])
- }
- }
- )
- result = diff_specs(old, new)
- assert not any(
- c.kind.startswith("operation_security_") for c in result.changes
- )
-
- def test_both_inherit_global_no_operation_security_change(self):
- old = _make_spec(paths={"/things": {"get": _op()}})
- new = _make_spec(paths={"/things": {"get": _op(extra={"summary": "x"})}})
- result = diff_specs(old, new)
- assert not any(
- c.kind.startswith("operation_security_") for c in result.changes
- )
-
-
-# ── Media-type (request/response) inline schema diff tests ──
-
-
-def _op_body(request_schema=None, response_schema=None, ct="application/json"):
- """Build an operation with inline request/response schemas for a content type."""
- op = {"responses": {"200": {"description": "OK"}}}
- if request_schema is not None:
- op["requestBody"] = {"content": {ct: {"schema": request_schema}}}
- if response_schema is not None:
- op["responses"]["200"] = {
- "description": "OK",
- "content": {ct: {"schema": response_schema}},
- }
- return op
-
-
-class TestMediaTypeSchemaDiff:
- def test_response_schema_property_removed_is_breaking(self):
- old = _make_spec(
- paths={
- "/u": {
- "get": _op_body(
- response_schema={
- "type": "object",
- "properties": {
- "id": {"type": "string"},
- "name": {"type": "string"},
- },
- }
- )
- }
- }
- )
- new = _make_spec(
- paths={
- "/u": {
- "get": _op_body(
- response_schema={
- "type": "object",
- "properties": {"id": {"type": "string"}},
- }
- )
- }
- }
- )
- result = diff_specs(old, new)
- matches = [c for c in result.changes if c.kind == "response_property_removed"]
- assert matches, "dropping a response field must be flagged"
- assert matches[0].severity == Severity.BREAKING
- assert "name" in matches[0].path
-
- def test_request_schema_new_required_property_is_breaking(self):
- old = _make_spec(
- paths={
- "/u": {
- "post": _op_body(
- request_schema={
- "type": "object",
- "properties": {"id": {"type": "string"}},
- "required": ["id"],
- }
- )
- }
- }
- )
- new = _make_spec(
- paths={
- "/u": {
- "post": _op_body(
- request_schema={
- "type": "object",
- "properties": {
- "id": {"type": "string"},
- "email": {"type": "string"},
- },
- "required": ["id", "email"],
- }
- )
- }
- }
- )
- result = diff_specs(old, new)
- added = [c for c in result.changes if c.kind == "request_property_added"]
- assert added, "adding a request property must be reported"
- assert added[0].severity == Severity.BREAKING
- assert "(required)" in added[0].description
-
- def test_request_property_became_required_is_breaking(self):
- old = _make_spec(
- paths={
- "/u": {
- "post": _op_body(
- request_schema={
- "type": "object",
- "properties": {"id": {"type": "string"}},
- }
- )
- }
- }
- )
- new = _make_spec(
- paths={
- "/u": {
- "post": _op_body(
- request_schema={
- "type": "object",
- "properties": {"id": {"type": "string"}},
- "required": ["id"],
- }
- )
- }
- }
- )
- result = diff_specs(old, new)
- matches = [
- c for c in result.changes if c.kind == "request_property_became_required"
- ]
- assert matches
- assert matches[0].severity == Severity.BREAKING
-
- def test_response_property_became_required_is_non_breaking(self):
- old = _make_spec(
- paths={
- "/u": {
- "get": _op_body(
- response_schema={
- "type": "object",
- "properties": {"id": {"type": "string"}},
- }
- )
- }
- }
- )
- new = _make_spec(
- paths={
- "/u": {
- "get": _op_body(
- response_schema={
- "type": "object",
- "properties": {"id": {"type": "string"}},
- "required": ["id"],
- }
- )
- }
- }
- )
- result = diff_specs(old, new)
- matches = [
- c for c in result.changes if c.kind == "response_property_became_required"
- ]
- assert matches
- assert matches[0].severity == Severity.NON_BREAKING
-
- def test_response_property_no_longer_required_is_breaking(self):
- old = _make_spec(
- paths={
- "/u": {
- "get": _op_body(
- response_schema={
- "type": "object",
- "properties": {"id": {"type": "string"}},
- "required": ["id"],
- }
- )
- }
- }
- )
- new = _make_spec(
- paths={
- "/u": {
- "get": _op_body(
- response_schema={
- "type": "object",
- "properties": {"id": {"type": "string"}},
- }
- )
- }
- }
- )
- result = diff_specs(old, new)
- matches = [
- c
- for c in result.changes
- if c.kind == "response_property_no_longer_required"
- ]
- assert matches
- assert matches[0].severity == Severity.BREAKING
-
- def test_request_property_removed_is_non_breaking(self):
- old = _make_spec(
- paths={
- "/u": {
- "post": _op_body(
- request_schema={
- "type": "object",
- "properties": {
- "id": {"type": "string"},
- "note": {"type": "string"},
- },
- }
- )
- }
- }
- )
- new = _make_spec(
- paths={
- "/u": {
- "post": _op_body(
- request_schema={
- "type": "object",
- "properties": {"id": {"type": "string"}},
- }
- )
- }
- }
- )
- result = diff_specs(old, new)
- matches = [c for c in result.changes if c.kind == "request_property_removed"]
- assert matches
- assert matches[0].severity == Severity.NON_BREAKING
-
- def test_property_type_change_in_response_is_breaking(self):
- old = _make_spec(
- paths={
- "/u": {
- "get": _op_body(
- response_schema={
- "type": "object",
- "properties": {"id": {"type": "integer"}},
- }
- )
- }
- }
- )
- new = _make_spec(
- paths={
- "/u": {
- "get": _op_body(
- response_schema={
- "type": "object",
- "properties": {"id": {"type": "string"}},
- }
- )
- }
- }
- )
- result = diff_specs(old, new)
- matches = [c for c in result.changes if c.kind == "property_type_changed"]
- assert matches
- assert matches[0].severity == Severity.BREAKING
- assert matches[0].old_value == "integer"
- assert matches[0].new_value == "string"
-
- def test_top_level_schema_type_change_is_breaking(self):
- old = _make_spec(
- paths={"/u": {"get": _op_body(response_schema={"type": "object"})}}
- )
- new = _make_spec(
- paths={"/u": {"get": _op_body(response_schema={"type": "array"})}}
- )
- result = diff_specs(old, new)
- matches = [c for c in result.changes if c.kind == "schema_type_changed"]
- assert matches
- assert matches[0].severity == Severity.BREAKING
-
- def test_schema_ref_change_is_dangerous(self):
- old = _make_spec(
- paths={
- "/u": {
- "get": _op_body(
- response_schema={"$ref": "#/components/schemas/UserV1"}
- )
- }
- }
- )
- new = _make_spec(
- paths={
- "/u": {
- "get": _op_body(
- response_schema={"$ref": "#/components/schemas/UserV2"}
- )
- }
- }
- )
- result = diff_specs(old, new)
- matches = [c for c in result.changes if c.kind == "schema_ref_changed"]
- assert matches
- assert matches[0].severity == Severity.DANGEROUS
-
- def test_same_ref_produces_no_inline_schema_change(self):
- schema = {"$ref": "#/components/schemas/User"}
- old = _make_spec(paths={"/u": {"get": _op_body(response_schema=schema)}})
- new = _make_spec(paths={"/u": {"get": _op_body(response_schema=schema)}})
- result = diff_specs(old, new)
- assert not any(
- c.kind
- in (
- "schema_ref_changed",
- "response_property_removed",
- "response_property_added",
- "property_type_changed",
- )
- for c in result.changes
- )
-
- def test_identical_inline_schemas_no_change(self):
- schema = {
- "type": "object",
- "properties": {"id": {"type": "string"}},
- "required": ["id"],
- }
- old = _make_spec(
- paths={
- "/u": {
- "post": _op_body(request_schema=schema, response_schema=schema)
- }
- }
- )
- new = _make_spec(
- paths={
- "/u": {
- "post": _op_body(request_schema=schema, response_schema=schema)
- }
- }
- )
- result = diff_specs(old, new)
- schema_kinds = {
- "schema_type_changed",
- "property_type_changed",
- "request_property_added",
- "response_property_removed",
- "request_property_became_required",
- "response_property_no_longer_required",
- }
- assert not any(c.kind in schema_kinds for c in result.changes)
-
- def test_nested_object_response_property_removed_is_breaking(self):
- old = _make_spec(
- paths={
- "/u": {
- "get": _op_body(
- response_schema={
- "type": "object",
- "properties": {
- "id": {"type": "string"},
- "address": {
- "type": "object",
- "properties": {
- "street": {"type": "string"},
- "city": {"type": "string"},
- },
- },
- },
- }
- )
- }
- }
- )
- new = _make_spec(
- paths={
- "/u": {
- "get": _op_body(
- response_schema={
- "type": "object",
- "properties": {
- "id": {"type": "string"},
- "address": {
- "type": "object",
- "properties": {"street": {"type": "string"}},
- },
- },
- }
- )
- }
- }
- )
- result = diff_specs(old, new)
- matches = [c for c in result.changes if c.kind == "response_property_removed"]
- assert matches, "dropping a nested response field must be flagged"
- assert matches[0].severity == Severity.BREAKING
- assert "address.properties.city" in matches[0].path
-
- def test_nested_object_request_new_required_field_is_breaking(self):
- old = _make_spec(
- paths={
- "/u": {
- "post": _op_body(
- request_schema={
- "type": "object",
- "properties": {
- "profile": {
- "type": "object",
- "properties": {"name": {"type": "string"}},
- }
- },
- }
- )
- }
- }
- )
- new = _make_spec(
- paths={
- "/u": {
- "post": _op_body(
- request_schema={
- "type": "object",
- "properties": {
- "profile": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "bio": {"type": "string"},
- },
- "required": ["name", "bio"],
- }
- },
- }
- )
- }
- }
- )
- result = diff_specs(old, new)
- matches = [c for c in result.changes if c.kind == "request_property_added"]
- assert matches, "adding a required nested request field must be flagged"
- assert matches[0].severity == Severity.BREAKING
- assert "profile.properties.bio" in matches[0].path
- assert "(required)" in matches[0].description
-
- def test_nested_array_item_property_removed_is_breaking(self):
- old = _make_spec(
- paths={
- "/u": {
- "get": _op_body(
- response_schema={
- "type": "object",
- "properties": {
- "items": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "sku": {"type": "string"},
- "qty": {"type": "integer"},
- },
- },
- }
- },
- }
- )
- }
- }
- )
- new = _make_spec(
- paths={
- "/u": {
- "get": _op_body(
- response_schema={
- "type": "object",
- "properties": {
- "items": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {"qty": {"type": "integer"}},
- },
- }
- },
- }
- )
- }
- }
- )
- result = diff_specs(old, new)
- matches = [c for c in result.changes if c.kind == "response_property_removed"]
- assert matches, "dropping a field inside an array-of-objects must be flagged"
- assert matches[0].severity == Severity.BREAKING
- assert "items.items.properties.sku" in matches[0].path
-
- def test_identical_nested_schemas_no_change(self):
- schema = {
- "type": "object",
- "properties": {
- "id": {"type": "string"},
- "address": {
- "type": "object",
- "properties": {"city": {"type": "string"}},
- "required": ["city"],
- },
- "items": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {"sku": {"type": "string"}},
- },
- },
- },
- }
- old = _make_spec(
- paths={"/u": {"post": _op_body(request_schema=schema, response_schema=schema)}}
- )
- new = _make_spec(
- paths={"/u": {"post": _op_body(request_schema=schema, response_schema=schema)}}
- )
- result = diff_specs(old, new)
- nested_kinds = {
- "schema_type_changed",
- "property_type_changed",
- "request_property_added",
- "response_property_removed",
- "request_property_became_required",
- "response_property_no_longer_required",
- }
- assert not any(c.kind in nested_kinds for c in result.changes)
diff --git a/tests/test_dummy.py b/tests/test_dummy.py
deleted file mode 100644
index 10cf3ad..0000000
--- a/tests/test_dummy.py
+++ /dev/null
@@ -1,2 +0,0 @@
-def test_dummy():
- pass
diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py
deleted file mode 100644
index b308592..0000000
--- a/tests/test_edge_cases.py
+++ /dev/null
@@ -1,94 +0,0 @@
-"""Edge-case tests for api-contract-guardian uncovered code paths.
-
-Covers:
-- require_license absent (cli.py:19-20)
-- __main__.py entry point (__main__.py:3-5)
-- version command (cli.py:256)
-"""
-
-from __future__ import annotations
-
-import subprocess
-import sys
-
-from typer.testing import CliRunner
-
-from api_contract_guardian.cli import app
-
-runner = CliRunner()
-
-
-class TestLicenseAgnostic:
- """Tests that work regardless of license module availability."""
-
- def test_version_command_runs(self):
- """version command prints version string."""
- result = runner.invoke(app, ["version"])
- assert result.exit_code == 0
- assert "v" in result.output
-
- def test_help_contains_version(self):
- """help output includes version command."""
- result = runner.invoke(app, ["--help"])
- assert result.exit_code == 0
- assert "version" in result.output
-
-
-class TestMainModule:
- """Tests for __main__.py entry point."""
-
- def test_main_module_runs(self):
- """python -m api_contract_guardian --help works."""
- result = subprocess.run(
- [sys.executable, "-m", "api_contract_guardian", "--help"],
- capture_output=True,
- text=False,
- )
- assert result.returncode == 0
- assert b"Usage" in result.stdout
-
- def test_main_module_version(self):
- """python -m api_contract_guardian version works."""
- result = subprocess.run(
- [sys.executable, "-m", "api_contract_guardian", "version"],
- capture_output=True,
- text=True,
- )
- assert result.returncode == 0
- assert "v" in result.stdout
-
-
-class TestDiffEdgeCases:
- """Edge cases for diff command."""
-
- def test_diff_invalid_input(self):
- """diff with non-existent file shows error."""
- result = runner.invoke(
- app, ["diff", "/nonexistent/old.yaml", "/nonexistent/new.yaml"]
- )
- assert result.exit_code != 0
- assert "Error" in result.output
-
-
-class TestCheckEdgeCases:
- """Edge cases for check command."""
-
- def test_check_invalid_input(self):
- """check with non-existent file shows error."""
- result = runner.invoke(
- app, ["check", "/nonexistent/old.yaml", "/nonexistent/new.yaml"]
- )
- assert result.exit_code != 0
- assert "Error" in result.output
-
-
-class TestMigrateEdgeCases:
- """Edge cases for migrate command."""
-
- def test_migrate_invalid_input(self):
- """migrate with non-existent file shows error."""
- result = runner.invoke(
- app, ["migrate", "/nonexistent/old.yaml", "/nonexistent/new.yaml"]
- )
- assert result.exit_code != 0
- assert "Error" in result.output
diff --git a/tests/test_gate.py b/tests/test_gate.py
deleted file mode 100644
index 66a64ad..0000000
--- a/tests/test_gate.py
+++ /dev/null
@@ -1,147 +0,0 @@
-"""Tests for the gate module."""
-
-from api_contract_guardian.diff import Change, DiffResult, Severity
-from api_contract_guardian.gate import GateResult, check_gate
-
-
-def _make_result(breaking=0, dangerous=0, non_breaking=0, info=0):
- """Helper to create a DiffResult with specified change counts."""
- changes = []
- for _ in range(breaking):
- changes.append(
- Change(kind="x", severity=Severity.BREAKING, path="", description="")
- )
- for _ in range(dangerous):
- changes.append(
- Change(kind="x", severity=Severity.DANGEROUS, path="", description="")
- )
- for _ in range(non_breaking):
- changes.append(
- Change(kind="x", severity=Severity.NON_BREAKING, path="", description="")
- )
- for _ in range(info):
- changes.append(
- Change(kind="x", severity=Severity.INFO, path="", description="")
- )
- return DiffResult(changes=changes)
-
-
-class TestGateResult:
- def test_to_dict(self):
- gr = GateResult(passed=True, breaking_count=0, dangerous_count=0, message="OK")
- d = gr.to_dict()
- assert d["passed"] is True
- assert d["breaking_count"] == 0
- assert d["exit_code"] == 0
-
-
-class TestCheckGate:
- def test_no_changes_passes(self):
- result = _make_result()
- gate = check_gate(result)
- assert gate.passed
- assert gate.exit_code == 0
-
- def test_breaking_fails_by_default(self):
- result = _make_result(breaking=1)
- gate = check_gate(result)
- assert not gate.passed
- assert gate.exit_code == 1
-
- def test_dangerous_passes_by_default(self):
- result = _make_result(dangerous=1)
- gate = check_gate(result)
- assert gate.passed
-
- def test_dangerous_fails_when_configured(self):
- result = _make_result(dangerous=1)
- gate = check_gate(result, fail_on_dangerous=True)
- assert not gate.passed
-
- def test_non_breaking_always_passes(self):
- result = _make_result(non_breaking=5)
- gate = check_gate(result)
- assert gate.passed
-
- def test_info_always_passes(self):
- result = _make_result(info=5)
- gate = check_gate(result)
- assert gate.passed
-
- def test_allow_breaking_with_flag(self):
- result = _make_result(breaking=2)
- gate = check_gate(result, fail_on_breaking=False)
- assert gate.passed
-
- def test_max_breaking_within_limit(self):
- result = _make_result(breaking=2)
- gate = check_gate(result, max_breaking=2)
- assert gate.passed
-
- def test_max_breaking_exceeds_limit(self):
- result = _make_result(breaking=3)
- gate = check_gate(result, max_breaking=2)
- assert not gate.passed
-
- def test_max_dangerous_within_limit(self):
- result = _make_result(dangerous=2)
- gate = check_gate(result, fail_on_breaking=False, max_dangerous=2)
- assert gate.passed
-
- def test_max_dangerous_exceeds_limit(self):
- result = _make_result(dangerous=3)
- gate = check_gate(result, fail_on_breaking=False, max_dangerous=2)
- assert not gate.passed
-
- def test_max_breaking_negative_one_unlimited(self):
- result = _make_result(breaking=100)
- gate = check_gate(result, fail_on_breaking=False, max_breaking=-1)
- assert gate.passed
-
- def test_mixed_changes_breaking_dominates(self):
- result = _make_result(breaking=1, non_breaking=5, info=3)
- gate = check_gate(result)
- assert not gate.passed
- assert gate.breaking_count == 1
-
- def test_mixed_changes_no_breaking_with_dangerous(self):
- result = _make_result(dangerous=1, non_breaking=5)
- gate = check_gate(result)
- assert gate.passed
-
- def test_message_on_pass(self):
- result = _make_result()
- gate = check_gate(result)
- assert "PASSED" in gate.message
-
- def test_message_on_fail(self):
- result = _make_result(breaking=1)
- gate = check_gate(result)
- assert "FAILED" in gate.message
-
- def test_both_breaking_and_dangerous_fail(self):
- result = _make_result(breaking=1, dangerous=1)
- gate = check_gate(result, fail_on_dangerous=True)
- assert not gate.passed
-
- def test_zero_max_breaking_with_zero_changes(self):
- result = _make_result(breaking=0)
- gate = check_gate(result, max_breaking=0)
- assert gate.passed
-
- def test_fail_on_breaking_false_max_breaking_exceeded(self):
- result = _make_result(breaking=3)
- gate = check_gate(result, fail_on_breaking=False, max_breaking=1)
- assert not gate.passed
-
- def test_fail_on_breaking_false_max_breaking_zero_enforced(self):
- """max_breaking=0 should still fail even when fail_on_breaking=False."""
- result = _make_result(breaking=1)
- gate = check_gate(result, fail_on_breaking=False, max_breaking=0)
- assert not gate.passed
-
- def test_fail_on_dangerous_false_max_dangerous_zero_enforced(self):
- """max_dangerous=0 should still fail even when fail_on_dangerous=False."""
- result = _make_result(dangerous=1)
- gate = check_gate(result, fail_on_breaking=False, max_dangerous=0)
- assert not gate.passed
diff --git a/tests/test_loader.py b/tests/test_loader.py
deleted file mode 100644
index a7718b4..0000000
--- a/tests/test_loader.py
+++ /dev/null
@@ -1,260 +0,0 @@
-"""Tests for the loader module."""
-
-import json
-
-import pytest
-import yaml
-
-from api_contract_guardian.loader import (
- SpecLoadError,
- get_operations,
- get_paths,
- get_schemas,
- load_spec,
- load_spec_from_string,
- validate_openapi_version,
-)
-
-# ── Fixtures ──
-
-
-@pytest.fixture
-def sample_spec():
- return {
- "openapi": "3.0.3",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {},
- }
-
-
-@pytest.fixture
-def yaml_spec_file(tmp_path):
- spec = {
- "openapi": "3.0.3",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {"/users": {"get": {"responses": {"200": {"description": "OK"}}}}},
- }
- p = tmp_path / "spec.yaml"
- p.write_text(yaml.dump(spec), encoding="utf-8")
- return p
-
-
-@pytest.fixture
-def json_spec_file(tmp_path):
- spec = {
- "openapi": "3.0.3",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {},
- }
- p = tmp_path / "spec.json"
- p.write_text(json.dumps(spec), encoding="utf-8")
- return p
-
-
-# ── load_spec tests ──
-
-
-class TestLoadSpec:
- def test_load_yaml_file(self, yaml_spec_file):
- spec = load_spec(yaml_spec_file)
- assert spec["openapi"] == "3.0.3"
- assert "/users" in spec["paths"]
-
- def test_load_json_file(self, json_spec_file):
- spec = load_spec(json_spec_file)
- assert spec["openapi"] == "3.0.3"
-
- def test_load_yml_extension(self, tmp_path):
- p = tmp_path / "spec.yml"
- p.write_text(
- yaml.dump(
- {
- "openapi": "3.0.3",
- "info": {"title": "T", "version": "1.0.0"},
- "paths": {},
- }
- ),
- encoding="utf-8",
- )
- spec = load_spec(p)
- assert spec["openapi"] == "3.0.3"
-
- def test_load_nonexistent_file_raises(self):
- with pytest.raises(SpecLoadError, match="not found"):
- load_spec("/nonexistent/path/spec.yaml")
-
- def test_load_invalid_yaml_raises(self, tmp_path):
- p = tmp_path / "bad.yaml"
- p.write_text(":\n bad: yaml: [", encoding="utf-8")
- with pytest.raises(SpecLoadError, match="Invalid YAML"):
- load_spec(p)
-
- def test_load_invalid_json_raises(self, tmp_path):
- p = tmp_path / "bad.json"
- p.write_text("{invalid json", encoding="utf-8")
- with pytest.raises(SpecLoadError, match="Invalid JSON"):
- load_spec(p)
-
- def test_load_non_dict_spec_raises(self, tmp_path):
- p = tmp_path / "list.yaml"
- p.write_text(yaml.dump(["item1", "item2"]), encoding="utf-8")
- with pytest.raises(SpecLoadError, match="not a valid OpenAPI document"):
- load_spec(p)
-
- def test_load_unreadable_file_raises(self, tmp_path):
- """Loading an unreadable target (e.g. a directory) raises SpecLoadError."""
- d = tmp_path / "somedir"
- d.mkdir()
- with pytest.raises(SpecLoadError, match="Cannot read"):
- load_spec(d)
-
- def test_load_string_path(self, yaml_spec_file):
- spec = load_spec(str(yaml_spec_file))
- assert spec["openapi"] == "3.0.3"
-
- def test_load_unknown_extension_tries_yaml_then_json(self, tmp_path):
- p = tmp_path / "spec.txt"
- spec_data = {
- "openapi": "3.0.3",
- "info": {"title": "T", "version": "1.0.0"},
- "paths": {},
- }
- p.write_text(yaml.dump(spec_data), encoding="utf-8")
- spec = load_spec(p)
- assert spec["openapi"] == "3.0.3"
-
- def test_load_unknown_extension_both_parse_fail_raises(self, tmp_path):
- """Unknown extension with unparseable content raises SpecLoadError."""
- p = tmp_path / "spec.txt"
- p.write_text("{{{{not yaml or json}}}}", encoding="utf-8")
- with pytest.raises(SpecLoadError, match="Cannot parse"):
- load_spec(p)
-
-
-# ── load_spec_from_string tests ──
-
-
-class TestLoadSpecFromString:
- def test_load_yaml_string(self):
- content = yaml.dump(
- {
- "openapi": "3.0.3",
- "info": {"title": "T", "version": "1.0.0"},
- "paths": {},
- }
- )
- spec = load_spec_from_string(content, fmt="yaml")
- assert spec["openapi"] == "3.0.3"
-
- def test_load_json_string(self):
- content = json.dumps(
- {
- "openapi": "3.0.3",
- "info": {"title": "T", "version": "1.0.0"},
- "paths": {},
- }
- )
- spec = load_spec_from_string(content, fmt="json")
- assert spec["openapi"] == "3.0.3"
-
- def test_load_invalid_yaml_string_raises(self):
- with pytest.raises(SpecLoadError, match="Invalid YAML"):
- load_spec_from_string(":\n bad: yaml: [", fmt="yaml")
-
- def test_load_invalid_json_string_raises(self):
- with pytest.raises(SpecLoadError, match="Invalid JSON"):
- load_spec_from_string("{invalid", fmt="json")
-
- def test_load_non_dict_string_raises(self):
- with pytest.raises(SpecLoadError, match="not a valid OpenAPI document"):
- load_spec_from_string('["list", "not", "dict"]', fmt="json")
-
- def test_load_non_dict_yaml_string_raises(self):
- """YAML parse of non-dict content raises SpecLoadError (covers YAML branch)."""
- content = "- item1\n- item2"
- with pytest.raises(SpecLoadError, match="not a valid OpenAPI document"):
- load_spec_from_string(content, fmt="yaml")
-
-
-# ── validate_openapi_version tests ──
-
-
-class TestValidateOpenapiVersion:
- def test_valid_3_0_spec(self, sample_spec):
- version = validate_openapi_version(sample_spec)
- assert version == "3.0.3"
-
- def test_valid_3_1_spec(self):
- version = validate_openapi_version({"openapi": "3.1.0"})
- assert version == "3.1.0"
-
- def test_swagger_2_raises(self):
- with pytest.raises(SpecLoadError, match="not supported"):
- validate_openapi_version({"swagger": "2.0"})
-
- def test_unknown_version_raises(self):
- with pytest.raises(SpecLoadError, match="Unrecognized"):
- validate_openapi_version({"openapi": "4.0.0"})
-
- def test_missing_version_raises(self):
- with pytest.raises(SpecLoadError, match="does not contain"):
- validate_openapi_version({"info": {"title": "T"}})
-
-
-# ── Helper function tests ──
-
-
-class TestGetPaths:
- def test_returns_paths(self):
- spec = {"paths": {"/users": {}, "/items": {}}}
- assert get_paths(spec) == {"/users": {}, "/items": {}}
-
- def test_empty_paths(self):
- assert get_paths({}) == {}
-
- def test_no_paths_key(self):
- assert get_paths({"info": {}}) == {}
-
-
-class TestGetSchemas:
- def test_returns_schemas(self):
- spec = {"components": {"schemas": {"User": {"type": "object"}}}}
- assert "User" in get_schemas(spec)
-
- def test_no_components(self):
- assert get_schemas({}) == {}
-
- def test_no_schemas_in_components(self):
- assert get_schemas({"components": {}}) == {}
-
-
-class TestGetOperations:
- def test_extracts_methods(self):
- path_item = {"get": {}, "post": {}, "put": {}}
- ops = get_operations(path_item)
- assert set(ops.keys()) == {"get", "post", "put"}
-
- def test_ignores_non_methods(self):
- path_item = {"get": {}, "parameters": [], "summary": "test"}
- ops = get_operations(path_item)
- assert "get" in ops
- assert "parameters" not in ops
- assert "summary" not in ops
-
- def test_all_methods(self):
- path_item = {
- "get": {},
- "post": {},
- "put": {},
- "patch": {},
- "delete": {},
- "head": {},
- "options": {},
- "trace": {},
- }
- ops = get_operations(path_item)
- assert len(ops) == 8
-
- def test_empty_path_item(self):
- ops = get_operations({})
- assert ops == {}
diff --git a/tests/test_migration.py b/tests/test_migration.py
deleted file mode 100644
index 24df83b..0000000
--- a/tests/test_migration.py
+++ /dev/null
@@ -1,417 +0,0 @@
-"""Tests for the migration module."""
-
-from api_contract_guardian.diff import Change, DiffResult, Severity
-from api_contract_guardian.migration import (
- generate_migration_guide,
- generate_migration_guide_json,
-)
-
-
-def _make_result(changes=None, old_version="3.0.0", new_version="3.1.0"):
- return DiffResult(
- changes=changes or [], old_version=old_version, new_version=new_version
- )
-
-
-class TestGenerateMigrationGuide:
- def test_empty_changes(self):
- result = _make_result()
- guide = generate_migration_guide(result)
- assert "No changes detected" in guide
-
- def test_has_title(self):
- result = _make_result()
- guide = generate_migration_guide(result)
- assert "# API Migration Guide" in guide
-
- def test_shows_versions(self):
- result = _make_result(old_version="3.0.0", new_version="3.1.0")
- guide = generate_migration_guide(result)
- assert "3.0.0" in guide
- assert "3.1.0" in guide
-
- def test_breaking_changes_section(self):
- changes = [
- Change(
- kind="path_removed",
- severity=Severity.BREAKING,
- path="paths./users",
- description="Path removed",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Breaking Changes" in guide
- assert "path_removed" in guide
-
- def test_dangerous_changes_section(self):
- changes = [
- Change(
- kind="operation_deprecated",
- severity=Severity.DANGEROUS,
- path="paths./old",
- description="Deprecated",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Dangerous Changes" in guide
-
- def test_non_breaking_changes_section(self):
- changes = [
- Change(
- kind="path_added",
- severity=Severity.NON_BREAKING,
- path="paths./new",
- description="Path added",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Non-Breaking Changes" in guide
-
- def test_info_section(self):
- changes = [
- Change(
- kind="title_changed",
- severity=Severity.INFO,
- path="info.title",
- description="Title changed",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Informational" in guide
-
- def test_summary_table(self):
- changes = [
- Change(kind="a", severity=Severity.BREAKING, path="", description=""),
- Change(kind="b", severity=Severity.NON_BREAKING, path="", description=""),
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "| Breaking | 1 |" in guide
- assert "| Non-breaking | 1 |" in guide
-
- def test_migration_steps_generated_for_breaking(self):
- changes = [
- Change(
- kind="path_removed",
- severity=Severity.BREAKING,
- path="paths./users",
- description="Path removed",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Recommended Migration Steps" in guide
-
- def test_no_migration_steps_without_breaking(self):
- changes = [
- Change(
- kind="path_added",
- severity=Severity.NON_BREAKING,
- path="paths./new",
- description="Added",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Recommended Migration Steps" not in guide
-
- def test_value_change_shown(self):
- changes = [
- Change(
- kind="type_changed",
- severity=Severity.BREAKING,
- path="x",
- description="Type changed",
- old_value="string",
- new_value="integer",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Before" in guide
- assert "After" in guide
-
-
-class TestMigrationStepGeneration:
- """Tests that _generate_steps produces correct steps for each breaking change kind."""
-
- def test_path_removed_step(self):
- changes = [
- Change(
- kind="path_removed",
- severity=Severity.BREAKING,
- path="paths./users",
- description="Path removed",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Remove client code referencing removed paths" in guide
- assert "paths./users" in guide
-
- def test_operation_removed_step(self):
- changes = [
- Change(
- kind="operation_removed",
- severity=Severity.BREAKING,
- path="paths./users.delete",
- description="DELETE removed",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "stop calling removed operations" in guide
-
- def test_parameter_became_required_step(self):
- changes = [
- Change(
- kind="parameter_became_required",
- severity=Severity.BREAKING,
- path="paths./users.get.parameters.query.id",
- description="Param became required",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Add required parameters to requests" in guide
-
- def test_required_parameter_added_step(self):
- changes = [
- Change(
- kind="parameter_added",
- severity=Severity.BREAKING,
- path="paths./users.get.parameters.query.sort",
- description="Parameter 'sort' (query) was added (required)",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Add newly required parameters" in guide
-
- def test_schema_removed_step(self):
- changes = [
- Change(
- kind="schema_removed",
- severity=Severity.BREAKING,
- path="components.schemas.Legacy",
- description="Schema removed",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Replace removed schemas" in guide
- assert "components.schemas.Legacy" in guide
-
- def test_type_changed_step(self):
- changes = [
- Change(
- kind="property_type_changed",
- severity=Severity.BREAKING,
- path="components.schemas.User.age",
- description="Type changed",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Update type handling code" in guide
-
- def test_schema_type_changed_step(self):
- changes = [
- Change(
- kind="schema_type_changed",
- severity=Severity.BREAKING,
- path="components.schemas.User",
- description="Schema type changed",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Update type handling code" in guide
-
- def test_parameter_type_changed_step(self):
- changes = [
- Change(
- kind="parameter_type_changed",
- severity=Severity.BREAKING,
- path="paths./users.get.parameters.query.id",
- description="Param type changed",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Update type handling code" in guide
-
- def test_property_removed_step(self):
- changes = [
- Change(
- kind="property_removed",
- severity=Severity.BREAKING,
- path="components.schemas.User.email",
- description="Property removed",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Update code that references removed properties" in guide
-
- def test_enum_values_removed_step(self):
- changes = [
- Change(
- kind="enum_values_removed",
- severity=Severity.BREAKING,
- path="components.schemas.Status",
- description="Enum values removed",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Update enum value references" in guide
-
- def test_request_body_became_required_step(self):
- changes = [
- Change(
- kind="request_body_became_required",
- severity=Severity.BREAKING,
- path="paths./users.post.requestBody",
- description="Request body became required",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "include required request bodies" in guide
-
- def test_content_type_removed_step(self):
- changes = [
- Change(
- kind="request_content_type_removed",
- severity=Severity.BREAKING,
- path="paths./users.post.requestBody.content.application/xml",
- description="Content type removed",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "content-type handling" in guide
-
- def test_response_content_type_removed_step(self):
- changes = [
- Change(
- kind="response_content_type_removed",
- severity=Severity.BREAKING,
- path="paths./users.get.responses.200.content.application/xml",
- description="Response content type removed",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "content-type handling" in guide
-
- def test_unrecognized_breaking_change_fallback_step(self):
- """An unrecognized breaking change kind still produces a generic step."""
- changes = [
- Change(
- kind="some_new_breaking_kind",
- severity=Severity.BREAKING,
- path="x",
- description="Unknown",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Recommended Migration Steps" in guide
- assert "Review breaking changes" in guide
-
- def test_multiple_step_types_combined(self):
- """Multiple different breaking change kinds produce multiple distinct steps."""
- changes = [
- Change(
- kind="path_removed",
- severity=Severity.BREAKING,
- path="paths./old",
- description="Path removed",
- ),
- Change(
- kind="schema_removed",
- severity=Severity.BREAKING,
- path="components.schemas.Old",
- description="Schema removed",
- ),
- Change(
- kind="enum_values_removed",
- severity=Severity.BREAKING,
- path="components.schemas.Status",
- description="Enum values removed",
- ),
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "Remove client code referencing removed paths" in guide
- assert "Replace removed schemas" in guide
- assert "Update enum value references" in guide
-
- def test_operation_id_removed_step(self):
- """operation_id_removed produces an SDK codegen migration step."""
- changes = [
- Change(
- kind="operation_id_removed",
- severity=Severity.BREAKING,
- path="paths./users.get",
- description="operationId removed",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "operationIds" in guide
-
- def test_operation_id_changed_step(self):
- """operation_id_changed produces an SDK codegen migration step."""
- changes = [
- Change(
- kind="operation_id_changed",
- severity=Severity.BREAKING,
- path="paths./users.get",
- description="operationId changed",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide(result)
- assert "operationIds" in guide
-
-
-class TestGenerateMigrationGuideJson:
- def test_empty_changes(self):
- result = _make_result()
- guide = generate_migration_guide_json(result)
- assert guide["from_version"] == "3.0.0"
- assert guide["to_version"] == "3.1.0"
- assert guide["breaking_changes"] == []
- assert guide["migration_steps"] == []
-
- def test_has_breaking_changes(self):
- changes = [
- Change(
- kind="path_removed",
- severity=Severity.BREAKING,
- path="p",
- description="d",
- )
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide_json(result)
- assert len(guide["breaking_changes"]) == 1
- assert len(guide["migration_steps"]) > 0
-
- def test_summary_counts(self):
- changes = [
- Change(kind="a", severity=Severity.BREAKING, path="", description=""),
- Change(kind="b", severity=Severity.BREAKING, path="", description=""),
- Change(kind="c", severity=Severity.DANGEROUS, path="", description=""),
- ]
- result = _make_result(changes=changes)
- guide = generate_migration_guide_json(result)
- assert guide["summary"]["breaking"] == 2
- assert guide["summary"]["dangerous"] == 1