srcly 0.3.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.3.0 → srcly-1.0.0}/PKG-INFO +2 -1
- {srcly-0.3.0 → srcly-1.0.0}/app/main.py +2 -1
- srcly-1.0.0/app/models.py +207 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/routers/analysis.py +27 -7
- {srcly-0.3.0 → srcly-1.0.0}/app/routers/files.py +68 -0
- srcly-1.0.0/app/routers/metrics.py +11 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/routers/scans.py +10 -3
- {srcly-0.3.0 → srcly-1.0.0}/app/run.py +75 -20
- srcly-1.0.0/app/services/activity.py +435 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/analysis.py +220 -215
- srcly-1.0.0/app/services/analysis_types.py +205 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/cache.py +1 -1
- srcly-1.0.0/app/services/comment_noise.py +256 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/diff.py +19 -36
- {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/git_refs.py +8 -1
- {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/service.py +8 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/snapshots.py +19 -3
- {srcly-0.3.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.3.0 → srcly-1.0.0}/app/services/ipynb/ipynb_analysis.py +1 -2
- {srcly-0.3.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.3.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.3.0 → srcly-1.0.0}/app/services/reporting.py +269 -169
- {srcly-0.3.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.3.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.3.0 → srcly-1.0.0}/app/static/index.html +2 -2
- {srcly-0.3.0 → srcly-1.0.0}/pyproject.toml +2 -1
- srcly-0.3.0/app/models.py +0 -105
- srcly-0.3.0/app/services/analysis_types.py +0 -70
- srcly-0.3.0/app/static/assets/CodeModal-CTBbqRv_.js +0 -29
- srcly-0.3.0/app/static/assets/CompareApp-BSOMRmdx.js +0 -3
- srcly-0.3.0/app/static/assets/DependencyView-dzVV2_Up.js +0 -1
- srcly-0.3.0/app/static/assets/index-BVNn0QLM.js +0 -2
- srcly-0.3.0/app/static/assets/index-D2ClTmny.css +0 -1
- srcly-0.3.0/app/static/assets/layout-DSK4VztQ.js +0 -1
- {srcly-0.3.0 → srcly-1.0.0}/.gitignore +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/README.md +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/.gitignore +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/__init__.py +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/config.py +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/routers/compare.py +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/__init__.py +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/github.py +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/jobs.py +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/sources.py +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/deps/__init__.py +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/deps/common.py +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/deps/graph.py +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/deps/python_deps.py +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/deps/tsconfig.py +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/deps/typescript_deps.py +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/scan_progress.py +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/services/scan_workers.py +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/bash-DgJyDXW6.js +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/css-PZBBrkI0.js +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/github-light-Rcb2q3L5.js +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/highlight.worker-C64dOQ1-.js +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/html-DOf0XnR2.js +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/javascript-WyGyM1wV.js +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/json-B8JryHcM.js +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/jsx-8cLv2Six.js +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/markdown-DX-TNHnc.js +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/python-B6Ihcp-E.js +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/toml-Ms459DBw.js +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/tsx-D6116Ycy.js +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/typescript-BqvgUolh.js +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/wasm-CG6Dc4jp.js +0 -0
- {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/yaml-Bopit2L3.js +0 -0
- {srcly-0.3.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]:
|
|
@@ -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")
|
|
@@ -9,7 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
import uvicorn
|
|
11
11
|
|
|
12
|
-
from app.services import reporting
|
|
12
|
+
from app.services import activity, metric_catalog, quadrants, reporting
|
|
13
13
|
from app.services.scan_workers import ScanCancelled
|
|
14
14
|
|
|
15
15
|
# Seconds uvicorn waits for open connections after Ctrl+C before closing them.
|
|
@@ -165,6 +165,12 @@ def _run_headless(argv: list[str]) -> None:
|
|
|
165
165
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
166
166
|
|
|
167
167
|
scan_parser = subparsers.add_parser("scan", help="Write the raw analysis tree as JSON.")
|
|
168
|
+
scan_parser.add_argument(
|
|
169
|
+
"--activity-window",
|
|
170
|
+
choices=list(activity.WINDOWS),
|
|
171
|
+
default=activity.DEFAULT_WINDOW,
|
|
172
|
+
help="Git history window for commit_count / churn_lines (default 90d).",
|
|
173
|
+
)
|
|
168
174
|
scan_parser.add_argument("path", nargs="?", help="Path to analyze. Defaults to the enclosing Git repo root.")
|
|
169
175
|
scan_parser.add_argument("--out", default="codebase_mri.json", help="Output JSON file.")
|
|
170
176
|
scan_parser.add_argument("--refresh", action="store_true", help="Ignore cache when cache support is available.")
|
|
@@ -177,6 +183,12 @@ def _run_headless(argv: list[str]) -> None:
|
|
|
177
183
|
)
|
|
178
184
|
|
|
179
185
|
report_parser = subparsers.add_parser("report", help="Write agent-ready report artifacts.")
|
|
186
|
+
report_parser.add_argument(
|
|
187
|
+
"--activity-window",
|
|
188
|
+
choices=list(activity.WINDOWS),
|
|
189
|
+
default=activity.DEFAULT_WINDOW,
|
|
190
|
+
help="Git history window for commit_count / churn_lines (default 90d).",
|
|
191
|
+
)
|
|
180
192
|
report_parser.add_argument("path", nargs="?", help="Path to analyze. Defaults to the enclosing Git repo root.")
|
|
181
193
|
report_parser.add_argument("--out", default=".srcly", help="Output artifact directory.")
|
|
182
194
|
report_parser.add_argument(
|
|
@@ -217,7 +229,23 @@ def _run_headless(argv: list[str]) -> None:
|
|
|
217
229
|
"--profile",
|
|
218
230
|
choices=["general", "frontend", "backend", "typescript", "python", "docs"],
|
|
219
231
|
default="general",
|
|
220
|
-
help="
|
|
232
|
+
help="Profile for the weighted findings queue.",
|
|
233
|
+
)
|
|
234
|
+
report_parser.add_argument(
|
|
235
|
+
"--quadrant",
|
|
236
|
+
choices=quadrants.PRESET_IDS,
|
|
237
|
+
help="Also list members per quadrant of a named 2x2 preset (report.md section + quadrants.json).",
|
|
238
|
+
)
|
|
239
|
+
report_parser.add_argument(
|
|
240
|
+
"--quadrant-cohort",
|
|
241
|
+
choices=("file", "function", "component"),
|
|
242
|
+
help="Point type for --quadrant. Defaults to the preset's first (files; components for component_weight).",
|
|
243
|
+
)
|
|
244
|
+
report_parser.add_argument(
|
|
245
|
+
"--metric",
|
|
246
|
+
choices=metric_catalog.RANKABLE_IDS,
|
|
247
|
+
default=None,
|
|
248
|
+
help="Metric for the Top Files / Top Functions tables and hotspots.json. Defaults to the profile's heaviest metric.",
|
|
221
249
|
)
|
|
222
250
|
report_parser.add_argument(
|
|
223
251
|
"--format",
|
|
@@ -241,12 +269,35 @@ def _run_headless(argv: list[str]) -> None:
|
|
|
241
269
|
)
|
|
242
270
|
|
|
243
271
|
hotspots_parser = subparsers.add_parser("hotspots", help="Print ranked hotspots.")
|
|
272
|
+
hotspots_parser.add_argument(
|
|
273
|
+
"--activity-window",
|
|
274
|
+
choices=list(activity.WINDOWS),
|
|
275
|
+
default=activity.DEFAULT_WINDOW,
|
|
276
|
+
help="Git history window for commit_count / churn_lines (default 90d).",
|
|
277
|
+
)
|
|
244
278
|
hotspots_parser.add_argument("path", nargs="?", help="Path to analyze. Defaults to the enclosing Git repo root.")
|
|
245
|
-
hotspots_parser.add_argument(
|
|
279
|
+
hotspots_parser.add_argument(
|
|
280
|
+
"--metric",
|
|
281
|
+
choices=metric_catalog.RANKABLE_IDS,
|
|
282
|
+
default="complexity",
|
|
283
|
+
help="Metric to rank by, worst first (the explorer's Hot Spots list).",
|
|
284
|
+
)
|
|
246
285
|
hotspots_parser.add_argument("--limit", type=int, default=25, help="Maximum hotspots to print.")
|
|
286
|
+
hotspots_parser.add_argument(
|
|
287
|
+
"--cohort",
|
|
288
|
+
choices=["file", "function"],
|
|
289
|
+
default="file",
|
|
290
|
+
help="Rank files against files, or functions against functions.",
|
|
291
|
+
)
|
|
247
292
|
hotspots_parser.add_argument("--format", choices=["markdown", "json"], default="markdown", help="Output format.")
|
|
248
293
|
|
|
249
294
|
explain_parser = subparsers.add_parser("explain", help="Print a focused report for one file or subtree.")
|
|
295
|
+
explain_parser.add_argument(
|
|
296
|
+
"--activity-window",
|
|
297
|
+
choices=list(activity.WINDOWS),
|
|
298
|
+
default=activity.DEFAULT_WINDOW,
|
|
299
|
+
help="Git history window for commit_count / churn_lines (default 90d).",
|
|
300
|
+
)
|
|
250
301
|
explain_parser.add_argument("path", nargs="?", help="Path to analyze. Defaults to the enclosing Git repo root.")
|
|
251
302
|
explain_parser.add_argument("--file", required=True, help="File path to explain.")
|
|
252
303
|
explain_parser.add_argument("--format", choices=["markdown", "json"], default="markdown", help="Output format.")
|
|
@@ -297,7 +348,7 @@ def _run_headless(argv: list[str]) -> None:
|
|
|
297
348
|
raise SystemExit("--include-source snippets is reserved and not implemented yet.")
|
|
298
349
|
target_path = _resolve_target_path(args.path)
|
|
299
350
|
out_path = Path("-") if args.out == "-" else Path(args.out).resolve()
|
|
300
|
-
reporting.write_scan(target_path, out_path, refresh=args.refresh)
|
|
351
|
+
reporting.write_scan(target_path, out_path, refresh=args.refresh, activity_window=args.activity_window)
|
|
301
352
|
if str(args.out) != "-":
|
|
302
353
|
print(f"Wrote Srcly tree JSON to {out_path}")
|
|
303
354
|
return
|
|
@@ -308,6 +359,7 @@ def _run_headless(argv: list[str]) -> None:
|
|
|
308
359
|
payload = reporting.build_report_payload(
|
|
309
360
|
target_path,
|
|
310
361
|
profile=args.profile,
|
|
362
|
+
metric=args.metric,
|
|
311
363
|
limit=args.limit,
|
|
312
364
|
refresh=args.refresh,
|
|
313
365
|
tree_depth=args.tree_depth,
|
|
@@ -319,6 +371,9 @@ def _run_headless(argv: list[str]) -> None:
|
|
|
319
371
|
agent_compact=args.agent_compact,
|
|
320
372
|
verbose=args.verbose,
|
|
321
373
|
output_format=args.format,
|
|
374
|
+
activity_window=args.activity_window,
|
|
375
|
+
quadrant=args.quadrant,
|
|
376
|
+
quadrant_cohort=args.quadrant_cohort,
|
|
322
377
|
)
|
|
323
378
|
print(reporting._json_dumps(payload))
|
|
324
379
|
else:
|
|
@@ -328,6 +383,7 @@ def _run_headless(argv: list[str]) -> None:
|
|
|
328
383
|
target_path,
|
|
329
384
|
out_dir,
|
|
330
385
|
profile=args.profile,
|
|
386
|
+
metric=args.metric,
|
|
331
387
|
limit=args.limit,
|
|
332
388
|
output_format=output_format,
|
|
333
389
|
refresh=args.refresh,
|
|
@@ -339,6 +395,9 @@ def _run_headless(argv: list[str]) -> None:
|
|
|
339
395
|
deprioritize_paths=_deprioritize_paths(args),
|
|
340
396
|
agent_compact=args.agent_compact,
|
|
341
397
|
verbose=args.verbose,
|
|
398
|
+
activity_window=args.activity_window,
|
|
399
|
+
quadrant=args.quadrant,
|
|
400
|
+
quadrant_cohort=args.quadrant_cohort,
|
|
342
401
|
)
|
|
343
402
|
if args.verbose:
|
|
344
403
|
print(f"Wrote Srcly report artifacts to {out_dir}")
|
|
@@ -355,22 +414,25 @@ def _run_headless(argv: list[str]) -> None:
|
|
|
355
414
|
|
|
356
415
|
if args.command == "hotspots":
|
|
357
416
|
target_path = _resolve_target_path(args.path)
|
|
358
|
-
tree = reporting.scan_tree(target_path)
|
|
359
|
-
|
|
360
|
-
ranked =
|
|
417
|
+
tree = reporting.scan_tree(target_path, activity_window=args.activity_window)
|
|
418
|
+
result = reporting.rank_by_metric(tree, args.metric, cohort=args.cohort, root_path=target_path)
|
|
419
|
+
result["ranked"] = result["ranked"][: args.limit]
|
|
361
420
|
if args.format == "json":
|
|
362
|
-
print(reporting._json_dumps(
|
|
421
|
+
print(reporting._json_dumps(result))
|
|
363
422
|
else:
|
|
364
|
-
for item in ranked:
|
|
423
|
+
for item in result["ranked"]:
|
|
365
424
|
location = item["path"]
|
|
366
425
|
if item.get("start_line") and item.get("end_line"):
|
|
367
426
|
location = f"{location}:{item['start_line']}-{item['end_line']}"
|
|
368
|
-
|
|
427
|
+
where = f" in {item['witness']['name']} (line {item['witness']['start_line']})" if item["witness"] else ""
|
|
428
|
+
print(f"{item['rank']}. {item['value']} {item['unit']} {location}{where}")
|
|
429
|
+
if result["na_count"]:
|
|
430
|
+
print(f"({result['na_count']} N/A for {args.metric}, not ranked)")
|
|
369
431
|
return
|
|
370
432
|
|
|
371
433
|
if args.command == "explain":
|
|
372
434
|
target_path = _resolve_target_path(args.path)
|
|
373
|
-
tree = reporting.scan_tree(target_path)
|
|
435
|
+
tree = reporting.scan_tree(target_path, activity_window=args.activity_window)
|
|
374
436
|
payload = _explain_file(tree, Path(args.file).resolve(), target_path)
|
|
375
437
|
if args.format == "json":
|
|
376
438
|
print(reporting._json_dumps(payload))
|
|
@@ -414,14 +476,6 @@ def _run_compare(args) -> None:
|
|
|
414
476
|
print(render_markdown(payload, limit=args.limit), end="")
|
|
415
477
|
|
|
416
478
|
|
|
417
|
-
def _profile_from_metrics(metrics: list[str] | None) -> str:
|
|
418
|
-
if not metrics:
|
|
419
|
-
return "general"
|
|
420
|
-
weights = {metric: 1.0 for metric in metrics}
|
|
421
|
-
reporting.PROFILE_WEIGHTS["_custom"] = weights
|
|
422
|
-
return "_custom"
|
|
423
|
-
|
|
424
|
-
|
|
425
479
|
def _deprioritize_paths(args) -> tuple[str, ...]:
|
|
426
480
|
if args.no_default_deprioritize:
|
|
427
481
|
return tuple(args.deprioritize or ())
|
|
@@ -467,7 +521,8 @@ def _explain_file(tree, file_path: Path, root_path: Path) -> dict:
|
|
|
467
521
|
if target is None:
|
|
468
522
|
raise SystemExit(f"File not found in analysis tree: {file_path}")
|
|
469
523
|
|
|
470
|
-
|
|
524
|
+
# Inside one file, rank its functions against each other.
|
|
525
|
+
ranked = reporting.rank_nodes(target, cohort="function")
|
|
471
526
|
return {
|
|
472
527
|
"path": str(file_path),
|
|
473
528
|
"summary": reporting.summarize_tree(target, ranked, root_path=root_path, max_depth=3, top_children=20),
|