@abdulmunimjemal/codescope 0.1.0 → 0.3.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.
package/dist/cli.js CHANGED
@@ -1,17 +1,73 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { mkdirSync } from "fs";
4
+ import { mkdirSync as mkdirSync2 } from "fs";
5
5
  import { resolve as resolve2 } from "path";
6
6
  import pc from "picocolors";
7
7
 
8
+ // src/affected.ts
9
+ var TEST_PATTERNS = [
10
+ /(^|\/)(tests?|spec|specs|__tests__|e2e|integration)\//i,
11
+ /\.(test|spec)\.[a-z]+$/i,
12
+ // foo.test.ts, foo.spec.js
13
+ /_test\.[a-z]+$/i,
14
+ // foo_test.go, foo_test.py, foo_test.rs
15
+ /(^|\/)test_[^/]+\.(py|rb)$/i,
16
+ // test_foo.py
17
+ /(Test|Tests|Spec)\.[a-z]+$/i,
18
+ // FooTest.java, FooTests.cs, FooSpec.scala
19
+ /_spec\.rb$/i
20
+ // foo_spec.rb
21
+ ];
22
+ function isTestFile(path) {
23
+ return TEST_PATTERNS.some((re) => re.test(path));
24
+ }
25
+ function moduleBasename(path) {
26
+ const base = path.slice(path.lastIndexOf("/") + 1);
27
+ const dot = base.indexOf(".");
28
+ return dot === -1 ? base : base.slice(0, dot);
29
+ }
30
+ function affected(store, changedPaths, opts = {}) {
31
+ const depth = opts.depth ?? 4;
32
+ const impactedFiles = new Set(changedPaths);
33
+ for (const path of changedPaths) {
34
+ for (const sym of store.fileOutline(path)) {
35
+ for (const caller of store.impact(sym.name, { depth })) {
36
+ impactedFiles.add(caller.file);
37
+ }
38
+ }
39
+ }
40
+ let frontier = [...changedPaths];
41
+ for (let d = 0; d < depth && frontier.length > 0; d++) {
42
+ const next = [];
43
+ for (const path of frontier) {
44
+ for (const importer of store.findImporters(moduleBasename(path))) {
45
+ if (!impactedFiles.has(importer)) {
46
+ impactedFiles.add(importer);
47
+ next.push(importer);
48
+ }
49
+ }
50
+ }
51
+ frontier = next;
52
+ }
53
+ return {
54
+ changed: changedPaths,
55
+ impactedFiles: [...impactedFiles].sort(),
56
+ tests: [...impactedFiles].filter(isTestFile).sort()
57
+ };
58
+ }
59
+
8
60
  // src/format.ts
61
+ function shortSig(sig) {
62
+ if (!sig) return "";
63
+ const s = sig.length > 88 ? `${sig.slice(0, 87)}\u2026` : sig;
64
+ return ` \xB7 ${s}`;
65
+ }
9
66
  function symbolLine(s) {
10
67
  const loc = `${s.file}:${s.startRow + 1}`;
11
68
  const container = s.container ? `${s.container}.` : "";
12
69
  const exp = s.exported ? "export " : "";
13
- const sig = s.signature ? ` \xB7 ${s.signature}` : "";
14
- return `${s.kind} ${exp}${container}${s.name} \u2014 ${loc}${sig}`;
70
+ return `${s.kind} ${exp}${container}${s.name} \u2014 ${loc}${shortSig(s.signature)}`;
15
71
  }
