codeabc 0.1.0__py3-none-any.whl
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.
- backend/__init__.py +0 -0
- backend/app.py +95 -0
- backend/desktop_server.py +46 -0
- backend/models.py +629 -0
- backend/prompts/__init__.py +0 -0
- backend/prompts/annotate.py +44 -0
- backend/prompts/edit.py +62 -0
- backend/prompts/overview.py +57 -0
- backend/prompts/qa.py +45 -0
- backend/routers/__init__.py +0 -0
- backend/routers/analyze.py +271 -0
- backend/routers/project.py +872 -0
- backend/services/__init__.py +0 -0
- backend/services/action_plan.py +366 -0
- backend/services/activity.py +313 -0
- backend/services/apimap.py +391 -0
- backend/services/async_surface.py +123 -0
- backend/services/cache.py +183 -0
- backend/services/churn.py +244 -0
- backend/services/ci_checks.py +317 -0
- backend/services/codemap_export.py +136 -0
- backend/services/commands.py +276 -0
- backend/services/complexity.py +111 -0
- backend/services/config_files.py +190 -0
- backend/services/contributing.py +308 -0
- backend/services/coverage.py +283 -0
- backend/services/datamodels.py +190 -0
- backend/services/deep_nesting.py +110 -0
- backend/services/dependencies.py +270 -0
- backend/services/docs.py +255 -0
- backend/services/docstrings.py +163 -0
- backend/services/duplicate_code.py +165 -0
- backend/services/entrypoints.py +152 -0
- backend/services/envscan.py +167 -0
- backend/services/error_handling.py +239 -0
- backend/services/filenames.py +261 -0
- backend/services/github_clone.py +129 -0
- backend/services/glossary.py +96 -0
- backend/services/health_score.py +381 -0
- backend/services/importgraph.py +986 -0
- backend/services/integrations.py +263 -0
- backend/services/licenses.py +505 -0
- backend/services/llm.py +117 -0
- backend/services/long_functions.py +101 -0
- backend/services/ownership.py +194 -0
- backend/services/package_purposes.py +112 -0
- backend/services/pr_reading.py +181 -0
- backend/services/release_map.py +402 -0
- backend/services/report_export.py +232 -0
- backend/services/risk.py +88 -0
- backend/services/scanner.py +546 -0
- backend/services/schedules.py +369 -0
- backend/services/security.py +445 -0
- backend/services/settings_map.py +234 -0
- backend/services/symbols.py +605 -0
- backend/services/techdebt.py +124 -0
- backend/services/too_many_params.py +102 -0
- backend/services/typing_coverage.py +184 -0
- codeabc-0.1.0.dist-info/METADATA +283 -0
- codeabc-0.1.0.dist-info/RECORD +62 -0
- codeabc-0.1.0.dist-info/WHEEL +4 -0
- codeabc-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
"""Project management routes: upload files, clone from GitHub."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException
|
|
8
|
+
from fastapi.responses import Response
|
|
9
|
+
|
|
10
|
+
from backend.models import (
|
|
11
|
+
ActionItem,
|
|
12
|
+
ActionPlan,
|
|
13
|
+
ActivityContributor,
|
|
14
|
+
ActivitySummary,
|
|
15
|
+
ActivityWindow,
|
|
16
|
+
AnalyzeRequest,
|
|
17
|
+
ApiMap,
|
|
18
|
+
ApiRoute,
|
|
19
|
+
ArchitectureLayer,
|
|
20
|
+
BlastRadiusHotspot,
|
|
21
|
+
ChurnHotspot,
|
|
22
|
+
CiCheck,
|
|
23
|
+
CliCommand,
|
|
24
|
+
CoChangeCoupling,
|
|
25
|
+
CodeWalkStep,
|
|
26
|
+
ComplexFile,
|
|
27
|
+
ConfigFile,
|
|
28
|
+
ContributionGuide,
|
|
29
|
+
ContributionRequirement,
|
|
30
|
+
CouplingHotspot,
|
|
31
|
+
DataModel,
|
|
32
|
+
DataModelField,
|
|
33
|
+
Definition,
|
|
34
|
+
DefinitionMatches,
|
|
35
|
+
Dependency,
|
|
36
|
+
DocCoverage,
|
|
37
|
+
DocCoverageFile,
|
|
38
|
+
EntryPoint,
|
|
39
|
+
EnvVar,
|
|
40
|
+
ErrorHandling,
|
|
41
|
+
ExternalService,
|
|
42
|
+
FileDependencies,
|
|
43
|
+
FileGlossary,
|
|
44
|
+
FileInfo,
|
|
45
|
+
FileOutline,
|
|
46
|
+
GitHubRequest,
|
|
47
|
+
GlossaryTerm,
|
|
48
|
+
HealthScore,
|
|
49
|
+
Hotspot,
|
|
50
|
+
ImportCycle,
|
|
51
|
+
Integrations,
|
|
52
|
+
KnowledgeSilo,
|
|
53
|
+
LicenseFinding,
|
|
54
|
+
LicenseSummary,
|
|
55
|
+
OrphanModule,
|
|
56
|
+
PackageDependency,
|
|
57
|
+
ProjectHealth,
|
|
58
|
+
ProjectMeta,
|
|
59
|
+
ProjectSummary,
|
|
60
|
+
PublicApi,
|
|
61
|
+
ReadingStep,
|
|
62
|
+
Reference,
|
|
63
|
+
ReferenceMatches,
|
|
64
|
+
ReleaseAutomation,
|
|
65
|
+
ReleaseSummary,
|
|
66
|
+
RiskHotspot,
|
|
67
|
+
ScheduledTask,
|
|
68
|
+
SecurityFinding,
|
|
69
|
+
SecuritySummary,
|
|
70
|
+
SilentFailure,
|
|
71
|
+
TechDebtFile,
|
|
72
|
+
TestCoverageSummary,
|
|
73
|
+
TunableSetting,
|
|
74
|
+
UploadedFile,
|
|
75
|
+
)
|
|
76
|
+
from backend.services import (
|
|
77
|
+
action_plan as action_plan_svc,
|
|
78
|
+
)
|
|
79
|
+
from backend.services import (
|
|
80
|
+
activity,
|
|
81
|
+
apimap,
|
|
82
|
+
cache,
|
|
83
|
+
churn,
|
|
84
|
+
ci_checks,
|
|
85
|
+
codemap_export,
|
|
86
|
+
commands,
|
|
87
|
+
complexity,
|
|
88
|
+
config_files,
|
|
89
|
+
contributing,
|
|
90
|
+
coverage,
|
|
91
|
+
datamodels,
|
|
92
|
+
deep_nesting,
|
|
93
|
+
dependencies,
|
|
94
|
+
docs,
|
|
95
|
+
duplicate_code,
|
|
96
|
+
entrypoints,
|
|
97
|
+
envscan,
|
|
98
|
+
error_handling,
|
|
99
|
+
filenames,
|
|
100
|
+
github_clone,
|
|
101
|
+
glossary,
|
|
102
|
+
importgraph,
|
|
103
|
+
integrations,
|
|
104
|
+
licenses,
|
|
105
|
+
long_functions,
|
|
106
|
+
ownership,
|
|
107
|
+
pr_reading,
|
|
108
|
+
release_map,
|
|
109
|
+
report_export,
|
|
110
|
+
risk,
|
|
111
|
+
scanner,
|
|
112
|
+
schedules,
|
|
113
|
+
security,
|
|
114
|
+
settings_map,
|
|
115
|
+
symbols,
|
|
116
|
+
techdebt,
|
|
117
|
+
too_many_params,
|
|
118
|
+
typing_coverage,
|
|
119
|
+
)
|
|
120
|
+
from backend.services import (
|
|
121
|
+
health_score as health_score_svc,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
router = APIRouter(tags=["project"])
|
|
125
|
+
|
|
126
|
+
# in-memory project store (good enough for MVP, single-process)
|
|
127
|
+
_projects: dict[str, dict] = {}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _select_scanned_contents(files: list[UploadedFile], scanned: list[dict]) -> dict[str, str]:
|
|
131
|
+
allowed_paths = {file["path"] for file in scanned}
|
|
132
|
+
return {file.path: file.content for file in files if file.path in allowed_paths}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _reading_steps(files: list[dict]) -> list["ReadingStep"]:
|
|
136
|
+
"""Build the deterministic reading map, tagging each step with what kind of
|
|
137
|
+
file it is so a non-coder can tell ``urls.py`` from ``models.py`` without
|
|
138
|
+
opening either."""
|
|
139
|
+
steps = []
|
|
140
|
+
for step in scanner.build_reading_map(files):
|
|
141
|
+
purpose = filenames.explain_path(step["path"])
|
|
142
|
+
steps.append(ReadingStep(**step, kind=purpose["kind"] if purpose else None))
|
|
143
|
+
return steps
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _activity_summary(data: dict | None) -> "ActivitySummary":
|
|
147
|
+
"""Reconstruct an ActivitySummary Pydantic model from a raw activity dict."""
|
|
148
|
+
if not data:
|
|
149
|
+
return ActivitySummary()
|
|
150
|
+
return ActivitySummary(
|
|
151
|
+
available=data.get("available", False),
|
|
152
|
+
total_commits=data.get("total_commits", 0),
|
|
153
|
+
first_commit_days_ago=data.get("first_commit_days_ago"),
|
|
154
|
+
last_commit_days_ago=data.get("last_commit_days_ago"),
|
|
155
|
+
label=data.get("label", ""),
|
|
156
|
+
label_zh=data.get("label_zh", ""),
|
|
157
|
+
windows={k: ActivityWindow(**v) for k, v in data.get("windows", {}).items()},
|
|
158
|
+
top_contributors=[ActivityContributor(**c) for c in data.get("top_contributors", [])],
|
|
159
|
+
recently_changed=data.get("recently_changed", []),
|
|
160
|
+
notes=data.get("notes", []),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _health_score_summary(data: dict | None) -> "HealthScore":
|
|
165
|
+
"""Reconstruct a HealthScore Pydantic model from a raw compute_health_score dict."""
|
|
166
|
+
if not data:
|
|
167
|
+
return HealthScore()
|
|
168
|
+
return HealthScore(
|
|
169
|
+
score=data.get("score", 0),
|
|
170
|
+
grade=data.get("grade", "F"),
|
|
171
|
+
category_scores=data.get("category_scores", {}),
|
|
172
|
+
weights=data.get("weights", {}),
|
|
173
|
+
strengths=data.get("strengths", []),
|
|
174
|
+
weaknesses=data.get("weaknesses", []),
|
|
175
|
+
notes=data.get("notes", []),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _compute_health_score(meta: "ProjectMeta", file_contents: dict[str, str] | None = None) -> dict:
|
|
180
|
+
"""Derive health score from an already-built ProjectMeta using .model_dump().
|
|
181
|
+
|
|
182
|
+
``file_contents`` (when available) lets the complexity dimension also account
|
|
183
|
+
for deep nesting, a readability signal derived directly from the source text.
|
|
184
|
+
"""
|
|
185
|
+
deep_nesting_files = None
|
|
186
|
+
if file_contents:
|
|
187
|
+
deep_nesting_files = deep_nesting.scan_deep_nesting(file_contents).get("files")
|
|
188
|
+
return health_score_svc.compute_health_score(
|
|
189
|
+
security=meta.security.model_dump() if meta.security else None,
|
|
190
|
+
test_coverage=meta.test_coverage.model_dump() if meta.test_coverage else None,
|
|
191
|
+
activity=meta.activity.model_dump() if meta.activity else None,
|
|
192
|
+
complexity_files=[f.model_dump() for f in meta.complexity_files],
|
|
193
|
+
tech_debt_files=[f.model_dump() for f in meta.tech_debt_files],
|
|
194
|
+
import_cycles=[c.model_dump() for c in meta.import_cycles],
|
|
195
|
+
orphan_modules=[o.model_dump() for o in meta.orphan_modules],
|
|
196
|
+
deep_nesting_files=deep_nesting_files,
|
|
197
|
+
total_files=meta.total_files,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _action_plan_summary(data: dict | None) -> "ActionPlan":
|
|
202
|
+
"""Reconstruct an ActionPlan Pydantic model from a raw build_action_plan dict."""
|
|
203
|
+
if not data:
|
|
204
|
+
return ActionPlan()
|
|
205
|
+
return ActionPlan(
|
|
206
|
+
total=data.get("total", 0),
|
|
207
|
+
items=[ActionItem(**i) for i in data.get("items", [])],
|
|
208
|
+
notes=data.get("notes", []),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _compute_action_plan(meta: "ProjectMeta", file_contents: dict[str, str] | None = None) -> dict:
|
|
213
|
+
"""Derive the priority action plan from an already-built ProjectMeta.
|
|
214
|
+
|
|
215
|
+
``file_contents`` (when available) lets the plan also surface deep-nesting and
|
|
216
|
+
low-type-coverage actions, which are derived directly from the source text
|
|
217
|
+
rather than stored on ``ProjectMeta``.
|
|
218
|
+
"""
|
|
219
|
+
deep_nesting_files = None
|
|
220
|
+
typing_files = None
|
|
221
|
+
long_functions_files = None
|
|
222
|
+
too_many_params_files = None
|
|
223
|
+
duplicate_code_clusters = None
|
|
224
|
+
if file_contents:
|
|
225
|
+
deep_nesting_files = deep_nesting.scan_deep_nesting(file_contents).get("files")
|
|
226
|
+
typing_files = typing_coverage.scan_typing_coverage(file_contents).get("files")
|
|
227
|
+
long_functions_files = long_functions.scan_long_functions(file_contents).get("files")
|
|
228
|
+
too_many_params_files = too_many_params.scan_too_many_params(file_contents).get("files")
|
|
229
|
+
duplicate_code_clusters = duplicate_code.scan_duplicate_code(file_contents).get("clusters")
|
|
230
|
+
return action_plan_svc.build_action_plan(
|
|
231
|
+
security=meta.security.model_dump() if meta.security else None,
|
|
232
|
+
test_coverage=meta.test_coverage.model_dump() if meta.test_coverage else None,
|
|
233
|
+
complexity_files=[f.model_dump() for f in meta.complexity_files],
|
|
234
|
+
tech_debt_files=[f.model_dump() for f in meta.tech_debt_files],
|
|
235
|
+
import_cycles=[c.model_dump() for c in meta.import_cycles],
|
|
236
|
+
deep_nesting_files=deep_nesting_files,
|
|
237
|
+
long_functions_files=long_functions_files,
|
|
238
|
+
too_many_params_files=too_many_params_files,
|
|
239
|
+
duplicate_code_clusters=duplicate_code_clusters,
|
|
240
|
+
typing_files=typing_files,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _content_analyses(
|
|
245
|
+
file_contents: dict[str, str], ownership_silos: list[dict] | None = None
|
|
246
|
+
) -> dict:
|
|
247
|
+
"""Build the file-content-derived ProjectMeta analyses (debt / env / silos).
|
|
248
|
+
|
|
249
|
+
Tech-debt and env vars come straight from the file text, so they work for
|
|
250
|
+
uploaded projects too; knowledge silos need git history, so callers pass the
|
|
251
|
+
already-computed ``ownership['silos']`` (empty for non-git uploads).
|
|
252
|
+
"""
|
|
253
|
+
tech_debt = techdebt.scan_tech_debt(file_contents)
|
|
254
|
+
env = envscan.scan_env_vars(file_contents)
|
|
255
|
+
entries = entrypoints.find_entry_points(file_contents)
|
|
256
|
+
cli_commands = commands.find_cli_commands(file_contents)
|
|
257
|
+
data_models = datamodels.find_data_models(file_contents)
|
|
258
|
+
tunable = settings_map.find_tunable_settings(file_contents)
|
|
259
|
+
cfg_files = config_files.find_config_files(file_contents)
|
|
260
|
+
scheduled = schedules.find_scheduled_tasks(file_contents)
|
|
261
|
+
ci_gates = ci_checks.find_ci_checks(file_contents)
|
|
262
|
+
license_map = licenses.find_licenses(file_contents)
|
|
263
|
+
complex_files = complexity.scan_complexity(file_contents)
|
|
264
|
+
deps = dependencies.scan_dependencies(file_contents)
|
|
265
|
+
sec = security.scan_security(file_contents)
|
|
266
|
+
api = apimap.scan_api_routes(file_contents)
|
|
267
|
+
doc = docs.assess_doc_coverage(file_contents)
|
|
268
|
+
silent = error_handling.find_swallowed_errors(file_contents)
|
|
269
|
+
integ = integrations.detect_external_services(file_contents)
|
|
270
|
+
rel = release_map.find_release_info(file_contents)
|
|
271
|
+
contrib = contributing.find_contribution_guide(file_contents)
|
|
272
|
+
return {
|
|
273
|
+
"knowledge_silos": [
|
|
274
|
+
KnowledgeSilo(
|
|
275
|
+
path=s["path"],
|
|
276
|
+
primary_author=s["primary_author"],
|
|
277
|
+
ownership=s["ownership"],
|
|
278
|
+
commits=s["commits"],
|
|
279
|
+
bus_factor=s["bus_factor"],
|
|
280
|
+
reason=s["reason"],
|
|
281
|
+
)
|
|
282
|
+
for s in (ownership_silos or [])
|
|
283
|
+
],
|
|
284
|
+
"tech_debt_files": [
|
|
285
|
+
TechDebtFile(path=f["path"], count=f["count"]) for f in tech_debt["files"]
|
|
286
|
+
],
|
|
287
|
+
"env_vars": [
|
|
288
|
+
EnvVar(name=v["name"], required=v["required"], count=v["count"]) for v in env["vars"]
|
|
289
|
+
],
|
|
290
|
+
"entry_points": [
|
|
291
|
+
EntryPoint(path=e["path"], kind=e["kind"], command=e["command"], reason=e["reason"])
|
|
292
|
+
for e in entries["entry_points"]
|
|
293
|
+
],
|
|
294
|
+
"cli_commands": [
|
|
295
|
+
CliCommand(
|
|
296
|
+
name=c["name"],
|
|
297
|
+
framework=c["framework"],
|
|
298
|
+
help=c["help"],
|
|
299
|
+
options=c["options"],
|
|
300
|
+
path=c["path"],
|
|
301
|
+
line=c["line"],
|
|
302
|
+
)
|
|
303
|
+
for c in cli_commands["commands"]
|
|
304
|
+
],
|
|
305
|
+
"data_models": [
|
|
306
|
+
DataModel(
|
|
307
|
+
name=m["name"],
|
|
308
|
+
kind=m["kind"],
|
|
309
|
+
fields=[
|
|
310
|
+
DataModelField(name=f["name"], type=f["type"], has_default=f["has_default"])
|
|
311
|
+
for f in m["fields"]
|
|
312
|
+
],
|
|
313
|
+
path=m["path"],
|
|
314
|
+
line=m["line"],
|
|
315
|
+
)
|
|
316
|
+
for m in data_models["models"]
|
|
317
|
+
],
|
|
318
|
+
"tunable_settings": [
|
|
319
|
+
TunableSetting(
|
|
320
|
+
name=s["name"],
|
|
321
|
+
kind=s["kind"],
|
|
322
|
+
value=s["value"],
|
|
323
|
+
path=s["path"],
|
|
324
|
+
line=s["line"],
|
|
325
|
+
)
|
|
326
|
+
for s in tunable["settings"]
|
|
327
|
+
],
|
|
328
|
+
"config_files": [
|
|
329
|
+
ConfigFile(
|
|
330
|
+
path=f["path"],
|
|
331
|
+
kind=f["kind"],
|
|
332
|
+
sections=f["sections"],
|
|
333
|
+
keys=f["keys"],
|
|
334
|
+
setting_count=f["setting_count"],
|
|
335
|
+
)
|
|
336
|
+
for f in cfg_files["files"]
|
|
337
|
+
],
|
|
338
|
+
"scheduled_tasks": [
|
|
339
|
+
ScheduledTask(
|
|
340
|
+
name=t["name"],
|
|
341
|
+
mechanism=t["mechanism"],
|
|
342
|
+
schedule=t["schedule"],
|
|
343
|
+
schedule_human=t["schedule_human"],
|
|
344
|
+
path=t["path"],
|
|
345
|
+
line=t["line"],
|
|
346
|
+
)
|
|
347
|
+
for t in scheduled["tasks"]
|
|
348
|
+
],
|
|
349
|
+
"ci_checks": [
|
|
350
|
+
CiCheck(
|
|
351
|
+
tool=c["tool"],
|
|
352
|
+
category=c["category"],
|
|
353
|
+
system=c["system"],
|
|
354
|
+
trigger=c["trigger"],
|
|
355
|
+
path=c["path"],
|
|
356
|
+
line=c["line"],
|
|
357
|
+
)
|
|
358
|
+
for c in ci_gates["checks"]
|
|
359
|
+
],
|
|
360
|
+
"licenses": LicenseSummary(
|
|
361
|
+
total=license_map["total"],
|
|
362
|
+
primary=license_map["primary"],
|
|
363
|
+
primary_category=license_map["primary_category"],
|
|
364
|
+
found=[LicenseFinding(**f) for f in license_map["found"]],
|
|
365
|
+
categories=license_map["categories"],
|
|
366
|
+
notes=license_map["notes"],
|
|
367
|
+
),
|
|
368
|
+
"complexity_files": [
|
|
369
|
+
ComplexFile(
|
|
370
|
+
path=f["path"],
|
|
371
|
+
complexity=f["complexity"],
|
|
372
|
+
functions=f["functions"],
|
|
373
|
+
reason=f["reason"],
|
|
374
|
+
)
|
|
375
|
+
for f in complex_files["files"]
|
|
376
|
+
],
|
|
377
|
+
"dependencies": [
|
|
378
|
+
Dependency(
|
|
379
|
+
name=d["name"],
|
|
380
|
+
version=d["version"],
|
|
381
|
+
kind=d["kind"],
|
|
382
|
+
manifest=d["manifest"],
|
|
383
|
+
purpose=d.get("purpose"),
|
|
384
|
+
)
|
|
385
|
+
for d in deps["dependencies"]
|
|
386
|
+
],
|
|
387
|
+
"security": SecuritySummary(
|
|
388
|
+
total=sec["total"],
|
|
389
|
+
critical=sec["critical"],
|
|
390
|
+
findings=[SecurityFinding(**f) for f in sec["findings"]],
|
|
391
|
+
notes=sec["notes"],
|
|
392
|
+
),
|
|
393
|
+
"api_map": ApiMap(
|
|
394
|
+
total=api["total"],
|
|
395
|
+
routes=[ApiRoute(**r) for r in api["routes"]],
|
|
396
|
+
frameworks=api["frameworks"],
|
|
397
|
+
notes=api["notes"],
|
|
398
|
+
),
|
|
399
|
+
"doc_coverage": DocCoverage(
|
|
400
|
+
total_source_files=doc["total_source_files"],
|
|
401
|
+
documented_files=doc["documented_files"],
|
|
402
|
+
undocumented_files=doc["undocumented_files"],
|
|
403
|
+
doc_percent=doc["doc_percent"],
|
|
404
|
+
under_documented=[DocCoverageFile(**f) for f in doc["under_documented"]],
|
|
405
|
+
notes=doc["notes"],
|
|
406
|
+
),
|
|
407
|
+
"error_handling": ErrorHandling(
|
|
408
|
+
total=silent["total"],
|
|
409
|
+
files_affected=silent["files_affected"],
|
|
410
|
+
findings=[SilentFailure(**f) for f in silent["findings"]],
|
|
411
|
+
notes=silent["notes"],
|
|
412
|
+
),
|
|
413
|
+
"integrations": Integrations(
|
|
414
|
+
total=integ["total"],
|
|
415
|
+
services=[ExternalService(**s) for s in integ["services"]],
|
|
416
|
+
categories=integ["categories"],
|
|
417
|
+
notes=integ["notes"],
|
|
418
|
+
),
|
|
419
|
+
"release": ReleaseSummary(
|
|
420
|
+
version=rel["version"],
|
|
421
|
+
version_source=rel["version_source"],
|
|
422
|
+
version_source_kind=rel["version_source_kind"],
|
|
423
|
+
dynamic_from_vcs=rel["dynamic_from_vcs"],
|
|
424
|
+
scheme=rel["scheme"],
|
|
425
|
+
scheme_zh=rel["scheme_zh"],
|
|
426
|
+
changelog_path=rel["changelog_path"],
|
|
427
|
+
changelog_style=rel["changelog_style"],
|
|
428
|
+
changelog_style_zh=rel["changelog_style_zh"],
|
|
429
|
+
automation=[ReleaseAutomation(**a) for a in rel["automation"]],
|
|
430
|
+
publish_targets=rel["publish_targets"],
|
|
431
|
+
notes=rel["notes"],
|
|
432
|
+
),
|
|
433
|
+
"contribution": ContributionGuide(
|
|
434
|
+
has_guide=contrib["has_guide"],
|
|
435
|
+
requirements=[ContributionRequirement(**r) for r in contrib["requirements"]],
|
|
436
|
+
notes=contrib["notes"],
|
|
437
|
+
),
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@router.post("/project/upload", response_model=ProjectMeta)
|
|
442
|
+
async def upload_project(req: AnalyzeRequest):
|
|
443
|
+
"""Receive files uploaded from the frontend (webkitdirectory)."""
|
|
444
|
+
if not req.files:
|
|
445
|
+
raise HTTPException(400, "No files provided")
|
|
446
|
+
|
|
447
|
+
scanned = scanner.scan_uploaded_files([f.model_dump() for f in req.files])
|
|
448
|
+
if not scanned:
|
|
449
|
+
raise HTTPException(400, "No readable source files found")
|
|
450
|
+
|
|
451
|
+
project_id = uuid.uuid4().hex[:12]
|
|
452
|
+
proj_data = {
|
|
453
|
+
"name": req.project_name,
|
|
454
|
+
"files": scanned,
|
|
455
|
+
"file_contents": _select_scanned_contents(req.files, scanned),
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
meta = ProjectMeta(
|
|
459
|
+
id=project_id,
|
|
460
|
+
name=req.project_name,
|
|
461
|
+
total_files=len(scanned),
|
|
462
|
+
reading_map=_reading_steps(scanned),
|
|
463
|
+
hotspots=[Hotspot(**h) for h in importgraph.rank_hotspots(scanned)],
|
|
464
|
+
code_walk=[CodeWalkStep(**s) for s in importgraph.suggest_reading_order(scanned)],
|
|
465
|
+
import_cycles=[ImportCycle(**c) for c in importgraph.find_import_cycles(scanned)],
|
|
466
|
+
orphan_modules=[OrphanModule(**o) for o in importgraph.find_orphan_modules(scanned)],
|
|
467
|
+
coupling_hotspots=[CouplingHotspot(**c) for c in importgraph.rank_coupling(scanned)],
|
|
468
|
+
blast_radius=[BlastRadiusHotspot(**b) for b in importgraph.rank_blast_radius(scanned)],
|
|
469
|
+
architecture_layers=[
|
|
470
|
+
ArchitectureLayer(**a) for a in importgraph.assign_architecture_layers(scanned)
|
|
471
|
+
],
|
|
472
|
+
package_dependencies=[
|
|
473
|
+
PackageDependency(**p) for p in importgraph.summarize_package_dependencies(scanned)
|
|
474
|
+
],
|
|
475
|
+
health=ProjectHealth(**importgraph.summarize_project_health(scanned)),
|
|
476
|
+
test_coverage=TestCoverageSummary(**coverage.assess_test_coverage(scanned)),
|
|
477
|
+
**_content_analyses(_select_scanned_contents(req.files, scanned)),
|
|
478
|
+
files=[
|
|
479
|
+
FileInfo(
|
|
480
|
+
path=f["path"],
|
|
481
|
+
size=f["size"],
|
|
482
|
+
language=f["language"],
|
|
483
|
+
preview="",
|
|
484
|
+
)
|
|
485
|
+
for f in scanned
|
|
486
|
+
],
|
|
487
|
+
)
|
|
488
|
+
hs_result = _compute_health_score(meta, proj_data["file_contents"])
|
|
489
|
+
ap_result = _compute_action_plan(meta, proj_data["file_contents"])
|
|
490
|
+
proj_data["health_score"] = hs_result
|
|
491
|
+
proj_data["action_plan"] = ap_result
|
|
492
|
+
_projects[project_id] = proj_data
|
|
493
|
+
await cache.save_project(project_id, proj_data)
|
|
494
|
+
meta.health_score = _health_score_summary(hs_result)
|
|
495
|
+
meta.action_plan = _action_plan_summary(ap_result)
|
|
496
|
+
return meta
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@router.post("/project/github", response_model=ProjectMeta)
|
|
500
|
+
async def clone_github_project(req: GitHubRequest):
|
|
501
|
+
"""Clone a GitHub repo and scan it."""
|
|
502
|
+
try:
|
|
503
|
+
repo_path = await github_clone.clone_repo(req.url)
|
|
504
|
+
except ValueError as e:
|
|
505
|
+
raise HTTPException(400, str(e))
|
|
506
|
+
|
|
507
|
+
scanned = scanner.scan_directory(repo_path)
|
|
508
|
+
if not scanned:
|
|
509
|
+
raise HTTPException(400, "No readable source files in this repo")
|
|
510
|
+
|
|
511
|
+
project_id = uuid.uuid4().hex[:12]
|
|
512
|
+
|
|
513
|
+
# read full file contents for later annotation
|
|
514
|
+
file_contents = {}
|
|
515
|
+
for f in scanned:
|
|
516
|
+
fpath = repo_path / f["path"]
|
|
517
|
+
try:
|
|
518
|
+
file_contents[f["path"]] = fpath.read_text(errors="replace")
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
# Mine git history while the clone is still on disk: change hotspots,
|
|
523
|
+
# co-change coupling and code ownership are dynamic signals the static
|
|
524
|
+
# analyses can't see. Collect the log once and feed both analyses.
|
|
525
|
+
git_log = churn.collect_git_log(repo_path)
|
|
526
|
+
scanned_paths = {f["path"] for f in scanned}
|
|
527
|
+
churn_result = churn.analyze_churn(git_log, scanned_paths=scanned_paths)
|
|
528
|
+
ownership_result = ownership.analyze_ownership(git_log, scanned_paths=scanned_paths)
|
|
529
|
+
activity_result = activity.analyze_activity(git_log)
|
|
530
|
+
|
|
531
|
+
repo_name = req.url.rstrip("/").split("/")[-1].replace(".git", "")
|
|
532
|
+
proj_data = {
|
|
533
|
+
"name": repo_name,
|
|
534
|
+
"files": scanned,
|
|
535
|
+
"file_contents": file_contents,
|
|
536
|
+
"churn": churn_result,
|
|
537
|
+
"ownership": ownership_result,
|
|
538
|
+
"activity": activity_result,
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
meta = ProjectMeta(
|
|
542
|
+
id=project_id,
|
|
543
|
+
name=repo_name,
|
|
544
|
+
total_files=len(scanned),
|
|
545
|
+
reading_map=_reading_steps(scanned),
|
|
546
|
+
hotspots=[Hotspot(**h) for h in importgraph.rank_hotspots(scanned)],
|
|
547
|
+
code_walk=[CodeWalkStep(**s) for s in importgraph.suggest_reading_order(scanned)],
|
|
548
|
+
import_cycles=[ImportCycle(**c) for c in importgraph.find_import_cycles(scanned)],
|
|
549
|
+
orphan_modules=[OrphanModule(**o) for o in importgraph.find_orphan_modules(scanned)],
|
|
550
|
+
coupling_hotspots=[CouplingHotspot(**c) for c in importgraph.rank_coupling(scanned)],
|
|
551
|
+
blast_radius=[BlastRadiusHotspot(**b) for b in importgraph.rank_blast_radius(scanned)],
|
|
552
|
+
architecture_layers=[
|
|
553
|
+
ArchitectureLayer(**a) for a in importgraph.assign_architecture_layers(scanned)
|
|
554
|
+
],
|
|
555
|
+
package_dependencies=[
|
|
556
|
+
PackageDependency(**p) for p in importgraph.summarize_package_dependencies(scanned)
|
|
557
|
+
],
|
|
558
|
+
health=ProjectHealth(**importgraph.summarize_project_health(scanned)),
|
|
559
|
+
churn_hotspots=[ChurnHotspot(**h) for h in churn_result.get("hotspots", [])],
|
|
560
|
+
co_change_couplings=[CoChangeCoupling(**c) for c in churn_result.get("couplings", [])],
|
|
561
|
+
risk_hotspots=[
|
|
562
|
+
RiskHotspot(**r)
|
|
563
|
+
for r in risk.rank_risk(
|
|
564
|
+
importgraph.rank_hotspots(scanned, limit=500),
|
|
565
|
+
churn_result.get("hotspots", []),
|
|
566
|
+
)
|
|
567
|
+
],
|
|
568
|
+
test_coverage=TestCoverageSummary(**coverage.assess_test_coverage(scanned)),
|
|
569
|
+
activity=_activity_summary(activity_result),
|
|
570
|
+
**_content_analyses(file_contents, ownership_result.get("silos")),
|
|
571
|
+
files=[
|
|
572
|
+
FileInfo(
|
|
573
|
+
path=f["path"],
|
|
574
|
+
size=f["size"],
|
|
575
|
+
language=f["language"],
|
|
576
|
+
preview="",
|
|
577
|
+
)
|
|
578
|
+
for f in scanned
|
|
579
|
+
],
|
|
580
|
+
)
|
|
581
|
+
hs_result = _compute_health_score(meta, proj_data["file_contents"])
|
|
582
|
+
ap_result = _compute_action_plan(meta, proj_data["file_contents"])
|
|
583
|
+
proj_data["health_score"] = hs_result
|
|
584
|
+
proj_data["action_plan"] = ap_result
|
|
585
|
+
_projects[project_id] = proj_data
|
|
586
|
+
await cache.save_project(project_id, proj_data)
|
|
587
|
+
meta.health_score = _health_score_summary(hs_result)
|
|
588
|
+
meta.action_plan = _action_plan_summary(ap_result)
|
|
589
|
+
return meta
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
async def _resolve_project(project_id: str) -> dict | None:
|
|
593
|
+
"""Look up project in memory, then fall back to SQLite."""
|
|
594
|
+
proj = _projects.get(project_id)
|
|
595
|
+
if proj:
|
|
596
|
+
return proj
|
|
597
|
+
# try loading from persistent storage
|
|
598
|
+
proj = await cache.load_project(project_id)
|
|
599
|
+
if proj:
|
|
600
|
+
_projects[project_id] = proj # re-populate memory cache
|
|
601
|
+
return proj
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@router.get("/projects", response_model=list[ProjectSummary])
|
|
605
|
+
async def list_projects():
|
|
606
|
+
"""List previously analyzed projects (the on-disk library), newest first.
|
|
607
|
+
|
|
608
|
+
Lets you reopen a past analysis without re-cloning or re-scanning. The
|
|
609
|
+
persisted SQLite store is the source of truth; a project created in this
|
|
610
|
+
process that isn't persisted yet (e.g. the cache DB is unavailable) is
|
|
611
|
+
merged in so it still shows up.
|
|
612
|
+
"""
|
|
613
|
+
stored = await cache.list_projects()
|
|
614
|
+
seen = {p["id"] for p in stored}
|
|
615
|
+
summaries = [ProjectSummary(**p) for p in stored]
|
|
616
|
+
summaries.extend(
|
|
617
|
+
ProjectSummary(
|
|
618
|
+
id=project_id,
|
|
619
|
+
name=proj.get("name") or project_id,
|
|
620
|
+
total_files=len(proj.get("files") or []),
|
|
621
|
+
)
|
|
622
|
+
for project_id, proj in _projects.items()
|
|
623
|
+
if project_id not in seen
|
|
624
|
+
)
|
|
625
|
+
return summaries
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
@router.get("/project/{project_id}")
|
|
629
|
+
async def get_project(project_id: str):
|
|
630
|
+
"""Get project metadata."""
|
|
631
|
+
proj = await _resolve_project(project_id)
|
|
632
|
+
if not proj:
|
|
633
|
+
raise HTTPException(404, "Project not found")
|
|
634
|
+
return ProjectMeta(
|
|
635
|
+
id=project_id,
|
|
636
|
+
name=proj["name"],
|
|
637
|
+
total_files=len(proj["files"]),
|
|
638
|
+
reading_map=_reading_steps(proj["files"]),
|
|
639
|
+
hotspots=[Hotspot(**h) for h in importgraph.rank_hotspots(proj["files"])],
|
|
640
|
+
code_walk=[CodeWalkStep(**s) for s in importgraph.suggest_reading_order(proj["files"])],
|
|
641
|
+
import_cycles=[ImportCycle(**c) for c in importgraph.find_import_cycles(proj["files"])],
|
|
642
|
+
orphan_modules=[OrphanModule(**o) for o in importgraph.find_orphan_modules(proj["files"])],
|
|
643
|
+
coupling_hotspots=[CouplingHotspot(**c) for c in importgraph.rank_coupling(proj["files"])],
|
|
644
|
+
blast_radius=[
|
|
645
|
+
BlastRadiusHotspot(**b) for b in importgraph.rank_blast_radius(proj["files"])
|
|
646
|
+
],
|
|
647
|
+
architecture_layers=[
|
|
648
|
+
ArchitectureLayer(**a) for a in importgraph.assign_architecture_layers(proj["files"])
|
|
649
|
+
],
|
|
650
|
+
package_dependencies=[
|
|
651
|
+
PackageDependency(**p)
|
|
652
|
+
for p in importgraph.summarize_package_dependencies(proj["files"])
|
|
653
|
+
],
|
|
654
|
+
health=ProjectHealth(**importgraph.summarize_project_health(proj["files"])),
|
|
655
|
+
churn_hotspots=[ChurnHotspot(**h) for h in (proj.get("churn") or {}).get("hotspots", [])],
|
|
656
|
+
co_change_couplings=[
|
|
657
|
+
CoChangeCoupling(**c) for c in (proj.get("churn") or {}).get("couplings", [])
|
|
658
|
+
],
|
|
659
|
+
risk_hotspots=[
|
|
660
|
+
RiskHotspot(**r)
|
|
661
|
+
for r in risk.rank_risk(
|
|
662
|
+
importgraph.rank_hotspots(proj["files"], limit=500),
|
|
663
|
+
(proj.get("churn") or {}).get("hotspots", []),
|
|
664
|
+
)
|
|
665
|
+
],
|
|
666
|
+
test_coverage=TestCoverageSummary(**coverage.assess_test_coverage(proj["files"])),
|
|
667
|
+
activity=_activity_summary(proj.get("activity")),
|
|
668
|
+
health_score=_health_score_summary(proj.get("health_score")),
|
|
669
|
+
action_plan=_action_plan_summary(proj.get("action_plan")),
|
|
670
|
+
**_content_analyses(
|
|
671
|
+
proj.get("file_contents", {}), (proj.get("ownership") or {}).get("silos")
|
|
672
|
+
),
|
|
673
|
+
files=[
|
|
674
|
+
FileInfo(
|
|
675
|
+
path=f["path"],
|
|
676
|
+
size=f["size"],
|
|
677
|
+
language=f["language"],
|
|
678
|
+
preview="",
|
|
679
|
+
)
|
|
680
|
+
for f in proj["files"]
|
|
681
|
+
],
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
@router.get("/project/{project_id}/file/{file_path:path}")
|
|
686
|
+
async def get_file_content(project_id: str, file_path: str):
|
|
687
|
+
"""Return full content of a specific file."""
|
|
688
|
+
proj = await _resolve_project(project_id)
|
|
689
|
+
if not proj:
|
|
690
|
+
raise HTTPException(404, "Project not found")
|
|
691
|
+
|
|
692
|
+
content = proj.get("file_contents", {}).get(file_path)
|
|
693
|
+
if content is None:
|
|
694
|
+
raise HTTPException(404, f"File not found: {file_path}")
|
|
695
|
+
|
|
696
|
+
lang = "unknown"
|
|
697
|
+
for f in proj["files"]:
|
|
698
|
+
if f["path"] == file_path:
|
|
699
|
+
lang = f["language"]
|
|
700
|
+
break
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
"path": file_path,
|
|
704
|
+
"language": lang,
|
|
705
|
+
"content": content,
|
|
706
|
+
"purpose": filenames.explain_path(file_path),
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
@router.get("/project/{project_id}/file/{file_path:path}/glossary", response_model=FileGlossary)
|
|
711
|
+
async def get_file_glossary(project_id: str, file_path: str):
|
|
712
|
+
"""Return the jargon terms found in a file with plain-language definitions.
|
|
713
|
+
|
|
714
|
+
Deterministic (no LLM): powers "hover a keyword, see what it means".
|
|
715
|
+
"""
|
|
716
|
+
proj = await _resolve_project(project_id)
|
|
717
|
+
if not proj:
|
|
718
|
+
raise HTTPException(404, "Project not found")
|
|
719
|
+
|
|
720
|
+
content = proj.get("file_contents", {}).get(file_path)
|
|
721
|
+
if content is None:
|
|
722
|
+
raise HTTPException(404, f"File not found: {file_path}")
|
|
723
|
+
|
|
724
|
+
return FileGlossary(
|
|
725
|
+
path=file_path,
|
|
726
|
+
terms=[GlossaryTerm(**t) for t in glossary.scan_terms(content)],
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@router.get(
|
|
731
|
+
"/project/{project_id}/file/{file_path:path}/dependencies",
|
|
732
|
+
response_model=FileDependencies,
|
|
733
|
+
)
|
|
734
|
+
async def get_file_dependencies(project_id: str, file_path: str):
|
|
735
|
+
"""Return what a single file connects to inside the project.
|
|
736
|
+
|
|
737
|
+
Deterministic (no LLM): the in-project files this file imports, and the ones
|
|
738
|
+
that import it — so a reader sees "this uses X" and "Y relies on this".
|
|
739
|
+
"""
|
|
740
|
+
proj = await _resolve_project(project_id)
|
|
741
|
+
if not proj:
|
|
742
|
+
raise HTTPException(404, "Project not found")
|
|
743
|
+
if proj.get("file_contents", {}).get(file_path) is None:
|
|
744
|
+
raise HTTPException(404, f"File not found: {file_path}")
|
|
745
|
+
|
|
746
|
+
deps = importgraph.file_dependencies(proj["files"], file_path)
|
|
747
|
+
return FileDependencies(
|
|
748
|
+
path=file_path,
|
|
749
|
+
imports=deps["imports"],
|
|
750
|
+
imported_by=deps["imported_by"],
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
@router.get("/project/{project_id}/definition", response_model=DefinitionMatches)
|
|
755
|
+
async def get_definition(project_id: str, name: str):
|
|
756
|
+
"""Return where a name is defined — the jump-to-definition lookup.
|
|
757
|
+
|
|
758
|
+
Deterministic (no LLM): click a function, class, or import and find the
|
|
759
|
+
file and line that declares it. Tries an exact match, then a
|
|
760
|
+
case-insensitive one, and returns every place a shared name is defined.
|
|
761
|
+
"""
|
|
762
|
+
proj = await _resolve_project(project_id)
|
|
763
|
+
if not proj:
|
|
764
|
+
raise HTTPException(404, "Project not found")
|
|
765
|
+
|
|
766
|
+
matches = symbols.find_definition(proj.get("file_contents", {}), name)
|
|
767
|
+
return DefinitionMatches(
|
|
768
|
+
name=name,
|
|
769
|
+
total=len(matches),
|
|
770
|
+
definitions=[Definition(**m) for m in matches],
|
|
771
|
+
notes=[] if matches else [f"No definition of '{name}' found in this project."],
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@router.get("/project/{project_id}/references", response_model=ReferenceMatches)
|
|
776
|
+
async def get_references(project_id: str, name: str):
|
|
777
|
+
"""Return where a name is used — the find-all-references lookup.
|
|
778
|
+
|
|
779
|
+
Deterministic (no LLM): the companion to jump-to-definition. Where the
|
|
780
|
+
definition index answers "where is this declared?", this answers "where is
|
|
781
|
+
it used?" — every call site with a one-line preview, with the declaration
|
|
782
|
+
itself flagged so a reader can tell the source from the uses.
|
|
783
|
+
"""
|
|
784
|
+
proj = await _resolve_project(project_id)
|
|
785
|
+
if not proj:
|
|
786
|
+
raise HTTPException(404, "Project not found")
|
|
787
|
+
|
|
788
|
+
result = symbols.find_references(proj.get("file_contents", {}), name)
|
|
789
|
+
return ReferenceMatches(
|
|
790
|
+
name=result["name"],
|
|
791
|
+
total=result["total"],
|
|
792
|
+
files=result["files"],
|
|
793
|
+
references=[Reference(**r) for r in result["references"]],
|
|
794
|
+
notes=result["notes"],
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
@router.get("/pr-reading")
|
|
799
|
+
async def get_pr_reading(url: str):
|
|
800
|
+
"""Turn a PR link into a plain-language diff walkthrough.
|
|
801
|
+
|
|
802
|
+
Deterministic (no LLM): fetches the PR's unified diff, breaks it into
|
|
803
|
+
per-file changes with add/del counts, classifies each file as code, test,
|
|
804
|
+
docs, or config, and proposes a review reading order (biggest production
|
|
805
|
+
diffs first). An LLM explanation layer can sit on this payload later.
|
|
806
|
+
"""
|
|
807
|
+
try:
|
|
808
|
+
return pr_reading.analyze_pr(url)
|
|
809
|
+
except ValueError as exc:
|
|
810
|
+
raise HTTPException(400, str(exc)) from exc
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@router.get("/project/{project_id}/outline", response_model=FileOutline)
|
|
814
|
+
async def get_file_outline(project_id: str, path: str):
|
|
815
|
+
"""Return one file's structure, top to bottom — its table of contents.
|
|
816
|
+
|
|
817
|
+
Deterministic (no LLM): where the definition index spans the whole project
|
|
818
|
+
alphabetically, this lists just the chosen file's functions and classes in
|
|
819
|
+
the order they are written, with each class's methods nested underneath, so
|
|
820
|
+
a reader can see the shape of an unfamiliar file before its detail.
|
|
821
|
+
"""
|
|
822
|
+
proj = await _resolve_project(project_id)
|
|
823
|
+
if not proj:
|
|
824
|
+
raise HTTPException(404, "Project not found")
|
|
825
|
+
|
|
826
|
+
result = symbols.file_outline(proj.get("file_contents", {}), path)
|
|
827
|
+
return FileOutline(**result)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
@router.get("/project/{project_id}/public-api", response_model=PublicApi)
|
|
831
|
+
async def get_public_api(project_id: str):
|
|
832
|
+
"""Return the project's public surface — the names other code is meant to call.
|
|
833
|
+
|
|
834
|
+
Deterministic (no LLM): filters the definition index down to the names a
|
|
835
|
+
project exposes on purpose — non-underscore in Python, ``export``-ed in
|
|
836
|
+
JS/TS — so a reader can see the interface before wading into the internals.
|
|
837
|
+
"""
|
|
838
|
+
proj = await _resolve_project(project_id)
|
|
839
|
+
if not proj:
|
|
840
|
+
raise HTTPException(404, "Project not found")
|
|
841
|
+
|
|
842
|
+
result = symbols.public_api(proj.get("file_contents", {}))
|
|
843
|
+
return PublicApi(
|
|
844
|
+
total=result["total"],
|
|
845
|
+
definitions=[Definition(**d) for d in result["definitions"]],
|
|
846
|
+
notes=result["notes"],
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
@router.get("/project/{project_id}/codemap.md")
|
|
851
|
+
async def get_codemap_markdown(project_id: str):
|
|
852
|
+
"""Export the deterministic code map (import-graph analyses) as Markdown."""
|
|
853
|
+
proj = await _resolve_project(project_id)
|
|
854
|
+
if not proj:
|
|
855
|
+
raise HTTPException(404, "Project not found")
|
|
856
|
+
markdown = codemap_export.build_codemap_markdown(proj)
|
|
857
|
+
return Response(content=markdown, media_type="text/markdown; charset=utf-8")
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
@router.get("/project/{project_id}/report.html")
|
|
861
|
+
async def get_report_html(project_id: str):
|
|
862
|
+
"""Export the full analysis as one self-contained, offline HTML report."""
|
|
863
|
+
proj = await _resolve_project(project_id)
|
|
864
|
+
if not proj:
|
|
865
|
+
raise HTTPException(404, "Project not found")
|
|
866
|
+
report = report_export.build_report_html(proj)
|
|
867
|
+
return Response(content=report, media_type="text/html; charset=utf-8")
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
async def get_project_data(project_id: str) -> dict | None:
|
|
871
|
+
"""Internal helper for analyze router to access project data."""
|
|
872
|
+
return await _resolve_project(project_id)
|