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,428 @@
1
+ """Blind-spot detectors: patterns cg knows it does not model, found in the indexed repository at index time.
2
+
3
+ Each detector reports the places where a route or handler is registered in a way no plugin turns into graph edges
4
+ (a route decorator wrapped in a helper, URL patterns built by a function, routes registered in a loop, handlers
5
+ registered through a decorator or a registry). Query answers use them to say when a route list or a caller list may
6
+ be partial, and where to look (file:line samples), so an agent can fall back to text search exactly there.
7
+
8
+ A finding: {kind, category ("route" = a route registration, "handler" = a function reached through registration),
9
+ language (coverage language key), count, sample ("file:line"), samples [...], what (short description)}; handler
10
+ findings also carry `nodes` (graph ids of the registered functions, aligned with `samples`) so an answer is flagged
11
+ only when it involves one of them.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import os
17
+ import re
18
+ from collections import defaultdict
19
+ from pathlib import Path
20
+
21
+ MAX_SAMPLES = 10
22
+ MAX_NODES = 2000
23
+ MAX_BYTES = 1_500_000
24
+
25
+ KINDS = {
26
+ "nest_wrapped_route_decorator": ("route", "typescript", "NestJS route decorator wrapped by applyDecorators / a decorator factory"),
27
+ "django_dynamic_urlpatterns": ("route", "python", "Django urlpatterns built by a function call, comprehension or loop"),
28
+ "django_unresolved_include": ("route", "python", "Django include() of a target cg could not resolve"),
29
+ "express_loop_routes": ("route", "typescript", "Express / Fastify / Koa / Hono routes registered in a loop or with a computed method"),
30
+ "laravel_loop_routes": ("route", "php", "Laravel Route:: calls inside a loop or a collection callback"),
31
+ "python_decorator_routes": ("route", "python", "route decorator of a framework cg has no plugin for"),
32
+ "python_decorator_registration": ("handler", "python", "function registered through a decorator no plugin models"),
33
+ "python_registry_assignment": ("handler", "python", "function stored in a registry (registry[key] = fn)"),
34
+ }
35
+
36
+
37
+ def _finding(kind: str, hits: list[tuple]) -> dict | None:
38
+ """hits: (file, line) or (file, line, node_id) tuples."""
39
+ if not hits:
40
+ return None
41
+ hits = sorted(set(hits))
42
+ cat, lang, what = KINDS[kind]
43
+ s = [f"{h[0]}:{h[1]}" for h in hits]
44
+ out = {"kind": kind, "category": cat, "language": lang, "count": len(hits), "sample": s[0], "samples": s[:MAX_SAMPLES],
45
+ "what": what}
46
+ if hits and all(len(h) > 2 for h in hits):
47
+ out["nodes"] = [h[2] for h in hits][:MAX_NODES]
48
+ out["node_samples"] = s[:MAX_NODES]
49
+ return out
50
+
51
+
52
+ def _read(root: Path, rel: str) -> str | None:
53
+ p = root / rel
54
+ try:
55
+ if p.stat().st_size > MAX_BYTES:
56
+ return None
57
+ return p.read_text(encoding="utf-8", errors="replace")
58
+ except OSError:
59
+ return None
60
+
61
+
62
+ def _line(src: str, pos: int) -> int:
63
+ return src.count("\n", 0, pos) + 1
64
+
65
+
66
+ _C_COMMENT = re.compile(r"/\*.*?\*/|(?<![:\"'`\\])//[^\n]*", re.S)
67
+
68
+
69
+ def _strip_comments(src: str) -> str:
70
+ """Blank out /* */ and // comments, keeping offsets (so line numbers stay right)."""
71
+ return _C_COMMENT.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), src)
72
+
73
+
74
+ def _match(src: str, i: int, open_: str, close: str) -> int:
75
+ """Index of the bracket closing the one at src[i] (or len(src)); string literals are skipped."""
76
+ depth, n, q = 0, len(src), None
77
+ while i < n:
78
+ c = src[i]
79
+ if q:
80
+ if c == "\\":
81
+ i += 2
82
+ continue
83
+ if c == q:
84
+ q = None
85
+ elif c in "\"'`":
86
+ q = c
87
+ elif c == open_:
88
+ depth += 1
89
+ elif c == close:
90
+ depth -= 1
91
+ if depth == 0:
92
+ return i
93
+ i += 1
94
+ return n
95
+
96
+
97
+ def _loop_bodies(src: str, heads: re.Pattern) -> list[tuple[int, int, int]]:
98
+ """(start, body_start, body_end) of each loop: `for (...) {...}` / `foreach (...) {...}` / `.forEach(...)` style
99
+ callbacks (heads must end at the opening parenthesis). Statement-bodied loops run to the next `;`."""
100
+ out = []
101
+ for m in heads.finditer(src):
102
+ p = m.end() - 1
103
+ close = _match(src, p, "(", ")")
104
+ if m.group(0).lstrip(".").startswith(("for", "foreach", "while")) and not m.group(0).startswith("."):
105
+ j = close + 1
106
+ while j < len(src) and src[j] in " \t\r\n":
107
+ j += 1
108
+ if j < len(src) and src[j] == "{":
109
+ out.append((m.start(), j, _match(src, j, "{", "}")))
110
+ else:
111
+ out.append((m.start(), j, src.find(";", j) if src.find(";", j) >= 0 else len(src)))
112
+ else:
113
+ out.append((m.start(), p, close))
114
+ return out
115
+
116
+
117
+ # ------------------------------------------------------------------------------------------- NestJS
118
+
119
+ NEST_VERBS = r"(?:Get|Post|Put|Patch|Delete|All|Options|Head|Search)"
120
+ _NEST_DEF = re.compile(r"(?:export\s+)?(?:function\s+(\w+)\s*(?:<[^>]*>)?\s*\(|(?:const|let|var)\s+(\w+)\s*=)")
121
+
122
+
123
+ def nest_wrapped_route_decorators(root: Path, files: list[str]) -> dict | None:
124
+ """Decorators defined as applyDecorators(Get(...), ...) or as a factory returning Get(...): the Nest plugin
125
+ only reads the built-in route decorators, so every method using such a decorator is a route cg does not see."""
126
+ defs: dict[str, tuple[str, int]] = {}
127
+ texts = {}
128
+ for rel in files:
129
+ src = _read(root, rel)
130
+ if not src or "@" not in src:
131
+ continue
132
+ src = _strip_comments(src)
133
+ texts[rel] = src # decorator uses can be in any file
134
+ if "@nestjs/" not in src and "applyDecorators" not in src:
135
+ continue
136
+ for m in _NEST_DEF.finditer(src):
137
+ name = m.group(1) or m.group(2)
138
+ if not name or not name[0].isupper():
139
+ continue # decorators are PascalCase by convention; keeps local helpers out
140
+ head_end = m.end()
141
+ # body of the function / initializer: up to the end of the matching block or statement
142
+ if m.group(1):
143
+ p = _match(src, head_end - 1, "(", ")")
144
+ b = src.find("{", p)
145
+ body = src[b:_match(src, b, "{", "}") + 1] if b >= 0 else ""
146
+ else:
147
+ semi = src.find(";", head_end)
148
+ nxt = re.search(r"\n(?:export\s+)?(?:const|let|var|function|class)\s", src[head_end:])
149
+ end = head_end + nxt.start() if nxt else (semi if semi >= 0 else len(src))
150
+ body = src[head_end:end]
151
+ if re.search(rf"\bapplyDecorators\s*\([^;]*?\b{NEST_VERBS}\s*\(", body, re.S) or \
152
+ re.search(rf"(?:\breturn\s+|=>\s*){NEST_VERBS}\s*\(", body):
153
+ defs.setdefault(name, (rel, _line(src, m.start())))
154
+ if not defs:
155
+ return None
156
+ use = re.compile(r"@(" + "|".join(map(re.escape, defs)) + r")\s*\(")
157
+ hits = []
158
+ for rel, src in texts.items():
159
+ hits += [(rel, _line(src, m.start())) for m in use.finditer(src)]
160
+ return _finding("nest_wrapped_route_decorator", hits or list(defs.values()))
161
+
162
+
163
+ # ------------------------------------------------------------------------------------------- Express-style
164
+
165
+ _JS_ROUTER_DECL = re.compile(r"(?:const|let|var)\s+(\w+)\s*(?::\s*[\w.<>]+\s*)?=\s*(?:await\s+)?(?:new\s+)?"
166
+ r"(?:express(?:\.Router)?|Router|KoaRouter|Koa|Fastify|fastify|Hono|createRouter)\s*\(")
167
+ _JS_LOOPS = re.compile(r"\bfor\s*(?:await\s*)?\(|\.\s*(?:forEach|map|flatMap)\s*\(")
168
+ JS_ROUTER_NAMES = {"app", "router", "server", "fastify"}
169
+ _JS_SERVER_IMPORT = re.compile(r"""(?:from\s+|require\s*\(\s*|import\s*\(\s*)['"](?:express|fastify|koa|@koa/router|koa-router|hono|@hono/[\w-]+)['"]""")
170
+ JS_SERVER_DEPS = ("express", "fastify", "koa", "@koa/router", "koa-router", "hono")
171
+
172
+
173
+ def _server_project(root: Path) -> bool:
174
+ """package.json depends on an Express-style server framework (then route modules that only receive `app` /
175
+ `router` as a parameter are checked too)."""
176
+ import json
177
+ try:
178
+ d = json.loads((root / "package.json").read_text())
179
+ except Exception: # noqa: BLE001
180
+ return False
181
+ deps = {**(d.get("dependencies") or {}), **(d.get("devDependencies") or {})}
182
+ return any(k in deps for k in JS_SERVER_DEPS)
183
+ JS_VERBS = r"(?:get|post|put|patch|delete|del|all|head|options|route|use|register)"
184
+
185
+
186
+ def express_loop_routes(root: Path, files: list[str]) -> dict | None:
187
+ """Routes registered inside a loop or callback over a list (`for (const r of routes) router[r.method](r.path, ...)`,
188
+ `['get', 'post'].forEach(...)`): Express-style plugins read literal registrations only."""
189
+ hits = []
190
+ project = _server_project(root)
191
+ for rel in files:
192
+ src = _read(root, rel)
193
+ if not src or not (_JS_SERVER_IMPORT.search(src) or (project and re.search(r"\b(?:app|router|server|fastify)\b", src))):
194
+ continue
195
+ src = _strip_comments(src)
196
+ names = JS_ROUTER_NAMES | {m.group(1) for m in _JS_ROUTER_DECL.finditer(src)}
197
+ reg = re.compile(r"\b(?:" + "|".join(map(re.escape, sorted(names))) + rf")\s*(?:\.\s*{JS_VERBS}\s*\(|\[\s*[^\]\n]+\]\s*\()")
198
+ done_until = -1
199
+ for start, b0, b1 in _loop_bodies(src, _JS_LOOPS):
200
+ if start < done_until:
201
+ continue # nested loop of a loop already reported
202
+ if reg.search(src, b0, b1):
203
+ hits.append((rel, _line(src, start)))
204
+ done_until = b1
205
+ # computed method outside a loop: router[method](path, handler)
206
+ creg = re.compile(r"\b(?:" + "|".join(map(re.escape, sorted(names))) + r")\s*\[\s*(?!['\"`])[^\]\n]+\]\s*\(")
207
+ for m in creg.finditer(src):
208
+ ln = _line(src, m.start())
209
+ if not any(f == rel and abs(l0 - ln) <= 3 for f, l0 in hits):
210
+ hits.append((rel, ln))
211
+ return _finding("express_loop_routes", hits)
212
+
213
+
214
+ # ------------------------------------------------------------------------------------------- Laravel
215
+
216
+ _PHP_LOOPS = re.compile(r"\b(?:foreach|for|while)\s*\(|->\s*(?:each|map|flatMap|eachSpread)\s*\(|\barray_(?:map|walk)\s*\(")
217
+
218
+
219
+ def laravel_loop_routes(root: Path, files: list[str]) -> dict | None:
220
+ """Route::get(...) inside foreach / for / ->each(...) / array_map(...): the URIs come from data, so the Laravel
221
+ plugin cannot list them."""
222
+ hits = []
223
+ for rel in files:
224
+ src = _read(root, rel)
225
+ if not src or "Route::" not in src:
226
+ continue
227
+ src = re.sub(r"#[^\n]*", lambda m: " " * len(m.group(0)), _strip_comments(src))
228
+ done_until = -1
229
+ for start, b0, b1 in _loop_bodies(src, _PHP_LOOPS):
230
+ if start < done_until:
231
+ continue
232
+ if re.search(r"\bRoute::\s*(?:get|post|put|patch|delete|any|match|resource|apiResource|resources|apiResources|view|redirect)\s*\(",
233
+ src[b0:b1]):
234
+ hits.append((rel, _line(src, start)))
235
+ done_until = b1
236
+ return _finding("laravel_loop_routes", hits)
237
+
238
+
239
+ # ------------------------------------------------------------------------------------------- Django (AST)
240
+
241
+ URL_FUNCS = {"path", "re_path", "url", "include", "static", "staticfiles_urlpatterns", "debug_toolbar_urls",
242
+ "format_suffix_patterns", "i18n_patterns", "list"}
243
+
244
+
245
+ def _dotted(e) -> str | None:
246
+ if isinstance(e, ast.Name):
247
+ return e.id
248
+ if isinstance(e, ast.Attribute):
249
+ b = _dotted(e.value)
250
+ return f"{b}.{e.attr}" if b else None
251
+ return None
252
+
253
+
254
+ def _dynamic_items(e) -> list[ast.AST]:
255
+ """Parts of a urlpatterns value that Django URL resolution in cg does not evaluate: calls to functions other
256
+ than the URL helpers (also as `*call(...)`), comprehensions."""
257
+ out = []
258
+ if isinstance(e, (ast.List, ast.Tuple)):
259
+ for x in e.elts:
260
+ out += _dynamic_items(x.value if isinstance(x, ast.Starred) else x)
261
+ elif isinstance(e, ast.BinOp) and isinstance(e.op, ast.Add):
262
+ out += _dynamic_items(e.left) + _dynamic_items(e.right)
263
+ elif isinstance(e, (ast.ListComp, ast.GeneratorExp)):
264
+ out.append(e)
265
+ elif isinstance(e, ast.Call):
266
+ fn = (_dotted(e.func) or "").split(".")[-1]
267
+ if fn in ("format_suffix_patterns", "i18n_patterns", "list"):
268
+ for a in e.args:
269
+ out += _dynamic_items(a.value if isinstance(a, ast.Starred) else a)
270
+ elif fn not in URL_FUNCS and not (isinstance(e.func, ast.Attribute) and e.func.attr in ("as_view", "as_asgi")):
271
+ out.append(e)
272
+ return out
273
+
274
+
275
+ def django_dynamic_urlpatterns(modules) -> dict | None:
276
+ """modules: [(file, tree)]. urlpatterns entries produced by a function call, a comprehension or a loop."""
277
+ hits = []
278
+ for rel, tree in modules:
279
+ def visit(stmts, in_loop=False):
280
+ for st in stmts:
281
+ tgt_is = lambda t: isinstance(t, ast.Name) and t.id == "urlpatterns" # noqa: E731
282
+ if isinstance(st, (ast.Assign, ast.AugAssign, ast.AnnAssign)):
283
+ tgts = st.targets if isinstance(st, ast.Assign) else [st.target]
284
+ if any(tgt_is(t) for t in tgts) and st.value is not None:
285
+ if in_loop:
286
+ hits.append((rel, st.lineno))
287
+ continue
288
+ for x in _dynamic_items(st.value):
289
+ if any(isinstance(n, ast.Name) and n.id == "urlpatterns" for n in ast.walk(x)):
290
+ continue # urlpatterns = wrap(urlpatterns): a pass-through, not new routes
291
+ hits.append((rel, x.lineno))
292
+ elif isinstance(st, ast.Expr) and isinstance(st.value, ast.Call) and isinstance(st.value.func, ast.Attribute) \
293
+ and tgt_is(st.value.func.value) and st.value.func.attr in ("append", "extend", "insert"):
294
+ if in_loop:
295
+ hits.append((rel, st.lineno))
296
+ elif st.value.func.attr == "extend" and st.value.args:
297
+ hits.extend((rel, x.lineno) for x in _dynamic_items(st.value.args[0]))
298
+ elif st.value.args:
299
+ hits.extend((rel, x.lineno) for x in _dynamic_items(ast.List(elts=[st.value.args[-1]])))
300
+ elif isinstance(st, (ast.For, ast.AsyncFor, ast.While)):
301
+ visit(st.body, True)
302
+ visit(st.orelse, in_loop)
303
+ elif isinstance(st, ast.If):
304
+ visit(st.body, in_loop)
305
+ visit(st.orelse, in_loop)
306
+ elif isinstance(st, (ast.Try, getattr(ast, "TryStar", ast.Try))):
307
+ visit(st.body, in_loop)
308
+ for h in st.handlers:
309
+ visit(h.body, in_loop)
310
+ visit(st.orelse, in_loop)
311
+ visit(st.finalbody, in_loop)
312
+ elif isinstance(st, ast.With):
313
+ visit(st.body, in_loop)
314
+ visit(tree.body)
315
+ return _finding("django_dynamic_urlpatterns", hits)
316
+
317
+
318
+ def django_unresolved_includes(unresolved: list[dict]) -> dict | None:
319
+ """include(<non-literal>) / unknown `.urls` targets the Django URL resolver could not follow. A literal include of a
320
+ package outside the repository (`include("allauth.urls")`) is third-party code, not a blind spot of this repo."""
321
+ hits = [(u["file"], u["line"]) for u in unresolved or []
322
+ if str(u.get("reason", "")).endswith(") unresolved") or str(u.get("reason", "")).startswith("unknown .urls")]
323
+ return _finding("django_unresolved_include", hits)
324
+
325
+
326
+ # ------------------------------------------------------------------------------------------- Python registrations
327
+
328
+ ROUTE_DECOS = {"route", "get", "post", "put", "patch", "delete", "head", "options", "api_route", "websocket", "add_route"}
329
+ REGISTER_DECOS = {"register", "command", "group", "task", "shared_task", "hook", "hookimpl", "handler", "on", "listener",
330
+ "subscriber", "subscribe", "callback", "event", "job", "signal", "receiver", "connect", "action",
331
+ "plugin", "provider", "expose", "export", "tool", "resource", "prompt", "cli"}
332
+
333
+
334
+ def _deco_name(text: str) -> tuple[str, bool]:
335
+ """'@app.route("/x")' text -> ('route', has_receiver)."""
336
+ head = text.split("(", 1)[0].strip().lstrip("@")
337
+ parts = head.split(".")
338
+ if "mock" in parts or parts[0] == "patch":
339
+ return "", False # unittest.mock.patch / patch.object: test doubles, not registrations
340
+ return parts[-1], len(parts) > 1
341
+
342
+
343
+ def python_registrations(builder) -> list[dict]:
344
+ """Python functions registered through a decorator (`@app.route`, `@registry.register`, `@click.command`) that no
345
+ plugin turned into an entry point or a caller: the graph holds the decorator's reference (REFERENCES_FN, how =
346
+ decorator) but not who invokes the registered function."""
347
+ incoming = set()
348
+ for e in builder.edges.values():
349
+ if e.kind != "CONTAINS" and not (e.kind == "REFERENCES_FN" and (e.attrs or {}).get("how") == "decorator"):
350
+ incoming.add(e.dst)
351
+ routes, regs, decos = [], [], {}
352
+ local_fns = {(n.file, n.name): n.id for n in builder.nodes.values() if n.lang == "python" and n.kind == "function"}
353
+ for n in builder.nodes.values():
354
+ if n.lang != "python" or n.kind not in ("function", "method") or n.entry_kind or n.id in incoming:
355
+ continue
356
+ for d in (n.attrs or {}).get("decorators") or []:
357
+ name, recv = _deco_name(str(d))
358
+ if recv and name in ROUTE_DECOS:
359
+ routes.append((n.file, n.line, n.id))
360
+ break
361
+ if name in REGISTER_DECOS or (recv and name.startswith("register")):
362
+ regs.append((n.file, n.line, n.id))
363
+ if not recv and (n.file, name) in local_fns: # a same-file decorator: its uses are not calls either
364
+ decos.setdefault(local_fns[(n.file, name)], f"{n.file}:{n.line}")
365
+ break
366
+ reg = _finding("python_decorator_registration", regs)
367
+ if reg and decos and "nodes" in reg:
368
+ reg["nodes"] += list(decos)
369
+ reg["node_samples"] += list(decos.values())
370
+ return [f for f in (_finding("python_decorator_routes", routes), reg) if f]
371
+
372
+
373
+ def python_registry_assignments(prog, builder) -> dict | None:
374
+ """`registry[key] = fn` (module level or inside a function) where fn is a function of the same module that
375
+ has no caller in the graph: no call through the registry was found (a module-level reference alone does not
376
+ count; `REGISTRY[key](...)` in indexed code becomes CALLS via collection)."""
377
+ incoming = set()
378
+ for e in builder.edges.values():
379
+ if e.kind != "CONTAINS" and not (e.kind == "REFERENCES_FN" and e.src.startswith("module:")):
380
+ incoming.add(e.dst)
381
+ hits = []
382
+ for m in prog.modules.values():
383
+ local = {f.name: f for f in prog.funcs.values() if f.module is m and f.cls is None}
384
+ if not local:
385
+ continue
386
+ for st in ast.walk(m.tree):
387
+ if isinstance(st, ast.Assign) and isinstance(st.value, ast.Name) and st.value.id in local \
388
+ and any(isinstance(t, ast.Subscript) for t in st.targets):
389
+ f = local[st.value.id]
390
+ if f.id not in incoming and not (builder.nodes.get(f.id) and builder.nodes[f.id].entry_kind):
391
+ hits.append((m.file, st.lineno, f.id))
392
+ return _finding("python_registry_assignment", hits)
393
+
394
+
395
+ # ------------------------------------------------------------------------------------------- driver
396
+
397
+ def detect(root: str | Path, files_by_ext: dict[str, list[str]], builder=None, programs: dict | None = None) -> list[dict]:
398
+ """Run every detector that applies; never raises (a detector that fails is skipped)."""
399
+ root = Path(root)
400
+ programs = programs or {}
401
+ js = [f for e in (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs") for f in files_by_ext.get(e, [])]
402
+ js = [f for f in js if not f.endswith(".d.ts")]
403
+ php = files_by_ext.get(".php", [])
404
+ out = []
405
+
406
+ def run(fn, *a):
407
+ try:
408
+ r = fn(*a)
409
+ except Exception as ex: # noqa: BLE001 (a detector bug must never fail the index)
410
+ import sys
411
+ print(f"codegraph: blind-spot detector {fn.__name__} failed: {type(ex).__name__}: {ex}", file=sys.stderr)
412
+ return
413
+ if isinstance(r, list):
414
+ out.extend(r)
415
+ elif r:
416
+ out.append(r)
417
+
418
+ run(nest_wrapped_route_decorators, root, js)
419
+ run(express_loop_routes, root, js)
420
+ run(laravel_loop_routes, root, php)
421
+ py = programs.get("python")
422
+ if py is not None:
423
+ run(django_dynamic_urlpatterns, [(m.file, m.tree) for m in py.modules.values()])
424
+ run(django_unresolved_includes, getattr(py, "url_unresolved", None))
425
+ if builder is not None:
426
+ run(python_registrations, builder)
427
+ run(python_registry_assignments, py, builder)
428
+ return out