@jefuriiij/synthra 0.10.0 → 0.11.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.
@@ -3106,11 +3106,14 @@ var TOOLS = [
3106
3106
  },
3107
3107
  {
3108
3108
  name: "blast_radius",
3109
- description: "Given a file (or 'file::symbol' target), return all files that depend on it transitively via imports, tests, and call edges (callers). Use BEFORE editing a widely-used file to see what could break. Call edges are name-resolved (precise within a file, unique-name across files) and projected to file granularity.",
3109
+ description: "See what could break before an edit. A bare file target returns all files that depend on it transitively via imports, tests, and call edges. A 'file::symbol' target returns the exact caller SYMBOLS that transitively call it (name \u2192 file:line) plus the test files guarding the impact \u2014 the precise rename-safety view. Call edges are name-resolved (precise within a file, unique-name across files).",
3110
3110
  inputSchema: {
3111
3111
  type: "object",
3112
3112
  properties: {
3113
- target: { type: "string", description: "File path or 'file::symbol' notation." },
3113
+ target: {
3114
+ type: "string",
3115
+ description: "File path (file-level dependents) or 'file::symbol' (caller symbols)."
3116
+ },
3114
3117
  depth: { type: "number", description: "Max hops to traverse. Default 3." }
3115
3118
  },
3116
3119
  required: ["target"]
@@ -3161,7 +3164,8 @@ function blastRadius(args, ctx) {
3161
3164
  const targetRaw = typeof args?.target === "string" ? args.target.trim() : "";
3162
3165
  const maxDepth = typeof args?.depth === "number" && args.depth > 0 ? Math.floor(args.depth) : 3;
3163
3166
  if (!targetRaw) return errorContent("blast_radius: 'target' (string) is required");
3164
- const filePath = targetRaw.split("::", 1)[0]?.trim() ?? targetRaw;
3167
+ if (targetRaw.includes("::")) return blastRadiusSymbol(targetRaw, maxDepth, ctx);
3168
+ const filePath = targetRaw;
3165
3169
  const root = ctx.graph.nodes.find((n) => n.kind === "file" && n.path === filePath);
3166
3170
  if (!root) return errorContent(`blast_radius: file not in graph: ${filePath}`);
3167
3171
  const fileIdBySymbol = /* @__PURE__ */ new Map();
@@ -3216,6 +3220,92 @@ _(no dependents \u2014 file is isolated)_`);
3216
3220
  }
3217
3221
  return textContent(lines.join("\n"));
3218
3222
  }
3223
+ function blastRadiusSymbol(targetRaw, maxDepth, ctx) {
3224
+ const [rawFile, rawSym] = targetRaw.split("::", 2);
3225
+ const filePath = (rawFile ?? "").trim();
3226
+ const symName = (rawSym ?? "").trim();
3227
+ if (!symName) return errorContent("blast_radius: 'file::symbol' target needs a symbol name");
3228
+ const resolved = resolveFileTarget(ctx.graph, filePath);
3229
+ if ("ambiguous" in resolved) {
3230
+ const shown = resolved.ambiguous.slice(0, 5).join(", ");
3231
+ return errorContent(
3232
+ `blast_radius: '${filePath}' matches multiple files (${shown}). Pass a longer path.`
3233
+ );
3234
+ }
3235
+ if ("none" in resolved) return errorContent(`blast_radius: file not in graph: ${filePath}`);
3236
+ const fileNode = resolved.node;
3237
+ const symbol = ctx.graph.nodes.find(
3238
+ (n) => n.kind === "symbol" && n.file === fileNode.path && n.name === symName
3239
+ );
3240
+ if (!symbol)
3241
+ return errorContent(`blast_radius: symbol '${symName}' not found in ${fileNode.path}`);
3242
+ const callersBySym = /* @__PURE__ */ new Map();
3243
+ for (const e of ctx.graph.edges) {
3244
+ if (e.kind !== "calls" || e.from === e.to) continue;
3245
+ const list = callersBySym.get(e.to) ?? [];
3246
+ list.push(e.from);
3247
+ callersBySym.set(e.to, list);
3248
+ }
3249
+ const symById = /* @__PURE__ */ new Map();
3250
+ for (const n of ctx.graph.nodes) if (n.kind === "symbol") symById.set(n.id, n);
3251
+ const visited = /* @__PURE__ */ new Set([symbol.id]);
3252
+ const hits = [];
3253
+ let frontier = [symbol.id];
3254
+ for (let d = 1; d <= maxDepth; d++) {
3255
+ const next = [];
3256
+ for (const cur of frontier) {
3257
+ for (const fromId of callersBySym.get(cur) ?? []) {
3258
+ if (visited.has(fromId)) continue;
3259
+ visited.add(fromId);
3260
+ next.push(fromId);
3261
+ const s = symById.get(fromId);
3262
+ if (s) hits.push({ name: s.name, file: s.file, line: s.start_line, depth: d });
3263
+ }
3264
+ }
3265
+ frontier = next;
3266
+ if (next.length === 0) break;
3267
+ }
3268
+ const header = `# Blast radius for ${fileNode.path}::${symbol.name} (callers, depth \u2264 ${maxDepth})`;
3269
+ if (hits.length === 0) {
3270
+ const tline2 = testsCoveringLine(ctx.graph, [fileNode.path]);
3271
+ return textContent(
3272
+ `${header}
3273
+
3274
+ _(no callers \u2014 safe to rename)_${tline2 ? `
3275
+
3276
+ ${tline2}` : ""}`
3277
+ );
3278
+ }
3279
+ hits.sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file) || a.line - b.line);
3280
+ const lines = [header, "", `${hits.length} caller symbol(s):`];
3281
+ for (const h of hits) lines.push(`- **depth ${h.depth}** \`${h.name}\` \u2192 ${h.file}:${h.line}`);
3282
+ const tline = testsCoveringLine(ctx.graph, [fileNode.path, ...hits.map((h) => h.file)]);
3283
+ if (tline) {
3284
+ lines.push("");
3285
+ lines.push(tline);
3286
+ }
3287
+ return textContent(lines.join("\n"));
3288
+ }
3289
+ function testsCoveringLine(graph, filePaths) {
3290
+ const fileByPath = /* @__PURE__ */ new Map();
3291
+ for (const n of graph.nodes) if (n.kind === "file") fileByPath.set(n.path, n);
3292
+ const seen = /* @__PURE__ */ new Set();
3293
+ const tests = [];
3294
+ for (const p of new Set(filePaths)) {
3295
+ const fn = fileByPath.get(p);
3296
+ if (!fn) continue;
3297
+ for (const t of findTestsForFile(graph, fn)) {
3298
+ if (!seen.has(t.path)) {
3299
+ seen.add(t.path);
3300
+ tests.push(t.path);
3301
+ }
3302
+ }
3303
+ }
3304
+ if (tests.length === 0) return "";
3305
+ const shown = tests.slice(0, TESTS_MAX_FILES);
3306
+ const omitted = tests.length - shown.length;
3307
+ return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
3308
+ }
3219
3309
  var LIKELY_ENTRY_PATTERNS = [
3220
3310
  /(?:^|\/)main\.[a-z0-9_]+$/i,
3221
3311
  /(?:^|\/)index\.[a-z0-9_]+$/i,
@@ -3293,6 +3383,7 @@ function resolveFileTarget(graph, filePath) {
3293
3383
  var DEPS_SIG_MAX = 140;
3294
3384
  var DEPS_MAX_CALLEES = 10;
3295
3385
  var DEPS_MAX_CALLERS = 12;
3386
+ var TESTS_MAX_FILES = 6;
3296
3387
  function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars) {
3297
3388
  const symById = /* @__PURE__ */ new Map();
3298
3389
  for (const n of graph.nodes) if (n.kind === "symbol") symById.set(n.id, n);
@@ -3356,6 +3447,21 @@ function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars
3356
3447
  }
3357
3448
  return lines.join("\n");
3358
3449
  }
