scopia 1.0.0__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.
- scopia-1.0.0/.gitignore +10 -0
- scopia-1.0.0/CHANGELOG.md +37 -0
- scopia-1.0.0/CONTRIBUTING.md +72 -0
- scopia-1.0.0/LICENSE +21 -0
- scopia-1.0.0/PKG-INFO +378 -0
- scopia-1.0.0/README.md +344 -0
- scopia-1.0.0/pyproject.toml +73 -0
- scopia-1.0.0/scopia/__init__.py +3 -0
- scopia-1.0.0/scopia/cli.py +363 -0
- scopia-1.0.0/scopia/graph/__init__.py +0 -0
- scopia-1.0.0/scopia/graph/build.py +748 -0
- scopia-1.0.0/scopia/graph/model.py +172 -0
- scopia-1.0.0/scopia/index/__init__.py +0 -0
- scopia-1.0.0/scopia/index/repo.py +232 -0
- scopia-1.0.0/scopia/index/store.py +70 -0
- scopia-1.0.0/scopia/lang/__init__.py +69 -0
- scopia-1.0.0/scopia/lang/_ts.py +71 -0
- scopia-1.0.0/scopia/lang/base.py +110 -0
- scopia-1.0.0/scopia/lang/java.py +290 -0
- scopia-1.0.0/scopia/lang/php.py +244 -0
- scopia-1.0.0/scopia/lang/python.py +280 -0
- scopia-1.0.0/scopia/lang/react.py +590 -0
- scopia-1.0.0/scopia/lsp/__init__.py +6 -0
- scopia-1.0.0/scopia/lsp/client.py +218 -0
- scopia-1.0.0/scopia/lsp/resolve.py +310 -0
- scopia-1.0.0/scopia/lsp/session.py +193 -0
- scopia-1.0.0/scopia/render/__init__.py +0 -0
- scopia-1.0.0/scopia/render/html.py +1000 -0
- scopia-1.0.0/scopia/render/layout.py +190 -0
- scopia-1.0.0/scopia/render/mermaid.py +66 -0
- scopia-1.0.0/scopia/render/text.py +313 -0
- scopia-1.0.0/scopia/vcs/__init__.py +0 -0
- scopia-1.0.0/scopia/vcs/diff.py +64 -0
- scopia-1.0.0/scopia/vcs/git.py +223 -0
- scopia-1.0.0/scopia/watch/__init__.py +1 -0
- scopia-1.0.0/scopia/watch/frames.py +79 -0
- scopia-1.0.0/scopia/watch/fresh.py +46 -0
- scopia-1.0.0/scopia/watch/live.py +62 -0
- scopia-1.0.0/scopia/watch/loop.py +136 -0
- scopia-1.0.0/scopia/watch/server.py +83 -0
- scopia-1.0.0/scopia/watch/terminal.py +82 -0
- scopia-1.0.0/tests/__init__.py +0 -0
- scopia-1.0.0/tests/conftest.py +236 -0
- scopia-1.0.0/tests/fake_lsp_server.py +80 -0
- scopia-1.0.0/tests/fixtures/__init__.py +0 -0
- scopia-1.0.0/tests/fixtures/php_repo.py +104 -0
- scopia-1.0.0/tests/fixtures/py_repo.py +165 -0
- scopia-1.0.0/tests/fixtures/react_repo.py +279 -0
- scopia-1.0.0/tests/test_cli.py +167 -0
- scopia-1.0.0/tests/test_graph.py +920 -0
- scopia-1.0.0/tests/test_lsp.py +269 -0
- scopia-1.0.0/tests/test_scale.py +105 -0
- scopia-1.0.0/tests/test_watch.py +338 -0
scopia-1.0.0/.gitignore
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 — 2026-10-02
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- Diff → touched-symbol graph for Python, PHP and React
|
|
8
|
+
- One-line changes are first-class: the unit of analysis is the enclosing symbol, and
|
|
9
|
+
nodes are weighted by fan-in rather than diff size
|
|
10
|
+
- Every edge carries provenance; a call through an unknown receiver is never presented
|
|
11
|
+
as certain, and uncertain edges state why
|
|
12
|
+
- PHP resolves the class a call site names — `Service::class`, `new Service()`,
|
|
13
|
+
`Service::method()`, typed properties, and inherited methods
|
|
14
|
+
- Python resolves aliased imports (`from x import run as v3`)
|
|
15
|
+
- React reads `.jsx`, `.tsx`, `.js` and `.ts` as one language: `<Button />` is a call
|
|
16
|
+
edge, `const`/`memo`/`forwardRef`/`useCallback` components are definitions, a default
|
|
17
|
+
import under another local name still resolves, and a declared TypeScript type
|
|
18
|
+
narrows a call to the class it names
|
|
19
|
+
- Untracked files are reviewed; `git diff` cannot see them
|
|
20
|
+
- Files in unsupported languages are listed rather than silently omitted
|
|
21
|
+
- Repo index cached per git blob SHA inside `.git/`, so warm runs re-parse nothing
|
|
22
|
+
- `--utility` hides widely-called plumbing; `--exclude` skips paths
|
|
23
|
+
- Text, mermaid, JSON, and a self-contained clickable HTML report
|
|
24
|
+
- A gate that declines to draw a graph when the diff is too small to need one
|
|
25
|
+
- `--watch`: redraws as files settle (debounced, polled from `git status` with no
|
|
26
|
+
file-watching dependency), holds the last good graph while a file does not parse,
|
|
27
|
+
skips redraws that change nothing, and marks what arrived since the last redraw
|
|
28
|
+
- `--watch --html`: a live page served on 127.0.0.1 that patches itself in place,
|
|
29
|
+
keeping the selected node and scroll position across redraws
|
|
30
|
+
- Java: `.java`, parsed through tree-sitter with no JDK; receivers resolve through
|
|
31
|
+
declared parameter, field and local types
|
|
32
|
+
- `--lsp`: optional language-server resolution (Python via `jedi-language-server`).
|
|
33
|
+
Confirmed edges are marked `resolved`; a definition outside the repo drops the
|
|
34
|
+
name-matched edges. Never required, never an error when absent
|
|
35
|
+
- `--to-entry`: climbs callers until each chain reaches something nothing in the repo
|
|
36
|
+
calls — the route, job or command that triggers the changed code
|
|
37
|
+
- Fixed: a long call chain in a large uncapped graph exhausted the recursion limit
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
## Adding a language
|
|
4
|
+
|
|
5
|
+
This is the contribution that matters most, and it is deliberately small: **one file**.
|
|
6
|
+
|
|
7
|
+
Everything outside `scopia/lang/` is language-neutral. The index, the graph builder
|
|
8
|
+
and all four renderers only ever see `FileFacts`, so a new language never requires a
|
|
9
|
+
change above the seam.
|
|
10
|
+
|
|
11
|
+
An adapter implements one method:
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
class MyLangAdapter:
|
|
15
|
+
name = "mylang"
|
|
16
|
+
extensions = (".ml",)
|
|
17
|
+
|
|
18
|
+
def extract(self, source: bytes) -> FileFacts:
|
|
19
|
+
...
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`FileFacts` holds three things, all defined in `scopia/lang/base.py`:
|
|
23
|
+
|
|
24
|
+
| Field | What it is |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `defs` | every named definition, with its line range and a path-independent `qualname` |
|
|
27
|
+
| `calls` | every call site, recorded by the **name** being called |
|
|
28
|
+
| `bases` | classes naming a base class or implemented interface |
|
|
29
|
+
|
|
30
|
+
Two rules matter:
|
|
31
|
+
|
|
32
|
+
1. **Record calls by name, don't resolve them.** Resolution happens in the index, where
|
|
33
|
+
the whole repo is visible. An adapter that guesses which definition a call refers to
|
|
34
|
+
is doing the wrong job in the wrong place.
|
|
35
|
+
2. **Keep `qualname` path-independent** (`Class.method`, not `src/a.py::Class.method`).
|
|
36
|
+
The fact cache is keyed by git blob SHA, so identical content anywhere in the repo
|
|
37
|
+
reuses the same entry.
|
|
38
|
+
|
|
39
|
+
Then register it in `scopia/lang/__init__.py::_registry`, and add a fixture repo under
|
|
40
|
+
`tests/fixtures/` covering the four shapes (`py_repo.py` is the reference).
|
|
41
|
+
|
|
42
|
+
A language that needs more than one grammar registers one instance per grammar and
|
|
43
|
+
gives them all the same `name` — `react.py` does this for `.js`, `.ts` and `.tsx`.
|
|
44
|
+
`name` is what stops a call crossing a language boundary, so two grammars parsing one
|
|
45
|
+
language must agree on it, or every import between them is dropped as a false edge.
|
|
46
|
+
|
|
47
|
+
Give the adapter a `grammar` attribute naming its tree-sitter grammar. Watch mode uses it
|
|
48
|
+
to tell a finished file from a half-written one: tree-sitter never raises on bad input, so
|
|
49
|
+
without it a syntax error looks identical to "no symbols".
|
|
50
|
+
|
|
51
|
+
Grammars should ship pre-built abi3 wheels. A dependency that needs a C toolchain at
|
|
52
|
+
install time breaks the "download and run" promise for exactly the people this tool is
|
|
53
|
+
for.
|
|
54
|
+
|
|
55
|
+
## Running things
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
59
|
+
.venv/bin/python -m pytest tests/ -q
|
|
60
|
+
.venv/bin/ruff check .
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## What this tool is not
|
|
64
|
+
|
|
65
|
+
Pull requests that turn scopia into a linter, a bug finder, or a code-quality scorer
|
|
66
|
+
will be declined. It maps what changed so a human reads less; it does not pass judgement
|
|
67
|
+
on the code. Output wording follows from that — "differs from the other five", never
|
|
68
|
+
"flagged" or "suspicious".
|
|
69
|
+
|
|
70
|
+
Similarly, an edge whose target is uncertain must fan out to every candidate and say so.
|
|
71
|
+
Picking the likeliest one produces a graph that looks confident and is sometimes wrong,
|
|
72
|
+
which is worse than one that is visibly unsure.
|
scopia-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 diffgraph contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
scopia-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: scopia
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: See the shape of a code change before reading its lines.
|
|
5
|
+
Project-URL: Homepage, https://github.com/printSamarth/scopia
|
|
6
|
+
Project-URL: Issues, https://github.com/printSamarth/scopia/issues
|
|
7
|
+
Author: Samarth Patel
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: call-graph,code-review,diff,git,java,react,static-analysis
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: tree-sitter-java>=0.23
|
|
23
|
+
Requires-Dist: tree-sitter-javascript>=0.23
|
|
24
|
+
Requires-Dist: tree-sitter-php>=0.23
|
|
25
|
+
Requires-Dist: tree-sitter-python>=0.23
|
|
26
|
+
Requires-Dist: tree-sitter-typescript>=0.23
|
|
27
|
+
Requires-Dist: tree-sitter<0.27,>=0.23
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
31
|
+
Provides-Extra: lsp
|
|
32
|
+
Requires-Dist: jedi-language-server>=0.40; extra == 'lsp'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# scopia
|
|
36
|
+
|
|
37
|
+
**σκοπιά — the watchtower. See what your AI is building, while it builds it.**
|
|
38
|
+
|
|
39
|
+
<p align="center">
|
|
40
|
+
<img src="https://raw.githubusercontent.com/printSamarth/scopia/main/docs/watch.gif" alt="scopia --watch --html drawing a call graph live as an AI agent writes code" width="900">
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
AI agents write code at a speed and volume no reviewer can match. By the time the agent
|
|
44
|
+
says *done*, you are staring at a twenty-file diff in alphabetical order, with no idea
|
|
45
|
+
which piece matters or how the pieces connect. The hard part of review is no longer
|
|
46
|
+
reading the code — it is getting **the lay of the land**.
|
|
47
|
+
|
|
48
|
+
`scopia` is a visualisation layer for code changes. It draws **one graph** — what
|
|
49
|
+
changed, what calls it, and what it calls — and it does so two ways:
|
|
50
|
+
|
|
51
|
+
- **Live** — watch the graph grow in real time *as the agent writes*, so you already
|
|
52
|
+
understand the change before it finishes.
|
|
53
|
+
- **Static** — point it at any commit, branch or range and get the same graph for code
|
|
54
|
+
that was already committed.
|
|
55
|
+
|
|
56
|
+
No config, no init step, no language servers required. Just git and Python.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Quick start
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install scopia # or: uv tool install scopia / pipx install scopia
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Run these from anywhere inside a git repository:
|
|
67
|
+
|
|
68
|
+
| I want to… | Command |
|
|
69
|
+
|---|---|
|
|
70
|
+
| **Watch the graph build live** while an agent works | `scopia --watch --html` |
|
|
71
|
+
| Watch live in the terminal instead | `scopia --watch` |
|
|
72
|
+
| Review my uncommitted work | `scopia` |
|
|
73
|
+
| Analyse a past commit | `scopia <sha>~1..<sha>` |
|
|
74
|
+
| Review a branch (the pull-request case) | `scopia main..` |
|
|
75
|
+
| Analyse the last 5 commits | `scopia HEAD~5..HEAD` |
|
|
76
|
+
| Save a clickable report to share | `scopia main.. --html review.html` |
|
|
77
|
+
| Paste a diagram into a PR comment | `scopia --format mermaid` |
|
|
78
|
+
| Feed it to other tools | `scopia --format json` |
|
|
79
|
+
| Trace each change back to the route/job/command that triggers it | `scopia --to-entry` |
|
|
80
|
+
| Sharpen uncertain edges with a language server | `scopia --lsp` |
|
|
81
|
+
|
|
82
|
+
Requires **Python 3.10+** and **git**. Every dependency ships as a pre-built wheel, so no
|
|
83
|
+
compiler is needed. Check with `scopia --version`.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Live mode: review while the agent is still typing
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
scopia --watch --html
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This opens a live page in your browser. Start your agent in another window and watch the
|
|
94
|
+
graph fill in as files land: changed symbols in green, the code they touch around them,
|
|
95
|
+
new arrivals marked `● just now`.
|
|
96
|
+
|
|
97
|
+
- **The page patches itself in place.** The node you are reading stays selected and the
|
|
98
|
+
view does not jump when the agent saves.
|
|
99
|
+
- **Half-written code is held, not thrashed.** A file that does not parse yet keeps the
|
|
100
|
+
last good graph on screen, with a note.
|
|
101
|
+
- **No-op rewrites are ignored.** A formatter or a `touch` that leaves the graph
|
|
102
|
+
unchanged causes no redraw.
|
|
103
|
+
- **Private by default.** It is served on `127.0.0.1` only (it serves your source), on a
|
|
104
|
+
random port. `--no-open` prints the URL instead of opening a browser.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
scopia --watch # same idea, rendered in the terminal
|
|
108
|
+
scopia --watch --html r.html # live page, and keep r.html up to date too
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Ctrl-C leaves your terminal as it found it.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Static mode: understand a commit after the fact
|
|
116
|
+
|
|
117
|
+
The same graph works on history. Give it a commit and it shows what that commit changed
|
|
118
|
+
and what the change reaches — useful for reviewing a teammate's (or an agent's) PR, for
|
|
119
|
+
auditing something that already merged, or for onboarding onto unfamiliar code.
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
scopia HEAD~1..HEAD --html commit.html && open commit.html
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
<p align="center">
|
|
126
|
+
<img src="https://raw.githubusercontent.com/printSamarth/scopia/main/docs/commit-graph.png" alt="scopia HTML report for a 16-file commit adding refunds to a payments codebase" width="900">
|
|
127
|
+
</p>
|
|
128
|
+
|
|
129
|
+
A real 16-file commit adding refunds to a payments codebase. Left to right: seven provider
|
|
130
|
+
classes gained a `calculate_refund`, and a new checkout path runs four layers deep to
|
|
131
|
+
land on `util.clamp` — a **one-line edit to existing code that 13 places call**, drawn
|
|
132
|
+
with an amber border. Dashed boxes are unchanged context; the five files at the bottom are
|
|
133
|
+
changed but untraceable (`NO CONNECTIONS FOUND`). Click any node to focus its
|
|
134
|
+
neighbourhood; the rest recedes rather than disappearing. One self-contained file — no
|
|
135
|
+
CDN, no network, opens from disk years from now.
|
|
136
|
+
|
|
137
|
+
Pass `<sha>~1..<sha>` for "what this commit changed":
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
scopia 3036712be90~1..3036712be90
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`~1` has no meaning on a repo's first commit; use `scopia <sha>` there. Skip merge
|
|
144
|
+
commits — `~1` follows only the first parent and reports something misleading.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Reading the output
|
|
149
|
+
|
|
150
|
+
The same commit as plain text:
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
$ scopia HEAD~1..HEAD
|
|
154
|
+
|
|
155
|
+
16 symbols touched across 16 files (10 shown for context)
|
|
156
|
+
|
|
157
|
+
CALL CHAINS — indented → means the line above calls it
|
|
158
|
+
checkout_form.CheckoutForm.submit 1 line changed · existing code app/
|
|
159
|
+
└→ checkout_controller.CheckoutController.apply_promo_code 3 lines changed app/
|
|
160
|
+
└→ discount_service.DiscountService.validate 4 lines changed app/
|
|
161
|
+
└→ pricing_engine.PricingEngine.recalculate 3 lines changed app/
|
|
162
|
+
└→ util.clamp 1 line changed · called from 13 places · existing code app/
|
|
163
|
+
|
|
164
|
+
stripe.StripeProvider.calculate_refund 3 lines changed app/providers/
|
|
165
|
+
└→ stripe.StripeProvider.fees called from ~6 places · context app/providers/
|
|
166
|
+
└→ util.clamp 1 line changed · called from 13 places · existing code app/
|
|
167
|
+
┈ implements refundable.Refundable.calculate_refund
|
|
168
|
+
|
|
169
|
+
NO CONNECTIONS FOUND — scopia could not trace these; not a statement that they are safe
|
|
170
|
+
billing.py 1 line changed config/
|
|
171
|
+
inventory.py 1 line changed config/
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Three things a file list cannot tell you, all visible at a glance:
|
|
175
|
+
|
|
176
|
+
1. **A new checkout path runs four layers deep** and bottoms out in `util.clamp` — a
|
|
177
|
+
one-line edit to existing code that **13 places call**. The riskiest change in the
|
|
178
|
+
diff is one line long.
|
|
179
|
+
2. **Several providers implement the same new interface method**, grouped by their
|
|
180
|
+
`implements` edges — read the pattern once instead of seven times.
|
|
181
|
+
3. **`ApplepayProvider` never calls `clamp`**, unlike its siblings. scopia does not
|
|
182
|
+
claim that is wrong, only that it differs — which is where attention belongs.
|
|
183
|
+
|
|
184
|
+
| Section | What it means |
|
|
185
|
+
|---|---|
|
|
186
|
+
| `CALL CHAINS` | What changed and what it reaches. Indentation is a call: the line above calls the line below. Heaviest chain first. |
|
|
187
|
+
| `CHANGED, REFERENCED FROM` | Changed things that call nothing themselves, shown with who calls *them*. Small edits with wide reach land here. |
|
|
188
|
+
| `NO CONNECTIONS FOUND` | scopia could not trace these. **Not** a claim that they are safe. |
|
|
189
|
+
| `NOT ANALYSED` | Changed files in unsupported languages. The graph says nothing about them. |
|
|
190
|
+
| `NOT CERTAIN` | Edges matched by name that may be wrong, each with its reason. Leads, not facts. |
|
|
191
|
+
|
|
192
|
+
| Annotation | Meaning |
|
|
193
|
+
|---|---|
|
|
194
|
+
| `3 lines changed` | Lines changed inside that symbol |
|
|
195
|
+
| `new` | The file did not exist before |
|
|
196
|
+
| `existing code` | Edits something that was already there — other code may depend on it |
|
|
197
|
+
| `called from 12 places` | Repo-wide caller count |
|
|
198
|
+
| `~12` | Count is shared across several same-named definitions; approximate |
|
|
199
|
+
| `context` | Unchanged, shown only to orient you |
|
|
200
|
+
| `+3 hidden` | Neighbours cut by `--max-nodes` |
|
|
201
|
+
|
|
202
|
+
**The number that matters is callers, not lines.** `1 line changed · called from 40
|
|
203
|
+
places` deserves more attention than a fifty-line change nothing calls.
|
|
204
|
+
|
|
205
|
+
### Mermaid and JSON
|
|
206
|
+
|
|
207
|
+
`--format mermaid` prints a diagram GitHub renders natively — paste it straight into a
|
|
208
|
+
pull-request comment:
|
|
209
|
+
|
|
210
|
+
```mermaid
|
|
211
|
+
flowchart LR
|
|
212
|
+
n0["CheckoutForm.submit"] -->|"1"| n1["CheckoutController.apply_promo_code"]
|
|
213
|
+
n1 -->|"2"| n2["DiscountService.validate"]
|
|
214
|
+
n2 -->|"3"| n3["PricingEngine.recalculate"]
|
|
215
|
+
n3 -->|"4"| n4["util.clamp<br/>13 callers"]
|
|
216
|
+
n5["StripeProvider.calculate_refund"] --> n4
|
|
217
|
+
n5 -.->|"implements"| n6["Refundable.calculate_refund"]
|
|
218
|
+
class n4 hot
|
|
219
|
+
classDef hot stroke-width:3px,stroke:#b45309
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`--format json` is for anything downstream. Every edge carries its provenance
|
|
223
|
+
(`static`, `inferred`, …), so a consumer can tell a resolved call from a guess. Nodes
|
|
224
|
+
carry `touched`, `added`, `fan_in`, `changed_lines`; the top level reports `truncated`,
|
|
225
|
+
`utilities_hidden`, `files_changed` and `unparsed`.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Options
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
scopia [revisions] [options]
|
|
233
|
+
|
|
234
|
+
--html [PATH] write a self-contained, clickable HTML report
|
|
235
|
+
(with --watch and no PATH: serve a live page)
|
|
236
|
+
--watch follow the working tree and redraw as files settle
|
|
237
|
+
--to-entry climb callers until each chain reaches an entry point
|
|
238
|
+
--lsp confirm name-matched calls with a language server, if installed
|
|
239
|
+
--no-open with --watch --html, print the URL instead of opening it
|
|
240
|
+
--format FORMAT text (default), mermaid, or json
|
|
241
|
+
--hops N neighbour hops to expand (default: 1)
|
|
242
|
+
--max-nodes N cap on drawn nodes (default: 60)
|
|
243
|
+
--utility N hide unchanged helpers called from more than N places
|
|
244
|
+
(default: 12; 0 keeps everything)
|
|
245
|
+
--exclude GLOB skip paths matching GLOB (repeatable)
|
|
246
|
+
--always draw even when the diff is below the gate
|
|
247
|
+
--jobs N parser processes used when indexing
|
|
248
|
+
--no-color disable ANSI colour
|
|
249
|
+
--version print the version
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**Too noisy?** Hide shared plumbing — a logger every handler calls says nothing about
|
|
253
|
+
*this* change — or exclude paths (`vendor/`, `node_modules/`, `.venv/`, `migrations/`,
|
|
254
|
+
`dist/`, `build/` and similar are already skipped):
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
scopia --utility 6
|
|
258
|
+
scopia --exclude 'tests/*' --exclude 'generated/*'
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Too small?** Raise the node cap if the report says nodes were hidden (edges to cut
|
|
262
|
+
nodes are not drawn, which can make a symbol look unreferenced), look further out, or
|
|
263
|
+
force a graph for a tiny diff:
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
scopia --max-nodes 300
|
|
267
|
+
scopia --hops 2
|
|
268
|
+
scopia --always
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## Languages
|
|
274
|
+
|
|
275
|
+
| Language | Extensions | Status |
|
|
276
|
+
|---|---|---|
|
|
277
|
+
| Python | `.py` `.pyi` | supported — including aliased imports (`from x import run as v3`) |
|
|
278
|
+
| PHP | `.php` `.phtml` | supported — no PHP runtime needed, parsed via tree-sitter |
|
|
279
|
+
| Java | `.java` | supported — no JDK needed; reads the type each parameter, field and local declares |
|
|
280
|
+
| React | `.jsx` `.tsx` `.js` `.ts` `.mjs` `.cjs` `.mts` `.cts` | supported — no Node, no `tsc`, no `node_modules` |
|
|
281
|
+
|
|
282
|
+
**PHP** resolves the class a call site names — `app(Service::class)->show()`,
|
|
283
|
+
`new Service()`, `Service::show()`, typed properties — including inherited methods. On a
|
|
284
|
+
Laravel-shaped app that removed roughly a third of all edges as false.
|
|
285
|
+
|
|
286
|
+
**React:** `<Button />` is a call (composition *is* the call graph; `<div>` is left
|
|
287
|
+
alone); `const Panel = () => …`, `memo(forwardRef(…))`, `useCallback(…)` and
|
|
288
|
+
`styled.div\`…\`` are definitions, so a one-line edit inside a handler is attributed to
|
|
289
|
+
the handler rather than the 200-line component around it; hooks are ordinary calls;
|
|
290
|
+
handlers wired through props are followed and listed as references. TypeScript is read
|
|
291
|
+
where it states a type, and `.js`/`.ts`/`.tsx` are one language, so a call between them
|
|
292
|
+
is an ordinary edge.
|
|
293
|
+
|
|
294
|
+
### Sharper edges with a language server
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
pip install "scopia[lsp]" # adds jedi-language-server — pure Python, no Node
|
|
298
|
+
scopia --lsp # or: scopia --watch --lsp
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Where scopia could only match a call by name, `--lsp` asks a language server where the
|
|
302
|
+
call really goes. One confirmed target becomes a solid edge; a definition outside your
|
|
303
|
+
repo (`dict.get`, the standard library) removes the name-matched edges outright. If no
|
|
304
|
+
server is installed, or it fails or times out, you get exactly the graph you would have
|
|
305
|
+
had without the flag — plus a line saying so. In watch mode the graph is drawn from
|
|
306
|
+
tree-sitter immediately and upgraded when answers arrive, so a slow server never stalls
|
|
307
|
+
a redraw.
|
|
308
|
+
|
|
309
|
+
Measured on Python (uncertain edges as a share of all edges, before → after):
|
|
310
|
+
|
|
311
|
+
| Repo | Before | After |
|
|
312
|
+
|---|---|---|
|
|
313
|
+
| scopia | 30% | 4% |
|
|
314
|
+
| pygls | 67% | 10% |
|
|
315
|
+
| parso | 64% | 21% |
|
|
316
|
+
| jedi (190,000 edges, uncapped) | 67% | 64% |
|
|
317
|
+
|
|
318
|
+
The last row is the honest limit: in heavily dynamic code a language server sharpens what
|
|
319
|
+
types can settle, and does not conjure types that are not there. Python is the only
|
|
320
|
+
language wired up so far.
|
|
321
|
+
|
|
322
|
+
Adding a language means writing **one adapter**. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## The ideas it is built on
|
|
327
|
+
|
|
328
|
+
- **A one-line change is a first-class change.** The unit of analysis is the enclosing
|
|
329
|
+
function, never the size of the hunk. A one-line sign flip in a helper called from forty
|
|
330
|
+
places outranks a two-hundred-line rewrite of something nobody calls.
|
|
331
|
+
- **Uncertainty is shown, never disguised.** Every edge carries its provenance; a call
|
|
332
|
+
through an unknown receiver is marked `inferred` even when only one candidate exists.
|
|
333
|
+
- **It does not claim knowledge it lacks.** Unsupported files are listed, not omitted.
|
|
334
|
+
Untraceable symbols are "no connections found", not "no edges" — a reviewer reads the
|
|
335
|
+
second as "safe to skip".
|
|
336
|
+
- **It is not a correctness checker.** scopia does not judge code or hunt for bugs; it
|
|
337
|
+
reduces how much you must read. The metric it optimises is hunks a reviewer *didn't*
|
|
338
|
+
have to open.
|
|
339
|
+
- **Small diffs get no graph.** Touch fewer than three files with nothing connecting them
|
|
340
|
+
and scopia tells you to just read the diff.
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## How it works
|
|
345
|
+
|
|
346
|
+
1. `git diff --unified=0` gives exact changed line ranges; context lines would overstate
|
|
347
|
+
the blast radius. Untracked files are added separately.
|
|
348
|
+
2. Each changed line maps to its **smallest enclosing definition**.
|
|
349
|
+
3. A symbol and reference index over the repo, cached per git blob SHA in `.git/scopia/`,
|
|
350
|
+
so only files whose content actually changed are ever re-parsed.
|
|
351
|
+
4. One hop of expansion to callers and callees — never a transitive closure, so graph
|
|
352
|
+
size scales with the diff rather than the repo.
|
|
353
|
+
5. Edges resolved and tagged by how the call named its target.
|
|
354
|
+
|
|
355
|
+
A 1,200-file repo with a 200-file diff: **1.7s cold, 0.9s warm.** To force a clean
|
|
356
|
+
re-index, `rm -rf .git/scopia`.
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## Development
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
git clone https://github.com/printSamarth/scopia && cd scopia
|
|
364
|
+
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
365
|
+
.venv/bin/python -m pytest -q
|
|
366
|
+
.venv/bin/ruff check .
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
## Status
|
|
370
|
+
|
|
371
|
+
First stable release. The graph, live watch mode and all four language adapters work. Planned: hunk
|
|
372
|
+
clustering (read a repeated pattern once, then only what differs), framework/DI edge
|
|
373
|
+
recognition, and runtime-trace ingestion for the dynamic dispatch static analysis cannot
|
|
374
|
+
see.
|
|
375
|
+
|
|
376
|
+
## License
|
|
377
|
+
|
|
378
|
+
MIT
|