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
codegraph/external.py ADDED
@@ -0,0 +1,613 @@
1
+ """External systems (#40, epic #30): databases, caches, brokers, mail relays, directories, file-transfer hosts and
2
+ object stores the code connects to, as `external:<protocol>:<target>` nodes.
3
+
4
+ target `host:port` when the address is known (a literal DSN, a `.env.example` value naming a docker-compose
5
+ service or a non-loopback host), otherwise `env:<KEY>` (the address comes from the environment; two repos
6
+ reading the same key meet on one node in `cg link`)
7
+ attrs protocol, scheme, host, port, resource, tls (true / false / null), address_source (literal | env-example |
8
+ compose | env | config) + address_at, address_default (a framework / example default that is not used as
9
+ the target, such as 127.0.0.1), credential_source (env | config | literal | none | unknown) + credential_at,
10
+ user (from a DSN), deployment_name / image (docker-compose), library
11
+ edges CONNECTS_TO (code or a logical connection -> external, propagating; attrs.op, via), CONFIGURED_BY
12
+ (external -> env / config node of the address), CREDENTIAL_FROM (external -> env / config node of the
13
+ password / token)
14
+
15
+ Sources, language-agnostic over the built graph:
16
+ * env keys read by code (env nodes, READS_ENV), grouped by prefix (DB_*, DATABASE_URL, REDIS_*, MAIL_* / SMTP_*,
17
+ MONGO*_*, RABBITMQ_* / AMQP_*, ELASTICSEARCH_*, MEMCACHED_*, LDAP_*, SFTP_* / SSH_*, FTP_*, S3_*); every reader of
18
+ an address key connects;
19
+ * Laravel `connection` nodes (config/database.php): the default connection, used ones and custom ones attach to
20
+ the external of their driver and host keys (the framework's unused template connections do not);
21
+ * `.env.example` / `.env.sample` / `.env.dist` values and `docker-compose*.yml` / `compose*.yaml` services resolve
22
+ the target (`DB_HOST=db` + a `db` service running postgres -> external:postgres:db:5432).
23
+ Secret values (passwords, tokens, DSN passwords) are never read into the graph: only the key and its location.
24
+ Third-party HTTP origins (`http` nodes with origin_kind = other) are shown by `cg external` as `https` systems
25
+ without extra nodes.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import re
31
+ from pathlib import Path
32
+ from urllib.parse import unquote, urlsplit
33
+
34
+ SCHEMES = {
35
+ "postgres": "postgres", "postgresql": "postgres", "pgsql": "postgres", "postgis": "postgres",
36
+ "mysql": "mysql", "mariadb": "mysql", "mysql2": "mysql", "sqlsrv": "mssql", "mssql": "mssql", "sqlserver": "mssql",
37
+ "oracle": "oracle", "mongodb": "mongodb", "mongodb+srv": "mongodb", "redis": "redis", "rediss": "redis",
38
+ "valkey": "redis", "amqp": "amqp", "amqps": "amqp", "smtp": "smtp", "smtps": "smtp", "submission": "smtp",
39
+ "ldap": "ldap", "ldaps": "ldap", "sftp": "ssh", "ssh": "ssh", "scp": "ssh", "ftp": "ftp", "ftps": "ftp",
40
+ "s3": "s3", "memcached": "memcached", "memcache": "memcached", "elasticsearch": "elasticsearch",
41
+ "nats": "nats", "kafka": "kafka", "mqtt": "mqtt", "mqtts": "mqtt", "imap": "imap", "imaps": "imap",
42
+ "pop3": "pop3", "pop3s": "pop3", "clickhouse": "clickhouse", "cassandra": "cassandra",
43
+ }
44
+ TLS_SCHEMES = {"rediss", "amqps", "smtps", "ldaps", "ftps", "mqtts", "imaps", "pop3s", "https", "mongodb+srv", "sftp", "ssh",
45
+ "scp", "s3"}
46
+ PLAIN_SCHEMES = {"redis", "amqp", "smtp", "ldap", "ftp", "mqtt", "imap", "pop3", "http"}
47
+ PORTS = {"postgres": 5432, "mysql": 3306, "mssql": 1433, "oracle": 1521, "mongodb": 27017, "redis": 6379, "amqp": 5672,
48
+ "smtp": 587, "ldap": 389, "ssh": 22, "ftp": 21, "memcached": 11211, "elasticsearch": 9200, "nats": 4222,
49
+ "kafka": 9092, "mqtt": 1883, "imap": 143, "pop3": 110, "clickhouse": 9000, "cassandra": 9042, "https": 443}
50
+ SCHEME_PORTS = {"amqps": 5671, "smtps": 465, "ldaps": 636, "ftps": 990, "mqtts": 8883, "imaps": 993, "pop3s": 995}
51
+ TLS_PORTS = {"smtp": {465}, "ldap": {636}, "imap": {993}, "pop3": {995}, "redis": set(), "amqp": {5671}, "mqtt": {8883},
52
+ "ftp": {990}}
53
+ DRIVERS = {"pgsql": "postgres", "mysql": "mysql", "mariadb": "mysql", "sqlsrv": "mssql", "oracle": "oracle",
54
+ "mongodb": "mongodb", "redis": "redis", "sqlite": None}
55
+ # env key prefix -> protocol (None: from DB_CONNECTION / the URL scheme)
56
+ PREFIXES = [("DATABASE", None), ("DB", None), ("POSTGRES", "postgres"), ("POSTGRESQL", "postgres"), ("PG", "postgres"),
57
+ ("MYSQL", "mysql"), ("MARIADB", "mysql"), ("MSSQL", "mssql"), ("REDIS", "redis"), ("VALKEY", "redis"),
58
+ ("MAIL", "smtp"), ("SMTP", "smtp"), ("EMAIL", "smtp"), ("MONGO", "mongodb"), ("MONGODB", "mongodb"),
59
+ ("RABBITMQ", "amqp"), ("RABBIT", "amqp"), ("AMQP", "amqp"), ("BROKER", None), ("ELASTICSEARCH", "elasticsearch"),
60
+ ("OPENSEARCH", "elasticsearch"), ("ELASTIC", "elasticsearch"), ("MEMCACHED", "memcached"), ("LDAP", "ldap"),
61
+ ("SFTP", "ssh"), ("SSH", "ssh"), ("FTP", "ftp"), ("S3", "s3"), ("MINIO", "s3"), ("NATS", "nats"),
62
+ ("KAFKA", "kafka"), ("MQTT", "mqtt"), ("IMAP", "imap"), ("CLICKHOUSE", "clickhouse")]
63
+ ADDRESS = ("URL", "URI", "DSN", "HOST", "HOSTNAME", "SERVER", "ENDPOINT", "ADDR", "ADDRESS", "BROKERS")
64
+ SECRET = ("PASSWORD", "PASS", "PASSWD", "SECRET", "TOKEN", "SECRET_KEY", "SECRET_ACCESS_KEY", "KEY", "AUTH")
65
+ IMAGES = [("postgis", "postgres"), ("postgres", "postgres"), ("pgvector", "postgres"), ("timescale", "postgres"),
66
+ ("mariadb", "mysql"), ("mysql", "mysql"), ("mssql", "mssql"), ("valkey", "redis"), ("redis", "redis"),
67
+ ("mongo", "mongodb"), ("rabbitmq", "amqp"), ("elasticsearch", "elasticsearch"), ("opensearch", "elasticsearch"),
68
+ ("memcached", "memcached"), ("minio", "s3"), ("localstack", "s3"), ("mailhog", "smtp"), ("mailpit", "smtp"),
69
+ ("maildev", "smtp"), ("openldap", "ldap"), ("nats", "nats"), ("kafka", "kafka"), ("mosquitto", "mqtt"),
70
+ ("clickhouse", "clickhouse"), ("cassandra", "cassandra")]
71
+ LLM_KEYS = {"openai": ("OPENAI_API_KEY",), "azure-openai": ("AZURE_OPENAI_API_KEY", "AZURE_OPENAI_KEY"),
72
+ "anthropic": ("ANTHROPIC_API_KEY",), "ollama": (), "openai-agents": ("OPENAI_API_KEY",)}
73
+ LOOPBACK = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal", ""}
74
+ ENV_FILES = (".env.example", ".env.sample", ".env.dist", ".env.template", ".env.defaults", "example.env", "env.example")
75
+
76
+
77
+ # ------------------------------------------------------------------ DSN / URL parsing
78
+ def parse_dsn(url: str) -> dict | None:
79
+ """`postgres://user:pw@db:5432/app?sslmode=require`, `jdbc:mysql://h/db`, `rediss://:pw@cache:6380/0` ... ->
80
+ {protocol, scheme, host, port, resource, user, has_password, tls}; None when it is not a known scheme or has no
81
+ host. The password is never returned."""
82
+ if not url or "://" not in url:
83
+ return None
84
+ u = url.strip()
85
+ if u.lower().startswith("jdbc:"):
86
+ u = u[5:]
87
+ scheme = u.split("://", 1)[0].lower()
88
+ base = scheme.split("+")[0] if scheme not in SCHEMES else scheme
89
+ proto = SCHEMES.get(scheme) or SCHEMES.get(base) or ("https" if base in ("http", "https") else None)
90
+ if proto is None:
91
+ return None
92
+ try:
93
+ p = urlsplit(u)
94
+ host = (p.hostname or "").lower()
95
+ port = p.port
96
+ except ValueError:
97
+ return None
98
+ if not host or "{" in host or "$" in host:
99
+ return None
100
+ tls = True if scheme in TLS_SCHEMES or re.search(r"ssl(mode)?=(require|verify|true|1)|tls=true", p.query or "", re.I) \
101
+ else False if scheme in PLAIN_SCHEMES else None
102
+ res = unquote(p.path.lstrip("/")) or None
103
+ return {"protocol": proto, "scheme": scheme, "host": host, "port": port or SCHEME_PORTS.get(scheme) or PORTS.get(proto), "resource": res,
104
+ "user": unquote(p.username) if p.username else None, "has_password": bool(p.password), "tls": tls}
105
+
106
+
107
+ def target_of(host: str, port) -> str:
108
+ return f"{host}:{port}" if port else host
109
+
110
+
111
+ # ------------------------------------------------------------------ project files
112
+ def read_env_example(root: Path) -> dict:
113
+ """Non-secret values of the example env files at the root: {KEY: (value, file:line)}; secret keys keep only
114
+ their location (value None); a DSN password becomes `***`."""
115
+ out = {}
116
+ for name in ENV_FILES:
117
+ f = root / name
118
+ if not f.is_file():
119
+ continue
120
+ try:
121
+ lines = f.read_text(errors="replace").splitlines()
122
+ except OSError:
123
+ continue
124
+ for i, ln in enumerate(lines, 1):
125
+ m = re.match(r"\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$", ln)
126
+ if not m or m.group(1) in out:
127
+ continue
128
+ k, v = m.group(1), m.group(2).strip()
129
+ if v[:1] in "\"'" and v[-1:] == v[:1]:
130
+ v = v[1:-1]
131
+ elif " #" in v:
132
+ v = v.split(" #", 1)[0].strip()
133
+ if "://" in v: # DSN password -> placeholder (presence kept, value dropped)
134
+ v = re.sub(r"(://[^:/@\s]*):[^@/\s]+@", r"\1:***@", v)
135
+ out[k] = (None if _secret(k) else v, f"{name}:{i}")
136
+ return out
137
+
138
+
139
+ def read_compose(root: Path) -> dict:
140
+ """docker-compose services with a known image: {service: {protocol, image, port, file}}."""
141
+ out = {}
142
+ files = sorted({*root.glob("docker-compose*.y*ml"), *root.glob("compose*.y*ml"), *root.glob("docker/docker-compose*.y*ml")})
143
+ if not files:
144
+ return out
145
+ try:
146
+ import yaml
147
+ except ImportError:
148
+ return out
149
+ for f in files:
150
+ try:
151
+ d = yaml.safe_load(f.read_text(errors="replace")) or {}
152
+ except Exception: # noqa: BLE001
153
+ continue
154
+ for svc, spec in ((d.get("services") or {}) if isinstance(d, dict) else {}).items():
155
+ if not isinstance(spec, dict):
156
+ continue
157
+ img = str(spec.get("image") or "")
158
+ name = img.rsplit("/", 1)[-1].split(":", 1)[0].lower() or str(svc).lower()
159
+ proto = next((p for k, p in IMAGES if k in name), None)
160
+ if proto is None:
161
+ continue
162
+ out[str(svc)] = {"protocol": proto, "image": img or None, "port": PORTS.get(proto),
163
+ "file": str(f.relative_to(root))}
164
+ for alias in ((spec.get("container_name"),) if spec.get("container_name") else ()):
165
+ out.setdefault(str(alias), out[str(svc)])
166
+ return out
167
+
168
+
169
+ def _secret(key: str) -> bool:
170
+ k = key.upper()
171
+ return any(k.endswith("_" + s) or k == s for s in SECRET) and not k.endswith(("_KEY_ID", "_PUBLIC_KEY"))
172
+
173
+
174
+ def _split(key: str):
175
+ """DB_HOST -> ('DB', 'HOST'); DATABASE_URL -> ('DATABASE', 'URL'); REDIS_CACHE_HOST -> ('REDIS_CACHE', 'HOST')."""
176
+ k = key.upper()
177
+ for pre, proto in sorted(PREFIXES, key=lambda x: -len(x[0])):
178
+ if k == pre or k.startswith(pre + "_"):
179
+ rest = k[len(pre) + 1:]
180
+ parts = rest.split("_") if rest else []
181
+ if not parts:
182
+ return None
183
+ role = "_".join(parts[-2:]) if "_".join(parts[-2:]) in SECRET else parts[-1]
184
+ group = "_".join([pre, *parts[:-len(role.split("_"))]])
185
+ return pre, proto, group, role
186
+ return None
187
+
188
+
189
+ SQL_DEPS = {"pg": "postgres", "postgres": "postgres", "pg-promise": "postgres", "@neondatabase/serverless": "postgres",
190
+ "mysql": "mysql", "mysql2": "mysql", "mariadb": "mysql", "mssql": "mssql", "tedious": "mssql",
191
+ "oracledb": "oracle", "psycopg": "postgres", "psycopg2": "postgres", "psycopg2-binary": "postgres",
192
+ "asyncpg": "postgres", "mysqlclient": "mysql", "pymysql": "mysql", "aiomysql": "mysql", "pyodbc": "mssql",
193
+ "cx_oracle": "oracle"}
194
+
195
+
196
+ def dep_sql_protocol(root: Path, compose: dict | None = None) -> str | None:
197
+ """The one SQL protocol a project's dependencies (package.json, requirements*.txt, pyproject.toml) or compose
198
+ services point to, for `DB_*` / `DATABASE_URL` keys without a driver or URL scheme; None when none or several."""
199
+ found = set()
200
+ pj = root / "package.json"
201
+ if pj.is_file():
202
+ try:
203
+ d = json.loads(pj.read_text(errors="replace"))
204
+ for sec in ("dependencies", "devDependencies", "optionalDependencies"):
205
+ found |= {SQL_DEPS[k.lower()] for k in (d.get(sec) or {}) if k.lower() in SQL_DEPS}
206
+ except (OSError, ValueError, AttributeError):
207
+ pass
208
+ for f in [*root.glob("requirements*.txt"), root / "pyproject.toml"]:
209
+ if f.is_file():
210
+ try:
211
+ txt = f.read_text(errors="replace").lower()
212
+ except OSError:
213
+ continue
214
+ found |= {v for k, v in SQL_DEPS.items() if re.search(rf"(^|[\s\"'\[])({re.escape(k)})\s*([<>=~!\[;\"',]|$)", txt, re.M)}
215
+ if not found and compose:
216
+ found = {c["protocol"] for c in compose.values() if c.get("protocol") in ("postgres", "mysql", "mssql", "oracle")}
217
+ return found.pop() if len(found) == 1 else None
218
+
219
+
220
+ # ------------------------------------------------------------------ index-time pass
221
+ def attach(builder, root: Path) -> dict:
222
+ root = Path(root)
223
+ envs = {n.id[len("env:"):]: n for n in builder.nodes.values() if n.kind == "env"}
224
+ conns = [n for n in builder.nodes.values() if n.kind == "connection"]
225
+ facts = getattr(builder, "external_facts", None) or []
226
+ # model calls made from test code are fixtures, not systems the application talks to (as for third-party HTTP)
227
+ llm = [n for n in builder.nodes.values() if (n.attrs or {}).get("llm_calls") and not n.attrs.get("test")]
228
+ if not envs and not conns and not facts and not llm:
229
+ return {}
230
+ ex = read_env_example(root)
231
+ compose = read_compose(root)
232
+ readers: dict = {}
233
+ for e in builder.edges.values():
234
+ if e.kind == "READS_ENV":
235
+ readers.setdefault(e.dst, []).append(e)
236
+ st = {"systems": 0, "connects": 0, "from_connections": 0, "compose_services": len(compose)}
237
+
238
+ def val(key):
239
+ v = ex.get(key)
240
+ return v[0] if v else None
241
+
242
+ def resolve(proto, host_key=None, port_key=None, url_key=None, host_val=None, port_val=None, src_hint=None):
243
+ """(target, attrs, confidence) for an address."""
244
+ a = {}
245
+ if url_key:
246
+ d = parse_dsn(val(url_key) or "")
247
+ if d:
248
+ proto = proto or d["protocol"]
249
+ a.update({k: d[k] for k in ("scheme", "resource", "user", "tls") if d.get(k) is not None})
250
+ a["credential_in_url"] = d["has_password"] or None
251
+ host, port = d["host"], d["port"]
252
+ src = ("env-example", ex[url_key][1])
253
+ else:
254
+ host, port, src = None, None, None
255
+ else:
256
+ host = (host_val if host_val is not None else val(host_key) if host_key else None)
257
+ pv = port_val if port_val is not None else val(port_key) if port_key else None
258
+ port = int(pv) if pv and str(pv).isdigit() else None
259
+ src = ("env-example", ex[host_key][1]) if host_key and val(host_key) else (src_hint if host_val else None)
260
+ if proto is None:
261
+ return None
262
+ if host and host.lower() in compose:
263
+ c = compose[host.lower()]
264
+ a.update({"deployment_name": host.lower(), "image": c["image"], "compose_file": c["file"]})
265
+ port = port or c["port"]
266
+ return target_of(host.lower(), port or PORTS.get(proto)), {**a, "address_source": "compose",
267
+ "address_at": src[1] if src else c["file"]}, "resolved", proto
268
+ if host and host.lower() not in LOOPBACK and re.match(r"^[A-Za-z0-9.\-]+$", host):
269
+ return target_of(host.lower(), port or PORTS.get(proto)), {**a, "address_source": src[0] if src else "literal",
270
+ "address_at": src[1] if src else None}, \
271
+ "resolved" if src and src[0] == "env-example" else "exact", proto
272
+ key = url_key or host_key
273
+ if key is None:
274
+ return None
275
+ if host:
276
+ a["address_default"] = target_of(host.lower(), port or PORTS.get(proto))
277
+ return f"env:{key}", {**a, "address_source": "env"}, "heuristic", proto
278
+
279
+ def node(proto, target, attrs, conf):
280
+ nid = builder.add_node("external", f"{proto}:{target}", f"{proto}:{target}", fqn=f"{proto}:{target}",
281
+ attrs={"protocol": proto, "target": target, "confidence": conf})
282
+ n = builder.nodes[nid]
283
+ for k, v in attrs.items():
284
+ if v not in (None, "", []):
285
+ n.attrs.setdefault(k, v)
286
+ if ":" in target and not target.startswith(("env:", "config:")):
287
+ h, _, p = target.rpartition(":")
288
+ n.attrs.setdefault("host", h)
289
+ if p.isdigit():
290
+ n.attrs.setdefault("port", int(p))
291
+ if n.attrs.get("tls") is None and int(p) in TLS_PORTS.get(proto, set()):
292
+ n.attrs["tls"] = True
293
+ if nid not in seen:
294
+ seen.add(nid)
295
+ st["systems"] += 1
296
+ return nid
297
+
298
+ seen: set = set()
299
+ used_keys: set = set()
300
+
301
+ def cred(nid, keys):
302
+ for k in keys:
303
+ eid = f"env:{k}"
304
+ if eid in builder.nodes:
305
+ builder.add_edge(nid, eid, "CREDENTIAL_FROM", None, None, "resolved", secret_kind=_cred_kind(k))
306
+ builder.nodes[nid].attrs.setdefault("credential_source", "env")
307
+ builder.nodes[nid].attrs.setdefault("credential_at", f"env:{k}")
308
+
309
+ # ---- Laravel connections (config/database.php) -> external of their driver and host
310
+ cfg = {n.id[len("config:"):]: n for n in builder.nodes.values() if n.kind == "config"}
311
+ default = None
312
+ dn = cfg.get("database.default")
313
+ if dn is not None:
314
+ da = dn.attrs or {}
315
+ envk = (da.get("env") or [None])[0]
316
+ default = (val(envk) if envk else None) or (da.get("env_default") or [None])[0] or da.get("value")
317
+ used = {e.dst for e in builder.edges.values() if e.kind == "USES_CONNECTION"}
318
+ templates = {"sqlite", "mysql", "mariadb", "pgsql", "sqlsrv"}
319
+ for c in conns:
320
+ name = c.id.split(":", 1)[1]
321
+ if name in templates and name != default and c.id not in used:
322
+ continue
323
+ base = f"database.connections.{name}"
324
+
325
+ def leaf(k):
326
+ n = cfg.get(f"{base}.{k}")
327
+ a = (n.attrs or {}) if n else {}
328
+ ek = (a.get("env") or [None])[0]
329
+ return ek, (val(ek) if ek and val(ek) else None) or (a.get("value") if "value" in a else None) or \
330
+ ((a.get("env_default") or [None])[0]), n
331
+ drv = (leaf("driver")[1]) or name
332
+ proto = DRIVERS.get(str(drv).lower(), SCHEMES.get(str(drv).lower()))
333
+ if proto is None:
334
+ continue
335
+ hk, hv, hn = leaf("host")
336
+ pk, pv, _ = leaf("port")
337
+ uk, uv, _ = leaf("url")
338
+ r = None
339
+ if uk and val(uk):
340
+ r = resolve(proto, url_key=uk)
341
+ if r is None:
342
+ if hk:
343
+ r = resolve(proto, host_key=hk, port_key=pk) or None
344
+ if r and r[0].startswith("env:") and hv:
345
+ pass
346
+ elif hv:
347
+ r = resolve(proto, host_val=str(hv), port_val=pv, src_hint=("config", f"config:{base}.host"))
348
+ if r is None:
349
+ continue
350
+ target, attrs, conf, proto = r
351
+ nid = node(proto, target, {**attrs, "library": "laravel", "connection": name}, conf)
352
+ builder.add_edge(c.id, nid, "CONNECTS_TO", c.file, c.line, conf, op="query", via=f"connection {name}")
353
+ if hn is not None:
354
+ builder.add_edge(nid, hn.id, "CONFIGURED_BY", None, None, conf)
355
+ if hk:
356
+ used_keys.add(hk)
357
+ if f"env:{hk}" in builder.nodes:
358
+ builder.add_edge(nid, f"env:{hk}", "CONFIGURED_BY", None, None, conf)
359
+ for k in ("password", "username"):
360
+ ck, _cv, cn = leaf(k)
361
+ if k == "password" and ck:
362
+ cred(nid, [ck])
363
+ used_keys.add(ck)
364
+ elif k == "password" and cn is not None and (cn.attrs or {}).get("value"):
365
+ builder.nodes[nid].attrs["credential_source"] = "literal"
366
+ builder.nodes[nid].attrs["credential_at"] = cn.id
367
+ st["from_connections"] += 1
368
+
369
+ # ---- facts from language plugins (Python settings dicts / URLs)
370
+ defaults_at: set = set()
371
+
372
+ def code_default(key, default, f):
373
+ """`os.environ.get("K", "db")`: the in-code default stands in for a missing .env.example value."""
374
+ if default and key not in ex and not _secret(key):
375
+ at = f"{f['file']}:{f['line']}"
376
+ ex[key] = (default, at)
377
+ defaults_at.add(at)
378
+
379
+ for f in facts:
380
+ u = f.get("url")
381
+ r = None
382
+ if u is not None:
383
+ if u[0] == "lit":
384
+ d = parse_dsn(u[1])
385
+ if d and f.get("client") and (d["host"] or "").lower() in LOOPBACK:
386
+ continue # redis.from_url("redis://localhost"): no system
387
+ if d:
388
+ # an HTTP endpoint of a client that speaks another protocol over it (boto3 S3 endpoint_url)
389
+ pr = f["protocol"] if f.get("client") and d["protocol"] in ("http", "https") and f["protocol"] else d["protocol"]
390
+ r = (target_of(d["host"], d["port"]), {"address_source": "literal", "address_at": f"{f['file']}:{f['line']}",
391
+ **{k: d[k] for k in ("scheme", "resource", "user", "tls") if d.get(k) is not None}}, "exact", pr)
392
+ if d["has_password"]:
393
+ r[1].update(credential_source="literal", credential_at=f"{f['file']}:{f['line']}")
394
+ elif f.get("host_only") and f["protocol"]:
395
+ r = resolve(f["protocol"], host_val=u[1], src_hint=("literal", f"{f['file']}:{f['line']}"))
396
+ else:
397
+ code_default(u[1], u[2], f)
398
+ r = resolve(f["protocol"], url_key=u[1]) if not f.get("host_only") else resolve(f["protocol"], host_key=u[1])
399
+ if r is None and f["protocol"]:
400
+ r = (f"env:{u[1]}", {"address_source": "env"}, "heuristic", f["protocol"])
401
+ else:
402
+ h, p = f.get("host"), f.get("port")
403
+ pv = p[1] if p and p[0] == "lit" else (p[2] if p and p[0] == "env" else None)
404
+ proto = f["protocol"] or (dep_sql_protocol(root, compose) if f["var"].startswith("DATABASE") else None) or "sql"
405
+ if h[0] == "lit":
406
+ r = resolve(proto, host_val=h[1], port_val=pv, src_hint=("literal", f"{f['file']}:{f['line']}"))
407
+ if f.get("client") and (r is None or r[0].startswith("env:")):
408
+ continue # a client pointed at the local machine: no system
409
+ if r is None or r[0].startswith("env:"):
410
+ r = (f"config:{f['module']}.{f['var']}", {"address_source": "config", "address_at": f"{f['file']}:{f['line']}",
411
+ "address_default": target_of(h[1] or "localhost", pv or PORTS.get(proto))}, "heuristic", proto)
412
+ elif h[0] == "env":
413
+ code_default(h[1], h[2], f)
414
+ r = resolve(proto, host_key=h[1], port_val=pv)
415
+ if r is None:
416
+ continue
417
+ target, attrs, conf, proto = r
418
+ if attrs.get("address_source") == "env-example" and attrs.get("address_at") in defaults_at:
419
+ attrs = {**attrs, "address_source": "code-default"}
420
+ if f.get("client"): # a client constructor in code (#77)
421
+ nid = node(proto, target, {**attrs, "client": f["var"], "library": f["client"], "resource": f.get("resource")}, conf)
422
+ builder.add_edge(f["src"], nid, "CONNECTS_TO", f["file"], f["line"], conf, op="connect", via=f"client {f['var']}")
423
+ else:
424
+ nid = node(proto, target, {**attrs, "setting": f["var"], "resource": f.get("resource")}, conf)
425
+ builder.add_edge(f["src"], nid, "CONNECTS_TO", f["file"], f["line"], conf, op="configure", via=f"setting {f['var']}")
426
+ st["connects"] += 1
427
+ for kk in ([u[1]] if u is not None and u[0] == "env" else []) + ([f["host"][1]] if f.get("host") and f["host"][0] == "env" else []):
428
+ if f"env:{kk}" in builder.nodes:
429
+ builder.add_edge(nid, f"env:{kk}", "CONFIGURED_BY", None, None, conf)
430
+ used_keys.add(kk)
431
+ pw = f.get("password")
432
+ if pw and pw[0] == "env":
433
+ cred(nid, [pw[1]])
434
+ builder.nodes[nid].attrs.setdefault("credential_source", "env")
435
+ builder.nodes[nid].attrs.setdefault("credential_at", f"env:{pw[1]}")
436
+ used_keys.add(pw[1])
437
+ elif pw:
438
+ builder.nodes[nid].attrs.update(credential_source="literal", credential_at=pw[1])
439
+
440
+ # ---- model calls (#66 attrs.llm_calls) -> external:llm:<provider>, the API-key env var as the credential (#77)
441
+ for n in llm:
442
+ for c in n.attrs["llm_calls"]:
443
+ prov = c.get("provider")
444
+ if not prov:
445
+ continue
446
+ base = parse_dsn(c["base_url"]) if c.get("base_url") else None
447
+ host = base["host"] if base else None
448
+ if host and host.lower() not in LOOPBACK:
449
+ target, a = target_of(host.lower(), base["port"]), {"address_source": "literal", "tls": base.get("tls")}
450
+ else:
451
+ target, a = prov, {"address_source": "provider", **({"address_default": target_of(host, base["port"])} if host else {})}
452
+ nid = node("llm", target, {**a, "provider": prov, "address_at": f"{n.file}:{c.get('line')}"}, "exact" if host else "resolved")
453
+ models = builder.nodes[nid].attrs.setdefault("models", [])
454
+ if c.get("model") and c["model"] not in models:
455
+ models.append(c["model"])
456
+ builder.add_edge(n.id, nid, "CONNECTS_TO", n.file, c.get("line"), "resolved", op=c.get("op") or "model call",
457
+ via=f"model {c['model']}" if c.get("model") else f"{prov} client")
458
+ st["connects"] += 1
459
+ cred(nid, [k for k in LLM_KEYS.get(prov, ()) if k in envs])
460
+ used_keys.update(k for k in LLM_KEYS.get(prov, ()) if k in envs)
461
+
462
+ # ---- env keys read by code, grouped by prefix
463
+ groups: dict = {}
464
+ for key in set(envs) | {k for k in ex if _split(k)}:
465
+ sp = _split(key)
466
+ if sp:
467
+ pre, proto, group, role = sp
468
+ groups.setdefault(group, {"proto": proto, "pre": pre, "keys": {}})["keys"][role] = key
469
+ for group, g in sorted(groups.items()):
470
+ ks = g["keys"]
471
+ addr = [ks[r] for r in ("URL", "URI", "DSN") if r in ks] or [ks[r] for r in ADDRESS[3:] if r in ks]
472
+ addr = [k for k in addr if k not in used_keys]
473
+ if not addr:
474
+ continue
475
+ akey = addr[0]
476
+ if f"env:{akey}" not in builder.nodes: # nothing reads it: an example file entry only
477
+ continue
478
+ proto = g["proto"]
479
+ if g["pre"] in ("DB", "DATABASE") and proto is None:
480
+ conn = val("DB_CONNECTION") or val("DATABASE_CONNECTION")
481
+ proto = DRIVERS.get((conn or "").lower(), SCHEMES.get((conn or "").lower()))
482
+ if proto is None and not conn:
483
+ proto = dep_sql_protocol(root, compose)
484
+ g["dep"] = proto is not None
485
+ if g["pre"] == "MAIL" and (val("MAIL_MAILER") or val("MAIL_DRIVER") or "smtp").lower() != "smtp":
486
+ continue
487
+ is_url = akey.endswith(("_URL", "_URI", "_DSN"))
488
+ r = resolve(proto, url_key=akey) if is_url else resolve(proto, host_key=akey, port_key=ks.get("PORT"))
489
+ if r is None and is_url and proto:
490
+ r = (f"env:{akey}", {"address_source": "env"}, "heuristic", proto)
491
+ if r is None:
492
+ continue
493
+ target, attrs, conf, proto = r
494
+ if g.get("dep"):
495
+ attrs = {**attrs, "protocol_source": "dependencies"}
496
+ nid = node(proto, target, attrs, conf)
497
+ builder.add_edge(nid, f"env:{akey}", "CONFIGURED_BY", None, None, conf)
498
+ if attrs.get("credential_in_url"):
499
+ builder.add_edge(nid, f"env:{akey}", "CREDENTIAL_FROM", None, None, "resolved", secret_kind="url password")
500
+ builder.nodes[nid].attrs.setdefault("credential_source", "env")
501
+ builder.nodes[nid].attrs.setdefault("credential_at", f"env:{akey}")
502
+ cred(nid, [ks[r] for r in ks if r in SECRET or r.endswith(tuple(SECRET))])
503
+ for e in readers.get(f"env:{akey}", []):
504
+ src = builder.nodes.get(e.src)
505
+ if src is None or src.kind in ("config", "env"):
506
+ continue
507
+ builder.add_edge(e.src, nid, "CONNECTS_TO", e.file, e.line, "heuristic" if conf == "heuristic" else "resolved",
508
+ op="connect", via=f"env {akey}")
509
+ st["connects"] += 1
510
+ return {k: v for k, v in st.items() if v} if st["systems"] else {}
511
+
512
+
513
+ def _cred_kind(key: str) -> str:
514
+ k = key.upper()
515
+ return "token" if "TOKEN" in k else "key" if k.endswith("KEY") else "password"
516
+
517
+
518
+ # ------------------------------------------------------------------ query: cg external / MCP external_systems
519
+ def external(st, pattern: str | None = None, protocol: str | None = None, source: str | None = None,
520
+ tls_off: bool = False, max_items: int = 200) -> dict:
521
+ import fnmatch
522
+ rows = list(st.q("SELECT id, name, attrs FROM nodes WHERE kind='external'"))
523
+ systems = []
524
+ for r in rows:
525
+ a = json.loads(r["attrs"] or "{}")
526
+ systems.append({"id": r["id"], "protocol": a.get("protocol"), "target": a.get("target"), "attrs": a})
527
+ # third-party HTTP origins: an adapter over http nodes (no extra nodes)
528
+ https: dict = {}
529
+ for r in st.q("SELECT id, attrs FROM nodes WHERE kind='http' AND attrs LIKE '%\"origin_kind\": \"other\"%'"):
530
+ a = json.loads(r["attrs"] or "{}")
531
+ o = a.get("origin") or ""
532
+ d = parse_dsn(o) if "://" in o else None
533
+ if not d or a.get("test_only"):
534
+ continue
535
+ kinds = {x["kind"] for x in st.q("SELECT kind FROM edges WHERE dst=?", (r["id"],))}
536
+ if kinds and all(k.startswith("TEST_") for k in kinds): # only tests call it (fixtures, mocks)
537
+ continue
538
+ key = target_of(d["host"], d["port"])
539
+ h = https.setdefault(key, {"id": f"external:https:{key}", "protocol": "https", "target": key,
540
+ "attrs": {"protocol": "https", "host": d["host"], "port": d["port"], "tls": d["tls"],
541
+ "address_source": "literal", "adapter": "http"}, "http": []})
542
+ h["http"].append(r["id"])
543
+ systems += list(https.values())
544
+ out = []
545
+ for s in sorted(systems, key=lambda x: x["id"]):
546
+ a = s["attrs"]
547
+ if protocol and s["protocol"] != protocol:
548
+ continue
549
+ if source and a.get("address_source") != source and a.get("credential_source") != source:
550
+ continue
551
+ if tls_off and a.get("tls") is not False:
552
+ continue
553
+ if pattern and not (fnmatch.fnmatchcase(s["id"], pattern) or pattern.lower() in s["id"].lower()):
554
+ continue
555
+ if s.get("http"):
556
+ users = [{"id": h, "via": "http", "op": "request"} for h in s["http"]]
557
+ ids = []
558
+ for h in s["http"]:
559
+ ids += [x["src"] for x in st.q("SELECT src FROM edges WHERE dst=? AND kind IN ('CALLS_API','SENDS_TO','CALLS')", (h,))]
560
+ else:
561
+ users = [{"id": x["src"], "op": json.loads(x["attrs"] or "{}").get("op"), "via": json.loads(x["attrs"] or "{}").get("via"),
562
+ "at": f"{x['file']}:{x['line']}" if x["file"] else None, "confidence": x["confidence"]}
563
+ for x in st.q("SELECT src, attrs, file, line, confidence FROM edges WHERE dst=? AND kind IN ('CONNECTS_TO','TEST_USES')", (s["id"],))]
564
+ ids = [u["id"] for u in users]
565
+ callers = set(ids)
566
+ for x in list(ids):
567
+ if x.startswith("connection:"):
568
+ callers |= {y["src"] for y in st.q("SELECT src FROM edges WHERE dst=? AND kind='USES_CONNECTION'", (x,))}
569
+ entries = {}
570
+ for c in callers:
571
+ for e in st.q("SELECT entry_kind, entry_count FROM node_entry WHERE node_id=?", (c,)):
572
+ entries[e["entry_kind"]] = entries.get(e["entry_kind"], 0) + e["entry_count"]
573
+ conf = [(x["dst"], x["kind"]) for x in st.q("SELECT dst, kind FROM edges WHERE src=? AND kind IN ('CONFIGURED_BY','CREDENTIAL_FROM')", (s["id"],))]
574
+ out.append({**s, "users": users, "callers": len(callers), "entry_kinds": entries,
575
+ "configured_by": sorted({k for k, v in conf if v == "CONFIGURED_BY"}),
576
+ "credential_from": sorted({k for k, v in conf if v == "CREDENTIAL_FROM"})})
577
+ summ: dict = {}
578
+ for s in out:
579
+ summ[s["protocol"]] = summ.get(s["protocol"], 0) + 1
580
+ return {"summary": dict(sorted(summ.items())), "systems": out[:max_items], "selected": len(out),
581
+ "filters": {"pattern": pattern, "protocol": protocol, "source": source, "tls_off": tls_off}}
582
+
583
+
584
+ def render_external(res: dict, max_items: int = 60) -> str:
585
+ if not res["systems"]:
586
+ return "no external systems found (see docs/external.md for what is recognised)"
587
+ L = [f"external systems: {res['selected']} (" + ", ".join(f"{k} {v}" for k, v in res["summary"].items()) + ")", ""]
588
+ for s in res["systems"][:max_items]:
589
+ a = s["attrs"]
590
+ bits = [f"address {a.get('address_source')}" + (f" @ {a['address_at']}" if a.get("address_at") else "")]
591
+ if a.get("address_default"):
592
+ bits.append(f"default {a['address_default']}")
593
+ if a.get("deployment_name"):
594
+ bits.append(f"compose service {a['deployment_name']}" + (f" ({a['image']})" if a.get("image") else ""))
595
+ if a.get("credential_source"):
596
+ bits.append(f"credentials {a['credential_source']}" + (f" @ {a['credential_at']}" if a.get("credential_at") else ""))
597
+ if a.get("tls") is not None:
598
+ bits.append("tls" if a["tls"] else "plaintext")
599
+ if a.get("resource"):
600
+ bits.append(f"resource {a['resource']}")
601
+ L.append(f"{s['id']} [{a.get('confidence') or 'exact'}]")
602
+ L.append(" " + "; ".join(bits))
603
+ if s["entry_kinds"]:
604
+ L.append(" reached from " + ", ".join(f"{k}({v})" for k, v in sorted(s["entry_kinds"].items())))
605
+ for u in s["users"][:8]:
606
+ L.append(f" <- {u['id']}" + (f" @ {u['at']}" if u.get("at") else "") + (f" ({u['via']})" if u.get("via") else ""))
607
+ if len(s["users"]) > 8:
608
+ L.append(f" ... {len(s['users']) - 8} more users")
609
+ if s["configured_by"]:
610
+ L.append(" configured by " + ", ".join(s["configured_by"][:6]))
611
+ if res["selected"] > max_items:
612
+ L.append(f"... {res['selected'] - max_items} more")
613
+ return "\n".join(L)