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.
Files changed (62) hide show
  1. backend/__init__.py +0 -0
  2. backend/app.py +95 -0
  3. backend/desktop_server.py +46 -0
  4. backend/models.py +629 -0
  5. backend/prompts/__init__.py +0 -0
  6. backend/prompts/annotate.py +44 -0
  7. backend/prompts/edit.py +62 -0
  8. backend/prompts/overview.py +57 -0
  9. backend/prompts/qa.py +45 -0
  10. backend/routers/__init__.py +0 -0
  11. backend/routers/analyze.py +271 -0
  12. backend/routers/project.py +872 -0
  13. backend/services/__init__.py +0 -0
  14. backend/services/action_plan.py +366 -0
  15. backend/services/activity.py +313 -0
  16. backend/services/apimap.py +391 -0
  17. backend/services/async_surface.py +123 -0
  18. backend/services/cache.py +183 -0
  19. backend/services/churn.py +244 -0
  20. backend/services/ci_checks.py +317 -0
  21. backend/services/codemap_export.py +136 -0
  22. backend/services/commands.py +276 -0
  23. backend/services/complexity.py +111 -0
  24. backend/services/config_files.py +190 -0
  25. backend/services/contributing.py +308 -0
  26. backend/services/coverage.py +283 -0
  27. backend/services/datamodels.py +190 -0
  28. backend/services/deep_nesting.py +110 -0
  29. backend/services/dependencies.py +270 -0
  30. backend/services/docs.py +255 -0
  31. backend/services/docstrings.py +163 -0
  32. backend/services/duplicate_code.py +165 -0
  33. backend/services/entrypoints.py +152 -0
  34. backend/services/envscan.py +167 -0
  35. backend/services/error_handling.py +239 -0
  36. backend/services/filenames.py +261 -0
  37. backend/services/github_clone.py +129 -0
  38. backend/services/glossary.py +96 -0
  39. backend/services/health_score.py +381 -0
  40. backend/services/importgraph.py +986 -0
  41. backend/services/integrations.py +263 -0
  42. backend/services/licenses.py +505 -0
  43. backend/services/llm.py +117 -0
  44. backend/services/long_functions.py +101 -0
  45. backend/services/ownership.py +194 -0
  46. backend/services/package_purposes.py +112 -0
  47. backend/services/pr_reading.py +181 -0
  48. backend/services/release_map.py +402 -0
  49. backend/services/report_export.py +232 -0
  50. backend/services/risk.py +88 -0
  51. backend/services/scanner.py +546 -0
  52. backend/services/schedules.py +369 -0
  53. backend/services/security.py +445 -0
  54. backend/services/settings_map.py +234 -0
  55. backend/services/symbols.py +605 -0
  56. backend/services/techdebt.py +124 -0
  57. backend/services/too_many_params.py +102 -0
  58. backend/services/typing_coverage.py +184 -0
  59. codeabc-0.1.0.dist-info/METADATA +283 -0
  60. codeabc-0.1.0.dist-info/RECORD +62 -0
  61. codeabc-0.1.0.dist-info/WHEEL +4 -0
  62. 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)