diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..a73e06f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,24 @@ +--- +name: Bug report +about: Report a wrong classification, missed call site, or command failure +title: '' +labels: '' +assignees: '' +--- + +### Version and environment + +Paste `patchline --version`, your Python version, and operating system. + +### Smallest example + +Include minimal before/after JSON specs or the source line being scanned. +Remove private URLs, credentials, and customer data. + +### Command and actual result + +Include the command, output, and exit code if relevant. + +### Expected result + +Explain why the API change should be breaking, additive, or require review. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..0d2a2a0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Describe a real API upgrade or integration Patchline should handle +title: '' +labels: '' +assignees: '' +--- + +### Your use case + +What API upgrade or workflow are you trying to handle? + +### Example input and desired output + +A small spec or source snippet helps define the behavior. + +### Current workaround + +How do you handle this today? What makes it difficult? diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..a5cef24 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,8 @@ +Describe the problem and the resulting behavior. + +Include a minimal before/after example for a classification change. + +Validation performed: + +- Tests or commands run, and their results. +- Any limitations or compatibility changes. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 205ee42..3722174 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,8 @@ jobs: - run: python -m unittest discover tests -v - name: build installable wheel run: python -m pip wheel . --no-deps --wheel-dir dist + - name: verify installed wheel outside source checkout + run: python tests/wheel_smoke.py dist # The demo must always work exactly as the README documents it. # (exit code 1 = breaking changes found, which is the expected result here) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5bca3fa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +## 0.3.0 + +- Add `patchline demo`, an offline example bundled in the installed wheel. +- Add `patchline --version`, package links, and installation verification outside + the source checkout on all four CI configurations. +- Fix false field-removal findings when an object becomes a composed schema. + Composition changes now use `REVIEW`, not `BREAKING`; diff still exits `1` + so CI does not silently approve an unverified compatibility change. +- Reproduce the pinned Stripe comparison as 6,385 findings: four breaking + field removals, one composition change requiring review, and 6,380 additive. + Earlier reports incorrectly described the terminal-reader union as removed fields. +- Add a CI integration recipe, issue templates, and contribution instructions. + +Reports now accept severity `REVIEW` and summaries include a `review` count. +Consumers that enumerate severity values must handle this additional value. + +## 0.2.0 + +- Expand comparison to all response statuses, media representations, array + items, required request bodies/parameters, and removed request media types. +- Add Python and JavaScript-module scanning, dependency-folder exclusions, + deterministic results, and input error exit code `2`. +- Add Windows/Linux CI and 46 regression tests. + +## 0.1.0 + +- Initial JSON API diff, JS/TS source scanning, and Stripe example. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1d7119c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Contributing to Patchline + +Start with a small, reproducible API change or scanner example. A useful report +includes a minimal old/new JSON spec, expected classification, actual output, +and a short source snippet if call-site matching is involved. + +## Run locally + +```bash +git clone https://github.com/Xenrai/patchline.git +cd patchline +python -m pip install -e . +python -m unittest discover tests -v +patchline demo +``` + +Python 3.10+ is required. Runtime and unit tests use the standard library. + +## Send a change + +1. Fork the repository and create a branch for one specific improvement. +2. For behavior changes, add a regression showing a real failure or missed case. +3. Keep findings deterministic and preserve the `0` / `1` / `2` CLI exit contract. +4. Document new limits or classification changes. Unsupported schemas must not + be presented as proven incompatibilities. +5. Run the suite and open a pull request describing the user-visible result. + +CI tests Windows and Linux, builds a wheel, and runs its demo in an isolated +environment outside the checkout. The Stripe baseline should change only after +reviewing why the findings changed; do not update counts just to make CI pass. + +## Useful first contributions + +- Small scanner regressions for Python dictionary access or JavaScript optional chaining. +- Minimal paired specs for an unsupported OpenAPI shape, with expected behavior. +- Clearer error messages with a reproducer and a test. +- Documentation tested on a platform you use. + +Discuss composition analysis, YAML support, and AST-based scanning in an issue +before a large implementation. These need a defined compatibility policy. + +If you use Patchline, a short account of your API, language, and false positives +is valuable feedback. A star helps people find it; fork when you want to change it. diff --git a/README.md b/README.md index 2a7bd02..a4ad1fa 100644 --- a/README.md +++ b/README.md @@ -1,160 +1,136 @@ -# patchline +# Patchline -Diff two OpenAPI specs, classify supported changes as breaking or additive, and map -the breaking ones to the potentially affected call sites in your codebase (file:line). +**An API changed. Which lines in your code should you review?** -Python 3.10+, zero dependencies, no network access, no LLM calls. MIT. +Patchline compares two OpenAPI JSON specs, flags supported breaking changes, +and points to potential callers in JavaScript, TypeScript, and Python. -## Why +[![Tests](https://github.com/Xenrai/patchline/actions/workflows/test.yml/badge.svg)](https://github.com/Xenrai/patchline/actions/workflows/test.yml) +[![Release](https://img.shields.io/github/v/release/Xenrai/patchline)](https://github.com/Xenrai/patchline/releases/latest) +[![MIT license](https://img.shields.io/badge/license-MIT-blue)](LICENSE) -Since 2024, Stripe ships breaking API changes twice a year through named -releases (acacia, basil, clover, dahlia) — documented in their own upgrade -guides. Most consumers find out when production breaks. I wanted a number for -how big the problem actually is, so I ran this tool on Stripe's real published -specs. The numbers below are that run, and you can reproduce them. +Python 3.10+ · zero runtime dependencies · offline analysis · no API keys -## The Stripe numbers (reproducible) +## Try it in a minute -Source: `openapi/spec3.json` in [stripe/openapi](https://github.com/stripe/openapi), -release tags `v2250` (2026-04-22.dahlia) → `v2349` (2026-07-29.dahlia), ~8 MB each. +Install the versioned wheel, preferably in your Python virtual environment: -| | | -|---|---| -| Total changes | **6,398** | -| Breaking | **18** | -| Additive | 6,380 | +```bash +python -m pip install https://github.com/Xenrai/patchline/releases/download/v0.3.0/patchline-0.3.0-py3-none-any.whl +python -m patchline demo +``` + +No checkout, API credentials, or sample downloads are needed after installation. +The demo runs the real differ and scanner on bundled OpenAPI 3 examples: -These v0.2 totals include array items and all response representations; they replace -the v0.1 totals of 679 changes (16 breaking). Findings are contract differences, -not a count of independent production failures. +```text +4 breaking changes -> 4 potential call sites -The breaking set (full report: [`examples/stripe/stripe-diff-report.json`](examples/stripe/stripe-diff-report.json)): + billing.js:2 [endpoint_removed] const CHARGE_URL = (id) => `/v1/charges/${id}`; + billing.js:9 [enum_value_removed] return charge.status === "pending"; + billing.js:13 [response_type_changed] return charge.billing_details.address.split(",")[1].trim(); + refunds.js:3 [request_required_added] return client.post("/v1/refunds", { charge: chargeId, amount }); +``` -- **`card.iin` removed, no replacement field** — from `POST /v1/tokens` and - `GET /v1/tokens/{token}`. Code doing `token.card.iin.slice(0, 6)` for BIN - routing compiles fine and dies at runtime. -- **`iin` removed from `GET /v1/customers/{customer}/cards/{id}`** — same field, - second surface. -- **`data[].iin` removed** from the customer card list response, previously - missed because it is inside an array. -- **Root response type changed** on the terminal reader cancel action. -- **13 fields removed from `POST /v1/terminal/readers/{reader}/cancel_action`** — - the response object was restructured; every property a client might read - (`serial_number`, `status`, `ip_address`, …) is gone. +This is a review aid: source matches are heuristic, not proof that a caller breaks. +The demo exits `0` on completion and does not change your project files. -Reproduce: +## Use your own API ```bash -pip install -e . -python examples/stripe/run_stripe_diff.py -# downloads the two pinned specs (~16 MB), runs the diff, checks the numbers: -# expected {'total': 6398, 'breaking': 18, 'additive': 6380} -> MATCH +patchline diff before.json after.json --out report.json +patchline scan --repo ./src --report report.json --out sites.json +patchline --version ``` -## Install +Use `python -m patchline` if your environment does not put the command on PATH. -```bash -pip install -e . # Python 3.10+, zero dependencies -``` +| Command result | Exit code | +| --- | --- | +| No supported findings | `0` | +| Diff has `BREAKING` or `REVIEW` findings; scan has potential call sites | `1` | +| Invalid input, unsupported reference, missing folder, or IO error | `2` | -## Quickstart +**[Add the copyable GitHub Actions check](docs/ci.md)** to compare specs on pull +requests and save JSON reports. Gate CI on the diff: a scan with no matches +does not establish compatibility. -```bash -# 1. Diff two specs (writes a full JSON report) -patchline diff examples/pay_api_v1.json examples/pay_api_v2.json --out diff.json +## What it catches -# 2. Map the breaking changes to call sites in a repo -patchline scan --repo examples/consumer --report diff.json -``` +| Change | Classification | +| --- | --- | +| Endpoint or response representation removed | `BREAKING` | +| Response field removed; root, field, or array-item type changed | `BREAKING` | +| Response enum value removed | `BREAKING` (conservative policy) | +| Required top-level request field, parameter, or body added | `BREAKING` | +| Request media type removed | `BREAKING` | +| Schema composition changes (`anyOf`, `oneOf`, `allOf`) | `REVIEW` | +| Endpoint, response, or response field added | `ADDITIVE` | -Real output of step 2: +The differ handles all eight OpenAPI HTTP methods, response statuses and media +representations, local references with escaped JSON Pointer tokens, arrays, +nullable types, and inherited parameters with operation-level overrides. +Report order is deterministic. -``` -4 breaking changes -> 7 affected call site(s) in examples/consumer - - billing.js:2 [endpoint_removed] const CHARGE_URL = (id) => `/v1/charges/${id}`; - billing.js:9 [enum_value_removed] return charge.status === "pending"; - billing.js:13 [response_type_changed] return charge.billing_details.address.split(",")[1].trim(); - mockClient.js:19 [request_required_added] if (path === "/v1/refunds") { - refunds.js:3 [request_required_added] return client.post("/v1/refunds", { charge: chargeId, amount }); - test.js:5 [request_required_added] const refunds = require("./refunds"); - test.js:37 [request_required_added] const r = await refunds.refundCharge(client, "ch_123", 500); -``` +The scanner supports `.js`, `.ts`, `.jsx`, `.tsx`, `.mjs`, `.cjs`, and `.py`. +It skips dependency, build, cache, and Git directories and symlinks. It reads +UTF-8 source and never executes it. + +## A reproducible Stripe example -Both commands exit `1` when breaking changes / affected call sites are found -and `0` when no supported findings are detected. Invalid input, missing folders, -unreadable files, and output-write failures exit `2` with an error on stderr. -Use these distinct codes in CI; a scan with zero matches does not prove compatibility. - -## What it detects - -| Class | Severity | Example | -|---|---|---| -| Endpoint removed | BREAKING | `GET /v1/charges/{id}` gone | -| Response field removed | BREAKING | `card.iin` no longer returned | -| Response type changed | BREAKING | `address`: string → object | -| Enum value removed | BREAKING | `status: "pending"` retired | -| Required request field added | BREAKING | `reason` now mandatory | -| Required body / parameter added | BREAKING | a header or body becomes mandatory | -| Response / media type removed | BREAKING | `201` or a content representation removed | -| Request media type removed | BREAKING | form encoding no longer accepted | -| Endpoint / response / field added | additive | informational | - -Spec support: JSON OpenAPI documents, all eight HTTP methods, every response -status (including `default` and status ranges), separate media representations, -local `$ref` resolution with escaped JSON Pointer tokens, array items, root -response types, nullable types, and required top-level request fields across -content types. Path-level parameters are inherited, with operation-level overrides. -Broken or external references encountered during comparison produce an error. - -## How it works - -- `patchline/spec_diff.py` compares operations and response representations, - walks nested properties and array items, and checks request requirements. -- `patchline/scanner.py` matches endpoint literals, dotted/bracket field access, - and enum literals against source files. Patterns are compiled once per scan. -- `patchline/cli.py` provides `diff` and `scan`, JSON report files, and CI exit codes. - -Scan results are sorted by file and line. Supported extensions are `.js`, `.ts`, -`.jsx`, `.tsx`, `.mjs`, `.cjs`, and `.py`. Dependency, build, cache, and Git folders -are excluded (`node_modules`, `.venv`, `venv`, `dist`, `build`, `__pycache__`, `.git`). -Symlinks are skipped. Source files must be UTF-8; unreadable source produces an error. - -## Limitations - -- This is a pattern scanner, not an AST or data-flow analyzer. It can both miss - affected call sites and flag unrelated code. Review the matched file and line. -- Schema traversal is capped at five levels. Composition (`allOf`, `oneOf`, - `anyOf`), discriminators, arbitrary additional properties, and full JSON Schema - constraint compatibility are not analyzed. -- Request checking covers top-level required properties, required bodies and - parameters, and removed media types. It does not compare nested request - requirements, parameter types, authentication, or validation bounds. -- YAML, external references, callbacks, and webhooks are unsupported. Convert or - bundle specs into JSON with local references first. -- Validation checks supported input shapes; it is not a complete OpenAPI validator. -- Response enum removals retain the original conservative breaking classification; - enum additions are not reported. A type change is conservatively breaking. - -## Tests +The repository includes a report comparing Stripe's published OpenAPI tags +`v2250` and `v2349`. With v0.3.0: + +| Findings | Count | +| --- | ---: | +| Breaking field removals | 4 | +| Schema changes requiring review | 1 | +| Additive findings | 6,380 | + +The removals concern `iin` on card/token responses, including `data[].iin` in +a card-list response. The terminal-reader cancel-action response becomes an +`anyOf` union and requires review; Patchline does **not** claim its fields were +all removed. This corrects an overstatement in earlier reports. + +These are contract findings, not a count of independent production failures. +[Read the generated report](examples/stripe/stripe-diff-report.json) or reproduce +it from a checkout: ```bash -python -m unittest discover tests -v +python examples/stripe/run_stripe_diff.py ``` -Tests cover original fixtures, response variants, arrays, references, request -requirements, scanner exclusions, input failures, and end-to-end CLI exit codes. -The weekly workflow reruns the pinned Stripe comparison. Package discovery is -explicit so installation includes only `patchline`, not fixtures or tests. +That separate example downloads the two pinned specs. Normal `diff`, `scan`, +and `demo` analysis runs offline. [Source snapshots](https://github.com/stripe/openapi). + +## Know the limits -## Open source vs. commercial +- Source matching is pattern-based: false positives and missed callers are possible. +- Schema traversal is capped at five levels. Composition changes are flagged + for review; branch semantics and changes hidden behind unchanged composition + references are not analyzed. JSON Schema constraint compatibility is incomplete. +- Request analysis does not compare nested requirements, parameter types, + authentication, or validation bounds. +- Input is JSON with local references. YAML, external references, callbacks, + and webhooks are unsupported. Input checks are not a full OpenAPI validator. +- Response enum removals are conservatively breaking; enum additions are not + reported. Type changes are conservatively breaking. -This repo is detection: diff, classify, call-site scan. The commercial product -adds remediation (rewrites affected call sites, opens a PR gated on your own -CI) and continuous watching of the APIs you depend on. If the detection core -is useful to you, issues and PRs are welcome — especially new change-class -patterns and scanner support for more languages. +If you need a complete compatibility proof, this tool does not provide one. -## License +## Build on it + +Found a missed API change or false positive? [Open a minimal example](https://github.com/Xenrai/patchline/issues/new/choose). +Want to improve it? [Read the contribution guide](CONTRIBUTING.md), fork the +repository, and add a focused regression. Useful contributions include scanner +examples, spec fixtures, and clearer errors. + +```bash +git clone https://github.com/Xenrai/patchline.git +cd patchline +python -m pip install -e . +python -m unittest discover tests -v +``` -MIT — see [LICENSE](LICENSE). +CI covers Python 3.10 and 3.12 on Windows and Linux, including wheel installation +and the demo outside the checkout. [Changelog](CHANGELOG.md) · [MIT license](LICENSE). diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000..ebb69cc --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,54 @@ +# Check API changes in a pull request + +This example compares a repository's `api/openapi.json` with the PR's base +commit, then scans `src`. Change those two paths for your project. It assumes +both revisions contain the spec and the document is JSON with local references. + +Save this as `.github/workflows/api-compatibility.yml` in your own repository: + +```yaml +name: API compatibility +on: pull_request +permissions: + contents: read +jobs: + compare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install Patchline + run: python -m pip install https://github.com/Xenrai/patchline/releases/download/v0.3.0/patchline-0.3.0-py3-none-any.whl + - name: Compare API and locate potential callers + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + shell: bash + run: | + mkdir -p .patchline + git show "$BASE_SHA:api/openapi.json" > .patchline/before.json + set +e + patchline diff .patchline/before.json api/openapi.json --out .patchline/report.json + diff_status=$? + if [ "$diff_status" -gt 1 ]; then exit "$diff_status"; fi + patchline scan --repo src --report .patchline/report.json --out .patchline/sites.json + scan_status=$? + if [ "$scan_status" -gt 1 ]; then exit "$scan_status"; fi + exit "$diff_status" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: api-compatibility + path: .patchline/*.json +``` + +The API diff determines the gate: `BREAKING` and `REVIEW` findings fail it even +when the heuristic scanner finds no callers. Exit `2` is an input or IO failure. +The source scan does not execute repository code. No API key is needed. + +For a third-party API, store the approved spec snapshot in your repository and +compare it with the proposed snapshot. Fetching and choosing snapshots is outside +Patchline's scope; the tool itself compares local files without network calls. diff --git a/examples/stripe/run_stripe_diff.py b/examples/stripe/run_stripe_diff.py index 14ce596..d170164 100644 --- a/examples/stripe/run_stripe_diff.py +++ b/examples/stripe/run_stripe_diff.py @@ -7,8 +7,8 @@ Usage: python examples/stripe/run_stripe_diff.py -Expected output (v0.2, verified 2026-09-19): - 6398 changes: 18 BREAKING, 6380 additive +Expected output (v0.3, verified 2026-09-19): + 6385 changes: 4 BREAKING, 1 review, 6380 additive The report committed at examples/stripe/stripe-diff-report.json was produced by exactly this script. If Stripe re-tags a release the numbers can shift; @@ -29,7 +29,7 @@ "v2349": "https://raw.githubusercontent.com/stripe/openapi/v2349/openapi/spec3.json", } -EXPECTED = {"total": 6398, "breaking": 18, "additive": 6380} +EXPECTED = {"total": 6385, "breaking": 4, "review": 1, "additive": 6380} def fetch(tag: str) -> Path: @@ -47,7 +47,7 @@ def main() -> int: changes = diff_specs(old, new) s = summarize(changes) print(f"Stripe API: {old['info']['version']} -> {new['info']['version']}") - print(f"{s['total']} changes: {s['breaking']} BREAKING, {s['additive']} additive") + print(f"{s['total']} changes: {s['breaking']} BREAKING, {s['review']} review, {s['additive']} additive") report = { "api": old["info"]["title"], @@ -61,10 +61,10 @@ def main() -> int: out.write_text(json.dumps(report, indent=2), encoding="utf-8") print(f"full report -> {out}") - breaking = [c for c in changes if c.severity == "BREAKING"] - print("\nbreaking changes:") + breaking = [c for c in changes if c.severity in ("BREAKING", "REVIEW")] + print("\nbreaking changes and manual reviews:") for c in breaking: - print(f" {c.method.upper():5s} {c.path:55s} {c.detail}") + print(f" [{c.severity}] {c.method.upper():5s} {c.path:55s} {c.detail}") ok = all(s[k] == v for k, v in EXPECTED.items()) print(f"\nexpected {EXPECTED} -> {'MATCH' if ok else 'MISMATCH'}") diff --git a/examples/stripe/stripe-diff-report.json b/examples/stripe/stripe-diff-report.json index 78b4a14..8dab087 100644 --- a/examples/stripe/stripe-diff-report.json +++ b/examples/stripe/stripe-diff-report.json @@ -7,14 +7,15 @@ "v2349": "https://raw.githubusercontent.com/stripe/openapi/v2349/openapi/spec3.json" }, "summary": { - "total": 6398, - "breaking": 18, + "total": 6385, + "breaking": 4, + "review": 1, "additive": 6380, "by_kind": { "endpoint_added": 2, "response_field_added": 6378, - "response_field_removed": 17, - "response_type_changed": 1 + "response_field_removed": 4, + "response_schema_composition_changed": 1 } }, "changes": [ @@ -54109,144 +54110,23 @@ "new": "string" }, { - "kind": "response_type_changed", - "severity": "BREAKING", + "kind": "response_schema_composition_changed", + "severity": "REVIEW", "path": "/v1/terminal/readers/{reader}/cancel_action", "method": "post", - "detail": "Response 200 root type changed", + "detail": "Schema composition changed; manual compatibility review required", "pointer": "paths./v1/terminal/readers/{reader}/cancel_action.post.responses.200.$", - "old": "object", - "new": "any" - }, - { - "kind": "response_field_removed", - "severity": "BREAKING", - "path": "/v1/terminal/readers/{reader}/cancel_action", - "method": "post", - "detail": "Response field 'action' removed from POST /v1/terminal/readers/{reader}/cancel_action", - "pointer": "paths./v1/terminal/readers/{reader}/cancel_action.post.responses.200.action", - "old": "any", - "new": null - }, - { - "kind": "response_field_removed", - "severity": "BREAKING", - "path": "/v1/terminal/readers/{reader}/cancel_action", - "method": "post", - "detail": "Response field 'device_sw_version' removed from POST /v1/terminal/readers/{reader}/cancel_action", - "pointer": "paths./v1/terminal/readers/{reader}/cancel_action.post.responses.200.device_sw_version", - "old": "null|string", - "new": null - }, - { - "kind": "response_field_removed", - "severity": "BREAKING", - "path": "/v1/terminal/readers/{reader}/cancel_action", - "method": "post", - "detail": "Response field 'device_type' removed from POST /v1/terminal/readers/{reader}/cancel_action", - "pointer": "paths./v1/terminal/readers/{reader}/cancel_action.post.responses.200.device_type", - "old": "string", - "new": null - }, - { - "kind": "response_field_removed", - "severity": "BREAKING", - "path": "/v1/terminal/readers/{reader}/cancel_action", - "method": "post", - "detail": "Response field 'id' removed from POST /v1/terminal/readers/{reader}/cancel_action", - "pointer": "paths./v1/terminal/readers/{reader}/cancel_action.post.responses.200.id", - "old": "string", - "new": null - }, - { - "kind": "response_field_removed", - "severity": "BREAKING", - "path": "/v1/terminal/readers/{reader}/cancel_action", - "method": "post", - "detail": "Response field 'ip_address' removed from POST /v1/terminal/readers/{reader}/cancel_action", - "pointer": "paths./v1/terminal/readers/{reader}/cancel_action.post.responses.200.ip_address", - "old": "null|string", - "new": null - }, - { - "kind": "response_field_removed", - "severity": "BREAKING", - "path": "/v1/terminal/readers/{reader}/cancel_action", - "method": "post", - "detail": "Response field 'label' removed from POST /v1/terminal/readers/{reader}/cancel_action", - "pointer": "paths./v1/terminal/readers/{reader}/cancel_action.post.responses.200.label", - "old": "string", - "new": null - }, - { - "kind": "response_field_removed", - "severity": "BREAKING", - "path": "/v1/terminal/readers/{reader}/cancel_action", - "method": "post", - "detail": "Response field 'last_seen_at' removed from POST /v1/terminal/readers/{reader}/cancel_action", - "pointer": "paths./v1/terminal/readers/{reader}/cancel_action.post.responses.200.last_seen_at", - "old": "integer|null", - "new": null - }, - { - "kind": "response_field_removed", - "severity": "BREAKING", - "path": "/v1/terminal/readers/{reader}/cancel_action", - "method": "post", - "detail": "Response field 'livemode' removed from POST /v1/terminal/readers/{reader}/cancel_action", - "pointer": "paths./v1/terminal/readers/{reader}/cancel_action.post.responses.200.livemode", - "old": "boolean", - "new": null - }, - { - "kind": "response_field_removed", - "severity": "BREAKING", - "path": "/v1/terminal/readers/{reader}/cancel_action", - "method": "post", - "detail": "Response field 'location' removed from POST /v1/terminal/readers/{reader}/cancel_action", - "pointer": "paths./v1/terminal/readers/{reader}/cancel_action.post.responses.200.location", - "old": "any", - "new": null - }, - { - "kind": "response_field_removed", - "severity": "BREAKING", - "path": "/v1/terminal/readers/{reader}/cancel_action", - "method": "post", - "detail": "Response field 'metadata' removed from POST /v1/terminal/readers/{reader}/cancel_action", - "pointer": "paths./v1/terminal/readers/{reader}/cancel_action.post.responses.200.metadata", - "old": "object", - "new": null - }, - { - "kind": "response_field_removed", - "severity": "BREAKING", - "path": "/v1/terminal/readers/{reader}/cancel_action", - "method": "post", - "detail": "Response field 'object' removed from POST /v1/terminal/readers/{reader}/cancel_action", - "pointer": "paths./v1/terminal/readers/{reader}/cancel_action.post.responses.200.object", - "old": "string", - "new": null - }, - { - "kind": "response_field_removed", - "severity": "BREAKING", - "path": "/v1/terminal/readers/{reader}/cancel_action", - "method": "post", - "detail": "Response field 'serial_number' removed from POST /v1/terminal/readers/{reader}/cancel_action", - "pointer": "paths./v1/terminal/readers/{reader}/cancel_action.post.responses.200.serial_number", - "old": "string", - "new": null - }, - { - "kind": "response_field_removed", - "severity": "BREAKING", - "path": "/v1/terminal/readers/{reader}/cancel_action", - "method": "post", - "detail": "Response field 'status' removed from POST /v1/terminal/readers/{reader}/cancel_action", - "pointer": "paths./v1/terminal/readers/{reader}/cancel_action.post.responses.200.status", - "old": "null|string", - "new": null + "old": null, + "new": { + "anyOf": [ + { + "$ref": "#/components/schemas/terminal.reader" + }, + { + "$ref": "#/components/schemas/deleted_terminal.reader" + } + ] + } }, { "kind": "response_field_added", diff --git a/patchline/__init__.py b/patchline/__init__.py index 96eb944..027706d 100644 --- a/patchline/__init__.py +++ b/patchline/__init__.py @@ -1,10 +1,10 @@ """Patchline — open-source API change detection. -Diff two OpenAPI specs, classify every change as BREAKING or ADDITIVE, -and map breaking changes to the exact call sites in a consumer codebase. +Diff supported OpenAPI changes as BREAKING, REVIEW, or ADDITIVE, +and suggest potentially affected source locations in a consumer codebase. """ from .spec_diff import Change, load_spec, diff_specs, summarize from .scanner import CallSite, scan_repo -__version__ = "0.2.0" +__version__ = "0.3.0" __all__ = ["Change", "CallSite", "load_spec", "diff_specs", "summarize", "scan_repo"] diff --git a/patchline/cli.py b/patchline/cli.py index 9bf7874..885aa1b 100644 --- a/patchline/cli.py +++ b/patchline/cli.py @@ -25,16 +25,16 @@ def _cmd_diff(args): } s = report["summary"] print(f"{report['api']}: {report['old_version']} -> {report['new_version']}") - print(f"{s['total']} changes: {s['breaking']} BREAKING, {s['additive']} additive") + print(f"{s['total']} changes: {s['breaking']} BREAKING, {s['review']} review, {s['additive']} additive") print() for c in changes: - if c.severity == "BREAKING": - print(f" [BREAKING] {c.kind:24s} {c.method.upper():5s} {c.path:40s} {c.detail}") + if c.severity in ("BREAKING", "REVIEW"): + print(f" [{c.severity}] {c.kind:24s} {c.method.upper():5s} {c.path:40s} {c.detail}") if args.out: with open(args.out, "w", encoding="utf-8") as f: json.dump(report, f, indent=2) print(f"\nFull report written to {args.out}") - return 1 if s["breaking"] else 0 # exit code doubles as a CI signal + return 1 if s["breaking"] or s["review"] else 0 def _cmd_scan(args): @@ -53,12 +53,14 @@ def _cmd_scan(args): for field in ("kind", "severity", "path", "method", "detail", "pointer"): if not isinstance(getattr(change, field), str): raise ValueError(f"change {i} field '{field}' must be a string") - if change.severity not in ("BREAKING", "ADDITIVE"): + if change.severity not in ("BREAKING", "REVIEW", "ADDITIVE"): raise ValueError(f"change {i} has invalid severity") changes.append(change) sites = scan_repo(args.repo, changes) breaking = [c for c in changes if c.severity == "BREAKING"] - print(f"{len(breaking)} breaking changes -> {len(sites)} affected call site(s) in {args.repo}") + review = sum(c.severity == "REVIEW" for c in changes) + review_label = f", {review} requiring review" if review else "" + print(f"{len(breaking)} breaking changes{review_label} -> {len(sites)} affected call site(s) in {args.repo}") print() for s_ in sites: print(f" {s_.file}:{s_.line:<5} [{s_.change_kind}] {s_.code[:80]}") @@ -73,8 +75,14 @@ def main(argv=None): parser = argparse.ArgumentParser( prog="patchline", description="Diff API specs and find what breaks your code.") + from . import __version__ + parser.add_argument("--version", action="version", version=f"patchline {__version__}") sub = parser.add_subparsers(dest="cmd", required=True) + from .demo import run_demo + demo = sub.add_parser("demo", help="run the bundled offline example; no clone or API key needed") + demo.set_defaults(fn=lambda args: run_demo()) + d = sub.add_parser("diff", help="diff two OpenAPI specs") d.add_argument("old_spec") d.add_argument("new_spec") diff --git a/patchline/demo.py b/patchline/demo.py new file mode 100644 index 0000000..3a8ed75 --- /dev/null +++ b/patchline/demo.py @@ -0,0 +1,32 @@ +"""An offline, installed-package demo using real diff and scan code.""" +import json +from importlib.resources import files +from pathlib import Path +from tempfile import TemporaryDirectory + +from .scanner import scan_repo +from .spec_diff import diff_specs, summarize + + +def run_demo(): + data = files("patchline").joinpath("demo_data") + old = json.loads(data.joinpath("before.json").read_text(encoding="utf-8")) + new = json.loads(data.joinpath("after.json").read_text(encoding="utf-8")) + changes = diff_specs(old, new) + summary = summarize(changes) + with TemporaryDirectory(prefix="patchline-demo-") as directory: + for name in ("billing.js", "refunds.js"): + Path(directory, name).write_text(data.joinpath(name).read_text(encoding="utf-8"), + encoding="utf-8") + sites = scan_repo(directory, changes) + print("Patchline demo: a payment API changes; find the callers to review.") + print(f"{summary['breaking']} breaking changes -> {len(sites)} potential call sites\n") + for site in sites: + print(f" {site.file}:{site.line} [{site.change_kind}] {site.code}") + print("\nThe scanner suggests review locations; it does not prove a caller is broken.") + print("No network requests were made and your project files were not changed.") + print("\nTry your API:") + print(" patchline diff before.json after.json --out report.json") + print(" patchline scan --repo . --report report.json") + print("\nDemo completed. In normal use, findings exit 1; input errors exit 2.") + return 0 diff --git a/patchline/demo_data/after.json b/patchline/demo_data/after.json new file mode 100644 index 0000000..029c968 --- /dev/null +++ b/patchline/demo_data/after.json @@ -0,0 +1,153 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "PayAPI", + "version": "v2 (2026-07-01.dahlia)" + }, + "x-migrations": { + "endpoints": { + "GET /v1/charges/{id}": "GET /v1/payments/{id}" + }, + "enums": { + "status": { + "pending": "processing" + } + }, + "fields": { + "billing_details.address": { + "shape": "object", + "formatter": "formatAddress" + } + }, + "defaults": { + "POST /v1/refunds": { + "reason": "requested_by_customer" + } + } + }, + "paths": { + "/v1/charges": { + "post": { + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "processing", + "succeeded", + "failed" + ] + }, + "billing_details": { + "type": "object", + "properties": { + "address": { + "type": "object", + "properties": { + "line1": { + "type": "string" + }, + "city": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/v1/payments/{id}": { + "get": { + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "processing", + "succeeded", + "failed" + ] + } + } + } + } + } + } + } + } + }, + "/v1/refunds": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "charge", + "amount", + "reason" + ], + "properties": { + "charge": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "reason": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + } + } + } + } + } + } + } +} diff --git a/patchline/demo_data/before.json b/patchline/demo_data/before.json new file mode 100644 index 0000000..4e2b4ea --- /dev/null +++ b/patchline/demo_data/before.json @@ -0,0 +1,120 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "PayAPI", + "version": "v1 (2025-03-01)" + }, + "paths": { + "/v1/charges": { + "post": { + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "succeeded", + "failed" + ] + }, + "billing_details": { + "type": "object", + "properties": { + "address": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/v1/charges/{id}": { + "get": { + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "succeeded", + "failed" + ] + } + } + } + } + } + } + } + } + }, + "/v1/refunds": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "charge", + "amount" + ], + "properties": { + "charge": { + "type": "string" + }, + "amount": { + "type": "number" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + } + } + } + } + } + } + } +} diff --git a/patchline/demo_data/billing.js b/patchline/demo_data/billing.js new file mode 100644 index 0000000..6f1771b --- /dev/null +++ b/patchline/demo_data/billing.js @@ -0,0 +1,16 @@ +// Consumer code — written against PayAPI v1 +const CHARGE_URL = (id) => `/v1/charges/${id}`; + +async function fetchCharge(client, id) { + return client.get(CHARGE_URL(id)); +} + +function isInFlight(charge) { + return charge.status === "pending"; +} + +function chargeCity(charge) { + return charge.billing_details.address.split(",")[1].trim(); +} + +module.exports = { fetchCharge, isInFlight, chargeCity }; diff --git a/patchline/demo_data/refunds.js b/patchline/demo_data/refunds.js new file mode 100644 index 0000000..b889a6e --- /dev/null +++ b/patchline/demo_data/refunds.js @@ -0,0 +1,6 @@ +// Consumer code — written against PayAPI v1 +async function refundCharge(client, chargeId, amount) { + return client.post("/v1/refunds", { charge: chargeId, amount }); +} + +module.exports = { refundCharge }; diff --git a/patchline/scanner.py b/patchline/scanner.py index f465ec1..ca7cbdc 100644 --- a/patchline/scanner.py +++ b/patchline/scanner.py @@ -4,8 +4,7 @@ Each change kind carries search patterns; we grep the consumer repo line by line and record (file, line, matched_text, change_pointer). -In production this layer is an agent with full AST + type information; the -pattern scan is the deterministic v1 that already covers the common cases: +This is a heuristic rather than AST or data-flow analysis. It covers: endpoint path literals, dotted field access, enum string comparisons. """ import os @@ -15,10 +14,11 @@ DEFAULT_EXTENSIONS = (".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".py") EXCLUDED_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build"} -SUPPORTED_BREAKING_KINDS = { +SUPPORTED_ACTIONABLE_KINDS = { "endpoint_removed", "response_removed", "request_body_required", "request_parameter_required", "request_media_removed", "response_type_changed", "response_field_removed", "enum_value_removed", "request_required_added", + "response_schema_composition_changed", } @@ -40,7 +40,7 @@ def _patterns_for(change): kind = change.kind path = change.path - if kind in ("endpoint_removed", "response_removed", "request_body_required", + if kind in ("endpoint_removed", "response_removed", "response_schema_composition_changed", "request_body_required", "request_parameter_required", "request_media_removed", "request_required_added") or ( kind == "response_type_changed" and change.pointer.endswith((".$", ".[]"))): # template-literal `/v1/charges/${id}` and literal `/v1/charges/ch_123` @@ -78,8 +78,8 @@ def scan_repo(repo_dir, changes, exts=DEFAULT_EXTENSIONS): if not os.path.isdir(repo_dir): raise ValueError(f"repository directory does not exist: {repo_dir}") sites = [] - breaking = [c for c in changes if c.severity == "BREAKING"] - unsupported = sorted({c.kind for c in breaking} - SUPPORTED_BREAKING_KINDS) + breaking = [c for c in changes if c.severity in ("BREAKING", "REVIEW")] + unsupported = sorted({c.kind for c in breaking} - SUPPORTED_ACTIONABLE_KINDS) if unsupported: raise ValueError(f"unsupported breaking change kinds: {', '.join(unsupported)}") patterns = [(c, _patterns_for(c)) for c in breaking] diff --git a/patchline/spec_diff.py b/patchline/spec_diff.py index 9e68ecb..5baa270 100644 --- a/patchline/spec_diff.py +++ b/patchline/spec_diff.py @@ -9,6 +9,7 @@ type changed | requestBody required property added | enum value removed | response removed | required body/parameter | request media removed ADDITIVE: endpoint added | response added | response property added + REVIEW: schema composition changed; compatibility is not established """ import json import re @@ -21,7 +22,7 @@ @dataclass class Change: kind: str - severity: str # BREAKING | ADDITIVE + severity: str # BREAKING | REVIEW | ADDITIVE path: str method: str detail: str @@ -171,10 +172,49 @@ def _enums(spec, schema, prefix="", depth=0): return out +def _compositions(spec, schema, prefix="", depth=0): + """Locate unsupported compositions without inventing field removals.""" + if depth > MAX_DEPTH: + return {} + schema = _resolve(spec, schema) + composed = {key: schema[key] for key in ("allOf", "oneOf", "anyOf") if key in schema} + if composed: + return {prefix: composed} + found = {} + properties = schema.get("properties", {}) + if isinstance(properties, dict): + for name, child in properties.items(): + pointer = f"{prefix}.{name}" if prefix else name + found.update(_compositions(spec, child, pointer, depth + 1)) + if "items" in schema: + found.update(_compositions(spec, schema["items"], prefix + "[]", depth + 1)) + return found + + def _diff_response(old_spec, new_spec, old_schema, new_schema, path, method, base): changes = [] + old_composed = _compositions(old_spec, old_schema) + new_composed = _compositions(new_spec, new_schema) old_props = _props(old_spec, old_schema) new_props = _props(new_spec, new_schema) + candidates = {p for p in old_composed.keys() | new_composed.keys() + if not p or p in old_props and p in new_props} + blocked = {p for p in candidates if not any( + parent != p and (not parent or p.startswith((parent + ".", parent + "[]"))) + for parent in candidates)} + + def covered(pointer): + return not any(not p or pointer == p or pointer.startswith((p + ".", p + "[]")) for p in blocked) + + for pointer in sorted(blocked): + if old_composed.get(pointer) != new_composed.get(pointer): + changes.append(Change( + kind="response_schema_composition_changed", severity="REVIEW", path=path, method=method, + detail="Schema composition changed; manual compatibility review required", + pointer=f"{base}.{pointer or '$'}", old=old_composed.get(pointer), + new=new_composed.get(pointer))) + old_props = {p: t for p, t in old_props.items() if covered(p)} + new_props = {p: t for p, t in new_props.items() if covered(p)} for ptr in sorted(old_props): if ptr not in new_props: changes.append(Change( @@ -196,6 +236,8 @@ def _diff_response(old_spec, new_spec, old_schema, new_schema, path, method, bas old_enums = _enums(old_spec, old_schema) new_enums = _enums(new_spec, new_schema) for ptr, old_vals in sorted(old_enums.items()): + if not covered(ptr): + continue new_vals = new_enums.get(ptr) if new_vals is None: continue @@ -250,7 +292,8 @@ def _parameters(spec, op): def diff_specs(old_spec, new_spec): """Compare supported OpenAPI contracts; raise ValueError for invalid input. - Changes retain the v1 report shape. Media types are included in pointers + Changes retain the original fields and add the REVIEW severity in v0.3. + Media types are included in pointers when more than one representation is present for a response status. """ _validate_spec(old_spec) @@ -284,7 +327,8 @@ def add(kind, pointer, detail, old=None, new=None, severity="BREAKING"): add("response_added", response_base, f"Response {status} {media} added", severity="ADDITIVE") continue old_schema, new_schema = old_variants[variant], new_variants[variant] - if _schema_type(old_schema) != _schema_type(new_schema): + composed_root = any(k in old_schema or k in new_schema for k in ("allOf", "oneOf", "anyOf")) + if not composed_root and _schema_type(old_schema) != _schema_type(new_schema): add("response_type_changed", response_base + ".$", f"Response {status} root type changed", _schema_type(old_schema), _schema_type(new_schema)) @@ -333,6 +377,7 @@ def summarize(changes): return { "total": len(changes), "breaking": len(breaking), + "review": sum(c.severity == "REVIEW" for c in changes), "additive": len(additive), "by_kind": {k: sum(1 for c in changes if c.kind == k) for k in sorted({c.kind for c in changes})}, diff --git a/pyproject.toml b/pyproject.toml index b98d322..9dbeb60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,22 @@ build-backend = "setuptools.build_meta" [project] name = "patchline" -version = "0.2.0" +version = "0.3.0" description = "Diff API specs and find what breaks your code" readme = "README.md" license = { text = "MIT" } requires-python = ">=3.10" dependencies = [] +keywords = ["openapi", "api-diff", "breaking-changes", "static-analysis", "ci"] + +[project.urls] +Repository = "https://github.com/Xenrai/patchline" +Documentation = "https://github.com/Xenrai/patchline#readme" +Issues = "https://github.com/Xenrai/patchline/issues" +Changelog = "https://github.com/Xenrai/patchline/blob/main/CHANGELOG.md" + +[tool.setuptools.package-data] +patchline = ["demo_data/*.json", "demo_data/*.js"] [tool.setuptools.packages.find] include = ["patchline*"] diff --git a/tests/test_demo.py b/tests/test_demo.py new file mode 100644 index 0000000..3ed0622 --- /dev/null +++ b/tests/test_demo.py @@ -0,0 +1,39 @@ +import contextlib +import io +import json +import unittest +from importlib.resources import files +from unittest.mock import patch + +from patchline import __version__ +from patchline.cli import main + + +class DemoTests(unittest.TestCase): + def test_demo_runs_real_comparison_offline(self): + output = io.StringIO() + with patch("socket.socket", side_effect=AssertionError("demo must be offline")): + with contextlib.redirect_stdout(output): + result = main(["demo"]) + self.assertEqual(result, 0) + text = output.getvalue() + self.assertIn("4 breaking changes -> 4 potential call sites", text) + for location in ("billing.js:2", "billing.js:9", "billing.js:13", "refunds.js:3"): + self.assertIn(location, text) + + def test_demo_specs_use_openapi_content_wrappers(self): + for name in ("before.json", "after.json"): + spec = json.loads(files("patchline").joinpath("demo_data", name).read_text(encoding="utf-8")) + for item in spec["paths"].values(): + for operation in item.values(): + for response in operation["responses"].values(): + self.assertIn("application/json", response["content"]) + if "requestBody" in operation: + self.assertIn("application/json", operation["requestBody"]["content"]) + + def test_version_works_without_subcommand(self): + output = io.StringIO() + with contextlib.redirect_stdout(output), self.assertRaises(SystemExit) as exit_: + main(["--version"]) + self.assertEqual(exit_.exception.code, 0) + self.assertEqual(output.getvalue().strip(), "patchline " + __version__) diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 36ff24b..24a8f91 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -25,6 +25,35 @@ def props(**fields): class ContractRegressions(unittest.TestCase): + def test_object_becoming_union_does_not_invent_field_removals(self): + old = spec(props(id={"type": "string"}, status={"type": "string"})) + new = spec({"anyOf": [props(id={"type": "string"}), props(deleted={"type": "boolean"})]}) + changes = diff_specs(old, new) + self.assertEqual([c.kind for c in changes], ["response_schema_composition_changed"]) + self.assertIn("manual compatibility review", changes[0].detail) + self.assertEqual(changes[0].severity, "REVIEW") + + def test_new_composed_field_is_additive(self): + changes = diff_specs(spec(props()), spec(props(result={"oneOf": [{"type": "string"}]}))) + self.assertEqual([c.kind for c in changes], ["response_field_added"]) + + def test_parent_composition_suppresses_nested_noise(self): + old = spec(props(result={"oneOf": [{"type": "string"}]})) + new = spec({"anyOf": [props(result={"type": "string"})]}) + self.assertEqual(len(diff_specs(old, new)), 1) + + def test_nested_composition_change_preserves_other_findings(self): + old = spec(props(result=props(id={"type": "string"}), extra={"type": "string"})) + new = spec(props(result={"oneOf": [props(id={"type": "string"})]})) + changes = diff_specs(old, new) + removed = [c.pointer for c in changes if c.kind == "response_field_removed"] + self.assertEqual(removed, ["paths./items.get.responses.200.extra"]) + self.assertIn("response_schema_composition_changed", [c.kind for c in changes]) + + def test_unchanged_composition_does_not_create_findings(self): + value = spec({"allOf": [props(id={"type": "string"})]}) + self.assertEqual(diff_specs(value, value), []) + def test_all_response_statuses(self): for status in ("201", "204", "400", "2XX", "default"): with self.subTest(status=status): @@ -199,6 +228,20 @@ def test_bad_inputs_return_two_without_traceback(self): self.assertIn("patchline: error:", err) self.assertNotIn("Traceback", err) + def test_manual_review_fails_diff_gate_and_can_be_scanned(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + old, new, report = (root / name for name in ("old.json", "new.json", "report.json")) + old.write_text(json.dumps(spec(props(id={"type": "string"}))), encoding="utf-8") + new.write_text(json.dumps(spec({"anyOf": [props(id={"type": "string"})]})), encoding="utf-8") + (root / "client.py").write_text('client.get("/items")\n', encoding="utf-8") + status, out, _ = self.invoke(["diff", str(old), str(new), "--out", str(report)]) + self.assertEqual(status, 1) + self.assertIn("0 BREAKING, 1 review", out) + parsed = json.loads(report.read_text(encoding="utf-8")) + self.assertEqual(parsed["changes"][0]["severity"], "REVIEW") + self.assertEqual(self.invoke(["scan", "--repo", temp, "--report", str(report)])[0], 1) + def test_invalid_reports_return_two(self): with tempfile.TemporaryDirectory() as temp: path = Path(temp) / "report.json" diff --git a/tests/test_spec_diff.py b/tests/test_spec_diff.py index 3b8befb..6d58842 100644 --- a/tests/test_spec_diff.py +++ b/tests/test_spec_diff.py @@ -173,9 +173,10 @@ def test_committed_report_numbers(self): self.skipTest("report not generated yet") import json report = json.loads(report_path.read_text(encoding="utf-8")) - self.assertEqual(report["summary"]["total"], 6398) - self.assertEqual(report["summary"]["breaking"], 18) + self.assertEqual(report["summary"]["total"], 6385) + self.assertEqual(report["summary"]["breaking"], 4) self.assertEqual(report["summary"]["additive"], 6380) + self.assertEqual(report["summary"]["review"], 1) iin = [c for c in report["changes"] if c["kind"] == "response_field_removed" and "card.iin" in c["pointer"]] self.assertEqual(len(iin), 2) # POST /v1/tokens + GET /v1/tokens/{token} diff --git a/tests/wheel_smoke.py b/tests/wheel_smoke.py new file mode 100644 index 0000000..e58715e --- /dev/null +++ b/tests/wheel_smoke.py @@ -0,0 +1,29 @@ +"""Verify the distributed wheel in an empty directory, outside the checkout.""" +from pathlib import Path +import subprocess +import sys +import tempfile +import venv + + +def main(): + wheels = sorted(Path(sys.argv[1]).glob("patchline-*.whl")) + if len(wheels) != 1: + raise SystemExit("provide a directory containing exactly one Patchline wheel") + wheel = wheels[0].resolve() + with tempfile.TemporaryDirectory(prefix="patchline-install-") as directory: + root = Path(directory) + venv.EnvBuilder(with_pip=True).create(root / "env") + python = root / "env" / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + subprocess.run([str(python), "-m", "pip", "install", "--no-index", "--no-deps", str(wheel)], + cwd=root, check=True) + result = subprocess.run([str(python), "-m", "patchline", "demo"], + cwd=root, text=True, capture_output=True, check=True) + if "4 breaking changes -> 4 potential call sites" not in result.stdout: + raise SystemExit("installed demo did not produce the expected findings") + subprocess.run([str(python), "-m", "patchline", "--version"], cwd=root, check=True) + print("Wheel installation and offline demo passed outside the source checkout.") + + +if __name__ == "__main__": + main()