breakscope 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- breakscope-0.0.1/.github/workflows/tests.yml +25 -0
- breakscope-0.0.1/.gitignore +8 -0
- breakscope-0.0.1/PKG-INFO +35 -0
- breakscope-0.0.1/README.md +17 -0
- breakscope-0.0.1/breakscope/__init__.py +3 -0
- breakscope-0.0.1/breakscope/__main__.py +3 -0
- breakscope-0.0.1/breakscope/cli/__init__.py +0 -0
- breakscope-0.0.1/breakscope/cli/main.py +44 -0
- breakscope-0.0.1/docs/ARCHITECTURE-v0.1.md +187 -0
- breakscope-0.0.1/docs/ROADMAP.md +107 -0
- breakscope-0.0.1/pyproject.toml +34 -0
- breakscope-0.0.1/recoverycode.md +9 -0
- breakscope-0.0.1/tests/__init__.py +0 -0
- breakscope-0.0.1/tests/test_cli.py +16 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
strategy:
|
|
11
|
+
fail-fast: false
|
|
12
|
+
matrix:
|
|
13
|
+
os: [ubuntu-latest, windows-latest]
|
|
14
|
+
python: ["3.11", "3.12"]
|
|
15
|
+
runs-on: ${{ matrix.os }}
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python }}
|
|
21
|
+
- run: pip install -e ".[dev]"
|
|
22
|
+
- run: ruff check .
|
|
23
|
+
- run: ruff format --check .
|
|
24
|
+
- run: mypy
|
|
25
|
+
- run: pytest -q
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: breakscope
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: See what your API changes will break in your code.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: pydantic>=2
|
|
8
|
+
Requires-Dist: pyyaml>=6
|
|
9
|
+
Requires-Dist: rich>=13
|
|
10
|
+
Requires-Dist: typer>=0.12
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
14
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
15
|
+
Requires-Dist: syrupy>=4; extra == 'dev'
|
|
16
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# BreakScope
|
|
20
|
+
|
|
21
|
+
> **See what your API changes will break — before they reach production.**
|
|
22
|
+
|
|
23
|
+
API contract changes are easy to detect. Knowing what they break isn't.
|
|
24
|
+
BreakScope diffs your OpenAPI specs and traces each breaking change to the exact `file:line` in your Python and TypeScript code that's likely affected.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install breakscope
|
|
28
|
+
breakscope diff openapi-old.yaml openapi-new.yaml
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Status:** pre-alpha. See [docs/ROADMAP.md](docs/ROADMAP.md) and [docs/ARCHITECTURE-v0.1.md](docs/ARCHITECTURE-v0.1.md).
|
|
32
|
+
|
|
33
|
+
## License
|
|
34
|
+
|
|
35
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# BreakScope
|
|
2
|
+
|
|
3
|
+
> **See what your API changes will break — before they reach production.**
|
|
4
|
+
|
|
5
|
+
API contract changes are easy to detect. Knowing what they break isn't.
|
|
6
|
+
BreakScope diffs your OpenAPI specs and traces each breaking change to the exact `file:line` in your Python and TypeScript code that's likely affected.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install breakscope
|
|
10
|
+
breakscope diff openapi-old.yaml openapi-new.yaml
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**Status:** pre-alpha. See [docs/ROADMAP.md](docs/ROADMAP.md) and [docs/ARCHITECTURE-v0.1.md](docs/ARCHITECTURE-v0.1.md).
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
MIT
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from breakscope import __version__
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(
|
|
9
|
+
name="breakscope",
|
|
10
|
+
help="See what your API changes will break in your code.",
|
|
11
|
+
no_args_is_help=True,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _version(value: bool) -> None:
|
|
16
|
+
if value:
|
|
17
|
+
typer.echo(f"breakscope {__version__}")
|
|
18
|
+
raise typer.Exit()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.callback()
|
|
22
|
+
def main(
|
|
23
|
+
version: Annotated[
|
|
24
|
+
bool, typer.Option("--version", callback=_version, is_eager=True, help="Show version.")
|
|
25
|
+
] = False,
|
|
26
|
+
) -> None:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def diff(
|
|
32
|
+
old: Annotated[Path, typer.Argument(exists=True, dir_okay=False, help="Old OpenAPI spec.")],
|
|
33
|
+
new: Annotated[Path, typer.Argument(exists=True, dir_okay=False, help="New OpenAPI spec.")],
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Show contract changes between two OpenAPI specs."""
|
|
36
|
+
typer.echo("diff: coming in v0.1", err=True)
|
|
37
|
+
raise typer.Exit(2)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command()
|
|
41
|
+
def analyze() -> None:
|
|
42
|
+
"""Trace API changes into your codebase."""
|
|
43
|
+
typer.echo("analyze: coming in v0.4", err=True)
|
|
44
|
+
raise typer.Exit(2)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# v0.1 Architecture — Contract Diff
|
|
2
|
+
|
|
3
|
+
v0.1 ships `breakscope diff` only. Its job is to fix the **internal change model** that code analysis (v0.2 onward) will consume. If this model is right, the analyzers never need to touch OpenAPI.
|
|
4
|
+
|
|
5
|
+
## Pipeline
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
old.yaml ─┐
|
|
9
|
+
├─► loader ─► resolver ($ref) ─► normalizer ─► Contract ─┐
|
|
10
|
+
new.yaml ─┘ ├─► differ ─► list[APIChange] ─► reporter
|
|
11
|
+
Contract┘
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Each stage is a pure function with no I/O except in the loader, so every stage can be tested on its own.
|
|
15
|
+
|
|
16
|
+
## Package layout (v0.1 only)
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
breakscope/
|
|
20
|
+
├── __init__.py # __version__
|
|
21
|
+
├── __main__.py # python -m breakscope
|
|
22
|
+
├── cli/
|
|
23
|
+
│ └── main.py # Typer app: diff (analyze/check are stubs that exit 2 with "coming in v0.4")
|
|
24
|
+
├── contracts/
|
|
25
|
+
│ ├── loader.py # read file / git ref → dict; detect openapi version
|
|
26
|
+
│ ├── resolver.py # $ref resolution, cycle-safe, records origin component name
|
|
27
|
+
│ ├── normalize.py # dict → Contract (3.0 + 3.1 → one model)
|
|
28
|
+
│ ├── model.py # Contract, Operation, Parameter, Schema
|
|
29
|
+
│ ├── diff.py # Contract × Contract → list[APIChange]
|
|
30
|
+
│ └── rules.py # rule catalog: id, default severity, description
|
|
31
|
+
├── changes.py # APIChange, Severity, ChangeKind — the shared contract for everything downstream
|
|
32
|
+
├── reports/
|
|
33
|
+
│ ├── terminal.py # rich
|
|
34
|
+
│ └── json.py
|
|
35
|
+
└── errors.py # SpecLoadError, RefError, UnsupportedVersionError (human messages + hints)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`changes.py` sits at the top level on purpose. Analyzers and the impact engine depend on it, not on `contracts/`.
|
|
39
|
+
|
|
40
|
+
## Core models
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
# changes.py
|
|
44
|
+
class Severity(StrEnum):
|
|
45
|
+
BREAKING = "breaking"
|
|
46
|
+
WARNING = "warning" # e.g. enum value added to response (may break exhaustive switches)
|
|
47
|
+
INFO = "info" # non-breaking additions
|
|
48
|
+
|
|
49
|
+
class Direction(StrEnum):
|
|
50
|
+
REQUEST = "request" # client sends it; tightening breaks clients
|
|
51
|
+
RESPONSE = "response" # client reads it; loosening/removal breaks clients
|
|
52
|
+
|
|
53
|
+
class APIChange(BaseModel, frozen=True):
|
|
54
|
+
rule: str # "response.property.removed"
|
|
55
|
+
severity: Severity
|
|
56
|
+
method: str | None # "GET"; None for global/schema-only changes
|
|
57
|
+
path: str | None # "/users/{id}"
|
|
58
|
+
direction: Direction | None
|
|
59
|
+
status_code: str | None # "200", "default"
|
|
60
|
+
field_path: tuple[str, ...] # ("items", "[]", "name"); "[]" = array element
|
|
61
|
+
schema_name: str | None # "User" if the field came from components/schemas/User
|
|
62
|
+
old: Any | None
|
|
63
|
+
new: Any | None
|
|
64
|
+
message: str # human-readable one-liner
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def key(self) -> str: ... # stable id for snapshots/dedupe/ignore-lists
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def group_key(self) -> tuple[str, str | None, tuple[str, ...]]: ... # (rule, schema_name, field_path)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`field_path` and `schema_name` are the two fields the impact engine will match code against (`user.name` ↔ `("name",)`, `User`). The resolver must keep `schema_name` when it inlines a `$ref`.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# contracts/model.py
|
|
77
|
+
class Schema(BaseModel):
|
|
78
|
+
types: frozenset[str] # {"string"}, {"string","null"}; nullable folded in
|
|
79
|
+
properties: dict[str, "Schema"]
|
|
80
|
+
required: frozenset[str]
|
|
81
|
+
items: "Schema | None"
|
|
82
|
+
enum: tuple[Any, ...] | None
|
|
83
|
+
format: str | None
|
|
84
|
+
union: tuple["Schema", ...] | None # oneOf/anyOf, compared opaquely in v0.1
|
|
85
|
+
ref_name: str | None
|
|
86
|
+
additional_properties: "bool | Schema"
|
|
87
|
+
|
|
88
|
+
class Parameter(BaseModel):
|
|
89
|
+
name: str; location: Literal["path","query","header","cookie"]
|
|
90
|
+
required: bool; schema: Schema
|
|
91
|
+
|
|
92
|
+
class Operation(BaseModel):
|
|
93
|
+
method: str; path: str; operation_id: str | None
|
|
94
|
+
parameters: dict[tuple[str, str], Parameter] # (location, name)
|
|
95
|
+
request_body: dict[str, Schema] # media type → schema
|
|
96
|
+
request_body_required: bool
|
|
97
|
+
responses: dict[str, dict[str, Schema]] # status → media type → schema
|
|
98
|
+
|
|
99
|
+
class Contract(BaseModel):
|
|
100
|
+
version: str
|
|
101
|
+
operations: dict[tuple[str, str], Operation] # (METHOD, normalized_path)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Path normalization:** `/users/{id}` and `/users/{userId}` are the same endpoint. The key replaces parameter names with `{}`, and the original path is kept for display.
|
|
105
|
+
|
|
106
|
+
## Direction-aware rule catalog (v0.1)
|
|
107
|
+
|
|
108
|
+
Breaking-ness depends on direction. Making a *response* field optional breaks readers. Making a *request* field required breaks writers.
|
|
109
|
+
|
|
110
|
+
| Rule | Severity |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `endpoint.removed` | breaking |
|
|
113
|
+
| `endpoint.added` | info |
|
|
114
|
+
| `parameter.removed` (path/query/header) | breaking |
|
|
115
|
+
| `parameter.added.required` | breaking |
|
|
116
|
+
| `parameter.added.optional` | info |
|
|
117
|
+
| `parameter.became_required` | breaking |
|
|
118
|
+
| `parameter.type.changed` | breaking |
|
|
119
|
+
| `parameter.enum.value_removed` | breaking |
|
|
120
|
+
| `request.body.added.required` | breaking |
|
|
121
|
+
| `request.property.added.required` | breaking |
|
|
122
|
+
| `request.property.became_required` | breaking |
|
|
123
|
+
| `request.property.type.changed` | breaking |
|
|
124
|
+
| `request.property.enum.value_removed` | breaking |
|
|
125
|
+
| `request.property.removed` | warning (server may now reject or ignore it) |
|
|
126
|
+
| `response.status.removed` (2xx) | breaking |
|
|
127
|
+
| `response.media_type.removed` | breaking |
|
|
128
|
+
| `response.property.removed` | breaking |
|
|
129
|
+
| `response.property.became_optional` | warning |
|
|
130
|
+
| `response.property.became_nullable` | breaking |
|
|
131
|
+
| `response.property.type.changed` | breaking |
|
|
132
|
+
| `response.property.format.changed` | warning |
|
|
133
|
+
| `response.enum.value_added` | warning |
|
|
134
|
+
| `response.enum.value_removed` | info |
|
|
135
|
+
| `response.property.added` | info |
|
|
136
|
+
| `schema.union.changed` | warning (opaque in v0.1) |
|
|
137
|
+
|
|
138
|
+
The differ recurses through `properties` and `items` and builds up `field_path`. Each rule is a small function `(old, new, ctx) -> Iterable[APIChange]` registered in `rules.py`, so adding a rule never touches the walker.
|
|
139
|
+
|
|
140
|
+
## Key decisions
|
|
141
|
+
|
|
142
|
+
| Decision | Choice | Why |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| Spec parsing | PyYAML (`CSafeLoader`) + own resolver | We need `ref_name` provenance, which most resolvers throw away. `openapi-spec-validator` is only an optional `--validate` step. |
|
|
145
|
+
| Models | Pydantic v2, frozen | Free JSON serialization for the JSON reporter, and hashable keys |
|
|
146
|
+
| Recursive schemas | Resolver keeps a visited set and emits a `RecursiveRef(name)` sentinel. The differ compares sentinels by name. | Avoids infinite recursion on `Node.children: [Node]` |
|
|
147
|
+
| Determinism | Changes are sorted by `(path, method, direction, field_path, rule)` | Stable snapshots and stable PR comments |
|
|
148
|
+
| Errors | Each error has a message, a file and JSON pointer, and a hint | Developer tools live or die by their error messages |
|
|
149
|
+
| Partial failure | One broken operation logs a warning and is skipped; the diff of the rest continues | Real specs are messy |
|
|
150
|
+
| 3.2 | `UnsupportedVersionError`, with a hint that it is planned | Explicit beats silently wrong |
|
|
151
|
+
|
|
152
|
+
## Shared schemas: one change, many endpoints
|
|
153
|
+
|
|
154
|
+
Removing `User.name` when `User` is used by 8 operations must not print 8 unrelated changes. The differ still emits one `APIChange` per operation, because the impact engine needs the `(method, path)` of each one. Reporters then group changes by `(rule, schema_name, field_path)`:
|
|
155
|
+
|
|
156
|
+
```text
|
|
157
|
+
🔴 response.property.removed User.name
|
|
158
|
+
affects 8 operations: GET /users/{id}, GET /users, GET /teams/{id}/members, …
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`APIChange.group_key` makes this grouping explicit. JSON output contains both the flat list and the groups.
|
|
162
|
+
|
|
163
|
+
## CLI surface (v0.1)
|
|
164
|
+
|
|
165
|
+
```text
|
|
166
|
+
breakscope diff OLD NEW [--format terminal|json] [--min-severity breaking|warning|info] [--output FILE]
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- `OLD`/`NEW` accept paths now. `git:<ref>:<path>` comes in v0.5.
|
|
170
|
+
- Exit codes: `0` no breaking changes, `1` breaking changes present, `2` error.
|
|
171
|
+
|
|
172
|
+
## Testing strategy
|
|
173
|
+
|
|
174
|
+
- `tests/contracts/rules/<rule_id>/{old,new}.yaml` holds one fixture per rule, auto-discovered by a parametrized test and snapshot-asserted
|
|
175
|
+
- `tests/contracts/test_normalize.py` checks that equivalent 3.0 and 3.1 forms normalize to equal `Schema`s
|
|
176
|
+
- `tests/contracts/corpus/` holds real-world specs, used only to assert "no crash, deterministic output"
|
|
177
|
+
- Target ≥90% coverage on `contracts/` and `changes.py`
|
|
178
|
+
|
|
179
|
+
## Dependencies
|
|
180
|
+
|
|
181
|
+
Runtime: `typer`, `rich`, `pydantic>=2`, `pyyaml`.
|
|
182
|
+
Dev: `pytest`, `syrupy`, `ruff`, `mypy`.
|
|
183
|
+
`tree-sitter` is **not** a v0.1 dependency.
|
|
184
|
+
|
|
185
|
+
## What v0.1 explicitly does not do
|
|
186
|
+
|
|
187
|
+
Code scanning, git refs, config files, remote `$ref`, deep `oneOf`/`anyOf` comparison, OpenAPI 3.2, Swagger 2.0.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# BreakScope — Implementation Roadmap
|
|
2
|
+
|
|
3
|
+
> Find the code that breaks before your API change reaches production.
|
|
4
|
+
|
|
5
|
+
The diff engine is a prerequisite. What sets this tool apart is tracing each change to `file:line` with an honest confidence level. Every milestone below either builds toward that or ships it.
|
|
6
|
+
|
|
7
|
+
Each milestone ends with a **demo gate**: one command, one fixture repo, and one expected output that is checked in as a snapshot test. A milestone is not done until its gate passes in CI.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## M0 — Skeleton (2–3 days)
|
|
12
|
+
|
|
13
|
+
**Goal:** a package that installs and runs, with CI in place.
|
|
14
|
+
|
|
15
|
+
- `pyproject.toml` (hatchling), Python 3.11+, package `breakscope`, entry point `breakscope`
|
|
16
|
+
- Typer CLI with `--version` and stub `diff` / `analyze` commands
|
|
17
|
+
- Tooling: ruff, mypy (strict, whole package), pytest, syrupy snapshots
|
|
18
|
+
- GitHub Actions: lint, type-check and tests on 3.11 and 3.12, Linux and Windows
|
|
19
|
+
- Name: **BreakScope** — PyPI `breakscope`, CLI `breakscope`, GitHub `breakscope` (PyPI name looked free in a search on 2026-10-06; the GitHub name is still unchecked. Register both before the first publish.)
|
|
20
|
+
|
|
21
|
+
**Gate:** `pipx install .` then `breakscope --version` works on Windows and Linux.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## M1 — Contract diff, v0.1 (2–3 weeks)
|
|
26
|
+
|
|
27
|
+
**Goal:** `breakscope diff old.yaml new.yaml` gives correct, stable output for OpenAPI 3.0 and 3.1.
|
|
28
|
+
|
|
29
|
+
1. **Loader:** YAML/JSON, local `$ref` resolution (in-file and relative files), with cycle detection. Remote `$ref` is off by default.
|
|
30
|
+
2. **Normalizer:** turn the spec into a flat, version-agnostic `Contract` model. Merge `nullable` (3.0) and `type: [x, "null"]` (3.1). Flatten `allOf`. Keep `oneOf`/`anyOf` as opaque unions for now.
|
|
31
|
+
3. **Differ:** walk the operations and schemas and produce `APIChange` records (see the rule catalog in [ARCHITECTURE-v0.1.md](ARCHITECTURE-v0.1.md)).
|
|
32
|
+
4. **Field paths:** every change carries a `field_path` such as `["name"]` or `["items", "[]", "status"]`. This path is the bridge to code analysis later, so get it right now.
|
|
33
|
+
5. **Reporters:** terminal (rich) and JSON. The JSON schema is versioned (`"schema_version": 1`).
|
|
34
|
+
6. **Exit codes:** `0` no breaking changes, `1` breaking changes found, `2` usage or parse error.
|
|
35
|
+
7. **Tests:** at least 25 rule fixtures, each a minimal `old.yaml`/`new.yaml` pair with a snapshot. Also test against 3 real-world specs (for example the Petstore, GitHub and Stripe subsets) to catch crashes.
|
|
36
|
+
|
|
37
|
+
**Gate:** `breakscope diff examples/demo/api/openapi-v1.yaml examples/demo/api/openapi-v2.yaml` reports `response.property.removed GET /users/{id} name`.
|
|
38
|
+
|
|
39
|
+
**Ship v0.1.0 to PyPI.**
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## M2 — Usage extraction, v0.2 (Python) + v0.3 (TS/JS) (3–4 weeks)
|
|
44
|
+
|
|
45
|
+
**Goal:** find every API call site in a repo and say which endpoint it targets.
|
|
46
|
+
|
|
47
|
+
- tree-sitter via `tree-sitter-language-pack` (prebuilt wheels, so no compiler is needed on Windows)
|
|
48
|
+
- `Analyzer` protocol: `scan(file) -> list[CallSite]`
|
|
49
|
+
- **TypeScript/JavaScript:** `fetch`, `axios.<verb>`, `axios({method, url})`, `<ident>.get|post|put|patch|delete(...)`. Handle template literals (`` `/users/${id}` ``) and string concatenation with a constant prefix.
|
|
50
|
+
- **Python:** `requests.*`, `httpx.*` (sync and async client), `session.*`, `client.*`
|
|
51
|
+
- **URL matcher:** turn literals and templates into path patterns and match them against the contract's path templates (`/users/${id}` → `/users/{id}`). Strip a configurable `base_url`.
|
|
52
|
+
- Command: `breakscope usages` lists endpoint → call sites. This is useful on its own and is how M2 gets dogfooded.
|
|
53
|
+
|
|
54
|
+
**Gate:** in `examples/demo`, `usages` finds both the TSX and the Python call sites of `GET /users/{id}`.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## M3 — Impact resolver, v0.4 (3–4 weeks) ← the product
|
|
59
|
+
|
|
60
|
+
**Goal:** `breakscope analyze` connects changes to `file:line` with a confidence level.
|
|
61
|
+
|
|
62
|
+
- **Intra-function data flow:** follow the response through `await`, `.data`, `.json()`, destructuring, and reassignment, then collect property accesses (`x.name`, `x["name"]`, `{ name } = x`, `x.get("name")`).
|
|
63
|
+
- **Confidence tiers:**
|
|
64
|
+
- **HIGH:** the call site's URL matches the endpoint *and* the access is reached through data flow from that call
|
|
65
|
+
- **MEDIUM:** the access is on a variable typed or named after the schema (`User`, `user`), or comes through a single hop across a function return
|
|
66
|
+
- **LOW:** the property name and the schema name both appear in the file with no traced flow
|
|
67
|
+
- Rule-based scoring (`severity × confidence`) that maps to a `risk` value of high, medium or low
|
|
68
|
+
- Tests are labelled separately (`tests/`, `*.test.ts`, `test_*.py`), downranked, but still shown
|
|
69
|
+
- Measure precision on the fixture corpus. Record a precision and recall table in `docs/accuracy.md` and **track it as a CI metric.**
|
|
70
|
+
|
|
71
|
+
**Gate:** the demo from the pitch, `frontend/UserProfile.tsx:6 {user.name}` reported HIGH, comes out exactly as a snapshot.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## M4 — CI and GitHub Action, v0.5 → v1.0 (1–2 weeks)
|
|
76
|
+
|
|
77
|
+
- `breakscope check --base origin/main`: reads the old spec through `git show <ref>:<path>`
|
|
78
|
+
- `.breakscope.yml` config and `breakscope init`
|
|
79
|
+
- Markdown reporter plus a composite GitHub Action that posts or updates a single PR comment
|
|
80
|
+
- `--fail-on high|medium|low|never`
|
|
81
|
+
- SARIF output, so results show up in GitHub code scanning (this costs little and adds a lot)
|
|
82
|
+
|
|
83
|
+
**Ship v1.0:** README with a GIF, `examples/` (fastapi, express, react, mixed), and the Action on the Marketplace.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## M5 — Generated clients and migrations (after 1.0)
|
|
88
|
+
|
|
89
|
+
- Generated-client mapping: parse `openapi-generator` and `openapi-typescript-codegen` output to map `UsersApi.getUserById` → `GET /users/{id}`
|
|
90
|
+
- Rename detection: a removed property plus an added property of the same type and a similar name gives a `likely_renamed_to` hint
|
|
91
|
+
- `breakscope fix --dry-run` writes a unified diff only. It never edits files.
|
|
92
|
+
- `breakscope explain <METHOD path>`
|
|
93
|
+
|
|
94
|
+
## Deliberately out of scope until after 1.0
|
|
95
|
+
|
|
96
|
+
Dashboard, SaaS, accounts, GraphQL/gRPC/AsyncAPI, languages beyond Python/TS/JS, IDE extension, cross-file inter-procedural analysis beyond one hop.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Risks to watch
|
|
101
|
+
|
|
102
|
+
| Risk | Mitigation |
|
|
103
|
+
|---|---|
|
|
104
|
+
| False positives destroy trust | Confidence tiers. LOW is hidden by default. Track precision in CI. |
|
|
105
|
+
| Custom HTTP wrappers hide URLs | Config `clients: [{ name: "api", base_url: "/v1" }]`, then generated-client support |
|
|
106
|
+
| `$ref`/`allOf` edge cases crash the diff | Real-world spec corpus in tests. Fail per-operation, not globally. |
|
|
107
|
+
| Scope creep into a better diff tool | Diff rule catalog is frozen at about 25 rules for 1.0. Effort goes into the resolver. |
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "breakscope"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "See what your API changes will break in your code."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = ["typer>=0.12", "rich>=13", "pydantic>=2", "pyyaml>=6"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["pytest>=8", "syrupy>=4", "ruff>=0.6", "mypy>=1.11", "types-PyYAML"]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
breakscope = "breakscope.cli.main:app"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.version]
|
|
21
|
+
path = "breakscope/__init__.py"
|
|
22
|
+
|
|
23
|
+
[tool.ruff]
|
|
24
|
+
line-length = 100
|
|
25
|
+
target-version = "py311"
|
|
26
|
+
extend-exclude = ["docs"]
|
|
27
|
+
|
|
28
|
+
[tool.ruff.lint]
|
|
29
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
30
|
+
|
|
31
|
+
[tool.mypy]
|
|
32
|
+
python_version = "3.11"
|
|
33
|
+
strict = true
|
|
34
|
+
packages = ["breakscope"]
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from typer.testing import CliRunner
|
|
2
|
+
|
|
3
|
+
from breakscope import __version__
|
|
4
|
+
from breakscope.cli.main import app
|
|
5
|
+
|
|
6
|
+
runner = CliRunner()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_version() -> None:
|
|
10
|
+
result = runner.invoke(app, ["--version"])
|
|
11
|
+
assert result.exit_code == 0
|
|
12
|
+
assert __version__ in result.output
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_analyze_stub_exits_2() -> None:
|
|
16
|
+
assert runner.invoke(app, ["analyze"]).exit_code == 2
|