@jefuriiij/synthra 0.10.0 → 0.12.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,44 @@ For older versions, see [GitHub Releases](https://github.com/jefuriiij/synthra/r
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.12.0] — 2026-06-24
11
+
12
+ ### Added
13
+
14
+ - **`find_symbol(name)` — reuse before you re-implement.** Before writing a new
15
+ helper, ask Synthra whether one already exists: `find_symbol` returns every
16
+ exact-name definition (with signatures + ready `graph_read` targets), or — if
17
+ there's no exact match — similarly-named symbols to reuse or extend. "No symbol
18
+ matching … — safe to create" is the green light that it's genuinely new. The
19
+ injected policy now nudges the agent to check first.
20
+ - **`duplicate_symbols` — consolidation candidates.** Lists symbol names defined
21
+ in more than one file (functions/classes/types; methods excluded, since shared
22
+ method names are normal). Advisory — duplicates can be intentional; it never
23
+ says "delete."
24
+
25
+ Both are built on the symbol index (exact name lookup) — no false-positive risk,
26
+ no new dependencies.
27
+
28
+ ---
29
+
30
+ ## [0.11.0] — 2026-06-24
31
+
32
+ ### Added
33
+
34
+ - **`graph_read` now shows which tests cover a symbol.** A symbol read appends a
35
+ `Tests (file-level): …` line listing the test files linked to the symbol's file
36
+ (via the graph's `tests` edges) — so after an edit you run the *right* test
37
+ instead of guessing or running the whole suite. Ordinary source files with no
38
+ linked test get a one-line "none linked" nudge.
39
+ - **`blast_radius` is now symbol-aware.** A `file::symbol` target returns the
40
+ exact caller **symbols** that transitively call it (`name → file:line`), plus a
41
+ line naming the test files that guard the impact — the precise view you want
42
+ before a rename. A bare file target keeps the existing file-level dependent
43
+ list. (The `graph_read` "Used by (N)" footer remains the cheap always-on
44
+ direct-caller summary; this is the complete, transitive, on-demand one.)
45
+
46
+ ---
47
+
10
48
  ## [0.10.0] — 2026-06-20
11
49
 
12
50
  ### 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.12.0",
22
22
  publishConfig: {
23
23
  access: "public"
24
24
  },
@@ -3017,7 +3017,7 @@ import { basename as basename5 } from "path";
3017
3017
  // src/hooks/claude-md.ts
3018
3018
  import { readFile as readFile12, writeFile as writeFile6 } from "fs/promises";
3019
3019
  import { basename as basename4, dirname as dirname9 } from "path";
3020
- var POLICY_VERSION = 7;
3020
+ var POLICY_VERSION = 8;
3021
3021
  var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
3022
3022
  var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
3023
3023
  var ANY_BLOCK_RE = /<!--\s*synthra-policy\s+v\d+\s+BEGIN\s*-->[\s\S]*?<!--\s*synthra-policy\s+v\d+\s+END\s*-->\s*/g;
@@ -3036,7 +3036,7 @@ function policyBlock() {
3036
3036
  "> `mcp__synthra__graph_register_edit`. **Short names will NOT resolve**",
3037
3037
  "> in ToolSearch or invocation \u2014 always use the full namespaced form.",
3038
3038
  "> If the tools are deferred, load their schemas with ToolSearch:",
3039
- "> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit`.",
3039
+ "> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit,mcp__synthra__find_symbol`.",
3040
3040
  "> Below, short names (`graph_continue` etc.) appear in prose for",
3041
3041
  "> readability only.",
3042
3042
  "",
@@ -3050,6 +3050,10 @@ function policyBlock() {
3050
3050
  " symbol is ~50 tokens, reading a whole file is thousands.",
3051
3051
  "- **`graph_register_edit(files)`** \u2014 after you edit files, call this so",
3052
3052
  " subsequent turns weight your changes and avoid stale snapshots.",
3053
+ "- **`find_symbol(name)`** \u2014 **reuse-first**: before writing a new helper,",
3054
+ " util, or function, call this to check whether one already exists. If it",
3055
+ " returns matches, reuse or extend them instead of re-implementing; only",
3056
+ ' "no match \u2014 safe to create" means it is genuinely new.',
3053
3057
  "",
3054
3058
  "### When to call `graph_continue` \u2014 and when to skip",
3055
3059
  "",
@@ -4197,11 +4201,14 @@ var TOOLS = [
4197
4201
  },
4198
4202
  {
4199
4203
  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.",
4204
+ 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
4205
  inputSchema: {
4202
4206
  type: "object",
4203
4207
  properties: {
4204
- target: { type: "string", description: "File path or 'file::symbol' notation." },
4208
+ target: {
4209
+ type: "string",
4210
+ description: "File path (file-level dependents) or 'file::symbol' (caller symbols)."
4211
+ },
4205
4212
  depth: { type: "number", description: "Max hops to traverse. Default 3." }
4206
4213
  },
4207
4214
  required: ["target"]
@@ -4216,6 +4223,27 @@ var TOOLS = [
4216
4223
  limit: { type: "number", description: "Cap on returned files. Default 50." }
4217
4224
  }
4218
4225
  }
4226
+ },
4227
+ {
4228
+ name: "find_symbol",
4229
+ description: "Find existing symbols by name BEFORE writing a new one \u2014 reuse beats re-implementing. Returns exact-name definitions (signatures + graph_read targets) or, if none, similarly-named symbols. 'No symbol matching \u2026 \u2014 safe to create' means it's genuinely new.",
4230
+ inputSchema: {
4231
+ type: "object",
4232
+ properties: {
4233
+ name: { type: "string", description: "Symbol name (or near-name) to look for." }
4234
+ },
4235
+ required: ["name"]
4236
+ }
4237
+ },
4238
+ {
4239
+ name: "duplicate_symbols",
4240
+ description: "List symbol names defined in more than one file (functions/classes/types; methods excluded) \u2014 consolidation candidates for review. Advisory: duplicates may be intentional.",
4241
+ inputSchema: {
4242
+ type: "object",
4243
+ properties: {
4244
+ limit: { type: "number", description: "Cap on returned names. Default 30." }
4245
+ }
4246
+ }
4219
4247
  }
4220
4248
  ];
4221
4249
  async function callTool(name, args, ctx) {
@@ -4238,6 +4266,10 @@ async function callTool(name, args, ctx) {
4238
4266
  return blastRadius(args, ctx);
4239
4267
  case "dead_code":
4240
4268
  return deadCode(args, ctx);
4269
+ case "find_symbol":
4270
+ return findSymbol(args, ctx);
4271
+ case "duplicate_symbols":
4272
+ return duplicateSymbols(args, ctx);
4241
4273
  default:
4242
4274
  return errorContent(`Unknown tool: ${name}`);
4243
4275
  }
@@ -4252,7 +4284,8 @@ function blastRadius(args, ctx) {
4252
4284
  const targetRaw = typeof args?.target === "string" ? args.target.trim() : "";
4253
4285
  const maxDepth = typeof args?.depth === "number" && args.depth > 0 ? Math.floor(args.depth) : 3;
4254
4286
  if (!targetRaw) return errorContent("blast_radius: 'target' (string) is required");
4255
- const filePath = targetRaw.split("::", 1)[0]?.trim() ?? targetRaw;
4287
+ if (targetRaw.includes("::")) return blastRadiusSymbol(targetRaw, maxDepth, ctx);
4288
+ const filePath = targetRaw;
4256
4289
  const root = ctx.graph.nodes.find((n) => n.kind === "file" && n.path === filePath);
4257
4290
  if (!root) return errorContent(`blast_radius: file not in graph: ${filePath}`);
4258
4291
  const fileIdBySymbol = /* @__PURE__ */ new Map();
@@ -4307,6 +4340,92 @@ _(no dependents \u2014 file is isolated)_`);
4307
4340
  }
4308
4341
  return textContent(lines.join("\n"));
4309
4342
  }
4343
+ function blastRadiusSymbol(targetRaw, maxDepth, ctx) {
4344
+ const [rawFile, rawSym] = targetRaw.split("::", 2);
4345
+ const filePath = (rawFile ?? "").trim();
4346
+ const symName = (rawSym ?? "").trim();
4347
+ if (!symName) return errorContent("blast_radius: 'file::symbol' target needs a symbol name");
4348
+ const resolved = resolveFileTarget(ctx.graph, filePath);
4349
+ if ("ambiguous" in resolved) {
4350
+ const shown = resolved.ambiguous.slice(0, 5).join(", ");
4351
+ return errorContent(
4352
+ `blast_radius: '${filePath}' matches multiple files (${shown}). Pass a longer path.`
4353
+ );
4354
+ }
4355
+ if ("none" in resolved) return errorContent(`blast_radius: file not in graph: ${filePath}`);
4356
+ const fileNode = resolved.node;
4357
+ const symbol = ctx.graph.nodes.find(
4358
+ (n) => n.kind === "symbol" && n.file === fileNode.path && n.name === symName
4359
+ );
4360
+ if (!symbol)
4361
+ return errorContent(`blast_radius: symbol '${symName}' not found in ${fileNode.path}`);
4362
+ const callersBySym = /* @__PURE__ */ new Map();
4363
+ for (const e of ctx.graph.edges) {
4364
+ if (e.kind !== "calls" || e.from === e.to) continue;
4365
+ const list = callersBySym.get(e.to) ?? [];
4366
+ list.push(e.from);
4367
+ callersBySym.set(e.to, list);
4368
+ }
4369
+ const symById = /* @__PURE__ */ new Map();
4370
+ for (const n of ctx.graph.nodes) if (n.kind === "symbol") symById.set(n.id, n);
4371
+ const visited = /* @__PURE__ */ new Set([symbol.id]);
4372
+ const hits = [];
4373
+ let frontier = [symbol.id];
4374
+ for (let d = 1; d <= maxDepth; d++) {
4375
+ const next = [];
4376
+ for (const cur of frontier) {
4377
+ for (const fromId of callersBySym.get(cur) ?? []) {
4378
+ if (visited.has(fromId)) continue;
4379
+ visited.add(fromId);
4380
+ next.push(fromId);
4381
+ const s = symById.get(fromId);
4382
+ if (s) hits.push({ name: s.name, file: s.file, line: s.start_line, depth: d });
4383
+ }
4384
+ }
4385
+ frontier = next;
4386
+ if (next.length === 0) break;
4387
+ }
4388
+ const header = `# Blast radius for ${fileNode.path}::${symbol.name} (callers, depth \u2264 ${maxDepth})`;
4389
+ if (hits.length === 0) {
4390
+ const tline2 = testsCoveringLine(ctx.graph, [fileNode.path]);
4391
+ return textContent(
4392
+ `${header}
4393
+
4394
+ _(no callers \u2014 safe to rename)_${tline2 ? `
4395
+
4396
+ ${tline2}` : ""}`
4397
+ );
4398
+ }
4399
+ hits.sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file) || a.line - b.line);
4400
+ const lines = [header, "", `${hits.length} caller symbol(s):`];
4401
+ for (const h of hits) lines.push(`- **depth ${h.depth}** \`${h.name}\` \u2192 ${h.file}:${h.line}`);
4402
+ const tline = testsCoveringLine(ctx.graph, [fileNode.path, ...hits.map((h) => h.file)]);
4403
+ if (tline) {
4404
+ lines.push("");
4405
+ lines.push(tline);
4406
+ }
4407
+ return textContent(lines.join("\n"));
4408
+ }
4409
+ function testsCoveringLine(graph, filePaths) {
4410
+ const fileByPath = /* @__PURE__ */ new Map();
4411
+ for (const n of graph.nodes) if (n.kind === "file") fileByPath.set(n.path, n);
4412
+ const seen = /* @__PURE__ */ new Set();
4413
+ const tests = [];
4414
+ for (const p of new Set(filePaths)) {
4415
+ const fn = fileByPath.get(p);
4416
+ if (!fn) continue;
4417
+ for (const t of findTestsForFile(graph, fn)) {
4418
+ if (!seen.has(t.path)) {
4419
+ seen.add(t.path);
4420
+ tests.push(t.path);
4421
+ }
4422
+ }
4423
+ }
4424
+ if (tests.length === 0) return "";
4425
+ const shown = tests.slice(0, TESTS_MAX_FILES);
4426
+ const omitted = tests.length - shown.length;
4427
+ return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
4428
+ }
4310
4429
  var LIKELY_ENTRY_PATTERNS = [
4311
4430
  /(?:^|\/)main\.[a-z0-9_]+$/i,
4312
4431
  /(?:^|\/)index\.[a-z0-9_]+$/i,
@@ -4354,6 +4473,107 @@ _(no file is unreferenced \u2014 every file is either imported by another, has a
4354
4473
  );
4355
4474
  return textContent(lines.join("\n"));
4356
4475
  }
4476
+ var FIND_MAX = 12;
4477
+ var FIND_SIG_MAX = 140;
4478
+ function symbolEntry(s) {
4479
+ const sig = s.signature.trim().slice(0, FIND_SIG_MAX);
4480
+ return `\u2022 ${sig} \u2192 mcp__synthra__graph_read("${s.file}::${s.name}") [${s.symbol_kind}, L${s.start_line}]`;
4481
+ }
4482
+ var byFileLine = (a, b) => a.file === b.file ? a.start_line - b.start_line : a.file < b.file ? -1 : 1;
4483
+ function findSymbol(args, ctx) {
4484
+ const name = typeof args?.name === "string" ? args.name.trim() : "";
4485
+ if (!name) return errorContent("find_symbol: 'name' (string) is required");
4486
+ const symbols = ctx.graph.nodes.filter((n) => n.kind === "symbol");
4487
+ const lower = name.toLowerCase();
4488
+ const exact = symbols.filter((s) => s.name === name);
4489
+ const exactHits = exact.length > 0 ? exact : symbols.filter((s) => s.name.toLowerCase() === lower);
4490
+ if (exactHits.length > 0) {
4491
+ const sorted = exactHits.slice().sort(byFileLine);
4492
+ const shown2 = sorted.slice(0, FIND_MAX);
4493
+ const omitted2 = sorted.length - shown2.length;
4494
+ const lines2 = [
4495
+ `# find_symbol: "${name}"`,
4496
+ "",
4497
+ `Exact matches (${sorted.length}) \u2014 reuse one of these instead of writing a new one:`,
4498
+ ...shown2.map(symbolEntry)
4499
+ ];
4500
+ if (omitted2 > 0) lines2.push(`\u2026+${omitted2} more`);
4501
+ return textContent(lines2.join("\n"));
4502
+ }
4503
+ const tokens = new Set(tokenizeQuery(name));
4504
+ const scored = symbols.map((s) => {
4505
+ const n = s.name.toLowerCase();
4506
+ let score2 = 0;
4507
+ if (n.includes(lower) || lower.includes(n)) score2 += 2;
4508
+ for (const t of tokens) if (n.includes(t)) score2 += 1;
4509
+ return { s, score: score2 };
4510
+ }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score || byFileLine(a.s, b.s));
4511
+ if (scored.length === 0) {
4512
+ return textContent(
4513
+ `# find_symbol: "${name}"
4514
+
4515
+ No symbol matching "${name}" \u2014 safe to create.`
4516
+ );
4517
+ }
4518
+ const shown = scored.slice(0, FIND_MAX);
4519
+ const omitted = scored.length - shown.length;
4520
+ const lines = [
4521
+ `# find_symbol: "${name}"`,
4522
+ "",
4523
+ `No exact match. Similar names (${scored.length}) \u2014 reuse or extend one before writing new:`,
4524
+ ...shown.map((x) => symbolEntry(x.s))
4525
+ ];
4526
+ if (omitted > 0) lines.push(`\u2026+${omitted} more`);
4527
+ return textContent(lines.join("\n"));
4528
+ }
4529
+ var DUP_INCLUDE = /* @__PURE__ */ new Set([
4530
+ "function",
4531
+ "class",
4532
+ "interface",
4533
+ "type",
4534
+ "enum",
4535
+ "const",
4536
+ "component"
4537
+ ]);
4538
+ function duplicateSymbols(args, ctx) {
4539
+ const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : 30;
4540
+ const defsByName = /* @__PURE__ */ new Map();
4541
+ const filesByName = /* @__PURE__ */ new Map();
4542
+ for (const n of ctx.graph.nodes) {
4543
+ if (n.kind !== "symbol" || !DUP_INCLUDE.has(n.symbol_kind)) continue;
4544
+ (defsByName.get(n.name) ?? defsByName.set(n.name, []).get(n.name)).push({
4545
+ file: n.file,
4546
+ line: n.start_line
4547
+ });
4548
+ (filesByName.get(n.name) ?? filesByName.set(n.name, /* @__PURE__ */ new Set()).get(n.name)).add(n.file);
4549
+ }
4550
+ const dups = [...defsByName.entries()].filter(([name]) => (filesByName.get(name)?.size ?? 0) >= 2).map(([name, defs]) => ({
4551
+ name,
4552
+ defs: defs.slice().sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1)
4553
+ })).sort((a, b) => b.defs.length - a.defs.length || a.name.localeCompare(b.name));
4554
+ if (dups.length === 0) {
4555
+ return textContent(
4556
+ "# Duplicate symbols\n\n_(no top-level symbol name is defined in more than one file)_"
4557
+ );
4558
+ }
4559
+ const shown = dups.slice(0, limit);
4560
+ const lines = [
4561
+ "# Duplicate symbols (consolidation candidates)",
4562
+ "",
4563
+ `${shown.length} of ${dups.length} name(s) defined in multiple files (functions/classes/types; methods excluded):`,
4564
+ ""
4565
+ ];
4566
+ for (const d of shown) {
4567
+ lines.push(
4568
+ `- \`${d.name}\` (${d.defs.length}): ${d.defs.map((x) => `${x.file}:${x.line}`).join(" \xB7 ")}`
4569
+ );
4570
+ }
4571
+ lines.push("");
4572
+ lines.push(
4573
+ "_advisory: the same name in multiple files may be intentional \u2014 verify before consolidating._"
4574
+ );
4575
+ return textContent(lines.join("\n"));
4576
+ }
4357
4577
  async function graphContinue(args, ctx) {
4358
4578
  const query = typeof args?.query === "string" ? args.query : "";
4359
4579
  if (!query) return errorContent("graph_continue: 'query' (string) is required");
@@ -4384,6 +4604,7 @@ function resolveFileTarget(graph, filePath) {
4384
4604
  var DEPS_SIG_MAX = 140;
4385
4605
  var DEPS_MAX_CALLEES = 10;
4386
4606
  var DEPS_MAX_CALLERS = 12;
4607
+ var TESTS_MAX_FILES = 6;
4387
4608
  function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars) {
4388
4609
  const symById = /* @__PURE__ */ new Map();
4389
4610
  for (const n of graph.nodes) if (n.kind === "symbol") symById.set(n.id, n);
@@ -4447,6 +4668,21 @@ function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars
4447
4668
  }
4448
4669
  return lines.join("\n");
4449
4670
  }
