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.
Files changed (110) hide show
  1. {srcly-0.1.34 → srcly-0.3.0}/.gitignore +3 -0
  2. {srcly-0.1.34 → srcly-0.3.0}/PKG-INFO +6 -6
  3. {srcly-0.1.34 → srcly-0.3.0}/README.md +3 -3
  4. srcly-0.3.0/app/config.py +139 -0
  5. {srcly-0.1.34 → srcly-0.3.0}/app/main.py +11 -8
  6. srcly-0.3.0/app/models.py +105 -0
  7. srcly-0.3.0/app/routers/analysis.py +131 -0
  8. srcly-0.3.0/app/routers/compare.py +171 -0
  9. {srcly-0.1.34 → srcly-0.3.0}/app/routers/files.py +26 -3
  10. {srcly-0.1.34 → srcly-0.3.0}/app/routers/scans.py +11 -6
  11. {srcly-0.1.34 → srcly-0.3.0}/app/run.py +73 -1
  12. {srcly-0.1.34 → srcly-0.3.0}/app/services/analysis.py +591 -65
  13. {srcly-0.1.34 → srcly-0.3.0}/app/services/analysis_types.py +9 -0
  14. srcly-0.3.0/app/services/cache.py +261 -0
  15. srcly-0.3.0/app/services/compare/__init__.py +8 -0
  16. srcly-0.3.0/app/services/compare/diff.py +315 -0
  17. srcly-0.3.0/app/services/compare/git_refs.py +429 -0
  18. srcly-0.3.0/app/services/compare/github.py +177 -0
  19. srcly-0.3.0/app/services/compare/jobs.py +199 -0
  20. srcly-0.3.0/app/services/compare/service.py +194 -0
  21. srcly-0.3.0/app/services/compare/snapshots.py +322 -0
  22. srcly-0.3.0/app/services/compare/sources.py +216 -0
  23. srcly-0.3.0/app/services/deps/__init__.py +0 -0
  24. srcly-0.3.0/app/services/deps/common.py +191 -0
  25. srcly-0.3.0/app/services/deps/graph.py +171 -0
  26. srcly-0.3.0/app/services/deps/python_deps.py +402 -0
  27. srcly-0.3.0/app/services/deps/tsconfig.py +181 -0
  28. srcly-0.3.0/app/services/deps/typescript_deps.py +466 -0
  29. {srcly-0.1.34 → srcly-0.3.0}/app/services/python/python_analysis.py +6 -5
  30. {srcly-0.1.34 → srcly-0.3.0}/app/services/reporting.py +6 -5
  31. {srcly-0.1.34 → srcly-0.3.0}/app/services/scan_jobs.py +99 -9
  32. {srcly-0.1.34 → srcly-0.3.0}/app/services/scan_workers.py +38 -2
  33. {srcly-0.1.34 → srcly-0.3.0}/app/services/typescript/typescript_analysis.py +6 -185
  34. srcly-0.3.0/app/static/assets/CodeModal-CTBbqRv_.js +29 -0
  35. srcly-0.3.0/app/static/assets/CompareApp-BSOMRmdx.js +3 -0
  36. srcly-0.3.0/app/static/assets/DependencyView-dzVV2_Up.js +1 -0
  37. srcly-0.3.0/app/static/assets/index-BVNn0QLM.js +2 -0
  38. srcly-0.3.0/app/static/assets/index-D2ClTmny.css +1 -0
  39. srcly-0.3.0/app/static/assets/layout-DSK4VztQ.js +1 -0
  40. {srcly-0.1.34 → srcly-0.3.0}/app/static/index.html +2 -2
  41. {srcly-0.1.34 → srcly-0.3.0}/pyproject.toml +3 -3
  42. srcly-0.1.34/app/config.py +0 -74
  43. srcly-0.1.34/app/models.py +0 -76
  44. srcly-0.1.34/app/routers/analysis.py +0 -607
  45. srcly-0.1.34/app/services/cache.py +0 -19
  46. srcly-0.1.34/app/static/assets/CodeModal-BBkwbpez.js +0 -29
  47. srcly-0.1.34/app/static/assets/DependencyGraph-9gIcsNut.js +0 -1
  48. srcly-0.1.34/app/static/assets/dependencyForce.worker-WVMkyynK.js +0 -1
  49. srcly-0.1.34/app/static/assets/elk-worker.min-DGCccKuH.js +0 -6386
  50. srcly-0.1.34/app/static/assets/index-64jBfyHS.css +0 -1
  51. srcly-0.1.34/app/static/assets/index-CPpALji7.js +0 -2
  52. {srcly-0.1.34 → srcly-0.3.0}/app/.gitignore +0 -0
  53. {srcly-0.1.34 → srcly-0.3.0}/app/__init__.py +0 -0
  54. {srcly-0.1.34 → srcly-0.3.0}/app/services/css/css_analysis.py +0 -0
  55. {srcly-0.1.34 → srcly-0.3.0}/app/services/ipynb/ipynb_analysis.py +0 -0
  56. {srcly-0.1.34 → srcly-0.3.0}/app/services/markdown/markdown_analysis.py +0 -0
  57. {srcly-0.1.34 → srcly-0.3.0}/app/services/scan_progress.py +0 -0
  58. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/bash-DgJyDXW6.js +0 -0
  59. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/css-PZBBrkI0.js +0 -0
  60. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/github-light-Rcb2q3L5.js +0 -0
  61. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/highlight.worker-C64dOQ1-.js +0 -0
  62. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/html-DOf0XnR2.js +0 -0
  63. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  64. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  65. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  66. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  67. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
  68. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
  69. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  70. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  71. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  72. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  73. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
  74. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
  75. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  76. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  77. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  78. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  79. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  80. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  81. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  82. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  83. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  84. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  85. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
  86. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
  87. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  88. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  89. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  90. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  91. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
  92. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
  93. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  94. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  95. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  96. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  97. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  98. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  99. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  100. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/javascript-WyGyM1wV.js +0 -0
  101. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/json-B8JryHcM.js +0 -0
  102. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/jsx-8cLv2Six.js +0 -0
  103. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/markdown-DX-TNHnc.js +0 -0
  104. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/python-B6Ihcp-E.js +0 -0
  105. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/toml-Ms459DBw.js +0 -0
  106. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/tsx-D6116Ycy.js +0 -0
  107. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/typescript-BqvgUolh.js +0 -0
  108. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/wasm-CG6Dc4jp.js +0 -0
  109. {srcly-0.1.34 → srcly-0.3.0}/app/static/assets/yaml-Bopit2L3.js +0 -0
  110. {srcly-0.1.34 → srcly-0.3.0}/app/static/favicon.svg +0 -0
@@ -68,3 +68,6 @@ venv.bak/
68
68
  # uv.lock is intentionally not ignored as this is an application
69
69
 
70
70
  .tmp
71
+
72
+ # Local dependency-graph captures (may come from private repos)
73
+ client/src/viz/deps/__fixtures__/local/
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: srcly
3
- Version: 0.1.34
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.19.0
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>=0.25.2
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 to `codebase_mri.json` to avoid redundant scans.
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 if no cache exists.
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 to `codebase_mri.json` to avoid redundant scans.
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 if no cache exists.
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")