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.
Files changed (126) hide show
  1. {srcly-0.3.0 → srcly-1.0.0}/PKG-INFO +2 -1
  2. {srcly-0.3.0 → srcly-1.0.0}/app/main.py +2 -1
  3. srcly-1.0.0/app/models.py +207 -0
  4. {srcly-0.3.0 → srcly-1.0.0}/app/routers/analysis.py +27 -7
  5. {srcly-0.3.0 → srcly-1.0.0}/app/routers/files.py +68 -0
  6. srcly-1.0.0/app/routers/metrics.py +11 -0
  7. {srcly-0.3.0 → srcly-1.0.0}/app/routers/scans.py +10 -3
  8. {srcly-0.3.0 → srcly-1.0.0}/app/run.py +75 -20
  9. srcly-1.0.0/app/services/activity.py +435 -0
  10. {srcly-0.3.0 → srcly-1.0.0}/app/services/analysis.py +220 -215
  11. srcly-1.0.0/app/services/analysis_types.py +205 -0
  12. {srcly-0.3.0 → srcly-1.0.0}/app/services/cache.py +1 -1
  13. srcly-1.0.0/app/services/comment_noise.py +256 -0
  14. {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/diff.py +19 -36
  15. {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/git_refs.py +8 -1
  16. {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/service.py +8 -0
  17. {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/snapshots.py +19 -3
  18. {srcly-0.3.0 → srcly-1.0.0}/app/services/css/css_analysis.py +3 -11
  19. srcly-1.0.0/app/services/findings/__init__.py +36 -0
  20. srcly-1.0.0/app/services/findings/builtin_ts.py +135 -0
  21. srcly-1.0.0/app/services/findings/jobs.py +86 -0
  22. srcly-1.0.0/app/services/findings/model.py +157 -0
  23. srcly-1.0.0/app/services/findings/overlay.py +133 -0
  24. srcly-1.0.0/app/services/findings/ruff.py +142 -0
  25. srcly-1.0.0/app/services/findings/runner.py +186 -0
  26. srcly-1.0.0/app/services/findings/tools.py +198 -0
  27. srcly-1.0.0/app/services/findings/typescript.py +269 -0
  28. {srcly-0.3.0 → srcly-1.0.0}/app/services/ipynb/ipynb_analysis.py +1 -2
  29. {srcly-0.3.0 → srcly-1.0.0}/app/services/markdown/markdown_analysis.py +2 -11
  30. srcly-1.0.0/app/services/markers.py +98 -0
  31. srcly-1.0.0/app/services/metric_catalog.py +343 -0
  32. {srcly-0.3.0 → srcly-1.0.0}/app/services/python/python_analysis.py +106 -21
  33. srcly-1.0.0/app/services/quadrants.py +382 -0
  34. {srcly-0.3.0 → srcly-1.0.0}/app/services/reporting.py +269 -169
  35. {srcly-0.3.0 → srcly-1.0.0}/app/services/scan_jobs.py +46 -1
  36. srcly-1.0.0/app/services/typescript/components.py +371 -0
  37. {srcly-0.3.0 → srcly-1.0.0}/app/services/typescript/typescript_analysis.py +137 -105
  38. srcly-1.0.0/app/static/assets/CodeModal-BaYjncYj.js +36 -0
  39. srcly-1.0.0/app/static/assets/CompareApp-Omwsxouz.js +3 -0
  40. srcly-1.0.0/app/static/assets/DependencyView-CKkGFSEj.js +1 -0
  41. srcly-1.0.0/app/static/assets/ScatterView-4PHaFV2z.js +1 -0
  42. srcly-1.0.0/app/static/assets/chevron-left-CGokVLxh.js +1 -0
  43. srcly-1.0.0/app/static/assets/index-DoC5YIyS.css +1 -0
  44. srcly-1.0.0/app/static/assets/index-E273RRji.js +2 -0
  45. srcly-1.0.0/app/static/assets/layout-CKrLZkty.js +1 -0
  46. {srcly-0.3.0 → srcly-1.0.0}/app/static/index.html +2 -2
  47. {srcly-0.3.0 → srcly-1.0.0}/pyproject.toml +2 -1
  48. srcly-0.3.0/app/models.py +0 -105
  49. srcly-0.3.0/app/services/analysis_types.py +0 -70
  50. srcly-0.3.0/app/static/assets/CodeModal-CTBbqRv_.js +0 -29
  51. srcly-0.3.0/app/static/assets/CompareApp-BSOMRmdx.js +0 -3
  52. srcly-0.3.0/app/static/assets/DependencyView-dzVV2_Up.js +0 -1
  53. srcly-0.3.0/app/static/assets/index-BVNn0QLM.js +0 -2
  54. srcly-0.3.0/app/static/assets/index-D2ClTmny.css +0 -1
  55. srcly-0.3.0/app/static/assets/layout-DSK4VztQ.js +0 -1
  56. {srcly-0.3.0 → srcly-1.0.0}/.gitignore +0 -0
  57. {srcly-0.3.0 → srcly-1.0.0}/README.md +0 -0
  58. {srcly-0.3.0 → srcly-1.0.0}/app/.gitignore +0 -0
  59. {srcly-0.3.0 → srcly-1.0.0}/app/__init__.py +0 -0
  60. {srcly-0.3.0 → srcly-1.0.0}/app/config.py +0 -0
  61. {srcly-0.3.0 → srcly-1.0.0}/app/routers/compare.py +0 -0
  62. {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/__init__.py +0 -0
  63. {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/github.py +0 -0
  64. {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/jobs.py +0 -0
  65. {srcly-0.3.0 → srcly-1.0.0}/app/services/compare/sources.py +0 -0
  66. {srcly-0.3.0 → srcly-1.0.0}/app/services/deps/__init__.py +0 -0
  67. {srcly-0.3.0 → srcly-1.0.0}/app/services/deps/common.py +0 -0
  68. {srcly-0.3.0 → srcly-1.0.0}/app/services/deps/graph.py +0 -0
  69. {srcly-0.3.0 → srcly-1.0.0}/app/services/deps/python_deps.py +0 -0
  70. {srcly-0.3.0 → srcly-1.0.0}/app/services/deps/tsconfig.py +0 -0
  71. {srcly-0.3.0 → srcly-1.0.0}/app/services/deps/typescript_deps.py +0 -0
  72. {srcly-0.3.0 → srcly-1.0.0}/app/services/scan_progress.py +0 -0
  73. {srcly-0.3.0 → srcly-1.0.0}/app/services/scan_workers.py +0 -0
  74. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/bash-DgJyDXW6.js +0 -0
  75. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/css-PZBBrkI0.js +0 -0
  76. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/github-light-Rcb2q3L5.js +0 -0
  77. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/highlight.worker-C64dOQ1-.js +0 -0
  78. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/html-DOf0XnR2.js +0 -0
  79. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  80. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  81. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  82. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  83. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
  84. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
  85. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  86. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  87. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  88. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  89. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
  90. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
  91. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  92. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  93. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  94. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  95. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  96. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  97. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  98. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  99. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  100. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  101. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
  102. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
  103. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  104. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  105. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  106. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  107. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
  108. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
  109. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  110. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  111. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  112. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  113. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  114. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  115. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  116. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/javascript-WyGyM1wV.js +0 -0
  117. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/json-B8JryHcM.js +0 -0
  118. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/jsx-8cLv2Six.js +0 -0
  119. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/markdown-DX-TNHnc.js +0 -0
  120. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/python-B6Ihcp-E.js +0 -0
  121. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/toml-Ms459DBw.js +0 -0
  122. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/tsx-D6116Ycy.js +0 -0
  123. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/typescript-BqvgUolh.js +0 -0
  124. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/wasm-CG6Dc4jp.js +0 -0
  125. {srcly-0.3.0 → srcly-1.0.0}/app/static/assets/yaml-Bopit2L3.js +0 -0
  126. {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.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 _scan_response(target_path: Path, force: bool = False) -> Response:
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.result_json(), media_type="application/json")
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=job.result_json(), media_type="application/json")
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="Scoring profile.",
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("--metric", action="append", default=None, help="Metric to score. May be repeated.")
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
- profile = _profile_from_metrics(args.metric)
360
- ranked = reporting.rank_nodes(tree, profile=profile)[: args.limit]
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({"rankings": ranked}))
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
- print(f"{item['rank']}. score={item['score']} {location} - {item['reason']}")
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
- ranked = reporting.rank_nodes(target)
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),