vibesequence 0.1.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.
- vibesequence-0.1.0/LICENSE +21 -0
- vibesequence-0.1.0/PKG-INFO +112 -0
- vibesequence-0.1.0/README.md +90 -0
- vibesequence-0.1.0/pyproject.toml +38 -0
- vibesequence-0.1.0/setup.cfg +4 -0
- vibesequence-0.1.0/tests/test_flow.py +179 -0
- vibesequence-0.1.0/vibesequence/__init__.py +0 -0
- vibesequence-0.1.0/vibesequence/__main__.py +2 -0
- vibesequence-0.1.0/vibesequence/builder.py +179 -0
- vibesequence-0.1.0/vibesequence/cli.py +309 -0
- vibesequence-0.1.0/vibesequence/editor.py +71 -0
- vibesequence-0.1.0/vibesequence/entrypoints.py +106 -0
- vibesequence-0.1.0/vibesequence/extractors/__init__.py +0 -0
- vibesequence-0.1.0/vibesequence/extractors/base.py +34 -0
- vibesequence-0.1.0/vibesequence/extractors/python_ast.py +257 -0
- vibesequence-0.1.0/vibesequence/extractors/typescript_ts.py +497 -0
- vibesequence-0.1.0/vibesequence/flow.py +222 -0
- vibesequence-0.1.0/vibesequence/model.py +97 -0
- vibesequence-0.1.0/vibesequence/render/__init__.py +0 -0
- vibesequence-0.1.0/vibesequence/render/html.py +27 -0
- vibesequence-0.1.0/vibesequence/render/mermaid.py +123 -0
- vibesequence-0.1.0/vibesequence/render/text.py +67 -0
- vibesequence-0.1.0/vibesequence/render/viewer.html +1195 -0
- vibesequence-0.1.0/vibesequence/resolver.py +211 -0
- vibesequence-0.1.0/vibesequence/serve.py +119 -0
- vibesequence-0.1.0/vibesequence.egg-info/PKG-INFO +112 -0
- vibesequence-0.1.0/vibesequence.egg-info/SOURCES.txt +29 -0
- vibesequence-0.1.0/vibesequence.egg-info/dependency_links.txt +1 -0
- vibesequence-0.1.0/vibesequence.egg-info/entry_points.txt +2 -0
- vibesequence-0.1.0/vibesequence.egg-info/requires.txt +4 -0
- vibesequence-0.1.0/vibesequence.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gery Wibowo
|
|
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.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vibesequence
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Turn a codebase into zoomable user sequence diagrams, for you and your AI assistant
|
|
5
|
+
Author: Gery Wibowo
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/monkeymars/vibesequence
|
|
8
|
+
Project-URL: Documentation, https://github.com/monkeymars/vibesequence/blob/main/docs/how-it-works.md
|
|
9
|
+
Project-URL: Issues, https://github.com/monkeymars/vibesequence/issues
|
|
10
|
+
Keywords: sequence-diagram,code-visualization,mermaid,static-analysis,ai-assistant
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Provides-Extra: ts
|
|
19
|
+
Requires-Dist: tree-sitter>=0.23; extra == "ts"
|
|
20
|
+
Requires-Dist: tree-sitter-typescript>=0.23; extra == "ts"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# vibesequence
|
|
24
|
+
|
|
25
|
+
Turn your codebase into **zoomable sequence diagrams** that show *when a user does X, who calls whom,
|
|
26
|
+
and in what order*. Use them as a map while you build, review or learn the code, on your own or with an
|
|
27
|
+
AI assistant.
|
|
28
|
+
|
|
29
|
+
Inspired by [graphify](https://github.com/Graphify-Labs/graphify), which turns a codebase into a knowledge
|
|
30
|
+
graph so an AI assistant can navigate it without re-reading every file. vibesequence takes the same idea
|
|
31
|
+
and adds what a graph leaves out: the **order** of calls and the branches around them (`if`, loops, error
|
|
32
|
+
handling), drawn as user sequence diagrams that you and the assistant can both read.
|
|
33
|
+
|
|
34
|
+

|
|
35
|
+
|
|
36
|
+
## Why
|
|
37
|
+
|
|
38
|
+
When an AI assistant edits your code, it is hard to see what actually changed in the behavior.
|
|
39
|
+
vibesequence reads your code (no running it), finds the entry points (HTTP routes, CLI commands, handlers),
|
|
40
|
+
and draws each one as a sequence diagram:
|
|
41
|
+
|
|
42
|
+
- **Zoom** between package, module, class and function level. Click a blue call to open it on its own.
|
|
43
|
+
- **Play** a flow arrow by arrow, with the `file:line` of each step.
|
|
44
|
+
- **Live**: while the AI edits, changed steps turn green and you can play only the changes.
|
|
45
|
+
- **AI friendly**: the same flow is available as compact text, so your assistant reads one diagram instead of eight files.
|
|
46
|
+
|
|
47
|
+
Supports Python (stdlib only) and TypeScript / JavaScript (Next.js, Express).
|
|
48
|
+
|
|
49
|
+
## Does it save tokens?
|
|
50
|
+
|
|
51
|
+
We benchmarked Claude Code on the same tasks with and without vibesequence. Every run passed in both setups.
|
|
52
|
+
|
|
53
|
+
| task type | input tokens | cost | turns |
|
|
54
|
+
|---|---|---|---|
|
|
55
|
+
| understanding a flow ("what happens when…", "what breaks if I change…") | **−23%** | **−18%** | 8 → 4 |
|
|
56
|
+
| small edits | +19% | +15% | 5.5 → 6 |
|
|
57
|
+
|
|
58
|
+
Impact questions save the most: one `vibesequence callers` call replaced 6–7 greps and reads (−53% tokens).
|
|
59
|
+
For edits, the agent still has to read the files it changes, so on the tiny example projects the map is
|
|
60
|
+
extra overhead. Full method, per-task results and caveats: [docs/benchmark](https://github.com/monkeymars/vibesequence/blob/main/docs/benchmark/README.md).
|
|
61
|
+
|
|
62
|
+
## Quick start
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install vibesequence # Python projects
|
|
66
|
+
pip install "vibesequence[ts]" # + TypeScript / JavaScript
|
|
67
|
+
|
|
68
|
+
vibesequence build . # parse the repo into vibesequence-out/
|
|
69
|
+
vibesequence serve # open the viewer at http://127.0.0.1:8765
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Try it on the bundled examples: clone this repo and run `vibesequence build examples`.
|
|
73
|
+
|
|
74
|
+
## Commands
|
|
75
|
+
|
|
76
|
+
| command | what it does |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `vibesequence build .` | parse the repo into `vibesequence-out/` (incremental) |
|
|
79
|
+
| `vibesequence serve` | live viewer; source links open your editor |
|
|
80
|
+
| `vibesequence list` | list all flows |
|
|
81
|
+
| `vibesequence show "POST /orders"` | one flow as Mermaid (`--format text` for the AI-friendly tree) |
|
|
82
|
+
| `vibesequence callers OrderRepo.create` | who calls this, and which flows are affected |
|
|
83
|
+
| `vibesequence diff` | which flows changed in the last build |
|
|
84
|
+
| `vibesequence install` | add instructions to `CLAUDE.md` + a Claude Code hook that shows changed flows after each edit |
|
|
85
|
+
|
|
86
|
+
## Viewer shortcuts
|
|
87
|
+
|
|
88
|
+
`Space` play · `←` `→` step · `[` `]` level · `Z` zen · `D` dark mode · `\` sidebar · `⌘`+scroll zoom · drag to pan
|
|
89
|
+
|
|
90
|
+
## Roadmap
|
|
91
|
+
|
|
92
|
+
- [ ] **Go**: tree-sitter extractor, entry points for `net/http`, Gin, Echo, Chi and Cobra commands
|
|
93
|
+
- [ ] **Java**: Spring controllers
|
|
94
|
+
- [ ] **Smarter resolution**: return-type inference, dependency injection, interface → implementations
|
|
95
|
+
- [ ] **Diff against a git ref**: review a PR as sequence changes, not line changes
|
|
96
|
+
- [ ] **MCP server**: let the assistant call `list_flows` / `show_flow` instead of reading files
|
|
97
|
+
- [ ] **Runtime traces** (optional): mark the paths that actually ran
|
|
98
|
+
|
|
99
|
+
## Contributing
|
|
100
|
+
|
|
101
|
+
Open for contributions! Pick a roadmap item, fix a bug, or improve the docs. Issues and pull requests are welcome.
|
|
102
|
+
|
|
103
|
+
- **New language**: add one extractor class in `vibesequence/extractors/` that outputs `ModuleFacts`
|
|
104
|
+
(see `extractors/base.py`). The resolver, viewer and CLI work without changes.
|
|
105
|
+
- **Set up**: clone the repo and run `pip install -e ".[ts]"`.
|
|
106
|
+
- **Run the tests** before opening a PR: `python tests/test_flow.py`
|
|
107
|
+
- Not sure where to start? Open an issue and say what you want to build.
|
|
108
|
+
|
|
109
|
+
## Learn more
|
|
110
|
+
|
|
111
|
+
How the engine turns code into sequence diagrams (extract → resolve → expand → project → render), plus
|
|
112
|
+
live mode, editor links and the AI hook: [docs/how-it-works.md](https://github.com/monkeymars/vibesequence/blob/main/docs/how-it-works.md).
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# vibesequence
|
|
2
|
+
|
|
3
|
+
Turn your codebase into **zoomable sequence diagrams** that show *when a user does X, who calls whom,
|
|
4
|
+
and in what order*. Use them as a map while you build, review or learn the code, on your own or with an
|
|
5
|
+
AI assistant.
|
|
6
|
+
|
|
7
|
+
Inspired by [graphify](https://github.com/Graphify-Labs/graphify), which turns a codebase into a knowledge
|
|
8
|
+
graph so an AI assistant can navigate it without re-reading every file. vibesequence takes the same idea
|
|
9
|
+
and adds what a graph leaves out: the **order** of calls and the branches around them (`if`, loops, error
|
|
10
|
+
handling), drawn as user sequence diagrams that you and the assistant can both read.
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
When an AI assistant edits your code, it is hard to see what actually changed in the behavior.
|
|
17
|
+
vibesequence reads your code (no running it), finds the entry points (HTTP routes, CLI commands, handlers),
|
|
18
|
+
and draws each one as a sequence diagram:
|
|
19
|
+
|
|
20
|
+
- **Zoom** between package, module, class and function level. Click a blue call to open it on its own.
|
|
21
|
+
- **Play** a flow arrow by arrow, with the `file:line` of each step.
|
|
22
|
+
- **Live**: while the AI edits, changed steps turn green and you can play only the changes.
|
|
23
|
+
- **AI friendly**: the same flow is available as compact text, so your assistant reads one diagram instead of eight files.
|
|
24
|
+
|
|
25
|
+
Supports Python (stdlib only) and TypeScript / JavaScript (Next.js, Express).
|
|
26
|
+
|
|
27
|
+
## Does it save tokens?
|
|
28
|
+
|
|
29
|
+
We benchmarked Claude Code on the same tasks with and without vibesequence. Every run passed in both setups.
|
|
30
|
+
|
|
31
|
+
| task type | input tokens | cost | turns |
|
|
32
|
+
|---|---|---|---|
|
|
33
|
+
| understanding a flow ("what happens when…", "what breaks if I change…") | **−23%** | **−18%** | 8 → 4 |
|
|
34
|
+
| small edits | +19% | +15% | 5.5 → 6 |
|
|
35
|
+
|
|
36
|
+
Impact questions save the most: one `vibesequence callers` call replaced 6–7 greps and reads (−53% tokens).
|
|
37
|
+
For edits, the agent still has to read the files it changes, so on the tiny example projects the map is
|
|
38
|
+
extra overhead. Full method, per-task results and caveats: [docs/benchmark](https://github.com/monkeymars/vibesequence/blob/main/docs/benchmark/README.md).
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install vibesequence # Python projects
|
|
44
|
+
pip install "vibesequence[ts]" # + TypeScript / JavaScript
|
|
45
|
+
|
|
46
|
+
vibesequence build . # parse the repo into vibesequence-out/
|
|
47
|
+
vibesequence serve # open the viewer at http://127.0.0.1:8765
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Try it on the bundled examples: clone this repo and run `vibesequence build examples`.
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
| command | what it does |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `vibesequence build .` | parse the repo into `vibesequence-out/` (incremental) |
|
|
57
|
+
| `vibesequence serve` | live viewer; source links open your editor |
|
|
58
|
+
| `vibesequence list` | list all flows |
|
|
59
|
+
| `vibesequence show "POST /orders"` | one flow as Mermaid (`--format text` for the AI-friendly tree) |
|
|
60
|
+
| `vibesequence callers OrderRepo.create` | who calls this, and which flows are affected |
|
|
61
|
+
| `vibesequence diff` | which flows changed in the last build |
|
|
62
|
+
| `vibesequence install` | add instructions to `CLAUDE.md` + a Claude Code hook that shows changed flows after each edit |
|
|
63
|
+
|
|
64
|
+
## Viewer shortcuts
|
|
65
|
+
|
|
66
|
+
`Space` play · `←` `→` step · `[` `]` level · `Z` zen · `D` dark mode · `\` sidebar · `⌘`+scroll zoom · drag to pan
|
|
67
|
+
|
|
68
|
+
## Roadmap
|
|
69
|
+
|
|
70
|
+
- [ ] **Go**: tree-sitter extractor, entry points for `net/http`, Gin, Echo, Chi and Cobra commands
|
|
71
|
+
- [ ] **Java**: Spring controllers
|
|
72
|
+
- [ ] **Smarter resolution**: return-type inference, dependency injection, interface → implementations
|
|
73
|
+
- [ ] **Diff against a git ref**: review a PR as sequence changes, not line changes
|
|
74
|
+
- [ ] **MCP server**: let the assistant call `list_flows` / `show_flow` instead of reading files
|
|
75
|
+
- [ ] **Runtime traces** (optional): mark the paths that actually ran
|
|
76
|
+
|
|
77
|
+
## Contributing
|
|
78
|
+
|
|
79
|
+
Open for contributions! Pick a roadmap item, fix a bug, or improve the docs. Issues and pull requests are welcome.
|
|
80
|
+
|
|
81
|
+
- **New language**: add one extractor class in `vibesequence/extractors/` that outputs `ModuleFacts`
|
|
82
|
+
(see `extractors/base.py`). The resolver, viewer and CLI work without changes.
|
|
83
|
+
- **Set up**: clone the repo and run `pip install -e ".[ts]"`.
|
|
84
|
+
- **Run the tests** before opening a PR: `python tests/test_flow.py`
|
|
85
|
+
- Not sure where to start? Open an issue and say what you want to build.
|
|
86
|
+
|
|
87
|
+
## Learn more
|
|
88
|
+
|
|
89
|
+
How the engine turns code into sequence diagrams (extract → resolve → expand → project → render), plus
|
|
90
|
+
live mode, editor links and the AI hook: [docs/how-it-works.md](https://github.com/monkeymars/vibesequence/blob/main/docs/how-it-works.md).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"] # 77+: SPDX `license` string (PEP 639)
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vibesequence"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Turn a codebase into zoomable user sequence diagrams, for you and your AI assistant"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Gery Wibowo" }]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
keywords = ["sequence-diagram", "code-visualization", "mermaid", "static-analysis", "ai-assistant"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Topic :: Software Development :: Documentation",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [] # Python projects: stdlib only
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
ts = ["tree-sitter>=0.23", "tree-sitter-typescript>=0.23"] # TypeScript / JavaScript projects
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/monkeymars/vibesequence"
|
|
28
|
+
Documentation = "https://github.com/monkeymars/vibesequence/blob/main/docs/how-it-works.md"
|
|
29
|
+
Issues = "https://github.com/monkeymars/vibesequence/issues"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
vibesequence = "vibesequence.cli:main"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
include = ["vibesequence*"]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
"vibesequence.render" = ["viewer.html"]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Smoke check: python tests/test_flow.py (also runs under pytest)."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import io
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import textwrap
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
10
|
+
from vibesequence import builder, cli # noqa: E402
|
|
11
|
+
from vibesequence.flow import expand, project # noqa: E402
|
|
12
|
+
from vibesequence.render.mermaid import render # noqa: E402
|
|
13
|
+
|
|
14
|
+
APP = textwrap.dedent('''
|
|
15
|
+
def save(x): pass
|
|
16
|
+
def notify(x): pass
|
|
17
|
+
def audit(x): pass
|
|
18
|
+
|
|
19
|
+
def handle(user, items):
|
|
20
|
+
if not user:
|
|
21
|
+
notify(user)
|
|
22
|
+
if user.admin:
|
|
23
|
+
audit(user)
|
|
24
|
+
for item in items:
|
|
25
|
+
save(item)
|
|
26
|
+
|
|
27
|
+
def main():
|
|
28
|
+
handle(None, [])
|
|
29
|
+
''')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run(*argv) -> str:
|
|
33
|
+
buf = io.StringIO()
|
|
34
|
+
with contextlib.redirect_stdout(buf):
|
|
35
|
+
cli.main(list(argv))
|
|
36
|
+
return buf.getvalue()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_flow():
|
|
40
|
+
with tempfile.TemporaryDirectory() as d:
|
|
41
|
+
root, out = Path(d, "src"), Path(d, "out")
|
|
42
|
+
root.mkdir()
|
|
43
|
+
(root / "app.py").write_text(APP)
|
|
44
|
+
graph = builder.build(root, out)
|
|
45
|
+
again = builder.build(root, out) # live mode relies on: same code -> same version
|
|
46
|
+
assert not again.stats["changed"] and again.stats["version"] == graph.stats["version"]
|
|
47
|
+
|
|
48
|
+
mm = render(project(graph, expand(graph, "app:handle", 3), "function"), "function")
|
|
49
|
+
assert "opt if not user" in mm, mm
|
|
50
|
+
assert "opt if user.admin" in mm, mm # two separate ifs stay two fragments
|
|
51
|
+
assert "loop for item in items" in mm, mm
|
|
52
|
+
|
|
53
|
+
text = _run("show", "app:handle", "--out", str(out), "--format", "text", "--level", "function")
|
|
54
|
+
assert "save app.py:2" in text, text # definition location for the agent
|
|
55
|
+
|
|
56
|
+
callers = _run("callers", "app:save", "--out", str(out))
|
|
57
|
+
assert "app:handle app.py:12" in callers, callers
|
|
58
|
+
assert "main()" in callers, callers # flow that reaches it
|
|
59
|
+
|
|
60
|
+
(root / "app.py").write_text(APP.replace(" save(item)", " save(item)\n notify(item)"))
|
|
61
|
+
hook = _run("build", str(root), "--out", str(out), "--hook")
|
|
62
|
+
assert builder.load(out).stats["version"] != graph.stats["version"]
|
|
63
|
+
assert "+" in hook and "notify" in hook, hook
|
|
64
|
+
assert _run("build", str(root), "--out", str(out), "--hook") == "" # no change -> silent
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_serve_open_guards():
|
|
68
|
+
"""POST /__open must refuse other origins and files outside the project (never launches an editor)."""
|
|
69
|
+
import json
|
|
70
|
+
import threading
|
|
71
|
+
import urllib.error
|
|
72
|
+
import urllib.request
|
|
73
|
+
from http.server import ThreadingHTTPServer
|
|
74
|
+
from vibesequence import editor, serve
|
|
75
|
+
|
|
76
|
+
editor.EDITORS["missing"] = ("Missing", lambda f, n: ["vibesequence-no-such-editor", f])
|
|
77
|
+
with tempfile.TemporaryDirectory() as d:
|
|
78
|
+
root = Path(d)
|
|
79
|
+
(root / "app.py").write_text("x = 1\n")
|
|
80
|
+
srv = ThreadingHTTPServer(("127.0.0.1", 0), serve._handler(root, root.resolve(), lambda: None, "missing"))
|
|
81
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
82
|
+
base = f"http://127.0.0.1:{srv.server_address[1]}"
|
|
83
|
+
|
|
84
|
+
def post(body, origin=base):
|
|
85
|
+
req = urllib.request.Request(base + "/__open", json.dumps(body).encode(), {"Origin": origin})
|
|
86
|
+
try:
|
|
87
|
+
return urllib.request.urlopen(req).status
|
|
88
|
+
except urllib.error.HTTPError as e:
|
|
89
|
+
return e.code
|
|
90
|
+
|
|
91
|
+
assert post({"file": "app.py", "line": 1}, "https://evil.example") == 403
|
|
92
|
+
assert post({"file": "../../etc/passwd", "line": 1}) == 403
|
|
93
|
+
assert post({"file": "app.py", "line": "x"}) == 400
|
|
94
|
+
assert post({"file": "app.py", "line": 1}) == 404 # CLI not on PATH
|
|
95
|
+
srv.shutdown()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_detect_editor():
|
|
99
|
+
from vibesequence.editor import detect
|
|
100
|
+
assert detect({"TERM_PROGRAM": "zed", "ZED_TERM": "true"})[0] == "zed"
|
|
101
|
+
assert detect({"TERM_PROGRAM": "vscode", "__CFBundleIdentifier": "com.microsoft.VSCode"})[0] == "vscode"
|
|
102
|
+
assert detect({"TERM_PROGRAM": "vscode", "__CFBundleIdentifier": "com.todesktop.230313mzl4w4u92"})[0] == "cursor"
|
|
103
|
+
assert detect({"VISUAL": "code --wait"})[0] == "vscode"
|
|
104
|
+
assert detect({"EDITOR": "vim", "TERM_PROGRAM": "vscode"})[0] == "vscode" # terminal editors skipped
|
|
105
|
+
assert detect({"TERMINAL_EMULATOR": "JetBrains-JediTerm", "__CFBundleIdentifier": "com.jetbrains.intellij"})[0] == "idea"
|
|
106
|
+
assert detect({"VIBESEQUENCE_EDITOR": "pycharm", "TERM_PROGRAM": "zed"})[0] == "pycharm" # explicit wins
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_install_keeps_user_content():
|
|
110
|
+
"""install edits the user's CLAUDE.md and Claude settings: replace only our section, add the hook once."""
|
|
111
|
+
import json
|
|
112
|
+
with tempfile.TemporaryDirectory() as d:
|
|
113
|
+
md, settings = Path(d, "CLAUDE.md"), Path(d, ".claude", "settings.local.json")
|
|
114
|
+
md.write_text("# Proj\n\nKeep tests green.\n\n## vibesequence (old)\nold text\n\n## Deploy\nmake deploy\n")
|
|
115
|
+
settings.parent.mkdir()
|
|
116
|
+
settings.write_text(json.dumps({"permissions": {"allow": ["Bash(make:*)"]}}))
|
|
117
|
+
for _ in range(2):
|
|
118
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
119
|
+
cli._install(md)
|
|
120
|
+
cli._install_hook(settings)
|
|
121
|
+
text, conf = md.read_text(), json.loads(settings.read_text())
|
|
122
|
+
assert "Keep tests green." in text and "## Deploy" in text and "old text" not in text
|
|
123
|
+
assert text.count("## vibesequence") == 1
|
|
124
|
+
assert conf["permissions"] == {"allow": ["Bash(make:*)"]}
|
|
125
|
+
assert len(conf["hooks"]["PostToolUse"]) == 1
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_import_root_and_ranking():
|
|
129
|
+
"""Built one folder too high, imports still resolve; roots that drive more code rank first."""
|
|
130
|
+
with tempfile.TemporaryDirectory() as d:
|
|
131
|
+
pkg = Path(d, "services", "app")
|
|
132
|
+
pkg.mkdir(parents=True)
|
|
133
|
+
(pkg / "lib.py").write_text("def f(): g()\ndef g(): h()\ndef h(): pass\n")
|
|
134
|
+
(pkg / "main.py").write_text(textwrap.dedent('''
|
|
135
|
+
from app.lib import f, g, h
|
|
136
|
+
|
|
137
|
+
def run():
|
|
138
|
+
f()
|
|
139
|
+
g()
|
|
140
|
+
h()
|
|
141
|
+
|
|
142
|
+
def _cb():
|
|
143
|
+
h()
|
|
144
|
+
'''))
|
|
145
|
+
graph = builder.build(Path(d), Path(d, "out"))
|
|
146
|
+
assert graph.stats["import_roots"] == ["services"], graph.stats
|
|
147
|
+
assert "app.main:run" in graph.functions
|
|
148
|
+
assert graph.functions["app.main:run"].calls[0].resolved == "app.lib:f"
|
|
149
|
+
roots = [e.id for e in graph.entries if e.kind == "root"]
|
|
150
|
+
assert roots.index("app.main:run") < roots.index("app.main:_cb"), roots
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_typescript_next_and_express():
|
|
154
|
+
"""examples/next_shop: Next.js route files + Express routes resolve through classes, inheritance,
|
|
155
|
+
path aliases and an imported instance. Skipped without the optional tree-sitter dependency."""
|
|
156
|
+
try:
|
|
157
|
+
import tree_sitter_typescript # noqa: F401
|
|
158
|
+
except ImportError:
|
|
159
|
+
print("skip test_typescript_next_and_express: pip install 'vibesequence[ts]'")
|
|
160
|
+
return
|
|
161
|
+
example = Path(__file__).resolve().parents[1] / "examples" / "next_shop"
|
|
162
|
+
with tempfile.TemporaryDirectory() as d:
|
|
163
|
+
graph = builder.build(example, Path(d))
|
|
164
|
+
labels = {e.label for e in graph.entries if e.kind == "route"}
|
|
165
|
+
assert {"POST /api/orders", "GET /api/orders/[id]", "GET /api/me", "GET /orders/:id", "POST /orders/:id/cancel"} <= labels, labels
|
|
166
|
+
text = _run("show", "POST /api/orders", "--out", d, "--format", "text", "--level", "function", "--depth", "6")
|
|
167
|
+
for step in ("auth.requireUser", "OrderService.place", "BaseRepo.get", "for each in items",
|
|
168
|
+
"PricingEngine.priceLine", "OrderRepo.markFailed", "fetch", "pool.query", "orders/route.POST"):
|
|
169
|
+
assert step in text, (step, text)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
test_typescript_next_and_express()
|
|
174
|
+
test_import_root_and_ranking()
|
|
175
|
+
test_flow()
|
|
176
|
+
test_serve_open_guards()
|
|
177
|
+
test_detect_editor()
|
|
178
|
+
test_install_keeps_user_content()
|
|
179
|
+
print("ok")
|
|
File without changes
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Build pipeline: files -> ModuleFacts (cached) -> resolve -> entry points -> Graph
|
|
2
|
+
|
|
3
|
+
Scalability notes:
|
|
4
|
+
* Extraction is per-file and cached by content hash in vibesequence-out/cache.json.
|
|
5
|
+
A rebuild on a 2000-file repo touches only the files whose hash changed.
|
|
6
|
+
* Resolution is a cheap dictionary chain, O(total calls).
|
|
7
|
+
* Flows are NOT precomputed. The viewer / CLI expands a flow lazily from the
|
|
8
|
+
graph, so output size is O(functions + calls), not O(entries × depth).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
from collections import Counter, defaultdict
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import asdict
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .entrypoints import detect
|
|
21
|
+
from .extractors.base import get_extractors
|
|
22
|
+
from .model import CallSite, FunctionDef, Graph, ModuleFacts
|
|
23
|
+
from .resolver import Resolver
|
|
24
|
+
|
|
25
|
+
CACHE_VERSION = 2 # bump when extractor output changes, so stale facts are reparsed
|
|
26
|
+
IGNORE_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build",
|
|
27
|
+
"vibesequence-out", "graphify-out", ".mypy_cache", ".pytest_cache", "site-packages",
|
|
28
|
+
".next", ".nuxt", ".svelte-kit", ".turbo", ".vercel", "coverage", ".cache", ".strapi"}
|
|
29
|
+
TS_EXTS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _hash(p: Path) -> str:
|
|
33
|
+
return hashlib.sha1(p.read_bytes()).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _facts_from_cache(d: dict) -> ModuleFacts:
|
|
37
|
+
fns = []
|
|
38
|
+
for f in d["functions"]:
|
|
39
|
+
f = dict(f)
|
|
40
|
+
f["calls"] = [CallSite(**c) for c in f["calls"]]
|
|
41
|
+
fns.append(FunctionDef(**f))
|
|
42
|
+
return ModuleFacts(module=d["module"], file=d["file"], functions=fns,
|
|
43
|
+
imports=d["imports"], instance_hints=d["instance_hints"],
|
|
44
|
+
bases=d.get("bases", {}), globals=d.get("globals", []), aliases=d.get("aliases", {}))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build(root: Path, out_dir: Path, use_cache: bool = True) -> Graph:
|
|
48
|
+
t0 = time.time()
|
|
49
|
+
root = root.resolve()
|
|
50
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
cache_path = out_dir / "cache.json"
|
|
52
|
+
cache: dict = json.loads(cache_path.read_text()) if (use_cache and cache_path.exists()) else {}
|
|
53
|
+
if cache.get("__version__") != CACHE_VERSION:
|
|
54
|
+
cache = {}
|
|
55
|
+
extractors = get_extractors()
|
|
56
|
+
|
|
57
|
+
modules: list[ModuleFacts] = []
|
|
58
|
+
new_cache: dict = {"__version__": CACHE_VERSION}
|
|
59
|
+
files_seen = files_parsed = ts_skipped = 0
|
|
60
|
+
for p in sorted(root.rglob("*")):
|
|
61
|
+
if not p.is_file() or p.name.endswith(".d.ts"):
|
|
62
|
+
continue
|
|
63
|
+
if p.suffix not in extractors:
|
|
64
|
+
ts_skipped += p.suffix in TS_EXTS and not any(part in IGNORE_DIRS for part in p.relative_to(root).parts)
|
|
65
|
+
continue
|
|
66
|
+
if any(part in IGNORE_DIRS for part in p.relative_to(root).parts):
|
|
67
|
+
continue
|
|
68
|
+
files_seen += 1
|
|
69
|
+
rel = str(p.relative_to(root))
|
|
70
|
+
h = _hash(p)
|
|
71
|
+
if rel in cache and cache[rel]["hash"] == h:
|
|
72
|
+
facts = _facts_from_cache(cache[rel]["facts"])
|
|
73
|
+
else:
|
|
74
|
+
try:
|
|
75
|
+
facts = extractors[p.suffix].extract(p, root)
|
|
76
|
+
except SyntaxError as e:
|
|
77
|
+
print(f" skip {rel}: {e.msg} (line {e.lineno})", file=sys.stderr)
|
|
78
|
+
continue
|
|
79
|
+
files_parsed += 1
|
|
80
|
+
for fn in facts.functions: # reset resolution; it depends on other files
|
|
81
|
+
for c in fn.calls:
|
|
82
|
+
c.resolved = None
|
|
83
|
+
modules.append(facts)
|
|
84
|
+
new_cache[rel] = {"hash": h, "facts": asdict(facts)}
|
|
85
|
+
|
|
86
|
+
import_roots, import_warning = _apply_import_roots(modules)
|
|
87
|
+
resolver = Resolver(modules)
|
|
88
|
+
res_stats = resolver.resolve_all()
|
|
89
|
+
entries = detect(resolver.functions)
|
|
90
|
+
|
|
91
|
+
stats = {
|
|
92
|
+
"root": str(root), "files": files_seen, "files_reparsed": files_parsed,
|
|
93
|
+
"functions": len(resolver.functions), "entries": len(entries),
|
|
94
|
+
**res_stats, "seconds": round(time.time() - t0, 3),
|
|
95
|
+
"import_roots": import_roots, "import_warning": import_warning or (
|
|
96
|
+
f"{ts_skipped} TypeScript/JavaScript files skipped: pip install 'vibesequence[ts]'" if ts_skipped else ""),
|
|
97
|
+
}
|
|
98
|
+
graph = Graph(functions=resolver.functions, entries=entries, stats=stats)
|
|
99
|
+
cache_path.write_text(json.dumps(new_cache))
|
|
100
|
+
graph_path = out_dir / "graph.json"
|
|
101
|
+
shape = _shape(graph.to_json())
|
|
102
|
+
old = _shape(json.loads(graph_path.read_text())) if graph_path.exists() else None
|
|
103
|
+
# version: changes only when functions/calls/entries change (not timings), so a live viewer
|
|
104
|
+
# and `serve` can tell "the flows changed" from "someone ran build again"
|
|
105
|
+
stats["version"] = hashlib.sha1(json.dumps(shape, sort_keys=True).encode()).hexdigest()[:12]
|
|
106
|
+
stats["changed"] = shape != old
|
|
107
|
+
# keep the graph from before the last CHANGE (not the last build) for `vibesequence diff`;
|
|
108
|
+
# a no-op rebuild must not overwrite it
|
|
109
|
+
if old is not None and stats["changed"]:
|
|
110
|
+
graph_path.replace(out_dir / "graph.prev.json")
|
|
111
|
+
graph_path.write_text(json.dumps(graph.to_json(), indent=1))
|
|
112
|
+
return graph
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _apply_import_roots(modules: list[ModuleFacts]) -> tuple[list[str], str]:
|
|
116
|
+
"""Built one level too high (monorepo, `vibesequence build .` above the project)? Then imports say
|
|
117
|
+
`app.repos.base` while the file's module is `services.app.repos.base`, and nothing resolves.
|
|
118
|
+
Find such prefixes (>= 3 imports match one folder unambiguously) and strip them from module
|
|
119
|
+
names, as if vibesequence had been run inside that folder. Skipped, with a warning, when stripping
|
|
120
|
+
would collide with an existing module or the match is ambiguous."""
|
|
121
|
+
known = {m.module for m in modules}
|
|
122
|
+
prefixes_of: dict[str, set[str]] = defaultdict(set) # "app.repos.base" -> {"services"}
|
|
123
|
+
for name in known:
|
|
124
|
+
parts = name.split(".")
|
|
125
|
+
for i in range(1, len(parts)):
|
|
126
|
+
prefixes_of[".".join(parts[i:])].add(".".join(parts[:i]))
|
|
127
|
+
hits: Counter[str] = Counter()
|
|
128
|
+
ambiguous: Counter[str] = Counter()
|
|
129
|
+
for m in modules:
|
|
130
|
+
for target in m.imports.values():
|
|
131
|
+
candidates = [t for t in (target, target.rpartition(".")[0]) if t]
|
|
132
|
+
if any(t in known for t in candidates):
|
|
133
|
+
continue
|
|
134
|
+
for t in candidates:
|
|
135
|
+
if len(prefixes_of.get(t, ())) == 1:
|
|
136
|
+
hits[next(iter(prefixes_of[t]))] += 1
|
|
137
|
+
break
|
|
138
|
+
if prefixes_of.get(t):
|
|
139
|
+
ambiguous[" / ".join(sorted(prefixes_of[t]))] += 1
|
|
140
|
+
break
|
|
141
|
+
applied, warning = [], ""
|
|
142
|
+
for prefix, n in hits.most_common():
|
|
143
|
+
if n < 3:
|
|
144
|
+
break
|
|
145
|
+
dot = prefix + "."
|
|
146
|
+
renamed = {m.module: m.module[len(dot):] for m in modules if m.module.startswith(dot)}
|
|
147
|
+
if set(renamed.values()) & (known - set(renamed)) or len(set(renamed.values())) < len(renamed):
|
|
148
|
+
warning = f"imports match {prefix.replace('.', '/')}/ but stripping it collides with other modules: build that folder"
|
|
149
|
+
continue
|
|
150
|
+
cut = lambda s: s[len(dot):] if s.startswith(dot) else s # noqa: E731
|
|
151
|
+
for m in modules:
|
|
152
|
+
m.module = cut(m.module)
|
|
153
|
+
m.imports = {k: cut(v) for k, v in m.imports.items()}
|
|
154
|
+
for fn in m.functions:
|
|
155
|
+
fn.module = cut(fn.module)
|
|
156
|
+
fn.id = f"{fn.module}:{fn.id.partition(':')[2]}"
|
|
157
|
+
known = {m.module for m in modules}
|
|
158
|
+
applied.append(prefix.replace(".", "/"))
|
|
159
|
+
if not applied and sum(ambiguous.values()) >= 3:
|
|
160
|
+
where = ambiguous.most_common(1)[0][0].replace(".", "/")
|
|
161
|
+
warning = f"imports match several folders ({where}): build one project at a time"
|
|
162
|
+
return applied, warning
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _shape(d: dict) -> tuple:
|
|
166
|
+
# ponytail: line shifts count as a change, so a comment-only edit rotates graph.prev.json and
|
|
167
|
+
# `vibesequence diff` then says "no flow changes"; compare line-free shapes if that bites
|
|
168
|
+
return d["functions"], d["entries"] # everything but stats (timings)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def load(out_dir: Path, name: str = "graph.json") -> Graph:
|
|
172
|
+
d = json.loads((out_dir / name).read_text())
|
|
173
|
+
fns = {}
|
|
174
|
+
for k, f in d["functions"].items():
|
|
175
|
+
f = dict(f)
|
|
176
|
+
f["calls"] = [CallSite(**c) for c in f["calls"]]
|
|
177
|
+
fns[k] = FunctionDef(**f)
|
|
178
|
+
from .model import EntryPoint
|
|
179
|
+
return Graph(functions=fns, entries=[EntryPoint(**e) for e in d["entries"]], stats=d["stats"])
|