@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 +18 -0
- package/dist/cli/index.js +116 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/index.js +1 -1
- package/dist/dashboard/index.js.map +1 -1
- package/dist/server/index.js +115 -4
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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: "
|
|
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: {
|
|
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
|
-
|
|
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();
|