serpentine-parser 0.3.2__tar.gz → 0.4.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.
- serpentine_parser-0.3.2/README.md → serpentine_parser-0.4.0/PKG-INFO +85 -0
- serpentine_parser-0.3.2/PKG-INFO → serpentine_parser-0.4.0/README.md +47 -34
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/pyproject.toml +7 -2
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/Cargo.lock +3 -3
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/Cargo.toml +1 -1
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/__init__.py +1 -1
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/cache.py +73 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/cli.py +55 -1
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/selector.py +15 -3
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/server/app.py +3 -1
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/server/routes.py +64 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/state.py +49 -2
- serpentine_parser-0.4.0/src/serpentine/static/assets/index-DgVKSbxY.js +32 -0
- serpentine_parser-0.4.0/src/serpentine/static/assets/index-Dmzvedun.css +1 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/static/index.html +2 -2
- serpentine_parser-0.4.0/src/serpentine/vcs/__init__.py +4 -0
- serpentine_parser-0.4.0/src/serpentine/vcs/backend.py +130 -0
- serpentine_parser-0.4.0/src/serpentine/vcs/manager.py +91 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/watcher.py +1 -1
- serpentine_parser-0.3.2/src/serpentine/static/assets/index-CqW3OZBs.css +0 -1
- serpentine_parser-0.3.2/src/serpentine/static/assets/index-DGBMc5dK.js +0 -32
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/LICENSE +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/CLAUDE.md +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/events.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/graph/builder.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/graph/loaders.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/graph/mod.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/graph/pdg.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/graph/resolvers.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/javascript/config.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/javascript/mod.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/lib.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/message_bus.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/python/config.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/python/mod.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/rust_lang/config.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/rust_lang/mod.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/README.md +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/code_snippet.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/decorators.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/definitions.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/event_counter.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/imports.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/mod.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/pdg.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/raw_bindings.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/scope_tree.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/types.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/uses.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/terraform/config.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/terraform/mod.rs +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/_analyzer.pyi +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/config.py +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/server/__init__.py +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/server/websocket.py +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/skills/__init__.py +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/skills/agents-md.md +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/skills/code-analysis.md +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/skills/copilot-instructions.md +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/skills/cursor-rules.md +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/static/assets/elk-worker.min-BqREE3LP.js +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/static/assets/layoutWorker-DEFCbISa.js +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/static/assets/mermaidExport-DphjeTrR.js +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/static/assets/svgToPng-C-twkIuO.js +0 -0
- {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/static/logo.svg +0 -0
|
@@ -1,3 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: serpentine-parser
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Classifier: Development Status :: 4 - Beta
|
|
5
|
+
Classifier: Environment :: Console
|
|
6
|
+
Classifier: Environment :: Web Environment
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Programming Language :: Rust
|
|
17
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Dist: watchdog>=6.0.0
|
|
20
|
+
Requires-Dist: click>=8.1.0
|
|
21
|
+
Requires-Dist: starlette>=1.3.1
|
|
22
|
+
Requires-Dist: uvicorn[standard]>=0.30.0
|
|
23
|
+
Requires-Dist: websockets>=12.0
|
|
24
|
+
Requires-Dist: pygit2>=1.15 ; extra == 'git'
|
|
25
|
+
Provides-Extra: git
|
|
26
|
+
Provides-Extra: hg
|
|
27
|
+
Provides-Extra: svn
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Summary: Fast dependency graph analysis and visualization for Python, JavaScript/TypeScript, Rust, and Terraform projects
|
|
30
|
+
Keywords: dependency-graph,visualization,static-analysis,python,javascript,rust,terraform,tree-sitter
|
|
31
|
+
Author: Christopher Garretty
|
|
32
|
+
Requires-Python: >=3.10
|
|
33
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
34
|
+
Project-URL: Homepage, https://github.com/serpentine-parser/serpentine
|
|
35
|
+
Project-URL: Issues, https://github.com/serpentine-parser/serpentine/issues
|
|
36
|
+
Project-URL: Repository, https://github.com/serpentine-parser/serpentine
|
|
37
|
+
|
|
1
38
|
<p align="center">
|
|
2
39
|
<img src="frontend/public/logo.svg" alt="Serpentine" width="64" />
|
|
3
40
|
</p>
|
|
@@ -18,6 +55,7 @@ Serpentine builds a **code reference graph** for Python, JavaScript/TypeScript,
|
|
|
18
55
|
- **Full reference resolution**: Traces calls, type annotations, assignments, and inheritance — not just import edges
|
|
19
56
|
- **Interactive graph**: Expandable nodes, search, pan/zoom, collapsible modules
|
|
20
57
|
- **Real-time updates**: File watcher pushes changes via WebSocket
|
|
58
|
+
- **VCS integration**: Compare any two git refs and see added, modified, and deleted nodes highlighted on the graph
|
|
21
59
|
- **Agent-ready CLI**: Structured JSON output with selectors for use in AI agent workflows
|
|
22
60
|
|
|
23
61
|
### What counts as a code reference?
|
|
@@ -307,6 +345,52 @@ Each edge has a `from` and `to` field with node IDs, and a `type` field:
|
|
|
307
345
|
|
|
308
346
|
---
|
|
309
347
|
|
|
348
|
+
## VCS Integration
|
|
349
|
+
|
|
350
|
+
When Serpentine is run inside a git repository, it automatically detects available refs (branches, tags, and recent commits) and enables the **compare** toolbar in the UI.
|
|
351
|
+
|
|
352
|
+
### Comparing refs
|
|
353
|
+
|
|
354
|
+
Click the compare toolbar to select a baseline ref and a target ref. Serpentine analyzes the graph at each ref and overlays change status on every node:
|
|
355
|
+
|
|
356
|
+
| Status | Meaning |
|
|
357
|
+
|---|---|
|
|
358
|
+
| `modified` | Node exists in both refs but its dependencies changed |
|
|
359
|
+
| `added` | Node exists in the target ref but not the baseline |
|
|
360
|
+
| `deleted` | Node existed in the baseline ref but was removed |
|
|
361
|
+
|
|
362
|
+
Unchanged nodes remain visible for context.
|
|
363
|
+
|
|
364
|
+
### Filtering by state
|
|
365
|
+
|
|
366
|
+
Use the `state:` selector method in the search bar to filter the graph to only changed nodes:
|
|
367
|
+
|
|
368
|
+
```
|
|
369
|
+
state:modified
|
|
370
|
+
state:added
|
|
371
|
+
state:deleted
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Selectors compose with all graph operators:
|
|
375
|
+
|
|
376
|
+
```
|
|
377
|
+
# All nodes modified on the current branch, plus their upstream dependencies
|
|
378
|
+
+state:modified
|
|
379
|
+
|
|
380
|
+
# Full connected component of every added node
|
|
381
|
+
@state:added
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Checkpoints
|
|
385
|
+
|
|
386
|
+
The **checkpoint** button marks the current live graph state as the comparison baseline. This is useful when you want to track changes made during a session rather than comparing against a git ref.
|
|
387
|
+
|
|
388
|
+
### Configuration
|
|
389
|
+
|
|
390
|
+
Files excluded via `.serpentine.yml` (`exclude_dirs`, `exclude_patterns`) are excluded from VCS snapshot analysis as well — ensuring consistent results between live analysis and historical comparison.
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
310
394
|
## Configuration
|
|
311
395
|
|
|
312
396
|
Serpentine looks for `.serpentine.yml` or `serpentine.yml` in the project root. All settings are optional.
|
|
@@ -448,3 +532,4 @@ These three events are the minimum required for the graph builder to resolve all
|
|
|
448
532
|
## License
|
|
449
533
|
|
|
450
534
|
[Apache 2.0](LICENSE)
|
|
535
|
+
|
|
@@ -1,36 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: serpentine-parser
|
|
3
|
-
Version: 0.3.2
|
|
4
|
-
Classifier: Development Status :: 4 - Beta
|
|
5
|
-
Classifier: Environment :: Console
|
|
6
|
-
Classifier: Environment :: Web Environment
|
|
7
|
-
Classifier: Intended Audience :: Developers
|
|
8
|
-
Classifier: License :: OSI Approved :: Apache Software License
|
|
9
|
-
Classifier: Operating System :: OS Independent
|
|
10
|
-
Classifier: Programming Language :: Python :: 3
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
-
Classifier: Programming Language :: Rust
|
|
17
|
-
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
|
-
Classifier: Topic :: Utilities
|
|
19
|
-
Requires-Dist: watchdog>=6.0.0
|
|
20
|
-
Requires-Dist: click>=8.1.0
|
|
21
|
-
Requires-Dist: starlette>=0.38.0
|
|
22
|
-
Requires-Dist: uvicorn[standard]>=0.30.0
|
|
23
|
-
Requires-Dist: websockets>=12.0
|
|
24
|
-
License-File: LICENSE
|
|
25
|
-
Summary: Fast dependency graph analysis and visualization for Python, JavaScript/TypeScript, Rust, and Terraform projects
|
|
26
|
-
Keywords: dependency-graph,visualization,static-analysis,python,javascript,rust,terraform,tree-sitter
|
|
27
|
-
Author: Christopher Garretty
|
|
28
|
-
Requires-Python: >=3.10
|
|
29
|
-
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
30
|
-
Project-URL: Homepage, https://github.com/serpentine-parser/serpentine
|
|
31
|
-
Project-URL: Issues, https://github.com/serpentine-parser/serpentine/issues
|
|
32
|
-
Project-URL: Repository, https://github.com/serpentine-parser/serpentine
|
|
33
|
-
|
|
34
1
|
<p align="center">
|
|
35
2
|
<img src="frontend/public/logo.svg" alt="Serpentine" width="64" />
|
|
36
3
|
</p>
|
|
@@ -51,6 +18,7 @@ Serpentine builds a **code reference graph** for Python, JavaScript/TypeScript,
|
|
|
51
18
|
- **Full reference resolution**: Traces calls, type annotations, assignments, and inheritance — not just import edges
|
|
52
19
|
- **Interactive graph**: Expandable nodes, search, pan/zoom, collapsible modules
|
|
53
20
|
- **Real-time updates**: File watcher pushes changes via WebSocket
|
|
21
|
+
- **VCS integration**: Compare any two git refs and see added, modified, and deleted nodes highlighted on the graph
|
|
54
22
|
- **Agent-ready CLI**: Structured JSON output with selectors for use in AI agent workflows
|
|
55
23
|
|
|
56
24
|
### What counts as a code reference?
|
|
@@ -340,6 +308,52 @@ Each edge has a `from` and `to` field with node IDs, and a `type` field:
|
|
|
340
308
|
|
|
341
309
|
---
|
|
342
310
|
|
|
311
|
+
## VCS Integration
|
|
312
|
+
|
|
313
|
+
When Serpentine is run inside a git repository, it automatically detects available refs (branches, tags, and recent commits) and enables the **compare** toolbar in the UI.
|
|
314
|
+
|
|
315
|
+
### Comparing refs
|
|
316
|
+
|
|
317
|
+
Click the compare toolbar to select a baseline ref and a target ref. Serpentine analyzes the graph at each ref and overlays change status on every node:
|
|
318
|
+
|
|
319
|
+
| Status | Meaning |
|
|
320
|
+
|---|---|
|
|
321
|
+
| `modified` | Node exists in both refs but its dependencies changed |
|
|
322
|
+
| `added` | Node exists in the target ref but not the baseline |
|
|
323
|
+
| `deleted` | Node existed in the baseline ref but was removed |
|
|
324
|
+
|
|
325
|
+
Unchanged nodes remain visible for context.
|
|
326
|
+
|
|
327
|
+
### Filtering by state
|
|
328
|
+
|
|
329
|
+
Use the `state:` selector method in the search bar to filter the graph to only changed nodes:
|
|
330
|
+
|
|
331
|
+
```
|
|
332
|
+
state:modified
|
|
333
|
+
state:added
|
|
334
|
+
state:deleted
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Selectors compose with all graph operators:
|
|
338
|
+
|
|
339
|
+
```
|
|
340
|
+
# All nodes modified on the current branch, plus their upstream dependencies
|
|
341
|
+
+state:modified
|
|
342
|
+
|
|
343
|
+
# Full connected component of every added node
|
|
344
|
+
@state:added
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Checkpoints
|
|
348
|
+
|
|
349
|
+
The **checkpoint** button marks the current live graph state as the comparison baseline. This is useful when you want to track changes made during a session rather than comparing against a git ref.
|
|
350
|
+
|
|
351
|
+
### Configuration
|
|
352
|
+
|
|
353
|
+
Files excluded via `.serpentine.yml` (`exclude_dirs`, `exclude_patterns`) are excluded from VCS snapshot analysis as well — ensuring consistent results between live analysis and historical comparison.
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
343
357
|
## Configuration
|
|
344
358
|
|
|
345
359
|
Serpentine looks for `.serpentine.yml` or `serpentine.yml` in the project root. All settings are optional.
|
|
@@ -481,4 +495,3 @@ These three events are the minimum required for the graph builder to resolve all
|
|
|
481
495
|
## License
|
|
482
496
|
|
|
483
497
|
[Apache 2.0](LICENSE)
|
|
484
|
-
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "serpentine-parser"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Fast dependency graph analysis and visualization for Python, JavaScript/TypeScript, Rust, and Terraform projects"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -30,11 +30,16 @@ classifiers = [
|
|
|
30
30
|
dependencies = [
|
|
31
31
|
"watchdog>=6.0.0",
|
|
32
32
|
"click>=8.1.0",
|
|
33
|
-
"starlette>=
|
|
33
|
+
"starlette>=1.3.1",
|
|
34
34
|
"uvicorn[standard]>=0.30.0",
|
|
35
35
|
"websockets>=12.0",
|
|
36
36
|
]
|
|
37
37
|
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
git = ["pygit2>=1.15"]
|
|
40
|
+
hg = []
|
|
41
|
+
svn = []
|
|
42
|
+
|
|
38
43
|
[project.urls]
|
|
39
44
|
Homepage = "https://github.com/serpentine-parser/serpentine"
|
|
40
45
|
Repository = "https://github.com/serpentine-parser/serpentine"
|
|
@@ -184,9 +184,9 @@ dependencies = [
|
|
|
184
184
|
|
|
185
185
|
[[package]]
|
|
186
186
|
name = "quote"
|
|
187
|
-
version = "1.0.
|
|
187
|
+
version = "1.0.46"
|
|
188
188
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
189
|
-
checksum = "
|
|
189
|
+
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
|
190
190
|
dependencies = [
|
|
191
191
|
"proc-macro2",
|
|
192
192
|
]
|
|
@@ -286,7 +286,7 @@ dependencies = [
|
|
|
286
286
|
|
|
287
287
|
[[package]]
|
|
288
288
|
name = "serpentine"
|
|
289
|
-
version = "0.
|
|
289
|
+
version = "0.4.0"
|
|
290
290
|
dependencies = [
|
|
291
291
|
"indexmap",
|
|
292
292
|
"pyo3",
|
|
@@ -180,3 +180,76 @@ class PerFileCacheManager:
|
|
|
180
180
|
entry.write_text(results_json, encoding="utf-8")
|
|
181
181
|
except Exception as e:
|
|
182
182
|
logger.debug(f"Per-file cache save failed for {file_path}: {e}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
REFS_CACHE_SUBDIR = "refs"
|
|
186
|
+
# Bump when VCS ref cache format changes in a breaking way.
|
|
187
|
+
VCS_REF_CACHE_VERSION = 1
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class VcsRefCacheManager:
|
|
191
|
+
"""
|
|
192
|
+
Cache for graphs computed from historical VCS commits.
|
|
193
|
+
|
|
194
|
+
Stored under .serpentine/refs/<key>.json, keyed by
|
|
195
|
+
sha256(commit_hash + ":" + config_fingerprint)[:24].
|
|
196
|
+
|
|
197
|
+
Historical commits are immutable so entries never expire — only
|
|
198
|
+
invalidated when the analyzer binary changes (same sentinel pattern
|
|
199
|
+
as PerFileCacheManager).
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, project_path: Path) -> None:
|
|
203
|
+
self._cache_dir = project_path / CACHE_DIR / REFS_CACHE_SUBDIR
|
|
204
|
+
self._ensure_version()
|
|
205
|
+
|
|
206
|
+
def _version_tag(self) -> str:
|
|
207
|
+
package_dir = Path(__file__).parent
|
|
208
|
+
binary = _find_analyzer_binary(package_dir)
|
|
209
|
+
binary_mtime: int = 0
|
|
210
|
+
if binary:
|
|
211
|
+
try:
|
|
212
|
+
binary_mtime = binary.stat().st_mtime_ns
|
|
213
|
+
except OSError:
|
|
214
|
+
pass
|
|
215
|
+
return f"{VCS_REF_CACHE_VERSION}:{binary_mtime}"
|
|
216
|
+
|
|
217
|
+
def _ensure_version(self) -> None:
|
|
218
|
+
version_file = self._cache_dir / ".version"
|
|
219
|
+
expected = self._version_tag()
|
|
220
|
+
try:
|
|
221
|
+
if version_file.exists() and version_file.read_text(encoding="utf-8").strip() == expected:
|
|
222
|
+
return
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
try:
|
|
226
|
+
if self._cache_dir.exists():
|
|
227
|
+
shutil.rmtree(self._cache_dir)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logger.debug(f"VCS ref cache clear failed: {e}")
|
|
230
|
+
try:
|
|
231
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
version_file.write_text(expected + "\n", encoding="utf-8")
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.debug(f"VCS ref cache version write failed: {e}")
|
|
235
|
+
|
|
236
|
+
def _entry_path(self, commit_hash: str, config_fp: str) -> Path:
|
|
237
|
+
key = hashlib.sha256(f"{commit_hash}:{config_fp}".encode()).hexdigest()[:24]
|
|
238
|
+
return self._cache_dir / f"{key}.json"
|
|
239
|
+
|
|
240
|
+
def get(self, commit_hash: str, config_fp: str) -> str | None:
|
|
241
|
+
try:
|
|
242
|
+
entry = self._entry_path(commit_hash, config_fp)
|
|
243
|
+
if entry.exists():
|
|
244
|
+
return entry.read_text(encoding="utf-8")
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.debug(f"VCS ref cache load failed: {e}")
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
def put(self, commit_hash: str, config_fp: str, graph_json: str) -> None:
|
|
250
|
+
try:
|
|
251
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
entry = self._entry_path(commit_hash, config_fp)
|
|
253
|
+
entry.write_text(graph_json, encoding="utf-8")
|
|
254
|
+
except Exception as e:
|
|
255
|
+
logger.debug(f"VCS ref cache save failed: {e}")
|
|
@@ -23,9 +23,12 @@ import click
|
|
|
23
23
|
import uvicorn
|
|
24
24
|
|
|
25
25
|
from serpentine import __version__
|
|
26
|
+
from serpentine.cache import VcsRefCacheManager
|
|
26
27
|
from serpentine.selector import GraphSelector, filter_by_state
|
|
27
28
|
from serpentine.server import create_app
|
|
28
29
|
from serpentine.state import GraphStateManager
|
|
30
|
+
from serpentine.vcs.backend import detect_backend
|
|
31
|
+
from serpentine.vcs.manager import VcsManager
|
|
29
32
|
from serpentine.watcher import FileWatcher
|
|
30
33
|
|
|
31
34
|
_DEFAULT_CONFIG_YAML = """\
|
|
@@ -135,8 +138,15 @@ def serve(
|
|
|
135
138
|
f"✅ Found {state_manager.node_count} nodes, {state_manager.edge_count} edges"
|
|
136
139
|
)
|
|
137
140
|
|
|
141
|
+
# Set up VCS manager if a backend is available
|
|
142
|
+
vcs_manager: VcsManager | None = None
|
|
143
|
+
backend = detect_backend(project_path)
|
|
144
|
+
if backend is not None:
|
|
145
|
+
vcs_manager = VcsManager(backend, VcsRefCacheManager(project_path), state_manager.config)
|
|
146
|
+
click.echo("🔀 VCS integration enabled")
|
|
147
|
+
|
|
138
148
|
# Create the web application
|
|
139
|
-
app = create_app(state_manager, static_dir=_get_static_dir())
|
|
149
|
+
app = create_app(state_manager, static_dir=_get_static_dir(), vcs_manager=vcs_manager)
|
|
140
150
|
|
|
141
151
|
# Set up file watcher if enabled
|
|
142
152
|
watcher: FileWatcher | None = None
|
|
@@ -255,6 +265,13 @@ def serve(
|
|
|
255
265
|
default=False,
|
|
256
266
|
help="Include assignment nodes in text output (default: excluded, only with --format text)",
|
|
257
267
|
)
|
|
268
|
+
@click.option(
|
|
269
|
+
"--compare",
|
|
270
|
+
"compare_ref",
|
|
271
|
+
type=str,
|
|
272
|
+
default=None,
|
|
273
|
+
help="VCS ref to compare against (branch, tag, or commit hash). Shows added/removed/modified nodes.",
|
|
274
|
+
)
|
|
258
275
|
def analyze(
|
|
259
276
|
path: str,
|
|
260
277
|
output: str | None,
|
|
@@ -270,6 +287,7 @@ def analyze(
|
|
|
270
287
|
fmt: str,
|
|
271
288
|
source: bool,
|
|
272
289
|
include_assignments: bool,
|
|
290
|
+
compare_ref: str | None,
|
|
273
291
|
) -> None:
|
|
274
292
|
"""Analyze a project and output the code reference graph.
|
|
275
293
|
|
|
@@ -287,6 +305,20 @@ def analyze(
|
|
|
287
305
|
state_manager = GraphStateManager(project_path)
|
|
288
306
|
state_manager.analyze_project(project_path, force_fresh=source)
|
|
289
307
|
|
|
308
|
+
if compare_ref is not None:
|
|
309
|
+
backend = detect_backend(project_path)
|
|
310
|
+
if backend is None:
|
|
311
|
+
raise click.ClickException(
|
|
312
|
+
"No VCS backend found. Is this a git repo? "
|
|
313
|
+
"Install git support with: pip install serpentine[git]"
|
|
314
|
+
)
|
|
315
|
+
try:
|
|
316
|
+
vcs_manager = VcsManager(backend, VcsRefCacheManager(project_path), state_manager.config)
|
|
317
|
+
from_graph_json = vcs_manager.get_graph_at(compare_ref)
|
|
318
|
+
except Exception as e:
|
|
319
|
+
raise click.ClickException(f"Could not resolve ref '{compare_ref}': {e}") from e
|
|
320
|
+
state_manager.set_vcs_comparison(from_graph_json, to_graph_json=None)
|
|
321
|
+
|
|
290
322
|
# Get the graph data as a dict for post-processing
|
|
291
323
|
graph_data = state_manager.get_graph_data()
|
|
292
324
|
|
|
@@ -421,6 +453,13 @@ def analyze(
|
|
|
421
453
|
default="text",
|
|
422
454
|
help="Output format: text (default) or json",
|
|
423
455
|
)
|
|
456
|
+
@click.option(
|
|
457
|
+
"--compare",
|
|
458
|
+
"compare_ref",
|
|
459
|
+
type=str,
|
|
460
|
+
default=None,
|
|
461
|
+
help="VCS ref to compare against (branch, tag, or commit hash). Shows added/removed/modified nodes.",
|
|
462
|
+
)
|
|
424
463
|
def catalog(
|
|
425
464
|
path: str,
|
|
426
465
|
filters: tuple[str, ...],
|
|
@@ -431,6 +470,7 @@ def catalog(
|
|
|
431
470
|
pretty: bool,
|
|
432
471
|
state: str | None,
|
|
433
472
|
fmt: str,
|
|
473
|
+
compare_ref: str | None,
|
|
434
474
|
) -> None:
|
|
435
475
|
"""Flat list of all nodes for agent discovery.
|
|
436
476
|
|
|
@@ -448,6 +488,20 @@ def catalog(
|
|
|
448
488
|
state_manager = GraphStateManager(project_path)
|
|
449
489
|
state_manager.analyze_project(project_path)
|
|
450
490
|
|
|
491
|
+
if compare_ref is not None:
|
|
492
|
+
backend = detect_backend(project_path)
|
|
493
|
+
if backend is None:
|
|
494
|
+
raise click.ClickException(
|
|
495
|
+
"No VCS backend found. Is this a git repo? "
|
|
496
|
+
"Install git support with: pip install serpentine[git]"
|
|
497
|
+
)
|
|
498
|
+
try:
|
|
499
|
+
vcs_manager = VcsManager(backend, VcsRefCacheManager(project_path), state_manager.config)
|
|
500
|
+
from_graph_json = vcs_manager.get_graph_at(compare_ref)
|
|
501
|
+
except Exception as e:
|
|
502
|
+
raise click.ClickException(f"Could not resolve ref '{compare_ref}': {e}") from e
|
|
503
|
+
state_manager.set_vcs_comparison(from_graph_json, to_graph_json=None)
|
|
504
|
+
|
|
451
505
|
graph_data = state_manager.get_graph_data()
|
|
452
506
|
|
|
453
507
|
if fmt == "text":
|
|
@@ -173,8 +173,12 @@ class GraphSelector:
|
|
|
173
173
|
|
|
174
174
|
if at_op:
|
|
175
175
|
# @pattern — full connected component
|
|
176
|
-
matched = self._match_pattern(base_pattern)
|
|
177
|
-
|
|
176
|
+
matched = self._match_state(base_pattern[len("state:"):]) if base_pattern.startswith("state:") else self._match_pattern(base_pattern)
|
|
177
|
+
roots: Set[str] = set()
|
|
178
|
+
for node_id in matched:
|
|
179
|
+
roots.add(node_id)
|
|
180
|
+
roots.update(self._get_all_descendants(node_id))
|
|
181
|
+
selected.update(self._expand_component(roots))
|
|
178
182
|
continue
|
|
179
183
|
|
|
180
184
|
# left_n/right_n are None when the +/N+ prefix/suffix is absent entirely.
|
|
@@ -185,7 +189,7 @@ class GraphSelector:
|
|
|
185
189
|
up_depth = int(left_n) if left_n else None # None = unlimited
|
|
186
190
|
down_depth = int(right_n) if right_n else None # None = unlimited
|
|
187
191
|
|
|
188
|
-
matched = self._match_pattern(base_pattern)
|
|
192
|
+
matched = self._match_state(base_pattern[len("state:"):]) if base_pattern.startswith("state:") else self._match_pattern(base_pattern)
|
|
189
193
|
expanded = matched.copy()
|
|
190
194
|
|
|
191
195
|
for node_id in matched:
|
|
@@ -222,6 +226,14 @@ class GraphSelector:
|
|
|
222
226
|
queue.append((neighbor, depth + 1))
|
|
223
227
|
return visited
|
|
224
228
|
|
|
229
|
+
def _match_state(self, value: str) -> Set[str]:
|
|
230
|
+
"""Match nodes by change_status value."""
|
|
231
|
+
return {
|
|
232
|
+
node_id
|
|
233
|
+
for node_id, node in self.flat_nodes.items()
|
|
234
|
+
if node.get("change_status") == value
|
|
235
|
+
}
|
|
236
|
+
|
|
225
237
|
def _match_pattern(self, pattern: str) -> Set[str]:
|
|
226
238
|
"""Match pattern against flat node ids using glob matching."""
|
|
227
239
|
# Normalize ** to * — in dotted IDs, * already matches dots
|
|
@@ -28,6 +28,7 @@ from starlette.staticfiles import StaticFiles
|
|
|
28
28
|
|
|
29
29
|
from serpentine.server.routes import create_routes
|
|
30
30
|
from serpentine.server.websocket import ConnectionManager, set_connection_manager
|
|
31
|
+
from serpentine.vcs.manager import VcsManager
|
|
31
32
|
|
|
32
33
|
if TYPE_CHECKING:
|
|
33
34
|
from serpentine.state import GraphStateManager
|
|
@@ -42,6 +43,7 @@ def create_app(
|
|
|
42
43
|
state_manager: "GraphStateManager",
|
|
43
44
|
static_dir: Path | None = None,
|
|
44
45
|
debug: bool = False,
|
|
46
|
+
vcs_manager: VcsManager | None = None,
|
|
45
47
|
) -> Starlette:
|
|
46
48
|
"""
|
|
47
49
|
Create and configure the Starlette application.
|
|
@@ -91,7 +93,7 @@ def create_app(
|
|
|
91
93
|
_event_loop = None
|
|
92
94
|
|
|
93
95
|
# Create API routes
|
|
94
|
-
routes = create_routes(state_manager, connection_manager)
|
|
96
|
+
routes = create_routes(state_manager, connection_manager, vcs_manager)
|
|
95
97
|
|
|
96
98
|
# Add static file serving if directory has a built frontend
|
|
97
99
|
if static_dir and (static_dir / "index.html").exists():
|
|
@@ -22,6 +22,7 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
|
22
22
|
|
|
23
23
|
from serpentine.selector import GraphSelector, filter_by_state
|
|
24
24
|
from serpentine.server.websocket import ConnectionManager
|
|
25
|
+
from serpentine.vcs.manager import VcsManager
|
|
25
26
|
|
|
26
27
|
if TYPE_CHECKING:
|
|
27
28
|
from serpentine.state import GraphStateManager
|
|
@@ -32,6 +33,7 @@ logger = logging.getLogger(__name__)
|
|
|
32
33
|
def create_routes(
|
|
33
34
|
state_manager: "GraphStateManager",
|
|
34
35
|
connection_manager: ConnectionManager,
|
|
36
|
+
vcs_manager: VcsManager | None = None,
|
|
35
37
|
) -> list[Route | WebSocketRoute]:
|
|
36
38
|
"""
|
|
37
39
|
Create all HTTP and WebSocket routes for the application.
|
|
@@ -178,6 +180,19 @@ def create_routes(
|
|
|
178
180
|
yield node
|
|
179
181
|
yield from _count_nodes(node.get("children", []))
|
|
180
182
|
|
|
183
|
+
async def get_vcs_refs(request: Request) -> Response:
|
|
184
|
+
"""Return available VCS refs for the ref picker."""
|
|
185
|
+
if vcs_manager is None:
|
|
186
|
+
return Response(
|
|
187
|
+
content=json.dumps({"available": False, "refs": []}),
|
|
188
|
+
media_type="application/json",
|
|
189
|
+
)
|
|
190
|
+
refs = [{"id": r.id, "display": r.display, "kind": r.kind} for r in vcs_manager.list_refs()]
|
|
191
|
+
return Response(
|
|
192
|
+
content=json.dumps({"available": True, "refs": refs}),
|
|
193
|
+
media_type="application/json",
|
|
194
|
+
)
|
|
195
|
+
|
|
181
196
|
async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
182
197
|
"""
|
|
183
198
|
WebSocket endpoint for real-time graph updates.
|
|
@@ -231,6 +246,54 @@ def create_routes(
|
|
|
231
246
|
"type": "node_code",
|
|
232
247
|
"data": {"qualname": qualname, "code": code},
|
|
233
248
|
})
|
|
249
|
+
elif data.get("action") == "set_vcs_comparison":
|
|
250
|
+
action_data = data.get("data", {})
|
|
251
|
+
from_ref: str = action_data.get("from", "@start")
|
|
252
|
+
to_ref: str = action_data.get("to", "@current")
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
# Resolve from_graph_json
|
|
256
|
+
if from_ref == "@current":
|
|
257
|
+
from_graph_json = json.dumps(state_manager._graph_data)
|
|
258
|
+
elif from_ref == "@start":
|
|
259
|
+
from_graph_json = json.dumps(state_manager._start_graph_data)
|
|
260
|
+
elif vcs_manager is None:
|
|
261
|
+
await websocket.send_json({"type": "error", "data": {"message": "VCS not available"}})
|
|
262
|
+
continue
|
|
263
|
+
else:
|
|
264
|
+
valid_ids = {r.id for r in vcs_manager.list_refs()}
|
|
265
|
+
if from_ref not in valid_ids:
|
|
266
|
+
await websocket.send_json({"type": "error", "data": {"message": f"Unknown ref: {from_ref}"}})
|
|
267
|
+
continue
|
|
268
|
+
from_graph_json = vcs_manager.get_graph_at(from_ref)
|
|
269
|
+
|
|
270
|
+
# Resolve to_graph_json
|
|
271
|
+
to_graph_json: str | None
|
|
272
|
+
if to_ref == "@current":
|
|
273
|
+
to_graph_json = None
|
|
274
|
+
elif to_ref == "@start":
|
|
275
|
+
to_graph_json = json.dumps(state_manager._start_graph_data)
|
|
276
|
+
elif vcs_manager is None:
|
|
277
|
+
await websocket.send_json({"type": "error", "data": {"message": "VCS not available"}})
|
|
278
|
+
continue
|
|
279
|
+
else:
|
|
280
|
+
valid_ids = {r.id for r in vcs_manager.list_refs()}
|
|
281
|
+
if to_ref not in valid_ids:
|
|
282
|
+
await websocket.send_json({"type": "error", "data": {"message": f"Unknown ref: {to_ref}"}})
|
|
283
|
+
continue
|
|
284
|
+
to_graph_json = vcs_manager.get_graph_at(to_ref)
|
|
285
|
+
|
|
286
|
+
state_manager.set_vcs_comparison(from_graph_json, to_graph_json)
|
|
287
|
+
await connection_manager.send_graph_update(state_manager.get_graph_json())
|
|
288
|
+
except Exception as e:
|
|
289
|
+
logger.error(f"[vcs] set_vcs_comparison failed: {e}", exc_info=True)
|
|
290
|
+
await websocket.send_json({"type": "error", "data": {"message": f"Comparison failed: {e}"}})
|
|
291
|
+
elif data.get("action") == "clear_vcs_comparison":
|
|
292
|
+
state_manager.clear_vcs_comparison()
|
|
293
|
+
await connection_manager.send_graph_update(state_manager.get_graph_json())
|
|
294
|
+
elif data.get("action") == "update_start":
|
|
295
|
+
state_manager.update_start()
|
|
296
|
+
await connection_manager.send_graph_update(state_manager.get_graph_json())
|
|
234
297
|
else:
|
|
235
298
|
logger.debug(f"Unknown message type: {data.get('type')}")
|
|
236
299
|
|
|
@@ -244,5 +307,6 @@ def create_routes(
|
|
|
244
307
|
Route("/api/health", health, methods=["GET"]),
|
|
245
308
|
Route("/api/graph", get_graph, methods=["GET"]),
|
|
246
309
|
Route("/api/catalog", get_catalog, methods=["GET"]),
|
|
310
|
+
Route("/api/vcs/refs", get_vcs_refs, methods=["GET"]),
|
|
247
311
|
WebSocketRoute("/ws", websocket_endpoint),
|
|
248
312
|
]
|