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 +18 -0
- package/package.json +1 -1
- package/skill/SKILL.md +15 -6
- package/src/analysis/change-classification.js +27 -2
- package/src/analysis/git-history.js +42 -0
- package/src/analysis/http-contracts.js +57 -5
- package/src/mcp/catalog.mjs +7 -6
- package/src/mcp/tool-result.mjs +9 -6
- package/src/mcp/tools-architecture.mjs +72 -16
- package/src/mcp/tools-graph-hubs.mjs +22 -3
- package/src/mcp/tools-history.mjs +15 -9
- package/src/mcp/tools-impact-change.mjs +1 -1
- package/src/mcp-server.mjs +3 -1
- package/src/path-classification.js +5 -0
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.
|
|
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`.
|
|
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
|
|
121
|
-
|
|
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.
|
|
124
|
-
|
|
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) =>
|
|
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(
|
|
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
|
|
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
|
-
|
|
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);
|
package/src/mcp/catalog.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
67
|
-
//
|
|
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
|
|
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
|
|
package/src/mcp/tool-result.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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:
|
|
49
|
-
|
|
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:
|
|
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 ${
|
|
52
|
-
`
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
11
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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).`,
|
package/src/mcp-server.mjs
CHANGED
|
@@ -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
|
-
|
|
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",
|