srcly 0.1.34__tar.gz → 0.3.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.
- {srcly-0.1.34 → srcly-0.3.0}/.gitignore +3 -0
- {srcly-0.1.34 → srcly-0.3.0}/PKG-INFO +6 -6
- {srcly-0.1.34 → srcly-0.3.0}/README.md +3 -3
- srcly-0.3.0/app/config.py +139 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/main.py +11 -8
- srcly-0.3.0/app/models.py +105 -0
- srcly-0.3.0/app/routers/analysis.py +131 -0
- srcly-0.3.0/app/routers/compare.py +171 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/routers/files.py +26 -3
- {srcly-0.1.34 → srcly-0.3.0}/app/routers/scans.py +11 -6
- {srcly-0.1.34 → srcly-0.3.0}/app/run.py +73 -1
- {srcly-0.1.34 → srcly-0.3.0}/app/services/analysis.py +591 -65
- {srcly-0.1.34 → srcly-0.3.0}/app/services/analysis_types.py +9 -0
- srcly-0.3.0/app/services/cache.py +261 -0
- srcly-0.3.0/app/services/compare/__init__.py +8 -0
- srcly-0.3.0/app/services/compare/diff.py +315 -0
- srcly-0.3.0/app/services/compare/git_refs.py +429 -0
- srcly-0.3.0/app/services/compare/github.py +177 -0
- srcly-0.3.0/app/services/compare/jobs.py +199 -0
- srcly-0.3.0/app/services/compare/service.py +194 -0
- srcly-0.3.0/app/services/compare/snapshots.py +322 -0
- srcly-0.3.0/app/services/compare/sources.py +216 -0
- srcly-0.3.0/app/services/deps/__init__.py +0 -0
- srcly-0.3.0/app/services/deps/common.py +191 -0
- srcly-0.3.0/app/services/deps/graph.py +171 -0
- srcly-0.3.0/app/services/deps/python_deps.py +402 -0
- srcly-0.3.0/app/services/deps/tsconfig.py +181 -0
- srcly-0.3.0/app/services/deps/typescript_deps.py +466 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/services/python/python_analysis.py +6 -5
- {srcly-0.1.34 → srcly-0.3.0}/app/services/reporting.py +6 -5
- {srcly-0.1.34 → srcly-0.3.0}/app/services/scan_jobs.py +99 -9
- {srcly-0.1.34 → srcly-0.3.0}/app/services/scan_workers.py +38 -2
- {srcly-0.1.34 → srcly-0.3.0}/app/services/typescript/typescript_analysis.py +6 -185
- srcly-0.3.0/app/static/assets/CodeModal-CTBbqRv_.js +29 -0
- srcly-0.3.0/app/static/assets/CompareApp-BSOMRmdx.js +3 -0
- srcly-0.3.0/app/static/assets/DependencyView-dzVV2_Up.js +1 -0
- srcly-0.3.0/app/static/assets/index-BVNn0QLM.js +2 -0
- srcly-0.3.0/app/static/assets/index-D2ClTmny.css +1 -0
- srcly-0.3.0/app/static/assets/layout-DSK4VztQ.js +1 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/index.html +2 -2
- {srcly-0.1.34 → srcly-0.3.0}/pyproject.toml +3 -3
- srcly-0.1.34/app/config.py +0 -74
- srcly-0.1.34/app/models.py +0 -76
- srcly-0.1.34/app/routers/analysis.py +0 -607
- srcly-0.1.34/app/services/cache.py +0 -19
- srcly-0.1.34/app/static/assets/CodeModal-BBkwbpez.js +0 -29
- srcly-0.1.34/app/static/assets/DependencyGraph-9gIcsNut.js +0 -1
- srcly-0.1.34/app/static/assets/dependencyForce.worker-WVMkyynK.js +0 -1
- srcly-0.1.34/app/static/assets/elk-worker.min-DGCccKuH.js +0 -6386
- srcly-0.1.34/app/static/assets/index-64jBfyHS.css +0 -1
- srcly-0.1.34/app/static/assets/index-CPpALji7.js +0 -2
- {srcly-0.1.34 → srcly-0.3.0}/app/.gitignore +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/__init__.py +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/services/css/css_analysis.py +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/services/ipynb/ipynb_analysis.py +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/services/markdown/markdown_analysis.py +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/services/scan_progress.py +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/bash-DgJyDXW6.js +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/css-PZBBrkI0.js +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/github-light-Rcb2q3L5.js +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/highlight.worker-C64dOQ1-.js +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/html-DOf0XnR2.js +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/javascript-WyGyM1wV.js +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/json-B8JryHcM.js +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/jsx-8cLv2Six.js +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/markdown-DX-TNHnc.js +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/python-B6Ihcp-E.js +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/toml-Ms459DBw.js +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/tsx-D6116Ycy.js +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/typescript-BqvgUolh.js +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/wasm-CG6Dc4jp.js +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/yaml-Bopit2L3.js +0 -0
- {srcly-0.1.34 → srcly-0.3.0}/app/static/favicon.svg +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: srcly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Interactive codebase treemap and metrics viewer.
|
|
5
5
|
Requires-Python: >=3.13
|
|
6
6
|
Requires-Dist: fastapi>=0.121.3
|
|
7
7
|
Requires-Dist: httpx>=0.28.1
|
|
8
|
-
Requires-Dist: lizard>=1.
|
|
8
|
+
Requires-Dist: lizard>=1.21.7
|
|
9
9
|
Requires-Dist: pathspec>=0.12.1
|
|
10
10
|
Requires-Dist: pydantic>=2.12.4
|
|
11
11
|
Requires-Dist: pytest>=9.0.1
|
|
@@ -14,7 +14,7 @@ Requires-Dist: tree-sitter-markdown<0.6.0,>=0.5.1
|
|
|
14
14
|
Requires-Dist: tree-sitter-python==0.25.0
|
|
15
15
|
Requires-Dist: tree-sitter-scss==1.0.0
|
|
16
16
|
Requires-Dist: tree-sitter-typescript>=0.23.2
|
|
17
|
-
Requires-Dist: tree-sitter
|
|
17
|
+
Requires-Dist: tree-sitter<0.26,>=0.25.2
|
|
18
18
|
Requires-Dist: uvicorn>=0.38.0
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
20
|
|
|
@@ -25,7 +25,7 @@ A FastAPI server for static code analysis using [lizard](https://github.com/terr
|
|
|
25
25
|
## Features
|
|
26
26
|
|
|
27
27
|
- **Static Analysis**: Scans the codebase for cyclomatic complexity and LOC (Lines of Code).
|
|
28
|
-
- **Caching**: Caches analysis results
|
|
28
|
+
- **Caching**: Caches analysis results in the user cache directory (see the main README) and reuses them until files change.
|
|
29
29
|
- **File Serving**: Provides an API to read raw file contents (absolute paths).
|
|
30
30
|
- **API Documentation**: Auto-generated Swagger UI at `/docs`.
|
|
31
31
|
|
|
@@ -53,8 +53,8 @@ The API will be available at `http://localhost:8000`.
|
|
|
53
53
|
|
|
54
54
|
### Analysis
|
|
55
55
|
|
|
56
|
-
- `GET /api/analysis`: Get the analysis tree. Scans
|
|
57
|
-
- `POST /api/analysis/refresh`: Force a new scan.
|
|
56
|
+
- `GET /api/analysis`: Get the analysis tree. Scans unless an unchanged cached analysis exists.
|
|
57
|
+
- `POST /api/analysis/refresh`: Force a new scan and replace the cached analysis.
|
|
58
58
|
|
|
59
59
|
### Files
|
|
60
60
|
|
|
@@ -5,7 +5,7 @@ A FastAPI server for static code analysis using [lizard](https://github.com/terr
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **Static Analysis**: Scans the codebase for cyclomatic complexity and LOC (Lines of Code).
|
|
8
|
-
- **Caching**: Caches analysis results
|
|
8
|
+
- **Caching**: Caches analysis results in the user cache directory (see the main README) and reuses them until files change.
|
|
9
9
|
- **File Serving**: Provides an API to read raw file contents (absolute paths).
|
|
10
10
|
- **API Documentation**: Auto-generated Swagger UI at `/docs`.
|
|
11
11
|
|
|
@@ -33,8 +33,8 @@ The API will be available at `http://localhost:8000`.
|
|
|
33
33
|
|
|
34
34
|
### Analysis
|
|
35
35
|
|
|
36
|
-
- `GET /api/analysis`: Get the analysis tree. Scans
|
|
37
|
-
- `POST /api/analysis/refresh`: Force a new scan.
|
|
36
|
+
- `GET /api/analysis`: Get the analysis tree. Scans unless an unchanged cached analysis exists.
|
|
37
|
+
- `POST /api/analysis/refresh`: Force a new scan and replace the cached analysis.
|
|
38
38
|
|
|
39
39
|
### Files
|
|
40
40
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from typing import Set
|
|
2
|
+
|
|
3
|
+
IGNORE_DIRS: Set[str] = {
|
|
4
|
+
".git",
|
|
5
|
+
"node_modules",
|
|
6
|
+
"venv",
|
|
7
|
+
".venv",
|
|
8
|
+
"__pycache__",
|
|
9
|
+
"dist",
|
|
10
|
+
"build",
|
|
11
|
+
".next",
|
|
12
|
+
"coverage",
|
|
13
|
+
".idea",
|
|
14
|
+
".vscode",
|
|
15
|
+
"target",
|
|
16
|
+
"out",
|
|
17
|
+
"android",
|
|
18
|
+
"ios",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
IGNORE_FILES: Set[str] = {
|
|
22
|
+
# In git worktrees and submodules `.git` is a pointer file, not a directory.
|
|
23
|
+
".git",
|
|
24
|
+
".DS_Store",
|
|
25
|
+
"package-lock.json",
|
|
26
|
+
"yarn.lock",
|
|
27
|
+
"pnpm-lock.yaml",
|
|
28
|
+
"cargo.lock",
|
|
29
|
+
"poetry.lock",
|
|
30
|
+
"Gemfile.lock",
|
|
31
|
+
"composer.lock",
|
|
32
|
+
"mix.lock",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
IGNORE_EXTENSIONS: Set[str] = {
|
|
36
|
+
".png",
|
|
37
|
+
".jpg",
|
|
38
|
+
".jpeg",
|
|
39
|
+
".gif",
|
|
40
|
+
".svg",
|
|
41
|
+
".ico",
|
|
42
|
+
".pdf",
|
|
43
|
+
".zip",
|
|
44
|
+
".tar",
|
|
45
|
+
".gz",
|
|
46
|
+
".map",
|
|
47
|
+
".min.js",
|
|
48
|
+
".min.css",
|
|
49
|
+
".json",
|
|
50
|
+
".xml",
|
|
51
|
+
".txt",
|
|
52
|
+
".ttf",
|
|
53
|
+
".woff",
|
|
54
|
+
".woff2",
|
|
55
|
+
".eot",
|
|
56
|
+
".otf",
|
|
57
|
+
".mp4",
|
|
58
|
+
".mp3",
|
|
59
|
+
".wav",
|
|
60
|
+
".webm",
|
|
61
|
+
".ogg",
|
|
62
|
+
".exe",
|
|
63
|
+
".dll",
|
|
64
|
+
".so",
|
|
65
|
+
".dylib",
|
|
66
|
+
".bin",
|
|
67
|
+
".iso",
|
|
68
|
+
".dmg",
|
|
69
|
+
".pkg",
|
|
70
|
+
".deb",
|
|
71
|
+
".rpm",
|
|
72
|
+
".csv",
|
|
73
|
+
".lock",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Binary formats that are never worth analyzing. Files without a listed
|
|
77
|
+
# extension are still caught by a null-byte sniff (see BINARY_SNIFF_BYTES).
|
|
78
|
+
BINARY_EXTENSIONS: Set[str] = {
|
|
79
|
+
".wasm",
|
|
80
|
+
".o",
|
|
81
|
+
".a",
|
|
82
|
+
".lib",
|
|
83
|
+
".obj",
|
|
84
|
+
".pdb",
|
|
85
|
+
".class",
|
|
86
|
+
".jar",
|
|
87
|
+
".war",
|
|
88
|
+
".pyc",
|
|
89
|
+
".pyo",
|
|
90
|
+
".node",
|
|
91
|
+
".webp",
|
|
92
|
+
".avif",
|
|
93
|
+
".bmp",
|
|
94
|
+
".tif",
|
|
95
|
+
".tiff",
|
|
96
|
+
".psd",
|
|
97
|
+
".heic",
|
|
98
|
+
".icns",
|
|
99
|
+
".mov",
|
|
100
|
+
".avi",
|
|
101
|
+
".mkv",
|
|
102
|
+
".flac",
|
|
103
|
+
".m4a",
|
|
104
|
+
".aac",
|
|
105
|
+
".7z",
|
|
106
|
+
".bz2",
|
|
107
|
+
".xz",
|
|
108
|
+
".zst",
|
|
109
|
+
".rar",
|
|
110
|
+
".tgz",
|
|
111
|
+
".sqlite",
|
|
112
|
+
".sqlite3",
|
|
113
|
+
".db",
|
|
114
|
+
".npy",
|
|
115
|
+
".npz",
|
|
116
|
+
".pkl",
|
|
117
|
+
".parquet",
|
|
118
|
+
".onnx",
|
|
119
|
+
".pt",
|
|
120
|
+
".safetensors",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# How much of each candidate file to read when sniffing for binary content.
|
|
124
|
+
BINARY_SNIFF_BYTES = 8 * 1024
|
|
125
|
+
|
|
126
|
+
# Minified/bundled JS and CSS: checked by content, since built bundles rarely
|
|
127
|
+
# carry a ``.min`` suffix (e.g. Vite's ``assets/index-<hash>.js``).
|
|
128
|
+
MINIFIED_CHECK_EXTENSIONS: Set[str] = {".js", ".mjs", ".cjs", ".css"}
|
|
129
|
+
MINIFIED_SNIFF_BYTES = 64 * 1024
|
|
130
|
+
# Hand-written code averages well under 80 characters per line.
|
|
131
|
+
MINIFIED_MIN_AVG_LINE_LENGTH = 200
|
|
132
|
+
|
|
133
|
+
# Files larger than this skip their analyzer and stay in the tree with a line
|
|
134
|
+
# count and size only (analysis_status "too_large"). The scan checks this before
|
|
135
|
+
# dispatch, so no worker is spent on them. Peak worker memory grows about 80 MB
|
|
136
|
+
# per MB of input on the heaviest paths (tree-sitter on generated TSX, CSS,
|
|
137
|
+
# lizard on HTML with data URLs), so 2 MiB keeps a worker near 220 MB. The
|
|
138
|
+
# largest real source files in the repos measured are under 1 MB.
|
|
139
|
+
MAX_ANALYZED_FILE_BYTES = 2 * 1024 * 1024
|
|
@@ -8,7 +8,7 @@ import os
|
|
|
8
8
|
import signal
|
|
9
9
|
import threading
|
|
10
10
|
|
|
11
|
-
from app.routers import analysis, files, scans
|
|
11
|
+
from app.routers import analysis, compare, files, scans
|
|
12
12
|
from app.services.scan_workers import cancel_all_scans
|
|
13
13
|
|
|
14
14
|
_SHUTDOWN_SIGNALS = (signal.SIGINT, signal.SIGTERM)
|
|
@@ -76,6 +76,14 @@ app.add_middleware(
|
|
|
76
76
|
app.include_router(analysis.router)
|
|
77
77
|
app.include_router(files.router)
|
|
78
78
|
app.include_router(scans.router)
|
|
79
|
+
app.include_router(compare.router)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.get("/api-status")
|
|
83
|
+
async def root():
|
|
84
|
+
return {
|
|
85
|
+
"message": "Srcly server is running. Visit /docs for API documentation."
|
|
86
|
+
}
|
|
79
87
|
|
|
80
88
|
|
|
81
89
|
def _find_static_dir() -> str | None:
|
|
@@ -111,14 +119,9 @@ def _find_static_dir() -> str | None:
|
|
|
111
119
|
return None
|
|
112
120
|
|
|
113
121
|
|
|
114
|
-
# Serve SPA if we can find built assets
|
|
122
|
+
# Serve SPA if we can find built assets. Mounted last: a mount at "/" matches
|
|
123
|
+
# every path, so routes declared after it would be unreachable.
|
|
115
124
|
static_dir = _find_static_dir()
|
|
116
125
|
if static_dir:
|
|
117
126
|
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
|
118
127
|
|
|
119
|
-
|
|
120
|
-
@app.get("/api-status")
|
|
121
|
-
async def root():
|
|
122
|
-
return {
|
|
123
|
-
"message": "Srcly server is running. Visit /docs for API documentation."
|
|
124
|
-
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
class Metrics(BaseModel):
|
|
5
|
+
loc: int = 0
|
|
6
|
+
# Max cyclomatic complexity (CCN) of any function or nested scope in this
|
|
7
|
+
# node, so files and folders mean the same thing (a folder is the max of
|
|
8
|
+
# its children). A function node holds its own CCN.
|
|
9
|
+
complexity: float = 0.0
|
|
10
|
+
# Mean CCN per function: the analyzer's per-file average for files, and
|
|
11
|
+
# the function-count-weighted mean of children for folders.
|
|
12
|
+
complexity_avg: float = 0.0
|
|
13
|
+
function_count: int = 0
|
|
14
|
+
last_modified: float = 0.0
|
|
15
|
+
gitignored_count: int = 0
|
|
16
|
+
file_size: int = 0
|
|
17
|
+
file_count: int = 0
|
|
18
|
+
|
|
19
|
+
# New metrics
|
|
20
|
+
comment_lines: int = 0
|
|
21
|
+
comment_density: float = 0.0
|
|
22
|
+
max_nesting_depth: int = 0
|
|
23
|
+
average_function_length: float = 0.0
|
|
24
|
+
parameter_count: int = 0
|
|
25
|
+
todo_count: int = 0
|
|
26
|
+
classes_count: int = 0
|
|
27
|
+
|
|
28
|
+
# TS/TSX Specific Metrics
|
|
29
|
+
tsx_nesting_depth: int = 0
|
|
30
|
+
tsx_render_branching_count: int = 0
|
|
31
|
+
tsx_react_use_effect_count: int = 0
|
|
32
|
+
tsx_anonymous_handler_count: int = 0
|
|
33
|
+
tsx_prop_count: int = 0
|
|
34
|
+
ts_any_usage_count: int = 0
|
|
35
|
+
ts_ignore_count: int = 0
|
|
36
|
+
ts_import_coupling_count: int = 0
|
|
37
|
+
tsx_hardcoded_string_volume: int = 0
|
|
38
|
+
tsx_duplicated_string_count: int = 0
|
|
39
|
+
ts_type_interface_count: int = 0
|
|
40
|
+
ts_export_count: int = 0
|
|
41
|
+
|
|
42
|
+
# Python-specific metrics
|
|
43
|
+
python_import_count: int = 0
|
|
44
|
+
|
|
45
|
+
# Markdown-specific metrics
|
|
46
|
+
md_data_url_count: int = 0
|
|
47
|
+
|
|
48
|
+
# Analysis outcome. Files whose analyzer timed out, crashed, or raised stay
|
|
49
|
+
# in the tree with fallback LOC/size; ``analysis_status`` says why
|
|
50
|
+
# ("ok" | "timeout" | "error" | "crash" | "too_large"). "too_large" files are
|
|
51
|
+
# over ``MAX_ANALYZED_FILE_BYTES`` and were skipped on purpose, so they do
|
|
52
|
+
# not count in ``analysis_failed_count``. Folders sum failed files below.
|
|
53
|
+
analysis_status: str = "ok"
|
|
54
|
+
analysis_error: str = ""
|
|
55
|
+
analysis_failed_count: int = 0
|
|
56
|
+
|
|
57
|
+
class Node(BaseModel):
|
|
58
|
+
name: str
|
|
59
|
+
type: str # "folder", "file", "function", "code_fragment"
|
|
60
|
+
path: str
|
|
61
|
+
metrics: Metrics
|
|
62
|
+
# Use default_factory to avoid sharing the same list across instances
|
|
63
|
+
children: List["Node"] = Field(default_factory=list)
|
|
64
|
+
start_line: int = 0
|
|
65
|
+
end_line: int = 0
|
|
66
|
+
|
|
67
|
+
model_config = {
|
|
68
|
+
"populate_by_name": True
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# --- Hierarchical dependency graph contract (client/src/viz/deps/contract.ts) ---
|
|
72
|
+
|
|
73
|
+
class DepGraphNode(BaseModel):
|
|
74
|
+
"""folder | file | symbol | external node in the dependency hierarchy."""
|
|
75
|
+
|
|
76
|
+
model_config = {"populate_by_name": True}
|
|
77
|
+
|
|
78
|
+
id: str
|
|
79
|
+
kind: str
|
|
80
|
+
name: str
|
|
81
|
+
parent: str | None = None
|
|
82
|
+
path: str | None = None
|
|
83
|
+
abs_path: str | None = Field(default=None, alias="absPath")
|
|
84
|
+
language: str | None = None
|
|
85
|
+
is_test: bool | None = Field(default=None, alias="isTest")
|
|
86
|
+
symbol_kind: str | None = Field(default=None, alias="symbolKind")
|
|
87
|
+
line: int | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DepGraphEdge(BaseModel):
|
|
91
|
+
"""importer → imported; endpoints are file, symbol or external ids."""
|
|
92
|
+
|
|
93
|
+
model_config = {"populate_by_name": True}
|
|
94
|
+
|
|
95
|
+
source: str
|
|
96
|
+
target: str
|
|
97
|
+
type_only: bool | None = Field(default=None, alias="typeOnly")
|
|
98
|
+
via_barrel: bool | None = Field(default=None, alias="viaBarrel")
|
|
99
|
+
symbols: List[str] | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DepGraph(BaseModel):
|
|
103
|
+
root: str
|
|
104
|
+
nodes: List[DepGraphNode]
|
|
105
|
+
edges: List[DepGraphEdge]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException, Response
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from app.models import Node
|
|
5
|
+
from app.services import analysis, scan_jobs
|
|
6
|
+
from app.models import DepGraph
|
|
7
|
+
from app.services.deps.graph import build_dependency_graph
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/api/analysis", tags=["analysis"])
|
|
10
|
+
|
|
11
|
+
# TODO: Make this configurable via env var or request
|
|
12
|
+
ROOT_PATH = Path.cwd()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _scan_response(target_path: Path, force: bool = False) -> Response:
|
|
16
|
+
"""
|
|
17
|
+
Scan ``target_path`` through the shared job manager and return the tree.
|
|
18
|
+
|
|
19
|
+
An unchanged cached analysis is returned without scanning unless
|
|
20
|
+
``force`` is set.
|
|
21
|
+
|
|
22
|
+
Joining the manager means concurrent requests for the same path (two tabs,
|
|
23
|
+
a reload mid-scan, or a ``/api/scans`` job started by the UI) share one
|
|
24
|
+
scan instead of each starting their own. The tree is returned as
|
|
25
|
+
pre-encoded JSON so the encoding happens here, in a worker thread, rather
|
|
26
|
+
than on the event loop.
|
|
27
|
+
"""
|
|
28
|
+
job = scan_jobs.manager.start(target_path, force=force)
|
|
29
|
+
job.wait()
|
|
30
|
+
if job.phase == "cancelled":
|
|
31
|
+
raise HTTPException(status_code=503, detail="Scan cancelled")
|
|
32
|
+
if job.phase != "complete":
|
|
33
|
+
raise HTTPException(status_code=500, detail=job.error or f"Scan {job.phase}")
|
|
34
|
+
return Response(content=job.result_json(), media_type="application/json")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Scan endpoints are plain `def` on purpose: FastAPI runs them in a worker
|
|
38
|
+
# thread, so a long scan never blocks the event loop (which would also stop
|
|
39
|
+
# uvicorn from reacting to Ctrl+C). They return a `Response` directly, so
|
|
40
|
+
# `response_model` only documents the shape.
|
|
41
|
+
@router.get("", response_model=Node)
|
|
42
|
+
def get_analysis(path: str = None):
|
|
43
|
+
"""
|
|
44
|
+
Get the static analysis of the codebase.
|
|
45
|
+
Returns the cached result if no files changed since it was taken,
|
|
46
|
+
otherwise scans.
|
|
47
|
+
"""
|
|
48
|
+
target_path = Path(path) if path else ROOT_PATH
|
|
49
|
+
|
|
50
|
+
if not target_path.exists():
|
|
51
|
+
# Fallback or error? Let's error if explicit path is invalid
|
|
52
|
+
if path:
|
|
53
|
+
raise HTTPException(status_code=404, detail="Path not found")
|
|
54
|
+
target_path = ROOT_PATH
|
|
55
|
+
|
|
56
|
+
return _scan_response(target_path)
|
|
57
|
+
|
|
58
|
+
@router.get("/dependency-graph", response_model=DepGraph, response_model_exclude_none=True)
|
|
59
|
+
def get_dependency_graph(path: str = None):
|
|
60
|
+
"""
|
|
61
|
+
Hierarchical, language-neutral dependency graph (folders → files, importer → imported
|
|
62
|
+
edges, barrels passed through). The client aggregates and lays it out.
|
|
63
|
+
"""
|
|
64
|
+
target_path = Path(path) if path else ROOT_PATH
|
|
65
|
+
if not target_path.exists():
|
|
66
|
+
raise HTTPException(status_code=404, detail="Path not found")
|
|
67
|
+
if target_path.is_file():
|
|
68
|
+
target_path = target_path.parent
|
|
69
|
+
return build_dependency_graph(target_path)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.post("/refresh", response_model=Node)
|
|
73
|
+
def refresh_analysis(path: str = None):
|
|
74
|
+
"""
|
|
75
|
+
Force a re-scan of the codebase, ignoring (and replacing) the cache.
|
|
76
|
+
"""
|
|
77
|
+
target_path = Path(path) if path else ROOT_PATH
|
|
78
|
+
if not target_path.exists():
|
|
79
|
+
raise HTTPException(status_code=404, detail="Path not found")
|
|
80
|
+
return _scan_response(target_path, force=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _estimate_counts(root: Path) -> tuple[int, int]:
|
|
84
|
+
"""
|
|
85
|
+
Return the (file_count, folder_count) a scan of ``root`` will analyze.
|
|
86
|
+
|
|
87
|
+
Uses the scan's own discovery (git ls-files or the .gitignore walk, plus
|
|
88
|
+
the built-in, binary, and minified filters) so the landing page matches
|
|
89
|
+
the scan.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
discovery = analysis.discover_files(root)
|
|
93
|
+
except Exception:
|
|
94
|
+
# If anything goes wrong, fall back to zeros but still report the path.
|
|
95
|
+
return 0, 0
|
|
96
|
+
return len(discovery.files), discovery.folder_count
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@router.get("/context")
|
|
100
|
+
def get_analysis_context():
|
|
101
|
+
"""
|
|
102
|
+
Return basic information about the current analysis root directory.
|
|
103
|
+
|
|
104
|
+
This is used by the client to offer one-click analysis options on first
|
|
105
|
+
load, for both the current working directory and the repository root.
|
|
106
|
+
"""
|
|
107
|
+
current_root = ROOT_PATH
|
|
108
|
+
# Use the same repository root detection logic as the analysis layer so that
|
|
109
|
+
# the client sees a consistent "repo root" regardless of how the server was
|
|
110
|
+
# started (uvx, direct CLI, dev mode, etc.).
|
|
111
|
+
#
|
|
112
|
+
# This mirrors the behaviour of the CLI helper that walks upwards from the
|
|
113
|
+
# current directory to the enclosing `.git` root, falling back to the
|
|
114
|
+
# starting directory when no repository is found.
|
|
115
|
+
repo_root = analysis.find_repo_root(current_root)
|
|
116
|
+
|
|
117
|
+
current_file_count, current_folder_count = _estimate_counts(current_root)
|
|
118
|
+
|
|
119
|
+
repo_file_count = 0
|
|
120
|
+
repo_folder_count = 0
|
|
121
|
+
if repo_root.exists():
|
|
122
|
+
repo_file_count, repo_folder_count = _estimate_counts(repo_root)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"root_path": str(current_root),
|
|
126
|
+
"file_count": current_file_count,
|
|
127
|
+
"folder_count": current_folder_count,
|
|
128
|
+
"repo_root_path": str(repo_root),
|
|
129
|
+
"repo_file_count": repo_file_count,
|
|
130
|
+
"repo_folder_count": repo_folder_count,
|
|
131
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compare two git refs (PR compare mode).
|
|
3
|
+
|
|
4
|
+
GET /api/compare/refs branches + recent commits for the picker
|
|
5
|
+
GET /api/compare/sources one picker section: prs | worktrees | branches
|
|
6
|
+
GET /api/compare/commits first-parent commits in base..head (commit stepper)
|
|
7
|
+
POST /api/compare/prewarm scan one commit side in the background
|
|
8
|
+
POST /api/compare start (or join) a compare -> snapshot
|
|
9
|
+
GET /api/compare/{id} current snapshot (poll)
|
|
10
|
+
GET /api/compare/{id}/result compare payload once complete
|
|
11
|
+
DELETE /api/compare/{id} cancel -> snapshot
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from fastapi import APIRouter, HTTPException
|
|
20
|
+
from fastapi.responses import Response
|
|
21
|
+
from pydantic import BaseModel
|
|
22
|
+
|
|
23
|
+
from app.routers import analysis as analysis_router
|
|
24
|
+
from app.services.compare import jobs, sources
|
|
25
|
+
from app.services.compare.git_refs import (
|
|
26
|
+
AUTO,
|
|
27
|
+
GitError,
|
|
28
|
+
default_branch,
|
|
29
|
+
list_commits,
|
|
30
|
+
repo_root,
|
|
31
|
+
resolve_auto_base,
|
|
32
|
+
resolve_ref,
|
|
33
|
+
run_git,
|
|
34
|
+
)
|
|
35
|
+
from app.services.compare.service import DEFAULT_BASE, DEFAULT_HEAD
|
|
36
|
+
|
|
37
|
+
router = APIRouter(prefix="/api/compare", tags=["compare"])
|
|
38
|
+
|
|
39
|
+
RECENT_COMMITS = 30
|
|
40
|
+
RECENT_BRANCHES = 20
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StartCompareRequest(BaseModel):
|
|
44
|
+
path: Optional[str] = None
|
|
45
|
+
base: str = DEFAULT_BASE
|
|
46
|
+
head: str = DEFAULT_HEAD
|
|
47
|
+
force: bool = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _target(path: Optional[str]) -> Path:
|
|
51
|
+
target = Path(path) if path else analysis_router.ROOT_PATH
|
|
52
|
+
if not target.exists():
|
|
53
|
+
raise HTTPException(status_code=404, detail=f"Path not found: {target}")
|
|
54
|
+
return target.resolve()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _job_or_404(job_id: str) -> jobs.CompareJob:
|
|
58
|
+
job = jobs.manager.get(job_id)
|
|
59
|
+
if job is None:
|
|
60
|
+
raise HTTPException(status_code=404, detail="Compare not found")
|
|
61
|
+
return job
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.get("/refs")
|
|
65
|
+
def list_refs(path: Optional[str] = None) -> dict:
|
|
66
|
+
try:
|
|
67
|
+
repo = repo_root(_target(path))
|
|
68
|
+
current = run_git(repo, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
|
69
|
+
branches = [
|
|
70
|
+
dict(zip(("name", "sha", "date"), line.split("\0")))
|
|
71
|
+
for line in run_git(
|
|
72
|
+
repo,
|
|
73
|
+
"for-each-ref",
|
|
74
|
+
"--sort=-committerdate",
|
|
75
|
+
f"--count={RECENT_BRANCHES}",
|
|
76
|
+
"--format=%(refname:short)%00%(objectname:short)%00%(committerdate:relative)",
|
|
77
|
+
"refs/heads",
|
|
78
|
+
).splitlines()
|
|
79
|
+
]
|
|
80
|
+
commits = [
|
|
81
|
+
dict(zip(("sha", "subject", "date"), line.split("\0")))
|
|
82
|
+
for line in run_git(
|
|
83
|
+
repo, "log", f"-{RECENT_COMMITS}", "--format=%h%x00%s%x00%cr"
|
|
84
|
+
).splitlines()
|
|
85
|
+
]
|
|
86
|
+
try:
|
|
87
|
+
default = default_branch(repo)
|
|
88
|
+
except GitError:
|
|
89
|
+
default = None
|
|
90
|
+
except GitError as exc:
|
|
91
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
92
|
+
return {
|
|
93
|
+
"repo": str(repo),
|
|
94
|
+
"current": current,
|
|
95
|
+
"defaultBranch": default,
|
|
96
|
+
"branches": branches,
|
|
97
|
+
"commits": commits,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.get("/sources")
|
|
102
|
+
def list_sources(kind: str, path: Optional[str] = None, all: bool = False, refresh: bool = False) -> dict:
|
|
103
|
+
"""
|
|
104
|
+
One picker section. Each kind is its own request so worktrees and branches
|
|
105
|
+
render while ``gh`` is still answering. A source that can't load (no gh,
|
|
106
|
+
not signed in, offline) comes back with ``available: false`` and a reason.
|
|
107
|
+
"""
|
|
108
|
+
if kind not in ("prs", "worktrees", "branches"):
|
|
109
|
+
raise HTTPException(status_code=400, detail=f"Unknown source kind: {kind}")
|
|
110
|
+
try:
|
|
111
|
+
repo = repo_root(_target(path))
|
|
112
|
+
except GitError as exc:
|
|
113
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
114
|
+
return {"repo": str(repo), **sources.load_source(repo, kind, mine=not all, refresh=refresh)}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@router.get("/commits")
|
|
118
|
+
def get_commits(base: str, head: str, path: Optional[str] = None) -> dict:
|
|
119
|
+
"""The commits a stepper walks: ``base`` resolves as a base (``pr:<n>`` → its merge-base, ``auto``)."""
|
|
120
|
+
try:
|
|
121
|
+
repo = repo_root(_target(path))
|
|
122
|
+
head_ref = resolve_ref(repo, head, side="head")
|
|
123
|
+
base_ref = resolve_auto_base(repo, head, head_ref) if base == AUTO else resolve_ref(repo, base, side="base")
|
|
124
|
+
listing = list_commits(repo, base_ref, head_ref)
|
|
125
|
+
except GitError as exc:
|
|
126
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
127
|
+
return {
|
|
128
|
+
"base": {"sha": base_ref.sha, "label": base_ref.label, "subject": base_ref.subject},
|
|
129
|
+
"head": {"sha": head_ref.sha, "label": head_ref.label},
|
|
130
|
+
**listing,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class PrewarmRequest(BaseModel):
|
|
135
|
+
path: Optional[str] = None
|
|
136
|
+
ref: str
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@router.post("/prewarm", status_code=202)
|
|
140
|
+
def prewarm(req: PrewarmRequest) -> dict:
|
|
141
|
+
try:
|
|
142
|
+
state = jobs.manager.prewarm(_target(req.path), req.ref.strip())
|
|
143
|
+
except GitError as exc:
|
|
144
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
145
|
+
return {"ref": req.ref, "state": state}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@router.post("", status_code=202)
|
|
149
|
+
def start_compare(req: StartCompareRequest) -> dict:
|
|
150
|
+
target = _target(req.path)
|
|
151
|
+
return jobs.manager.start(target, req.base.strip(), req.head.strip(), force=req.force).snapshot()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@router.get("/{job_id}")
|
|
155
|
+
def get_compare(job_id: str) -> dict:
|
|
156
|
+
return _job_or_404(job_id).snapshot()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@router.delete("/{job_id}")
|
|
160
|
+
def cancel_compare(job_id: str) -> dict:
|
|
161
|
+
job = _job_or_404(job_id)
|
|
162
|
+
job.cancel_token.cancel()
|
|
163
|
+
return job.snapshot()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@router.get("/{job_id}/result")
|
|
167
|
+
def get_compare_result(job_id: str):
|
|
168
|
+
job = _job_or_404(job_id)
|
|
169
|
+
if job.phase != "complete" or job.result_json is None:
|
|
170
|
+
raise HTTPException(status_code=409, detail=f"Compare is {job.phase}, not complete")
|
|
171
|
+
return Response(content=job.result_json, media_type="application/json")
|