weavatrix 0.2.8 → 0.2.9

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.
@@ -11,8 +11,10 @@ import { safeRead } from "../util.js";
11
11
  import { createRepoBoundary } from "../repo-path.js";
12
12
  import { extractRustEndpoints } from "./endpoints-rust.js";
13
13
  import { extractSpringEndpoints } from "./endpoints-java.js";
14
+ import { posix } from "node:path";
14
15
 
15
16
  const MAX_FILES = 3000;
17
+ const MAX_ENDPOINTS = 2000;
16
18
  const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT", "ALL", "ANY"]);
17
19
  // UNAMBIGUOUS HTTP CLIENTS (make requests) vs servers (define routes) — reject `axios.get("/x")`-style client
18
20
  // calls in frontend code. Ambiguous names (api/client/service — could be a server router) are NOT listed;
@@ -84,6 +86,152 @@ const OPENAPI_BLOCK = /\boperationId\b|\bresponses\s*:|\brequestBody\b|\bschemaR
84
86
  const looksLikePath = (p) => typeof p === "string" && /^\/[\w\-./:{}*$?]*$/.test(p) && !p.includes("://");
85
87
  const cleanPath = (p) => String(p || "").replace(/\/+$/, "") || "/";
86
88
 
89
+ const JS_ROUTE_EXTENSIONS = [".js", ".ts", ".tsx", ".jsx", ".cjs", ".mjs"];
90
+ const normalizedFile = (file) => String(file || "").replace(/\\/g, "/").replace(/^\.\//, "");
91
+
92
+ function importBindings(text) {
93
+ const bindings = new Map();
94
+ const add = (name, specifier) => {
95
+ if (/^[A-Za-z_$][\w$]*$/.test(name || "") && typeof specifier === "string") bindings.set(name, specifier);
96
+ };
97
+ let match;
98
+ const direct = /\bimport\s+([A-Za-z_$][\w$]*)\s*(?:,\s*\{[^}]*\})?\s+from\s*(["'`])([^"'`]+)\2/g;
99
+ while ((match = direct.exec(text))) add(match[1], match[3]);
100
+ const namespace = /\bimport\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s*(["'`])([^"'`]+)\2/g;
101
+ while ((match = namespace.exec(text))) add(match[1], match[3]);
102
+ const named = /\bimport\s*\{([^}]+)\}\s*from\s*(["'`])([^"'`]+)\2/g;
103
+ while ((match = named.exec(text))) {
104
+ for (const item of match[1].split(",")) {
105
+ const binding = /^\s*[A-Za-z_$][\w$]*(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*$/.exec(item);
106
+ if (binding) add(binding[1] || item.trim(), match[3]);
107
+ }
108
+ }
109
+ const commonJs = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*(["'`])([^"'`]+)\2\s*\)/g;
110
+ while ((match = commonJs.exec(text))) add(match[1], match[3]);
111
+ return bindings;
112
+ }
113
+
114
+ function handlerReference(expr) {
115
+ const s = String(expr || "").trim();
116
+ if (!s || /=>/.test(s) || /^\s*(async\s+)?function\b/.test(s)) return "";
117
+ const refs = [...s.matchAll(/([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)+)/g)];
118
+ return refs.length ? refs.at(-1)[1].replace(/\s+/g, "") : "";
119
+ }
120
+
121
+ function resolveImportedFile(importer, specifier, availableFiles) {
122
+ if (!specifier?.startsWith(".")) return null;
123
+ const base = posix.normalize(posix.join(posix.dirname(importer), specifier));
124
+ const candidates = [base];
125
+ if (!JS_ROUTE_EXTENSIONS.some((extension) => base.endsWith(extension))) {
126
+ for (const extension of JS_ROUTE_EXTENSIONS) candidates.push(`${base}${extension}`);
127
+ for (const extension of JS_ROUTE_EXTENSIONS) candidates.push(`${base}/index${extension}`);
128
+ }
129
+ return candidates.find((candidate) => availableFiles.has(candidate)) || null;
130
+ }
131
+
132
+ function callArguments(text, openParen) {
133
+ let quote = "", escaped = false, depth = 0, start = openParen + 1;
134
+ const args = [];
135
+ for (let index = openParen; index < text.length; index++) {
136
+ const char = text[index];
137
+ if (quote) {
138
+ if (escaped) escaped = false;
139
+ else if (char === "\\") escaped = true;
140
+ else if (char === quote) quote = "";
141
+ continue;
142
+ }
143
+ if (char === '"' || char === "'" || char === "`") { quote = char; continue; }
144
+ if (char === "(") { depth++; continue; }
145
+ if (char === ")") {
146
+ depth--;
147
+ if (depth === 0) {
148
+ args.push(text.slice(start, index).trim());
149
+ return {args, end: index + 1};
150
+ }
151
+ continue;
152
+ }
153
+ if (char === "," && depth === 1) {
154
+ args.push(text.slice(start, index).trim());
155
+ start = index + 1;
156
+ }
157
+ }
158
+ return null;
159
+ }
160
+
161
+ function routerMounts(text, file, availableFiles) {
162
+ const scanText = maskComments(text);
163
+ const bindings = importBindings(scanText);
164
+ const mounts = [];
165
+ const useCall = /\b[A-Za-z_$][\w$]*\s*\.\s*use\s*\(/g;
166
+ let match;
167
+ while ((match = useCall.exec(scanText))) {
168
+ const openParen = scanText.indexOf("(", match.index);
169
+ const parsed = callArguments(scanText, openParen);
170
+ if (!parsed) continue;
171
+ useCall.lastIndex = parsed.end;
172
+ const args = parsed.args.filter(Boolean);
173
+ if (!args.length) continue;
174
+ const literal = /^(["'`])(\/[^"'`]*)\1$/.exec(args[0]);
175
+ const mountPath = literal ? cleanPath(literal[2]) : "/";
176
+ const childExpression = args.at(-1) || "";
177
+ const identifier = /^([A-Za-z_$][\w$]*)(?:\.[A-Za-z_$][\w$]*)?$/.exec(childExpression)?.[1];
178
+ const specifier = identifier ? bindings.get(identifier) : null;
179
+ const child = resolveImportedFile(file, specifier, availableFiles);
180
+ if (child && child !== file) mounts.push({parent: file, child, path: mountPath, line: lineAt(text, match.index)});
181
+ }
182
+ return mounts;
183
+ }
184
+
185
+ function joinEndpointPath(base, route) {
186
+ const left = cleanPath(base || "/");
187
+ const right = cleanPath(route || "/");
188
+ if (left === "/") return right;
189
+ if (right === "/") return left;
190
+ return cleanPath(`${left}/${right.replace(/^\/+/, "")}`.replace(/\/{2,}/g, "/"));
191
+ }
192
+
193
+ function mountedBasePaths(files, sources) {
194
+ const available = new Set(files);
195
+ const incoming = new Map();
196
+ const mounts = [];
197
+ for (const file of files) {
198
+ for (const mount of routerMounts(sources.get(file) || "", file, available)) {
199
+ mounts.push(mount);
200
+ if (!incoming.has(mount.child)) incoming.set(mount.child, []);
201
+ incoming.get(mount.child).push(mount);
202
+ }
203
+ }
204
+ const cache = new Map();
205
+ const resolve = (file, stack = new Set()) => {
206
+ if (cache.has(file)) return cache.get(file);
207
+ if (stack.has(file)) return [];
208
+ const parents = incoming.get(file) || [];
209
+ if (!parents.length) return [{path: "", chain: []}];
210
+ const nextStack = new Set(stack).add(file);
211
+ const paths = [];
212
+ for (const mount of parents) {
213
+ for (const base of resolve(mount.parent, nextStack)) {
214
+ const composed = joinEndpointPath(base.path, mount.path);
215
+ const key = `${composed}\0${base.chain.map((item) => `${item.file}:${item.line}:${item.path}`).join("|")}\0${mount.parent}:${mount.line}:${mount.path}`;
216
+ if (!paths.some((item) => item.key === key)) paths.push({
217
+ key,
218
+ path: composed,
219
+ chain: [...base.chain, {file: mount.parent, line: mount.line, path: mount.path, child: mount.child}],
220
+ });
221
+ if (paths.length >= 32) break;
222
+ }
223
+ if (paths.length >= 32) break;
224
+ }
225
+ const result = paths.length ? paths.map(({key, ...item}) => item) : [{path: "", chain: []}];
226
+ cache.set(file, result);
227
+ return result;
228
+ };
229
+ return {
230
+ paths: new Map(files.map((file) => [file, resolve(file)])),
231
+ mounts,
232
+ };
233
+ }
234
+
87
235
  export function nextRoutePath(file) {
88
236
  const parts = String(file || "").replace(/\\/g, "/").split("/").filter(Boolean);
89
237
  if (!/^route\.[cm]?[jt]s$/i.test(parts.at(-1) || "")) return "";
@@ -113,7 +261,9 @@ export function extractEndpointsFromText(text, file) {
113
261
  if (!looksLikePath(p)) return;
114
262
  const m = String(method || "ANY").toUpperCase();
115
263
  if (!HTTP_METHODS.has(m)) return;
116
- out.push({ method: m, path: p, handler: handlerName(expr), file, line: lineAt(text, idx) });
264
+ const handler = handlerName(expr);
265
+ const handlerRef = handlerReference(expr);
266
+ out.push({ method: m, path: p, handler, ...(handlerRef ? {handlerRef} : {}), file, line: lineAt(text, idx) });
117
267
  };
118
268
 
119
269
  // Next.js App Router: the filesystem provides the path and exported HTTP-method functions provide the
@@ -212,26 +362,76 @@ const normParamKey = (p) => String(p).replace(/\{([^/}]+)\}/g, ":$1");
212
362
  // Detect endpoints across the repo's code files (from the graph's file nodes, or a caller-supplied list
213
363
  // of {path, full}). Deduped by method+normalized-path; on a collision the entry with a resolvable
214
364
  // handler (and `:param` display) wins. Capped. Returns [{method, path, handler, file, line}].
215
- export function detectEndpoints(repoPath, codeFiles) {
365
+ export function analyzeEndpointInventory(repoPath, codeFiles) {
216
366
  const files = (codeFiles || []).slice(0, MAX_FILES);
217
367
  const byKey = new Map();
368
+ const declarations = new Map();
218
369
  const boundary = createRepoBoundary(repoPath);
370
+ const sources = new Map();
371
+ const eligibleFiles = [];
219
372
  for (const f of files) {
220
- const rel = f.path || f;
373
+ const rel = normalizedFile(f.path || f);
221
374
  if (!/\.(js|ts|tsx|jsx|cjs|mjs|py|go|rs|java)$/i.test(rel)) continue;
222
375
  const resolved = boundary.resolve(rel);
223
376
  if (!resolved.ok) continue;
224
377
  const text = safeRead(resolved.path);
378
+ sources.set(rel, text || "");
379
+ eligibleFiles.push(rel);
380
+ }
381
+ const mountAnalysis = mountedBasePaths(eligibleFiles, sources);
382
+ let truncated = false;
383
+ for (const rel of eligibleFiles) {
384
+ const text = sources.get(rel);
225
385
  if (!text || (!nextRoutePath(rel) && !/["'`]\/|\.(get|post|put|patch|delete)\s*\(|HandleFunc|@\w*\.?(get|post|put|patch|delete)|@(?:[\w$]+\.)*(?:Request|Get|Post|Put|Patch|Delete)Mapping\b/i.test(text))) continue;
226
386
  for (const e of extractEndpointsFromText(text, rel.replace(/\\/g, "/"))) {
227
- const key = `${e.method} ${normParamKey(e.path)}`;
228
- const prev = byKey.get(key);
229
- if (!prev) { byKey.set(key, e); }
230
- else if (preferEndpoint(e, prev)) { byKey.set(key, e); }
231
- if (byKey.size >= 500) return [...byKey.values()].sort(sortEndpoints);
387
+ const declarationKey = `${e.file}\0${e.line}\0${e.method}\0${normParamKey(e.path)}`;
388
+ if (!declarations.has(declarationKey)) declarations.set(declarationKey, e);
389
+ const bases = mountAnalysis.paths.get(rel) || [{path: "", chain: []}];
390
+ for (const base of bases) {
391
+ const composed = base.path ? joinEndpointPath(base.path, e.path) : e.path;
392
+ const endpoint = {
393
+ ...e,
394
+ declaredPath: e.path,
395
+ path: composed,
396
+ mountState: base.chain.length ? "COMPOSED_STATIC" : "DECLARED_LOCAL",
397
+ confidence: base.chain.length ? "high" : "medium",
398
+ mountChain: base.chain,
399
+ ...(base.path ? {localPath: e.path} : {}),
400
+ };
401
+ const key = `${endpoint.method} ${normParamKey(endpoint.path)}`;
402
+ const prev = byKey.get(key);
403
+ if (!prev) { byKey.set(key, endpoint); }
404
+ else if (preferEndpoint(endpoint, prev)) { byKey.set(key, endpoint); }
405
+ if (byKey.size >= MAX_ENDPOINTS) { truncated = true; break; }
406
+ }
407
+ if (truncated) break;
232
408
  }
409
+ if (truncated) break;
233
410
  }
234
- return [...byKey.values()].sort(sortEndpoints);
411
+ const endpoints = [...byKey.values()].sort(sortEndpoints);
412
+ const composed = endpoints.filter((endpoint) => endpoint.mountChain.length).length;
413
+ return {
414
+ endpoints,
415
+ declarations: [...declarations.values()].sort(sortEndpoints),
416
+ mounts: mountAnalysis.mounts,
417
+ stats: {
418
+ scannedFiles: eligibleFiles.length,
419
+ declaredRoutes: declarations.size,
420
+ emittedRoutes: endpoints.length,
421
+ reachableRoutes: composed,
422
+ reachableStaticRoutes: composed,
423
+ composedRoutes: composed,
424
+ localRoutes: endpoints.length - composed,
425
+ localDeclarations: endpoints.length - composed,
426
+ staticMounts: mountAnalysis.mounts.length,
427
+ truncated,
428
+ maxEndpoints: MAX_ENDPOINTS,
429
+ },
430
+ };
431
+ }
432
+
433
+ export function detectEndpoints(repoPath, codeFiles) {
434
+ return analyzeEndpointInventory(repoPath, codeFiles).endpoints;
235
435
  }
236
436
 
237
437
  // true when candidate `a` is a better representative of a route than the already-kept `b`:
@@ -26,14 +26,14 @@ const PROFILE_CAPS = Object.freeze({
26
26
 
27
27
  // The files whose mtime the stdio shell watches for hot reload — keep in sync with the imports in
28
28
  // loadHotApi below (catalog.mjs itself is last: a change here re-derives the whole table).
29
- export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs', 'tools-context.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'tools-verified-change.mjs', 'catalog.mjs']
29
+ export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs', 'tools-context.mjs', 'tools-endpoints.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'tools-verified-change.mjs', 'catalog.mjs']
30
30
 
31
- function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps}) {
31
+ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
32
32
  const tools = [
33
33
  {cap: 'graph', name: 'graph_stats', description: 'Return summary statistics: node count, edge count, communities, versioned edge-provenance/legacy-confidence breakdowns, and graph build time vs repo HEAD (staleness).', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tg.tGraphStats(g, ctx)},
34
34
  {cap: 'graph', name: 'get_node', description: 'Get full details for a specific node by label or ID.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID to look up'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNode(g, a, ctx)},
35
35
  {cap: 'graph', name: 'get_neighbors', description: 'Get all direct neighbors of a node with edge details (1 hop, call sites deduped). For transitive impact use get_dependents; for the impact of your current branch changes use change_impact.', inputSchema: {type: 'object', properties: {label: {type: 'string'}, relation_filter: {type: 'string', description: 'Optional: filter by relation type'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNeighbors(g, a, ctx)},
36
- {cap: 'graph', name: 'query_graph', description: 'Explore a focused production-first graph around a concept (BFS/DFS). Exact seed files stay pinned; classified paths and unreferenced constant/field leaves are suppressed unless the question or explicit flags request them. Reports policy and suppression instead of silently flooding the result.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Natural language question or keyword search'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Optional exact repo-relative file paths. When resolved, these are the only seeds unless augment_seeds is true'}, augment_seeds: {type: 'boolean', default: false, description: 'With seed_files, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, include_classified: {type: 'boolean', default: false, description: 'Allow traversal through tests/e2e/generated/mocks/stories/docs/benchmarks/temp and explicitly excluded paths. An explicit class term in the question enables only that class.'}, include_low_signal: {type: 'boolean', default: false, description: 'Include unreferenced constant/field leaf symbols that do not match a query term'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
36
+ {cap: 'graph', name: 'query_graph', description: 'Explore a focused production-first graph around a concept (BFS/DFS). Exact seed files stay pinned; a code-shaped identifier uses only exact same-name declarations. Classified paths and unreferenced constant/field leaves are suppressed unless the question or explicit flags request them. Reports policy and suppression instead of silently flooding the result.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Natural language question or keyword search'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Optional exact repo-relative file paths. When resolved, these are the only seeds unless augment_seeds is true'}, augment_seeds: {type: 'boolean', default: false, description: 'With seed_files, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, include_classified: {type: 'boolean', default: false, description: 'Allow traversal through tests/e2e/generated/mocks/stories/docs/benchmarks/temp and explicitly excluded paths. An explicit class term in the question enables only that class.'}, include_low_signal: {type: 'boolean', default: false, description: 'Include unreferenced constant/field leaf symbols that do not match a query term'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
37
37
  {cap: 'graph', name: 'god_nodes', description: 'Rank production-code connectivity hubs by unique call/import/reference neighbors, with class/method ownership reported separately from runtime connectivity. Repeated call sites do not inflate the rank; classified tests, generated/build output and other non-product paths are excluded by default.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}, include_classified: {type: 'boolean', default: false, description: 'Include tests/e2e/generated/build output/mocks/stories/docs/benchmarks/temp and paths explicitly excluded by repository classification'}}}, run: (g, a, ctx) => tg.tGodNodes(g, a, ctx)},
38
38
  {cap: 'graph', name: 'shortest_path', description: 'Find the shortest path between two concepts in the knowledge graph.', inputSchema: {type: 'object', properties: {source: {type: 'string'}, target: {type: 'string'}, max_hops: {type: 'integer', default: 8}}, required: ['source', 'target']}, run: (g, a) => tg.tShortestPath(g, a)},
39
39
  {cap: 'graph', name: 'get_dependents', description: 'Transitive blast-radius of ONE node: everything that calls/imports/inherits it, directly or through intermediaries (reverse edges, ranked by proximity then connectivity). Symbol queries stay symbol-precise by default; set include_container_importers only for a conservative module-wide radius. Use before refactoring; get_neighbors shows only 1 hop, change_impact covers your whole current diff at once.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID'}, depth: {type: 'integer', description: 'Max reverse hops, default 3', default: 3}, max_nodes: {type: 'integer', description: 'Max dependents to list, default 40', default: 40}, include_container_importers: {type: 'boolean', description: 'Also seed importers of the symbol containing file (broader, conservative; default false)', default: false}}, required: ['label']}, run: (g, a) => ti.tGetDependents(g, a)},
@@ -106,14 +106,15 @@ function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps}) {
106
106
  {cap: 'source', name: 'read_source', description: "Read the actual source of a node (by label/ID) or a repo-relative file path — the symbol's lines with context. The graph stores only locations, not source text. For a path read, pass start_line to anchor the window anywhere in the file (otherwise it shows the head).", inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'node label or ID'}, path: {type: 'string', description: 'or a repo-relative file path'}, start_line: {type: 'integer', description: 'anchor line: window = start_line-before .. start_line+after'}, before: {type: 'integer', default: 3}, after: {type: 'integer', default: 40}}}, run: (g, a, ctx) => readSource({repoRoot: ctx.repoRoot, resolveNode, isSymbol}, g, a)},
107
107
  {cap: 'source', refreshGraph: true, name: 'inspect_symbol', description: 'Inspect one exact symbol with an on-demand TypeScript/JavaScript LSP reference query, grouped occurrence containers, graph blast radius, complexity facts and bounded local source context. Ambiguous labels fail closed; point queries never replace the broad precision overlay.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_containers: {type: 'integer', minimum: 1, maximum: 50, default: 15}, context_lines: {type: 'integer', minimum: 0, maximum: 40, default: 8}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => ts.tInspectSymbol(g, a, ctx)},
108
108
  {cap: 'source', refreshGraph: true, name: 'context_bundle', description: 'Return one compact, bounded source bundle for an exact symbol: definition, aggregated inbound/outbound containers, exact re-export sites, on-demand TS/JS reference evidence and a few focused source excerpts. Use before an edit when query_graph would be too broad.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_related: {type: 'integer', minimum: 1, maximum: 30, default: 10}, max_reexports: {type: 'integer', minimum: 1, maximum: 100, default: 20}, max_source_files: {type: 'integer', minimum: 1, maximum: 8, default: 4}, context_lines: {type: 'integer', minimum: 0, maximum: 12, default: 4}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => tb.tContextBundle(g, a, ctx, ts.tInspectSymbol)},
109
- {cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Supports high-confidence small clones down to 12 tokens when min_tokens is lowered. Tests, e2e, generated code, mocks, stories, docs, benchmarks and temporary roots are classified separately and excluded by default; opt them in explicitly.", inputSchema: {type: 'object', properties: {min_similarity: {type: 'integer', description: '50-100, default 80 (ignored in semantic mode)'}, min_tokens: {type: 'integer', minimum: 12, maximum: 400, description: 'min fragment size, 12-400; default 50'}, mode: {type: 'string', enum: ['renamed', 'strict', 'semantic'], default: 'renamed'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, include_strings: {type: 'boolean', description: 'Also clone-check large multi-line string literals', default: false}, top_n: {type: 'integer', default: 15}}}, run: (g, a, ctx) => th.tFindDuplicates(g, a, ctx)},
109
+ {cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Supports high-confidence small clones down to 12 tokens when min_tokens is lowered. Tests, classified non-product paths, and all-router framework boilerplate clone groups are excluded by default; opt them in explicitly.", inputSchema: {type: 'object', properties: {min_similarity: {type: 'integer', description: '50-100, default 80 (ignored in semantic mode)'}, min_tokens: {type: 'integer', minimum: 12, maximum: 400, description: 'min fragment size, 12-400; default 50'}, mode: {type: 'string', enum: ['renamed', 'strict', 'semantic'], default: 'renamed'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, include_boilerplate: {type: 'boolean', default: false, description: 'Include clone groups made entirely of conventional *.router.js/ts router symbols'}, include_strings: {type: 'boolean', description: 'Also clone-check large multi-line string literals', default: false}, top_n: {type: 'integer', default: 15}}}, run: (g, a, ctx) => th.tFindDuplicates(g, a, ctx)},
110
110
  {cap: 'health', name: 'find_dead_code', description: 'Conservative review queue for statically unreferenced files, functions, methods and symbols. Returns confidence, reason, bounded evidence and explicit framework/dynamic/reflection/public-API caveats; never an auto-delete verdict. Tests, generated code, mocks, stories, docs, benchmarks and temporary roots are excluded by default.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repo-relative path prefix'}, kinds: {type: 'array', items: {type: 'string', enum: ['file', 'function', 'method', 'symbol']}, maxItems: 4, uniqueItems: true, description: 'Optional candidate kinds; defaults to all'}, min_confidence: {type: 'string', enum: ['high', 'medium', 'low'], default: 'medium', description: 'Minimum confidence to include. low explicitly includes public/framework/dynamic review candidates'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 30}}}, run: (g, a, ctx) => th.tFindDeadCode(g, a, ctx)},
111
- {cap: 'health', name: 'run_audit', description: 'Full internal health audit. With base_ref, builds and audits an immutable Git checkout, derives changed files, and compares stable deterministic finding IDs; debt defaults to genuinely new findings. Supply-chain checks remain explicitly uncomparable across the source-only baseline. changed_files without base_ref is only changed-scope, never a new-debt claim.', inputSchema: {type: 'object', properties: {category: {type: 'string', enum: ['unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category'}, min_severity: {type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Minimum severity to include'}, max_findings: {type: 'integer', description: 'Max findings to list, default 30'}, include_malware_scan: {type: 'boolean', description: 'Also grep installed packages for malware heuristics (slow)', default: false}, base_ref: {type: 'string', maxLength: 200, description: 'Optional immutable Git baseline (for example HEAD~1 or origin/main). Enables honest new/existing/fixed debt comparison'}, changed_files: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 500, description: 'Optional explicit repo-relative scope. Without base_ref this is changed-scope only; when omitted with base_ref, files are derived from the Git diff'}, debt: {type: 'string', enum: ['new', 'existing', 'all'], default: 'new', description: 'Baseline comparison view. Defaults to genuinely new deterministic findings when base_ref is present'}}}, run: (g, a, ctx) => th.tRunAudit(g, a, ctx)},
111
+ {cap: 'health', name: 'run_audit', description: 'Production-first internal health audit. Findings whose evidence is entirely test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded are suppressed by default; opt them in with include_classified. category=dependencies is a dedicated dependency-health projection across missing, unused and duplicate declarations while preserving each finding\'s native category. With base_ref, builds and audits an immutable Git checkout and compares stable deterministic finding IDs; debt defaults to genuinely new findings. Supply-chain checks remain explicitly uncomparable across the source-only baseline. changed_files without base_ref is only changed-scope, never a new-debt claim.', inputSchema: {type: 'object', properties: {category: {type: 'string', enum: ['dependencies', 'unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category; dependencies selects dependency manifest/import findings across native categories'}, min_severity: {type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Minimum severity to include'}, max_findings: {type: 'integer', description: 'Max findings to list, default 30'}, include_classified: {type: 'boolean', default: false, description: 'Include findings whose evidence is entirely tests/e2e/generated/mocks/stories/docs/benchmarks/temp or explicitly excluded'}, include_malware_scan: {type: 'boolean', description: 'Also grep installed packages for malware heuristics (slow)', default: false}, base_ref: {type: 'string', maxLength: 200, description: 'Optional immutable Git baseline (for example HEAD~1 or origin/main). Enables honest new/existing/fixed debt comparison'}, changed_files: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 500, description: 'Optional explicit repo-relative scope. Without base_ref this is changed-scope only; when omitted with base_ref, files are derived from the Git diff'}, debt: {type: 'string', enum: ['new', 'existing', 'all'], default: 'new', description: 'Baseline comparison view. Defaults to genuinely new deterministic findings when base_ref is present'}}}, run: (g, a, ctx) => th.tRunAudit(g, a, ctx)},
112
112
  {cap: 'health', name: 'coverage_map', description: 'Map a real existing coverage report onto the graph. If no report exists, return clearly labelled static test reachability (a test imports/reaches a source file) with actualCoverage=NOT_AVAILABLE; reachability is never presented as measured coverage.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max risk hotspots to list, default 15'}, path: {type: 'string', description: 'Optional repo-relative path prefix filter, e.g. src/query'}}}, run: (g, a, ctx) => th.tCoverageMap(g, a, ctx)},
113
113
  {cap: 'health', name: 'hot_path_review', description: 'Rank a focused production-symbol hot-path queue from parser-derived local complexity, inside-loop allocations/copies/scans/sorts/recursion, graph fan-in/fan-out, and measured coverage or clearly labelled static test reachability. The default score gate is 85 with a narrow strong-local fallback; set min_score=0 for the full diagnostic queue. This is not profiler data or interprocedural Big-O.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repository-relative path prefix'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 20}, min_score: {type: 'integer', minimum: 0, maximum: 100, default: 85, description: 'Focused default is 85; lower explicitly to broaden, or use 0 for every threshold-matching diagnostic candidate'}, cyclomatic_threshold: {type: 'integer', minimum: 2, maximum: 1000, default: 8}, call_threshold: {type: 'integer', minimum: 1, maximum: 10000, default: 12}, loop_depth_threshold: {type: 'integer', minimum: 1, maximum: 10, default: 2}, time_rank_threshold: {type: 'integer', minimum: 0, maximum: 5, default: 2}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and explicitly excluded paths; tests still require include_tests'}}}, run: (g, a, ctx) => th.tHotPathReview(g, a, ctx)},
114
114
  {cap: 'graph', name: 'list_communities', description: 'List graph communities named by their dominant folder (largest first) with sample files — a readable module overview; feed the list position into get_community.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max communities to list, default 20'}}}, run: (g, a, ctx) => th.tListCommunities(g, a, ctx)},
115
115
  {cap: 'graph', name: 'module_map', description: 'Production-first folder architecture map with file/symbol counts and strongest module dependencies, separating runtime, TypeScript type-only and language compile-only coupling.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max modules to list, default 25'}, include_non_product: {type: 'boolean', default: false, description: 'Include tests, fixtures, benchmarks, generated output, docs and other classified non-product files; false by default'}}}, run: (g, a, ctx) => th.tModuleMap(g, a, ctx)},
116
- {cap: 'source', refreshGraph: true, name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web/Spring MVC and WebFlux): method, composed path, handler, and file:line.', inputSchema: {type: 'object', properties: {max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
116
+ {cap: 'source', refreshGraph: true, name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web/Spring MVC and WebFlux): declared and reachable composed paths, static mount provenance, confidence, handler, and file:line.', inputSchema: {type: 'object', properties: {method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, path: {type: 'string', maxLength: 2048, description: 'Optional exact composed path or segment-aligned suffix'}, max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
117
+ {cap: 'source', refreshGraph: true, name: 'trace_endpoint', description: 'Resolve one exact reachable HTTP endpoint, prove its router mount chain, bind its handler symbol, and return a bounded production-only multi-hop call graph with call-site excerpts. This is a focused projection of the repository graph, not text-search inference.', inputSchema: {type: 'object', properties: {path: {type: 'string', minLength: 1, maxLength: 2048, description: 'Exact composed path; a suffix is accepted only when unambiguous'}, method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, max_depth: {type: 'integer', minimum: 1, maximum: 4, default: 3}, max_nodes: {type: 'integer', minimum: 2, maximum: 40, default: 20}, max_excerpts: {type: 'integer', minimum: 0, maximum: 12, default: 6}, context_lines: {type: 'integer', minimum: 0, maximum: 6, default: 2}, include_classified: {type: 'boolean', default: false, description: 'Include test/e2e/generated/mock/story/docs/benchmark/temp and explicitly excluded targets'}}, required: ['path']}, run: (g, a, ctx) => te.tTraceEndpoint(g, a, ctx)},
117
118
  {cap: 'build', name: 'rebuild_graph', description: "Rebuild the active full-repository graph and report a structural delta. Omitted mode/precision preserve the active graph; a first build uses full and the startup precision setting (lsp unless WEAVATRIX_PRECISION=off). The local TypeScript/JavaScript LSP overlay validates bounded ambiguous edges; precision:off is an explicit fallback. With scope, build an isolated diagnostic graph without replacing or diffing the full graph.", inputSchema: {type: 'object', properties: {mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], description: 'Build mode; omit to preserve the active mode (or use full for a first build)'}, precision: {type: 'string', enum: ['lsp', 'off'], description: 'Semantic precision; omit to preserve the active mode (or use the startup setting for a first build)'}, scope: {type: 'string', description: 'Optional isolated diagnostic path prefix; never replaces the active full graph'}}}, run: (g, a, ctx) => ta.tRebuildGraph(g, a, ctx)},
118
119
  {cap: 'graph', name: 'graph_diff', description: 'Structural graph diff: compare the current graph with an immutable Git-ref baseline (base_ref such as HEAD~1 or main), or with graph.prev.json from the last rebuild when base_ref is omitted. Reports architecture drift, cycle changes and symbols that lost their last caller.', inputSchema: {type: 'object', properties: {base_ref: {type: 'string', maxLength: 256, description: 'Optional immutable Git baseline to build in isolation, e.g. HEAD~1, main or origin/main; never checks out or mutates the working tree'}, path: {type: 'string', description: 'Optional node-id/path prefix to scope the diff, e.g. src/query'}}}, run: (g, a, ctx) => ti.tGraphDiff(g, a, ctx)},
119
120
  {cap: 'graph', name: 'get_architecture_contract', description: 'Return the executable target-architecture contract, quality budgets and ratchet mode that an agent must follow before editing.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tar.tGetArchitectureContract(g, a, ctx)},
@@ -124,8 +125,9 @@ function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps}) {
124
125
  {cap: 'retarget', name: 'open_repo', description: 'OFFLINE RETARGET: switch this server to another local Git repository, building its graph when missing. This explicit tool call changes the active repository boundary; pass build:false to probe without building. Omitted mode/precision preserve an existing graph; a new graph uses full and the startup precision setting (lsp unless WEAVATRIX_PRECISION=off). Omit the retarget capability at registration to pin one repository.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Absolute path to a Git working tree'}, build: {type: 'boolean', description: 'Build the graph when missing (default true)', default: true}, mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], description: 'Optional build mode override; omit to preserve an existing graph'}, precision: {type: 'string', enum: ['lsp', 'off'], description: 'Optional semantic precision override; omit to preserve an existing graph or use the startup setting for a new graph'}} , required: ['path']}, run: (g, a, ctx) => ta.tOpenRepo(g, a, ctx)},
125
126
  {cap: 'retarget', name: 'list_known_repos', description: 'OFFLINE RETARGET: list every registered local repository graph from the global per-user registry, regardless of parent folder.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tListKnownRepos(g, a, ctx)},
126
127
  {cap: 'advisories', name: 'refresh_advisories', description: "NETWORK / explicit advisories profile: refresh the local OSV advisory store for this repo's lockfile-pinned packages (npm/PyPI/Go). Sends package names + versions to OSV.dev — never automatic, never source code. Afterwards run_audit reads the refreshed store fully offline (~/.weavatrix/advisories.json).", inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', description: 'Per-request timeout, default 20000'}}}, run: (g, a, ctx) => ta.tRefreshAdvisories(g, a, ctx)},
127
- {cap: 'hosted', name: 'pull_architecture_contract', description: 'NETWORK / explicit hosted profile: fetch the owner-approved target architecture for the active stable repository UUID, validate it and cache it locally. Sends only the opaque UUID with bearer auth; never source or paths.', inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000}}}, run: (g, a, ctx) => ta.tPullArchitectureContract(g, a, ctx)},
128
- {cap: 'hosted', name: 'sync_graph', description: 'NETWORK / explicit hosted profile, off until configured: derive a bounded evidence snapshot locally and send it with an allowlisted graph payload to an endpoint you configure (env WEAVATRIX_SYNC_URL, WEAVATRIX_SYNC_TOKEN bearer auth). Local analyzers may read source files, but file bodies, snippets, absolute paths and unknown fields are never uploaded. Payload v3 includes architecture/health/stack/package evidence; request v2 only for graph-only compatibility.', inputSchema: {type: 'object', properties: {payload_version: {type: 'integer', enum: [2, 3], default: 3, description: '3 includes the evidence snapshot; 2 is graph-only compatibility and is never selected as a silent fallback'}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000, description: 'Network timeout in milliseconds'}}}, run: (g, a, ctx) => ta.tSyncGraph(g, a, ctx)},
128
+ {cap: 'hosted', name: 'pull_architecture_contract', description: 'NETWORK / explicit hosted profile: fetch the owner-approved target architecture for the active stable repository UUID, validate it and cache it locally. Sends only the opaque UUID with bearer auth; never source or paths. HTTP failures are returned as actionable AUTH_REQUIRED/FORBIDDEN/NOT_FOUND/REPOSITORY_NOT_READY states.', inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000}}}, run: (g, a, ctx) => ta.tPullArchitectureContract(g, a, ctx)},
129
+ {cap: 'hosted', name: 'preview_sync', description: 'LOCAL SAFETY PREVIEW / no network: serialize the exact bounded hosted payload, validate the HTTPS destination, and show hostname/path, repository UUID, fields, sections, node/edge counts, bytes and body hash. Returns a five-minute confirmation token for sync_graph. Source bodies, snippets, absolute paths, environment values, credentials, Git remotes and unknown fields are excluded.', inputSchema: {type: 'object', properties: {payload_version: {type: 'integer', enum: [2, 3], default: 3, description: '3 includes bounded architecture/health/stack/package evidence; 2 is explicit graph-only compatibility'}}}, run: (g, a, ctx) => ta.tPreviewSyncGraph(g, a, ctx)},
130
+ {cap: 'hosted', name: 'sync_graph', description: 'NETWORK / explicit hosted profile. Uploads only the exact payload created by preview_sync and requires dry_run:false plus its short-lived confirm_token after user approval. Calling without dry_run:false remains a no-network preview compatibility alias. Source bodies, snippets, absolute paths and unknown fields are never uploaded.', inputSchema: {type: 'object', properties: {payload_version: {type: 'integer', enum: [2, 3], default: 3, description: 'Used only when producing a compatibility preview; the approved token pins the exact serialized payload'}, dry_run: {type: 'boolean', default: true, description: 'Compatibility safety switch. Must be explicitly false to send.'}, confirm_token: {type: 'string', maxLength: 64, description: 'Short-lived token returned by preview_sync for the exact destination and payload'}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000, description: 'Network timeout in milliseconds'}}}, run: (g, a, ctx) => ta.tSyncGraph(g, a, ctx)},
129
131
  ]
130
132
  // Every tool supports the same machine-output switch. Text mode is TextContent-only so large
131
133
  // analysis payloads are not duplicated into an agent's context; JSON opts into structuredContent
@@ -153,12 +155,13 @@ function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps}) {
153
155
  // present string (even '') is an explicit selection, so "select nothing" really exposes nothing.
154
156
  export async function loadHotApi(version, capsArg) {
155
157
  const v = version ? `?v=${version}` : ''
156
- const [tg, ti, th, ts, tb, ta, tar, thi, tc, tv] = await Promise.all([
158
+ const [tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv] = await Promise.all([
157
159
  import(new URL(`./tools-graph.mjs${v}`, import.meta.url).href),
158
160
  import(new URL(`./tools-impact.mjs${v}`, import.meta.url).href),
159
161
  import(new URL(`./tools-health.mjs${v}`, import.meta.url).href),
160
162
  import(new URL(`./tools-source.mjs${v}`, import.meta.url).href),
161
163
  import(new URL(`./tools-context.mjs${v}`, import.meta.url).href),
164
+ import(new URL(`./tools-endpoints.mjs${v}`, import.meta.url).href),
162
165
  import(new URL(`./tools-actions.mjs${v}`, import.meta.url).href),
163
166
  import(new URL(`./tools-architecture.mjs${v}`, import.meta.url).href),
164
167
  import(new URL(`./tools-history.mjs${v}`, import.meta.url).href),
@@ -169,7 +172,7 @@ export async function loadHotApi(version, capsArg) {
169
172
  const selected = PROFILE_CAPS[raw] || raw.split(',').map((s) => s.trim()).filter(Boolean)
170
173
  .flatMap((cap) => cap === 'online' ? ['advisories', 'hosted'] : [cap])
171
174
  const caps = new Set(selected)
172
- const all = buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps})
175
+ const all = buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps})
173
176
  const tools = all.filter((t) => caps.has(t.cap))
174
177
  return {
175
178
  tools,