srcly 0.2.0__tar.gz → 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {srcly-0.2.0 → srcly-1.0.0}/.gitignore +3 -0
- {srcly-0.2.0 → srcly-1.0.0}/PKG-INFO +2 -1
- {srcly-0.2.0 → srcly-1.0.0}/app/main.py +2 -1
- srcly-1.0.0/app/models.py +207 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/routers/analysis.py +27 -7
- {srcly-0.2.0 → srcly-1.0.0}/app/routers/compare.py +61 -2
- {srcly-0.2.0 → srcly-1.0.0}/app/routers/files.py +68 -0
- srcly-1.0.0/app/routers/metrics.py +11 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/routers/scans.py +10 -3
- {srcly-0.2.0 → srcly-1.0.0}/app/run.py +94 -25
- srcly-1.0.0/app/services/activity.py +435 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/analysis.py +220 -215
- srcly-1.0.0/app/services/analysis_types.py +205 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/cache.py +1 -1
- srcly-1.0.0/app/services/comment_noise.py +256 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/compare/diff.py +19 -36
- srcly-1.0.0/app/services/compare/git_refs.py +436 -0
- srcly-1.0.0/app/services/compare/github.py +177 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/compare/jobs.py +51 -2
- {srcly-0.2.0 → srcly-1.0.0}/app/services/compare/service.py +98 -14
- {srcly-0.2.0 → srcly-1.0.0}/app/services/compare/snapshots.py +74 -12
- srcly-1.0.0/app/services/compare/sources.py +216 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/css/css_analysis.py +3 -11
- srcly-1.0.0/app/services/findings/__init__.py +36 -0
- srcly-1.0.0/app/services/findings/builtin_ts.py +135 -0
- srcly-1.0.0/app/services/findings/jobs.py +86 -0
- srcly-1.0.0/app/services/findings/model.py +157 -0
- srcly-1.0.0/app/services/findings/overlay.py +133 -0
- srcly-1.0.0/app/services/findings/ruff.py +142 -0
- srcly-1.0.0/app/services/findings/runner.py +186 -0
- srcly-1.0.0/app/services/findings/tools.py +198 -0
- srcly-1.0.0/app/services/findings/typescript.py +269 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/ipynb/ipynb_analysis.py +1 -2
- {srcly-0.2.0 → srcly-1.0.0}/app/services/markdown/markdown_analysis.py +2 -11
- srcly-1.0.0/app/services/markers.py +98 -0
- srcly-1.0.0/app/services/metric_catalog.py +343 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/python/python_analysis.py +106 -21
- srcly-1.0.0/app/services/quadrants.py +382 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/reporting.py +269 -169
- {srcly-0.2.0 → srcly-1.0.0}/app/services/scan_jobs.py +46 -1
- srcly-1.0.0/app/services/typescript/components.py +371 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/typescript/typescript_analysis.py +137 -105
- srcly-1.0.0/app/static/assets/CodeModal-BaYjncYj.js +36 -0
- srcly-1.0.0/app/static/assets/CompareApp-Omwsxouz.js +3 -0
- srcly-1.0.0/app/static/assets/DependencyView-CKkGFSEj.js +1 -0
- srcly-1.0.0/app/static/assets/ScatterView-4PHaFV2z.js +1 -0
- srcly-1.0.0/app/static/assets/chevron-left-CGokVLxh.js +1 -0
- srcly-1.0.0/app/static/assets/index-DoC5YIyS.css +1 -0
- srcly-1.0.0/app/static/assets/index-E273RRji.js +2 -0
- srcly-1.0.0/app/static/assets/layout-CKrLZkty.js +1 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/index.html +2 -2
- {srcly-0.2.0 → srcly-1.0.0}/pyproject.toml +2 -1
- srcly-0.2.0/app/models.py +0 -105
- srcly-0.2.0/app/services/analysis_types.py +0 -70
- srcly-0.2.0/app/services/compare/git_refs.py +0 -185
- srcly-0.2.0/app/static/assets/CodeModal-BC4-51lF.js +0 -29
- srcly-0.2.0/app/static/assets/CompareApp-CtVSUR0J.js +0 -2
- srcly-0.2.0/app/static/assets/DependencyView-Cz2blz64.js +0 -1
- srcly-0.2.0/app/static/assets/index-F49dMNqG.css +0 -1
- srcly-0.2.0/app/static/assets/index-mm3bqu0K.js +0 -2
- srcly-0.2.0/app/static/assets/layout-CCJmnknz.js +0 -1
- {srcly-0.2.0 → srcly-1.0.0}/README.md +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/.gitignore +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/__init__.py +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/config.py +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/compare/__init__.py +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/deps/__init__.py +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/deps/common.py +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/deps/graph.py +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/deps/python_deps.py +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/deps/tsconfig.py +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/deps/typescript_deps.py +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/scan_progress.py +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/services/scan_workers.py +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/bash-DgJyDXW6.js +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/css-PZBBrkI0.js +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/github-light-Rcb2q3L5.js +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/highlight.worker-C64dOQ1-.js +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/html-DOf0XnR2.js +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/javascript-WyGyM1wV.js +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/json-B8JryHcM.js +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/jsx-8cLv2Six.js +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/markdown-DX-TNHnc.js +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/python-B6Ihcp-E.js +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/toml-Ms459DBw.js +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/tsx-D6116Ycy.js +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/typescript-BqvgUolh.js +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/wasm-CG6Dc4jp.js +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/yaml-Bopit2L3.js +0 -0
- {srcly-0.2.0 → srcly-1.0.0}/app/static/favicon.svg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: srcly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Interactive codebase treemap and metrics viewer.
|
|
5
5
|
Requires-Python: >=3.13
|
|
6
6
|
Requires-Dist: fastapi>=0.121.3
|
|
@@ -9,6 +9,7 @@ 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
|
|
12
|
+
Requires-Dist: ruff>=0.17.1
|
|
12
13
|
Requires-Dist: tree-sitter-css==0.25.0
|
|
13
14
|
Requires-Dist: tree-sitter-markdown<0.6.0,>=0.5.1
|
|
14
15
|
Requires-Dist: tree-sitter-python==0.25.0
|
|
@@ -8,7 +8,7 @@ import os
|
|
|
8
8
|
import signal
|
|
9
9
|
import threading
|
|
10
10
|
|
|
11
|
-
from app.routers import analysis, compare, files, scans
|
|
11
|
+
from app.routers import analysis, compare, files, metrics, scans
|
|
12
12
|
from app.services.scan_workers import cancel_all_scans
|
|
13
13
|
|
|
14
14
|
_SHUTDOWN_SIGNALS = (signal.SIGINT, signal.SIGTERM)
|
|
@@ -77,6 +77,7 @@ app.include_router(analysis.router)
|
|
|
77
77
|
app.include_router(files.router)
|
|
78
78
|
app.include_router(scans.router)
|
|
79
79
|
app.include_router(compare.router)
|
|
80
|
+
app.include_router(metrics.router)
|
|
80
81
|
|
|
81
82
|
|
|
82
83
|
@app.get("/api-status")
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
class Witness(BaseModel):
|
|
5
|
+
"""The function responsible for a max-type metric, so the UI can open it."""
|
|
6
|
+
|
|
7
|
+
path: str # node path of the function: "<file>::<scope>::<name>"
|
|
8
|
+
name: str
|
|
9
|
+
start_line: int = 0
|
|
10
|
+
end_line: int = 0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Metrics(BaseModel):
|
|
14
|
+
# Always measured: every file has lines, bytes and a mtime.
|
|
15
|
+
loc: int = 0
|
|
16
|
+
last_modified: float = 0.0
|
|
17
|
+
gitignored_count: int = 0
|
|
18
|
+
file_size: int = 0
|
|
19
|
+
file_count: int = 0
|
|
20
|
+
|
|
21
|
+
# Everything below is ``None`` (N/A) when the metric doesn't apply to the
|
|
22
|
+
# node's language, the file was skipped or failed, or (for means) there are
|
|
23
|
+
# no functions to average. A measured zero stays 0. ``na_reason`` says why
|
|
24
|
+
# this node's ``None`` values are missing. Folder rollups ignore N/A
|
|
25
|
+
# children; a folder value is ``None`` only when no child measured it.
|
|
26
|
+
#
|
|
27
|
+
# Max cyclomatic complexity (CCN) of any executable function in this node,
|
|
28
|
+
# so files and folders mean the same thing; ``complexity_witness`` names
|
|
29
|
+
# that function. A scope node holds its own CCN.
|
|
30
|
+
complexity: float | None = None
|
|
31
|
+
complexity_witness: Witness | None = None
|
|
32
|
+
# Mean CCN over executable functions (any depth), weighted by function
|
|
33
|
+
# count for folders.
|
|
34
|
+
complexity_avg: float | None = None
|
|
35
|
+
# Executable functions at any depth (methods, nested functions, arrows);
|
|
36
|
+
# classes, types, object literals and JSX containers don't count.
|
|
37
|
+
function_count: int | None = None
|
|
38
|
+
|
|
39
|
+
comment_lines: int | None = None
|
|
40
|
+
# Comment noise: flags for over-commenting on absolute thresholds (see
|
|
41
|
+
# ``services/comment_noise.py``). The total and its three parts.
|
|
42
|
+
comment_noise: int | None = None
|
|
43
|
+
comment_noise_ratio: int | None = None
|
|
44
|
+
comment_noise_docblock: int | None = None
|
|
45
|
+
comment_noise_commented_code: int | None = None
|
|
46
|
+
max_nesting_depth: int | None = None
|
|
47
|
+
average_function_length: float | None = None
|
|
48
|
+
parameter_count: int | None = None
|
|
49
|
+
# Neutral detail: shown, never ranked as risk.
|
|
50
|
+
todo_count: int | None = None
|
|
51
|
+
classes_count: int | None = None
|
|
52
|
+
|
|
53
|
+
# React/Solid components (TSX). A component scope holds its own values;
|
|
54
|
+
# files and folders hold the max over their components, and ``witnesses``
|
|
55
|
+
# names the component behind each max. N/A when there are no components.
|
|
56
|
+
component_loc: int | None = None
|
|
57
|
+
component_hook_count: int | None = None
|
|
58
|
+
component_effect_count: int | None = None
|
|
59
|
+
component_jsx_depth: int | None = None
|
|
60
|
+
component_prop_count: int | None = None
|
|
61
|
+
component_inline_handler_count: int | None = None
|
|
62
|
+
component_jsx_conditional_count: int | None = None
|
|
63
|
+
witnesses: dict[str, Witness] | None = None
|
|
64
|
+
|
|
65
|
+
# TS/TSX Specific Metrics
|
|
66
|
+
ts_any_usage_count: int | None = None
|
|
67
|
+
ts_ignore_count: int | None = None
|
|
68
|
+
# Neutral detail
|
|
69
|
+
ts_import_coupling_count: int | None = None
|
|
70
|
+
ts_type_interface_count: int | None = None
|
|
71
|
+
ts_export_count: int | None = None
|
|
72
|
+
|
|
73
|
+
# Python-specific metrics
|
|
74
|
+
python_import_count: int | None = None
|
|
75
|
+
|
|
76
|
+
# Markdown-specific metrics
|
|
77
|
+
md_data_url_count: int | None = None
|
|
78
|
+
|
|
79
|
+
# Git activity (``services/activity.py``), laid over the tree when it is
|
|
80
|
+
# served: commits touching the file in the selected window, and lines
|
|
81
|
+
# added + removed by them. Files and folders (sums) only; scopes are N/A.
|
|
82
|
+
commit_count: int | None = None
|
|
83
|
+
churn_lines: int | None = None
|
|
84
|
+
|
|
85
|
+
# Lint and type-check findings (``services/findings``), laid over the tree
|
|
86
|
+
# when it is served: everything the checkers reported for the file, and
|
|
87
|
+
# the errors among them. N/A when no checker covers the file or its
|
|
88
|
+
# checker failed or timed out. Files and folders (sums); scopes are N/A.
|
|
89
|
+
finding_count: int | None = None
|
|
90
|
+
error_count: int | None = None
|
|
91
|
+
|
|
92
|
+
na_reason: str = ""
|
|
93
|
+
# Per-metric reasons that override ``na_reason`` (activity has its own:
|
|
94
|
+
# "Not a Git repository", "Shallow clone", ...).
|
|
95
|
+
na_reasons: dict[str, str] | None = None
|
|
96
|
+
|
|
97
|
+
# Analysis outcome. Files whose analyzer timed out, crashed, or raised stay
|
|
98
|
+
# in the tree with fallback LOC/size; ``analysis_status`` says why
|
|
99
|
+
# ("ok" | "timeout" | "error" | "crash" | "too_large"). "too_large" files are
|
|
100
|
+
# over ``MAX_ANALYZED_FILE_BYTES`` and were skipped on purpose, so they do
|
|
101
|
+
# not count in ``analysis_failed_count``. Folders sum failed files below.
|
|
102
|
+
analysis_status: str = "ok"
|
|
103
|
+
analysis_error: str = ""
|
|
104
|
+
analysis_failed_count: int = 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# Metrics that can be N/A, in display order. Rollup rules live in
|
|
108
|
+
# ``analysis.aggregate_metrics``.
|
|
109
|
+
OPTIONAL_METRIC_FIELDS: tuple[str, ...] = tuple(
|
|
110
|
+
name for name, field in Metrics.model_fields.items()
|
|
111
|
+
if name not in ("complexity_witness", "witnesses", "na_reasons") and field.default is None
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ActivityInfo(BaseModel):
|
|
116
|
+
"""Git activity status for a whole tree (root node only)."""
|
|
117
|
+
|
|
118
|
+
window: str
|
|
119
|
+
window_label: str
|
|
120
|
+
status: str # "ok" | "na"
|
|
121
|
+
reason: str = ""
|
|
122
|
+
note: str = "" # e.g. uncommitted changes; results are still valid
|
|
123
|
+
head: str = ""
|
|
124
|
+
files_touched: int = 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class FindingsTool(BaseModel):
|
|
128
|
+
"""One checker run: which tool, from where, and how it went."""
|
|
129
|
+
|
|
130
|
+
id: str
|
|
131
|
+
tool: str # "tsc" | "eslint" | "ruff" | "srcly-ts"
|
|
132
|
+
label: str # "tsc (client/tsconfig.app.json)", "ruff (srcly defaults)"
|
|
133
|
+
source: str # "project" | "srcly defaults"
|
|
134
|
+
status: str # "ok" | "na"
|
|
135
|
+
reason: str = ""
|
|
136
|
+
seconds: float = 0.0
|
|
137
|
+
finding_count: int = 0
|
|
138
|
+
error_count: int = 0
|
|
139
|
+
files_checked: int | None = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class FindingsInfo(BaseModel):
|
|
143
|
+
"""Lint and type-check status for a whole tree (root node only)."""
|
|
144
|
+
|
|
145
|
+
status: str # "running" | "ok" | "na"
|
|
146
|
+
reason: str = ""
|
|
147
|
+
tools: List[FindingsTool] = Field(default_factory=list)
|
|
148
|
+
finding_count: int = 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Node(BaseModel):
|
|
152
|
+
name: str
|
|
153
|
+
type: str # "folder", "file", "function", "function_body"
|
|
154
|
+
# For "function" nodes: the scope kind ("function" is executable code;
|
|
155
|
+
# "class" | "type" | "object" | "jsx" | "imports" | "block" are display
|
|
156
|
+
# scopes). ``None`` for folders, files and bodies.
|
|
157
|
+
kind: str | None = None
|
|
158
|
+
path: str
|
|
159
|
+
metrics: Metrics
|
|
160
|
+
# Use default_factory to avoid sharing the same list across instances
|
|
161
|
+
children: List["Node"] = Field(default_factory=list)
|
|
162
|
+
start_line: int = 0
|
|
163
|
+
end_line: int = 0
|
|
164
|
+
# Root only: whether Git activity could be measured, and for which window.
|
|
165
|
+
activity: ActivityInfo | None = None
|
|
166
|
+
# Root only: whether the checks finished, and which tools ran.
|
|
167
|
+
findings: FindingsInfo | None = None
|
|
168
|
+
|
|
169
|
+
model_config = {
|
|
170
|
+
"populate_by_name": True
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# --- Hierarchical dependency graph contract (client/src/viz/deps/contract.ts) ---
|
|
174
|
+
|
|
175
|
+
class DepGraphNode(BaseModel):
|
|
176
|
+
"""folder | file | symbol | external node in the dependency hierarchy."""
|
|
177
|
+
|
|
178
|
+
model_config = {"populate_by_name": True}
|
|
179
|
+
|
|
180
|
+
id: str
|
|
181
|
+
kind: str
|
|
182
|
+
name: str
|
|
183
|
+
parent: str | None = None
|
|
184
|
+
path: str | None = None
|
|
185
|
+
abs_path: str | None = Field(default=None, alias="absPath")
|
|
186
|
+
language: str | None = None
|
|
187
|
+
is_test: bool | None = Field(default=None, alias="isTest")
|
|
188
|
+
symbol_kind: str | None = Field(default=None, alias="symbolKind")
|
|
189
|
+
line: int | None = None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class DepGraphEdge(BaseModel):
|
|
193
|
+
"""importer → imported; endpoints are file, symbol or external ids."""
|
|
194
|
+
|
|
195
|
+
model_config = {"populate_by_name": True}
|
|
196
|
+
|
|
197
|
+
source: str
|
|
198
|
+
target: str
|
|
199
|
+
type_only: bool | None = Field(default=None, alias="typeOnly")
|
|
200
|
+
via_barrel: bool | None = Field(default=None, alias="viaBarrel")
|
|
201
|
+
symbols: List[str] | None = None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class DepGraph(BaseModel):
|
|
205
|
+
root: str
|
|
206
|
+
nodes: List[DepGraphNode]
|
|
207
|
+
edges: List[DepGraphEdge]
|
|
@@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException, Response
|
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
4
|
from app.models import Node
|
|
5
|
-
from app.services import analysis, scan_jobs
|
|
5
|
+
from app.services import analysis, quadrants, scan_jobs
|
|
6
6
|
from app.models import DepGraph
|
|
7
7
|
from app.services.deps.graph import build_dependency_graph
|
|
8
8
|
|
|
@@ -12,7 +12,15 @@ router = APIRouter(prefix="/api/analysis", tags=["analysis"])
|
|
|
12
12
|
ROOT_PATH = Path.cwd()
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
def
|
|
15
|
+
def result_bytes(job: scan_jobs.ScanJob, activity_window: str | None = None) -> bytes:
|
|
16
|
+
"""The finished tree with Git activity for ``activity_window`` (default 90d) and findings."""
|
|
17
|
+
try:
|
|
18
|
+
return job.result_json_overlaid(activity_window)
|
|
19
|
+
except ValueError as exc:
|
|
20
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _scan_response(target_path: Path, force: bool = False, activity_window: str | None = None) -> Response:
|
|
16
24
|
"""
|
|
17
25
|
Scan ``target_path`` through the shared job manager and return the tree.
|
|
18
26
|
|
|
@@ -31,7 +39,7 @@ def _scan_response(target_path: Path, force: bool = False) -> Response:
|
|
|
31
39
|
raise HTTPException(status_code=503, detail="Scan cancelled")
|
|
32
40
|
if job.phase != "complete":
|
|
33
41
|
raise HTTPException(status_code=500, detail=job.error or f"Scan {job.phase}")
|
|
34
|
-
return Response(content=job
|
|
42
|
+
return Response(content=result_bytes(job, activity_window), media_type="application/json")
|
|
35
43
|
|
|
36
44
|
|
|
37
45
|
# Scan endpoints are plain `def` on purpose: FastAPI runs them in a worker
|
|
@@ -39,7 +47,7 @@ def _scan_response(target_path: Path, force: bool = False) -> Response:
|
|
|
39
47
|
# uvicorn from reacting to Ctrl+C). They return a `Response` directly, so
|
|
40
48
|
# `response_model` only documents the shape.
|
|
41
49
|
@router.get("", response_model=Node)
|
|
42
|
-
def get_analysis(path: str = None):
|
|
50
|
+
def get_analysis(path: str = None, activity_window: str | None = None):
|
|
43
51
|
"""
|
|
44
52
|
Get the static analysis of the codebase.
|
|
45
53
|
Returns the cached result if no files changed since it was taken,
|
|
@@ -53,7 +61,7 @@ def get_analysis(path: str = None):
|
|
|
53
61
|
raise HTTPException(status_code=404, detail="Path not found")
|
|
54
62
|
target_path = ROOT_PATH
|
|
55
63
|
|
|
56
|
-
return _scan_response(target_path)
|
|
64
|
+
return _scan_response(target_path, activity_window=activity_window)
|
|
57
65
|
|
|
58
66
|
@router.get("/dependency-graph", response_model=DepGraph, response_model_exclude_none=True)
|
|
59
67
|
def get_dependency_graph(path: str = None):
|
|
@@ -69,15 +77,27 @@ def get_dependency_graph(path: str = None):
|
|
|
69
77
|
return build_dependency_graph(target_path)
|
|
70
78
|
|
|
71
79
|
|
|
80
|
+
@router.get("/fan-in")
|
|
81
|
+
def get_fan_in(path: str = None) -> dict:
|
|
82
|
+
"""Absolute file path → distinct files importing it, for the Blast radius 2x2."""
|
|
83
|
+
target_path = Path(path) if path else ROOT_PATH
|
|
84
|
+
if not target_path.exists():
|
|
85
|
+
raise HTTPException(status_code=404, detail="Path not found")
|
|
86
|
+
if target_path.is_file():
|
|
87
|
+
target_path = target_path.parent
|
|
88
|
+
counts = quadrants.fan_in_by_path(target_path)
|
|
89
|
+
return {"counts": counts, "available": bool(counts)}
|
|
90
|
+
|
|
91
|
+
|
|
72
92
|
@router.post("/refresh", response_model=Node)
|
|
73
|
-
def refresh_analysis(path: str = None):
|
|
93
|
+
def refresh_analysis(path: str = None, activity_window: str | None = None):
|
|
74
94
|
"""
|
|
75
95
|
Force a re-scan of the codebase, ignoring (and replacing) the cache.
|
|
76
96
|
"""
|
|
77
97
|
target_path = Path(path) if path else ROOT_PATH
|
|
78
98
|
if not target_path.exists():
|
|
79
99
|
raise HTTPException(status_code=404, detail="Path not found")
|
|
80
|
-
return _scan_response(target_path, force=True)
|
|
100
|
+
return _scan_response(target_path, force=True, activity_window=activity_window)
|
|
81
101
|
|
|
82
102
|
|
|
83
103
|
def _estimate_counts(root: Path) -> tuple[int, int]:
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
Compare two git refs (PR compare mode).
|
|
3
3
|
|
|
4
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
|
|
5
8
|
POST /api/compare start (or join) a compare -> snapshot
|
|
6
9
|
GET /api/compare/{id} current snapshot (poll)
|
|
7
10
|
GET /api/compare/{id}/result compare payload once complete
|
|
@@ -18,8 +21,17 @@ from fastapi.responses import Response
|
|
|
18
21
|
from pydantic import BaseModel
|
|
19
22
|
|
|
20
23
|
from app.routers import analysis as analysis_router
|
|
21
|
-
from app.services.compare import jobs
|
|
22
|
-
from app.services.compare.git_refs import
|
|
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
|
+
)
|
|
23
35
|
from app.services.compare.service import DEFAULT_BASE, DEFAULT_HEAD
|
|
24
36
|
|
|
25
37
|
router = APIRouter(prefix="/api/compare", tags=["compare"])
|
|
@@ -86,6 +98,53 @@ def list_refs(path: Optional[str] = None) -> dict:
|
|
|
86
98
|
}
|
|
87
99
|
|
|
88
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
|
+
|
|
89
148
|
@router.post("", status_code=202)
|
|
90
149
|
def start_compare(req: StartCompareRequest) -> dict:
|
|
91
150
|
target = _target(req.path)
|
|
@@ -74,6 +74,74 @@ def _content_at_ref(file_path: Path, ref: str) -> str:
|
|
|
74
74
|
raise HTTPException(status_code=404, detail=f"File not found at {ref}: {exc}") from exc
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
@router.get("/markers")
|
|
78
|
+
def get_file_markers(
|
|
79
|
+
path: str = Query(..., description="Absolute path to the file"),
|
|
80
|
+
ref: str | None = Query(None, description="Git commit to read the file from instead of the disk"),
|
|
81
|
+
):
|
|
82
|
+
"""
|
|
83
|
+
Where each metric fires in a file: ``{metric_id: [{line, end_line, note}]}``.
|
|
84
|
+
The code viewer overlays these lines when a metric is picked.
|
|
85
|
+
"""
|
|
86
|
+
from app.services.markers import collect_markers, collect_markers_for_text
|
|
87
|
+
|
|
88
|
+
file_path = Path(path)
|
|
89
|
+
try:
|
|
90
|
+
if ref:
|
|
91
|
+
markers = collect_markers_for_text(_content_at_ref(file_path, ref), file_path.suffix)
|
|
92
|
+
elif not file_path.is_file():
|
|
93
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
94
|
+
else:
|
|
95
|
+
markers = collect_markers(str(file_path))
|
|
96
|
+
markers.update(_findings_markers(file_path))
|
|
97
|
+
except HTTPException:
|
|
98
|
+
raise
|
|
99
|
+
except Exception as e:
|
|
100
|
+
raise HTTPException(status_code=500, detail=f"Failed to locate metrics: {e}") from e
|
|
101
|
+
return {"markers": markers}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _findings_job_result(file_path: Path):
|
|
105
|
+
from app.services import findings
|
|
106
|
+
|
|
107
|
+
job = findings.manager.for_file(file_path)
|
|
108
|
+
return job.result if job is not None else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _findings_markers(file_path: Path) -> dict:
|
|
112
|
+
"""Lines behind ``finding_count`` / ``error_count``, from the last checks run on this file."""
|
|
113
|
+
from app.services import findings
|
|
114
|
+
|
|
115
|
+
return findings.markers_for(str(file_path), _findings_job_result(file_path))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.get("/findings")
|
|
119
|
+
def get_file_findings(path: str = Query(..., description="Absolute path to the file")):
|
|
120
|
+
"""
|
|
121
|
+
Lint and type-check findings for one file from the latest scan's checks:
|
|
122
|
+
``{"status": "ok"|"running"|"na", "reason", "tools": [...], "findings":
|
|
123
|
+
[{line, column, rule, message, severity, tool}]}``. ``tools`` are the
|
|
124
|
+
checkers that covered the file, so the viewer can say which one ran.
|
|
125
|
+
"""
|
|
126
|
+
from app.services import findings
|
|
127
|
+
|
|
128
|
+
return findings.file_findings(str(Path(path)), _findings_job_result(Path(path)))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@router.get("/commits")
|
|
132
|
+
def get_file_commits(
|
|
133
|
+
path: str = Query(..., description="Absolute path to the file"),
|
|
134
|
+
limit: int = Query(5, ge=1, le=50),
|
|
135
|
+
):
|
|
136
|
+
"""
|
|
137
|
+
The last commits touching a file (following renames): the evidence behind
|
|
138
|
+
its activity metrics. ``{"status", "reason", "commits": [{sha, short, date, author, subject}]}``.
|
|
139
|
+
"""
|
|
140
|
+
from app.services.activity import recent_commits
|
|
141
|
+
|
|
142
|
+
return recent_commits(Path(path), limit=limit)
|
|
143
|
+
|
|
144
|
+
|
|
77
145
|
@router.get("/suggest")
|
|
78
146
|
def suggest_files(path: str = Query(..., description="Path to list contents of")):
|
|
79
147
|
"""
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
|
|
3
|
+
from app.services.metric_catalog import catalog_payload
|
|
4
|
+
|
|
5
|
+
router = APIRouter(prefix="/api/metrics", tags=["metrics"])
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.get("/catalog")
|
|
9
|
+
def get_metric_catalog() -> dict:
|
|
10
|
+
"""Every metric's label, unit, group, direction, scope, languages and color stops."""
|
|
11
|
+
return catalog_payload()
|
|
@@ -4,7 +4,8 @@ Scan jobs with live progress over Server-Sent Events.
|
|
|
4
4
|
POST /api/scans start (or join) a scan -> snapshot
|
|
5
5
|
GET /api/scans/{id} current snapshot (polling fallback)
|
|
6
6
|
GET /api/scans/{id}/events SSE stream of snapshots
|
|
7
|
-
GET /api/scans/{id}/result analysis tree once complete
|
|
7
|
+
GET /api/scans/{id}/result analysis tree once complete (``?activity_window=30d|90d|1y|all``)
|
|
8
|
+
GET /api/scans/{id}/findings lint/type-check status; the result has counts once it isn't running
|
|
8
9
|
DELETE /api/scans/{id} cancel -> snapshot
|
|
9
10
|
|
|
10
11
|
SSE rather than WebSockets: progress only flows server -> client, cancel is
|
|
@@ -82,13 +83,19 @@ def cancel_scan(job_id: str) -> dict:
|
|
|
82
83
|
|
|
83
84
|
|
|
84
85
|
@router.get("/{job_id}/result", response_model=Node)
|
|
85
|
-
def get_scan_result(job_id: str):
|
|
86
|
+
def get_scan_result(job_id: str, activity_window: Optional[str] = None):
|
|
86
87
|
job = _job_or_404(job_id)
|
|
87
88
|
if job.phase != "complete":
|
|
88
89
|
raise HTTPException(status_code=409, detail=f"Scan is {job.phase}, not complete")
|
|
89
90
|
# Pre-encoded bytes: serializing a large tree on the event loop would
|
|
90
91
|
# block every other request (see ScanJob.result_json).
|
|
91
|
-
return Response(content=
|
|
92
|
+
return Response(content=analysis_router.result_bytes(job, activity_window), media_type="application/json")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.get("/{job_id}/findings")
|
|
96
|
+
def get_scan_findings(job_id: str) -> dict:
|
|
97
|
+
"""The scan's lint/type-check status (``FindingsInfo``); poll until it isn't ``running``."""
|
|
98
|
+
return _job_or_404(job_id).findings_result().info()
|
|
92
99
|
|
|
93
100
|
|
|
94
101
|
@router.get("/{job_id}/events")
|