weavatrix 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -168,6 +168,24 @@ disclosed instead of silently guessed; and the server **hot-reloads its watched
168
168
  modules and catalog** when those files change — other MCP helpers and analysis engines require a
169
169
  reconnect.
170
170
 
171
+ ### 0.2.1 bounded-output patch
172
+
173
+ - `git_history top_n=N` is a hard per-collection MCP cap, including churn, hotspots and every
174
+ coupling list. JSON output reports `total`, `returned` and `truncated` for each collection.
175
+ - `god_nodes` ranks production code by default, excluding classified tests and generated/build
176
+ artifacts; pass `include_classified:true` only when those surfaces are intentionally in scope.
177
+ - `output_format:"text"` returns concise TextContent only. Use `output_format:"json"` when a
178
+ workflow needs the full stable `weavatrix.tool.v1` structured envelope.
179
+ - `trace_api_contract` resolves bounded constant prefixes in template URLs and accepts
180
+ segment-aligned path fragments such as `/query` for `/edgeAnalytics/query/...`.
181
+ - `change_impact` labels test/e2e edits as `test-only` and does not seed product blast radius from
182
+ them.
183
+ - Without a saved architecture contract, `prepare_change` returns concise provisional budgets and
184
+ clearly labels them as non-enforceable; request `get_architecture_contract output_format:"json"`
185
+ only when the inferred starter contract is actually needed.
186
+
187
+ Full patch notes: [docs/releases/v0.2.1.md](docs/releases/v0.2.1.md).
188
+
171
189
  ## Signal quality and repository configuration
172
190
 
173
191
  Weavatrix `0.2.0` reduces the most common sources of static-analysis noise while deepening Rust and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weavatrix",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Weavatrix — code graph & blast-radius MCP server for AI coding agents: change impact, transitive dependents, health audit, duplicate detection, and coverage mapping over any local repository.",
6
6
  "author": "Sergii Ziborov <sergii.ziborov@gmail.com>",
package/skill/SKILL.md CHANGED
@@ -84,7 +84,9 @@ named profiles for new registrations.
84
84
 
85
85
  ## Recipes
86
86
 
87
- - **Orient in the configured repo**: `module_map` → `list_communities` → `god_nodes`.
87
+ - **Orient in the configured repo**: `module_map` → `list_communities` → `god_nodes`. Hub ranking
88
+ is production-only by default; use `include_classified:true` only when tests/generated/build
89
+ surfaces are deliberately part of the question.
88
90
  - **Refactor safety for one symbol**: `get_dependents` → `coverage_map` (low coverage × many
89
91
  dependents ⇒ write tests first) → `read_source`.
