srcly 1.2.0__tar.gz → 1.4.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 (142) hide show
  1. {srcly-1.2.0 → srcly-1.4.0}/PKG-INFO +2 -1
  2. {srcly-1.2.0 → srcly-1.4.0}/app/config.py +10 -0
  3. {srcly-1.2.0 → srcly-1.4.0}/app/main.py +7 -1
  4. srcly-1.4.0/app/routers/dataflow.py +386 -0
  5. srcly-1.4.0/app/services/dataflow/__init__.py +1 -0
  6. srcly-1.4.0/app/services/dataflow/availability.py +177 -0
  7. srcly-1.4.0/app/services/dataflow/contracts/anchorGraph.schema.json +601 -0
  8. srcly-1.4.0/app/services/dataflow/contracts/fileExports.schema.json +183 -0
  9. srcly-1.4.0/app/services/dataflow/contracts/functionEffects.schema.json +512 -0
  10. srcly-1.4.0/app/services/dataflow/contracts/hello.schema.json +44 -0
  11. srcly-1.4.0/app/services/dataflow/contracts/routeGraph.schema.json +641 -0
  12. srcly-1.4.0/app/services/dataflow/contracts/routePath.schema.json +446 -0
  13. srcly-1.4.0/app/services/dataflow/contracts/routes.schema.json +119 -0
  14. srcly-1.4.0/app/services/dataflow/contracts/selectExpression.schema.json +109 -0
  15. srcly-1.4.0/app/services/dataflow/contracts/status.schema.json +100 -0
  16. srcly-1.4.0/app/services/dataflow/contracts/valueFlow.schema.json +440 -0
  17. srcly-1.4.0/app/services/dataflow/engine.py +522 -0
  18. srcly-1.4.0/app/services/dataflow/models.py +696 -0
  19. srcly-1.4.0/app/static/assets/CodeModal-CAGDwaQk.js +38 -0
  20. srcly-1.4.0/app/static/assets/CompareApp-CP_1quNF.js +3 -0
  21. srcly-1.4.0/app/static/assets/DependencyView-WPtVjgom.js +29 -0
  22. srcly-1.4.0/app/static/assets/EffectsPanel-DUIjGBhK.js +5 -0
  23. srcly-1.4.0/app/static/assets/ScatterView-DDBsP4Xa.js +1 -0
  24. srcly-1.2.0/app/static/assets/chevron-left-BvHMpJKL.js → srcly-1.4.0/app/static/assets/chevron-left-DCB4wg6x.js +1 -1
  25. srcly-1.4.0/app/static/assets/index-D-s66jK1.css +1 -0
  26. srcly-1.4.0/app/static/assets/index-DO_J8PSg.js +3 -0
  27. srcly-1.4.0/app/static/assets/layout-CedD-71I.js +1 -0
  28. srcly-1.4.0/app/static/assets/rotate-ccw-BpnqGmsg.js +1 -0
  29. {srcly-1.2.0 → srcly-1.4.0}/app/static/index.html +2 -2
  30. {srcly-1.2.0 → srcly-1.4.0}/pyproject.toml +7 -1
  31. srcly-1.2.0/app/static/assets/CodeModal-B3bjl-Ss.js +0 -36
  32. srcly-1.2.0/app/static/assets/CompareApp-BsjkqC-L.js +0 -3
  33. srcly-1.2.0/app/static/assets/DependencyView-BDDpKKk8.js +0 -1
  34. srcly-1.2.0/app/static/assets/ScatterView-CLYnewZq.js +0 -1
  35. srcly-1.2.0/app/static/assets/index-CJ-sdGl0.css +0 -1
  36. srcly-1.2.0/app/static/assets/index-DLPxgS2d.js +0 -2
  37. srcly-1.2.0/app/static/assets/layout-v5JPUzcA.js +0 -1
  38. {srcly-1.2.0 → srcly-1.4.0}/.gitignore +0 -0
  39. {srcly-1.2.0 → srcly-1.4.0}/README.md +0 -0
  40. {srcly-1.2.0 → srcly-1.4.0}/app/.gitignore +0 -0
  41. {srcly-1.2.0 → srcly-1.4.0}/app/__init__.py +0 -0
  42. {srcly-1.2.0 → srcly-1.4.0}/app/models.py +0 -0
  43. {srcly-1.2.0 → srcly-1.4.0}/app/routers/analysis.py +0 -0
  44. {srcly-1.2.0 → srcly-1.4.0}/app/routers/compare.py +0 -0
  45. {srcly-1.2.0 → srcly-1.4.0}/app/routers/files.py +0 -0
  46. {srcly-1.2.0 → srcly-1.4.0}/app/routers/metrics.py +0 -0
  47. {srcly-1.2.0 → srcly-1.4.0}/app/routers/scans.py +0 -0
  48. {srcly-1.2.0 → srcly-1.4.0}/app/run.py +0 -0
  49. {srcly-1.2.0 → srcly-1.4.0}/app/services/activity.py +0 -0
  50. {srcly-1.2.0 → srcly-1.4.0}/app/services/analysis.py +0 -0
  51. {srcly-1.2.0 → srcly-1.4.0}/app/services/analysis_types.py +0 -0
  52. {srcly-1.2.0 → srcly-1.4.0}/app/services/cache.py +0 -0
  53. {srcly-1.2.0 → srcly-1.4.0}/app/services/comment_noise.py +0 -0
  54. {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/__init__.py +0 -0
  55. {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/diff.py +0 -0
  56. {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/git_refs.py +0 -0
  57. {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/github.py +0 -0
  58. {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/jobs.py +0 -0
  59. {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/service.py +0 -0
  60. {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/snapshots.py +0 -0
  61. {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/sources.py +0 -0
  62. {srcly-1.2.0 → srcly-1.4.0}/app/services/css/css_analysis.py +0 -0
  63. {srcly-1.2.0 → srcly-1.4.0}/app/services/deps/__init__.py +0 -0
  64. {srcly-1.2.0 → srcly-1.4.0}/app/services/deps/common.py +0 -0
  65. {srcly-1.2.0 → srcly-1.4.0}/app/services/deps/graph.py +0 -0
  66. {srcly-1.2.0 → srcly-1.4.0}/app/services/deps/python_deps.py +0 -0
  67. {srcly-1.2.0 → srcly-1.4.0}/app/services/deps/tsconfig.py +0 -0
  68. {srcly-1.2.0 → srcly-1.4.0}/app/services/deps/typescript_deps.py +0 -0
  69. {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/__init__.py +0 -0
  70. {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/builtin_ts.py +0 -0
  71. {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/jobs.py +0 -0
  72. {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/model.py +0 -0
  73. {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/overlay.py +0 -0
  74. {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/ruff.py +0 -0
  75. {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/runner.py +0 -0
  76. {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/tools.py +0 -0
  77. {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/typescript.py +0 -0
  78. {srcly-1.2.0 → srcly-1.4.0}/app/services/ipynb/ipynb_analysis.py +0 -0
  79. {srcly-1.2.0 → srcly-1.4.0}/app/services/markdown/markdown_analysis.py +0 -0
  80. {srcly-1.2.0 → srcly-1.4.0}/app/services/markers.py +0 -0
  81. {srcly-1.2.0 → srcly-1.4.0}/app/services/metric_catalog.py +0 -0
  82. {srcly-1.2.0 → srcly-1.4.0}/app/services/python/python_analysis.py +0 -0
  83. {srcly-1.2.0 → srcly-1.4.0}/app/services/quadrants.py +0 -0
  84. {srcly-1.2.0 → srcly-1.4.0}/app/services/reporting.py +0 -0
  85. {srcly-1.2.0 → srcly-1.4.0}/app/services/scan_jobs.py +0 -0
  86. {srcly-1.2.0 → srcly-1.4.0}/app/services/scan_progress.py +0 -0
  87. {srcly-1.2.0 → srcly-1.4.0}/app/services/scan_workers.py +0 -0
  88. {srcly-1.2.0 → srcly-1.4.0}/app/services/typescript/components.py +0 -0
  89. {srcly-1.2.0 → srcly-1.4.0}/app/services/typescript/typescript_analysis.py +0 -0
  90. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/bash-DgJyDXW6.js +0 -0
  91. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/css-PZBBrkI0.js +0 -0
  92. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/github-light-Rcb2q3L5.js +0 -0
  93. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/highlight.worker-C64dOQ1-.js +0 -0
  94. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/html-DOf0XnR2.js +0 -0
  95. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  96. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  97. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  98. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  99. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
  100. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
  101. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  102. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  103. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  104. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  105. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
  106. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
  107. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  108. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  109. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  110. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  111. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  112. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  113. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  114. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  115. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  116. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  117. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
  118. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
  119. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  120. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  121. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  122. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  123. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
  124. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
  125. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  126. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  127. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  128. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  129. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  130. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  131. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  132. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/javascript-WyGyM1wV.js +0 -0
  133. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/json-B8JryHcM.js +0 -0
  134. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/jsx-8cLv2Six.js +0 -0
  135. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/markdown-DX-TNHnc.js +0 -0
  136. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/python-B6Ihcp-E.js +0 -0
  137. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/toml-Ms459DBw.js +0 -0
  138. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/tsx-D6116Ycy.js +0 -0
  139. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/typescript-BqvgUolh.js +0 -0
  140. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/wasm-CG6Dc4jp.js +0 -0
  141. {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/yaml-Bopit2L3.js +0 -0
  142. {srcly-1.2.0 → srcly-1.4.0}/app/static/favicon.svg +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: srcly
3
- Version: 1.2.0
3
+ Version: 1.4.0
4
4
  Summary: Interactive codebase treemap and metrics viewer.
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: fastapi>=0.121.3
7
7
  Requires-Dist: httpx>=0.28.1
8
8
  Requires-Dist: lizard>=1.21.7
9
+ Requires-Dist: mypy>=1.18
9
10
  Requires-Dist: pathspec>=0.12.1
10
11
  Requires-Dist: pydantic>=2.12.4
11
12
  Requires-Dist: pytest>=9.0.1
@@ -16,6 +16,16 @@ IGNORE_DIRS: Set[str] = {
16
16
  "out",
17
17
  "android",
18
18
  "ios",
19
+ # Agent and tool output: browser-automation snapshots, logs and caches.
20
+ ".playwright-mcp",
21
+ ".playwright-cli",
22
+ "playwright-report",
23
+ "test-results",
24
+ ".pytest_cache",
25
+ ".mypy_cache",
26
+ ".ruff_cache",
27
+ ".turbo",
28
+ ".nuxt",
19
29
  }
20
30
 
21
31
  IGNORE_FILES: Set[str] = {
@@ -8,7 +8,8 @@ import os
8
8
  import signal
9
9
  import threading
10
10
 
11
- from app.routers import analysis, compare, files, metrics, scans
11
+ from app.routers import analysis, compare, dataflow, files, metrics, scans
12
+ from app.services.dataflow import engine as dataflow_engine
12
13
  from app.services.scan_workers import cancel_all_scans
13
14
 
14
15
  _SHUTDOWN_SIGNALS = (signal.SIGINT, signal.SIGTERM)
@@ -32,6 +33,9 @@ def _install_scan_cancelling_signal_handlers() -> dict:
32
33
  prior = signal.getsignal(sig)
33
34
 
34
35
  def handler(signum, frame, _prior=prior):
36
+ # Release requests waiting on the data-flow engine first, so they
37
+ # end now instead of at their own timeout.
38
+ dataflow_engine.registry.cancel_all()
35
39
  cancel_all_scans()
36
40
  if callable(_prior):
37
41
  _prior(signum, frame)
@@ -52,6 +56,7 @@ async def lifespan(_app: FastAPI):
52
56
  yield
53
57
  finally:
54
58
  cancel_all_scans()
59
+ dataflow_engine.registry.close_all()
55
60
  for sig, prior in previous.items():
56
61
  signal.signal(sig, prior)
57
62
 
@@ -78,6 +83,7 @@ app.include_router(files.router)
78
83
  app.include_router(scans.router)
79
84
  app.include_router(compare.router)
80
85
  app.include_router(metrics.router)
86
+ app.include_router(dataflow.router)
81
87
 
82
88
 
83
89
  @app.get("/api-status")
@@ -0,0 +1,386 @@
1
+ """
2
+ ``/api/dataflow/*``: the data-flow engine behind the scan.
3
+
4
+ Plain ``def`` handlers (threadpool), like the scan endpoints: a first query can
5
+ take seconds while the engine builds its TypeScript program. Every analysis
6
+ state is a 200 with an ``outcome``; 400 is bad input and 503 means this very
7
+ request crashed the engine.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ from fastapi import APIRouter, HTTPException, Response
16
+ from pydantic import BaseModel
17
+
18
+ from app.services.dataflow import engine as dataflow_engine
19
+ from app.services.dataflow.engine import EngineCrashed, EngineError
20
+ from app.services.dataflow.models import (
21
+ AnchorGraphResponse,
22
+ AnchorGraphResult,
23
+ FileExportsResponse,
24
+ FileExportsResult,
25
+ FunctionEffectsResponse,
26
+ FunctionEffectsResult,
27
+ RoutePathResponse,
28
+ RoutePathResult,
29
+ NA_REASONS,
30
+ DataflowStatus,
31
+ Outcome,
32
+ Progress,
33
+ RouteGraphResponse,
34
+ RouteGraphResult,
35
+ RoutesResponse,
36
+ RoutesResult,
37
+ SelectExpressionResult,
38
+ SelectResponse,
39
+ SourceSpan,
40
+ ValueFlowResponse,
41
+ ValueFlowResult,
42
+ StatusResult,
43
+ )
44
+
45
+ router = APIRouter(prefix="/api/dataflow", tags=["dataflow"])
46
+
47
+
48
+ def _registry() -> dataflow_engine.EngineRegistry:
49
+ return dataflow_engine.registry
50
+
51
+
52
+ def _root(path: str | None) -> Path:
53
+ if not path:
54
+ raise HTTPException(status_code=400, detail="path is required")
55
+ root = Path(path)
56
+ if not root.is_absolute():
57
+ raise HTTPException(status_code=400, detail="path must be absolute")
58
+ if not root.exists():
59
+ raise HTTPException(status_code=404, detail="Path not found")
60
+ if root.is_file():
61
+ root = root.parent
62
+ return root.resolve()
63
+
64
+
65
+ def _json(model: BaseModel, exclude_none: bool = True) -> Response:
66
+ # Contract payloads keep their nulls (a route node's `parent: null` is data).
67
+ return Response(content=model.model_dump_json(exclude_none=exclude_none), media_type="application/json")
68
+
69
+
70
+ def _na(exc: EngineError) -> dict:
71
+ reason = exc.code if exc.code in NA_REASONS else None
72
+ return {"outcome": Outcome.not_analyzed, "reason": reason, "message": exc.message}
73
+
74
+
75
+ def _status(root: Path) -> DataflowStatus:
76
+ registry = _registry()
77
+ found = registry.probe(root)
78
+ if not found.available:
79
+ return DataflowStatus(available=False, state="na", reason=found.reason, message=found.message, node=found.node)
80
+ failure = registry.failure(root)
81
+ if failure is not None:
82
+ na = _na(failure)
83
+ return DataflowStatus(available=False, state="na", node=found.node, reason=na["reason"], message=na["message"])
84
+ engine = registry.get(root)
85
+ if engine is None:
86
+ state = "starting" if registry.warming(root) else "idle"
87
+ return DataflowStatus(available=True, state=state, node=found.node)
88
+ hello = engine.hello or {}
89
+ base = {
90
+ "available": True,
91
+ "node": found.node,
92
+ "engine": hello.get("engine"),
93
+ "typescript": hello.get("typescript"),
94
+ "typescriptFrom": hello.get("typescriptFrom"),
95
+ }
96
+ try:
97
+ status = StatusResult.model_validate(engine.request("status", timeout=5.0))
98
+ except EngineError:
99
+ # A busy or dying child still has its last progress frame.
100
+ progress = Progress.model_validate(engine.latest_progress) if engine.latest_progress else None
101
+ return DataflowStatus(state="starting", progress=progress, **base)
102
+ progress = status.progress
103
+ if progress is None and engine.latest_progress:
104
+ progress = Progress.model_validate(engine.latest_progress)
105
+ state = "ready" if status.built and not registry.warming(root) else ("starting" if registry.warming(root) else "running")
106
+ return DataflowStatus(
107
+ state=state,
108
+ generation=status.generation,
109
+ programHash=status.currentHash or status.programHash,
110
+ stale=status.stale,
111
+ rssBytes=status.rssBytes,
112
+ progress=progress,
113
+ **base,
114
+ )
115
+
116
+
117
+ @router.get("/status", response_model=DataflowStatus, response_model_exclude_none=True)
118
+ def get_status(path: str | None = None):
119
+ """Whether data flow can run for ``path`` and what the engine is doing. Never starts it."""
120
+ return _json(_status(_root(path)))
121
+
122
+
123
+ class StartRequest(BaseModel):
124
+ path: str
125
+
126
+
127
+ @router.post("/start", response_model=DataflowStatus, response_model_exclude_none=True)
128
+ def start(body: StartRequest):
129
+ """Start the engine and build tier 1 in the background; poll ``status`` for progress."""
130
+ root = _root(body.path)
131
+ if _registry().probe(root).available:
132
+ _registry().start_background(root)
133
+ return _json(_status(root))
134
+
135
+
136
+ def _query(root: Path, op: str, params: dict, expected_generation: int | None) -> dict:
137
+ """
138
+ Run one op, turning every analysis state into a response body with an
139
+ ``outcome``. Returns either ``{"result": ...}`` or the outcome fields.
140
+ """
141
+ registry = _registry()
142
+ found = registry.probe(root)
143
+ if not found.available:
144
+ return {"outcome": Outcome.not_analyzed, "reason": found.reason, "message": found.message}
145
+ known = registry.failure(root)
146
+ if known is not None:
147
+ return _na(known)
148
+ try:
149
+ return {"result": registry.query(root, op, params, expected_generation=expected_generation)}
150
+ except EngineCrashed as exc:
151
+ raise HTTPException(status_code=503, detail={"reason": exc.code, "message": exc.message, "stderr": exc.stderr_tail[-8:]}) from exc
152
+ except EngineError as exc:
153
+ if exc.code == "stale-generation":
154
+ return {
155
+ "outcome": Outcome.stale,
156
+ "message": exc.message,
157
+ "generation": exc.details.get("generation"),
158
+ "programHash": exc.details.get("programHash"),
159
+ }
160
+ if exc.code == "cancelled":
161
+ return {"outcome": Outcome.cancelled, "message": exc.message}
162
+ if exc.code == "budget-exhausted":
163
+ return {"outcome": Outcome.budget, "message": exc.message}
164
+ if exc.code == "not-found":
165
+ raise HTTPException(status_code=404, detail={"reason": exc.code, "message": exc.message}) from exc
166
+ if exc.code in NA_REASONS:
167
+ return _na(exc)
168
+ if exc.code in ("unsupported-selection", "ambiguous-selection"):
169
+ return {"outcome": Outcome.unsupported, "reason": exc.code, "message": exc.message}
170
+ raise HTTPException(status_code=500, detail={"reason": exc.code, "message": exc.message}) from exc
171
+
172
+
173
+ @router.get("/routes", response_model=RoutesResponse, response_model_exclude_none=True)
174
+ def get_routes(path: str | None = None, expectedGeneration: int | None = None):
175
+ """The routes the engine discovered under ``path`` (starts the engine on first use)."""
176
+ body = _query(_root(path), "routes", {}, expectedGeneration)
177
+ if "result" not in body:
178
+ return _json(RoutesResponse(**body))
179
+ return _json(RoutesResponse(**RoutesResult.model_validate(body["result"]).model_dump()))
180
+
181
+
182
+ class RefreshRequest(BaseModel):
183
+ path: str
184
+
185
+
186
+ @router.post("/refresh", response_model=RoutesResponse, response_model_exclude_none=True)
187
+ def refresh(body: RefreshRequest):
188
+ """
189
+ Rebuild tier 1 from the sources as they are now. The reply is the new
190
+ generation's routes; every open graph must be fetched again with it, and a
191
+ query that names the old generation gets the ``stale`` outcome.
192
+ """
193
+ result = _query(_root(body.path), "refresh", {}, None)
194
+ if "result" not in result:
195
+ return _json(RoutesResponse(**result))
196
+ return _json(RoutesResponse(**RoutesResult.model_validate(result["result"]).model_dump()))
197
+
198
+
199
+ @router.get("/route-graph", response_model=RouteGraphResponse)
200
+ def get_route_graph(route: str, path: str | None = None, source: str | None = None, expectedGeneration: int | None = None):
201
+ """
202
+ One route's render backbone (occurrences, framework boundaries, terminals,
203
+ origins). With ``source`` (an origin node id) it also carries that origin's
204
+ field edges, frontiers and proven ``destinations``. The first call per route builds its totality and can take tens
205
+ of seconds on a large app; ``status`` shows the progress meanwhile.
206
+ """
207
+ params = {"route": route, **({"source": source} if source else {})}
208
+ body = _query(_root(path), "routeGraph", params, expectedGeneration)
209
+ if "result" not in body:
210
+ return _json(RouteGraphResponse(**body), exclude_none=False)
211
+ graph = RouteGraphResult.model_validate(body["result"])
212
+ return _json(RouteGraphResponse(outcome=graph.outcome, generation=graph.generation, programHash=graph.programHash, graph=graph), exclude_none=False)
213
+
214
+
215
+ class FunctionEffectsRequest(BaseModel):
216
+ """The function enclosing ``line`` (and ``column``, when given) in ``file``."""
217
+
218
+ path: str
219
+ file: str
220
+ line: int
221
+ column: int | None = None
222
+ expectedGeneration: int | None = None
223
+
224
+
225
+ @router.post("/function-effects", response_model=FunctionEffectsResponse)
226
+ def function_effects(body: FunctionEffectsRequest):
227
+ """
228
+ What the function at a position touches (M6): its effects as facts with
229
+ witnesses, the places the analysis could not follow as frontiers, how each
230
+ promise-yielding call is handled, and what is assumed. Known and partial
231
+ sit together; nothing here is a score.
232
+ """
233
+ root = _root(body.path)
234
+ params: dict = {"file": _contained_file(root, body.file), "line": body.line, **({"column": body.column} if body.column else {})}
235
+ result = _query(root, "functionEffects", params, body.expectedGeneration)
236
+ if "result" not in result:
237
+ return _json(FunctionEffectsResponse(**result), exclude_none=False)
238
+ effects = FunctionEffectsResult.model_validate(result["result"])
239
+ return _json(FunctionEffectsResponse(outcome=effects.outcome, reason=effects.reason, generation=effects.generation, programHash=effects.programHash, effects=effects), exclude_none=False)
240
+
241
+
242
+ @router.get("/route-path", response_model=RoutePathResponse)
243
+ def get_route_path(route: str, edge: str, path: str | None = None, source: str | None = None, expectedGeneration: int | None = None):
244
+ """
245
+ One lit edge of a route graph, explained in order (M4): the steps of the
246
+ field attachment, frontier or handoff behind it, as ledger rows. Field
247
+ edges need the ``source`` the graph was lit for. 404 for an edge the
248
+ totality does not carry; steps are never invented.
249
+ """
250
+ params = {"route": route, "edge": edge, **({"source": source} if source else {})}
251
+ body = _query(_root(path), "routePath", params, expectedGeneration)
252
+ if "result" not in body:
253
+ return _json(RoutePathResponse(**body), exclude_none=False)
254
+ result = RoutePathResult.model_validate(body["result"])
255
+ return _json(RoutePathResponse(outcome=result.outcome, reason=result.reason, generation=result.generation, programHash=result.programHash, path=result), exclude_none=False)
256
+
257
+
258
+ @router.get("/file-exports", response_model=FileExportsResponse)
259
+ def get_file_exports(file: str, path: str | None = None, expectedGeneration: int | None = None):
260
+ """
261
+ The exported declarations of a file, or of every program file under a
262
+ folder (T19): what a file or folder anchor fans out to, one anchor
263
+ (name and line) per export. Type-only exports are listed as not
264
+ traceable. 404 for a path with no TypeScript program file.
265
+ """
266
+ root = _root(path)
267
+ body = _query(root, "fileExports", {"file": _contained_file(root, file, folders=True)}, expectedGeneration)
268
+ if "result" not in body:
269
+ return _json(FileExportsResponse(**body), exclude_none=False)
270
+ result = FileExportsResult.model_validate(body["result"])
271
+ return _json(FileExportsResponse(outcome=result.outcome, reason=result.reason, generation=result.generation, programHash=result.programHash, exports=result), exclude_none=False)
272
+
273
+
274
+ @router.get("/route-surface", response_model=RouteGraphResponse)
275
+ def get_route_surface(route: str, path: str | None = None, expectedGeneration: int | None = None):
276
+ """
277
+ One route's render backbone alone (``phase: surface``): the same boxes and
278
+ render edges as ``route-graph``, with no origins, handoffs or gaps, from the
279
+ route file's own facts and the Program. Answers well before the first
280
+ ``route-graph`` on a large app, so a client draws it first.
281
+ """
282
+ body = _query(_root(path), "routeSurface", {"route": route}, expectedGeneration)
283
+ if "result" not in body:
284
+ return _json(RouteGraphResponse(**body), exclude_none=False)
285
+ graph = RouteGraphResult.model_validate(body["result"])
286
+ return _json(RouteGraphResponse(outcome=graph.outcome, generation=graph.generation, programHash=graph.programHash, graph=graph), exclude_none=False)
287
+
288
+
289
+ @router.delete("")
290
+ def close(path: str | None = None):
291
+ """Close the engine for ``path`` (tests, and a rescan from the folder menu)."""
292
+ return {"closed": _registry().close(_root(path))}
293
+
294
+
295
+ def _contained_file(root: Path, file: str, folders: bool = False) -> str:
296
+ """``file`` (absolute, or relative to the scan root) as a path relative to the root; 400 when it escapes, 404 when it is not a file (or folder, when allowed)."""
297
+ candidate = (root / file).resolve() if not Path(file).is_absolute() else Path(file).resolve()
298
+ try:
299
+ relative = candidate.relative_to(root)
300
+ except ValueError as exc:
301
+ raise HTTPException(status_code=400, detail="file must be inside path") from exc
302
+ if not (candidate.is_file() or (folders and candidate.is_dir())):
303
+ raise HTTPException(status_code=404, detail="Folder not found" if folders else "File not found")
304
+ return relative.as_posix()
305
+
306
+
307
+ class SelectionRequest(BaseModel):
308
+ path: str
309
+ file: str
310
+ span: SourceSpan
311
+ text: str | None = None
312
+ expectedGeneration: int | None = None
313
+
314
+
315
+ @router.post("/select", response_model=SelectResponse)
316
+ def select_expression(body: SelectionRequest):
317
+ """The supported expressions at a code-viewer selection or caret, innermost first."""
318
+ root = _root(body.path)
319
+ params = {"file": _contained_file(root, body.file), "span": body.span.model_dump(), **({"text": body.text} if body.text else {})}
320
+ result = _query(root, "selectExpression", params, body.expectedGeneration)
321
+ if "result" not in result:
322
+ return _json(SelectResponse(**result), exclude_none=False)
323
+ parsed = SelectExpressionResult.model_validate(result["result"])
324
+ return _json(SelectResponse(outcome=parsed.outcome, reason=parsed.reason, generation=parsed.generation, programHash=parsed.programHash, result=parsed), exclude_none=False)
325
+
326
+
327
+ class ValueFlowRequest(SelectionRequest):
328
+ direction: Literal["forward"] = "forward"
329
+
330
+
331
+ @router.post("/value-flow", response_model=ValueFlowResponse)
332
+ def value_flow(body: ValueFlowRequest):
333
+ """
334
+ The ledger for one selected expression: its parts (fallbacks, branches,
335
+ assertions, calls) and where its value goes, in operation order.
336
+ """
337
+ root = _root(body.path)
338
+ params = {"file": _contained_file(root, body.file), "span": body.span.model_dump(), **({"text": body.text} if body.text else {})}
339
+ result = _query(root, "valueFlow", params, body.expectedGeneration)
340
+ if "result" not in result:
341
+ return _json(ValueFlowResponse(**result), exclude_none=False)
342
+ ledger = ValueFlowResult.model_validate(result["result"])
343
+ return _json(ValueFlowResponse(outcome=ledger.outcome, reason=ledger.reason, generation=ledger.generation, programHash=ledger.programHash, ledger=ledger), exclude_none=False)
344
+
345
+
346
+ class AnchorRequest(BaseModel):
347
+ """An expression by exact span (plus its text), a symbol by name on a line, or a drawn box by its element id."""
348
+
349
+ path: str
350
+ file: str
351
+ span: SourceSpan | None = None
352
+ text: str | None = None
353
+ name: str | None = None
354
+ line: int | None = None
355
+ elementId: str | None = None
356
+ direction: Literal["forward", "backward", "both"] = "both"
357
+ expectedGeneration: int | None = None
358
+
359
+
360
+ @router.post("/anchor-graph", response_model=AnchorGraphResponse)
361
+ def anchor_graph(body: AnchorRequest):
362
+ """
363
+ A value's neighbourhood from any expression, not a route: where it comes
364
+ from (backward), where it goes (forward) or both, as boxes grouped by the
365
+ function that owns them. 404 for a name that is not on its line.
366
+ """
367
+ root = _root(body.path)
368
+ if body.span is None and not body.name and not body.elementId:
369
+ raise HTTPException(status_code=422, detail="span, name and line, or elementId is required")
370
+ params: dict = {"file": _contained_file(root, body.file), "direction": body.direction}
371
+ if body.elementId:
372
+ params["elementId"] = body.elementId
373
+ elif body.span is not None:
374
+ params["span"] = body.span.model_dump()
375
+ if body.text:
376
+ params["text"] = body.text
377
+ else:
378
+ if body.line is None:
379
+ raise HTTPException(status_code=422, detail="name needs line")
380
+ params["name"] = body.name
381
+ params["line"] = body.line
382
+ result = _query(root, "anchorGraph", params, body.expectedGeneration)
383
+ if "result" not in result:
384
+ return _json(AnchorGraphResponse(**result), exclude_none=False)
385
+ graph = AnchorGraphResult.model_validate(result["result"])
386
+ return _json(AnchorGraphResponse(outcome=graph.outcome, reason=graph.reason, generation=graph.generation, programHash=graph.programHash, graph=graph), exclude_none=False)
@@ -0,0 +1 @@
1
+ """The data-flow engine (``engine/``) as a lazy child process owned by the server."""
@@ -0,0 +1,177 @@
1
+ """
2
+ Where the data-flow engine comes from, and whether a root can use it.
3
+
4
+ Pure lookups: nothing here starts a process. The reasons a root is N/A that
5
+ can be decided without spawning (no Node, no engine, no TypeScript or Python
6
+ project, no mypy for a Python one) are decided here; the rest (non-strict
7
+ tsconfig, no source files, a crash) come from the child.
8
+
9
+ A root with a tsconfig is analyzed as TypeScript. Otherwise a root with a
10
+ Python project file or ``.py`` files is analyzed as Python: the engine runs
11
+ ``engine/python/srcly_pyfacts.py`` with this server's own interpreter, which
12
+ has mypy because srcly depends on it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib.util
18
+ import os
19
+ import shutil
20
+ import sys
21
+ from dataclasses import dataclass
22
+ from importlib import resources
23
+ from pathlib import Path
24
+
25
+ NODE_ENV = "SRCLY_NODE"
26
+ DEV_ENV = "SRCLY_ENGINE_DEV"
27
+
28
+ # server/app/services/dataflow -> repository root
29
+ _REPO_ROOT = Path(__file__).resolve().parents[4]
30
+ ENGINE_DIR = _REPO_ROOT / "engine"
31
+
32
+ _SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".output", ".vinxi", "coverage", "__pycache__", "site-packages", "venv"}
33
+ _PYTHON_PROJECT_FILES = ("pyproject.toml", "setup.py", "setup.cfg")
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Launch:
38
+ """How to start the engine: argv before ``--root``, and the working directory."""
39
+
40
+ argv: tuple[str, ...]
41
+ cwd: Path
42
+ kind: str # "bundle" | "dev" | "test"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Availability:
47
+ available: bool
48
+ reason: str | None = None
49
+ message: str | None = None
50
+ node: str | None = None
51
+ launch: Launch | None = None
52
+ language: str = "typescript" # "typescript" | "python"
53
+ # The interpreter that runs the engine's Python helper (Python roots only).
54
+ python: str | None = None
55
+
56
+
57
+ def locate_node() -> str | None:
58
+ """``SRCLY_NODE`` when set and executable, else ``node`` on PATH."""
59
+ explicit = os.environ.get(NODE_ENV)
60
+ if explicit:
61
+ return explicit if os.access(explicit, os.X_OK) else None
62
+ return shutil.which("node")
63
+
64
+
65
+ def locate_bundle(node: str) -> Launch | None:
66
+ """
67
+ The engine entry, in order: the bundle packaged in the wheel, the
68
+ repository's built bundle, then (only with ``SRCLY_ENGINE_DEV=1``) the
69
+ TypeScript source run through the engine's own ``tsx``.
70
+ """
71
+ try:
72
+ packaged = resources.files("app") / "engine" / "srcly-engine.mjs"
73
+ if packaged.is_file():
74
+ path = Path(os.fspath(packaged))
75
+ return Launch((node, str(path)), path.parent, "bundle")
76
+ except (ModuleNotFoundError, TypeError):
77
+ pass
78
+ built = ENGINE_DIR / "dist" / "srcly-engine.mjs"
79
+ if built.is_file():
80
+ return Launch((node, str(built)), built.parent, "bundle")
81
+ if os.environ.get(DEV_ENV) == "1":
82
+ entry = ENGINE_DIR / "bin" / "srcly-engine.ts"
83
+ if entry.is_file() and (ENGINE_DIR / "node_modules" / "tsx").is_dir():
84
+ # `--import tsx` resolves from the working directory, so run in engine/.
85
+ return Launch((node, "--import", "tsx", str(entry)), ENGINE_DIR, "dev")
86
+ return None
87
+
88
+
89
+ def has_tsconfig(root: Path, max_depth: int = 3) -> bool:
90
+ """A ``tsconfig*.json`` at ``root`` or a few levels below (not in dependency or build folders)."""
91
+ stack = [(root, 0)]
92
+ while stack:
93
+ folder, depth = stack.pop()
94
+ try:
95
+ entries = list(os.scandir(folder))
96
+ except OSError:
97
+ continue
98
+ for entry in entries:
99
+ if entry.is_file() and entry.name.startswith("tsconfig") and entry.name.endswith(".json"):
100
+ return True
101
+ if depth < max_depth and entry.is_dir(follow_symlinks=False):
102
+ if entry.name in _SKIP_DIRS or entry.name.startswith("."):
103
+ continue
104
+ stack.append((Path(entry.path), depth + 1))
105
+ return False
106
+
107
+
108
+ def is_python_root(root: Path, max_depth: int = 2) -> bool:
109
+ """A Python project file at ``root``, or ``.py`` files at most a couple of levels down."""
110
+ if any((root / name).is_file() for name in _PYTHON_PROJECT_FILES):
111
+ return True
112
+ stack = [(root, 0)]
113
+ while stack:
114
+ folder, depth = stack.pop()
115
+ try:
116
+ entries = list(os.scandir(folder))
117
+ except OSError:
118
+ continue
119
+ for entry in entries:
120
+ if entry.is_file() and entry.name.endswith(".py"):
121
+ return True
122
+ if depth < max_depth and entry.is_dir(follow_symlinks=False) and entry.name not in _SKIP_DIRS and not entry.name.startswith("."):
123
+ stack.append((Path(entry.path), depth + 1))
124
+ return False
125
+
126
+
127
+ def python_checker() -> str | None:
128
+ """This interpreter, when it can import mypy (the Python provider's type checker)."""
129
+ return sys.executable if importlib.util.find_spec("mypy") is not None else None
130
+
131
+
132
+ def probe(root: Path) -> Availability:
133
+ """Whether the engine can be started for ``root``, without starting it."""
134
+ node = locate_node()
135
+ if node is None:
136
+ return Availability(False, "node-not-found", "Node.js was not found on PATH (set SRCLY_NODE to choose one).")
137
+ launch = locate_bundle(node)
138
+ if launch is None:
139
+ return Availability(
140
+ False,
141
+ "engine-missing",
142
+ "The data-flow engine is not bundled in this install (in a checkout, set SRCLY_ENGINE_DEV=1 after `cd engine && pnpm install`).",
143
+ node=node,
144
+ )
145
+ if has_tsconfig(root):
146
+ return Availability(True, node=node, launch=launch)
147
+ if is_python_root(root):
148
+ python = python_checker()
149
+ if python is None:
150
+ return Availability(
151
+ False,
152
+ "python-checker-not-found",
153
+ f"{sys.executable} cannot import mypy, which the Python data-flow provider needs for checked types.",
154
+ node=node,
155
+ launch=launch,
156
+ language="python",
157
+ )
158
+ return Availability(True, node=node, launch=launch, language="python", python=python)
159
+ return Availability(False, "no-tsconfig", f"No tsconfig.json or Python project was found in {root}.", node=node, launch=launch)
160
+
161
+
162
+ def default_source(root: Path) -> Path:
163
+ """The folder the engine treats as source: ``src`` when it exists, else the root."""
164
+ src = root / "src"
165
+ return src if src.is_dir() else root
166
+
167
+
168
+ def count_source_files(root: Path, limit: int = 20000, language: str = "typescript") -> int:
169
+ """Source files under ``root`` (outside dependency and build folders), for the first-query timeout."""
170
+ count = 0
171
+ suffixes = (".py",) if language == "python" else (".ts", ".tsx")
172
+ for folder, dirs, files in os.walk(root):
173
+ dirs[:] = [d for d in dirs if d not in _SKIP_DIRS and not d.startswith(".")]
174
+ count += sum(1 for name in files if name.endswith(suffixes) and not name.endswith(".d.ts"))
175
+ if count >= limit:
176
+ break
177
+ return count