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.
Files changed (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. 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)