@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.
@@ -1811,7 +1811,7 @@ import { basename as basename2 } from "path";
1811
1811
  // src/hooks/claude-md.ts
1812
1812
  import { readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
1813
1813
  import { basename, dirname as dirname5 } from "path";
1814
- var POLICY_VERSION = 7;
1814
+ var POLICY_VERSION = 8;
1815
1815
  var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
1816
1816
  var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
1817
1817
  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;
@@ -1830,7 +1830,7 @@ function policyBlock() {
1830
1830
  "> `mcp__synthra__graph_register_edit`. **Short names will NOT resolve**",
1831
1831
  "> in ToolSearch or invocation \u2014 always use the full namespaced form.",
1832
1832
  "> If the tools are deferred, load their schemas with ToolSearch:",
1833
- "> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit`.",
1833
+ "> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit,mcp__synthra__find_symbol`.",
1834
1834
  "> Below, short names (`graph_continue` etc.) appear in prose for",
1835
1835
  "> readability only.",
1836
1836
  "",
@@ -1844,6 +1844,10 @@ function policyBlock() {
1844
1844
  " symbol is ~50 tokens, reading a whole file is thousands.",
1845
1845
  "- **`graph_register_edit(files)`** \u2014 after you edit files, call this so",
1846
1846
  " subsequent turns weight your changes and avoid stale snapshots.",
1847
+ "- **`find_symbol(name)`** \u2014 **reuse-first**: before writing a new helper,",
1848
+ " util, or function, call this to check whether one already exists. If it",
1849
+ " returns matches, reuse or extend them instead of re-implementing; only",
1850
+ ' "no match \u2014 safe to create" means it is genuinely new.',
1847
1851
  "",
1848
1852
  "### When to call `graph_continue` \u2014 and when to skip",
1849
1853
  "",
@@ -3106,11 +3110,14 @@ var TOOLS = [
3106
3110
  },
3107
3111
  {
3108
3112
  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.",
3113
+ 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
3114
  inputSchema: {
3111
3115
  type: "object",
3112
3116
  properties: {
3113
- target: { type: "string", description: "File path or 'file::symbol' notation." },
3117
+ target: {
3118
+ type: "string",
3119
+ description: "File path (file-level dependents) or 'file::symbol' (caller symbols)."
3120
+ },
3114
3121
  depth: { type: "number", description: "Max hops to traverse. Default 3." }
3115
3122
  },
3116
3123
  required: ["target"]
@@ -3125,6 +3132,27 @@ var TOOLS = [
3125
3132
  limit: { type: "number", description: "Cap on returned files. Default 50." }
3126
3133
  }
3127
3134
  }
3135
+ },
3136
+ {
3137
+ name: "find_symbol",
3138
+ 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.",
3139
+ inputSchema: {
3140
+ type: "object",
3141
+ properties: {
3142
+ name: { type: "string", description: "Symbol name (or near-name) to look for." }
3143
+ },
3144
+ required: ["name"]
3145
+ }
3146
+ },
3147
+ {
3148
+ name: "duplicate_symbols",
3149
+ 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.",
3150
+ inputSchema: {
3151
+ type: "object",
3152
+ properties: {
3153
+ limit: { type: "number", description: "Cap on returned names. Default 30." }
3154
+ }
3155
+ }
3128
3156
  }
3129
3157
  ];
3130
3158
  async function callTool(name, args, ctx) {
@@ -3147,6 +3175,10 @@ async function callTool(name, args, ctx) {
3147
3175
  return blastRadius(args, ctx);
3148
3176
  case "dead_code":
3149
3177
  return deadCode(args, ctx);
3178
+ case "find_symbol":
3179
+ return findSymbol(args, ctx);
3180
+ case "duplicate_symbols":
3181
+ return duplicateSymbols(args, ctx);
3150
3182
  default:
3151
3183
  return errorContent(`Unknown tool: ${name}`);
3152
3184
  }
@@ -3161,7 +3193,8 @@ function blastRadius(args, ctx) {
3161
3193
  const targetRaw = typeof args?.target === "string" ? args.target.trim() : "";
3162
3194
  const maxDepth = typeof args?.depth === "number" && args.depth > 0 ? Math.floor(args.depth) : 3;
3163
3195
  if (!targetRaw) return errorContent("blast_radius: 'target' (string) is required");
3164
- const filePath = targetRaw.split("::", 1)[0]?.trim() ?? targetRaw;
3196
+ if (targetRaw.includes("::")) return blastRadiusSymbol(targetRaw, maxDepth, ctx);
3197
+ const filePath = targetRaw;
3165
3198
  const root = ctx.graph.nodes.find((n) => n.kind === "file" && n.path === filePath);
3166
3199
  if (!root) return errorContent(`blast_radius: file not in graph: ${filePath}`);
3167
3200
  const fileIdBySymbol = /* @__PURE__ */ new Map();
@@ -3216,6 +3249,92 @@ _(no dependents \u2014 file is isolated)_`);
3216
3249
  }
3217
3250
  return textContent(lines.join("\n"));
3218
3251
  }
3252
+ function blastRadiusSymbol(targetRaw, maxDepth, ctx) {
3253
+ const [rawFile, rawSym] = targetRaw.split("::", 2);
3254
+ const filePath = (rawFile ?? "").trim();
3255
+ const symName = (rawSym ?? "").trim();
3256
+ if (!symName) return errorContent("blast_radius: 'file::symbol' target needs a symbol name");
3257
+ const resolved = resolveFileTarget(ctx.graph, filePath);
3258
+ if ("ambiguous" in resolved) {
3259
+ const shown = resolved.ambiguous.slice(0, 5).join(", ");
3260
+ return errorContent(
3261
+ `blast_radius: '${filePath}' matches multiple files (${shown}). Pass a longer path.`
3262
+ );
3263
+ }
3264
+ if ("none" in resolved) return errorContent(`blast_radius: file not in graph: ${filePath}`);
3265
+ const fileNode = resolved.node;
3266
+ const symbol = ctx.graph.nodes.find(
3267
+ (n) => n.kind === "symbol" && n.file === fileNode.path && n.name === symName
3268
+ );
3269
+ if (!symbol)
3270
+ return errorContent(`blast_radius: symbol '${symName}' not found in ${fileNode.path}`);
3271
+ const callersBySym = /* @__PURE__ */ new Map();
3272
+ for (const e of ctx.graph.edges) {
3273
+ if (e.kind !== "calls" || e.from === e.to) continue;
3274
+ const list = callersBySym.get(e.to) ?? [];
3275
+ list.push(e.from);
3276
+ callersBySym.set(e.to, list);
3277
+ }
3278
+ const symById = /* @__PURE__ */ new Map();
3279
+ for (const n of ctx.graph.nodes) if (n.kind === "symbol") symById.set(n.id, n);
3280
+ const visited = /* @__PURE__ */ new Set([symbol.id]);
3281
+ const hits = [];
3282
+ let frontier = [symbol.id];
3283
+ for (let d = 1; d <= maxDepth; d++) {
3284
+ const next = [];
3285
+ for (const cur of frontier) {
3286
+ for (const fromId of callersBySym.get(cur) ?? []) {
3287
+ if (visited.has(fromId)) continue;
3288
+ visited.add(fromId);
3289
+ next.push(fromId);
3290
+ const s = symById.get(fromId);
3291
+ if (s) hits.push({ name: s.name, file: s.file, line: s.start_line, depth: d });
3292
+ }
3293
+ }
3294
+ frontier = next;
3295
+ if (next.length === 0) break;
3296
+ }
3297
+ const header = `# Blast radius for ${fileNode.path}::${symbol.name} (callers, depth \u2264 ${maxDepth})`;
3298
+ if (hits.length === 0) {
3299
+ const tline2 = testsCoveringLine(ctx.graph, [fileNode.path]);
3300
+ return textContent(
3301
+ `${header}
3302
+
3303
+ _(no callers \u2014 safe to rename)_${tline2 ? `
3304
+
3305
+ ${tline2}` : ""}`
3306
+ );
3307
+ }
3308
+ hits.sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file) || a.line - b.line);
3309
+ const lines = [header, "", `${hits.length} caller symbol(s):`];
3310
+ for (const h of hits) lines.push(`- **depth ${h.depth}** \`${h.name}\` \u2192 ${h.file}:${h.line}`);
3311
+ const tline = testsCoveringLine(ctx.graph, [fileNode.path, ...hits.map((h) => h.file)]);
3312
+ if (tline) {
3313
+ lines.push("");
3314
+ lines.push(tline);
3315
+ }
3316
+ return textContent(lines.join("\n"));
3317
+ }
3318
+ function testsCoveringLine(graph, filePaths) {
3319
+ const fileByPath = /* @__PURE__ */ new Map();
3320
+ for (const n of graph.nodes) if (n.kind === "file") fileByPath.set(n.path, n);
3321
+ const seen = /* @__PURE__ */ new Set();
3322
+ const tests = [];
3323
+ for (const p of new Set(filePaths)) {
3324
+ const fn = fileByPath.get(p);
3325
+ if (!fn) continue;
3326
+ for (const t of findTestsForFile(graph, fn)) {
3327
+ if (!seen.has(t.path)) {
3328
+ seen.add(t.path);
3329
+ tests.push(t.path);
3330
+ }
3331
+ }
3332
+ }
3333
+ if (tests.length === 0) return "";
3334
+ const shown = tests.slice(0, TESTS_MAX_FILES);
3335
+ const omitted = tests.length - shown.length;
3336
+ return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
3337
+ }
3219
3338
  var LIKELY_ENTRY_PATTERNS = [
3220
3339
  /(?:^|\/)main\.[a-z0-9_]+$/i,
3221
3340
  /(?:^|\/)index\.[a-z0-9_]+$/i,
@@ -3263,6 +3382,107 @@ _(no file is unreferenced \u2014 every file is either imported by another, has a
3263
3382
  );
3264
3383
  return textContent(lines.join("\n"));
3265
3384
  }
3385
+ var FIND_MAX = 12;
3386
+ var FIND_SIG_MAX = 140;
3387
+ function symbolEntry(s) {
3388
+ const sig = s.signature.trim().slice(0, FIND_SIG_MAX);
3389
+ return `\u2022 ${sig} \u2192 mcp__synthra__graph_read("${s.file}::${s.name}") [${s.symbol_kind}, L${s.start_line}]`;
3390
+ }
3391
+ var byFileLine = (a, b) => a.file === b.file ? a.start_line - b.start_line : a.file < b.file ? -1 : 1;
3392
+ function findSymbol(args, ctx) {
3393
+ const name = typeof args?.name === "string" ? args.name.trim() : "";
3394
+ if (!name) return errorContent("find_symbol: 'name' (string) is required");
3395
+ const symbols = ctx.graph.nodes.filter((n) => n.kind === "symbol");
3396
+ const lower = name.toLowerCase();
3397
+ const exact = symbols.filter((s) => s.name === name);
3398
+ const exactHits = exact.length > 0 ? exact : symbols.filter((s) => s.name.toLowerCase() === lower);
3399
+ if (exactHits.length > 0) {
3400
+ const sorted = exactHits.slice().sort(byFileLine);
3401
+ const shown2 = sorted.slice(0, FIND_MAX);
3402
+ const omitted2 = sorted.length - shown2.length;
3403
+ const lines2 = [
3404
+ `# find_symbol: "${name}"`,
3405
+ "",
3406
+ `Exact matches (${sorted.length}) \u2014 reuse one of these instead of writing a new one:`,
3407
+ ...shown2.map(symbolEntry)
3408
+ ];
3409
+ if (omitted2 > 0) lines2.push(`\u2026+${omitted2} more`);
3410
+ return textContent(lines2.join("\n"));
3411
+ }
3412
+ const tokens = new Set(tokenizeQuery(name));
3413
+ const scored = symbols.map((s) => {
3414
+ const n = s.name.toLowerCase();
3415
+ let score2 = 0;
3416
+ if (n.includes(lower) || lower.includes(n)) score2 += 2;
3417
+ for (const t of tokens) if (n.includes(t)) score2 += 1;
3418
+ return { s, score: score2 };
3419
+ }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score || byFileLine(a.s, b.s));
3420
+ if (scored.length === 0) {
3421
+ return textContent(
3422
+ `# find_symbol: "${name}"
3423
+
3424
+ No symbol matching "${name}" \u2014 safe to create.`
3425
+ );
3426
+ }
3427
+ const shown = scored.slice(0, FIND_MAX);
3428
+ const omitted = scored.length - shown.length;
3429
+ const lines = [
3430
+ `# find_symbol: "${name}"`,
3431
+ "",
3432
+ `No exact match. Similar names (${scored.length}) \u2014 reuse or extend one before writing new:`,
3433
+ ...shown.map((x) => symbolEntry(x.s))
3434
+ ];
3435
+ if (omitted > 0) lines.push(`\u2026+${omitted} more`);
3436
+ return textContent(lines.join("\n"));
3437
+ }
3438
+ var DUP_INCLUDE = /* @__PURE__ */ new Set([
3439
+ "function",
3440
+ "class",
3441
+ "interface",
3442
+ "type",
3443
+ "enum",
3444
+ "const",
3445
+ "component"
3446
+ ]);
3447
+ function duplicateSymbols(args, ctx) {
3448
+ const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : 30;
3449
+ const defsByName = /* @__PURE__ */ new Map();
3450
+ const filesByName = /* @__PURE__ */ new Map();
3451
+ for (const n of ctx.graph.nodes) {
3452
+ if (n.kind !== "symbol" || !DUP_INCLUDE.has(n.symbol_kind)) continue;
3453
+ (defsByName.get(n.name) ?? defsByName.set(n.name, []).get(n.name)).push({
3454
+ file: n.file,
3455
+ line: n.start_line
3456
+ });
3457
+ (filesByName.get(n.name) ?? filesByName.set(n.name, /* @__PURE__ */ new Set()).get(n.name)).add(n.file);
3458
+ }
3459
+ const dups = [...defsByName.entries()].filter(([name]) => (filesByName.get(name)?.size ?? 0) >= 2).map(([name, defs]) => ({
3460
+ name,
3461
+ defs: defs.slice().sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1)
3462
+ })).sort((a, b) => b.defs.length - a.defs.length || a.name.localeCompare(b.name));
3463
+ if (dups.length === 0) {
3464
+ return textContent(
3465
+ "# Duplicate symbols\n\n_(no top-level symbol name is defined in more than one file)_"
3466
+ );
3467
+ }
3468
+ const shown = dups.slice(0, limit);
3469
+ const lines = [
3470
+ "# Duplicate symbols (consolidation candidates)",
3471
+ "",
3472
+ `${shown.length} of ${dups.length} name(s) defined in multiple files (functions/classes/types; methods excluded):`,
3473
+ ""
3474
+ ];
3475
+ for (const d of shown) {
3476
+ lines.push(
3477
+ `- \`${d.name}\` (${d.defs.length}): ${d.defs.map((x) => `${x.file}:${x.line}`).join(" \xB7 ")}`
3478
+ );
3479
+ }
3480
+ lines.push("");
3481
+ lines.push(
3482
+ "_advisory: the same name in multiple files may be intentional \u2014 verify before consolidating._"
3483
+ );
3484
+ return textContent(lines.join("\n"));
3485
+ }
3266
3486
  async function graphContinue(args, ctx) {
3267
3487
  const query = typeof args?.query === "string" ? args.query : "";
3268
3488
  if (!query) return errorContent("graph_continue: 'query' (string) is required");
@@ -3293,6 +3513,7 @@ function resolveFileTarget(graph, filePath) {
3293
3513
  var DEPS_SIG_MAX = 140;
3294
3514
  var DEPS_MAX_CALLEES = 10;
3295
3515
  var DEPS_MAX_CALLERS = 12;
3516
+ var TESTS_MAX_FILES = 6;
3296
3517
  function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars) {
3297
3518
  const symById = /* @__PURE__ */ new Map();
3298
3519
  for (const n of graph.nodes) if (n.kind === "symbol") symById.set(n.id, n);
@@ -3356,6 +3577,21 @@ function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars
3356
3577
  }
3357
3578
  return lines.join("\n");
3358
3579
  }