16
72
  function formatSymbols(rows) {
17
73
  if (rows.length === 0) return "No matching symbols.";
@@ -20,8 +76,8 @@ function formatSymbols(rows) {
20
76
  function formatRefs(rows) {
21
77
  if (rows.length === 0) return "No references.";
22
78
  return rows.map((r) => {
23
- const where = r.fromSymbol ? `${r.fromSymbol}` : "(top level)";
24
- return `${where} \u2192 ${r.name} [${r.kind}] \u2014 ${r.file}:${r.startRow + 1}`;
79
+ const where = r.fromSymbol ?? "(top level)";
80
+ return `${r.file}:${r.startRow + 1} ${where}`;
25
81
  }).join("\n");
26
82
  }
27
83
  function formatNeighborhood(n) {
@@ -38,6 +94,32 @@ function formatNeighborhood(n) {
38
94
  }
39
95
  return lines.join("\n");
40
96
  }
97
+ function formatImpact(rows) {
98
+ if (rows.length === 0) return "No callers \u2014 changing this is low-risk.";
99
+ return rows.map((r) => `[${r.distance} hop] ${symbolLine(r)}`).join("\n");
100
+ }
101
+ function formatContext(b) {
102
+ const lines = [`context for "${b.query}":`, "", "matches:"];
103
+ if (b.seeds.length === 0) lines.push(" (no symbols matched)");
104
+ else for (const s of b.seeds) lines.push(` ${symbolLine(s)}`);
105
+ if (b.related.length > 0) {
106
+ lines.push("", "related (ranked by call-site centrality):");
107
+ for (const s of b.related) lines.push(` ${symbolLine(s)}`);
108
+ }
109
+ if (b.edges.length > 0) {
110
+ lines.push("", "call edges:");
111
+ for (const e of b.edges) lines.push(` ${e.from} \u2192 ${e.to}`);
112
+ }
113
+ return lines.join("\n");
114
+ }
115
+ function formatAffected(r) {
116
+ if (r.tests.length === 0) {
117
+ return `No test files appear affected by ${r.changed.length} changed file(s).`;
118
+ }
119
+ const lines = [`${r.tests.length} test file(s) affected by your changes:`, ""];
120
+ for (const t of r.tests) lines.push(` ${t}`);
121
+ return lines.join("\n");
122
+ }
41
123
  function formatStats(s) {
42
124
  const kinds = Object.entries(s.byKind).sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k}=${n}`).join(" ");
43
125
  const langs = Object.entries(s.byLang).sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k}=${n}`).join(" ");
@@ -268,6 +350,118 @@ var php = {
268
350
  importRules: [{ type: "namespace_use_declaration", childTypes: ["namespace_use_clause"] }],
269
351
  exportTypes: /* @__PURE__ */ new Set()
270
352
  };
353
+ var scala = {
354
+ id: "scala",
355
+ wasm: "scala",
356
+ defs: {
357
+ class_definition: { kind: "class" },
358
+ object_definition: { kind: "class" },
359
+ trait_definition: { kind: "interface" },
360
+ function_definition: { kind: "method" },
361
+ type_definition: { kind: "type" }
362
+ },
363
+ functionBindings: /* @__PURE__ */ new Set(),
364
+ nestedFunctionsAreMethods: false,
365
+ callRules: [
366
+ { type: "call_expression", fnField: "function", memberTypes: ["field_expression"], memberField: "field" }
367
+ ],
368
+ importRules: [{ type: "import_declaration", childTypes: ["stable_identifier", "identifier"] }],
369
+ exportTypes: /* @__PURE__ */ new Set()
370
+ };
371
+ var solidity = {
372
+ id: "solidity",
373
+ wasm: "solidity",
374
+ defs: {
375
+ contract_declaration: { kind: "class" },
376
+ interface_declaration: { kind: "interface" },
377
+ library_declaration: { kind: "class" },
378
+ function_definition: { kind: "method" },
379
+ modifier_definition: { kind: "function" },
380
+ struct_declaration: { kind: "class" },
381
+ enum_declaration: { kind: "enum" }
382
+ },
383
+ functionBindings: /* @__PURE__ */ new Set(),
384
+ nestedFunctionsAreMethods: false,
385
+ callRules: [{ type: "call_expression", fnField: "function" }],
386
+ importRules: [{ type: "import_directive", field: "source" }],
387
+ exportTypes: /* @__PURE__ */ new Set()
388
+ };
389
+ var zig = {
390
+ id: "zig",
391
+ wasm: "zig",
392
+ defs: { function_declaration: { kind: "function" } },
393
+ functionBindings: /* @__PURE__ */ new Set(),
394
+ nestedFunctionsAreMethods: false,
395
+ callRules: [{ type: "call_expression", fnField: "function" }],
396
+ importRules: [],
397
+ exportTypes: /* @__PURE__ */ new Set()
398
+ };
399
+ var kotlin = {
400
+ id: "kotlin",
401
+ wasm: "kotlin",
402
+ defs: {
403
+ class_declaration: { kind: "class", name: "first_typed", nameTypes: ["type_identifier"] },
404
+ object_declaration: { kind: "class", name: "first_typed", nameTypes: ["type_identifier"] },
405
+ function_declaration: { kind: "function", name: "first_typed", nameTypes: ["simple_identifier"] }
406
+ },
407
+ functionBindings: /* @__PURE__ */ new Set(),
408
+ nestedFunctionsAreMethods: true,
409
+ callRules: [],
410
+ importRules: [{ type: "import_header", childTypes: ["identifier"] }],
411
+ exportTypes: /* @__PURE__ */ new Set()
412
+ };
413
+ var objc = {
414
+ id: "objc",
415
+ wasm: "objc",
416
+ defs: {
417
+ class_interface: { kind: "class", name: "first_typed", nameTypes: ["identifier"] },
418
+ class_implementation: { kind: "class", name: "first_typed", nameTypes: ["identifier"] },
419
+ method_declaration: { kind: "method", name: "first_typed", nameTypes: ["identifier"] },
420
+ method_definition: { kind: "method", name: "first_typed", nameTypes: ["identifier"] }
421
+ },
422
+ functionBindings: /* @__PURE__ */ new Set(),
423
+ nestedFunctionsAreMethods: false,
424
+ callRules: [{ type: "call_expression", fnField: "function" }],
425
+ importRules: [{ type: "preproc_include", field: "path" }],
426
+ exportTypes: /* @__PURE__ */ new Set()
427
+ };
428
+ var lua = {
429
+ id: "lua",
430
+ wasm: "lua",
431
+ defs: {
432
+ function_definition_statement: { kind: "function" },
433
+ local_function_definition_statement: { kind: "function" }
434
+ },
435
+ functionBindings: /* @__PURE__ */ new Set(),
436
+ nestedFunctionsAreMethods: false,
437
+ callRules: [],
438
+ importRules: [],
439
+ exportTypes: /* @__PURE__ */ new Set()
440
+ };
441
+ var bash = {
442
+ id: "bash",
443
+ wasm: "bash",
444
+ defs: { function_definition: { kind: "function" } },
445
+ functionBindings: /* @__PURE__ */ new Set(),
446
+ nestedFunctionsAreMethods: false,
447
+ callRules: [],
448
+ importRules: [],
449
+ exportTypes: /* @__PURE__ */ new Set()
450
+ };
451
+ var ocaml = {
452
+ id: "ocaml",
453
+ wasm: "ocaml",
454
+ defs: {
455
+ let_binding: { kind: "function", name: "field", nameField: "pattern" },
456
+ module_definition: { kind: "class" },
457
+ type_definition: { kind: "type" }
458
+ },
459
+ functionBindings: /* @__PURE__ */ new Set(),
460
+ nestedFunctionsAreMethods: false,
461
+ callRules: [],
462
+ importRules: [],
463
+ exportTypes: /* @__PURE__ */ new Set()
464
+ };
271
465
  var LANGUAGES = {
272
466
  typescript,
273
467
  tsx,
@@ -280,7 +474,15 @@ var LANGUAGES = {
280
474
  c,
281
475
  cpp,
282
476
  csharp,
283
- php
477
+ php,
478
+ scala,
479
+ solidity,
480
+ zig,
481
+ kotlin,
482
+ objc,
483
+ lua,
484
+ bash,
485
+ ocaml
284
486
  };
285
487
  var EXT_TO_LANG = {
286
488
  ".ts": "typescript",
@@ -305,7 +507,19 @@ var EXT_TO_LANG = {
305
507
  ".hpp": "cpp",
306
508
  ".hh": "cpp",
307
509
  ".cs": "csharp",
308
- ".php": "php"
510
+ ".php": "php",
511
+ ".scala": "scala",
512
+ ".sc": "scala",
513
+ ".sol": "solidity",
514
+ ".zig": "zig",
515
+ ".kt": "kotlin",
516
+ ".kts": "kotlin",
517
+ ".m": "objc",
518
+ ".lua": "lua",
519
+ ".sh": "bash",
520
+ ".bash": "bash",
521
+ ".ml": "ocaml",
522
+ ".mli": "ocaml"
309
523
  };
310
524
  var SUPPORTED_EXTENSIONS = Object.keys(EXT_TO_LANG);
311
525
  function languageForPath(path) {
@@ -391,7 +605,7 @@ function classify(node, lang) {
391
605
  return null;
392
606
  }
393
607
  function buildSymbol(node, rule, container, containerKind, lang) {
394
- const name = symbolName(node, rule.name ?? "field");
608
+ const name = symbolName(node, rule);
395
609
  if (!name) return null;
396
610
  let kind = rule.kind;
397
611
  if (kind === "function" && lang.nestedFunctionsAreMethods && containerKind === "class") {
@@ -411,9 +625,20 @@ function buildSymbol(node, rule, container, containerKind, lang) {
411
625
  endByte: node.endIndex
412
626
  };
413
627
  }
414
- function symbolName(node, strategy) {
415
- if (strategy === "c_declarator") return cDeclaratorName(node);
416
- return node.childForFieldName("name")?.text ?? null;
628
+ function symbolName(node, rule) {
629
+ switch (rule.name ?? "field") {
630
+ case "c_declarator":
631
+ return cDeclaratorName(node);
632
+ case "first_typed": {
633
+ const types = rule.nameTypes ?? [];
634
+ for (const child of node.namedChildren) {
635
+ if (types.includes(child.type)) return child.text;
636
+ }
637
+ return null;
638
+ }
639
+ default:
640
+ return node.childForFieldName(rule.nameField ?? "name")?.text ?? null;
641
+ }
417
642
  }
418
643
  function cDeclaratorName(node) {
419
644
  let decl = node.childForFieldName("declarator");
@@ -629,13 +854,65 @@ function errorMessage(err) {
629
854
  return err instanceof Error ? err.message : String(err);
630
855
  }
631
856
 
857
+ // src/install.ts
858
+ import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
859
+ import { homedir } from "os";
860
+ import { dirname, join } from "path";
861
+ var PACKAGE = "@abdulmunimjemal/codescope";
862
+ var MCP_SERVER_NAME = "codescope";
863
+ var SUPPORTED_AGENTS = ["claude", "cursor"];
864
+ function serverEntry(serveTarget = ".") {
865
+ return { command: "npx", args: ["-y", PACKAGE, "mcp", serveTarget] };
866
+ }
867
+ function configPath(agent, root, global = false) {
868
+ switch (agent) {
869
+ case "claude":
870
+ return global ? join(homedir(), ".mcp.json") : join(root, ".mcp.json");
871
+ case "cursor":
872
+ return global ? join(homedir(), ".cursor", "mcp.json") : join(root, ".cursor", "mcp.json");
873
+ }
874
+ }
875
+ function readJson(path) {
876
+ if (!existsSync(path)) return {};
877
+ try {
878
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
879
+ return parsed && typeof parsed === "object" ? parsed : {};
880
+ } catch {
881
+ return {};
882
+ }
883
+ }
884
+ function installInto(agent, root, opts = {}) {
885
+ const path = configPath(agent, root, opts.global ?? false);
886
+ const config = readJson(path);
887
+ const existing = config.mcpServers;
888
+ const servers = existing && typeof existing === "object" ? existing : {};
889
+ const existed = Object.prototype.hasOwnProperty.call(servers, MCP_SERVER_NAME);
890
+ servers[MCP_SERVER_NAME] = serverEntry(opts.serveTarget);
891
+ config.mcpServers = servers;
892
+ mkdirSync(dirname(path), { recursive: true });
893
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}
894
+ `);
895
+ return { agent, path, action: existed ? "updated" : "added" };
896
+ }
897
+ function install(root, opts = {}) {
898
+ const agents = opts.agents ?? [...SUPPORTED_AGENTS];
899
+ return agents.map((agent) => installInto(agent, root, opts));
900
+ }
901
+ function codexSnippet(serveTarget = ".") {
902
+ return [
903
+ "[mcp_servers.codescope]",
904
+ 'command = "npx"',
905
+ `args = ["-y", "${PACKAGE}", "mcp", "${serveTarget}"]`
906
+ ].join("\n");
907
+ }
908
+
632
909
  // src/mcp.ts
633
910
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
634
911
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
635
912
  import { z } from "zod";
636
913
 
637
914
  // src/version.ts
638
- var VERSION = "0.1.0";
915
+ var VERSION = "0.3.0";
639
916
 
640
917
  // src/mcp.ts
641
918
  var KIND = z.enum(["function", "method", "class", "interface", "type", "enum", "variable"]);
@@ -681,6 +958,43 @@ function createServer(store) {
681
958
  },
682
959
  async ({ name, limit }) => textResult(formatRefs(store.findCallers(name, { limit })))
683
960
  );
961
+ server.registerTool(
962
+ "find_callees",
963
+ {
964
+ title: "Find callees",
965
+ description: "List the functions/methods that a given symbol calls (resolved to their definitions). The outgoing side of the call graph \u2014 what this symbol depends on.",
966
+ inputSchema: {
967
+ name: z.string().describe("the calling function/method name"),
968
+ limit: z.number().int().positive().max(500).optional()
969
+ }
970
+ },
971
+ async ({ name, limit }) => textResult(formatSymbols(store.findCallees(name, { limit })))
972
+ );
973
+ server.registerTool(
974
+ "impact",
975
+ {
976
+ title: "Change impact / blast radius",
977
+ description: "Show the transitive callers of a symbol \u2014 everything that could be affected if you change it \u2014 annotated with hop distance. Use before editing a widely-used function.",
978
+ inputSchema: {
979
+ name: z.string(),
980
+ depth: z.number().int().min(1).max(6).optional().describe("hops to expand (default 3)"),
981
+ limit: z.number().int().positive().max(300).optional()
982
+ }
983
+ },
984
+ async ({ name, depth, limit }) => textResult(formatImpact(store.impact(name, { depth, limit })))
985
+ );
986
+ server.registerTool(
987
+ "context",
988
+ {
989
+ title: "Build task context",
990
+ description: "Given a task or feature query, return a compact, ranked relevance map: the matching symbols plus their immediate call neighbourhood, ordered by how widely each is called. The fastest way to orient an agent before a change \u2014 graph facts instead of reading files.",
991
+ inputSchema: {
992
+ query: z.string().describe("a task description or symbol/feature name"),
993
+ maxSymbols: z.number().int().min(5).max(100).optional().describe("cap on symbols (default 30)")
994
+ }
995
+ },
996
+ async ({ query, maxSymbols }) => textResult(formatContext(store.context(query, { maxSymbols })))
997
+ );
684
998
  server.registerTool(
685
999
  "find_references",
686
1000
  {
@@ -718,6 +1032,18 @@ function createServer(store) {
718
1032
  },
719
1033
  async ({ name, depth, limit }) => textResult(formatNeighborhood(store.neighborhood(name, { depth, limit })))
720
1034
  );
1035
+ server.registerTool(
1036
+ "affected",
1037
+ {
1038
+ title: "Affected tests",
1039
+ description: "Given a list of changed files, return the test files likely affected \u2014 the symbols those files define, walked through their transitive callers, filtered to tests. Use before running a suite to know what's worth re-running.",
1040
+ inputSchema: {
1041
+ files: z.array(z.string()).describe("repo-relative paths of the changed files"),
1042
+ depth: z.number().int().min(1).max(6).optional()
1043
+ }
1044
+ },
1045
+ async ({ files, depth }) => textResult(formatAffected(affected(store, files, { depth })))
1046
+ );
721
1047
  server.registerTool(
722
1048
  "stats",
723
1049
  {
@@ -911,15 +1237,119 @@ var GraphStore = class {
911
1237
  WHERE s.name = ? ORDER BY s.exported DESC, f.path LIMIT ?`
912
1238
  ).all(name, clampLimit(opts.limit)).map(toSymbolRow);
913
1239
  }
914
- /** Symbols that call a given name (both bare `foo()` and `x.foo()`). */
1240
+ /**
1241
+ * Distinct callers of a name (both bare `foo()` and `x.foo()`). Multiple call
1242
+ * sites from the same caller in the same file collapse to one row — fewer
1243
+ * tokens and a more useful "who depends on this" answer.
1244
+ */
915
1245
  findCallers(name, opts = {}) {
916
1246
  return this.db.prepare(
917
- `SELECT r.id, f.path AS file, r.from_symbol, r.name, r.kind, r.start_row, r.start_col
1247
+ `SELECT MIN(r.id) AS id, f.path AS file, r.from_symbol, r.name,
1248
+ MIN(r.kind) AS kind, MIN(r.start_row) AS start_row, MIN(r.start_col) AS start_col
918
1249
  FROM refs r JOIN files f ON f.id = r.file_id
919
1250
  WHERE r.name = ? AND r.kind IN ('call', 'method')
920
- ORDER BY f.path, r.start_row LIMIT ?`
1251
+ GROUP BY f.path, r.from_symbol
1252
+ ORDER BY f.path, start_row LIMIT ?`
921
1253
  ).all(name, clampLimit(opts.limit)).map(toRefRow);
922
1254
  }
1255
+ /** The definitions that a symbol calls, resolved kind-aware to project symbols. */
1256
+ findCallees(name, opts = {}) {
1257
+ const limit = clampLimit(opts.limit);
1258
+ const out = [];
1259
+ const seen = /* @__PURE__ */ new Set();
1260
+ for (const callee of this.calleesOf(name)) {
1261
+ const defs = this.resolveCallee(callee.name, callee.kind, 6);
1262
+ if (!defs) continue;
1263
+ for (const d of defs) {
1264
+ const key = `${d.name}@${d.file}:${d.startRow}`;
1265
+ if (!seen.has(key)) {
1266
+ seen.add(key);
1267
+ out.push(d);
1268
+ if (out.length >= limit) return out;
1269
+ }
1270
+ }
1271
+ }
1272
+ return out;
1273
+ }
1274
+ /** How many call sites reference this name (popularity / centrality signal). */
1275
+ callerCount(name) {
1276
+ return this.db.prepare(
1277
+ "SELECT COUNT(*) AS n FROM refs WHERE name = ? AND kind IN ('call','method')"
1278
+ ).get(name)?.n ?? 0;
1279
+ }
1280
+ /**
1281
+ * The blast radius of changing a symbol: its transitive callers, breadth-first,
1282
+ * annotated with hop distance and ordered nearest-first. Answers "what could
1283
+ * break if I change this?" without reading the codebase.
1284
+ */
1285
+ impact(name, opts = {}) {
1286
+ const depth = Math.max(1, Math.min(opts.depth ?? 3, 6));
1287
+ const limit = clampLimit(opts.limit, 300);
1288
+ const distance = /* @__PURE__ */ new Map([[name, 0]]);
1289
+ let frontier = [name];
1290
+ for (let d = 0; d < depth && frontier.length > 0 && distance.size < limit; d++) {
1291
+ const next = [];
1292
+ for (const node of frontier) {
1293
+ for (const caller of this.callersOf(node)) {
1294
+ if (!distance.has(caller)) {
1295
+ distance.set(caller, d + 1);
1296
+ next.push(caller);
1297
+ }
1298
+ }
1299
+ }
1300
+ frontier = next;
1301
+ }
1302
+ const out = [];
1303
+ for (const [n, dist] of distance) {
1304
+ if (dist === 0) continue;
1305
+ for (const def of this.getSymbol(n, { limit: 3 })) out.push({ ...def, distance: dist });
1306
+ }
1307
+ out.sort((a, b) => a.distance - b.distance || a.file.localeCompare(b.file));
1308
+ return out.slice(0, limit);
1309
+ }
1310
+ /**
1311
+ * A token-budgeted relevance map for a task: the symbols matching `query` plus
1312
+ * their immediate call neighbourhood, ranked by call-site centrality, capped at
1313
+ * `maxSymbols`. This is the slice of the codebase an agent needs to start a
1314
+ * change — delivered as graph facts, not file dumps.
1315
+ */
1316
+ context(query, opts = {}) {
1317
+ const maxSymbols = Math.max(5, Math.min(opts.maxSymbols ?? 30, 100));
1318
+ const seeds = this.searchSymbols(query, { limit: Math.min(8, maxSymbols) });
1319
+ const picked = /* @__PURE__ */ new Map();
1320
+ const key = (s) => `${s.name}@${s.file}:${s.startRow}`;
1321
+ for (const s of seeds) picked.set(key(s), s);
1322
+ const candidates = /* @__PURE__ */ new Map();
1323
+ const edges = [];
1324
+ const edgeKeys = /* @__PURE__ */ new Set();
1325
+ for (const seed of seeds) {
1326
+ for (const callee of this.findCallees(seed.name, { limit: 15 })) {
1327
+ addEdge(edges, edgeKeys, seed.name, callee.name);
1328
+ const k = key(callee);
1329
+ if (!picked.has(k) && !candidates.has(k)) {
1330
+ candidates.set(k, { row: callee, score: this.callerCount(callee.name) });
1331
+ }
1332
+ }
1333
+ for (const caller of this.findCallers(seed.name, { limit: 15 })) {
1334
+ if (!caller.fromSymbol) continue;
1335
+ addEdge(edges, edgeKeys, caller.fromSymbol, seed.name);
1336
+ for (const def of this.getSymbol(caller.fromSymbol, { limit: 1 })) {
1337
+ const k = key(def);
1338
+ if (!picked.has(k) && !candidates.has(k)) {
1339
+ candidates.set(k, { row: def, score: this.callerCount(def.name) });
1340
+ }
1341
+ }
1342
+ }
1343
+ }
1344
+ const related = [...candidates.values()].sort((a, b) => b.score - a.score).slice(0, Math.max(0, maxSymbols - picked.size)).map((c2) => c2.row);
1345
+ const keptNames = new Set([...seeds, ...related].map((s) => s.name));
1346
+ return {
1347
+ query,
1348
+ seeds,
1349
+ related,
1350
+ edges: edges.filter((e) => keptNames.has(e.from) && keptNames.has(e.to))
1351
+ };
1352
+ }
923
1353
  /** All references (calls + imports) to a name. */
924
1354
  findReferences(name, opts = {}) {
925
1355
  const limit = clampLimit(opts.limit);
@@ -934,6 +1364,18 @@ var GraphStore = class {
934
1364
  ).all(name, limit);
935
1365
  return rows.map(toRefRow);
936
1366
  }
1367
+ /**
1368
+ * Files whose imports reference a module basename (e.g. `store` for
1369
+ * `src/store.ts`). Matches `import … from "./store"`, `"../src/store.js"`,
1370
+ * etc. Used by affected-test analysis to follow import edges, which — unlike
1371
+ * call edges — reliably reach test files (tests import the module under test).
1372
+ */
1373
+ findImporters(moduleBasename2) {
1374
+ return this.db.prepare(
1375
+ `SELECT DISTINCT f.path FROM refs r JOIN files f ON f.id = r.file_id
1376
+ WHERE r.kind = 'import' AND (r.name = ? OR r.name LIKE ? OR r.name LIKE ?)`
1377
+ ).all(moduleBasename2, `%/${moduleBasename2}`, `%/${moduleBasename2}.%`).map((r) => r.path);
1378
+ }
937
1379
  /** The symbols defined in a file, in source order. */
938
1380
  fileOutline(path) {
939
1381
  return this.db.prepare(
@@ -1122,6 +1564,7 @@ Usage:
1122
1564
  codescope <command> [path] [options]
1123
1565
 
1124
1566
  Commands:
1567
+ install [path] Wire codescope into your agents (Claude Code, Cursor) automatically.
1125
1568
  mcp [path] Index, watch for changes, and serve the graph over MCP (stdio).
1126
1569
  This is what you wire into Claude Code / Cursor / Codex.
1127
1570
  index [path] Build (or refresh) the on-disk graph and print stats.
@@ -1130,9 +1573,15 @@ Commands:
1130
1573
  search <query> [path] Fuzzy-search symbol names.
1131
1574
  get <name> [path] Look up a definition by exact name.
1132
1575
  callers <name> [path] List callers of a function/method.
1576
+ callees <name> [path] List what a function/method calls.
1577
+ impact <name> [path] Transitive callers (blast radius) of a symbol.
1578
+ context <query> [path] Ranked relevance map for a task (matches + neighbours).
1579
+ affected <files...> Test files affected by a set of changed files.
1133
1580
  neighborhood <name> Show the call neighbourhood around a symbol.
1134
1581
 
1135
1582
  Options:
1583
+ --agent <name> install target: claude | cursor | all (default all).
1584
+ --global install: write to your home dir instead of the project.
1136
1585
  --path <dir> Repository root (default: current directory or the positional path).
1137
1586
  --db <file> SQLite graph location (default: <root>/.codescope/graph.db).
1138
1587
  --memory Use an in-memory graph (not persisted).
@@ -1149,13 +1598,19 @@ Examples:
1149
1598
  codescope mcp . # add this command to your agent's MCP config
1150
1599
  `;
1151
1600
  function parseArgs(argv) {
1152
- const flags = { positional: [], memory: false };
1601
+ const flags = { positional: [], memory: false, global: false };
1153
1602
  for (let i = 0; i < argv.length; i++) {
1154
1603
  const arg = argv[i];
1155
1604
  switch (arg) {
1156
1605
  case "--memory":
1157
1606
  flags.memory = true;
1158
1607
  break;
1608
+ case "--global":
1609
+ flags.global = true;
1610
+ break;
1611
+ case "--agent":
1612
+ flags.agent = argv[++i];
1613
+ break;
1159
1614
  case "--path":
1160
1615
  flags.path = argv[++i];
1161
1616
  break;
@@ -1183,7 +1638,7 @@ function rootDir(flags, positionalRootIndex = 0) {
1183
1638
  function openStore(root, flags) {
1184
1639
  if (flags.memory) return new GraphStore(":memory:");
1185
1640
  const dir = resolve2(root, ".codescope");
1186
- mkdirSync(dir, { recursive: true });
1641
+ mkdirSync2(dir, { recursive: true });
1187
1642
  return new GraphStore(flags.db ?? resolve2(dir, "graph.db"));
1188
1643
  }
1189
1644
  async function ensureIndexed(indexer, store) {
@@ -1236,6 +1691,15 @@ async function cmdQuery(command, root, flags) {
1236
1691
  case "callers":
1237
1692
  out = formatRefs(store.findCallers(term, { limit: flags.limit }));
1238
1693
  break;
1694
+ case "callees":
1695
+ out = formatSymbols(store.findCallees(term, { limit: flags.limit }));
1696
+ break;
1697
+ case "impact":
1698
+ out = formatImpact(store.impact(term, { depth: flags.depth, limit: flags.limit }));
1699
+ break;
1700
+ case "context":
1701
+ out = formatContext(store.context(term, { maxSymbols: flags.limit }));
1702
+ break;
1239
1703
  case "neighborhood":
1240
1704
  out = formatNeighborhood(store.neighborhood(term, { depth: flags.depth, limit: flags.limit }));
1241
1705
  break;
@@ -1246,6 +1710,32 @@ async function cmdQuery(command, root, flags) {
1246
1710
  `);
1247
1711
  store.close();
1248
1712
  }
1713
+ async function cmdAffected(root, flags) {
1714
+ const files = flags.positional;
1715
+ if (files.length === 0) fail("'affected' needs one or more changed file paths. See --help.");
1716
+ const store = openStore(root, flags);
1717
+ const indexer = new Indexer(store, root);
1718
+ await ensureIndexed(indexer, store);
1719
+ const rels = files.map((f) => indexer.rel(resolve2(root, f)));
1720
+ process.stdout.write(`${formatAffected(affected(store, rels, { depth: flags.depth }))}
1721
+ `);
1722
+ store.close();
1723
+ }
1724
+ function cmdInstall(root, flags) {
1725
+ const agents = !flags.agent || flags.agent === "all" ? [...SUPPORTED_AGENTS] : SUPPORTED_AGENTS.includes(flags.agent) ? [flags.agent] : fail(`unknown --agent '${flags.agent}'. Use: ${SUPPORTED_AGENTS.join(", ")}, or all.`);
1726
+ const results = install(root, { agents, global: flags.global });
1727
+ for (const r of results) {
1728
+ process.stdout.write(`${pc.green("\u2713")} ${r.action} codescope for ${pc.bold(r.agent)} \u2192 ${r.path}
1729
+ `);
1730
+ }
1731
+ process.stdout.write(
1732
+ `
1733
+ ${pc.dim("Restart your agent to pick up the change.")}
1734
+ ${pc.dim("Codex (TOML config) \u2014 add this to ~/.codex/config.toml:")}
1735
+ ${codexSnippet()}
1736
+ `
1737
+ );
1738
+ }
1249
1739
  async function cmdWatch(root, flags) {
1250
1740
  const store = openStore(root, flags);
1251
1741
  const indexer = new Indexer(store, root);
@@ -1307,8 +1797,15 @@ async function main() {
1307
1797
  case "search":
1308
1798
  case "get":
1309
1799
  case "callers":
1800
+ case "callees":
1801
+ case "impact":
1802
+ case "context":
1310
1803
  case "neighborhood":
1311
1804
  return cmdQuery(command, resolve2(flags.path ?? flags.positional[1] ?? "."), flags);
1805
+ case "affected":
1806
+ return cmdAffected(resolve2(flags.path ?? "."), flags);
1807
+ case "install":
1808
+ return cmdInstall(rootDir(flags), flags);
1312
1809
  case "watch":
1313
1810
  return cmdWatch(rootDir(flags), flags);
1314
1811
  case "mcp":