@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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ For older versions, see [GitHub Releases](https://github.com/jefuriiij/synthra/r
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.11.0] — 2026-06-24
11
+
12
+ ### Added
13
+
14
+ - **`graph_read` now shows which tests cover a symbol.** A symbol read appends a
15
+ `Tests (file-level): …` line listing the test files linked to the symbol's file
16
+ (via the graph's `tests` edges) — so after an edit you run the *right* test
17
+ instead of guessing or running the whole suite. Ordinary source files with no
18
+ linked test get a one-line "none linked" nudge.
19
+ - **`blast_radius` is now symbol-aware.** A `file::symbol` target returns the
20
+ exact caller **symbols** that transitively call it (`name → file:line`), plus a
21
+ line naming the test files that guard the impact — the precise view you want
22
+ before a rename. A bare file target keeps the existing file-level dependent
23
+ list. (The `graph_read` "Used by (N)" footer remains the cheap always-on
24
+ direct-caller summary; this is the complete, transitive, on-demand one.)
25
+
26
+ ---
27
+
10
28
  ## [0.10.0] — 2026-06-20
11
29
 
12
30
  ### Added
package/dist/cli/index.js CHANGED
@@ -18,7 +18,7 @@ var init_package = __esm({
18
18
  "package.json"() {
19
19
  package_default = {
20
20
  name: "@jefuriiij/synthra",
21
- version: "0.10.0",
21
+ version: "0.11.0",
22
22
  publishConfig: {
23
23
  access: "public"
24
24
  },
@@ -4197,11 +4197,14 @@ var TOOLS = [
4197
4197
  },
4198
4198
  {
4199
4199
  name: "blast_radius",
4200
- 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.",
4200
+ 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).",
4201
4201
  inputSchema: {
4202
4202
  type: "object",
4203
4203
  properties: {
4204
- target: { type: "string", description: "File path or 'file::symbol' notation." },
4204
+ target: {
4205
+ type: "string",
4206
+ description: "File path (file-level dependents) or 'file::symbol' (caller symbols)."
4207
+ },
4205
4208
  depth: { type: "number", description: "Max hops to traverse. Default 3." }
4206
4209
  },
4207
4210
  required: ["target"]
@@ -4252,7 +4255,8 @@ function blastRadius(args, ctx) {
4252
4255
  const targetRaw = typeof args?.target === "string" ? args.target.trim() : "";
4253
4256
  const maxDepth = typeof args?.depth === "number" && args.depth > 0 ? Math.floor(args.depth) : 3;
4254
4257
  if (!targetRaw) return errorContent("blast_radius: 'target' (string) is required");
4255
- const filePath = targetRaw.split("::", 1)[0]?.trim() ?? targetRaw;
4258
+ if (targetRaw.includes("::")) return blastRadiusSymbol(targetRaw, maxDepth, ctx);
4259
+ const filePath = targetRaw;
4256
4260
  const root = ctx.graph.nodes.find((n) => n.kind === "file" && n.path === filePath);
4257
4261
  if (!root) return errorContent(`blast_radius: file not in graph: ${filePath}`);
4258
4262
  const fileIdBySymbol = /* @__PURE__ */ new Map();
@@ -4307,6 +4311,92 @@ _(no dependents \u2014 file is isolated)_`);
4307
4311
  }
4308
4312
  return textContent(lines.join("\n"));
4309
4313
  }
4314
+ function blastRadiusSymbol(targetRaw, maxDepth, ctx) {
4315
+ const [rawFile, rawSym] = targetRaw.split("::", 2);
4316
+ const filePath = (rawFile ?? "").trim();
4317
+ const symName = (rawSym ?? "").trim();
4318
+ if (!symName) return errorContent("blast_radius: 'file::symbol' target needs a symbol name");
4319
+ const resolved = resolveFileTarget(ctx.graph, filePath);
4320
+ if ("ambiguous" in resolved) {
4321
+ const shown = resolved.ambiguous.slice(0, 5).join(", ");
4322
+ return errorContent(
4323
+ `blast_radius: '${filePath}' matches multiple files (${shown}). Pass a longer path.`
4324
+ );
4325
+ }
4326
+ if ("none" in resolved) return errorContent(`blast_radius: file not in graph: ${filePath}`);
4327
+ const fileNode = resolved.node;
4328
+ const symbol = ctx.graph.nodes.find(
4329
+ (n) => n.kind === "symbol" && n.file === fileNode.path && n.name === symName
4330
+ );
4331
+ if (!symbol)
4332
+ return errorContent(`blast_radius: symbol '${symName}' not found in ${fileNode.path}`);
4333
+ const callersBySym = /* @__PURE__ */ new Map();
4334
+ for (const e of ctx.graph.edges) {
4335
+ if (e.kind !== "calls" || e.from === e.to) continue;
4336
+ const list = callersBySym.get(e.to) ?? [];
4337
+ list.push(e.from);
4338
+ callersBySym.set(e.to, list);
4339
+ }
4340
+ const symById = /* @__PURE__ */ new Map();
4341
+ for (const n of ctx.graph.nodes) if (n.kind === "symbol") symById.set(n.id, n);
4342
+ const visited = /* @__PURE__ */ new Set([symbol.id]);
4343
+ const hits = [];
4344
+ let frontier = [symbol.id];
4345
+ for (let d = 1; d <= maxDepth; d++) {
4346
+ const next = [];
4347
+ for (const cur of frontier) {
4348
+ for (const fromId of callersBySym.get(cur) ?? []) {
4349
+ if (visited.has(fromId)) continue;
4350
+ visited.add(fromId);
4351
+ next.push(fromId);
4352
+ const s = symById.get(fromId);
4353
+ if (s) hits.push({ name: s.name, file: s.file, line: s.start_line, depth: d });
4354
+ }
4355
+ }
4356
+ frontier = next;
4357
+ if (next.length === 0) break;
4358
+ }
4359
+ const header = `# Blast radius for ${fileNode.path}::${symbol.name} (callers, depth \u2264 ${maxDepth})`;
4360
+ if (hits.length === 0) {
4361
+ const tline2 = testsCoveringLine(ctx.graph, [fileNode.path]);
4362
+ return textContent(
4363
+ `${header}
4364
+
4365
+ _(no callers \u2014 safe to rename)_${tline2 ? `
4366
+
4367
+ ${tline2}` : ""}`
4368
+ );
4369
+ }
4370
+ hits.sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file) || a.line - b.line);
4371
+ const lines = [header, "", `${hits.length} caller symbol(s):`];
4372
+ for (const h of hits) lines.push(`- **depth ${h.depth}** \`${h.name}\` \u2192 ${h.file}:${h.line}`);
4373
+ const tline = testsCoveringLine(ctx.graph, [fileNode.path, ...hits.map((h) => h.file)]);
4374
+ if (tline) {
4375
+ lines.push("");
4376
+ lines.push(tline);
4377
+ }
4378
+ return textContent(lines.join("\n"));
4379
+ }
4380
+ function testsCoveringLine(graph, filePaths) {
4381
+ const fileByPath = /* @__PURE__ */ new Map();
4382
+ for (const n of graph.nodes) if (n.kind === "file") fileByPath.set(n.path, n);
4383
+ const seen = /* @__PURE__ */ new Set();
4384
+ const tests = [];
4385
+ for (const p of new Set(filePaths)) {
4386
+ const fn = fileByPath.get(p);
4387
+ if (!fn) continue;
4388
+ for (const t of findTestsForFile(graph, fn)) {
4389
+ if (!seen.has(t.path)) {
4390
+ seen.add(t.path);
4391
+ tests.push(t.path);
4392
+ }
4393
+ }
4394
+ }
4395
+ if (tests.length === 0) return "";
4396
+ const shown = tests.slice(0, TESTS_MAX_FILES);
4397
+ const omitted = tests.length - shown.length;
4398
+ return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
4399
+ }
4310
4400
  var LIKELY_ENTRY_PATTERNS = [
4311
4401
  /(?:^|\/)main\.[a-z0-9_]+$/i,
4312
4402
  /(?:^|\/)index\.[a-z0-9_]+$/i,
@@ -4384,6 +4474,7 @@ function resolveFileTarget(graph, filePath) {
4384
4474
  var DEPS_SIG_MAX = 140;
4385
4475
  var DEPS_MAX_CALLEES = 10;
4386
4476
  var DEPS_MAX_CALLERS = 12;
4477
+ var TESTS_MAX_FILES = 6;
4387
4478
  function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars) {
4388
4479
  const symById = /* @__PURE__ */ new Map();
4389
4480
  for (const n of graph.nodes) if (n.kind === "symbol") symById.set(n.id, n);
@@ -4447,6 +4538,21 @@ function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars
4447
4538
  }
4448
4539
  return lines.join("\n");
4449
4540
  }
4541
+ function buildTestsFooter(symbol, graph) {
4542
+ const fileNode = graph.nodes.find(
4543
+ (n) => n.kind === "file" && n.path === symbol.file
4544
+ );
4545
+ if (!fileNode) return "";
4546
+ const tests = findTestsForFile(graph, fileNode);
4547
+ if (tests.length > 0) {
4548
+ const shown = tests.slice(0, TESTS_MAX_FILES).map((t) => t.path);
4549
+ const omitted = tests.length - shown.length;
4550
+ const more = omitted > 0 ? ` \u2026+${omitted} more` : "";
4551
+ return `Tests (file-level): ${shown.join(" \xB7 ")}${more} \u2014 run after editing`;
4552
+ }
4553
+ if (isLikelyEntry(symbol.file)) return "";
4554
+ return "Tests: none linked to this file.";
4555
+ }
4450
4556
  async function graphRead(args, ctx) {
4451
4557
  const target = typeof args?.target === "string" ? args.target : "";
4452
4558
  if (!target) return errorContent("graph_read: 'target' (string) is required");
@@ -4490,10 +4596,15 @@ ${fileNode.content}`);
4490
4596
 
4491
4597
  ---
4492
4598
  ${deps}` : "";
4599
+ const tests = buildTestsFooter(symbol, ctx.graph);
4600
+ const testsBlock = tests ? `
4601
+
4602
+ ---
4603
+ ${tests}` : "";
4493
4604
  return textContent(
4494
4605
  `# ${fileNode.path}::${symbol.name} (L${symbol.start_line}-${symbol.end_line})
4495
4606
 
4496
- ${body}${depsBlock}${editHint}`
4607
+ ${body}${depsBlock}${testsBlock}${editHint}`
4497
4608
  );
4498
4609
  }
4499
4610
  var editedFiles = /* @__PURE__ */ new Set();