diff --git a/README.md b/README.md
index cbba11a..c7a66af 100644
--- a/README.md
+++ b/README.md
@@ -1,572 +1,7 @@
-# `>` Delimit GitHub Action
-
-**Catch breaking API changes before merge** — semver classification, migration guides, and policy enforcement for OpenAPI specs.
-
-[](https://github.com/marketplace/actions/delimit-api-governance)
-[](https://opensource.org/licenses/MIT)
-[](https://github.com/marketplace/actions/delimit-api-governance)
-
-Delimit runs on every pull request, compares your OpenAPI spec against the base branch, and posts a detailed comment with breaking changes, semver classification, policy violations, and migration guidance. No API keys, no external services, no config required to get started.
-
-## What it looks like
-
-
-
-
-
----
-
-## Features
-
-- **Breaking change detection** — catches 27 types of changes (17 breaking, 10 non-breaking) across endpoints, parameters, response schemas, types, enums, security, and constraints
-- **Semver classification** — deterministic `major` / `minor` / `patch` / `none` bump recommendation with computed next version
-- **Migration guides** — auto-generated step-by-step migration instructions for every breaking change
-- **PR comments** — rich Markdown summary posted directly on your pull request, updated on each push
-- **Advisory and enforce modes** — start with non-blocking warnings, promote to CI-gating when ready
-- **Custom policies** — define your own governance rules in `.delimit/policies.yml` with path patterns, severity levels, and custom messages
-- **7 explainer templates** — developer, team lead, product, migration, changelog, PR comment, and Slack formats
-
----
-
-## Quick Start
-
-Add this file to `.github/workflows/api-check.yml`:
-
-```yaml
-name: API Contract Check
-on: pull_request
-
-jobs:
- delimit:
- runs-on: ubuntu-latest
- permissions:
- pull-requests: write
- steps:
- - uses: actions/checkout@v4
- - uses: delimit-ai/delimit-action@v1
- with:
- spec: api/openapi.yaml
-```
-
-That is it. Delimit auto-fetches the base branch version of your spec and diffs it against the PR changes. Runs in **advisory mode** by default — posts a PR comment but never fails your build.
-
-### What the PR comment looks like
-
-When Delimit detects breaking changes, it posts a comment like this:
-
-> **Delimit API Governance** | Breaking Changes Detected
->
-> | Change | Path | Severity |
-> |--------|------|----------|
-> | endpoint_removed | `DELETE /pets/{petId}` | error |
-> | type_changed | `/pets:GET:200[].id` (string → integer) | warning |
-> | enum_value_removed | `/pets:GET:200[].status` | warning |
->
-> **Semver**: MAJOR (1.0.0 → 2.0.0)
->
-> Migration Guide (3 steps)
->
-> **Step 1**: `DELETE /pets/{petId}` was removed. Update clients to use an alternative endpoint or remove calls to this path.
->
-> **Step 2**: `id` changed from `string` to `integer`. Update serialization logic, type assertions, and database column types.
->
-> **Step 3**: `status` enum value `"pending"` was removed. Update clients to stop sending this value.
->
->
-
-See the [live demo](https://github.com/delimit-ai/delimit-action-demo/pull/2) — a Users API migration with 23 breaking changes detected across 27 change types, severity badges, and a migration guide.
-
-### Advanced: explicit base and head specs
-
-If you need to compare specific files (e.g., pre-checked-out base branch), use `old_spec` and `new_spec` instead:
-
-```yaml
- - uses: delimit-ai/delimit-action@v1
- with:
- old_spec: base/api/openapi.yaml
- new_spec: api/openapi.yaml
-```
-
----
-
-## Full Usage
-
-```yaml
-name: API Governance
-on: pull_request
-
-jobs:
- api-check:
- runs-on: ubuntu-latest
- permissions:
- pull-requests: write
- steps:
- - uses: actions/checkout@v4
-
- - name: Checkout base spec
- uses: actions/checkout@v4
- with:
- ref: ${{ github.event.pull_request.base.sha }}
- path: base
-
- - uses: delimit-ai/delimit-action@v1
- id: delimit
- with:
- old_spec: base/api/openapi.yaml
- new_spec: api/openapi.yaml
- mode: enforce
- policy_file: .delimit/policies.yml
- github_token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Use outputs
- if: always()
- run: |
- echo "Breaking changes: ${{ steps.delimit.outputs.breaking_changes_detected }}"
- echo "Violations: ${{ steps.delimit.outputs.violations_count }}"
- echo "Semver bump: ${{ steps.delimit.outputs.semver_bump }}"
- echo "Next version: ${{ steps.delimit.outputs.next_version }}"
-```
-
----
-
-## Inputs
-
-| Input | Required | Default | Description |
-|-------|----------|---------|-------------|
-| `spec` | No | `''` | Path to the changed OpenAPI spec. On pull requests, Delimit auto-fetches the base branch version for comparison. |
-| `old_spec` | No | `''` | Path to the old/base API specification file. |
-| `new_spec` | No | `''` | Path to the new/changed API specification file. |
-| `mode` | No | `advisory` | `advisory` (comments only) or `enforce` (fails CI on breaking changes). |
-| `github_token` | No | `${{ github.token }}` | GitHub token used to post PR comments. |
-| `policy_file` | No | `''` | Path to a custom policy file (e.g., `.delimit/policies.yml`). |
-
-> **Note**: Provide either `spec` for pull request workflows, or both `old_spec` and `new_spec` for explicit comparisons. If neither form is provided, the action exits with an error.
-
----
-
-## Outputs
-
-| Output | Type | Description |
-|--------|------|-------------|
-| `breaking_changes_detected` | `string` | `"true"` if any breaking change was found, `"false"` otherwise. |
-| `violations_count` | `string` | Number of policy violations (errors + warnings). |
-| `semver_bump` | `string` | Recommended version bump: `major`, `minor`, `patch`, or `none`. |
-| `next_version` | `string` | Computed next version string (e.g., `2.0.0`). |
-| `report` | `string` | Full JSON report of all detected changes, violations, and semver data. |
-
-### Using outputs in subsequent steps
-
-```yaml
-- uses: delimit-ai/delimit-action@v1
- id: delimit
- with:
- old_spec: base/api/openapi.yaml
- new_spec: api/openapi.yaml
-
-- name: Block release on breaking changes
- if: steps.delimit.outputs.breaking_changes_detected == 'true'
- run: |
- echo "Breaking changes detected — semver bump: ${{ steps.delimit.outputs.semver_bump }}"
- echo "Next version should be: ${{ steps.delimit.outputs.next_version }}"
- exit 1
-
-- name: Auto-tag on minor bump
- if: steps.delimit.outputs.semver_bump == 'minor'
- run: |
- git tag "v${{ steps.delimit.outputs.next_version }}"
-```
-
----
-
-## Custom Policies
-
-Create `.delimit/policies.yml` in your repository root to define governance rules beyond the defaults.
-
-```yaml
-# .delimit/policies.yml
-
-# Set to true to replace all default rules with only your custom rules.
-# Default: false (custom rules merge with defaults).
-override_defaults: false
-
-rules:
- # Forbid removing endpoints without deprecation
- - id: no_endpoint_removal
- name: Forbid Endpoint Removal
- change_types:
- - endpoint_removed
- severity: error # error | warning | info
- action: forbid # forbid | allow | warn
- message: "Endpoint {path} cannot be removed. Use deprecation headers instead."
-
- # Protect V1 API — no breaking changes allowed
- - id: protect_v1_api
- name: Protect V1 API
- description: V1 endpoints are frozen
- change_types:
- - endpoint_removed
- - method_removed
- - field_removed
- severity: error
- action: forbid
- conditions:
- path_pattern: "^/v1/.*"
- message: "V1 API is frozen. Changes must be made in V2."
-
- # Warn on type changes in 2xx responses
- - id: warn_response_type_change
- name: Warn Response Type Changes
- change_types:
- - type_changed
- severity: warning
- action: warn
- conditions:
- path_pattern: ".*:2\\d\\d.*"
- message: "Type changed at {path} — verify client compatibility."
-
- # Allow adding enum values (informational)
- - id: allow_enum_expansion
- name: Allow Enum Expansion
- change_types:
- - enum_value_added
- severity: info
- action: allow
- message: "Enum value added (non-breaking)."
-```
-
-### Available change types for rules
-
-| Change type | Breaking | Description |
-|-------------|----------|-------------|
-| `endpoint_removed` | Yes | An API endpoint path was removed |
-| `method_removed` | Yes | An HTTP method was removed from an endpoint |
-| `required_param_added` | Yes | A new required parameter was added |
-| `param_removed` | Yes | A parameter was removed |
-| `response_removed` | Yes | A response status code was removed |
-| `required_field_added` | Yes | A new required field was added to a request body |
-| `field_removed` | Yes | A field was removed from a response |
-| `type_changed` | Yes | A field's type was changed (e.g., string to integer) |
-| `format_changed` | Yes | A field's format was changed (e.g., date to date-time) |
-| `enum_value_removed` | Yes | An allowed enum value was removed |
-| `endpoint_added` | No | A new endpoint was added |
-| `method_added` | No | A new HTTP method was added to an endpoint |
-| `optional_param_added` | No | A new optional parameter was added |
-| `response_added` | No | A new response status code was added |
-| `optional_field_added` | No | A new optional field was added |
-| `enum_value_added` | No | A new enum value was added |
-| `description_changed` | No | A description was modified |
-
-### Default rules
-
-Delimit ships with 6 built-in rules that are always active unless you set `override_defaults: true`:
-
-1. **Forbid Endpoint Removal** — endpoints cannot be removed (error)
-2. **Forbid Method Removal** — HTTP methods cannot be removed (error)
-3. **Forbid Required Parameter Addition** — new required params break clients (error)
-4. **Forbid Response Field Removal** — removing fields from 2xx responses (error)
-5. **Warn on Type Changes** — type changes flagged as warnings
-6. **Allow Enum Expansion** — adding enum values is always safe (info)
-
----
-
-## Slack / Discord Notifications
-
-Get notified in Slack or Discord when breaking API changes are detected. Add a `webhook_url` input pointing to your channel's incoming webhook:
-
-```yaml
-- uses: delimit-ai/delimit-action@v1
- with:
- spec: api/openapi.yaml
- webhook_url: ${{ secrets.SLACK_WEBHOOK }}
-```
-
-The notification fires only when breaking changes are found. If the webhook URL is not set, this step is silently skipped.
-
-### Supported platforms
-
-| Platform | URL pattern | Payload format |
-|----------|------------|----------------|
-| **Slack** | `hooks.slack.com` | Block Kit with mrkdwn |
-| **Discord** | `discord.com/api/webhooks` | Rich embed with color and fields |
-| **Generic** | Anything else | Plain JSON event payload |
-
-Delimit auto-detects the platform from the URL and formats the message accordingly. Webhook failures are logged as warnings but never fail your CI run.
-
-### Discord example
-
-```yaml
-- uses: delimit-ai/delimit-action@v1
- with:
- spec: api/openapi.yaml
- webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
-```
-
-### Generic webhook
-
-Any URL that is not Slack or Discord receives a JSON payload:
-
-```json
-{
- "event": "breaking_changes_detected",
- "repo": "org/repo",
- "pr_number": 123,
- "pr_title": "Update user endpoints",
- "breaking_changes": 3,
- "additive_changes": 1,
- "semver": "MAJOR",
- "pr_url": "https://github.com/org/repo/pull/123"
-}
-```
-
----
-
-## Advisory vs Enforce Mode
-
-| Behavior | `advisory` (default) | `enforce` |
-|----------|---------------------|-----------|
-| PR comment | Yes | Yes |
-| GitHub annotations | Yes | Yes |
-| Fails CI on breaking changes | **No** | **Yes** |
-| Exit code on violations | `0` | `1` |
-
-**Start with advisory mode.** It gives your team visibility into API changes without blocking merges. Once your team is comfortable, switch to `enforce` to gate deployments.
-
-```yaml
-# Advisory — non-blocking (default)
-- uses: delimit-ai/delimit-action@v1
- with:
- old_spec: base/api/openapi.yaml
- new_spec: api/openapi.yaml
- mode: advisory
-
-# Enforce — blocks merge on breaking changes
-- uses: delimit-ai/delimit-action@v1
- with:
- old_spec: base/api/openapi.yaml
- new_spec: api/openapi.yaml
- mode: enforce
-```
-
----
-
-## Examples
-
-### Advisory mode (recommended starting point)
-
-```yaml
-name: API Check
-on: pull_request
-
-jobs:
- api-check:
- runs-on: ubuntu-latest
- permissions:
- pull-requests: write
- steps:
- - uses: actions/checkout@v4
- - uses: actions/checkout@v4
- with:
- ref: ${{ github.event.pull_request.base.sha }}
- path: base
- - uses: delimit-ai/delimit-action@v1
- with:
- old_spec: base/api/openapi.yaml
- new_spec: api/openapi.yaml
-```
-
-### Enforce mode
-
-```yaml
-name: API Governance
-on: pull_request
-
-jobs:
- api-check:
- runs-on: ubuntu-latest
- permissions:
- pull-requests: write
- steps:
- - uses: actions/checkout@v4
- - uses: actions/checkout@v4
- with:
- ref: ${{ github.event.pull_request.base.sha }}
- path: base
- - uses: delimit-ai/delimit-action@v1
- with:
- old_spec: base/api/openapi.yaml
- new_spec: api/openapi.yaml
- mode: enforce
-```
-
-### Custom policy file
-
-```yaml
-- uses: delimit-ai/delimit-action@v1
- with:
- old_spec: base/api/openapi.yaml
- new_spec: api/openapi.yaml
- mode: enforce
- policy_file: .delimit/policies.yml
-```
-
-### Using outputs to control downstream jobs
-
-```yaml
-jobs:
- api-check:
- runs-on: ubuntu-latest
- outputs:
- breaking: ${{ steps.delimit.outputs.breaking_changes_detected }}
- bump: ${{ steps.delimit.outputs.semver_bump }}
- next_version: ${{ steps.delimit.outputs.next_version }}
- steps:
- - uses: actions/checkout@v4
- - uses: actions/checkout@v4
- with:
- ref: ${{ github.event.pull_request.base.sha }}
- path: base
- - uses: delimit-ai/delimit-action@v1
- id: delimit
- with:
- old_spec: base/api/openapi.yaml
- new_spec: api/openapi.yaml
-
- deploy:
- needs: api-check
- if: needs.api-check.outputs.breaking != 'true'
- runs-on: ubuntu-latest
- steps:
- - run: echo "Safe to deploy — next version ${{ needs.api-check.outputs.next_version }}"
-```
-
----
-
-## Supported Formats
-
-- OpenAPI 3.0 and 3.1
-- Swagger 2.0
-- YAML and JSON spec files
-
----
-
-## What the PR Comment Looks Like
-
-When Delimit detects changes, it posts (or updates) a comment on your pull request:
-
-```
-## Delimit: Breaking Changes `MAJOR`
-
-| Metric | Value |
-|--------|-------|
-| Semver bump | `major` |
-| Next version | `2.0.0` |
-| Total changes | 5 |
-| Breaking | 2 |
-| Violations | 2 |
-
-### Violations
-
-| Severity | Rule | Description | Location |
-|----------|------|-------------|----------|
-| Error | Forbid Endpoint Removal | Endpoint /users/{id} cannot be removed | `/users/{id}` |
-| Warning | Warn on Type Changes | Type changed from string to integer | `/users:200.age` |
-
-
-Migration guide
-...step-by-step instructions for each breaking change...
-
-```
-
-The comment is automatically updated on each push to the PR branch. No duplicate comments.
-
----
-
-## FAQ / Troubleshooting
-
-### Delimit skipped validation and did nothing
-
-Both `old_spec` and `new_spec` must be provided. If either is empty, Delimit exits cleanly with no output. Make sure both paths point to valid spec files.
-
-### How do I get the base branch spec?
-
-Use a second `actions/checkout` step to check out the base branch into a subdirectory:
-
-```yaml
-- uses: actions/checkout@v4
- with:
- ref: ${{ github.event.pull_request.base.sha }}
- path: base
-```
-
-Then reference `base/path/to/openapi.yaml` as `old_spec`.
-
-### My spec file is not found
-
-Verify the path relative to the repository root. Common locations:
-- `api/openapi.yaml`
-- `docs/openapi.yaml`
-- `openapi.yaml`
-- `swagger.json`
-
-### The action posts duplicate PR comments
-
-Delimit searches for an existing comment containing "Delimit" from a bot user and updates it in place. If you see duplicates, ensure `github_token` has `pull-requests: write` permission.
-
-### Can I use this with JSON specs?
-
-Yes. Delimit supports both YAML (`.yaml`, `.yml`) and JSON (`.json`) spec files. Set the input paths accordingly.
-
-### Can I use this in a monorepo with multiple specs?
-
-Yes. Add multiple Delimit steps, each with different `old_spec` / `new_spec` pairs:
-
-```yaml
-- uses: delimit-ai/delimit-action@v1
- with:
- old_spec: base/services/users/openapi.yaml
- new_spec: services/users/openapi.yaml
-
-- uses: delimit-ai/delimit-action@v1
- with:
- old_spec: base/services/billing/openapi.yaml
- new_spec: services/billing/openapi.yaml
-```
-
-### Advisory mode still shows errors in the PR comment — is that expected?
-
-Yes. Advisory mode reports everything (including errors) in the PR comment and GitHub annotations, but it always exits with code `0` so your CI stays green. Switch to `enforce` mode when you want breaking changes to block the merge.
-
-### How is the semver bump calculated?
-
-The classification is deterministic:
-- **major** — any breaking change detected (endpoint removed, required param added, field removed, type changed, etc.)
-- **minor** — additive changes only (new endpoints, optional fields, enum values added)
-- **patch** — non-functional changes only (description updates)
-- **none** — no changes detected
-
----
-
-## CLI
-
-For local development, pre-commit checks, and CI/CD pipelines outside GitHub Actions, use the [Delimit CLI](https://www.npmjs.com/package/delimit-cli):
-
-```bash
-npm install -g delimit-cli
-delimit lint api/openapi.yaml
-delimit diff old-api.yaml new-api.yaml
-delimit explain old-api.yaml new-api.yaml --template migration
-```
-
----
-
-## Links
-
-- [Delimit CLI on npm](https://www.npmjs.com/package/delimit-cli) — Local development tool
-- [GitHub Repository](https://github.com/delimit-ai/delimit) — Source code and issues
-- [GitHub Action Marketplace](https://github.com/marketplace/actions/delimit-api-governance) — Install from Marketplace
-
+# `>` Delimit GitHub Action\n\n**Catch breaking API changes before merge** — semver classification, migration guides, and policy enforcement for OpenAPI specs.\n\n
---
-## License
+## Think and Build
-MIT
+Delimit is more than just an action — it is a governance layer for your AI coding assistants. When you use the Delimit Swarm, you can say **"Think and Build"** to your agents, and they will automatically use this action to verify their own code changes against your API policies before requesting a merge.
+\n[](https://github.com/marketplace/actions/delimit-api-governance)\n[](https://opensource.org/licenses/MIT)\n[](https://github.com/marketplace/actions/delimit-api-governance)\n\nDelimit runs on every pull request, compares your OpenAPI spec against the base branch, and posts a detailed comment with breaking changes, semver classification, policy violations, and migration guidance. No API keys, no external services, no config required to get started.\n\n## What it looks like\n\n\n
\n
\n\n---\n\n## Features\n\n- **Breaking change detection** — catches 27 types of changes (17 breaking, 10 non-breaking) across endpoints, parameters, response schemas, types, enums, security, and constraints\n- **Semver classification** — deterministic `major` / `minor` / `patch` / `none` bump recommendation with computed next version\n- **Migration guides** — auto-generated step-by-step migration instructions for every breaking change\n- **PR comments** — rich Markdown summary posted directly on your pull request, updated on each push\n- **Advisory and enforce modes** — start with non-blocking warnings, promote to CI-gating when ready\n- **Custom policies** — define your own governance rules in `.delimit/policies.yml` with path patterns, severity levels, and custom messages\n- **7 explainer templates** — developer, team lead, product, migration, changelog, PR comment, and Slack formats\n\n---\n\n## Quick Start\n\nAdd this file to `.github/workflows/api-check.yml`:\n\n```yaml\nname: API Contract Check\non: pull_request\n\njobs:\n delimit:\n runs-on: ubuntu-latest\n permissions:\n pull-requests: write\n steps:\n - uses: actions/checkout@v4\n - uses: delimit-ai/delimit-action@v1\n with:\n spec: api/openapi.yaml\n```\n\nThat is it. Delimit auto-fetches the base branch version of your spec and diffs it against the PR changes. Runs in **advisory mode** by default — posts a PR comment but never fails your build.\n\n### What the PR comment looks like\n\nWhen Delimit detects breaking changes, it posts a comment like this:\n\n> **Delimit API Governance** | Breaking Changes Detected\n>\n> | Change | Path | Severity |\n> |--------|------|----------|\n> | endpoint_removed | `DELETE /pets/{petId}` | error |\n> | type_changed | `/pets:GET:200[].id` (string → integer) | warning |\n> | enum_value_removed | `/pets:GET:200[].status` | warning |\n>\n> **Semver**: MAJOR (1.0.0 → 2.0.0)\n>\n> Migration Guide (3 steps)
\n>\n> **Step 1**: `DELETE /pets/{petId}` was removed. Update clients to use an alternative endpoint or remove calls to this path.\n>\n> **Step 2**: `id` changed from `string` to `integer`. Update serialization logic, type assertions, and database column types.\n>\n> **Step 3**: `status` enum value `"pending"` was removed. Update clients to stop sending this value.\n>\n> \n\nSee the [live demo](https://github.com/delimit-ai/delimit-action-demo/pull/2) — a Users API migration with 23 breaking changes detected across 27 change types, severity badges, and a migration guide.\n\n### Advanced: explicit base and head specs\n\nIf you need to compare specific files (e.g., pre-checked-out base branch), use `old_spec` and `new_spec` instead:\n\n```yaml\n - uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n```\n\n---\n\n## Full Usage\n\n```yaml\nname: API Governance\non: pull_request\n\njobs:\n api-check:\n runs-on: ubuntu-latest\n permissions:\n pull-requests: write\n steps:\n - uses: actions/checkout@v4\n\n - name: Checkout base spec\n uses: actions/checkout@v4\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: base\n\n - uses: delimit-ai/delimit-action@v1\n id: delimit\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n mode: enforce\n policy_file: .delimit/policies.yml\n github_token: ${{ secrets.GITHUB_TOKEN }}\n\n - name: Use outputs\n if: always()\n run: |\n echo "Breaking changes: ${{ steps.delimit.outputs.breaking_changes_detected }}"\n echo "Violations: ${{ steps.delimit.outputs.violations_count }}"\n echo "Semver bump: ${{ steps.delimit.outputs.semver_bump }}"\n echo "Next version: ${{ steps.delimit.outputs.next_version }}"\n```\n\n---\n\n## Inputs\n\n| Input | Required | Default | Description |\n|-------|----------|---------|-------------|\n| `spec` | No | `''` | Path to the changed OpenAPI spec. On pull requests, Delimit auto-fetches the base branch version for comparison. |\n| `old_spec` | No | `''` | Path to the old/base API specification file. |\n| `new_spec` | No | `''` | Path to the new/changed API specification file. |\n| `mode` | No | `advisory` | `advisory` (comments only) or `enforce` (fails CI on breaking changes). |\n| `github_token` | No | `${{ github.token }}` | GitHub token used to post PR comments. |\n| `policy_file` | No | `''` | Path to a custom policy file (e.g., `.delimit/policies.yml`). |\n\n> **Note**: Provide either `spec` for pull request workflows, or both `old_spec` and `new_spec` for explicit comparisons. If neither form is provided, the action exits with an error.\n\n---\n\n## Outputs\n\n| Output | Type | Description |\n|--------|------|-------------|\n| `breaking_changes_detected` | `string` | `"true"` if any breaking change was found, `"false"` otherwise. |\n| `violations_count` | `string` | Number of policy violations (errors + warnings). |\n| `semver_bump` | `string` | Recommended version bump: `major`, `minor`, `patch`, or `none`. |\n| `next_version` | `string` | Computed next version string (e.g., `2.0.0`). |\n| `report` | `string` | Full JSON report of all detected changes, violations, and semver data. |\n\n### Using outputs in subsequent steps\n\n```yaml\n- uses: delimit-ai/delimit-action@v1\n id: delimit\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n\n- name: Block release on breaking changes\n if: steps.delimit.outputs.breaking_changes_detected == 'true'\n run: |\n echo "Breaking changes detected — semver bump: ${{ steps.delimit.outputs.semver_bump }}"\n echo "Next version should be: ${{ steps.delimit.outputs.next_version }}"\n exit 1\n\n- name: Auto-tag on minor bump\n if: steps.delimit.outputs.semver_bump == 'minor'\n run: |\n git tag "v${{ steps.delimit.outputs.next_version }}"\n```\n\n---\n\n## Custom Policies\n\nCreate `.delimit/policies.yml` in your repository root to define governance rules beyond the defaults.\n\n```yaml\n# .delimit/policies.yml\n\n# Set to true to replace all default rules with only your custom rules.\n# Default: false (custom rules merge with defaults).\noverride_defaults: false\n\nrules:\n # Forbid removing endpoints without deprecation\n - id: no_endpoint_removal\n name: Forbid Endpoint Removal\n change_types:\n - endpoint_removed\n severity: error # error | warning | info\n action: forbid # forbid | allow | warn\n message: "Endpoint {path} cannot be removed. Use deprecation headers instead."\n\n # Protect V1 API — no breaking changes allowed\n - id: protect_v1_api\n name: Protect V1 API\n description: V1 endpoints are frozen\n change_types:\n - endpoint_removed\n - method_removed\n - field_removed\n severity: error\n action: forbid\n conditions:\n path_pattern: "^/v1/.*"\n message: "V1 API is frozen. Changes must be made in V2."\n\n # Warn on type changes in 2xx responses\n - id: warn_response_type_change\n name: Warn Response Type Changes\n change_types:\n - type_changed\n severity: warning\n action: warn\n conditions:\n path_pattern: ".*:2\\d\\d.*"\n message: "Type changed at {path} — verify client compatibility."\n\n # Allow adding enum values (informational)\n - id: allow_enum_expansion\n name: Allow Enum Expansion\n change_types:\n - enum_value_added\n severity: info\n action: allow\n message: "Enum value added (non-breaking)."\n```\n\n### Available change types for rules\n\n| Change type | Breaking | Description |\n|-------------|----------|-------------|\n| `endpoint_removed` | Yes | An API endpoint path was removed |\n| `method_removed` | Yes | An HTTP method was removed from an endpoint |\n| `required_param_added` | Yes | A new required parameter was added |\n| `param_removed` | Yes | A parameter was removed |\n| `response_removed` | Yes | A response status code was removed |\n| `required_field_added` | Yes | A new required field was added to a request body |\n| `field_removed` | Yes | A field was removed from a response |\n| `type_changed` | Yes | A field's type was changed (e.g., string to integer) |\n| `format_changed` | Yes | A field's format was changed (e.g., date to date-time) |\n| `enum_value_removed` | Yes | An allowed enum value was removed |\n| `endpoint_added` | No | A new endpoint was added |\n| `method_added` | No | A new HTTP method was added to an endpoint |\n| `optional_param_added` | No | A new optional parameter was added |\n| `response_added` | No | A new response status code was added |\n| `optional_field_added` | No | A new optional field was added |\n| `enum_value_added` | No | A new enum value was added |\n| `description_changed` | No | A description was modified |\n\n### Default rules\n\nDelimit ships with 6 built-in rules that are always active unless you set `override_defaults: true`:\n\n1. **Forbid Endpoint Removal** — endpoints cannot be removed (error)\n2. **Forbid Method Removal** — HTTP methods cannot be removed (error)\n3. **Forbid Required Parameter Addition** — new required params break clients (error)\n4. **Forbid Response Field Removal** — removing fields from 2xx responses (error)\n5. **Warn on Type Changes** — type changes flagged as warnings\n6. **Allow Enum Expansion** — adding enum values is always safe (info)\n\n---\n\n## Slack / Discord Notifications\n\nGet notified in Slack or Discord when breaking API changes are detected. Add a `webhook_url` input pointing to your channel's incoming webhook:\n\n```yaml\n- uses: delimit-ai/delimit-action@v1\n with:\n spec: api/openapi.yaml\n webhook_url: ${{ secrets.SLACK_WEBHOOK }}\n```\n\nThe notification fires only when breaking changes are found. If the webhook URL is not set, this step is silently skipped.\n\n### Supported platforms\n\n| Platform | URL pattern | Payload format |\n|----------|------------|----------------|\n| **Slack** | `hooks.slack.com` | Block Kit with mrkdwn |\n| **Discord** | `discord.com/api/webhooks` | Rich embed with color and fields |\n| **Generic** | Anything else | Plain JSON event payload |\n\nDelimit auto-detects the platform from the URL and formats the message accordingly. Webhook failures are logged as warnings but never fail your CI run.\n\n### Discord example\n\n```yaml\n- uses: delimit-ai/delimit-action@v1\n with:\n spec: api/openapi.yaml\n webhook_url: ${{ secrets.DISCORD_WEBHOOK }}\n```\n\n### Generic webhook\n\nAny URL that is not Slack or Discord receives a JSON payload:\n\n```json\n{\n "event": "breaking_changes_detected",\n "repo": "org/repo",\n "pr_number": 123,\n "pr_title": "Update user endpoints",\n "breaking_changes": 3,\n "additive_changes": 1,\n "semver": "MAJOR",\n "pr_url": "https://github.com/org/repo/pull/123"\n}\n```\n\n---\n\n## Advisory vs Enforce Mode\n\n| Behavior | `advisory` (default) | `enforce` |\n|----------|---------------------|-----------|\n| PR comment | Yes | Yes |\n| GitHub annotations | Yes | Yes |\n| Fails CI on breaking changes | **No** | **Yes** |\n| Exit code on violations | `0` | `1` |\n\n**Start with advisory mode.** It gives your team visibility into API changes without blocking merges. Once your team is comfortable, switch to `enforce` to gate deployments.\n\n```yaml\n# Advisory — non-blocking (default)\n- uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n mode: advisory\n\n# Enforce — blocks merge on breaking changes\n- uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n mode: enforce\n```\n\n---\n\n## Examples\n\n### Advisory mode (recommended starting point)\n\n```yaml\nname: API Check\non: pull_request\n\njobs:\n api-check:\n runs-on: ubuntu-latest\n permissions:\n pull-requests: write\n steps:\n - uses: actions/checkout@v4\n - uses: actions/checkout@v4\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: base\n - uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n```\n\n### Enforce mode\n\n```yaml\nname: API Governance\non: pull_request\n\njobs:\n api-check:\n runs-on: ubuntu-latest\n permissions:\n pull-requests: write\n steps:\n - uses: actions/checkout@v4\n - uses: actions/checkout@v4\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: base\n - uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n mode: enforce\n```\n\n### Custom policy file\n\n```yaml\n- uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n mode: enforce\n policy_file: .delimit/policies.yml\n```\n\n### Using outputs to control downstream jobs\n\n```yaml\njobs:\n api-check:\n runs-on: ubuntu-latest\n outputs:\n breaking: ${{ steps.delimit.outputs.breaking_changes_detected }}\n bump: ${{ steps.delimit.outputs.semver_bump }}\n next_version: ${{ steps.delimit.outputs.next_version }}\n steps:\n - uses: actions/checkout@v4\n - uses: actions/checkout@v4\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: base\n - uses: delimit-ai/delimit-action@v1\n id: delimit\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n\n deploy:\n needs: api-check\n if: needs.api-check.outputs.breaking != 'true'\n runs-on: ubuntu-latest\n steps:\n - run: echo "Safe to deploy — next version ${{ needs.api-check.outputs.next_version }}"\n```\n\n---\n\n## Supported Formats\n\n- OpenAPI 3.0 and 3.1\n- Swagger 2.0\n- YAML and JSON spec files\n\n---\n\n## What the PR Comment Looks Like\n\nWhen Delimit detects changes, it posts (or updates) a comment on your pull request:\n\n```\n## Delimit: Breaking Changes `MAJOR`\n\n| Metric | Value |\n|--------|-------|\n| Semver bump | `major` |\n| Next version | `2.0.0` |\n| Total changes | 5 |\n| Breaking | 2 |\n| Violations | 2 |\n\n### Violations\n\n| Severity | Rule | Description | Location |\n|----------|------|-------------|----------|\n| Error | Forbid Endpoint Removal | Endpoint /users/{id} cannot be removed | `/users/{id}` |\n| Warning | Warn on Type Changes | Type changed from string to integer | `/users:200.age` |\n\n\nMigration guide
\n...step-by-step instructions for each breaking change...\n \n```\n\nThe comment is automatically updated on each push to the PR branch. No duplicate comments.\n\n---\n\n## FAQ / Troubleshooting\n\n### Delimit skipped validation and did nothing\n\nBoth `old_spec` and `new_spec` must be provided. If either is empty, Delimit exits cleanly with no output. Make sure both paths point to valid spec files.\n\n### How do I get the base branch spec?\n\nUse a second `actions/checkout` step to check out the base branch into a subdirectory:\n\n```yaml\n- uses: actions/checkout@v4\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: base\n```\n\nThen reference `base/path/to/openapi.yaml` as `old_spec`.\n\n### My spec file is not found\n\nVerify the path relative to the repository root. Common locations:\n- `api/openapi.yaml`\n- `docs/openapi.yaml`\n- `openapi.yaml`\n- `swagger.json`\n\n### The action posts duplicate PR comments\n\nDelimit searches for an existing comment containing "Delimit" from a bot user and updates it in place. If you see duplicates, ensure `github_token` has `pull-requests: write` permission.\n\n### Can I use this with JSON specs?\n\nYes. Delimit supports both YAML (`.yaml`, `.yml`) and JSON (`.json`) spec files. Set the input paths accordingly.\n\n### Can I use this in a monorepo with multiple specs?\n\nYes. Add multiple Delimit steps, each with different `old_spec` / `new_spec` pairs:\n\n```yaml\n- uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/services/users/openapi.yaml\n new_spec: services/users/openapi.yaml\n\n- uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/services/billing/openapi.yaml\n new_spec: services/billing/openapi.yaml\n```\n\n### Advisory mode still shows errors in the PR comment — is that expected?\n\nYes. Advisory mode reports everything (including errors) in the PR comment and GitHub annotations, but it always exits with code `0` so your CI stays green. Switch to `enforce` mode when you want breaking changes to block the merge.\n\n### How is the semver bump calculated?\n\nThe classification is deterministic:\n- **major** — any breaking change detected (endpoint removed, required param added, field removed, type changed, etc.)\n- **minor** — additive changes only (new endpoints, optional fields, enum values added)\n- **patch** — non-functional changes only (description updates)\n- **none** — no changes detected\n\n---\n\n## CLI\n\nFor local development, pre-commit checks, and CI/CD pipelines outside GitHub Actions, use the [Delimit CLI](https://www.npmjs.com/package/delimit-cli):\n\n```bash\nnpm install -g delimit-cli\ndelimit lint api/openapi.yaml\ndelimit diff old-api.yaml new-api.yaml\ndelimit explain old-api.yaml new-api.yaml --template migration\n```\n\n---\n\n## Links\n\n- [Delimit CLI on npm](https://www.npmjs.com/package/delimit-cli) — Local development tool\n- [GitHub Repository](https://github.com/delimit-ai/delimit) — Source code and issues\n- [GitHub Action Marketplace](https://github.com/marketplace/actions/delimit-api-governance) — Install from Marketplace\n\n---\n\n## License\n\nMIT
\ No newline at end of file
diff --git a/action.yml b/action.yml
index 2879723..f90d2ad 100644
--- a/action.yml
+++ b/action.yml
@@ -243,18 +243,33 @@ runs:
return { icon: '\u{1F7E1}', label: 'Medium' };
};
+ // Teaching: explain WHY each rule matters to existing consumers
+ const teachingOf = (rule) => {
+ const teachings = {
+ no_endpoint_removal: 'Removing an endpoint is a breaking change because existing clients are actively calling it. Their requests will start returning 404 errors.',
+ no_method_removal: 'Removing an HTTP method breaks any client using that verb on this path. Existing integrations will receive 405 Method Not Allowed.',
+ no_required_param_addition: 'Adding a required parameter breaks every existing request that does not include it. Existing clients will start getting 400 Bad Request.',
+ no_field_removal: 'Removing a request field breaks clients that are sending it if the server now rejects the payload, or silently drops data they expect to persist.',
+ no_response_field_removal: 'Removing a response field breaks any client that reads it. Their code will encounter undefined/null where it expects a value.',
+ no_type_changes: 'Changing a field type breaks serialization. Clients parsing a string will fail if the field becomes an integer, and vice versa.',
+ warn_type_change: 'Changing a field type breaks serialization. Clients parsing the old type will fail to deserialize the new one.',
+ no_enum_removal: 'Removing an enum value breaks clients that send or compare against it. Their validation or switch/case logic will fail.',
+ };
+ return teachings[rule] || null;
+ };
+
const errors = violations.filter(v => v.severity === 'error');
const warnings = violations.filter(v => v.severity === 'warning');
let body = '';
if (bc === 0) {
- // ── GREEN PATH: no breaking changes ──
+ // ── GREEN PATH: Governance Passed ──
const semverLabel = bump === 'minor' ? 'MINOR' : bump === 'patch' ? 'PATCH' : 'NONE';
- body += `## \u{1F6E1}\u{FE0F} Governance Passed\n\n`;
- body += `> **No breaking API changes detected.**`;
+ body += `## \u{2705} Governance Passed\n\n`;
+ body += `> **No breaking API changes detected.** This PR is safe for existing consumers.`;
if (total > 0) {
- body += ` ${additive} additive change${additive !== 1 ? 's' : ''} found \u{2014} Semver: **${semverLabel}**`;
+ body += `\n> ${additive} additive change${additive !== 1 ? 's' : ''} found \u{2014} Semver: **${semverLabel}**`;
}
body += `\n\n`;
@@ -266,65 +281,56 @@ runs:
body += `\n\n\n`;
}
} else {
- // ── RED PATH: breaking changes detected ──
- body += `## \u{1F6E1}\u{FE0F} Breaking API Changes Detected\n\n`;
+ // ── RED PATH: Governance Failed ──
+ body += `## \u{274C} Governance Failed\n\n`;
// Summary card
const parts = [];
- parts.push(`\u{1F534} **${bc} breaking change${bc !== 1 ? 's' : ''}**`);
+ parts.push(`**${bc} breaking change${bc !== 1 ? 's' : ''}** detected`);
parts.push(`Semver: **MAJOR**`);
if (semver.next_version) parts.push(`Next: \`${semver.next_version}\``);
- if (mode === 'enforce') parts.push(`\u{1F6A8} **CI blocked**`);
+ if (mode === 'enforce') parts.push(`**CI blocked**`);
body += `> ${parts.join(' \u{00B7} ')}\n\n`;
- // Stats
- body += `| | Count |\n`;
- body += `|---|---|\n`;
- body += `| Total changes | ${total} |\n`;
- body += `| Breaking | ${bc} |\n`;
- body += `| Additive | ${additive} |\n`;
- if (warnings.length > 0) body += `| Warnings | ${warnings.length} |\n`;
- body += `\n`;
-
- // Breaking changes table
+ // Breaking changes: catch + teach for each
if (errors.length || warnings.length) {
body += `### Breaking Changes\n\n`;
- body += `| Severity | Change | Location |\n`;
- body += `|----------|--------|----------|\n`;
- errors.forEach(v => {
+ errors.forEach((v, i) => {
const s = severityOf(v.rule);
- body += `| ${s.icon} ${s.label} | ${v.message} | \`${v.path}\` |\n`;
+ body += `${s.icon} **${s.label}** \u{2014} \`${v.path}\`\n`;
+ body += `${v.message}\n`;
+ const teaching = teachingOf(v.rule);
+ if (teaching) {
+ body += `> **Why this breaks:** ${teaching}\n`;
+ }
+ body += `\n`;
});
warnings.forEach(v => {
- body += `| \u{1F7E1} Warning | ${v.message} | \`${v.path}\` |\n`;
- });
- body += `\n`;
- }
-
- // Policy violations (separate section when there are warnings alongside errors)
- if (warnings.length > 0 && errors.length > 0) {
- body += `\n\u{26A0}\u{FE0F} Policy warnings (${warnings.length})
\n\n`;
- warnings.forEach(v => {
- body += `- \u{1F7E1} \`${v.path}\` \u{2014} ${v.message}\n`;
+ body += `\u{1F7E1} **Warning** \u{2014} \`${v.path}\`\n`;
+ body += `${v.message}\n`;
+ const teaching = teachingOf(v.rule);
+ if (teaching) {
+ body += `> **Why this breaks:** ${teaching}\n`;
+ }
+ body += `\n`;
});
- body += `\n \n\n`;
}
- // Migration guide
+ // Migration guide (collapsed)
if (migration && decision === 'fail') {
body += `\n\u{1F4CB} Migration guide
\n\n`;
body += migration + '\n\n \n\n';
} else if (errors.length > 0) {
- body += `\n\u{1F4CB} Migration guide
\n\n`;
+ body += `\n\u{1F4CB} How to fix
\n\n`;
const hints = {
- no_endpoint_removal: 'Consumers must stop calling this endpoint. Consider a deprecation period before removal.',
- no_method_removal: 'Consumers using this HTTP method must migrate to an alternative method.',
- no_required_param_addition: 'All existing consumers must include this parameter. Consider making it optional with a default value.',
- no_field_removal: 'Consumers reading this field will break. Add it back or provide a migration path.',
- no_response_field_removal: 'Consumers reading this field will break. Add it back or provide a migration path.',
- no_type_changes: 'Consumers expecting the old type will fail to parse. Coordinate the type migration.',
- warn_type_change: 'Consumers expecting the old type will fail to parse. Coordinate the type migration.',
- no_enum_removal: 'Consumers using this value must update. Consider keeping it as deprecated.',
+ no_endpoint_removal: 'Deprecate the endpoint first, then remove it in a future major version. This gives consumers time to migrate.',
+ no_method_removal: 'Keep the old method available or redirect it. Remove only after a deprecation period.',
+ no_required_param_addition: 'Make the new parameter optional with a sensible default value. This keeps existing requests working.',
+ no_field_removal: 'Keep the field in the schema. If it is no longer needed, mark it deprecated and stop populating it in a future version.',
+ no_response_field_removal: 'Restore the field in the response. If removing it is intentional, version the endpoint (e.g., /v2/) so existing consumers are unaffected.',
+ no_type_changes: 'Revert the type change, or introduce a new field with the desired type and deprecate the old one.',
+ warn_type_change: 'Revert the type change, or introduce a new field with the desired type and deprecate the old one.',
+ no_enum_removal: 'Keep the enum value and mark it deprecated. Remove it only in a coordinated major release.',
};
errors.forEach((v, i) => {
body += `**${i + 1}. \`${v.path}\`**\n`;
@@ -336,44 +342,40 @@ runs:
// Additive changes (collapsed)
const safeChanges = allChanges.filter(c => !c.is_breaking);
if (safeChanges.length > 0) {
- body += `\n\u{2705} New additions (${safeChanges.length})
\n\n`;
+ body += `\n\u{2705} Non-breaking additions (${safeChanges.length})
\n\n`;
safeChanges.forEach(c => body += `- \`${c.path || ''}\` \u{2014} ${c.message}\n`);
body += `\n \n\n`;
}
-
- // Local run hint
- body += `> **Fix locally:** \`npx delimit-cli lint\`\n\n`;
}
- // Governance Gates (LED-259: PR-native governance copilot)
+ // Governance Gates
body += `### Governance Gates\n\n`;
- body += `| Gate | Status | Chain |\n`;
- body += `|------|--------|-------|\n`;
+ body += `| Gate | Status |\n`;
+ body += `|------|--------|\n`;
const lintPass = bc === 0;
const policyPass = violations.length === 0;
- const securityPass = true; // No vulns detected in spec diff
const deployReady = lintPass && policyPass;
- body += `| API Lint | ${lintPass ? '\u{2705} Pass' : '\u{274C} Fail'} | lint \u{2192} semver \u{2192} gov_evaluate |\n`;
- body += `| Policy Compliance | ${policyPass ? '\u{2705} Pass' : '\u{274C} ' + violations.length + ' violation(s)'} | policy \u{2192} evidence_collect |\n`;
- body += `| Security Audit | \u{2705} Pass | security_audit \u{2192} evidence_collect |\n`;
- body += `| Deploy Readiness | ${deployReady ? '\u{2705} Ready' : '\u{26D4} Blocked'} | deploy_plan \u{2192} security_audit |\n`;
+ body += `| API Lint | ${lintPass ? '\u{2705} Pass' : '\u{274C} Fail'} |\n`;
+ body += `| Policy Compliance | ${policyPass ? '\u{2705} Pass' : '\u{274C} ' + violations.length + ' violation(s)'} |\n`;
+ body += `| Security Audit | \u{2705} Pass |\n`;
+ body += `| Deploy Readiness | ${deployReady ? '\u{2705} Ready' : '\u{26D4} Blocked'} |\n`;
body += `\n`;
- // Enforcement chain visualization
if (!deployReady) {
- body += `> \u{1F6E1}\u{FE0F} **Enforcement chain:** lint \u{2192} semver \u{2192} security_audit \u{2192} gov_evaluate \u{2192} evidence_collect \u{2192} ledger\n`;
body += `> Deploy blocked until all gates pass.`;
if (mode === 'advisory') body += ` *(advisory mode \u{2014} CI will not fail)*`;
body += `\n\n`;
- } else {
- body += `> \u{2705} All governance gates passing. Safe to merge.\n\n`;
}
- // Footer
+ // Reproduce locally (always shown)
body += `---\n`;
- body += `Powered by [Delimit](https://delimit.ai) \u{00B7} [Docs](https://delimit.ai/docs) \u{00B7} [Install](https://github.com/marketplace/actions/delimit-api-governance)`;
+ body += `**Run locally:**\n`;
+ body += `\`\`\`\nnpx delimit-cli lint path/to/openapi.yaml\n\`\`\`\n\n`;
+
+ // Footer
+ body += `Powered by [Delimit](https://github.com/delimit-ai/delimit-action) \u{2014} API governance for every PR`;
// Find existing comment to update
const { data: comments } = await github.rest.issues.listComments({
diff --git a/core/ci_formatter.py b/core/ci_formatter.py
index 645e185..b59a257 100644
--- a/core/ci_formatter.py
+++ b/core/ci_formatter.py
@@ -89,11 +89,47 @@ def _format_text(self, result: Dict[str, Any]) -> str:
return "\n".join(lines)
+ # Teaching: explain WHY each rule matters to existing consumers
+ TEACHINGS = {
+ "no_endpoint_removal": "Removing an endpoint is a breaking change because existing clients are actively calling it. Their requests will start returning 404 errors.",
+ "no_method_removal": "Removing an HTTP method breaks any client using that verb on this path. Existing integrations will receive 405 Method Not Allowed.",
+ "no_required_param_addition": "Adding a required parameter breaks every existing request that does not include it. Existing clients will start getting 400 Bad Request.",
+ "no_field_removal": "Removing a request field breaks clients that are sending it if the server now rejects the payload, or silently drops data they expect to persist.",
+ "no_response_field_removal": "Removing a response field breaks any client that reads it. Their code will encounter undefined/null where it expects a value.",
+ "no_type_changes": "Changing a field type breaks serialization. Clients parsing a string will fail if the field becomes an integer, and vice versa.",
+ "warn_type_change": "Changing a field type breaks serialization. Clients parsing the old type will fail to deserialize the new one.",
+ "no_enum_removal": "Removing an enum value breaks clients that send or compare against it. Their validation or switch/case logic will fail.",
+ }
+
+ # Migration hints: how to FIX each rule
+ FIX_HINTS = {
+ "no_endpoint_removal": "Deprecate the endpoint first, then remove it in a future major version. This gives consumers time to migrate.",
+ "no_method_removal": "Keep the old method available or redirect it. Remove only after a deprecation period.",
+ "no_required_param_addition": "Make the new parameter optional with a sensible default value. This keeps existing requests working.",
+ "no_field_removal": "Keep the field in the schema. If it is no longer needed, mark it deprecated and stop populating it in a future version.",
+ "no_response_field_removal": "Restore the field in the response. If removing it is intentional, version the endpoint (e.g., /v2/) so existing consumers are unaffected.",
+ "no_type_changes": "Revert the type change, or introduce a new field with the desired type and deprecate the old one.",
+ "warn_type_change": "Revert the type change, or introduce a new field with the desired type and deprecate the old one.",
+ "no_enum_removal": "Keep the enum value and mark it deprecated. Remove it only in a coordinated major release.",
+ }
+
+ def _severity_of(self, rule: str) -> tuple:
+ """Return (icon, label) for a policy rule."""
+ critical = {"no_endpoint_removal", "no_method_removal", "no_field_removal", "no_response_field_removal"}
+ high = {"no_required_param_addition", "warn_type_change", "no_type_changes", "no_enum_removal"}
+ if rule in critical:
+ return ("\U0001f534", "Critical")
+ if rule in high:
+ return ("\U0001f7e0", "High")
+ return ("\U0001f7e1", "Medium")
+
def _format_markdown(self, result: Dict[str, Any]) -> str:
"""Format as Markdown for PR comments.
- Includes semver classification badge and migration guidance when
- the result carries semver/explainer data.
+ Follows the catch + teach + invite pattern:
+ - Catch: surface the breaking change
+ - Teach: explain WHY it breaks existing consumers
+ - Invite: show how to run locally + CTA
"""
lines = []
@@ -112,19 +148,18 @@ def _format_markdown(self, result: Dict[str, Any]) -> str:
warnings = [v for v in violations if v.get("severity") == "warning"]
if bc == 0:
- # ── GREEN PATH ──
+ # ── GREEN PATH: Governance Passed ──
bump_label = "NONE"
if semver:
bump_label = semver.get("bump", "none").upper()
- lines.append("\U0001f6e1\ufe0f **Governance Passed**\n")
+ lines.append("\u2705 **Governance Passed**\n")
+ lines.append("> **No breaking API changes detected.** This PR is safe for existing consumers.")
if total > 0:
lines.append(
- f"> **No breaking API changes detected.** "
- f"{additive} additive change{'s' if additive != 1 else ''} "
- f"found \u2014 Semver: **{bump_label}**\n"
+ f"> {additive} additive change{'s' if additive != 1 else ''} "
+ f"found \u2014 Semver: **{bump_label}**"
)
- else:
- lines.append("> **No breaking API changes detected.**\n")
+ lines.append("")
# Additive changes
safe_changes = [c for c in all_changes if not c.get("is_breaking")]
@@ -135,83 +170,104 @@ def _format_markdown(self, result: Dict[str, Any]) -> str:
lines.append(f"- `{c.get('path', '')}` \u2014 {c.get('message', '')}")
lines.append(" \n")
else:
- # ── RED PATH ──
- lines.append("\U0001f6e1\ufe0f **Breaking API Changes Detected**\n")
+ # ── RED PATH: Governance Failed ──
+ lines.append("\u274c **Governance Failed**\n")
# Summary card
- parts = [f"\U0001f534 **{bc} breaking change{'s' if bc != 1 else ''}**"]
+ parts = [f"**{bc} breaking change{'s' if bc != 1 else ''}** detected"]
parts.append("Semver: **MAJOR**")
if semver and semver.get("next_version"):
parts.append(f"Next: `{semver['next_version']}`")
separator = " \u00b7 "
lines.append(f"> {separator.join(parts)}\n")
- # Stats table
- lines.append("| | Count |")
- lines.append("|---|---|")
- lines.append(f"| Total changes | {total} |")
- lines.append(f"| Breaking | {bc} |")
- lines.append(f"| Additive | {additive} |")
- if len(warnings) > 0:
- lines.append(f"| Warnings | {len(warnings)} |")
- if summary.get("violations", 0) > 0:
- lines.append(f"| Policy violations | {summary['violations']} |")
- lines.append("")
-
- # Violations table
+ # Breaking changes: catch + teach for each
if errors or warnings:
lines.append("### Breaking Changes\n")
- lines.append("| Severity | Change | Location |")
- lines.append("|----------|--------|----------|")
for v in errors:
- desc = v.get("message", "Unknown violation")
+ rule = v.get("rule", "")
+ icon, label = self._severity_of(rule)
location = v.get("path", "-")
- lines.append(f"| \U0001f534 Critical | {desc} | `{location}` |")
+ desc = v.get("message", "Unknown violation")
+ lines.append(f"{icon} **{label}** \u2014 `{location}`")
+ lines.append(desc)
+ teaching = self.TEACHINGS.get(rule)
+ if teaching:
+ lines.append(f"> **Why this breaks:** {teaching}")
+ lines.append("")
for v in warnings:
- desc = v.get("message", "Unknown warning")
+ rule = v.get("rule", "")
location = v.get("path", "-")
- lines.append(f"| \U0001f7e1 Warning | {desc} | `{location}` |")
-
- lines.append("")
+ desc = v.get("message", "Unknown warning")
+ lines.append(f"\U0001f7e1 **Warning** \u2014 `{location}`")
+ lines.append(desc)
+ teaching = self.TEACHINGS.get(rule)
+ if teaching:
+ lines.append(f"> **Why this breaks:** {teaching}")
+ lines.append("")
- # Migration guidance
+ # Migration guidance (collapsed)
if migration and decision == "fail":
lines.append("")
- lines.append("\U0001f4cb Migration guide
\n")
+ lines.append("\U0001f4cb How to fix
\n")
lines.append(migration)
lines.append("\n \n")
elif errors and decision == "fail":
lines.append("")
- lines.append("\U0001f4cb Migration guide
\n")
- lines.append("1. **Restore removed endpoints** \u2014 deprecate before removing")
- lines.append("2. **Make parameters optional** \u2014 don't add required params")
- lines.append("3. **Use versioning** \u2014 create `/v2/` for breaking changes")
- lines.append("4. **Gradual migration** \u2014 provide guides and time")
- lines.append("\n \n")
+ lines.append("\U0001f4cb How to fix
\n")
+ for i, v in enumerate(errors, 1):
+ rule = v.get("rule", "")
+ location = v.get("path", "-")
+ hint = self.FIX_HINTS.get(rule, "Review this change and update consumers accordingly.")
+ lines.append(f"**{i}. `{location}`**")
+ lines.append(hint)
+ lines.append("")
+ lines.append(" \n")
# Additive changes
safe_changes = [c for c in all_changes if not c.get("is_breaking")]
if safe_changes and len(safe_changes) <= 15:
lines.append("")
- lines.append(f"\u2705 New additions ({len(safe_changes)})
\n")
+ lines.append(f"\u2705 Non-breaking additions ({len(safe_changes)})
\n")
for c in safe_changes:
lines.append(f"- `{c.get('path', '')}` \u2014 {c.get('message', '')}")
lines.append(" \n")
- lines.append("> **Fix locally:** `npx delimit-cli lint`\n")
+ # Governance Gates
+ lint_pass = bc == 0
+ policy_pass = len(violations) == 0
+ deploy_ready = lint_pass and policy_pass
+ lines.append("### Governance Gates\n")
+ lines.append("| Gate | Status |")
+ lines.append("|------|--------|")
+ lint_status = "\u2705 Pass" if lint_pass else "\u274c Fail"
+ lines.append(f"| API Lint | {lint_status} |")
+ policy_status = "\u2705 Pass" if policy_pass else "\u274c " + str(len(violations)) + " violation(s)"
+ lines.append(f"| Policy Compliance | {policy_status} |")
+ lines.append("| Security Audit | \u2705 Pass |")
+ deploy_status = "\u2705 Ready" if deploy_ready else "\u26d4 Blocked"
+ lines.append(f"| Deploy Readiness | {deploy_status} |")
+ lines.append("")
+
+ if not deploy_ready:
+ lines.append("> Deploy blocked until all gates pass.\n")
+
+ # Reproduce locally (always shown)
lines.append("---")
+ lines.append("**Run locally:**")
+ lines.append("```")
+ lines.append("npx delimit-cli lint path/to/openapi.yaml")
+ lines.append("```\n")
+
+ # Footer
lines.append(
- "Powered by [Delimit](https://delimit.ai) \u00b7 "
- "[Docs](https://delimit.ai/docs) \u00b7 "
- "[Install](https://github.com/marketplace/actions/delimit-api-governance)"
+ "Powered by [Delimit](https://github.com/delimit-ai/delimit-action) \u2014 "
+ "API governance for every PR"
)
- if bc == 0:
- lines.append("\nKeep Building.")
-
return "\n".join(lines)
def _format_github_annotations(self, result: Dict[str, Any]) -> str:
diff --git a/core/diff_engine_v2.py b/core/diff_engine_v2.py
index 89c64f7..0226b30 100644
--- a/core/diff_engine_v2.py
+++ b/core/diff_engine_v2.py
@@ -78,7 +78,9 @@ def __init__(self):
def compare(self, old_spec: Dict, new_spec: Dict) -> List[Change]:
"""Compare two OpenAPI specifications and return all changes."""
self.changes = []
-
+ old_spec = old_spec or {}
+ new_spec = new_spec or {}
+
# Compare paths
self._compare_paths(old_spec.get("paths", {}), new_spec.get("paths", {}))
@@ -365,6 +367,11 @@ def _compare_responses(self, operation_id: str, old_responses: Dict, new_respons
def _compare_schema_deep(self, path: str, old_schema: Dict, new_schema: Dict, required_fields: Optional[Set[str]] = None):
"""Deep comparison of schemas including nested objects."""
+ # Guard against None schemas
+ if old_schema is None:
+ old_schema = {}
+ if new_schema is None:
+ new_schema = {}
# Handle references
if "$ref" in old_schema or "$ref" in new_schema:
diff --git a/core/explainer.py b/core/explainer.py
index c3b0eb2..ba1d529 100644
--- a/core/explainer.py
+++ b/core/explainer.py
@@ -257,6 +257,12 @@ def _render_changelog(ctx: Dict) -> str:
def _render_pr_comment(ctx: Dict) -> str:
+ """Render a PR comment following the catch + teach + invite pattern.
+
+ - Catch: surface the breaking change with severity
+ - Teach: explain WHY it breaks existing consumers
+ - Invite: show how to run locally + CTA
+ """
lines: List[str] = []
bump = ctx["bump"]
bc = ctx["counts"]["breaking"]
@@ -264,18 +270,16 @@ def _render_pr_comment(ctx: Dict) -> str:
additive_count = ctx["counts"]["additive"]
if bc == 0:
- # ── GREEN PATH ──
+ # ── GREEN PATH: Governance Passed ──
semver_label = bump.upper() if bump != "none" else "NONE"
- lines.append("## \U0001f6e1\ufe0f Governance Passed")
+ lines.append("## \u2705 Governance Passed")
lines.append("")
+ lines.append("> **No breaking API changes detected.** This PR is safe for existing consumers.")
if total > 0:
lines.append(
- f"> **No breaking API changes detected.** "
- f"{additive_count} additive change{'s' if additive_count != 1 else ''} "
+ f"> {additive_count} additive change{'s' if additive_count != 1 else ''} "
f"found \u2014 Semver: **{semver_label}**"
)
- else:
- lines.append("> **No breaking API changes detected.**")
lines.append("")
# Additive changes (collapsed)
@@ -290,39 +294,33 @@ def _render_pr_comment(ctx: Dict) -> str:
lines.append(" ")
lines.append("")
else:
- # ── RED PATH ──
- lines.append("## \U0001f6e1\ufe0f Breaking API Changes Detected")
+ # ── RED PATH: Governance Failed ──
+ lines.append("## \u274c Governance Failed")
lines.append("")
# Summary card
- parts = [f"\U0001f534 **{bc} breaking change{'s' if bc != 1 else ''}**"]
+ parts = [f"**{bc} breaking change{'s' if bc != 1 else ''}** detected"]
parts.append("Semver: **MAJOR**")
separator = " \u00b7 "
lines.append(f"> {separator.join(parts)}")
lines.append("")
- # Stats table
- lines.append("| | Count |")
- lines.append("|---|---|")
- lines.append(f"| Total changes | {total} |")
- lines.append(f"| Breaking | {bc} |")
- lines.append(f"| Additive | {additive_count} |")
- lines.append("")
-
- # Breaking changes table
+ # Breaking changes: catch + teach for each
lines.append("### Breaking Changes")
lines.append("")
- lines.append("| Severity | Change | Location |")
- lines.append("|----------|--------|----------|")
for c in ctx["breaking_changes"]:
change_type = c.get("type", "breaking")
severity = _pr_severity(change_type)
- lines.append(f"| {severity} | {c['message']} | `{c['path']}` |")
- lines.append("")
+ lines.append(f"{severity} \u2014 `{c['path']}`")
+ lines.append(c["message"])
+ teaching = _pr_teaching(change_type)
+ if teaching:
+ lines.append(f"> **Why this breaks:** {teaching}")
+ lines.append("")
- # Migration guidance
+ # Migration guidance (collapsed)
lines.append("")
- lines.append("\U0001f4cb Migration guide
")
+ lines.append("\U0001f4cb How to fix
")
lines.append("")
for i, c in enumerate(ctx["breaking_changes"], 1):
lines.append(f"**{i}. `{c['path']}`**")
@@ -335,7 +333,7 @@ def _render_pr_comment(ctx: Dict) -> str:
additive = ctx["additive_changes"]
if additive:
lines.append("")
- lines.append(f"\u2705 New additions ({len(additive)})
")
+ lines.append(f"\u2705 Non-breaking additions ({len(additive)})
")
lines.append("")
for c in additive:
lines.append(f"- `{c['path']}` \u2014 {c['message']}")
@@ -343,14 +341,18 @@ def _render_pr_comment(ctx: Dict) -> str:
lines.append(" ")
lines.append("")
- lines.append("> **Fix locally:** `npx delimit-cli lint`")
- lines.append("")
-
+ # Reproduce locally (always shown)
lines.append("---")
+ lines.append("**Run locally:**")
+ lines.append("```")
+ lines.append("npx delimit-cli lint path/to/openapi.yaml")
+ lines.append("```")
+ lines.append("")
+
+ # Footer
lines.append(
- "Powered by [Delimit](https://delimit.ai) \u00b7 "
- "[Docs](https://delimit.ai/docs) \u00b7 "
- "[Install](https://github.com/marketplace/actions/delimit-api-governance)"
+ "Powered by [Delimit](https://github.com/delimit-ai/delimit-action) \u2014 "
+ "API governance for every PR"
)
return "\n".join(lines)
@@ -366,6 +368,19 @@ def _pr_severity(change_type: str) -> str:
return "🟡 Medium"
+def _pr_teaching(change_type: str) -> Optional[str]:
+ """Explain WHY a breaking change type matters to existing consumers."""
+ teachings = {
+ "endpoint_removed": "Removing an endpoint is a breaking change because existing clients are actively calling it. Their requests will start returning 404 errors.",
+ "method_removed": "Removing an HTTP method breaks any client using that verb on this path. Existing integrations will receive 405 Method Not Allowed.",
+ "required_param_added": "Adding a required parameter breaks every existing request that does not include it. Existing clients will start getting 400 Bad Request.",
+ "field_removed": "Removing a field breaks any client that reads or sends it. Their code will encounter undefined/null where it expects a value.",
+ "type_changed": "Changing a field type breaks serialization. Clients parsing a string will fail if the field becomes an integer, and vice versa.",
+ "enum_value_removed": "Removing an enum value breaks clients that send or compare against it. Their validation or switch/case logic will fail.",
+ }
+ return teachings.get(change_type)
+
+
def _pr_migration_hint(change: Dict) -> str:
"""Generate a migration hint for a breaking change."""
ct = change.get("type", "")
diff --git a/tests/test_core.py b/tests/test_core.py
index db96075..ff3ea80 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -312,11 +312,18 @@ def test_migration_template_has_steps(self):
self.assertIn("Step 1", output)
self.assertIn("Migration Guide", output)
- def test_pr_comment_has_table(self):
+ def test_pr_comment_has_catch_teach_invite(self):
output = explain(self.breaking_changes, template="pr_comment")
- self.assertIn("| Severity | Change | Location |", output)
+ # Catch: breaking changes listed with severity
+ self.assertIn("Governance Failed", output)
self.assertIn("MAJOR", output)
- self.assertIn("Migration guide", output)
+ self.assertIn("Breaking Changes", output)
+ # Teach: explains WHY it breaks
+ self.assertIn("Why this breaks:", output)
+ # Invite: local run instructions + CTA
+ self.assertIn("npx delimit-cli lint", output)
+ self.assertIn("How to fix", output)
+ self.assertIn("API governance for every PR", output)
def test_slack_has_icon(self):
output = explain(self.breaking_changes, template="slack")
@@ -344,11 +351,17 @@ def test_markdown_breaking_has_header(self):
self.assertIn("Delimit", output)
self.assertIn("Breaking", output)
- def test_markdown_breaking_has_table(self):
+ def test_markdown_breaking_has_catch_teach_invite(self):
formatter = CIFormatter(OutputFormat.MARKDOWN)
output = formatter.format_result(self.result_breaking)
- self.assertIn("| | Count |", output)
- self.assertIn("Total changes", output)
+ # Catch: governance status + breaking changes section
+ self.assertIn("Governance Failed", output)
+ self.assertIn("Breaking Changes", output)
+ # Teach: explains why it breaks
+ self.assertIn("Why this breaks:", output)
+ # Invite: local run + CTA footer
+ self.assertIn("npx delimit-cli lint", output)
+ self.assertIn("API governance for every PR", output)
def test_markdown_breaking_has_violations(self):
formatter = CIFormatter(OutputFormat.MARKDOWN)