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.
@@ -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,8 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .venv/
4
+ dist/
5
+ build/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ .pytest_cache/
@@ -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
@@ -0,0 +1,3 @@
1
+ """BreakScope: see what your API changes will break in your code."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,3 @@
1
+ from breakscope.cli.main import app
2
+
3
+ app()
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"]
@@ -0,0 +1,9 @@
1
+ PyPI recovery codes
2
+ e1df80f727ecd59c
3
+ 9fe858e62d69b605
4
+ 9fb2c174950c9285
5
+ 95a927d08905c0af
6
+ 875444e0a46ceae5
7
+ 85524ca88f5a0c4d
8
+ 7b41ad842f0d1268
9
+ 5b42057cbe24c039
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