graph-indexer 3.0.0 → 3.1.0

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 (60) hide show
  1. package/CHANGELOG.md +456 -0
  2. package/README.md +72 -15
  3. package/SECURITY.md +21 -4
  4. package/bin/graph-indexer.mjs +46 -5
  5. package/integrations/claude-code/.claude-plugin/plugin.json +3 -2
  6. package/integrations/claude-code/agents/code-structure.md +0 -3
  7. package/integrations/claude-code/skills/setup/SKILL.md +26 -0
  8. package/integrations/claude-code-local/.claude-plugin/plugin.json +9 -0
  9. package/integrations/{claude-code → claude-code-local}/.mcp.json +1 -1
  10. package/integrations/claude-code-local/README.md +10 -0
  11. package/integrations/claude-code-local/agents/code-structure.md +22 -0
  12. package/integrations/{claude-code → claude-code-local}/hooks/gi-hook.mjs +12 -6
  13. package/integrations/opencode/graph-indexer.js +13 -8
  14. package/package.json +2 -2
  15. package/src/cli/helper.mjs +15 -1
  16. package/src/cli/hook.mjs +140 -48
  17. package/src/cli/init.mjs +49 -17
  18. package/src/cli/resident.mjs +15 -5
  19. package/src/index/discover.mjs +3 -5
  20. package/src/index/indexer.mjs +77 -41
  21. package/src/index/modules.mjs +82 -7
  22. package/src/index/parse-worker.mjs +12 -0
  23. package/src/index/parsepool.mjs +73 -0
  24. package/src/index/resolver.mjs +371 -25
  25. package/src/mcp/server.mjs +16 -12
  26. package/src/mcp/tools.mjs +419 -26
  27. package/src/parse/arity.mjs +41 -3
  28. package/src/parse/extract.mjs +277 -46
  29. package/src/parse/lang/ecma.mjs +2 -0
  30. package/src/parse/lang/go.mjs +2 -0
  31. package/src/parse/lang/jvm.mjs +33 -0
  32. package/src/parse/lang/markdown.mjs +92 -0
  33. package/src/parse/lang/python.mjs +43 -0
  34. package/src/parse/lang/rust.mjs +215 -15
  35. package/src/parse/lang/sfc.mjs +125 -0
  36. package/src/parse/languages.mjs +9 -2
  37. package/src/query/check.mjs +55 -17
  38. package/src/query/facts.mjs +3 -2
  39. package/src/query/injection-spring.mjs +405 -0
  40. package/src/query/injection.mjs +173 -0
  41. package/src/query/intel.mjs +87 -22
  42. package/src/query/issue.mjs +156 -0
  43. package/src/query/orm.mjs +286 -0
  44. package/src/query/read.mjs +6 -1
  45. package/src/query/report.mjs +53 -0
  46. package/src/query/routes.mjs +142 -0
  47. package/src/query/showcase.mjs +59 -0
  48. package/src/query/testcmd.mjs +25 -14
  49. package/src/query/textsearch.mjs +29 -11
  50. package/src/search/search.mjs +85 -17
  51. package/src/search/semantic.mjs +201 -0
  52. package/src/search/vectors.mjs +122 -0
  53. package/src/store/db.mjs +22 -4
  54. package/src/util/git.mjs +30 -0
  55. package/src/util/paths.mjs +40 -1
  56. package/src/util/regex.mjs +2 -0
  57. package/vendor/grammars/README.md +47 -0
  58. package/vendor/grammars/kotlin.wasm.gz +0 -0
  59. package/vendor/grammars/manifest.json +4 -4
  60. /package/integrations/{claude-code → claude-code-local}/hooks/hooks.json +0 -0