3450
+ function buildTestsFooter(symbol, graph) {
3451
+ const fileNode = graph.nodes.find(
3452
+ (n) => n.kind === "file" && n.path === symbol.file
3453
+ );
3454
+ if (!fileNode) return "";
3455
+ const tests = findTestsForFile(graph, fileNode);
3456
+ if (tests.length > 0) {
3457
+ const shown = tests.slice(0, TESTS_MAX_FILES).map((t) => t.path);
3458
+ const omitted = tests.length - shown.length;
3459
+ const more = omitted > 0 ? ` \u2026+${omitted} more` : "";
3460
+ return `Tests (file-level): ${shown.join(" \xB7 ")}${more} \u2014 run after editing`;
3461
+ }
3462
+ if (isLikelyEntry(symbol.file)) return "";
3463
+ return "Tests: none linked to this file.";
3464
+ }
3359
3465
  async function graphRead(args, ctx) {
3360
3466
  const target = typeof args?.target === "string" ? args.target : "";
3361
3467
  if (!target) return errorContent("graph_read: 'target' (string) is required");
@@ -3399,10 +3505,15 @@ ${fileNode.content}`);
3399
3505
 
3400
3506
  ---
3401
3507
  ${deps}` : "";
3508
+ const tests = buildTestsFooter(symbol, ctx.graph);
3509
+ const testsBlock = tests ? `
3510
+
3511
+ ---
3512
+ ${tests}` : "";
3402
3513
  return textContent(
3403
3514
  `# ${fileNode.path}::${symbol.name} (L${symbol.start_line}-${symbol.end_line})
3404
3515
 
3405
- ${body}${depsBlock}${editHint}`
3516
+ ${body}${depsBlock}${testsBlock}${editHint}`
3406
3517
  );
3407
3518
  }
3408
3519
  var editedFiles = /* @__PURE__ */ new Set();