90
92
  - **Pre-PR review of your current changes**: `change_impact` (auto merge-base; includes uncommitted
@@ -114,14 +116,21 @@ named profiles for new registrations.
114
116
  - **API inventory**: `list_endpoints` (including Next.js App Router, Rust axum and actix-web).
115
117
  - **Cross-repository API impact**: ensure both repositories are in `list_known_repos`, then call
116
118
  `trace_api_contract backend=<uuid-or-label> clients=[<uuid-or-label>]`; narrow with `method`, `path`,
117
- or backend `changed_files`. Treat unresolved/dynamic URLs as incomplete evidence, not a clean result.
119
+ or backend `changed_files`. `path` may be a segment-aligned fragment (`/query` can select
120
+ `/edgeAnalytics/query/...`), and bounded constant prefixes in template URLs are resolved. Treat
121
+ remaining unresolved/dynamic URLs as incomplete evidence, not a clean result.
118
122
  - **Target architecture before editing**: `get_architecture_contract` → `prepare_change` with the
119
123
  intended files → edit and rebuild → `verify_architecture`. A missing contract returns a starter
120
- proposal, not an automatically approved architecture. Pull an owner-approved hosted contract only
121
- when the user selected `hosted` and explicitly asks for it.
124
+ proposal from `get_architecture_contract output_format:"json"`, not an automatically approved
125
+ architecture. Without a contract, `prepare_change` still returns provisional no-regression
126
+ budgets, but they are guidance rather than enforceable policy. Pull an owner-approved hosted
127
+ contract only when the user selected `hosted` and explicitly asks for it.
122
128
  - **Behavioral architecture**: `git_history` ranks churn × connectivity hotspots and hidden
123
- co-change coupling from bounded local numstat history. Use it as review evidence, not proof that two
124
- files must be merged.
129
+ co-change coupling from bounded local numstat history. Always set `top_n`; it is enforced across
130
+ every returned structured collection and JSON reports per-collection truncation. Use it as review
131
+ evidence, not proof that two files must be merged.
132
+ - **Machine output**: keep the default `output_format:"text"` for concise agent conversations; opt
133
+ into `output_format:"json"` only when a workflow consumes the full `weavatrix.tool.v1` envelope.
125
134
  - **Find code**: `search_code` (regex + glob) → `get_node` → `read_source`.
126
135
  - **Another repo**: `list_known_repos` → `open_repo <path>`
127
136
  (builds or upgrades the graph when needed; `build:false` probes without building).
@@ -4,6 +4,7 @@
4
4
  // from flooding the report with every legacy importer of that file.
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { childProcessEnv } from "../child-env.js";
7
+ import { createPathClassifier, hasPathClass } from "../path-classification.js";
7
8
 
8
9
  const DEFAULT_LIMITS = Object.freeze({
9
10
  maxDiffBytes: 2 * 1024 * 1024,
@@ -15,6 +16,7 @@ const DEFAULT_LIMITS = Object.freeze({
15
16
  });
16
17
  const CLASS_RANK = Object.freeze({
17
18
  "metadata-only": 0,
19
+ "test-only": 0,
18
20
  added: 1,
19
21
  "body-changed": 2,
20
22
  "signature-changed": 3,
@@ -395,6 +397,20 @@ function unknownFile(path, indexed, reason) {
395
397
  };
396
398
  }
397
399
 
400
+ function classifyTestSurface(file, pathClassifier) {
401
+ const explanation = pathClassifier.explain(file.path);
402
+ if (!hasPathClass(explanation, "test", "e2e")) return file;
403
+ const surface = explanation.classes.includes("e2e") ? "e2e" : "test";
404
+ return {
405
+ ...file,
406
+ classification: "test-only",
407
+ changeClassification: file.classification,
408
+ reason: `${surface} path; excluded from the product blast-radius seed set`,
409
+ pathClasses: explanation.classes,
410
+ seedIds: [],
411
+ };
412
+ }
413
+
398
414
  function runGitDiff(repoRoot, base, _files, limits) {
399
415
  const args = ["-C", repoRoot, "diff", "--no-ext-diff", "--find-renames", "--no-color", "--unified=0", String(base), "--"];
400
416
  const result = spawnSync("git", args, {
@@ -443,17 +459,25 @@ export function classifyChangeImpact({
443
459
  }
444
460
 
445
461
  const indexed = graphIndex(graph, limits);
462
+ const pathClassifier = createPathClassifier(repoRoot);
446
463
  const parsed = available ? parseZeroContextDiff(text, limits) : { files: [], changedLines: 0, byteLength: 0, truncated: gitOversized, oversized: gitOversized, limits };
447
- const analyzed = parsed.files.map((file) => analyzeParsedFile(file, indexed, { includeAddedSeeds }));
464
+ const analyzed = parsed.files.map((file) => classifyTestSurface(
465
+ analyzeParsedFile(file, indexed, { includeAddedSeeds }),
466
+ pathClassifier,
467
+ ));
448
468
  const represented = new Set(analyzed.flatMap((file) => [file.oldPath, file.newPath].filter(Boolean)));
449
469
  for (const file of explicitFiles) {
450
- if (!represented.has(file)) analyzed.push(unknownFile(file, indexed, available ? "explicitly changed file had no textual hunk" : unavailableReason));
470
+ if (!represented.has(file)) analyzed.push(classifyTestSurface(
471
+ unknownFile(file, indexed, available ? "explicitly changed file had no textual hunk" : unavailableReason),
472
+ pathClassifier,
473
+ ));
451
474
  }
452
475
  if (!available && !explicitFiles.length) analyzed.push(unknownFile("(diff unavailable)", indexed, unavailableReason));
453
476
  analyzed.sort((a, b) => a.path.localeCompare(b.path));
454
477
 
455
478
  if (parsed.truncated || parsed.oversized || gitOversized) {
456
479
  for (const file of analyzed) {
480
+ if (file.classification === "test-only") continue;
457
481
  file.classification = "unknown";
458
482
  file.reason = "diff was truncated/oversized; symbol-level classification is incomplete";
459
483
  const record = indexed.get(file.newPath) || indexed.get(file.oldPath);
@@ -480,6 +504,7 @@ export function classifyChangeImpact({
480
504
  if (counts["body-changed"]) reasons.push(`${counts["body-changed"]} file(s) contain mapped executable body changes.`);
481
505
  if (counts.added && !includeAddedSeeds) reasons.push(`${counts.added} purely additive file change(s) create no dependent seeds by default.`);
482
506
  if (counts["metadata-only"]) reasons.push(`${counts["metadata-only"]} metadata-only file change(s) create no dependent seeds.`);
507
+ if (counts["test-only"]) reasons.push(`${counts["test-only"]} test-only file change(s) are labelled explicitly and create no product blast-radius seeds.`);
483
508
  if (counts.unknown) reasons.push(`${counts.unknown} file change(s) remain unknown and are seeded conservatively.`);
484
509
  if (!reasons.length) reasons.push("No changed files were present in the supplied diff.");
485
510
 
@@ -547,3 +547,45 @@ export function formatGitHistoryAnalytics(result, options = {}) {
547
547
  if (result.completeness.reasons.length) lines.push("", `Partial: ${result.completeness.reasons.join("; ")}.`);
548
548
  return lines.join("\n");
549
549
  }
550
+
551
+ // Keep the full analytics object available to local callers, but never expose its unbounded
552
+ // collections through an MCP response. `topN` is a per-collection ceiling, not merely a text
553
+ // formatting hint. The accompanying page metadata lets machine consumers distinguish "there were
554
+ // no more results" from "more evidence exists but was deliberately omitted".
555
+ export function boundGitHistoryAnalytics(result, options = {}) {
556
+ const topN = boundedInteger(options.topN, 10, 1, 50);
557
+ const source = result && typeof result === "object" ? result : {};
558
+ const coupling = source.coupling && typeof source.coupling === "object" ? source.coupling : {};
559
+ const collections = {};
560
+ const cap = (name, value) => {
561
+ const items = Array.isArray(value) ? value : [];
562
+ const bounded = items.slice(0, topN);
563
+ collections[name] = {
564
+ total: items.length,
565
+ returned: bounded.length,
566
+ truncated: items.length > bounded.length,
567
+ };
568
+ return bounded;
569
+ };
570
+
571
+ const bounded = {
572
+ ...source,
573
+ limits: {...(source.limits || {}), topN},
574
+ fileChurn: cap("fileChurn", source.fileChurn),
575
+ hotspots: cap("hotspots", source.hotspots),
576
+ coupling: {
577
+ ...coupling,
578
+ observed: cap("coupling.observed", coupling.observed),
579
+ expectedTestSource: cap("coupling.expectedTestSource", coupling.expectedTestSource),
580
+ hidden: cap("coupling.hidden", coupling.hidden),
581
+ },
582
+ };
583
+ return {
584
+ result: bounded,
585
+ page: {
586
+ limit: topN,
587
+ truncated: Object.values(collections).some((entry) => entry.truncated),
588
+ collections,
589
+ },
590
+ };
591
+ }
@@ -148,7 +148,7 @@ function quotedArgument(text, start, quote) {
148
148
  return null;
149
149
  }
150
150
 
151
- function templateArgument(text, start) {
151
+ function templateArgument(text, start, constants = null, requireStatic = false) {
152
152
  let value = "";
153
153
  let dynamicSegments = 0;
154
154
  let unknownPrefix = false;
@@ -182,6 +182,13 @@ function templateArgument(text, start) {
182
182
  cursor += 1;
183
183
  }
184
184
  if (depth !== 0) return null;
185
+ const expression = text.slice(index + 2, cursor - 1).trim();
186
+ if (/^[A-Za-z_$][\w$]*$/.test(expression) && constants?.has(expression)) {
187
+ value += constants.get(expression);
188
+ index = cursor - 1;
189
+ continue;
190
+ }
191
+ if (requireStatic) return null;
185
192
  const next = text[cursor];
186
193
  const leftBoundary = value === "" || value.endsWith("/");
187
194
  const rightBoundary = !next || next === "/" || next === "?" || next === "#" || next === "`";
@@ -194,7 +201,41 @@ function templateArgument(text, start) {
194
201
  return null;
195
202
  }
196
203
 
197
- function parseUrlArgument(text, openParen) {
204
+ function extractStaticStringConstants(text) {
205
+ const source = String(text || "");
206
+ const mask = maskNonCode(source);
207
+ const declarations = [];
208
+ const declaration = /\bconst\s+([A-Za-z_$][\w$]*)\s*=/g;
209
+ let match;
210
+ while ((match = declaration.exec(mask)) && declarations.length < 500) {
211
+ let start = match.index + match[0].length;
212
+ while (/\s/.test(source[start] || "")) start += 1;
213
+ declarations.push({ name: match[1], start });
214
+ }
215
+
216
+ const constants = new Map();
217
+ for (const item of declarations) {
218
+ const quote = source[item.start];
219
+ if (quote !== "'" && quote !== '"') continue;
220
+ const parsed = quotedArgument(source, item.start, quote);
221
+ if (parsed && parsed.value.length <= 2_048) constants.set(item.name, parsed.value);
222
+ }
223
+ // Resolve bounded chains such as `const route = `${API_ROOT}/query`` without evaluating code.
224
+ for (let pass = 0; pass < Math.min(8, declarations.length); pass += 1) {
225
+ let changed = false;
226
+ for (const item of declarations) {
227
+ if (constants.has(item.name) || source[item.start] !== "`") continue;
228
+ const parsed = templateArgument(source, item.start, constants, true);
229
+ if (!parsed || parsed.value.length > 2_048) continue;
230
+ constants.set(item.name, parsed.value);
231
+ changed = true;
232
+ }
233
+ if (!changed) break;
234
+ }
235
+ return constants;
236
+ }
237
+
238
+ function parseUrlArgument(text, openParen, constants) {
198
239
  let start = openParen + 1;
199
240
  while (/\s/.test(text[start] || "")) start += 1;
200
241
  const quote = text[start];
@@ -204,7 +245,7 @@ function parseUrlArgument(text, openParen) {
204
245
  return { path: normalizeHttpContractPath(parsed.value), endIndex: parsed.endIndex, kind: "literal", dynamic: false, unknownPrefix: false, partialDynamic: false, reason: null };
205
246
  }
206
247
  if (quote === "`") {
207
- const parsed = templateArgument(text, start);
248
+ const parsed = templateArgument(text, start, constants);
208
249
  if (!parsed) return { path: null, endIndex: start, kind: "dynamic", dynamic: true, reason: "unterminated URL template" };
209
250
  return {
210
251
  path: normalizeHttpContractPath(parsed.value),
@@ -239,13 +280,14 @@ function normalizedClientNames(values) {
239
280
  export function extractHttpClientCallsFromText(text, file, options = {}) {
240
281
  const source = String(text || "");
241
282
  const mask = maskNonCode(source);
283
+ const constants = extractStaticStringConstants(source);
242
284
  const allowed = normalizedClientNames(options.clientNames);
243
285
  const maxCalls = boundedInteger(options.maxCalls, DEFAULTS.maxCallsPerClient, 1, HARD.maxCallsPerClient);
244
286
  const calls = [];
245
287
  let truncated = false;
246
288
  const add = (clientName, method, openParen, fetch = false) => {
247
289
  if (calls.length >= maxCalls) { truncated = true; return; }
248
- const parsed = parseUrlArgument(source, openParen);
290
+ const parsed = parseUrlArgument(source, openParen, constants);
249
291
  const fetchInfo = fetch ? fetchMethod(source, parsed.endIndex) : { method: method.toUpperCase(), uncertain: false };
250
292
  calls.push({
251
293
  file: normalizeFile(file),
@@ -334,6 +376,14 @@ function suffixShapeMatch(endpointSegments, callSegments) {
334
376
  return routeShapeMatches(endpointSegments.slice(endpointSegments.length - callSegments.length), callSegments);
335
377
  }
336
378
 
379
+ function routeShapeContains(endpointSegments, requestedSegments) {
380
+ if (!requestedSegments.length || requestedSegments.length > endpointSegments.length) return false;
381
+ for (let start = 0; start <= endpointSegments.length - requestedSegments.length; start += 1) {
382
+ if (routeShapeMatches(endpointSegments.slice(start, start + requestedSegments.length), requestedSegments)) return true;
383
+ }
384
+ return false;
385
+ }
386
+
337
387
  function methodMatches(endpointMethod, callMethod) {
338
388
  return endpointMethod === "ANY" || endpointMethod === "ALL" || endpointMethod === callMethod;
339
389
  }
@@ -450,7 +500,9 @@ function endpointFilter(endpoint, backendId, options) {
450
500
  if (options.method && endpoint.method !== options.method && endpoint.method !== "ANY" && endpoint.method !== "ALL") return false;
451
501
  if (options.path) {
452
502
  const requested = normalizeHttpContractPath(options.path);
453
- if (!requested || !routeShapeMatches(pathSegments(normalizeHttpContractPath(endpoint.path)), pathSegments(requested))) return false;
503
+ const endpointSegments = pathSegments(normalizeHttpContractPath(endpoint.path));
504
+ const requestedSegments = pathSegments(requested);
505
+ if (!requested || (!routeShapeMatches(endpointSegments, requestedSegments) && !routeShapeContains(endpointSegments, requestedSegments))) return false;
454
506
  }
455
507
  if (options.changedFiles?.size) {
456
508
  const file = normalizeFile(endpoint.file);
@@ -34,12 +34,12 @@ function buildTools({tg, ti, th, ta, tar, thi, tc}) {
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
36
  {cap: 'graph', name: 'query_graph', description: 'Explore the graph around a concept (BFS/DFS). Returns a focused, ranked subgraph — the closest, most-connected nodes near the matched seeds, with edges among them — not the full flood; states how many nodes were reached vs shown.', 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'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a) => tg.tQueryGraph(g, a)},
37
- {cap: 'graph', name: 'god_nodes', description: 'Rank 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.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}}}, run: (g, a) => tg.tGodNodes(g, a)},
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). For a symbol, also follows importers of its containing file. 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}}, required: ['label']}, run: (g, a) => ti.tGetDependents(g, a)},
40
40
  {cap: 'graph', name: 'change_impact', description: 'Verdict-first, symbol-aware blast radius. Parses a zero-context git diff, distinguishes additive exports from signature/body/removal risk, and reverse-traverses only exact changed seeds so a new API does not inherit every legacy importer of its file. Measured coverage is used when present; otherwise static test reachability is labelled actualCoverage=NOT_AVAILABLE. Pass `diff` for a not-checked-out PR; `files` without a diff remains a conservative fallback.', inputSchema: {type: 'object', properties: {base: {type: 'string', description: 'Base ref, e.g. origin/main or HEAD~1 (default: first existing of origin/HEAD, origin/main, origin/master, main, master)'}, diff: {type: 'string', maxLength: 2097152, description: 'Optional unified diff (prefer --unified=0) for a PR/change that is not checked out; enables symbol-level classification'}, files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional repo-relative changed-file hints. Without diff evidence these are classified conservatively rather than guessed additive'}, depth: {type: 'integer', description: 'Max reverse hops, default 2'}, max_nodes: {type: 'integer', description: 'Max impacted nodes to list, default 40'}}}, run: (g, a, ctx) => ti.tChangeImpact(g, a, ctx)},
41
41
  {cap: 'graph', name: 'git_history', description: 'Behavioral architecture evidence from bounded local git history: churn × connectivity hotspots, hidden co-change coupling, and expected test/source coupling. Reads numstat only — never commit messages, authors, or source bodies.', inputSchema: {type: 'object', properties: {months: {type: 'integer', enum: [3, 6, 12], default: 6}, max_commits: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, min_pair_count: {type: 'integer', minimum: 2, maximum: 100, default: 3}, max_pairs: {type: 'integer', minimum: 1, maximum: 500, default: 100}, top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10}}}, run: (g, a, ctx) => thi.tGitHistory(g, a, ctx)},
42
- {cap: 'crossrepo', name: 'trace_api_contract', description: 'Cross-repository HTTP contract and blast-radius evidence. Select a registered backend and one or more registered clients by stable repository UUID or unambiguous label; joins backend endpoints to static client calls and follows client importers to affected screens. Repository paths stay local and cannot be supplied through this tool. This capability is intentionally absent from the pinned profile.', inputSchema: {type: 'object', properties: {backend: {type: 'string', description: 'Backend repository UUID or exact unambiguous registry label'}, clients: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 20, description: 'Client repository UUIDs or exact unambiguous registry labels'}, method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']}, path: {type: 'string', maxLength: 2048, description: 'Optional endpoint route filter; {id}, :id and concrete parameter values are normalized'}, changed_files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional backend repo-relative changed files; only endpoints declared in those files are traced'}, include_tests: {type: 'boolean', default: false}, max_impact_depth: {type: 'integer', minimum: 0, maximum: 5, default: 2}, max_endpoints: {type: 'integer', minimum: 1, maximum: 500, default: 250}, max_matches: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_affected_files: {type: 'integer', minimum: 1, maximum: 500, default: 100}, top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10}}, required: ['backend', 'clients']}, run: (g, a, ctx) => tc.tTraceApiContract(g, a, ctx)},
42
+ {cap: 'crossrepo', name: 'trace_api_contract', description: 'Cross-repository HTTP contract and blast-radius evidence. Select a registered backend and one or more registered clients by stable repository UUID or unambiguous label; joins backend endpoints to static client calls and follows client importers to affected screens. Repository paths stay local and cannot be supplied through this tool. This capability is intentionally absent from the pinned profile.', inputSchema: {type: 'object', properties: {backend: {type: 'string', description: 'Backend repository UUID or exact unambiguous registry label'}, clients: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 20, description: 'Client repository UUIDs or exact unambiguous registry labels'}, method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']}, path: {type: 'string', maxLength: 2048, description: 'Optional full route or segment-aligned route fragment; /query matches /edgeAnalytics/query/... and {id}, :id and concrete parameter values are normalized'}, changed_files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional backend repo-relative changed files; only endpoints declared in those files are traced'}, include_tests: {type: 'boolean', default: false}, max_impact_depth: {type: 'integer', minimum: 0, maximum: 5, default: 2}, max_endpoints: {type: 'integer', minimum: 1, maximum: 500, default: 250}, max_matches: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_affected_files: {type: 'integer', minimum: 1, maximum: 500, default: 100}, top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10}}, required: ['backend', 'clients']}, run: (g, a, ctx) => tc.tTraceApiContract(g, a, ctx)},
43
43
  {cap: 'graph', name: 'get_community', description: 'Get all nodes in a community by community ID (0-indexed by size).', inputSchema: {type: 'object', properties: {community_id: {type: 'integer', description: 'Community ID (0-indexed by size)'}}, required: ['community_id']}, run: (g, a) => tg.tGetCommunity(g, a)},
44
44
  {cap: 'search', name: 'search_code', description: 'Full-text or regex search across the repo source (ripgrep-backed, Node fallback). The graph only stores structure — use this to find literal text/patterns, then get_node/get_neighbors for structure.', inputSchema: {type: 'object', properties: {query: {type: 'string', description: 'text or regex to search for'}, is_regex: {type: 'boolean', default: false}, glob: {type: 'string', description: 'optional path glob, e.g. "*.js" or "src/**"'}, max_results: {type: 'integer', default: 40}}, required: ['query']}, run: (g, a, ctx) => searchCode({repoRoot: ctx.repoRoot, resolveRg}, a)},
45
45
  {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)},
@@ -63,8 +63,10 @@ function buildTools({tg, ti, th, ta, tar, thi, tc}) {
63
63
  {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)},
64
64
  {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)},
65
65
  ]
66
- // Every tool supports the same machine-output switch. structuredContent is returned regardless;
67
- // output_format=json mirrors that envelope into TextContent for older workflow runners.
66
+ // Every tool supports the same machine-output switch. Text mode is TextContent-only so large
67
+ // analysis payloads are not duplicated into an agent's context; JSON opts into structuredContent
68
+ // and mirrors that envelope into TextContent for older workflow runners. Since MCP output schemas
69
+ // apply to every invocation, tools do not advertise one conditionally here.
68
70
  return tools.map((tool) => ({
69
71
  ...tool,
70
72
  inputSchema: {
@@ -75,11 +77,10 @@ function buildTools({tg, ti, th, ta, tar, thi, tc}) {
75
77
  type: 'string',
76
78
  enum: ['text', 'json'],
77
79
  default: 'text',
78
- description: 'text keeps the concise summary; json mirrors the stable structuredContent envelope',
80
+ description: 'text returns only the concise TextContent summary; json also returns and mirrors the stable structuredContent envelope',
79
81
  },
80
82
  },
81
83
  },
82
- outputSchema: {type: 'object', additionalProperties: true},
83
84
  }))
84
85
  }
85
86
 
@@ -1,7 +1,6 @@
1
- // MCP tool results keep a concise human summary and a stable machine-readable envelope together.
2
- // Older clients continue to consume TextContent; MCP 2025-06 clients can use structuredContent
3
- // directly without scraping prose. Tool implementations may opt into richer `result` data through
4
- // toolResult(), while legacy string-returning tools still receive a deterministic envelope.
1
+ // Text mode stays genuinely compact for agents and older clients: it returns TextContent only.
2
+ // JSON mode adds the stable machine-readable structuredContent envelope and mirrors that envelope
3
+ // into TextContent for workflow runners that cannot consume structured results directly.
5
4
 
6
5
  export const TOOL_RESULT_SCHEMA = 'weavatrix.tool.v1'
7
6
 
@@ -44,8 +43,12 @@ export function normalizeToolResult({toolName, value, args, ctx, refresh, warnin
44
43
  page: rich ? (value.page || {}) : {},
45
44
  ...(rich && value.completeness ? {completeness: value.completeness} : {}),
46
45
  }
46
+ const json = args?.output_format === 'json'
47
47
  return {
48
- text: args?.output_format === 'json' ? JSON.stringify(structured, null, 2) : `Repository: ${structured.repo.name}\n${text}`,
49
- structured,
48
+ text: json ? JSON.stringify(structured, null, 2) : `Repository: ${structured.repo.name}\n${text}`,
49
+ // MCP output schemas apply to every invocation of a tool. The catalog intentionally does not
50
+ // advertise one because text mode must not attach a second, potentially very large payload.
51
+ // JSON callers still receive the stable structured result as an optional MCP extension.
52
+ structured: json ? structured : undefined,
50
53
  }
51
54
  }
@@ -1,9 +1,26 @@
1
1
  // Executable target architecture for agents: read the active contract before a change, then run the
2
2
  // same deterministic verifier after it. No tool silently edits policy or accepts debt.
3
3
  import {contractForChange, loadArchitectureContract, normalizeArchitectureContract, verifyArchitecture} from '../analysis/architecture-contract.js'
4
+ import {createPathClassifier, hasPathClass} from '../path-classification.js'
4
5
  import {detectRepoStack} from '../scan/discover.js'
5
6
  import {toolResult} from './tool-result.mjs'
6
7
 
8
+ const PROVISIONAL_BUDGETS = Object.freeze({
9
+ runtimeCycles: 0,
10
+ maxFileLoc: 300,
11
+ maxFunctionLoc: 120,
12
+ maxCyclomatic: 15,
13
+ maxModuleFiles: 80,
14
+ minModuleCohesion: .5,
15
+ maxModuleBoundaryRatio: .65,
16
+ })
17
+
18
+ const remediation = () => ({
19
+ offlinePath: '.weavatrix/architecture.json',
20
+ hostedAction: 'Open Architecture -> choose intended style -> Save target & baseline',
21
+ nextTool: 'verify_architecture',
22
+ })
23
+
7
24
  const stackIds = (repoRoot) => {
8
25
  try {
9
26
  const stack = detectRepoStack(repoRoot)
@@ -40,25 +57,62 @@ function starterContract(g) {
40
57
  return normalizeArchitectureContract({
41
58
  name: 'Proposed no-regressions baseline', style: 'custom', enforcement: 'ratchet', components,
42
59
  dependencyRules: [],
43
- budgets: {runtimeCycles: 0, maxFileLoc: 300, maxFunctionLoc: 120, maxCyclomatic: 15, maxModuleFiles: 80, minModuleCohesion: .5, maxModuleBoundaryRatio: .65},
60
+ budgets: PROVISIONAL_BUDGETS,
44
61
  technologies: {required: [], forbidden: []}, exceptions: [], ratchet: {baseline: {fingerprints: [], metrics: {}}},
45
62
  })
46
63
  }
47
64
 
48
- function notConfiguredResult(g, action) {
49
- const starter = starterContract(g)
65
+ function notConfiguredResult(g, action, {includeStarter = false} = {}) {
66
+ const starter = includeStarter ? starterContract(g) : null
67
+ const starterText = starter
68
+ ? ` A source-free starter with ${starter.components.length} path territories is available in JSON output from this lookup.`
69
+ : ''
70
+ return toolResult([
71
+ `Architecture ${action} is NOT_CONFIGURED — no target contract is active.${starterText}`,
72
+ 'Next: save .weavatrix/architecture.json (offline) or approve a target in Hosted, then call verify_architecture.',
73
+ ].join('\n'), {
74
+ state: 'NOT_CONFIGURED',
75
+ remediation: remediation(),
76
+ ...(starter ? {
77
+ starterSummary: {components: starter.components.length, budgets: starter.budgets},
78
+ starterContract: starter,
79
+ } : {}),
80
+ })
81
+ }
82
+
83
+ function classifyChangeFiles(files, repoRoot) {
84
+ const classifier = createPathClassifier(repoRoot)
85
+ const normalized = [...new Set((Array.isArray(files) ? files : [])
86
+ .slice(0, 200)
87
+ .map((file) => String(file || '').replace(/\\/g, '/').replace(/^\.\//, ''))
88
+ .filter((file) => file && !file.startsWith('../') && !file.includes('/../')))]
89
+ .sort((a, b) => a.localeCompare(b))
90
+ const testOnlyFiles = normalized.filter((file) => hasPathClass(classifier.explain(file), 'test', 'e2e'))
91
+ const testOnly = new Set(testOnlyFiles)
92
+ return {files: normalized, productFiles: normalized.filter((file) => !testOnly.has(file)), testOnlyFiles}
93
+ }
94
+
95
+ function provisionalPreflight(g, args, ctx) {
96
+ const surfaces = classifyChangeFiles(args?.files, ctx?.repoRoot)
97
+ const intent = String(args?.intent || '').slice(0, 500)
98
+ const budgetText = [
99
+ `no new runtime cycles (baseline ${PROVISIONAL_BUDGETS.runtimeCycles})`,
100
+ `file <= ${PROVISIONAL_BUDGETS.maxFileLoc} LOC`,
101
+ `function <= ${PROVISIONAL_BUDGETS.maxFunctionLoc} LOC`,
102
+ `cyclomatic <= ${PROVISIONAL_BUDGETS.maxCyclomatic}`,
103
+ ].join('; ')
50
104
  return toolResult([
51
- `Architecture ${action} is NOT_CONFIGUREDno target contract is active.`,
52
- `A source-free starter was inferred from ${starter.components.length} path territories; review it before saving because folders are evidence, not semantic truth.`,
53
- 'Next: save it as .weavatrix/architecture.json (offline) or approve it in the hosted Architecture editor, then call verify_architecture.',
105
+ `Architecture preflight is NOT_CONFIGURED for ${surfaces.files.length} file(s)${intent ? ` ${intent}` : ''}.`,
106
+ `Provisional no-regression guidance (not enforced policy): ${budgetText}.`,
107
+ `${surfaces.productFiles.length} product file(s); ${surfaces.testOnlyFiles.length} test-only file(s). Save a target contract to make these budgets enforceable.`,
54
108
  ].join('\n'), {
55
109
  state: 'NOT_CONFIGURED',
56
- remediation: {
57
- offlinePath: '.weavatrix/architecture.json',
58
- hostedAction: 'Open Architecture → choose intended style → Save target & baseline',
59
- nextTool: 'verify_architecture',
60
- },
61
- starterContract: starter,
110
+ guidance: 'PROVISIONAL_BUDGETS',
111
+ enforceable: false,
112
+ intent,
113
+ ...surfaces,
114
+ provisionalBudgets: PROVISIONAL_BUDGETS,
115
+ remediation: remediation(),
62
116
  })
63
117
  }
64
118
 
@@ -68,7 +122,7 @@ export function tGetArchitectureContract(g, args, ctx) {
68
122
  const text = loaded.error
69
123
  ? `Architecture contract is invalid (${loaded.source || 'unknown'}): ${loaded.error}`
70
124
  : 'No target architecture contract is active.'
71
- if (!loaded.error) return notConfiguredResult(g, 'lookup')
125
+ if (!loaded.error) return notConfiguredResult(g, 'lookup', {includeStarter: true})
72
126
  return toolResult(text, {state: 'ERROR', source: loaded.source, error: loaded.error})
73
127
  }
74
128
  const contract = loaded.contract
@@ -79,17 +133,19 @@ export function tGetArchitectureContract(g, args, ctx) {
79
133
  ].join('\n'), {state: 'ACTIVE', source: loaded.source, contract})
80
134
  }
81
135
 
82
- export function tPrepareChange(g, args, ctx) {
136
+ export function tPrepareChange(g, args = {}, ctx) {
83
137
  const loaded = activeContract(ctx)
84
- if (!loaded.contract) return notConfiguredResult(g, 'preflight')
138
+ if (!loaded.contract) return provisionalPreflight(g, args, ctx)
85
139
  const prepared = contractForChange(loaded.contract, args.files)
140
+ const surfaces = classifyChangeFiles(prepared.files, ctx?.repoRoot)
86
141
  const intent = String(args.intent || '').slice(0, 500)
87
142
  return toolResult([
88
143
  `Architecture preflight for ${prepared.files.length} file(s)${intent ? ` — ${intent}` : ''}.`,
89
144
  `Affected target components: ${prepared.components.join(', ') || '(unmapped)'}.`,
145
+ ...(surfaces.testOnlyFiles.length ? [`Test-only surface: ${surfaces.testOnlyFiles.join(', ')}.`] : []),
90
146
  `Applicable rules: ${prepared.rules.map((rule) => rule.id).join(', ') || '(none)'}.`,
91
147
  'Run verify_architecture after the edit; do not silently add an exception or rewrite the target contract.',
92
- ].join('\n'), {state: 'READY', intent, contractHash: loaded.contract.contractHash, ...prepared})
148
+ ].join('\n'), {state: 'READY', intent, contractHash: loaded.contract.contractHash, ...prepared, productFiles: surfaces.productFiles, testOnlyFiles: surfaces.testOnlyFiles})
93
149
  }
94
150
 
95
151
  export function tVerifyArchitecture(g, args, ctx) {
@@ -1,14 +1,30 @@
1
1
  // Connectivity-hub reporting, split from the general graph query tools.
2
2
  import {connList} from './graph-context.mjs'
3
+ import {createPathClassifier, hasPathClass} from '../path-classification.js'
3
4
 
4
5
  const isCompileTimeEdge = (edge) => edge?.typeOnly === true || edge?.compileOnly === true
6
+ const NON_PRODUCT_CLASSES = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
7
+ const sourceFileOf = (node) => String(node?.source_file || (node?.file_type === 'code' ? node?.id : '') || '').replace(/\\/g, '/')
5
8
 
6
- export function tGodNodes(g, {top_n = 10} = {}) {
9
+ export function tGodNodes(g, {top_n = 10, include_classified = false} = {}, ctx = {}) {
7
10
  const n = Math.max(1, Math.min(100, Number(top_n) || 10))
11
+ const classifier = createPathClassifier(ctx.repoRoot || null)
12
+ const classificationByFile = new Map()
13
+ const isNonProduct = (node) => {
14
+ if (include_classified === true) return false
15
+ const file = sourceFileOf(node)
16
+ if (!file) return false
17
+ if (!classificationByFile.has(file)) classificationByFile.set(file, classifier.explain(file))
18
+ const info = classificationByFile.get(file)
19
+ return info.excluded || hasPathClass(info, ...NON_PRODUCT_CLASSES)
20
+ }
21
+ const excludedIds = new Set(g.nodes.filter(isNonProduct).map((node) => String(node.id)))
8
22
  const scored = g.nodes
23
+ .filter((node) => !excludedIds.has(String(node.id)))
9
24
  .map((node) => {
10
- const rawOuts = g.out.get(String(node.id)) || []
11
- const rawIns = g.inn.get(String(node.id)) || []
25
+ // Coupling from a suppressed artifact must not inflate an otherwise valid product hub.
26
+ const rawOuts = (g.out.get(String(node.id)) || []).filter((edge) => !excludedIds.has(String(edge.id)))
27
+ const rawIns = (g.inn.get(String(node.id)) || []).filter((edge) => !excludedIds.has(String(edge.id)))
12
28
  const outs = connList(rawOuts)
13
29
  const ins = connList(rawIns)
14
30
  const ownedMethods = new Set(rawOuts.filter((e) => e.relation === 'method').map((e) => String(e.id)))
@@ -54,5 +70,8 @@ export function tGodNodes(g, {top_n = 10} = {}) {
54
70
  ...occurrenceHotspots.map((r) =>
55
71
  ` ${r.node.label ?? r.node.id} (${r.occurrences} occurrences across ${r.deg} unique neighbors; ${r.occurrences - r.deg} repeats) [${r.node.id}]`
56
72
  ),
73
+ excludedIds.size > 0 && include_classified !== true
74
+ ? `${excludedIds.size} node(s) classified as tests/e2e/generated/build output/mocks/stories/docs/benchmarks/temp or explicitly excluded were omitted; pass include_classified:true to inspect them.`
75
+ : null,
57
76
  ].filter((line) => line != null).join('\n')
58
77
  }
@@ -1,8 +1,20 @@
1
1
  // Behavioral architecture evidence from bounded, local git history.
2
- import {analyzeGitHistory, formatGitHistoryAnalytics} from '../analysis/git-history.js'
2
+ import {analyzeGitHistory, boundGitHistoryAnalytics, formatGitHistoryAnalytics} from '../analysis/git-history.js'
3
3
  import {toolResult} from './tool-result.mjs'
4
4
 
5
- export async function tGitHistory(g, args, ctx) {
5
+ export function gitHistoryToolResult(result, args = {}) {
6
+ const bounded = boundGitHistoryAnalytics(result, {topN: args.top_n})
7
+ return toolResult(formatGitHistoryAnalytics(bounded.result, {topN: args.top_n}), bounded.result, {
8
+ page: bounded.page,
9
+ completeness: {
10
+ status: result.status,
11
+ complete: result.completeness?.complete === true,
12
+ reasons: result.completeness?.reasons || [],
13
+ },
14
+ })
15
+ }
16
+
17
+ export async function tGitHistory(g, args = {}, ctx) {
6
18
  if (!ctx?.repoRoot) return toolResult('Git history intelligence is unavailable: no repository root is active.', {status: 'unavailable'})
7
19
  const result = await analyzeGitHistory({
8
20
  repoRoot: ctx.repoRoot,
@@ -12,11 +24,5 @@ export async function tGitHistory(g, args, ctx) {
12
24
  maxPairs: args.max_pairs,
13
25
  minPairCount: args.min_pair_count,
14
26
  })
15
- return toolResult(formatGitHistoryAnalytics(result, {topN: args.top_n}), result, {
16
- completeness: {
17
- status: result.status,
18
- complete: result.completeness?.complete === true,
19
- reasons: result.completeness?.reasons || [],
20
- },
21
- })
27
+ return gitHistoryToolResult(result, args)
22
28
  }
@@ -214,7 +214,7 @@ export function tChangeImpactV2(g, args = {}, ctx = {}) {
214
214
  '',
215
215
  impacted.length
216
216
  ? `Blast radius: ${impacted.length} impacted node(s) within ${maxDepth} reverse hop(s) (${runtimeImpacted} runtime, ${compileImpacted} compile-time-only), showing ${shown.length}:`
217
- : seeds.size ? `Blast radius: nothing else depends on the exact changed symbols within ${maxDepth} hop(s).` : `Blast radius: 0 — additive/metadata-only changes do not inherit the containing file's legacy importers.`,
217
+ : seeds.size ? `Blast radius: nothing else depends on the exact changed symbols within ${maxDepth} hop(s).` : `Blast radius: 0 — additive, metadata-only, and test-only changes do not inherit the containing file's legacy importers.`,
218
218
  hasCoverage
219
219
  ? 'Test evidence: measured coverage is shown where mapped; static reachability remains separate in structured output.'
220
220
  : `Test evidence: actualCoverage NOT_AVAILABLE; static paths only (${staticTests.reachableFiles}/${staticTests.productFiles} product files reachable from indexed tests).`,
@@ -217,7 +217,9 @@ async function main() {
217
217
  if (schemaWarning) text += `\n\nWarning: ${schemaWarning.message}`
218
218
  if (staleLine && staleNotices.shouldShow({line: staleLine, graphPath: callCtx.graphPath, force: tool.name === 'graph_stats'})) text += `\n\n${staleLine}`
219
219
  }
220
- return reply(id, {content: [{type: 'text', text}], structuredContent: normalized.structured})
220
+ const response = {content: [{type: 'text', text}]}
221
+ if (normalized.structured) response.structuredContent = normalized.structured
222
+ return reply(id, response)
221
223
  } catch (e) {
222
224
  log(`tool ${params?.name} threw: ${e.stack || e.message}`)
223
225
  return reply(id, {content: [{type: 'text', text: `Tool error: ${e.message}`}], isError: true})
@@ -53,6 +53,11 @@ const DEFAULT_RULES = [
53
53
  pattern: "generated/OpenAPI output path",
54
54
  regex: /(^|\/)(?:generated|gen|openapi[-_]?generated|generated[-_]?client|api[-_]?client[-_]?generated)(\/|$)/i,
55
55
  },
56
+ {
57
+ category: "generated",
58
+ pattern: "conventional build output path",
59
+ regex: /(^|\/)(?:dist|build|out|coverage|\.next)(\/|$)/i,
60
+ },
56
61
  {
57
62
  category: "mock",
58
63
  pattern: "mock/fixture path or mockData filename",