@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 +38 -0
- package/dist/cli/index.js +248 -7
- 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 +247 -6
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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 =
|
|
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: "
|
|
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: {
|
|
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
|
-
|
|
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();
|