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.
Files changed (127) hide show
  1. {srcly-0.2.0 → srcly-1.0.0}/.gitignore +3 -0
  2. {srcly-0.2.0 → srcly-1.0.0}/PKG-INFO +2 -1
  3. {srcly-0.2.0 → srcly-1.0.0}/app/main.py +2 -1
  4. srcly-1.0.0/app/models.py +207 -0
  5. {srcly-0.2.0 → srcly-1.0.0}/app/routers/analysis.py +27 -7
  6. {srcly-0.2.0 → srcly-1.0.0}/app/routers/compare.py +61 -2
  7. {srcly-0.2.0 → srcly-1.0.0}/app/routers/files.py +68 -0
  8. srcly-1.0.0/app/routers/metrics.py +11 -0
  9. {srcly-0.2.0 → srcly-1.0.0}/app/routers/scans.py +10 -3
  10. {srcly-0.2.0 → srcly-1.0.0}/app/run.py +94 -25
  11. srcly-1.0.0/app/services/activity.py +435 -0
  12. {srcly-0.2.0 → srcly-1.0.0}/app/services/analysis.py +220 -215
  13. srcly-1.0.0/app/services/analysis_types.py +205 -0
  14. {srcly-0.2.0 → srcly-1.0.0}/app/services/cache.py +1 -1
  15. srcly-1.0.0/app/services/comment_noise.py +256 -0
  16. {srcly-0.2.0 → srcly-1.0.0}/app/services/compare/diff.py +19 -36
  17. srcly-1.0.0/app/services/compare/git_refs.py +436 -0
  18. srcly-1.0.0/app/services/compare/github.py +177 -0
  19. {srcly-0.2.0 → srcly-1.0.0}/app/services/compare/jobs.py +51 -2
  20. {srcly-0.2.0 → srcly-1.0.0}/app/services/compare/service.py +98 -14
  21. {srcly-0.2.0 → srcly-1.0.0}/app/services/compare/snapshots.py +74 -12
  22. srcly-1.0.0/app/services/compare/sources.py +216 -0
  23. {srcly-0.2.0 → srcly-1.0.0}/app/services/css/css_analysis.py +3 -11
  24. srcly-1.0.0/app/services/findings/__init__.py +36 -0
  25. srcly-1.0.0/app/services/findings/builtin_ts.py +135 -0
  26. srcly-1.0.0/app/services/findings/jobs.py +86 -0
  27. srcly-1.0.0/app/services/findings/model.py +157 -0
  28. srcly-1.0.0/app/services/findings/overlay.py +133 -0
  29. srcly-1.0.0/app/services/findings/ruff.py +142 -0
  30. srcly-1.0.0/app/services/findings/runner.py +186 -0
  31. srcly-1.0.0/app/services/findings/tools.py +198 -0
  32. srcly-1.0.0/app/services/findings/typescript.py +269 -0
  33. {srcly-0.2.0 → srcly-1.0.0}/app/services/ipynb/ipynb_analysis.py +1 -2
  34. {srcly-0.2.0 → srcly-1.0.0}/app/services/markdown/markdown_analysis.py +2 -11
  35. srcly-1.0.0/app/services/markers.py +98 -0
  36. srcly-1.0.0/app/services/metric_catalog.py +343 -0
  37. {srcly-0.2.0 → srcly-1.0.0}/app/services/python/python_analysis.py +106 -21
  38. srcly-1.0.0/app/services/quadrants.py +382 -0
  39. {srcly-0.2.0 → srcly-1.0.0}/app/services/reporting.py +269 -169
  40. {srcly-0.2.0 → srcly-1.0.0}/app/services/scan_jobs.py +46 -1
  41. srcly-1.0.0/app/services/typescript/components.py +371 -0
  42. {srcly-0.2.0 → srcly-1.0.0}/app/services/typescript/typescript_analysis.py +137 -105
  43. srcly-1.0.0/app/static/assets/CodeModal-BaYjncYj.js +36 -0
  44. srcly-1.0.0/app/static/assets/CompareApp-Omwsxouz.js +3 -0
  45. srcly-1.0.0/app/static/assets/DependencyView-CKkGFSEj.js +1 -0
  46. srcly-1.0.0/app/static/assets/ScatterView-4PHaFV2z.js +1 -0
  47. srcly-1.0.0/app/static/assets/chevron-left-CGokVLxh.js +1 -0
  48. srcly-1.0.0/app/static/assets/index-DoC5YIyS.css +1 -0
  49. srcly-1.0.0/app/static/assets/index-E273RRji.js +2 -0
  50. srcly-1.0.0/app/static/assets/layout-CKrLZkty.js +1 -0
  51. {srcly-0.2.0 → srcly-1.0.0}/app/static/index.html +2 -2
  52. {srcly-0.2.0 → srcly-1.0.0}/pyproject.toml +2 -1
  53. srcly-0.2.0/app/models.py +0 -105
  54. srcly-0.2.0/app/services/analysis_types.py +0 -70
  55. srcly-0.2.0/app/services/compare/git_refs.py +0 -185
  56. srcly-0.2.0/app/static/assets/CodeModal-BC4-51lF.js +0 -29
  57. srcly-0.2.0/app/static/assets/CompareApp-CtVSUR0J.js +0 -2
  58. srcly-0.2.0/app/static/assets/DependencyView-Cz2blz64.js +0 -1
  59. srcly-0.2.0/app/static/assets/index-F49dMNqG.css +0 -1
  60. srcly-0.2.0/app/static/assets/index-mm3bqu0K.js +0 -2
  61. srcly-0.2.0/app/static/assets/layout-CCJmnknz.js +0 -1
  62. {srcly-0.2.0 → srcly-1.0.0}/README.md +0 -0
  63. {srcly-0.2.0 → srcly-1.0.0}/app/.gitignore +0 -0
  64. {srcly-0.2.0 → srcly-1.0.0}/app/__init__.py +0 -0
  65. {srcly-0.2.0 → srcly-1.0.0}/app/config.py +0 -0
  66. {srcly-0.2.0 → srcly-1.0.0}/app/services/compare/__init__.py +0 -0
  67. {srcly-0.2.0 → srcly-1.0.0}/app/services/deps/__init__.py +0 -0
  68. {srcly-0.2.0 → srcly-1.0.0}/app/services/deps/common.py +0 -0
  69. {srcly-0.2.0 → srcly-1.0.0}/app/services/deps/graph.py +0 -0
  70. {srcly-0.2.0 → srcly-1.0.0}/app/services/deps/python_deps.py +0 -0
  71. {srcly-0.2.0 → srcly-1.0.0}/app/services/deps/tsconfig.py +0 -0
  72. {srcly-0.2.0 → srcly-1.0.0}/app/services/deps/typescript_deps.py +0 -0
  73. {srcly-0.2.0 → srcly-1.0.0}/app/services/scan_progress.py +0 -0
  74. {srcly-0.2.0 → srcly-1.0.0}/app/services/scan_workers.py +0 -0
  75. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/bash-DgJyDXW6.js +0 -0
  76. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/css-PZBBrkI0.js +0 -0
  77. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/github-light-Rcb2q3L5.js +0 -0
  78. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/highlight.worker-C64dOQ1-.js +0 -0
  79. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/html-DOf0XnR2.js +0 -0
  80. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  81. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  82. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  83. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  84. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
  85. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
  86. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  87. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  88. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  89. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  90. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
  91. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
  92. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  93. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  94. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  95. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  96. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  97. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  98. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  99. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  100. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  101. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  102. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
  103. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
  104. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  105. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  106. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  107. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  108. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
  109. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
  110. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  111. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  112. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  113. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  114. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  115. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  116. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  117. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/javascript-WyGyM1wV.js +0 -0
  118. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/json-B8JryHcM.js +0 -0
  119. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/jsx-8cLv2Six.js +0 -0
  120. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/markdown-DX-TNHnc.js +0 -0
  121. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/python-B6Ihcp-E.js +0 -0
  122. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/toml-Ms459DBw.js +0 -0
  123. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/tsx-D6116Ycy.js +0 -0
  124. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/typescript-BqvgUolh.js +0 -0
  125. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/wasm-CG6Dc4jp.js +0 -0
  126. {srcly-0.2.0 → srcly-1.0.0}/app/static/assets/yaml-Bopit2L3.js +0 -0
  127. {srcly-0.2.0 → srcly-1.0.0}/app/static/favicon.svg +0 -0
@@ -68,3 +68,6 @@ venv.bak/
68
68
  # uv.lock is intentionally not ignored as this is an application
69
69
 
70
70
  .tmp
71
+
72
+ # Local dependency-graph captures (may come from private repos)
73
+ client/src/viz/deps/__fixtures__/local/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: srcly
3
- Version: 0.2.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]:
@@ -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 GitError, default_branch, repo_root, run_git
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=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")