4671
+ function buildTestsFooter(symbol, graph) {
4672
+ const fileNode = graph.nodes.find(
4673
+ (n) => n.kind === "file" && n.path === symbol.file
4674
+ );
4675
+ if (!fileNode) return "";
4676
+ const tests = findTestsForFile(graph, fileNode);
4677
+ if (tests.length > 0) {
4678
+ const shown = tests.slice(0, TESTS_MAX_FILES).map((t) => t.path);
4679
+ const omitted = tests.length - shown.length;
4680
+ const more = omitted > 0 ? ` \u2026+${omitted} more` : "";
4681
+ return `Tests (file-level): ${shown.join(" \xB7 ")}${more} \u2014 run after editing`;
4682
+ }
4683
+ if (isLikelyEntry(symbol.file)) return "";
4684
+ return "Tests: none linked to this file.";
4685
+ }
4450
4686
  async function graphRead(args, ctx) {
4451
4687
  const target = typeof args?.target === "string" ? args.target : "";
4452
4688
  if (!target) return errorContent("graph_read: 'target' (string) is required");
@@ -4490,10 +4726,15 @@ ${fileNode.content}`);
4490
4726
 
4491
4727
  ---
4492
4728
  ${deps}` : "";
4729
+ const tests = buildTestsFooter(symbol, ctx.graph);
4730
+ const testsBlock = tests ? `
4731
+
4732
+ ---
4733
+ ${tests}` : "";
4493
4734
  return textContent(
4494
4735
  `# ${fileNode.path}::${symbol.name} (L${symbol.start_line}-${symbol.end_line})
4495
4736
 
4496
- ${body}${depsBlock}${editHint}`
4737
+ ${body}${depsBlock}${testsBlock}${editHint}`
4497
4738
  );
4498
4739
  }
4499
4740
  var editedFiles = /* @__PURE__ */ new Set();