3580
+ function buildTestsFooter(symbol, graph) {
3581
+ const fileNode = graph.nodes.find(
3582
+ (n) => n.kind === "file" && n.path === symbol.file
3583
+ );
3584
+ if (!fileNode) return "";
3585
+ const tests = findTestsForFile(graph, fileNode);
3586
+ if (tests.length > 0) {
3587
+ const shown = tests.slice(0, TESTS_MAX_FILES).map((t) => t.path);
3588
+ const omitted = tests.length - shown.length;
3589
+ const more = omitted > 0 ? ` \u2026+${omitted} more` : "";
3590
+ return `Tests (file-level): ${shown.join(" \xB7 ")}${more} \u2014 run after editing`;
3591
+ }
3592
+ if (isLikelyEntry(symbol.file)) return "";
3593
+ return "Tests: none linked to this file.";
3594
+ }
3359
3595
  async function graphRead(args, ctx) {
3360
3596
  const target = typeof args?.target === "string" ? args.target : "";
3361
3597
  if (!target) return errorContent("graph_read: 'target' (string) is required");
@@ -3399,10 +3635,15 @@ ${fileNode.content}`);
3399
3635
 
3400
3636
  ---
3401
3637
  ${deps}` : "";
3638
+ const tests = buildTestsFooter(symbol, ctx.graph);
3639
+ const testsBlock = tests ? `
3640
+
3641
+ ---
3642
+ ${tests}` : "";
3402
3643
  return textContent(
3403
3644
  `# ${fileNode.path}::${symbol.name} (L${symbol.start_line}-${symbol.end_line})
3404
3645
 
3405
- ${body}${depsBlock}${editHint}`
3646
+ ${body}${depsBlock}${testsBlock}${editHint}`
3406
3647
  );
3407
3648
  }
3408
3649
  var editedFiles = /* @__PURE__ */ new Set();