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.
- {srcly-1.2.0 → srcly-1.4.0}/PKG-INFO +2 -1
- {srcly-1.2.0 → srcly-1.4.0}/app/config.py +10 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/main.py +7 -1
- srcly-1.4.0/app/routers/dataflow.py +386 -0
- srcly-1.4.0/app/services/dataflow/__init__.py +1 -0
- srcly-1.4.0/app/services/dataflow/availability.py +177 -0
- srcly-1.4.0/app/services/dataflow/contracts/anchorGraph.schema.json +601 -0
- srcly-1.4.0/app/services/dataflow/contracts/fileExports.schema.json +183 -0
- srcly-1.4.0/app/services/dataflow/contracts/functionEffects.schema.json +512 -0
- srcly-1.4.0/app/services/dataflow/contracts/hello.schema.json +44 -0
- srcly-1.4.0/app/services/dataflow/contracts/routeGraph.schema.json +641 -0
- srcly-1.4.0/app/services/dataflow/contracts/routePath.schema.json +446 -0
- srcly-1.4.0/app/services/dataflow/contracts/routes.schema.json +119 -0
- srcly-1.4.0/app/services/dataflow/contracts/selectExpression.schema.json +109 -0
- srcly-1.4.0/app/services/dataflow/contracts/status.schema.json +100 -0
- srcly-1.4.0/app/services/dataflow/contracts/valueFlow.schema.json +440 -0
- srcly-1.4.0/app/services/dataflow/engine.py +522 -0
- srcly-1.4.0/app/services/dataflow/models.py +696 -0
- srcly-1.4.0/app/static/assets/CodeModal-CAGDwaQk.js +38 -0
- srcly-1.4.0/app/static/assets/CompareApp-CP_1quNF.js +3 -0
- srcly-1.4.0/app/static/assets/DependencyView-WPtVjgom.js +29 -0
- srcly-1.4.0/app/static/assets/EffectsPanel-DUIjGBhK.js +5 -0
- srcly-1.4.0/app/static/assets/ScatterView-DDBsP4Xa.js +1 -0
- srcly-1.2.0/app/static/assets/chevron-left-BvHMpJKL.js → srcly-1.4.0/app/static/assets/chevron-left-DCB4wg6x.js +1 -1
- srcly-1.4.0/app/static/assets/index-D-s66jK1.css +1 -0
- srcly-1.4.0/app/static/assets/index-DO_J8PSg.js +3 -0
- srcly-1.4.0/app/static/assets/layout-CedD-71I.js +1 -0
- srcly-1.4.0/app/static/assets/rotate-ccw-BpnqGmsg.js +1 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/index.html +2 -2
- {srcly-1.2.0 → srcly-1.4.0}/pyproject.toml +7 -1
- srcly-1.2.0/app/static/assets/CodeModal-B3bjl-Ss.js +0 -36
- srcly-1.2.0/app/static/assets/CompareApp-BsjkqC-L.js +0 -3
- srcly-1.2.0/app/static/assets/DependencyView-BDDpKKk8.js +0 -1
- srcly-1.2.0/app/static/assets/ScatterView-CLYnewZq.js +0 -1
- srcly-1.2.0/app/static/assets/index-CJ-sdGl0.css +0 -1
- srcly-1.2.0/app/static/assets/index-DLPxgS2d.js +0 -2
- srcly-1.2.0/app/static/assets/layout-v5JPUzcA.js +0 -1
- {srcly-1.2.0 → srcly-1.4.0}/.gitignore +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/README.md +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/.gitignore +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/__init__.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/models.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/routers/analysis.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/routers/compare.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/routers/files.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/routers/metrics.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/routers/scans.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/run.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/activity.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/analysis.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/analysis_types.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/cache.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/comment_noise.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/__init__.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/diff.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/git_refs.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/github.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/jobs.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/service.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/snapshots.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/compare/sources.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/css/css_analysis.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/deps/__init__.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/deps/common.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/deps/graph.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/deps/python_deps.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/deps/tsconfig.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/deps/typescript_deps.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/__init__.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/builtin_ts.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/jobs.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/model.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/overlay.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/ruff.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/runner.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/tools.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/findings/typescript.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/ipynb/ipynb_analysis.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/markdown/markdown_analysis.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/markers.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/metric_catalog.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/python/python_analysis.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/quadrants.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/reporting.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/scan_jobs.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/scan_progress.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/scan_workers.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/typescript/components.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/services/typescript/typescript_analysis.py +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/bash-DgJyDXW6.js +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/css-PZBBrkI0.js +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/github-light-Rcb2q3L5.js +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/highlight.worker-C64dOQ1-.js +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/html-DOf0XnR2.js +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/javascript-WyGyM1wV.js +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/json-B8JryHcM.js +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/jsx-8cLv2Six.js +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/markdown-DX-TNHnc.js +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/python-B6Ihcp-E.js +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/toml-Ms459DBw.js +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/tsx-D6116Ycy.js +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/typescript-BqvgUolh.js +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/wasm-CG6Dc4jp.js +0 -0
- {srcly-1.2.0 → srcly-1.4.0}/app/static/assets/yaml-Bopit2L3.js +0 -0
- {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.
|
|
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
|