cg-code-graph 0.10.1__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.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- codegraph/xcode.py +251 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Programs a test starts in a subprocess, in languages other than Python (#60; Python: plugins/python/subproc.py).
|
|
2
|
+
|
|
3
|
+
A source scan over test code (and helpers) finds process starts whose program is the project's own entry point and
|
|
4
|
+
adds CALLS (attrs.via = "subprocess", attrs.how) from the enclosing function / test to it; `isolate_tests` turns them
|
|
5
|
+
into TEST_CALLS, so `cg tests` counts the test for what the program reaches:
|
|
6
|
+
|
|
7
|
+
* Rust: `env!("CARGO_BIN_EXE_x")`, `Command::cargo_bin("x")` / `cargo_bin!("x")` (assert_cmd, escargot) ->
|
|
8
|
+
the `main` of bin target `x` (Cargo.toml, auto-discovered `src/main.rs` / `src/bin/*`); `CARGO_PKG_NAME` -> the
|
|
9
|
+
bin named like the test's package;
|
|
10
|
+
* Node: `spawn / spawnSync / execFile / execFileSync / fork / exec / execSync` (child_process) and
|
|
11
|
+
`execa / execaSync / execaNode / execaCommand` running `node | tsx | ts-node | bun | process.execPath` with a
|
|
12
|
+
project script, `fork(script)`, or a `package.json` `bin` name -> that script's module node;
|
|
13
|
+
* PHP: `new Process(['php', 'artisan', 'x'])`, `Process::run('php artisan x')`, `exec / shell_exec / system /
|
|
14
|
+
passthru('php artisan x')` -> the artisan command node (`$this->artisan()` and `Artisan::call()` are modelled by
|
|
15
|
+
the Laravel plugin);
|
|
16
|
+
* Dart: `Process.run / runSync / start('dart', ['run'?, 'bin/x.dart', ...])`, `TestProcess.start(...)`
|
|
17
|
+
(`Platform.resolvedExecutable` as the program too) -> `main` of that file.
|
|
18
|
+
Go has no language plugin, so `exec.Command(os.Args[0])` / `go run ./cmd/x` are not linked. Names passed in the call
|
|
19
|
+
are read from their last assignment earlier in the same function (`var script = p.join(dir, '../bin/x.dart')`);
|
|
20
|
+
values built in other functions or from configuration are not followed (the Python layer evaluates further).
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import shlex
|
|
27
|
+
from bisect import bisect_right
|
|
28
|
+
from collections import defaultdict
|
|
29
|
+
|
|
30
|
+
from .core.model import RESOLVED
|
|
31
|
+
|
|
32
|
+
JS_EXT = (".js", ".mjs", ".cjs", ".ts", ".mts", ".cts", ".jsx", ".tsx")
|
|
33
|
+
SCAN_EXT = JS_EXT + (".rs", ".php", ".dart")
|
|
34
|
+
LIT = re.compile(r"""(?P<q>['"`])(?P<s>(?:\\.|(?!(?P=q)).)*?)(?P=q)""", re.S)
|
|
35
|
+
RUST_RX = re.compile(r'CARGO_BIN_EXE_([A-Za-z0-9_\-]+)|cargo_bin!?\s*\(\s*(?:"([^"]+)"|env!\s*\(\s*"CARGO_PKG_NAME"\s*\))')
|
|
36
|
+
JS_RX = re.compile(r"\b(spawn|spawnSync|execFile|execFileSync|fork|exec|execSync|execa|execaSync|execaNode|"
|
|
37
|
+
r"execaCommand|execaCommandSync)\s*\(")
|
|
38
|
+
PHP_RX = re.compile(r"new\s+Process\s*\(|Process::(?:run|start|pipe)\s*\(|\b(?:exec|shell_exec|system|passthru)\s*\(")
|
|
39
|
+
DART_RX = re.compile(r"\b(?:Process\.(?:run|runSync|start)|TestProcess\.start)\s*\(")
|
|
40
|
+
JS_RUNTIMES = {"node", "nodejs", "tsx", "ts-node", "bun", "deno"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _args_text(src: str, start: int, limit: int = 600) -> str:
|
|
44
|
+
"""Text of the call's argument list from the `(` at `start` (balanced parentheses / brackets)."""
|
|
45
|
+
depth, i, n = 0, start, min(len(src), start + limit)
|
|
46
|
+
while i < n:
|
|
47
|
+
c = src[i]
|
|
48
|
+
if c in "([{":
|
|
49
|
+
depth += 1
|
|
50
|
+
elif c in ")]}":
|
|
51
|
+
depth -= 1
|
|
52
|
+
if depth == 0:
|
|
53
|
+
return src[start + 1:i]
|
|
54
|
+
elif c in "'\"`":
|
|
55
|
+
m = LIT.match(src, i)
|
|
56
|
+
if m:
|
|
57
|
+
i = m.end()
|
|
58
|
+
continue
|
|
59
|
+
i += 1
|
|
60
|
+
return src[start + 1:n]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _lits(text: str) -> list[str]:
|
|
64
|
+
return [m.group("s") for m in LIT.finditer(text)]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Scan:
|
|
68
|
+
def __init__(self, project, b):
|
|
69
|
+
self.root, self.b = project.root, b
|
|
70
|
+
self.st = defaultdict(int)
|
|
71
|
+
self.samples = defaultdict(list)
|
|
72
|
+
self.by_file = defaultdict(list) # file -> [(line, end_line, nid)] for functions / tests
|
|
73
|
+
self.modules = {} # file -> module node id
|
|
74
|
+
self.mains = {} # file -> main function id
|
|
75
|
+
self.commands = {} # artisan command name -> node id
|
|
76
|
+
for nid, n in b.nodes.items():
|
|
77
|
+
if not n.file:
|
|
78
|
+
continue
|
|
79
|
+
if n.kind == "module":
|
|
80
|
+
self.modules.setdefault(n.file, nid)
|
|
81
|
+
elif n.kind in ("function", "method", "test") and n.line:
|
|
82
|
+
self.by_file[n.file].append((n.line, n.end_line or n.line, nid))
|
|
83
|
+
if n.name == "main" or nid.endswith("#main") or nid.endswith("::main"):
|
|
84
|
+
if n.kind == "function":
|
|
85
|
+
self.mains.setdefault(n.file, nid)
|
|
86
|
+
elif n.kind == "command":
|
|
87
|
+
self.commands[n.name] = nid
|
|
88
|
+
for f in self.by_file.values():
|
|
89
|
+
f.sort()
|
|
90
|
+
self.files = sorted(self.modules) + sorted(set(self.mains) - set(self.modules))
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------- shared
|
|
93
|
+
def enclosing(self, file, line):
|
|
94
|
+
best = None
|
|
95
|
+
for ln, end, nid in self.by_file.get(file, ()):
|
|
96
|
+
if ln <= line <= end and (best is None or end - ln < best[0]):
|
|
97
|
+
best = (end - ln, nid)
|
|
98
|
+
return best[1] if best else self.modules.get(file)
|
|
99
|
+
|
|
100
|
+
def assigned_lits(self, file, src, pos, args) -> list[str]:
|
|
101
|
+
"""Literals assigned to the names the call passes (`var script = p.join(dir, '../bin/x.dart');` earlier in
|
|
102
|
+
the same function, else a module-level constant above it): the last assignment before the call. Values built
|
|
103
|
+
in other functions are not followed."""
|
|
104
|
+
line = src.count("\n", 0, pos) + 1
|
|
105
|
+
start = 1
|
|
106
|
+
for ln, end, _nid in self.by_file.get(file, ()):
|
|
107
|
+
if ln <= line <= end and ln > start:
|
|
108
|
+
start = ln
|
|
109
|
+
lo = 0
|
|
110
|
+
for _ in range(start - 1):
|
|
111
|
+
lo = src.find("\n", lo) + 1
|
|
112
|
+
body, out = src[lo:pos], []
|
|
113
|
+
for ident in dict.fromkeys(re.findall(r"(?<![\w.$])([A-Za-z_]\w*)\b(?!\s*[(.])", LIT.sub("", args))):
|
|
114
|
+
rx = re.compile(rf"(?<![\w.$]){re.escape(ident)}\s*=(?!=)\s*([^;]+);")
|
|
115
|
+
ms = list(rx.finditer(body)) or list(rx.finditer(src[:lo])) # else a module-level constant above
|
|
116
|
+
if ms:
|
|
117
|
+
out += _lits(ms[-1].group(1))
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
def add(self, file, line, dst, how, cmd):
|
|
121
|
+
src = self.enclosing(file, line)
|
|
122
|
+
if src is None or src == dst:
|
|
123
|
+
return
|
|
124
|
+
self.b.add_edge(src, dst, "CALLS", file, line, RESOLVED, via="subprocess", how=how, command=cmd[:120])
|
|
125
|
+
self.st["linked"] += 1
|
|
126
|
+
self.st[f"linked_{how.replace(' ', '_')}"] += 1
|
|
127
|
+
|
|
128
|
+
def miss(self, key, text):
|
|
129
|
+
self.st[key] += 1
|
|
130
|
+
if text and len(self.samples[key]) < 5 and text not in self.samples[key]:
|
|
131
|
+
self.samples[key].append(text[:80])
|
|
132
|
+
|
|
133
|
+
def resolve_path(self, file: str, text: str, pool) -> str | None:
|
|
134
|
+
"""Project file a script path names: `./` / `../` paths relative to the calling file's directory
|
|
135
|
+
(`path.join(__dirname, '../bin/cli.js')`) or the root, other paths root-relative, else a unique suffix match
|
|
136
|
+
among `pool` (files with a node)."""
|
|
137
|
+
import posixpath
|
|
138
|
+
t = text.replace("\\", "/")
|
|
139
|
+
cands = []
|
|
140
|
+
if t.startswith(("./", "../")):
|
|
141
|
+
cands.append(posixpath.normpath(posixpath.join(posixpath.dirname(file), t)))
|
|
142
|
+
cands.append(posixpath.normpath(t.lstrip("/")))
|
|
143
|
+
for c in cands:
|
|
144
|
+
if not c.startswith("..") and (self.root / c).is_file():
|
|
145
|
+
return c
|
|
146
|
+
if t.startswith("../"):
|
|
147
|
+
return None # outside the caller's tree: not guessed by suffix
|
|
148
|
+
return self.by_suffix(t, pool)
|
|
149
|
+
|
|
150
|
+
def by_suffix(self, tail: str, pool) -> str | None:
|
|
151
|
+
tail = tail.replace("\\", "/").lstrip("./")
|
|
152
|
+
while tail.startswith("../"):
|
|
153
|
+
tail = tail[3:]
|
|
154
|
+
if not tail:
|
|
155
|
+
return None
|
|
156
|
+
hits = [f for f in pool if f == tail or f.endswith("/" + tail)]
|
|
157
|
+
return hits[0] if len(hits) == 1 else None
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------- Rust
|
|
160
|
+
def rust_bins(self) -> dict:
|
|
161
|
+
try:
|
|
162
|
+
from .plugins.rust.cargo import from_toml
|
|
163
|
+
pkgs = from_toml(self.root)
|
|
164
|
+
except Exception:
|
|
165
|
+
pkgs = []
|
|
166
|
+
bins, pkg_dirs = {}, []
|
|
167
|
+
for p in pkgs:
|
|
168
|
+
pkg_dirs.append((p.dir, p.name))
|
|
169
|
+
for t in p.targets:
|
|
170
|
+
if t.kind == "bin":
|
|
171
|
+
bins.setdefault(t.name, t.src)
|
|
172
|
+
return bins, sorted(pkg_dirs, key=lambda x: -len(x[0]))
|
|
173
|
+
|
|
174
|
+
def rust(self, file, src):
|
|
175
|
+
if not hasattr(self, "_bins"):
|
|
176
|
+
self._bins = self.rust_bins()
|
|
177
|
+
bins, pkg_dirs = self._bins
|
|
178
|
+
for m in RUST_RX.finditer(src):
|
|
179
|
+
name = m.group(1) or m.group(2)
|
|
180
|
+
if name is None: # env!("CARGO_PKG_NAME"): the package this test belongs to
|
|
181
|
+
name = next((n for d, n in pkg_dirs if not d or file.startswith(d.rstrip("/") + "/")), None)
|
|
182
|
+
line = src.count("\n", 0, m.start()) + 1
|
|
183
|
+
path = bins.get(name or "") or bins.get((name or "").replace("-", "_"))
|
|
184
|
+
dst = self.mains.get(path) if path else None
|
|
185
|
+
if dst is None:
|
|
186
|
+
self.miss("unresolved", f"bin {name}")
|
|
187
|
+
continue
|
|
188
|
+
self.add(file, line, dst, "cargo bin", f"cargo bin {name}")
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------- Node
|
|
191
|
+
def js_bins(self) -> dict:
|
|
192
|
+
out = {}
|
|
193
|
+
for pj in [self.root / "package.json"] + sorted(self.root.glob("packages/*/package.json")):
|
|
194
|
+
try:
|
|
195
|
+
d = json.loads(pj.read_text())
|
|
196
|
+
except Exception:
|
|
197
|
+
continue
|
|
198
|
+
rel = "" if pj.parent == self.root else pj.parent.relative_to(self.root).as_posix() + "/"
|
|
199
|
+
b = d.get("bin")
|
|
200
|
+
if isinstance(b, str) and d.get("name"):
|
|
201
|
+
out[d["name"].split("/")[-1]] = rel + b.lstrip("./")
|
|
202
|
+
elif isinstance(b, dict):
|
|
203
|
+
for k, v in b.items():
|
|
204
|
+
out[k] = rel + str(v).lstrip("./")
|
|
205
|
+
return out
|
|
206
|
+
|
|
207
|
+
def js_script(self, lits, file="") -> str | None:
|
|
208
|
+
"""Project script among the call's literals: the first one with a JS extension, with the plain path segments
|
|
209
|
+
before it (`path.join(__dirname, '..', 'bin', 'cli.js')`)."""
|
|
210
|
+
js_files = [f for f in self.modules if f.endswith(JS_EXT)]
|
|
211
|
+
for i, s in enumerate(lits):
|
|
212
|
+
if s.endswith(JS_EXT) and not s.startswith("-"):
|
|
213
|
+
segs = [x for x in lits[max(0, i - 3):i] if re.fullmatch(r"[\w.\-/]+", x) and not x.startswith("-")]
|
|
214
|
+
for k in range(len(segs), -1, -1):
|
|
215
|
+
got = self.resolve_path(file, "/".join(segs[len(segs) - k:] + [s]), js_files)
|
|
216
|
+
if got:
|
|
217
|
+
return got
|
|
218
|
+
return None
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
def js(self, file, src):
|
|
222
|
+
if not hasattr(self, "_jsbins"):
|
|
223
|
+
self._jsbins = self.js_bins()
|
|
224
|
+
for m in JS_RX.finditer(src):
|
|
225
|
+
fn = m.group(1)
|
|
226
|
+
line = src.count("\n", 0, m.start()) + 1
|
|
227
|
+
args = _args_text(src, m.end() - 1)
|
|
228
|
+
lits = _lits(args)
|
|
229
|
+
if fn in ("exec", "execSync", "execaCommand", "execaCommandSync"):
|
|
230
|
+
if not lits:
|
|
231
|
+
continue
|
|
232
|
+
try:
|
|
233
|
+
lits = shlex.split(lits[0]) + lits[1:]
|
|
234
|
+
except ValueError:
|
|
235
|
+
lits = lits[0].split() + lits[1:]
|
|
236
|
+
prog = "process.execPath" if args.lstrip().startswith("process.execPath") else (lits[0] if lits else "")
|
|
237
|
+
rest = lits if fn in ("fork", "execaNode") else lits[1:] if prog != "process.execPath" else lits
|
|
238
|
+
rest = rest + self.assigned_lits(file, src, m.start(), args)
|
|
239
|
+
base = re.split(r"[/\\]", prog)[-1]
|
|
240
|
+
target = None
|
|
241
|
+
if fn in ("fork", "execaNode") or prog == "process.execPath" or base in JS_RUNTIMES or base == "npx":
|
|
242
|
+
if base == "npx" and rest and rest[0] in self._jsbins:
|
|
243
|
+
target = self._jsbins[rest[0]]
|
|
244
|
+
else:
|
|
245
|
+
target = self.js_script([x for x in rest if x not in ("run", "--")], file)
|
|
246
|
+
elif base in self._jsbins:
|
|
247
|
+
target = self._jsbins[base]
|
|
248
|
+
elif prog.endswith(JS_EXT):
|
|
249
|
+
target = self.js_script(lits, file)
|
|
250
|
+
else:
|
|
251
|
+
continue # git, docker, ...: not a project program
|
|
252
|
+
dst = self.modules.get(target) if target else None
|
|
253
|
+
if dst is None:
|
|
254
|
+
self.miss("script_without_node" if target else "unresolved",
|
|
255
|
+
target or f"{fn}({' '.join(lits[:3])})")
|
|
256
|
+
continue
|
|
257
|
+
self.add(file, line, dst, "node script" if base not in self._jsbins else "package bin",
|
|
258
|
+
" ".join(lits[:6]))
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------- PHP
|
|
261
|
+
def php(self, file, src):
|
|
262
|
+
for m in PHP_RX.finditer(src):
|
|
263
|
+
line = src.count("\n", 0, m.start()) + 1
|
|
264
|
+
lits = _lits(_args_text(src, m.end() - 1))
|
|
265
|
+
if not lits:
|
|
266
|
+
continue
|
|
267
|
+
if len(lits) == 1 and " " in lits[0]:
|
|
268
|
+
try:
|
|
269
|
+
lits = shlex.split(lits[0])
|
|
270
|
+
except ValueError:
|
|
271
|
+
lits = lits[0].split()
|
|
272
|
+
words = [re.split(r"[/\\]", x)[-1] for x in lits]
|
|
273
|
+
if "artisan" not in words:
|
|
274
|
+
continue
|
|
275
|
+
i = words.index("artisan")
|
|
276
|
+
name = next((w for w in lits[i + 1:] if not w.startswith("-")), None)
|
|
277
|
+
dst = self.commands.get(name or "")
|
|
278
|
+
if dst is None:
|
|
279
|
+
self.miss("unresolved", f"artisan {name}")
|
|
280
|
+
continue
|
|
281
|
+
self.add(file, line, dst, "artisan", " ".join(lits[:6]))
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------- Dart
|
|
284
|
+
def dart(self, file, src):
|
|
285
|
+
dart_files = list(self.mains)
|
|
286
|
+
for m in DART_RX.finditer(src):
|
|
287
|
+
line = src.count("\n", 0, m.start()) + 1
|
|
288
|
+
args = _args_text(src, m.end() - 1)
|
|
289
|
+
lits = _lits(args)
|
|
290
|
+
if not (args.lstrip().startswith("Platform.resolvedExecutable") or (lits and lits[0] in ("dart", "flutter"))):
|
|
291
|
+
continue
|
|
292
|
+
script = next((x for x in lits + self.assigned_lits(file, src, m.start(), args) if x.endswith(".dart")), None)
|
|
293
|
+
path = self.resolve_path(file, script, dart_files) if script else None
|
|
294
|
+
dst = self.mains.get(path) if path else None
|
|
295
|
+
if dst is None:
|
|
296
|
+
self.miss("unresolved", " ".join(lits[:4]))
|
|
297
|
+
continue
|
|
298
|
+
self.add(file, line, dst, "dart script", " ".join(lits[:6]))
|
|
299
|
+
|
|
300
|
+
def run(self) -> dict:
|
|
301
|
+
files = set(self.modules) | set(self.by_file)
|
|
302
|
+
for file in sorted(files):
|
|
303
|
+
if not file.endswith(SCAN_EXT): # files of graph nodes: the walks already skipped presets' dirs
|
|
304
|
+
continue
|
|
305
|
+
try:
|
|
306
|
+
src = (self.root / file).read_text(encoding="utf-8", errors="replace")
|
|
307
|
+
except OSError:
|
|
308
|
+
continue
|
|
309
|
+
if file.endswith(".rs"):
|
|
310
|
+
if "cargo_bin" in src or "CARGO_BIN_EXE_" in src:
|
|
311
|
+
self.rust(file, src)
|
|
312
|
+
elif file.endswith(".php"):
|
|
313
|
+
if "artisan" in src:
|
|
314
|
+
self.php(file, src)
|
|
315
|
+
elif file.endswith(".dart"):
|
|
316
|
+
if "Process" in src:
|
|
317
|
+
self.dart(file, src)
|
|
318
|
+
elif ("child_process" in src or "execa" in src) and JS_RX.search(src):
|
|
319
|
+
self.js(file, src)
|
|
320
|
+
out = {k: v for k, v in self.st.items() if v}
|
|
321
|
+
if self.samples:
|
|
322
|
+
out["samples"] = dict(self.samples)
|
|
323
|
+
return out
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def apply(project, builder) -> dict:
|
|
327
|
+
"""Add the subprocess edges; returns stats (empty when nothing was found)."""
|
|
328
|
+
return _Scan(project, builder).run()
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Protocol links (#31, epic #29): one model for every way two pieces of code talk through a named endpoint.
|
|
2
|
+
|
|
3
|
+
sender code -SENDS_TO-> endpoint:<protocol>:<name> -RECEIVED_BY-> handler
|
|
4
|
+
endpoint (send side) -MATCHES_ENDPOINT-> endpoint (receive side) names differ but match
|
|
5
|
+
|
|
6
|
+
* Registry: one `Protocol` per protocol (name, transport, default ports, URL schemes, name normaliser, matcher,
|
|
7
|
+
fan-out, whether receivers are entry points and whether guards are recorded). Plugins register theirs with
|
|
8
|
+
`register(Protocol(...))`; the shared matchers live in protocols/matchers.py (path, MQTT / NATS / AMQP topics, glob,
|
|
9
|
+
exact, `{param}` templates).
|
|
10
|
+
* Builder helpers: `protocol_send(builder, protocol, name, src, ...)` / `protocol_receive(builder, protocol, name,
|
|
11
|
+
handler, ...)`: a plugin reports facts, the name is normalised here and matching happens once, at the end of
|
|
12
|
+
`cg index` (`apply`) and over the combined graph of `cg link` (`link_db`). Identical ids need no edge: the combined
|
|
13
|
+
DB keeps one node per id.
|
|
14
|
+
* Existing node kinds keep their ids and are adapted by the query (protocols/view.py): `http` / `route` (protocol
|
|
15
|
+
http; `route:WS ...` ws, `route:GRAPHQL ...` graphql), `channel_sub` / `channel` (pusher), Nest `message:`
|
|
16
|
+
(nest-rpc / nest-event / nest-ws / grpc), `job:` (bull, laravel-queue, celery), `event:` (laravel-event,
|
|
17
|
+
nest-event-emitter, django-signal) and the bridge endpoints of codegraph/bridges.py (capacitor, react-native,
|
|
18
|
+
flutter, electron-ipc, tauri, ...).
|
|
19
|
+
* `cg protocols` / MCP `protocol_links`: summary per protocol and one block per endpoint with senders, receivers,
|
|
20
|
+
guards, matches and checks (no_receiver, no_sender, ambiguous, schema_mismatch, unguarded, test_sender_only).
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import fnmatch
|
|
25
|
+
import re
|
|
26
|
+
from collections import defaultdict
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Callable
|
|
29
|
+
|
|
30
|
+
from . import matchers as M
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Protocol:
|
|
35
|
+
name: str
|
|
36
|
+
transport: str # tcp | udp | quic | local (in-process / same machine) | ipc
|
|
37
|
+
description: str
|
|
38
|
+
matcher: Callable[[str, str], dict | None] = M.exact
|
|
39
|
+
normalise: Callable[[str], str] | None = None
|
|
40
|
+
ports: tuple = ()
|
|
41
|
+
schemes: tuple = ()
|
|
42
|
+
fanout: bool = False # pub/sub: every matching receiver gets the message (never ambiguous)
|
|
43
|
+
entry: bool = True # receivers can be reached from outside the process (message_handler entry)
|
|
44
|
+
guards: bool = False # receivers record guards: `unguarded` is checked
|
|
45
|
+
source: str = "endpoint" # endpoint (protocol_send / protocol_receive) | the adapter of an existing kind
|
|
46
|
+
kinds: tuple = ("endpoint",) # node kinds holding its endpoints
|
|
47
|
+
aliases: tuple = field(default_factory=tuple)
|
|
48
|
+
entry_kind: str = "message_handler" # entry kind of a receiving endpoint (llm_tool: called by a model / MCP client)
|
|
49
|
+
framework_senders: tuple = () # name globs the framework itself sends (Django's post_save, ...): never no_sender
|
|
50
|
+
directional: bool = False # both processes send and receive on one name (Socket.IO): a sender's
|
|
51
|
+
# `process` (server / client) reaches only receivers of the other one (#69)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
REGISTRY: dict[str, Protocol] = {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def register(p: Protocol) -> Protocol:
|
|
58
|
+
REGISTRY[p.name] = p
|
|
59
|
+
return p
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get(name: str) -> Protocol | None:
|
|
63
|
+
return REGISTRY.get(name)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_TEMPLATE = re.compile(r"\$\{\s*([A-Za-z_$][\w$.]*)?[^{}]*\}|(?<![\w}])\{\{\s*(\w*)[^{}]*\}\}|(?<=[/.:])\:([A-Za-z_]\w*)")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalise(name: str) -> str:
|
|
70
|
+
"""Default name normaliser: template parts become `{param}` (`${id}` / `{{id}}` -> `{id}`, `/:id` -> `/{id}`);
|
|
71
|
+
surrounding blanks dropped."""
|
|
72
|
+
def rep(m):
|
|
73
|
+
n = m.group(1) or m.group(2) or m.group(3) or ""
|
|
74
|
+
return "{" + n.rsplit(".", 1)[-1] + "}"
|
|
75
|
+
return _TEMPLATE.sub(rep, (name or "").strip())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def norm_name(protocol: str, name: str) -> str:
|
|
79
|
+
p = REGISTRY.get(protocol)
|
|
80
|
+
return (p.normalise if p and p.normalise else normalise)(name)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_pattern(name: str) -> bool:
|
|
84
|
+
return bool(re.search(r"\{[^{}]*\}|[*?#>+]", name or ""))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------ builder helpers
|
|
88
|
+
def _endpoint(builder, protocol: str, name: str, node_attrs: dict | None) -> str:
|
|
89
|
+
p = REGISTRY.get(protocol)
|
|
90
|
+
key = f"{protocol}:{name}"
|
|
91
|
+
nid = builder.add_node("endpoint", key, name, fqn=name,
|
|
92
|
+
attrs={"protocol": protocol, "transport": p.transport if p else "tcp"})
|
|
93
|
+
n = builder.nodes[nid]
|
|
94
|
+
if is_pattern(name):
|
|
95
|
+
n.attrs["pattern"] = name
|
|
96
|
+
for k, v in (node_attrs or {}).items():
|
|
97
|
+
if v not in (None, [], ""):
|
|
98
|
+
n.attrs[k] = v
|
|
99
|
+
return nid
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def protocol_send(builder, protocol: str, name: str, src: str, file: str | None, line: int | None,
|
|
103
|
+
confidence: str, test: bool = False, role: str = "send", node_attrs: dict | None = None,
|
|
104
|
+
**attrs) -> str:
|
|
105
|
+
"""Code `src` sends to `endpoint:<protocol>:<name>` (SENDS_TO; from test code: TEST_CALLS with orig SENDS_TO).
|
|
106
|
+
role: send | publish | emit | request | invoke | enqueue; attrs: library, payload, schema, room, ..."""
|
|
107
|
+
nid = _endpoint(builder, protocol, norm_name(protocol, name), node_attrs)
|
|
108
|
+
n = builder.nodes[nid]
|
|
109
|
+
if test:
|
|
110
|
+
n.attrs.setdefault("test_only", True)
|
|
111
|
+
else:
|
|
112
|
+
n.attrs["test_only"] = False
|
|
113
|
+
a = {k: v for k, v in attrs.items() if v not in (None, [], "")}
|
|
114
|
+
if test:
|
|
115
|
+
builder.add_edge(src, nid, "TEST_CALLS", file=file, line=line, confidence=confidence, orig="SENDS_TO", role=role, **a)
|
|
116
|
+
else:
|
|
117
|
+
builder.add_edge(src, nid, "SENDS_TO", file=file, line=line, confidence=confidence, role=role, **a)
|
|
118
|
+
return nid
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def protocol_receive(builder, protocol: str, name: str, handler: str, file: str | None, line: int | None,
|
|
122
|
+
confidence: str, guards: list | None = None, node_attrs: dict | None = None, **attrs) -> str:
|
|
123
|
+
"""`endpoint:<protocol>:<name>` is handled by `handler` (RECEIVED_BY). guards: names of the guards / middleware /
|
|
124
|
+
signature checks protecting the receiver ([] = known to have none); the endpoint becomes a message_handler entry
|
|
125
|
+
point when the protocol's senders can sit outside the process."""
|
|
126
|
+
nid = _endpoint(builder, protocol, norm_name(protocol, name), node_attrs)
|
|
127
|
+
n = builder.nodes[nid]
|
|
128
|
+
p = REGISTRY.get(protocol)
|
|
129
|
+
if guards is not None:
|
|
130
|
+
n.attrs["guards"] = sorted(set(n.attrs.get("guards") or []) | set(guards))
|
|
131
|
+
if (p is None or p.entry) and not n.entry_kind:
|
|
132
|
+
n.entry_kind = p.entry_kind if p else "message_handler"
|
|
133
|
+
builder.add_edge(nid, handler, "RECEIVED_BY", file=file, line=line, confidence=confidence,
|
|
134
|
+
**{k: v for k, v in attrs.items() if v not in (None, [], "")})
|
|
135
|
+
return nid
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------------------------ matching (cg index and cg link)
|
|
139
|
+
def compatible(p, sprocs, rprocs) -> bool:
|
|
140
|
+
"""A sender process set can reach a receiver process set: always, unless the protocol is directional and both
|
|
141
|
+
sides name their processes (server -> client, client -> server)."""
|
|
142
|
+
if p is None or not p.directional or not sprocs or not rprocs:
|
|
143
|
+
return True
|
|
144
|
+
return any(a != b for a in sprocs for b in rprocs)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def match_rows(eps: dict[str, tuple[str, str]], senders: set, receivers: set, existing: set = frozenset(),
|
|
148
|
+
externals: list | tuple = (), procs: dict | None = None) -> tuple[list, dict]:
|
|
149
|
+
"""eps: endpoint id -> (protocol, name) of the generic endpoints in a graph; senders / receivers: ids with a
|
|
150
|
+
SENDS_TO (or test send) / RECEIVED_BY. Returns ([(src, dst, confidence, attrs)], stats per protocol) for
|
|
151
|
+
MATCHES_ENDPOINT send-side -> receive-side endpoints whose names differ but match by the protocol's matcher.
|
|
152
|
+
Fan-out protocols link every matching receiver; the others the most specific ones (ties: ambiguous, heuristic)."""
|
|
153
|
+
by_proto: dict[str, dict] = {}
|
|
154
|
+
for nid, (proto, name) in eps.items():
|
|
155
|
+
d = by_proto.setdefault(proto, {"send": [], "recv": []})
|
|
156
|
+
if nid in senders:
|
|
157
|
+
d["send"].append((nid, name))
|
|
158
|
+
if nid in receivers:
|
|
159
|
+
d["recv"].append((nid, name))
|
|
160
|
+
rows, stats = [], {}
|
|
161
|
+
for proto, d in sorted(by_proto.items()):
|
|
162
|
+
p = REGISTRY.get(proto)
|
|
163
|
+
if p is None or p.source != "endpoint":
|
|
164
|
+
continue
|
|
165
|
+
s = stats.setdefault(proto, {"endpoints": 0, "send": 0, "receive": 0, "matched": 0, "match_edges": 0,
|
|
166
|
+
"no_receiver": 0, "no_sender": 0, "ambiguous": 0, "external": 0})
|
|
167
|
+
ids = {nid for nid, (pp, _) in eps.items() if pp == proto}
|
|
168
|
+
s["endpoints"] = len(ids)
|
|
169
|
+
s["send"], s["receive"] = len(d["send"]), len(d["recv"])
|
|
170
|
+
got_recv = set()
|
|
171
|
+
pr = procs or {}
|
|
172
|
+
|
|
173
|
+
def ok(a, b):
|
|
174
|
+
return compatible(p, pr.get(("send", a)), pr.get(("recv", b)))
|
|
175
|
+
for sid, sname in d["send"]:
|
|
176
|
+
hits = []
|
|
177
|
+
for rid, rname in d["recv"]:
|
|
178
|
+
if rid == sid or p.matcher is M.exact or not ok(sid, rid):
|
|
179
|
+
continue
|
|
180
|
+
info = p.matcher(sname, rname)
|
|
181
|
+
if info is not None:
|
|
182
|
+
hits.append((rid, rname, info))
|
|
183
|
+
direct = sid in receivers and ok(sid, sid)
|
|
184
|
+
if direct:
|
|
185
|
+
got_recv.add(sid)
|
|
186
|
+
if hits and not p.fanout:
|
|
187
|
+
if direct:
|
|
188
|
+
hits = [] # the exact name is received: the most specific receiver
|
|
189
|
+
else:
|
|
190
|
+
top = max(M.rank(h[2]) for h in hits)
|
|
191
|
+
hits = [h for h in hits if M.rank(h[2]) == top]
|
|
192
|
+
tied = len(hits) > 1 and not p.fanout
|
|
193
|
+
for rid, rname, info in hits:
|
|
194
|
+
got_recv.add(rid)
|
|
195
|
+
if (sid, rid) in existing:
|
|
196
|
+
continue
|
|
197
|
+
a = {"sender_name": sname, "pattern": rname, "segments": info}
|
|
198
|
+
if tied:
|
|
199
|
+
a["ambiguous"] = len(hits)
|
|
200
|
+
rows.append((sid, rid, M.confidence(info, tied), a))
|
|
201
|
+
s["match_edges"] += 1
|
|
202
|
+
if hits or direct:
|
|
203
|
+
s["matched"] += 1
|
|
204
|
+
elif externals and external_match(externals, proto, sname):
|
|
205
|
+
s["external"] += 1 # .cg.yaml protocols.external: received outside these repos (#69)
|
|
206
|
+
else:
|
|
207
|
+
s["no_receiver"] += 1 if d["recv"] else 0
|
|
208
|
+
if tied:
|
|
209
|
+
s["ambiguous"] += 1
|
|
210
|
+
unsent = [rname for rid, rname in d["recv"] if (rid not in senders or not ok(rid, rid)) and rid not in got_recv] \
|
|
211
|
+
if d["send"] else []
|
|
212
|
+
ext = [r for r in unsent if externals and external_match(externals, proto, r)]
|
|
213
|
+
s["no_sender"] = len(unsent) - len(ext)
|
|
214
|
+
s["external"] += len(ext)
|
|
215
|
+
return rows, stats
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def apply(builder, externals: list | tuple = ()) -> dict:
|
|
219
|
+
"""End of `cg index`: MATCHES_ENDPOINT between the generic endpoints of one graph; stats per protocol (only
|
|
220
|
+
when the graph has endpoints of registered non-bridge protocols)."""
|
|
221
|
+
eps = {nid: (n.attrs.get("protocol"), n.name) for nid, n in builder.nodes.items()
|
|
222
|
+
if n.kind == "endpoint" and (REGISTRY.get(n.attrs.get("protocol")) or Protocol("", "", "", source="?")).source == "endpoint"}
|
|
223
|
+
if not eps:
|
|
224
|
+
return {}
|
|
225
|
+
senders, receivers, loc, procs = set(), set(), {}, defaultdict(set)
|
|
226
|
+
for e in builder.edges.values():
|
|
227
|
+
if e.kind == "SENDS_TO" and e.dst in eps:
|
|
228
|
+
senders.add(e.dst)
|
|
229
|
+
if e.attrs.get("process"):
|
|
230
|
+
procs[("send", e.dst)].add(e.attrs["process"])
|
|
231
|
+
elif e.kind == "TEST_CALLS" and e.dst in eps and e.attrs.get("orig") == "SENDS_TO":
|
|
232
|
+
senders.add(e.dst)
|
|
233
|
+
elif e.kind == "RECEIVED_BY" and e.src in eps:
|
|
234
|
+
receivers.add(e.src)
|
|
235
|
+
if e.attrs.get("process"):
|
|
236
|
+
procs[("recv", e.src)].add(e.attrs["process"])
|
|
237
|
+
loc.setdefault(e.src, (e.file, e.line)) # the match is evidenced where the receiver registers
|
|
238
|
+
for nid in eps:
|
|
239
|
+
a = builder.nodes[nid].attrs
|
|
240
|
+
a["side"] = "both" if nid in senders and nid in receivers else "send" if nid in senders else "receive" if nid in receivers else "none"
|
|
241
|
+
rows, st = match_rows(eps, senders, receivers, externals=externals, procs=procs)
|
|
242
|
+
for s, d, conf, a in rows:
|
|
243
|
+
f, ln = loc.get(d, (None, None))
|
|
244
|
+
builder.add_edge(s, d, "MATCHES_ENDPOINT", file=f, line=ln, confidence=conf, **a)
|
|
245
|
+
return st
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def link_db(db) -> dict:
|
|
249
|
+
"""`cg link`: MATCHES_ENDPOINT over the combined graph (senders in one repo, receivers in the other)."""
|
|
250
|
+
import json
|
|
251
|
+
from ..core.model import CONFIDENCE_RANK
|
|
252
|
+
names = [p.name for p in REGISTRY.values() if p.source == "endpoint"]
|
|
253
|
+
if not names:
|
|
254
|
+
return {}
|
|
255
|
+
q = ",".join("?" * len(names))
|
|
256
|
+
eps = {r[0]: (r[1], r[2]) for r in db.execute(
|
|
257
|
+
f"SELECT id, json_extract(attrs,'$.protocol'), name FROM nodes WHERE kind='endpoint' AND json_extract(attrs,'$.protocol') IN ({q})", names)}
|
|
258
|
+
if not eps:
|
|
259
|
+
return {}
|
|
260
|
+
senders = {r[0] for r in db.execute("SELECT DISTINCT dst FROM edges WHERE kind='SENDS_TO' OR (kind='TEST_CALLS' AND "
|
|
261
|
+
"json_extract(attrs,'$.orig')='SENDS_TO')") if r[0] in eps}
|
|
262
|
+
receivers = {r[0] for r in db.execute("SELECT DISTINCT src FROM edges WHERE kind='RECEIVED_BY'") if r[0] in eps}
|
|
263
|
+
existing = {(r[0], r[1]) for r in db.execute("SELECT src, dst FROM edges WHERE kind='MATCHES_ENDPOINT'")}
|
|
264
|
+
externals = []
|
|
265
|
+
for alias in ("b", "f"): # protocols.external of both repos (.cg.yaml, in their index stats)
|
|
266
|
+
try:
|
|
267
|
+
r = db.execute(f"SELECT value FROM {alias}.meta WHERE key='stats'").fetchone()
|
|
268
|
+
except Exception: # noqa: BLE001 (not attached: a graph linked on its own)
|
|
269
|
+
r = None
|
|
270
|
+
if r:
|
|
271
|
+
externals += ((json.loads(r[0]).get("config") or {}).get("protocols") or {}).get("external") or []
|
|
272
|
+
procs = defaultdict(set)
|
|
273
|
+
for kind, col, side in (("SENDS_TO", "dst", "send"), ("RECEIVED_BY", "src", "recv")):
|
|
274
|
+
for nid, pr in db.execute(f"SELECT {col}, json_extract(attrs,'$.process') FROM edges WHERE kind=? AND "
|
|
275
|
+
"json_extract(attrs,'$.process') IS NOT NULL", (kind,)):
|
|
276
|
+
if nid in eps:
|
|
277
|
+
procs[(side, nid)].add(pr)
|
|
278
|
+
rows, st = match_rows(eps, senders, receivers, existing, externals=list(dict.fromkeys(externals)), procs=procs)
|
|
279
|
+
loc = {}
|
|
280
|
+
for r in db.execute("SELECT src, file, line FROM edges WHERE kind='RECEIVED_BY' ORDER BY rowid"):
|
|
281
|
+
loc.setdefault(r[0], (r[1], r[2]))
|
|
282
|
+
db.executemany("INSERT INTO edges(src,dst,kind,file,line,confidence,conf_rank,attrs,gate) VALUES (?,?,?,?,?,?,?,?,?)",
|
|
283
|
+
[(s, d, "MATCHES_ENDPOINT", *loc.get(d, (None, None)), c, CONFIDENCE_RANK[c], json.dumps(a), None)
|
|
284
|
+
for s, d, c, a in rows])
|
|
285
|
+
db.commit()
|
|
286
|
+
return st
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def external_match(patterns: list[str], protocol: str, name: str) -> str | None:
|
|
290
|
+
"""`.cg.yaml` protocols.external entries (`kafka:audit.*`, `http:GET /v1/*`, `socketio:*`): the first one
|
|
291
|
+
matching `<protocol>:<name>` (glob)."""
|
|
292
|
+
for p in patterns or []:
|
|
293
|
+
pp, _, nn = p.partition(":")
|
|
294
|
+
if fnmatch.fnmatchcase(protocol, pp) and fnmatch.fnmatchcase(name, nn or "*"):
|
|
295
|
+
return p
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
from . import builtin # noqa: E402,F401 (registers the built-in protocols)
|