@@ -0,0 +1,286 @@
1
+ /**
2
+ * ORM models, their tables, the migrations that touch them and, for Prisma, the client calls that
3
+ * use them: links the compiler does not make, since a migration names its table in a string and
4
+ * Prisma's client is generated outside the repository (`prisma.envelope.findFirst(…)` is typed
5
+ * only by `node_modules/.prisma`).
6
+ *
7
+ * Models recognised:
8
+ * - Prisma: `model Name { … }` in `*.prisma` files, table `@@map("…")` or the model's name;
9
+ * - Django: classes deriving from `models.Model` (directly or through other models, abstract
10
+ * ones left out), table `Meta.db_table` or `<app_label>_<name>`, the app label from `Meta`
11
+ * (inherited from an abstract base) or the app's directory; a proxy model has its base's table;
12
+ * - SQLAlchemy / SQLModel: classes with `__tablename__`, or `table=True` (SQLModel names the
13
+ * table after the class, lower-cased);
14
+ * - TypeORM: `@Entity('name')` / `@Entity({ name, schema })`, or `@Entity()` (the class name in
15
+ * snake_case, TypeORM's default naming).
16
+ * Migrations: files under a directory whose name holds `migration` (or Alembic's `versions`), and
17
+ * code that drives TypeORM's `queryRunner` elsewhere (upgrade commands). A table is touched by SQL
18
+ * that names it (`CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`, `RENAME TO`, `INSERT INTO`, `UPDATE`,
19
+ * `DELETE FROM`, an index `ON` it, a foreign key `REFERENCES` it, a read `FROM` / `JOIN` it), by
20
+ * Alembic's `op.*('table', …)` and `batch_alter_table`, by TypeORM's `queryRunner.*Table` /
21
+ * `*Column`(…'table'…); a Django model by its app's `migrations.CreateModel(name=…)` and
22
+ * `model_name=…` operations and by data migrations' `apps.get_model('app', 'Model')`.
23
+ * Django models whose base comes from a library (auth's `AbstractUser`, treebeard, mptt,
24
+ * polymorphic) are recognised by name.
25
+ * Prisma client calls: `<client>.<model>.<operation>(` where the client is named `prisma…`, `db`,
26
+ * `tx`/`trx`, `client` or `…Prisma`, and the operation is one of Prisma's model methods.
27
+ * Test files are left out.
28
+ */
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { spawnSync } from 'node:child_process';
32
+
33
+ const PRISMA_OPS = 'findUnique|findUniqueOrThrow|findFirst|findFirstOrThrow|findMany|create|createMany|createManyAndReturn|update|updateMany|updateManyAndReturn|upsert|delete|deleteMany|count|aggregate|groupBy';
34
+ const PRISMA_CALL = new RegExp(`\\b(prisma\\w*|\\w*Prisma|db|tx|trx|client|transaction)\\s*\\.\\s*([a-z]\\w*)\\s*\\.\\s*(${PRISMA_OPS})\\s*[(<]`, 'g');
35
+ const MIGRATION_DIR = /(^|\/)([^/]*migrations?[^/]*|versions)\//i;
36
+ const SQL_TABLE = /\b(CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?|ALTER\s+TABLE(?:\s+IF\s+EXISTS)?(?:\s+ONLY)?|DROP\s+TABLE(?:\s+IF\s+EXISTS)?|INSERT\s+INTO|UPDATE|DELETE\s+FROM|TRUNCATE(?:\s+TABLE)?|RENAME\s+TO|REFERENCES|FROM|JOIN|ON)\s+((?:[`"[]?\w+[`"\]]?\s*\.\s*)?[`"[]?\w+[`"\]]?)/gi;
37
+ const ALEMBIC = /\bop\.(create_primary_key|create_table|drop_table|add_column|drop_column|alter_column|rename_table|create_index|drop_index|create_foreign_key|create_unique_constraint|create_check_constraint|drop_constraint|bulk_insert|batch_alter_table)\s*\(/g;
38
+ const TYPEORM_RUNNER = /\bqueryRunner\.(createTable|dropTable|renameTable|addColumn|addColumns|dropColumn|dropColumns|changeColumn|renameColumn|createIndex|dropIndex|createForeignKey|createForeignKeys|dropForeignKey|createUniqueConstraint|dropUniqueConstraint|createPrimaryKey|clearTable)\s*\(\s*['"`]((?:\w+\.)?\w+)['"`]/g;
39
+ const DJANGO_OP = /\bmigrations\.(\w+)\s*\(([^)]*?)\b(name|model_name)\s*=\s*['"](\w+)['"]/g;
40
+
41
+ const EXTERNAL_MODEL_BASES = /^(?:\w+\.)*(AbstractUser|AbstractBaseUser|MP_Node|NS_Node|AL_Node|MPTTModel|PolymorphicModel|TimeStampedModel)$/;
42
+ const snake = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2').toLowerCase();
43
+ const unquote = (s) => s.replace(/[`"[\]\s]/g, '');
44
+ const decorators = (s) => { try { return JSON.parse(s.decorators || '[]'); } catch { return []; } };
45
+
46
+ /** Repository files matching a test, indexed or not (`*.prisma`, migration `.sql`): git's list, else a bounded walk. */
47
+ function repoFiles(root, test) {
48
+ const r = spawnSync('git', ['-C', root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { encoding: 'utf8', timeout: 5000, maxBuffer: 64 << 20 });
49
+ if (r.status === 0 && r.stdout) return r.stdout.split('\0').filter(f => f && test(f));
50
+ const out = [];
51
+ const walk = (dir, depth) => {
52
+ if (depth > 8 || out.length > 5000) return;
53
+ let ents = [];
54
+ try { ents = fs.readdirSync(path.join(root, dir), { withFileTypes: true }); } catch { return; }
55
+ for (const e of ents) {
56
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
57
+ const rel = dir ? `${dir}/${e.name}` : e.name;
58
+ if (e.isDirectory()) walk(rel, depth + 1); else if (test(rel)) out.push(rel);
59
+ }
60
+ };
61
+ walk('', 0);
62
+ return out;
63
+ }
64
+
65
+ function readLines(intel, rel) {
66
+ const l = intel.fileLines(rel);
67
+ if (l) return l;
68
+ try { return fs.readFileSync(path.join(intel.root, rel), 'utf8').split(/\r?\n/); } catch { return null; }
69
+ }
70
+
71
+ /** Prisma models of every `.prisma` file. */
72
+ function prismaModels(intel) {
73
+ const out = [];
74
+ for (const rel of repoFiles(intel.root, f => f.endsWith('.prisma') && !f.includes('node_modules/'))) {
75
+ const lines = readLines(intel, rel) ?? [];
76
+ let cur = null;
77
+ lines.forEach((l, i) => {
78
+ const m = /^\s*model\s+(\w+)\s*\{/.exec(l);
79
+ if (m) { cur = { orm: 'Prisma', name: m[1], sym: null, table: m[1], file: rel, line: i + 1, end: i + 1 }; out.push(cur); return; }
80
+ if (!cur) return;
81
+ const map = /^\s*@@map\(\s*(?:name\s*:\s*)?["']([^"']+)["']/.exec(l);
82
+ if (map) cur.table = map[1];
83
+ if (/^\s*\}/.test(l)) { cur.end = i + 1; cur = null; }
84
+ });
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** The text of a class's body (its own lines), for `class Meta`, `__tablename__`. */
90
+ const bodyOf = (intel, s) => (intel.fileLines(s.path) ?? []).slice(s.start_line - 1, s.end_line).join('\n');
91
+
92
+ /** Django, SQLAlchemy/SQLModel and TypeORM models among the indexed classes. */
93
+ function classModels(intel) {
94
+ const out = [];
95
+ const classes = intel.store.all(`SELECT s.id, s.name, s.qname, s.kind, s.start_line, s.end_line, s.decorators, s.bases, s.sig, s.parent_id, f.path, f.lang
96
+ FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.kind = 'class' AND f.is_test = 0 AND f.lang IN ('python', 'typescript', 'tsx', 'javascript')`);
97
+ const byId = new Map(classes.map(c => [c.id, c]));
98
+ const basesOf = new Map();
99
+ for (const r of intel.store.all(`SELECT src_id, dst_id FROM refs WHERE kind = 'inherit' AND dst_id IS NOT NULL AND src_id IS NOT NULL`)) (basesOf.get(r.src_id) ?? basesOf.set(r.src_id, []).get(r.src_id)).push(r.dst_id);
100
+ const bases = (c) => { try { return JSON.parse(c.bases || '[]'); } catch { return []; } };
101
+ const meta = (c) => {
102
+ const body = bodyOf(intel, c);
103
+ const m = /\n\s*class\s+Meta\b[^:]*:([\s\S]*?)(?=\n\s{0,8}(?:def|class|@)\s|\n\S|$)/.exec(body)?.[1] ?? '';
104
+ return {
105
+ abstract: /\babstract\s*=\s*True\b/.test(m),
106
+ proxy: /\bproxy\s*=\s*True\b/.test(m),
107
+ appLabel: /\bapp_label\s*=\s*['"](\w+)['"]/.exec(m)?.[1] ?? null,
108
+ dbTable: /\bdb_table\s*=\s*['"]([^'"]+)['"]/.exec(m)?.[1] ?? null,
109
+ };
110
+ };
111
+ // Django: a class is a model if a base is `models.Model` or a model, through the index
112
+ const djangoMemo = new Map();
113
+ const isDjango = (c, seen = new Set()) => {
114
+ if (djangoMemo.has(c.id)) return djangoMemo.get(c.id);
115
+ if (seen.has(c.id)) return false;
116
+ seen.add(c.id);
117
+ // a base from django or a library of models (auth's users, treebeard, mptt, polymorphic …)
118
+ let v = c.lang === 'python' && bases(c).some(b => (/^(models\.)?Model$/.test(String(b).trim()) || EXTERNAL_MODEL_BASES.test(String(b).trim())) && /\bmodels\b|django|treebeard|mptt|polymorphic/.test(bodyOrHead(c)));
119
+ if (!v) for (const b of basesOf.get(c.id) ?? []) { const bc = byId.get(b); if (bc && isDjango(bc, seen)) { v = true; break; } }
120
+ djangoMemo.set(c.id, v);
121
+ return v;
122
+ };
123
+ function bodyOrHead(c) {
124
+ // the module imports django's models (`from django.db import models`): read its first lines once
125
+ const lines = intel.fileLines(c.path) ?? [];
126
+ return lines.slice(0, 80).join('\n');
127
+ }
128
+ const inheritedMeta = (c, key, seen = new Set()) => {
129
+ if (seen.has(c.id)) return null;
130
+ seen.add(c.id);
131
+ const v = meta(c)[key];
132
+ if (v) return v;
133
+ for (const b of basesOf.get(c.id) ?? []) { const bc = byId.get(b); const x = bc && inheritedMeta(bc, key, seen); if (x) return x; }
134
+ return null;
135
+ };
136
+ for (const c of classes) {
137
+ if (c.lang === 'python') {
138
+ const body = bodyOf(intel, c);
139
+ const tn = /^\s*__tablename__\s*=\s*['"]([^'"]+)['"]/m.exec(body)?.[1];
140
+ if (tn) { out.push({ orm: 'SQLAlchemy', name: c.name, sym: c, table: tn, file: c.path, line: c.start_line }); continue; }
141
+ if (/\btable\s*=\s*True\b/.test(c.sig ?? body.split('\n')[0])) { out.push({ orm: 'SQLModel', name: c.name, sym: c, table: c.name.toLowerCase(), file: c.path, line: c.start_line }); continue; }
142
+ if (isDjango(c) && !meta(c).abstract) {
143
+ const dir = path.posix.dirname(c.path);
144
+ const appDir = /\/models$/.test(dir) ? path.posix.dirname(dir) : dir;
145
+ const label = inheritedMeta(c, 'appLabel') ?? path.posix.basename(appDir);
146
+ // a proxy model has no table of its own: its concrete base's
147
+ const proxyOf = meta(c).proxy ? (basesOf.get(c.id) ?? []).map(b => byId.get(b)).find(b => b && isDjango(b)) ?? null : null;
148
+ out.push({ orm: 'Django', name: c.name, sym: c, table: inheritedMeta(c, 'dbTable') ?? `${label}_${c.name.toLowerCase()}`, label, proxyOf, appDir, file: c.path, line: c.start_line });
149
+ }
150
+ } else {
151
+ // `@Entity(…)` right above the class (an exported class's decorators are not stored on it)
152
+ const lines = intel.fileLines(c.path) ?? [];
153
+ let top = c.start_line - 1; // the decorators: the lines above the class up to the previous statement
154
+ while (top > 0 && top > c.start_line - 150) {
155
+ const l = lines[top - 1].trim();
156
+ if (/;$/.test(l) || /^(import|export|class|const|let|var|function|interface|type)\b/.test(l) || l === '}') break;
157
+ top--;
158
+ }
159
+ const last = lines.slice(top, c.start_line - 1).join('\n');
160
+ if (!decorators(c).includes('Entity') && !/@Entity\(/.test(last)) continue;
161
+ const head = decorators(c).includes('Entity') ? (intel.fileLines(c.path) ?? []).slice(Math.max(0, c.start_line - 6), c.start_line + 1).join('\n') : last;
162
+ const e = /@Entity\(\s*(?:['"`]([^'"`]+)['"`]|\{([^}]*)\})?/.exec(head);
163
+ const name = e?.[1] ?? /\bname\s*:\s*['"`]([^'"`]+)['"`]/.exec(e?.[2] ?? '')?.[1] ?? null;
164
+ const schema = /\bschema\s*:\s*['"`]([^'"`]+)['"`]/.exec(e?.[2] ?? '')?.[1] ?? null;
165
+ out.push({ orm: 'TypeORM', name: c.name, sym: c, table: name ?? snake(c.name), schema, defaultName: !name, file: c.path, line: c.start_line });
166
+ }
167
+ }
168
+ // proxies take their base's table (resolved through proxies of proxies)
169
+ const byClass = new Map(out.filter(m => m.sym).map(m => [m.sym.id, m]));
170
+ for (const m of out) {
171
+ let base = m.proxyOf, guard = 0;
172
+ while (base && guard++ < 8) {
173
+ const bm = byClass.get(base.id);
174
+ if (bm && !bm.proxyOf) { m.table = bm.table; m.appDir = bm.appDir; m.proxyName = bm.name; break; }
175
+ base = bm?.proxyOf ?? null;
176
+ }
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /** Every table a migration file touches: [{ table (lower case, unqualified), schema, file, line, op }], and Django operations by model. */
182
+ function migrationRefs(intel) {
183
+ const tables = [], django = [];
184
+ const files = repoFiles(intel.root, f => MIGRATION_DIR.test(f) && /\.(sql|py|ts|js|mjs|cjs)$/.test(f) && !f.includes('node_modules/'));
185
+ // schema changes outside a migrations directory (upgrade commands): code that drives TypeORM's queryRunner
186
+ const seen = new Set(files);
187
+ for (const r of intel.store.all(`SELECT DISTINCT f.path FROM refs r JOIN files f ON f.id = r.file_id WHERE r.recv = 'queryRunner' AND f.is_test = 0`)) if (!seen.has(r.path)) { seen.add(r.path); files.push(r.path); }
188
+ for (const rel of files) {
189
+ const lines = readLines(intel, rel);
190
+ if (!lines) continue;
191
+ const text = lines.join('\n');
192
+ const lineAt = (off) => text.slice(0, off).split('\n').length;
193
+ const isPy = /\.py$/.test(rel);
194
+ const add = (raw, off, op) => {
195
+ const parts = unquote(raw).split('.');
196
+ const table = parts.pop();
197
+ if (!table || /^(SET|SELECT|TABLE|IF|ONLY|EXISTS|DELETE|UPDATE|NOT|CONFLICT|LATERAL|DUAL|INSERT|VALUES|WHERE)$/i.test(table)) return;
198
+ tables.push({ table: table.toLowerCase(), schema: parts.pop() ?? null, file: rel, line: lineAt(off), op });
199
+ };
200
+ for (const m of text.matchAll(SQL_TABLE)) {
201
+ const kw = m[1].replace(/\s+/g, ' ').toUpperCase();
202
+ // `ON` is an index's table only after CREATE [UNIQUE] INDEX …
203
+ if (kw === 'ON' && !/CREATE\s+(UNIQUE\s+)?INDEX\b[^;]*$/i.test(text.slice(Math.max(0, m.index - 300), m.index))) continue;
204
+ // `UPDATE t [AS] alias SET`: a statement, not the word in prose or a column name
205
+ if (kw === 'UPDATE' && !/^\s*(?:(?:AS\s+)?\w+\s+)?SET\b/i.test(text.slice(m.index + m[0].length, m.index + m[0].length + 60))) continue;
206
+ // reads (`FROM`, `JOIN`) in SQL only: in Python, `from x import y` is no table
207
+ if ((kw === 'FROM' || kw === 'JOIN') && isPy && !/^["`]/.test(m[2]) && !/(execute|RunSQL|text)\s*\(/.test(text.slice(text.lastIndexOf('\n', m.index) + 1, m.index))) continue;
208
+ add(m[2], m.index, kw.split(' ').slice(0, 2).join(' '));
209
+ }
210
+ for (const m of text.matchAll(ALEMBIC)) {
211
+ const args = text.slice(m.index + m[0].length, m.index + m[0].length + 400);
212
+ // the table: the first string argument (the second for create_index / create_foreign_key …), or table_name=
213
+ const named = /\btable_name\s*=\s*['"](\w+)['"]/.exec(args.split(')')[0] ?? '')?.[1];
214
+ const strs = [...args.matchAll(/['"](\w+)['"]/g)].map(x => x[1]);
215
+ const viaF = /^\s*op\.f\(/.test(args);
216
+ let table = named;
217
+ if (!table) table = /^(create_index|create_unique_constraint|create_check_constraint|create_primary_key|drop_constraint|drop_index)$/.test(m[1]) || viaF ? strs[1] : strs[0];
218
+ if (m[1] === 'create_foreign_key') {
219
+ // (name, source, referent, …); the name may be None
220
+ const [src, ref] = /^\s*None\b/.test(args) ? [strs[0], strs[1]] : [strs[1], strs[2]];
221
+ if (src) tables.push({ table: src.toLowerCase(), schema: null, file: rel, line: lineAt(m.index), op: m[1] });
222
+ if (ref) tables.push({ table: ref.toLowerCase(), schema: null, file: rel, line: lineAt(m.index), op: `${m[1]} (referenced)` });
223
+ continue;
224
+ }
225
+ if (table) tables.push({ table: table.toLowerCase(), schema: null, file: rel, line: lineAt(m.index), op: m[1] });
226
+ }
227
+ for (const m of text.matchAll(TYPEORM_RUNNER)) add(m[2], m.index, m[1]);
228
+ if (isPy) {
229
+ for (const m of text.matchAll(DJANGO_OP)) django.push({ model: m[4].toLowerCase(), dir: path.posix.dirname(rel), file: rel, line: lineAt(m.index), op: m[1] });
230
+ // data migrations: `apps.get_model("catalogue", "Option")`
231
+ for (const m of text.matchAll(/\bget_model\(\s*['"](\w+)['"]\s*,\s*['"](\w+)['"]/g)) django.push({ model: m[2].toLowerCase(), label: m[1], file: rel, line: lineAt(m.index), op: 'get_model (data migration)' });
232
+ }
233
+ }
234
+ return { tables, django };
235
+ }
236
+
237
+ /** Prisma client calls in the indexed TypeScript/JavaScript: [{ model (lower-first), op, file, line }]. */
238
+ function prismaCalls(intel, models) {
239
+ const names = new Set(models.map(m => m.name[0].toLowerCase() + m.name.slice(1)));
240
+ const out = [];
241
+ for (const f of intel.store.all(`SELECT id, path FROM files WHERE lang IN ('typescript', 'tsx', 'javascript', 'vue', 'svelte') AND is_test = 0`)) {
242
+ const lines = intel.fileLines(f.path);
243
+ if (!lines) continue;
244
+ lines.forEach((l, i) => {
245
+ if (!l.includes('.')) return;
246
+ for (const m of l.matchAll(PRISMA_CALL)) if (names.has(m[2])) out.push({ model: m[2], op: m[3], fileId: f.id, file: f.path, line: i + 1, client: m[1] });
247
+ });
248
+ }
249
+ return out;
250
+ }
251
+
252
+ /**
253
+ * Models with their table, the migrations that touch it and (Prisma) the client calls:
254
+ * [{ orm, name, sym, table, file, line, migrations: [{ file, line, op }], calls: [{ file, line, op, owner }] }].
255
+ */
256
+ export function collectModels(intel) {
257
+ if (intel._orm?.version === intel.ix?.version) return intel._orm.data;
258
+ const prisma = prismaModels(intel);
259
+ const models = [...prisma, ...classModels(intel)];
260
+ if (!models.length) { intel._orm = { version: intel.ix?.version, data: [] }; return []; }
261
+ const { tables, django } = migrationRefs(intel);
262
+ const byTable = new Map();
263
+ for (const t of tables) (byTable.get(t.table) ?? byTable.set(t.table, []).get(t.table)).push(t);
264
+ const calls = prisma.length ? prismaCalls(intel, prisma) : [];
265
+ for (const m of models) {
266
+ const table = m.table.toLowerCase().split('.').pop();
267
+ m.migrations = (byTable.get(table) ?? []).filter(t => !m.schema || !t.schema || t.schema.toLowerCase() === m.schema.toLowerCase());
268
+ if (m.orm === 'Django') m.migrations.push(...django.filter(d => d.model === (m.proxyName ?? m.name).toLowerCase() && (d.label ? d.label === m.label : d.dir === `${m.appDir}/migrations`)));
269
+ m.migrations.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
270
+ m.calls = m.orm === 'Prisma' ? calls.filter(c => c.model === m.name[0].toLowerCase() + m.name.slice(1)) : [];
271
+ for (const c of m.calls) c.owner ??= intel.store.get(`SELECT qname FROM symbols WHERE file_id = ? AND start_line <= ? AND end_line >= ? AND kind IN ('function', 'method', 'variable', 'constant') ORDER BY start_line DESC LIMIT 1`, c.fileId, c.line, c.line)?.qname ?? null;
272
+ }
273
+ intel._orm = { version: intel.ix?.version, data: models };
274
+ return models;
275
+ }
276
+
277
+ /** The model a class symbol is, if any. */
278
+ export function modelOf(intel, id) {
279
+ return collectModels(intel).find(m => m.sym?.id === id) ?? null;
280
+ }
281
+
282
+ /** Prisma models named so (case-insensitive), for a name that is no indexed symbol. */
283
+ export function prismaModelsNamed(intel, name) {
284
+ const n = String(name ?? '').toLowerCase();
285
+ return collectModels(intel).filter(m => m.orm === 'Prisma' && m.name.toLowerCase() === n);
286
+ }
@@ -14,6 +14,8 @@ const NAMED_KINDS = ['call', 'new', 'inherit', 'type', 'decorator'];
14
14
  // what the reader most often needs next: code it calls and types it uses; data (fields, constants,
15
15
  // enum members) comes after, its one-line definition is usually all there is to it
16
16
  const TARGET_WEIGHT = (kind) => (['field', 'property', 'variable', 'constant'].includes(kind) ? 0.35 : 1);
17
+ // what a call or instantiation can bind to by name
18
+ const CALLABLE_TARGETS = ['function', 'method', 'constructor', 'class', 'struct', 'macro', 'object', 'type'];
17
19
  const DEFINITION_KINDS = ['function', 'method', 'constructor', 'class', 'interface', 'struct', 'trait', 'enum', 'type', 'constant', 'macro', 'object', 'module'];
18
20
 
19
21
  /**
@@ -45,8 +47,9 @@ export function definitionsUsed(intel, file, from, to, { max = 10, skipIds = new
45
47
  const e = bound.get(r.dst_id) ?? bound.set(r.dst_id, { n: 0, score: 0, first: r.line }).get(r.dst_id);
46
48
  e.n++; e.score += w * Math.max(0.3, r.conf ?? 1);
47
49
  } else if (NAMED_KINDS.includes(r.kind)) {
48
- const e = unbound.get(r.name) ?? unbound.set(r.name, { n: 0, score: 0, first: r.line }).get(r.name);
50
+ const e = unbound.get(r.name) ?? unbound.set(r.name, { n: 0, score: 0, first: r.line, called: false }).get(r.name);
49
51
  e.n++; e.score += w * 0.5;
52
+ if (r.kind === 'call' || r.kind === 'new') e.called = true;
50
53
  }
51
54
  }
52
55
  const inView = (s) => s.path === file && s.start_line >= from && s.end_line <= to;
@@ -66,6 +69,8 @@ export function definitionsUsed(intel, file, from, to, { max = 10, skipIds = new
66
69
  if (cands.length !== 1 || seen.has(cands[0].id) || skip.has(cands[0].id)) continue;
67
70
  const s = get.get(cands[0].id);
68
71
  if (!s || inView(s)) continue;
72
+ // `Math.max(…)` is not a call to a constant named `max`
73
+ if (e.called && !CALLABLE_TARGETS.includes(s.kind)) continue;
69
74
  seen.add(s.id);
70
75
  rows.push({ ...s, ...e, score: e.score * TARGET_WEIGHT(s.kind), byName: true });
71
76
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `graph-indexer report`: what a bug report needs about an index, without anything from the code.
3
+ *
4
+ * graph-indexer sends nothing anywhere. When an answer is wrong or slow, this summary — versions,
5
+ * size, languages, how much of each language's references are bound, and latencies — is what the
6
+ * user pastes into an issue. It carries no paths, names or source text, so it is safe to share.
7
+ */
8
+ import os from 'node:os';
9
+ import { callTool } from '../mcp/tools.mjs';
10
+
11
+ const pct = (a, b) => (b ? `${Math.round((1000 * a) / b) / 10}%` : '–');
12
+
13
+ /** Median wall time of `fn` over `n` runs, in milliseconds. */
14
+ async function median(fn, n = 5) {
15
+ const t = [];
16
+ for (let i = 0; i < n; i++) { const s = process.hrtime.bigint(); await fn(); t.push(Number(process.hrtime.bigint() - s) / 1e6); }
17
+ t.sort((a, b) => a - b);
18
+ return Math.round(t[n >> 1] * 10) / 10;
19
+ }
20
+
21
+ export async function reportText(intel, { version }) {
22
+ const st = intel.stats();
23
+ const sync = intel.lastSyncStats ?? {};
24
+ const byLang = intel.store.all(`SELECT f.lang, COUNT(DISTINCT f.id) AS files,
25
+ SUM(CASE WHEN r.kind IN ('call', 'new') THEN 1 ELSE 0 END) AS calls,
26
+ SUM(CASE WHEN r.kind IN ('call', 'new') AND r.dst_id IS NOT NULL THEN 1 ELSE 0 END) AS bound,
27
+ SUM(CASE WHEN r.kind IN ('call', 'new') AND r.conf >= 0.9 THEN 1 ELSE 0 END) AS exact
28
+ FROM files f LEFT JOIN refs r ON r.file_id = f.id GROUP BY f.lang ORDER BY files DESC`);
29
+ const tests = intel.store.get('SELECT COUNT(*) AS n FROM files WHERE is_test = 1')?.n ?? 0;
30
+ // latencies on the most referenced definition: the heaviest ordinary question
31
+ const top = intel.store.get(`SELECT s.id, s.qname FROM symbols s JOIN refs r ON r.dst_id = s.id GROUP BY s.id ORDER BY COUNT(*) DESC LIMIT 1`);
32
+ const lat = [];
33
+ if (top) {
34
+ const target = `${intel.sym(top.id).path}:${top.qname}`;
35
+ lat.push(`find_references ${await median(() => callTool(intel, 'find_references', { symbol: target }))} ms`);
36
+ lat.push(`read_code ${await median(() => callTool(intel, 'read_code', { targets: [target] }))} ms`);
37
+ lat.push(`explore ${await median(() => callTool(intel, 'explore', { symbols: [target] }))} ms`);
38
+ }
39
+ const out = [
40
+ '### graph-indexer report',
41
+ `- graph-indexer ${version}, Node.js ${process.versions.node}, ${os.platform()} ${os.arch()}, ${os.cpus().length} CPUs`,
42
+ `- index: ${st.files} files (${tests} test files), ${st.symbols} symbols, ${st.refs} references — ${pct(st.resolved, st.refs)} bound, ${pct(st.exact, st.refs)} exact`,
43
+ `- last sync: ${sync.added ?? 0} added, ${sync.changed ?? 0} changed, ${sync.removed ?? 0} removed in ${sync.ms ?? '?'} ms`,
44
+ `- warm latency (median of 5, most referenced definition): ${lat.join(', ') || 'no references indexed'}`,
45
+ '',
46
+ '| language | files | calls | bound | exact |',
47
+ '|---|---|---|---|---|',
48
+ ...byLang.map(l => `| ${l.lang} | ${l.files} | ${l.calls ?? 0} | ${pct(l.bound ?? 0, l.calls ?? 0)} | ${pct(l.exact ?? 0, l.calls ?? 0)} |`),
49
+ '',
50
+ 'No paths, names or code are included. For a wrong answer, add the command you ran and what you expected.',
51
+ ];
52
+ return out.join('\n');
53
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * HTTP routes and the code that handles them: the edge no compiler sees and grep finds only in
3
+ * pieces (a decorator here, a prefix on the class or router there, the handler named as a value).
4
+ *
5
+ * Recognised by the shape frameworks give them, from the indexed source:
6
+ * - decorators: FastAPI / Flask (`@app.get("/x")`, `@router.route("/x", methods=[…])`, with the
7
+ * prefix of an `APIRouter(prefix=…)` / `Blueprint(url_prefix=…)` defined in the same file),
8
+ * NestJS (`@Controller('users')` + `@Get(':id')`), Spring (`@RequestMapping` on the class +
9
+ * `@GetMapping("/x")`);
10
+ * - registration calls: Express / Fastify / Koa / Hono (`router.post('/x', auth, handler)`), Gin /
11
+ * Echo / chi (`r.GET("/x", handler)`) and net/http (`http.HandleFunc("/x", handler)`).
12
+ * The handler is the decorated definition, or the last argument of the registration call bound
13
+ * through the index (an inline function is reported by its location).
14
+ */
15
+
16
+ const METHODS = 'get|post|put|delete|patch|head|options';
17
+ const PY_ROUTE = new RegExp(`^\\s*@([\\w.]+)\\.(${METHODS}|route|api_route|websocket)\\(\\s*(?:path\\s*=\\s*|rule\\s*=\\s*)?[rbuf]?["']([^"']*)["']([^\\n]*)`, 'i');
18
+ const PY_PREFIX = /^\s*(\w+)\s*=\s*(?:\w+\.)?(APIRouter|Blueprint)\(([^\n]*)/;
19
+ const NEST_CONTROLLER = /@Controller\(\s*(?:\{[^}]*path:\s*)?['"`]([^'"`]*)['"`]/;
20
+ const NEST_ROUTE = /^\s*@(Get|Post|Put|Delete|Patch|All|Head|Options)\(\s*(?:['"`]([^'"`]*)['"`])?\s*\)/;
21
+ const SPRING_CLASS = /^\s*@RequestMapping\(\s*(?:(?:value|path)\s*=\s*)?\{?\s*"([^"]*)"/;
22
+ const SPRING_ROUTE = /^\s*@(Get|Post|Put|Delete|Patch|Request)Mapping(?:\(\s*(?:(?:value|path)\s*=\s*)?\{?\s*"([^"]*)"([^\n]*))?/;
23
+ const JS_CALL = new RegExp(`\\b([\\w$]+)\\.(${METHODS}|all)\\(\\s*['"\`]([^'"\`]+)['"\`]\\s*,([^\\n]*)`);
24
+ const GO_CALL = /\b(\w+)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|Any|Get|Post|Put|Delete|Patch|HandleFunc|Handle)\(\s*"([^"]+)"\s*,([^\n]*)/;
25
+
26
+ const HANDLER_KINDS = new Set(['function', 'method']);
27
+ const COMMENT = /^\s*(\/\/|\/?\*|#(?!\s*@)|>>>)/;
28
+
29
+ /** `/users/{id}`, `/users/:id`, `/users/<int:id>` → one comparable shape: `/users/*`. */
30
+ export function routeShape(p) {
31
+ return ('/' + String(p).trim().replace(/^\/+/, ''))
32
+ .replace(/\{[^}]*\}|<[^>]*>|:[A-Za-z_]\w*\??|\*\w*/g, '*')
33
+ .replace(/\/+$/, '') || '/';
34
+ }
35
+
36
+ const join = (a, b) => ('/' + [a, b].map(x => String(x ?? '').replace(/^\/+|\/+$/g, '')).filter(Boolean).join('/')) || '/';
37
+
38
+ /** Every route in the repository: { method, path, file, line, handler (symbol row or null), framework }. */
39
+ export function collectRoutes(intel) {
40
+ if (intel._routes?.version === intel.ix?.version) return intel._routes.rows;
41
+ const files = intel.store.all(`SELECT id, path, lang FROM files WHERE is_test = 0 AND lang IN ('python', 'typescript', 'tsx', 'javascript', 'java', 'kotlin', 'go')`);
42
+ const out = [];
43
+ for (const f of files) {
44
+ const lines = intel.fileLines(f.path);
45
+ if (!lines) continue;
46
+ const text = lines.join('\n');
47
+ if (!/(route|get|post|put|delete|patch|Mapping|Controller|HandleFunc|GET|POST)\s*\(/i.test(text)) continue;
48
+ const defs = intel.store.all(`SELECT id, name, qname, kind, start_line, end_line, parent_id, ? AS path FROM symbols WHERE file_id = ? ORDER BY start_line`, f.path, f.id);
49
+ const defAfter = (line, span = 12) => defs.find(d => HANDLER_KINDS.has(d.kind) && d.start_line >= line && d.start_line <= line + span) ?? null;
50
+ // the prefix a class declares in an annotation just above its `class` line (`@Controller('users')`)
51
+ const classPrefix = (line, re) => {
52
+ let decl = line - 1;
53
+ while (decl > 0 && !/\b(class|interface)\s+\w/.test(lines[decl - 1])) decl--;
54
+ if (decl <= 0) return '';
55
+ for (let i = decl; i >= Math.max(1, decl - 8); i--) { const m = re.exec(lines[i - 1]); if (m) return m[1]; }
56
+ return '';
57
+ };
58
+ // an annotation is the class's own when the next line that is not an annotation declares the class
59
+ const onClass = (line) => { let i = line; while (i < lines.length && /^\s*@/.test(lines[i])) i++; return /\b(class|interface)\s+\w/.test(lines[i] ?? ''); };
60
+ // examples in comments and docstrings are not routes
61
+ const add = (r) => { if (!COMMENT.test(lines[r.line - 1] ?? '')) out.push({ ...r, file: f.path, shape: routeShape(r.path) }); };
62
+ if (f.lang === 'python') {
63
+ const prefixes = new Map();
64
+ for (const l of lines) {
65
+ const m = PY_PREFIX.exec(l);
66
+ if (m) prefixes.set(m[1], /(?:url_)?prefix\s*=\s*["']([^"']*)["']/.exec(m[3])?.[1] ?? '');
67
+ }
68
+ lines.forEach((l, i) => {
69
+ const m = PY_ROUTE.exec(l);
70
+ if (!m) return;
71
+ const [, obj, verb, p, rest] = m;
72
+ const owner = obj.split('.').pop();
73
+ const methods = verb.toLowerCase() === 'route' || verb.toLowerCase() === 'api_route'
74
+ ? (/methods\s*=\s*[[(]([^\])]*)/.exec(rest)?.[1].match(/[A-Za-z]+/g)?.map(x => x.toUpperCase()) ?? ['GET'])
75
+ : [verb.toUpperCase() === 'WEBSOCKET' ? 'WS' : verb.toUpperCase()];
76
+ const h = defAfter(i + 1);
77
+ for (const method of methods) add({ method, path: join(prefixes.get(owner) ?? '', p), line: i + 1, handler: h, framework: prefixes.has(owner) ? 'router' : 'app' });
78
+ });
79
+ } else if (f.lang === 'typescript' || f.lang === 'tsx' || f.lang === 'javascript') {
80
+ lines.forEach((l, i) => {
81
+ const n = NEST_ROUTE.exec(l);
82
+ if (n) {
83
+ add({ method: n[1].toUpperCase(), path: join(classPrefix(i + 1, NEST_CONTROLLER), n[2] ?? ''), line: i + 1, handler: defAfter(i + 1, 8), framework: 'nestjs' });
84
+ return;
85
+ }
86
+ const m = JS_CALL.exec(l);
87
+ if (!m || !/^\//.test(m[3])) return;
88
+ add({ method: m[2].toUpperCase() === 'ALL' ? 'ANY' : m[2].toUpperCase(), path: m[3], line: i + 1, handler: handlerArg(intel, f, i + 1, m[4]), framework: 'express' });
89
+ });
90
+ } else if (f.lang === 'java' || f.lang === 'kotlin') {
91
+ lines.forEach((l, i) => {
92
+ const m = SPRING_ROUTE.exec(l);
93
+ if (!m) return;
94
+ // the class-level @RequestMapping is the prefix, not a route
95
+ if (m[1] === 'Request' && onClass(i + 1)) return;
96
+ const prefix = classPrefix(i + 1, SPRING_CLASS);
97
+ const method = m[1] === 'Request' ? (/RequestMethod\.(\w+)/.exec(m[3] ?? '')?.[1] ?? 'ANY') : m[1].toUpperCase();
98
+ add({ method, path: join(prefix, m[2] ?? ''), line: i + 1, handler: defAfter(i + 1, 8), framework: 'spring' });
99
+ });
100
+ } else if (f.lang === 'go') {
101
+ lines.forEach((l, i) => {
102
+ const m = GO_CALL.exec(l);
103
+ if (!m || !m[3].startsWith('/')) return;
104
+ const verb = m[2];
105
+ const method = /^Handle/.test(verb) ? 'ANY' : verb.toUpperCase();
106
+ add({ method, path: m[3], line: i + 1, handler: handlerArg(intel, f, i + 1, m[4]), framework: 'go' });
107
+ });
108
+ }
109
+ }
110
+ intel._routes = { version: intel.ix?.version, rows: out };
111
+ return out;
112
+ }
113
+
114
+ /** The handler of a registration call: its last argument, bound through the index where possible. */
115
+ function handlerArg(intel, f, line, args) {
116
+ const parts = args.split(',').map(x => x.trim()).filter(Boolean);
117
+ const last = (parts[parts.length - 1] ?? '').replace(/\)+;?\s*$/, '').trim();
118
+ const name = /^(?:[\w$]+\.)*([\w$]+)$/.exec(last)?.[1];
119
+ if (!name) return null; // an inline function: the route's own line is the handler
120
+ const r = intel.store.get(`SELECT dst_id FROM refs WHERE file_id = ? AND line = ? AND name = ? AND dst_id IS NOT NULL LIMIT 1`, f.id, line, name);
121
+ const id = r?.dst_id ?? intel.store.get(`SELECT s.id FROM symbols s JOIN files fl ON fl.id = s.file_id WHERE s.name = ? AND s.kind IN ('function', 'method') AND fl.is_test = 0 LIMIT 2`, name)?.id;
122
+ return id != null ? intel.store.get('SELECT s.id, s.name, s.qname, s.kind, s.start_line, s.end_line, s.parent_id, f.path FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.id = ?', id) : null;
123
+ }
124
+
125
+ /** Routes in a query (`POST /api/login`, `/users/{id}`): [{ method | null, shape }]. */
126
+ export function routesInQuery(q) {
127
+ const out = [];
128
+ const re = /(?:\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+)?(\/[\w\-.{}:<>*~/]*[\w}>*])/gi;
129
+ for (let m; (m = re.exec(String(q))); ) if (m[2].length > 1) out.push({ method: m[1]?.toUpperCase() ?? null, shape: routeShape(m[2]) });
130
+ return out;
131
+ }
132
+
133
+ /** Routes matching a query route: same shape (parameters match anything), and method when given. */
134
+ export function matchRoutes(routes, { method, shape }) {
135
+ const parts = shape.split('/');
136
+ const fits = (r) => {
137
+ const rp = r.shape.split('/');
138
+ if (rp.length !== parts.length) return false;
139
+ return rp.every((x, i) => x === '*' || parts[i] === '*' || x.toLowerCase() === parts[i].toLowerCase());
140
+ };
141
+ return routes.filter(r => fits(r) && (!method || r.method === method || r.method === 'ANY'));
142
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The first answer a new user sees, on their own repository: a function or method that shares its
3
+ * name with other definitions, what a text search for that name returns, and the exact uses the
4
+ * index binds to that one definition. The contrast is the point of the tool, shown instead of told.
5
+ */
6
+ import { textSearch } from './textsearch.mjs';
7
+ import { escapeRegExp } from '../util/regex.mjs';
8
+
9
+ const CALLABLE = ['function', 'method'];
10
+
11
+ /**
12
+ * The best example in the repository, or null: a non-test callable with at least 3 bound uses whose
13
+ * name at least one other callable carries, ranked by its uses and how much same-name text surrounds them.
14
+ * @returns {Promise<{ sym: object, defs: number, uses: number, useFiles: number, grepLines: number, grepFiles: number } | null>}
15
+ */
16
+ export async function pickShowcase(intel, { candidates = 8 } = {}) {
17
+ const ph = CALLABLE.map(() => '?').join(',');
18
+ const homonyms = new Map(intel.store.all(`SELECT name, COUNT(*) AS n FROM symbols WHERE kind IN (${ph}) AND length(name) >= 3 GROUP BY name HAVING COUNT(*) >= 2`, ...CALLABLE)
19
+ .map(r => [r.name, r.n]));
20
+ if (!homonyms.size) return null;
21
+ const byName = new Map(intel.store.all(`SELECT name, COUNT(*) AS n FROM refs WHERE kind IN ('call', 'new') GROUP BY name`).map(r => [r.name, r.n]));
22
+ // a class's method or an exported function: what a reader recognises, not a local helper
23
+ const rows = intel.store.all(`SELECT s.id, s.name, s.qname, s.kind, COUNT(r.id) AS uses, COUNT(DISTINCT r.file_id) AS files
24
+ FROM symbols s JOIN files f ON f.id = s.file_id JOIN refs r ON r.dst_id = s.id LEFT JOIN symbols p ON p.id = s.parent_id
25
+ WHERE s.kind IN (${ph}) AND f.is_test = 0 AND r.kind IN ('call', 'new') AND r.conf >= 0.9
26
+ AND (p.kind IN ('class', 'struct', 'interface', 'trait', 'object', 'impl') OR (s.parent_id IS NULL AND s.exported = 1))
27
+ GROUP BY s.id HAVING COUNT(r.id) >= 3`, ...CALLABLE)
28
+ .filter(r => homonyms.has(r.name))
29
+ .map(r => ({ ...r, others: (byName.get(r.name) ?? 0) - r.uses }))
30
+ .filter(r => r.others > 0)
31
+ // most uses drowned in the most same-name noise
32
+ .sort((a, b) => b.uses * Math.log1p(b.others / b.uses) - a.uses * Math.log1p(a.others / a.uses))
33
+ .slice(0, candidates);
34
+ let best = null;
35
+ for (const r of rows) {
36
+ const g = await textSearch(intel, { pattern: `\\b${escapeRegExp(r.name)}\\b`, limit: 1 });
37
+ if (g.error || !g.total) continue;
38
+ const ex = { sym: intel.sym(r.id), defs: homonyms.get(r.name), uses: r.uses, useFiles: r.files, grepLines: g.total, grepFiles: g.files };
39
+ const score = (x) => Math.min(x.uses, 40) * Math.log2(x.grepLines / x.uses);
40
+ if (ex.grepLines >= 2 * ex.uses && (!best || score(ex) > score(best))) best = ex;
41
+ }
42
+ return best;
43
+ }
44
+
45
+ /** The showcase as text, with the command that reproduces it (`cli` names the executable). */
46
+ export async function showcaseText(intel, { cli = 'npx graph-indexer' } = {}) {
47
+ const st = intel.stats();
48
+ const ex = await pickShowcase(intel);
49
+ const head = `Indexed ${st.files} files, ${st.symbols} symbols, ${st.refs} references.`;
50
+ if (!ex) return `${head}\nNo method here shares its name with another one, so there is no grep-versus-index contrast to show; try \`${cli} explore "<a task in words>"\`.`;
51
+ const s = ex.sym;
52
+ return [
53
+ head,
54
+ `Example from this repository: ${s.qname} (${s.path}:${s.start_line}) is one of ${ex.defs} definitions named "${s.name}".`,
55
+ ` grep -w ${s.name}: ${ex.grepLines} lines in ${ex.grepFiles} files to read through`,
56
+ ` graph-indexer: ${ex.uses} calls in ${ex.useFiles} file${ex.useFiles === 1 ? '' : 's'}, bound to this definition (scopes, imports, receiver types)`,
57
+ `See them: ${cli} refs ${s.qname}`,
58
+ ].join('\n');
59
+ }