weavatrix 0.2.19 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -93
- package/SECURITY.md +13 -31
- package/bin/weavatrix-mcp.mjs +2 -1
- package/docs/adr/0001-v0.3-offline-online-split.md +9 -7
- package/docs/releases/v0.3.0.md +43 -0
- package/package.json +8 -2
- package/skill/SKILL.md +34 -59
- package/src/analysis/architecture/contract-storage.js +2 -2
- package/src/analysis/audit-extensions.js +60 -0
- package/src/analysis/duplicate-groups.js +13 -2
- package/src/analysis/duplicates.compute.js +7 -2
- package/src/analysis/health-capabilities.js +2 -1
- package/src/analysis/internal-audit/supply-chain.js +17 -2
- package/src/analysis/jvm-artifact-index.js +87 -0
- package/src/analysis/jvm-dependency-evidence.js +31 -8
- package/src/analysis/transport-contracts.js +78 -11
- package/src/analysis/transport-runtime-evidence.js +155 -0
- package/src/child-env.js +2 -2
- package/src/extension/local-services.mjs +75 -0
- package/src/graph/internal-builder.langs.js +2 -0
- package/src/graph/repo-registry.js +2 -2
- package/src/mcp/catalog.mjs +47 -21
- package/src/mcp/evidence/duplicate-member-order.mjs +1 -1
- package/src/mcp/evidence-snapshot.health.mjs +2 -2
- package/src/mcp/extension-api.mjs +77 -0
- package/src/mcp/health/audit-format.mjs +11 -1
- package/src/mcp/health/audit.mjs +10 -7
- package/src/mcp/health/duplicates.mjs +5 -3
- package/src/mcp/sync-payload.mjs +1 -1
- package/src/mcp/tools-actions.mjs +1 -8
- package/src/mcp/tools-architecture.mjs +2 -2
- package/src/mcp/tools-company.mjs +4 -2
- package/src/mcp-runtime.mjs +2 -0
- package/src/mcp-server.mjs +28 -17
- package/src/security/advisory-store.js +133 -181
- package/src/security/rust-advisory-report.js +60 -0
- package/src/mcp/actions/advisories.mjs +0 -17
- package/src/mcp/actions/graph-sync.mjs +0 -195
- package/src/mcp/actions/hosted-architecture.mjs +0 -57
|
@@ -2,6 +2,7 @@ import { createRepoBoundary } from "../repo-path.js";
|
|
|
2
2
|
import { lineNumberAt, uniqueBy } from "../util.js";
|
|
3
3
|
import { listRepoFiles, readRepoText } from "./internal-audit/repo-files.js";
|
|
4
4
|
import { affectedForEndpoint, reverseRuntimeImports } from "./http-contracts/graph-context.js";
|
|
5
|
+
import { loadTransportRuntimeEvidence } from "./transport-runtime-evidence.js";
|
|
5
6
|
|
|
6
7
|
const TEST_RE = /(^|\/)(?:test|tests|__tests__|spec|e2e|fixtures?)(\/|$)|[._-](?:test|spec)\.[a-z0-9]+$/i;
|
|
7
8
|
const SOURCE_RE = /\.(?:[cm]?[jt]sx?|py|go|java|rs|cs|proto|graphql|gql)$/i;
|
|
@@ -89,6 +90,31 @@ function eventEvidence(source, role) {
|
|
|
89
90
|
return { contracts, uncertain };
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
const contractIdentity = (item) => `${item.transport}|${item.side}|${norm(item.service)}|${item.operation || ""}|${item.name}`;
|
|
94
|
+
|
|
95
|
+
function mergeRuntimeContracts(staticContracts, observations) {
|
|
96
|
+
const contracts = staticContracts.map((item) => ({ ...item, evidence: ["STATIC"] }));
|
|
97
|
+
const byIdentity = new Map(contracts.map((item) => [contractIdentity(item), item]));
|
|
98
|
+
for (const observation of observations) {
|
|
99
|
+
const key = contractIdentity(observation);
|
|
100
|
+
const existing = byIdentity.get(key);
|
|
101
|
+
if (existing) {
|
|
102
|
+
existing.runtimeObserved = true;
|
|
103
|
+
existing.observedCount = (existing.observedCount || 0) + observation.observedCount;
|
|
104
|
+
existing.evidence = ["STATIC", "RUNTIME"];
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const added = { ...observation, evidence: ["RUNTIME"] };
|
|
108
|
+
contracts.push(added); byIdentity.set(key, added);
|
|
109
|
+
}
|
|
110
|
+
return contracts;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sameRuntimeLocation(uncertain, observation) {
|
|
114
|
+
return observation.file && observation.line && observation.transport === uncertain.transport &&
|
|
115
|
+
observation.file === uncertain.file && observation.line === uncertain.line;
|
|
116
|
+
}
|
|
117
|
+
|
|
92
118
|
function detect(descriptor, role, options) {
|
|
93
119
|
const loaded = sourcesFor(descriptor, options), contracts = [], uncertain = [];
|
|
94
120
|
for (const source of loaded.sources) {
|
|
@@ -97,29 +123,52 @@ function detect(descriptor, role, options) {
|
|
|
97
123
|
contracts.push(...result.contracts); uncertain.push(...result.uncertain);
|
|
98
124
|
}
|
|
99
125
|
}
|
|
126
|
+
const runtime = loadTransportRuntimeEvidence(descriptor, {
|
|
127
|
+
file: options.runtimeEvidenceFiles?.[descriptor.id],
|
|
128
|
+
maxAgeHours: options.runtimeEvidenceMaxAgeHours,
|
|
129
|
+
transport: options.transport,
|
|
130
|
+
now: options.now,
|
|
131
|
+
});
|
|
132
|
+
const runtimeContracts = runtime.observations.filter((item) => options.transport === "all" || item.transport === options.transport);
|
|
133
|
+
const resolvedRuntime = uncertain.filter((item) => runtimeContracts.some((observation) => sameRuntimeLocation(item, observation)));
|
|
134
|
+
const unresolved = uncertain.filter((item) => !runtimeContracts.some((observation) => sameRuntimeLocation(item, observation)));
|
|
100
135
|
return {
|
|
101
|
-
contracts:
|
|
102
|
-
|
|
103
|
-
|
|
136
|
+
contracts: mergeRuntimeContracts(
|
|
137
|
+
uniqueBy(contracts, (item) => `${item.transport}|${item.side}|${item.service || ""}|${item.operation || ""}|${item.name}|${item.file}|${item.line}`),
|
|
138
|
+
runtimeContracts,
|
|
139
|
+
),
|
|
140
|
+
uncertain: uniqueBy(unresolved, (item) => `${item.transport}|${item.file}|${item.line}|${item.reason}`),
|
|
141
|
+
resolvedRuntime: uniqueBy(resolvedRuntime, (item) => `${item.transport}|${item.file}|${item.line}|${item.reason}`),
|
|
142
|
+
reasons: [...loaded.reasons, ...runtime.reasons],
|
|
143
|
+
runtime,
|
|
104
144
|
filesScanned: loaded.sources.length,
|
|
105
145
|
};
|
|
106
146
|
}
|
|
107
147
|
|
|
108
148
|
function contractMatch(server, caller, backendServers) {
|
|
109
149
|
if (server.transport !== caller.transport) return null;
|
|
110
|
-
|
|
150
|
+
const runtime = server.runtimeObserved || caller.runtimeObserved;
|
|
151
|
+
const match = (kind) => ({ confidence: "high", kind, evidence: runtime ? "RUNTIME_OBSERVED" : "STATIC" });
|
|
152
|
+
if (server.transport === "graphql" && caller.side === "client" && server.operation === caller.operation && server.name === caller.name) return match("operation-field");
|
|
111
153
|
if (server.transport === "grpc" && caller.side === "client" && norm(server.name) === norm(caller.name)) {
|
|
112
|
-
if (caller.service && norm(server.service) === norm(caller.service)) return
|
|
154
|
+
if (caller.service && norm(server.service) === norm(caller.service)) return match("service-method");
|
|
113
155
|
const sameMethod = backendServers.filter((item) => item.transport === "grpc" && norm(item.name) === norm(caller.name));
|
|
114
|
-
if (!caller.service && sameMethod.length === 1) return { confidence: "medium", kind: "unique-method" };
|
|
156
|
+
if (!caller.service && sameMethod.length === 1) return { confidence: "medium", kind: "unique-method", evidence: runtime ? "RUNTIME_OBSERVED" : "STATIC" };
|
|
115
157
|
}
|
|
116
|
-
if (server.transport === "event" && server.name === caller.name && server.side !== caller.side) return
|
|
158
|
+
if (server.transport === "event" && server.name === caller.name && server.side !== caller.side) return match("topic-direction");
|
|
117
159
|
return null;
|
|
118
160
|
}
|
|
119
161
|
|
|
120
162
|
export function analyzeTransportContracts(input = {}) {
|
|
121
163
|
const transport = ["all", "graphql", "grpc", "event"].includes(input.transport) ? input.transport : "all";
|
|
122
|
-
const options = {
|
|
164
|
+
const options = {
|
|
165
|
+
includeTests: input.includeTests === true,
|
|
166
|
+
maxFiles: Math.max(1, Math.min(10_000, Number(input.maxFiles) || 3_000)),
|
|
167
|
+
transport,
|
|
168
|
+
runtimeEvidenceFiles: input.runtimeEvidenceFiles || {},
|
|
169
|
+
runtimeEvidenceMaxAgeHours: input.runtimeEvidenceMaxAgeHours,
|
|
170
|
+
now: input.now,
|
|
171
|
+
};
|
|
123
172
|
const backend = detect(input.backend, "backend", options);
|
|
124
173
|
const clients = (input.clients || []).map((descriptor) => ({ descriptor, evidence: detect(descriptor, "client", options), reverse: reverseRuntimeImports(descriptor.graph) }));
|
|
125
174
|
const selected = (item) => transport === "all" || item.transport === transport;
|
|
@@ -131,7 +180,7 @@ export function analyzeTransportContracts(input = {}) {
|
|
|
131
180
|
for (const client of clients) for (const caller of client.evidence.contracts.filter(selected)) {
|
|
132
181
|
const match = contractMatch(server, caller, servers);
|
|
133
182
|
if (!match) continue;
|
|
134
|
-
callsites.push({ clientRepo: client.descriptor.id, file: caller.file, line: caller.line, detector: caller.detector, match });
|
|
183
|
+
callsites.push({ clientRepo: client.descriptor.id, file: caller.file, line: caller.line, detector: caller.detector, match, runtimeObserved: caller.runtimeObserved === true, observedCount: caller.observedCount });
|
|
135
184
|
}
|
|
136
185
|
const affected = affectedForEndpoint(callsites, clients.map((item) => ({ id: item.descriptor.id, reverse: item.reverse })), {
|
|
137
186
|
maxImpactDepth: Math.max(0, Math.min(5, Number(input.maxImpactDepth) || 2)),
|
|
@@ -145,13 +194,31 @@ export function analyzeTransportContracts(input = {}) {
|
|
|
145
194
|
].filter(selected);
|
|
146
195
|
const reasons = [...backend.reasons, ...clients.flatMap((item) => item.evidence.reasons)];
|
|
147
196
|
if (uncertain.length) reasons.push(`${uncertain.length} dynamic/reflection contract expression(s) remain UNKNOWN`);
|
|
197
|
+
const runtimeReports = [
|
|
198
|
+
{ repository: input.backend.id, ...backend.runtime },
|
|
199
|
+
...clients.map((item) => ({ repository: item.descriptor.id, ...item.evidence.runtime })),
|
|
200
|
+
];
|
|
201
|
+
const resolvedRuntime = backend.resolvedRuntime.length + clients.reduce((sum, item) => sum + item.evidence.resolvedRuntime.length, 0);
|
|
148
202
|
return {
|
|
149
|
-
transportContractsV:
|
|
203
|
+
transportContractsV: 2,
|
|
150
204
|
transport,
|
|
151
205
|
status: reasons.length ? "PARTIAL" : "COMPLETE",
|
|
152
206
|
completeness: { complete: reasons.length === 0, reasons: [...new Set(reasons)] },
|
|
153
|
-
totals: {
|
|
207
|
+
totals: {
|
|
208
|
+
contracts: results.length,
|
|
209
|
+
matches: results.reduce((sum, item) => sum + item.callsites.length, 0),
|
|
210
|
+
uncertain: uncertain.length,
|
|
211
|
+
filesScanned: backend.filesScanned + clients.reduce((sum, item) => sum + item.evidence.filesScanned, 0),
|
|
212
|
+
runtimeObservations: runtimeReports.reduce((sum, item) => sum + item.observations.length, 0),
|
|
213
|
+
runtimeResolved: resolvedRuntime,
|
|
214
|
+
runtimeReportsComplete: runtimeReports.filter((item) => item.status === "COMPLETE").length,
|
|
215
|
+
},
|
|
154
216
|
contracts: results,
|
|
155
217
|
uncertain: uncertain.slice(0, 200),
|
|
218
|
+
runtimeEvidence: {
|
|
219
|
+
status: runtimeReports.every((item) => item.status === "COMPLETE") ? "COMPLETE" : "PARTIAL",
|
|
220
|
+
reports: runtimeReports.map(({ observations, reasons: reportReasons, ...report }) => ({ ...report, observationCount: observations.length, reasons: reportReasons })),
|
|
221
|
+
resolvedUnknowns: resolvedRuntime,
|
|
222
|
+
},
|
|
156
223
|
};
|
|
157
224
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { createRepoBoundary, isPathInside } from "../repo-path.js";
|
|
4
|
+
|
|
5
|
+
const TRANSPORT_RUNTIME_SCHEMA = "weavatrix.transport-runtime.v1";
|
|
6
|
+
const TRANSPORTS = ["graphql", "grpc", "event"];
|
|
7
|
+
const DEFAULT_REPORTS = [
|
|
8
|
+
".weavatrix/transport-runtime.json",
|
|
9
|
+
".weavatrix/reports/transport-runtime.json",
|
|
10
|
+
];
|
|
11
|
+
const MAX_REPORT_BYTES = 2 * 1024 * 1024;
|
|
12
|
+
const MAX_OBSERVATIONS = 10_000;
|
|
13
|
+
|
|
14
|
+
const text = (value, max = 256) => typeof value === "string" ? value.trim().slice(0, max) : "";
|
|
15
|
+
const number = (value) => Number.isFinite(Number(value)) ? Number(value) : undefined;
|
|
16
|
+
|
|
17
|
+
function requestedTransports(transport) {
|
|
18
|
+
return TRANSPORTS.includes(transport) ? [transport] : TRANSPORTS;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function reportPath(descriptor, candidate) {
|
|
22
|
+
const boundary = createRepoBoundary(descriptor.repoRoot);
|
|
23
|
+
const resolved = boundary.resolve(candidate);
|
|
24
|
+
return resolved.ok ? { boundary, path: resolved.path } : { boundary, reason: resolved.reason };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeSourceFile(descriptor, value) {
|
|
28
|
+
const raw = text(value, 1024).replace(/\\/g, "/");
|
|
29
|
+
if (!raw || raw.includes("\0")) return undefined;
|
|
30
|
+
if (!isAbsolute(raw) && !raw.startsWith("../") && !raw.includes("/../")) return raw.replace(/^\.\//, "");
|
|
31
|
+
const root = resolve(descriptor.repoRoot);
|
|
32
|
+
const target = resolve(raw);
|
|
33
|
+
return isPathInside(root, target) ? relative(root, target).replace(/\\/g, "/") : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function attributeValue(attribute) {
|
|
37
|
+
const value = attribute?.value ?? attribute;
|
|
38
|
+
for (const key of ["stringValue", "intValue", "doubleValue", "boolValue"]) {
|
|
39
|
+
if (value?.[key] !== undefined) return value[key];
|
|
40
|
+
}
|
|
41
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function attributes(input) {
|
|
45
|
+
const out = new Map();
|
|
46
|
+
for (const item of Array.isArray(input) ? input : []) if (typeof item?.key === "string") out.set(item.key, attributeValue(item));
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function spanSide(span, transport) {
|
|
51
|
+
const kind = String(span?.kind ?? "").toUpperCase();
|
|
52
|
+
const numeric = Number(span?.kind);
|
|
53
|
+
if (transport === "event") {
|
|
54
|
+
if (kind.includes("PRODUCER") || numeric === 4) return "publisher";
|
|
55
|
+
if (kind.includes("CONSUMER") || numeric === 5) return "subscriber";
|
|
56
|
+
}
|
|
57
|
+
if (kind.includes("SERVER") || numeric === 2) return "server";
|
|
58
|
+
if (kind.includes("CLIENT") || numeric === 3) return "client";
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function observationFromSpan(descriptor, span) {
|
|
63
|
+
const attrs = attributes(span?.attributes);
|
|
64
|
+
const common = {
|
|
65
|
+
file: safeSourceFile(descriptor, attrs.get("code.file.path") ?? attrs.get("code.filepath")),
|
|
66
|
+
line: number(attrs.get("code.line.number") ?? attrs.get("code.lineno")),
|
|
67
|
+
observedCount: 1,
|
|
68
|
+
detector: "otlp-span",
|
|
69
|
+
};
|
|
70
|
+
const rpcSystem = text(attrs.get("rpc.system")).toLowerCase();
|
|
71
|
+
if (rpcSystem === "grpc") {
|
|
72
|
+
const fallback = text(span?.name).split("/").filter(Boolean);
|
|
73
|
+
return { ...common, transport: "grpc", side: spanSide(span, "grpc"), service: text(attrs.get("rpc.service")) || fallback.at(-2), name: text(attrs.get("rpc.method")) || fallback.at(-1) };
|
|
74
|
+
}
|
|
75
|
+
const graphOperation = text(attrs.get("graphql.operation.type")).toUpperCase();
|
|
76
|
+
const graphField = text(attrs.get("graphql.field.name") ?? attrs.get("graphql.field"));
|
|
77
|
+
if (graphOperation || graphField) return { ...common, transport: "graphql", side: spanSide(span, "graphql"), operation: graphOperation, name: graphField };
|
|
78
|
+
const messagingSystem = text(attrs.get("messaging.system")).toLowerCase();
|
|
79
|
+
const destination = text(attrs.get("messaging.destination.name") ?? attrs.get("messaging.destination"));
|
|
80
|
+
if (messagingSystem || destination) return { ...common, transport: "event", side: spanSide(span, "event"), kind: text(attrs.get("messaging.operation.type") ?? attrs.get("messaging.operation")), name: destination };
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function otlpObservations(descriptor, report) {
|
|
85
|
+
const root = report.otlp && typeof report.otlp === "object" ? report.otlp : report;
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const resource of Array.isArray(root.resourceSpans) ? root.resourceSpans : []) {
|
|
88
|
+
const scopes = resource.scopeSpans ?? resource.instrumentationLibrarySpans ?? [];
|
|
89
|
+
for (const scope of scopes) for (const span of Array.isArray(scope.spans) ? scope.spans : []) {
|
|
90
|
+
const observation = observationFromSpan(descriptor, span);
|
|
91
|
+
if (observation) out.push(observation);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeObservation(descriptor, raw) {
|
|
98
|
+
const transport = text(raw?.transport).toLowerCase();
|
|
99
|
+
const side = text(raw?.side).toLowerCase();
|
|
100
|
+
const name = text(raw?.name);
|
|
101
|
+
if (!TRANSPORTS.includes(transport) || !name) return null;
|
|
102
|
+
const allowedSides = transport === "event" ? ["publisher", "subscriber"] : ["server", "client", "server-call"];
|
|
103
|
+
if (!allowedSides.includes(side)) return null;
|
|
104
|
+
const operation = text(raw?.operation).toUpperCase();
|
|
105
|
+
if (transport === "graphql" && !["QUERY", "MUTATION", "SUBSCRIPTION"].includes(operation)) return null;
|
|
106
|
+
const line = number(raw?.line);
|
|
107
|
+
return {
|
|
108
|
+
transport, side, name,
|
|
109
|
+
...(operation ? { operation } : {}),
|
|
110
|
+
...(text(raw?.service) ? { service: text(raw.service) } : {}),
|
|
111
|
+
...(text(raw?.kind) ? { kind: text(raw.kind) } : {}),
|
|
112
|
+
...(safeSourceFile(descriptor, raw?.file) ? { file: safeSourceFile(descriptor, raw.file) } : {}),
|
|
113
|
+
...(line > 0 && line <= 10_000_000 ? { line: Math.floor(line) } : {}),
|
|
114
|
+
observedCount: Math.max(1, Math.min(1_000_000_000, Math.floor(number(raw?.observedCount) || 1))),
|
|
115
|
+
detector: text(raw?.detector) || "runtime-report",
|
|
116
|
+
runtimeObserved: true,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function validateReport(descriptor, report, options, file) {
|
|
121
|
+
const reasons = [];
|
|
122
|
+
let usable = true;
|
|
123
|
+
if (report?.schema !== TRANSPORT_RUNTIME_SCHEMA) { reasons.push(`${descriptor.id}: unsupported runtime evidence schema`); usable = false; }
|
|
124
|
+
if (!descriptor.graph?.graphRevision || report?.repositoryRevision !== descriptor.graph.graphRevision) { reasons.push(`${descriptor.id}: runtime evidence revision does not match the active graph`); usable = false; }
|
|
125
|
+
const generatedAt = Date.parse(report?.generatedAt);
|
|
126
|
+
const now = Number(options.now ?? Date.now());
|
|
127
|
+
const maxAgeMs = Math.max(1, Math.min(8760, Number(options.maxAgeHours) || 168)) * 3_600_000;
|
|
128
|
+
if (!Number.isFinite(generatedAt)) { reasons.push(`${descriptor.id}: runtime evidence generatedAt is invalid`); usable = false; }
|
|
129
|
+
else if (generatedAt > now + 300_000 || now - generatedAt > maxAgeMs) { reasons.push(`${descriptor.id}: runtime evidence is stale or from the future`); usable = false; }
|
|
130
|
+
const selected = requestedTransports(options.transport);
|
|
131
|
+
const coverage = Object.fromEntries(selected.map((item) => [item, String(report?.coverage?.[item] || "NOT_CHECKED").toUpperCase()]));
|
|
132
|
+
for (const [item, status] of Object.entries(coverage)) if (status !== "COMPLETE") reasons.push(`${descriptor.id}: ${item} runtime capture is ${status}`);
|
|
133
|
+
const raw = [...(Array.isArray(report?.observations) ? report.observations : []), ...otlpObservations(descriptor, report)].slice(0, MAX_OBSERVATIONS);
|
|
134
|
+
const observations = usable ? raw.map((item) => normalizeObservation(descriptor, item)).filter(Boolean) : [];
|
|
135
|
+
if (raw.length !== observations.length) reasons.push(`${descriptor.id}: ${raw.length - observations.length} invalid runtime observation(s) were ignored`);
|
|
136
|
+
return { status: reasons.length ? "PARTIAL" : "COMPLETE", file, generatedAt: report?.generatedAt, repositoryRevision: report?.repositoryRevision, coverage, observations, reasons };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function loadTransportRuntimeEvidence(descriptor, options = {}) {
|
|
140
|
+
const candidates = options.file ? [String(options.file)] : DEFAULT_REPORTS;
|
|
141
|
+
for (const candidate of candidates) {
|
|
142
|
+
const resolved = reportPath(descriptor, candidate);
|
|
143
|
+
if (!resolved.path) {
|
|
144
|
+
if (options.file) return { status: "ERROR", file: candidate, coverage: {}, observations: [], reasons: [`${descriptor.id}: runtime evidence path is ${resolved.reason}`] };
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
if (statSync(resolved.path).size > MAX_REPORT_BYTES) return { status: "ERROR", file: candidate, coverage: {}, observations: [], reasons: [`${descriptor.id}: runtime evidence exceeds ${MAX_REPORT_BYTES} bytes`] };
|
|
149
|
+
return validateReport(descriptor, JSON.parse(readFileSync(resolved.path, "utf8")), options, candidate);
|
|
150
|
+
} catch {
|
|
151
|
+
return { status: "ERROR", file: candidate, coverage: {}, observations: [], reasons: [`${descriptor.id}: runtime evidence is unreadable or invalid JSON`] };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { status: "NOT_CHECKED", file: null, coverage: {}, observations: [], reasons: [`${descriptor.id}: no revision-bound runtime transport evidence was found`] };
|
|
155
|
+
}
|
package/src/child-env.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Child processes and worker threads
|
|
2
|
-
// Keep
|
|
1
|
+
// Child processes and worker threads never need credentials owned by a composing connector.
|
|
2
|
+
// Keep connector secrets in the parent MCP process and out of Git/LSP/test subprocesses.
|
|
3
3
|
export function childProcessEnv(overrides = {}) {
|
|
4
4
|
const env = { ...process.env, ...overrides };
|
|
5
5
|
delete env.WEAVATRIX_SYNC_TOKEN;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {readFileSync, statSync} from 'node:fs'
|
|
2
|
+
import {createHash} from 'node:crypto'
|
|
3
|
+
import {basename} from 'node:path'
|
|
4
|
+
import {graphStaleness} from '../mcp/graph-context.mjs'
|
|
5
|
+
import {createSyncPayload, createSyncPayloadV3, MAX_SYNC_BODY_BYTES} from '../mcp/sync-payload.mjs'
|
|
6
|
+
import {createEvidenceSnapshot} from '../mcp/evidence-snapshot.mjs'
|
|
7
|
+
import {graphHomeDir, graphOutDirForRepo} from '../graph/layout.js'
|
|
8
|
+
import {registerRepository, repositoryRecord} from '../graph/repo-registry.js'
|
|
9
|
+
import {writeCachedArchitectureContract} from '../analysis/architecture-contract.js'
|
|
10
|
+
import {collectInstalled} from '../security/installed.js'
|
|
11
|
+
import {toolResult} from '../mcp/tool-result.mjs'
|
|
12
|
+
import {
|
|
13
|
+
commitAdvisoryRefresh, createAdvisoryQueryPlan, DEFAULT_STORE, storeMeta,
|
|
14
|
+
} from '../security/advisory-store.js'
|
|
15
|
+
|
|
16
|
+
const MAX_GRAPH_FILE_BYTES = 64 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
const repositoryLabel = (repoRoot) => {
|
|
19
|
+
const safe = basename(String(repoRoot || '')).normalize('NFKC').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '')
|
|
20
|
+
return (safe || 'repo').slice(0, 128)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const identityFor = (repoRoot) => repositoryRecord(repoRoot, graphHomeDir())
|
|
24
|
+
|| registerRepository({repoPath: repoRoot, graphDir: graphOutDirForRepo(repoRoot), graphHome: graphHomeDir()})
|
|
25
|
+
|
|
26
|
+
// Produces the exact source-free wire material locally. It reads no credentials and performs no
|
|
27
|
+
// network I/O; the caller owns destination policy, consent, authentication and transport.
|
|
28
|
+
export async function createSourceFreeSyncMaterial(graph, {payloadVersion = 3} = {}, ctx = {}) {
|
|
29
|
+
if (!graph) throw new Error('No graph loaded — build one first (open_repo / rebuild_graph).')
|
|
30
|
+
if (!ctx.graphPath || !ctx.repoRoot) throw new Error('No active repository graph — open_repo first.')
|
|
31
|
+
const size = statSync(ctx.graphPath).size
|
|
32
|
+
if (size > MAX_GRAPH_FILE_BYTES) throw new Error(`graph.json exceeds the ${MAX_GRAPH_FILE_BYTES / 1024 / 1024} MB local safety limit`)
|
|
33
|
+
const raw = JSON.parse(readFileSync(ctx.graphPath, 'utf8'))
|
|
34
|
+
let payload
|
|
35
|
+
if (Number(payloadVersion) === 2) {
|
|
36
|
+
payload = createSyncPayload(raw)
|
|
37
|
+
} else {
|
|
38
|
+
if (graphStaleness(ctx).stale) throw new Error('Cannot produce synchronized evidence from a stale graph. Run rebuild_graph first.')
|
|
39
|
+
payload = createSyncPayloadV3(raw, await createEvidenceSnapshot({repoRoot: ctx.repoRoot, graph: raw}))
|
|
40
|
+
}
|
|
41
|
+
const body = JSON.stringify(payload)
|
|
42
|
+
const bodyBytes = Buffer.byteLength(body)
|
|
43
|
+
if (bodyBytes > MAX_SYNC_BODY_BYTES) throw new Error(`source-free payload exceeds the ${MAX_SYNC_BODY_BYTES / 1024} KB safety limit`)
|
|
44
|
+
const identity = identityFor(ctx.repoRoot)
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
payload,
|
|
47
|
+
body,
|
|
48
|
+
bodyBytes,
|
|
49
|
+
bodyHash: createHash('sha256').update(body).digest('hex'),
|
|
50
|
+
repoName: repositoryLabel(ctx.repoRoot),
|
|
51
|
+
repositoryId: identity.repositoryId,
|
|
52
|
+
graphPath: ctx.graphPath,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function activeRepositoryIdentity(ctx = {}) {
|
|
57
|
+
if (!ctx.repoRoot || !ctx.graphPath) throw new Error('No active repository graph — open_repo first.')
|
|
58
|
+
const identity = identityFor(ctx.repoRoot)
|
|
59
|
+
return Object.freeze({repositoryId: identity.repositoryId, repoName: repositoryLabel(ctx.repoRoot), graphPath: ctx.graphPath})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const cacheArchitectureContract = (graphPath, contract) => writeCachedArchitectureContract(graphPath, contract)
|
|
63
|
+
|
|
64
|
+
export function installedPackageCoordinates(repoRoot) {
|
|
65
|
+
if (!repoRoot) throw new Error('No repository root is active.')
|
|
66
|
+
return collectInstalled(repoRoot).installed
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const advisoryCacheMetadata = (storePath = DEFAULT_STORE) => ({
|
|
70
|
+
path: storePath,
|
|
71
|
+
...storeMeta(storePath),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
export {commitAdvisoryRefresh, createAdvisoryQueryPlan}
|
|
75
|
+
export {toolResult}
|
|
@@ -50,6 +50,7 @@ const isDataFile = (p) => DATA_EXT.has(extname(p)) || INFRA_NAME.test(String(p))
|
|
|
50
50
|
// Claude Code / Codex node. AGENT_DOTFILE lets a few AI-agent instruction dotfiles past the dotfile skip below.
|
|
51
51
|
const DOC_EXT = new Set([".md", ".mdx", ".markdown", ".mdown", ".mkd", ".mkdn", ".rst", ".adoc", ".asciidoc"]);
|
|
52
52
|
const AGENT_DOTFILE = /^\.(cursorrules|windsurfrules|clinerules)$/i;
|
|
53
|
+
const RUNTIME_EVIDENCE = /^\.weavatrix\/(?:reports\/)?transport-runtime\.json$/i;
|
|
53
54
|
const isDocFile = (p) => DOC_EXT.has(extname(p)) || AGENT_DOTFILE.test(String(p).split(/[\\/]/).pop() || "");
|
|
54
55
|
const SKIP = new Set(["node_modules", ".git", "dist", "build", "coverage", "vendor", "weavatrix-graphs", "weavatrix-graphs", ".next", "out", "__pycache__", ".venv", "venv", "env", ".tox", "site-packages", ".mypy_cache", ".pytest_cache"]);
|
|
55
56
|
const MAX_PARSE_BYTES = 1_500_000; // skip parsing files above this (minified bundles / generated blobs wedge tree-sitter)
|
|
@@ -139,6 +140,7 @@ function gitFileUniverse(dir) {
|
|
|
139
140
|
const files = [];
|
|
140
141
|
for (const rel of raw.split("\0")) {
|
|
141
142
|
if (!rel) continue;
|
|
143
|
+
if (RUNTIME_EVIDENCE.test(rel.replace(/\\/g, "/"))) continue; // runtime output must not change the source revision it attests
|
|
142
144
|
const full = join(dir, rel);
|
|
143
145
|
let real; try { real = realpathSync.native(full); } catch { continue; } // deleted index entry
|
|
144
146
|
if (!isPathInside(rootReal, real)) continue; // tracked symlink/junction escaping the repo
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Global local registry for repository graphs. Absolute paths never leave the machine;
|
|
2
|
-
//
|
|
1
|
+
// Global local registry for repository graphs. Absolute paths never leave the machine; composing
|
|
2
|
+
// extensions may use the opaque UUID. Identity is anchored in each canonical graph folder so simultaneous MCP
|
|
3
3
|
// processes cannot mint different IDs for the same repository.
|
|
4
4
|
import {randomUUID} from 'node:crypto'
|
|
5
5
|
import {existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync} from 'node:fs'
|
package/src/mcp/catalog.mjs
CHANGED
|
@@ -8,21 +8,18 @@ import {fileURLToPath} from 'node:url'
|
|
|
8
8
|
import {resolveNode, isSymbol, stalenessLine, resetStalenessCache} from './graph-context.mjs'
|
|
9
9
|
import {createRgResolver} from '../mcp-rg.mjs'
|
|
10
10
|
import {readSource, searchCode} from '../mcp-source-tools.mjs'
|
|
11
|
+
import {extensionRuntimeSummary, normalizeWeavatrixExtensions} from './extension-api.mjs'
|
|
11
12
|
|
|
12
13
|
const SELF_DIR = dirname(fileURLToPath(import.meta.url))
|
|
13
14
|
const resolveRg = createRgResolver(SELF_DIR)
|
|
14
|
-
// The
|
|
15
|
-
//
|
|
16
|
-
// split into explicit `advisories` and `hosted` capabilities; the old `online` name remains an alias
|
|
17
|
-
// for both so existing registrations keep working.
|
|
15
|
+
// The core artifact has only local profiles. Online packages compose their own profiles through the
|
|
16
|
+
// public extension API; the MIT package never contains a capability alias that can enable HTTP.
|
|
18
17
|
export const DEFAULT_CAPS = Object.freeze(['graph', 'search', 'source', 'health', 'build', 'retarget', 'crossrepo'])
|
|
19
18
|
const PROFILE_CAPS = Object.freeze({
|
|
20
19
|
offline: DEFAULT_CAPS,
|
|
21
20
|
pinned: ['graph', 'search', 'source', 'health', 'build'],
|
|
22
|
-
osv: [...DEFAULT_CAPS, 'advisories'],
|
|
23
|
-
hosted: [...DEFAULT_CAPS, 'advisories', 'hosted'],
|
|
24
|
-
full: [...DEFAULT_CAPS, 'advisories', 'hosted'],
|
|
25
21
|
})
|
|
22
|
+
const MOVED_PROFILES = new Set(['online', 'osv', 'hosted', 'full'])
|
|
26
23
|
|
|
27
24
|
// The files whose mtime the stdio shell watches for hot reload — keep in sync with the imports in
|
|
28
25
|
// loadHotApi below (catalog.mjs itself is last: a change here re-derives the whole table).
|
|
@@ -36,8 +33,7 @@ const HOT_OWNERS = [
|
|
|
36
33
|
'graph/tools-core.mjs', 'graph/tools-query.mjs', 'tools-graph-hubs.mjs',
|
|
37
34
|
'health/duplicates.mjs', 'health/dead-code.mjs', 'health/audit-format.mjs',
|
|
38
35
|
'health/audit.mjs', 'health/structure.mjs', 'health/endpoints.mjs',
|
|
39
|
-
'actions/graph-lifecycle.mjs',
|
|
40
|
-
'actions/hosted-architecture.mjs', 'actions/graph-sync.mjs',
|
|
36
|
+
'actions/graph-lifecycle.mjs',
|
|
41
37
|
'architecture-starter.mjs', 'architecture-bootstrap.mjs',
|
|
42
38
|
'company-contract-verdict.mjs',
|
|
43
39
|
]
|
|
@@ -76,13 +72,13 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
76
72
|
{
|
|
77
73
|
cap: 'crossrepo',
|
|
78
74
|
name: 'trace_api_contract',
|
|
79
|
-
description: 'Cross-repository HTTP, GraphQL, gRPC and event/topic contract, handler-liveness and blast-radius evidence. Joins static
|
|
75
|
+
description: 'Cross-repository HTTP, GraphQL, gRPC and event/topic contract, handler-liveness and blast-radius evidence. Joins static models with optional revision-bound runtime/OTLP evidence; unobserved dynamic URLs/topics/reflection remain explicit UNKNOWN. Medium/high-confidence external matches mark a handler/contract NOT_DEAD_EXTERNAL_USE. Repository paths stay local and runtime report paths are repository-contained.',
|
|
80
76
|
inputSchema: {
|
|
81
77
|
type: 'object',
|
|
82
78
|
properties: {
|
|
83
79
|
backend: {type: 'string', description: 'Backend repository UUID or exact unambiguous registry label'},
|
|
84
80
|
clients: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 20, description: 'Client repository UUIDs or exact unambiguous registry labels'},
|
|
85
|
-
transport: {type: 'string', enum: ['all', 'http', 'graphql', 'grpc', 'event'], default: 'all', description: 'Contract family to trace; all runs every supported
|
|
81
|
+
transport: {type: 'string', enum: ['all', 'http', 'graphql', 'grpc', 'event'], default: 'all', description: 'Contract family to trace; all runs static and revision-bound runtime evidence for every supported transport'},
|
|
86
82
|
method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']},
|
|
87
83
|
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'},
|
|
88
84
|
changed_files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional backend repo-relative changed files; only endpoints declared in those files are traced'},
|
|
@@ -107,6 +103,8 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
107
103
|
},
|
|
108
104
|
auto_discover_wrappers: {type: 'boolean', default: true, description: 'Discover only simple unambiguous functions that forward a URL parameter directly to a known object-style HTTP client'},
|
|
109
105
|
runtime_config: {type: 'object', maxProperties: 50, additionalProperties: {type: 'string', maxLength: 2048}, description: 'Optional non-secret static bindings for runtime URL prefixes, e.g. process.env.API_BASE. Values are used locally for this call and are not returned.'},
|
|
106
|
+
runtime_evidence_files: {type: 'object', maxProperties: 21, additionalProperties: {type: 'string', maxLength: 512}, description: 'Optional repository-label/UUID to repository-relative weavatrix.transport-runtime.v1 JSON path. Defaults to .weavatrix/transport-runtime.json or .weavatrix/reports/transport-runtime.json in each repository.'},
|
|
107
|
+
runtime_evidence_max_age_hours: {type: 'integer', minimum: 1, maximum: 8760, default: 168, description: 'Maximum accepted age for a revision-matched runtime evidence report'},
|
|
110
108
|
include_tests: {type: 'boolean', default: false},
|
|
111
109
|
max_impact_depth: {type: 'integer', minimum: 0, maximum: 5, default: 2},
|
|
112
110
|
max_endpoints: {type: 'integer', minimum: 1, maximum: 500, default: 250},
|
|
@@ -123,7 +121,7 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
123
121
|
{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)},
|
|
124
122
|
{cap: 'source', refreshGraph: true, name: 'inspect_symbol', description: 'Inspect one exact symbol with an on-demand TypeScript/JavaScript LSP reference query, grouped occurrence containers, graph blast radius, complexity facts and bounded local source context. Ambiguous labels fail closed; point queries never replace the broad precision overlay.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_containers: {type: 'integer', minimum: 1, maximum: 50, default: 15}, context_lines: {type: 'integer', minimum: 0, maximum: 40, default: 8}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => ts.tInspectSymbol(g, a, ctx)},
|
|
125
123
|
{cap: 'source', refreshGraph: true, name: 'context_bundle', description: 'Return one compact, bounded source bundle for an exact symbol: definition, production-first inbound/outbound containers, exact re-export sites, on-demand TS/JS reference evidence and diverse excerpts around call sites. Use before an edit when query_graph would be too broad.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_related: {type: 'integer', minimum: 1, maximum: 30, default: 10}, max_reexports: {type: 'integer', minimum: 1, maximum: 100, default: 20}, max_source_files: {type: 'integer', minimum: 1, maximum: 8, default: 4}, context_lines: {type: 'integer', minimum: 0, maximum: 12, default: 4}, include_classified: {type: 'boolean', default: false, description: 'Include test/e2e/generated/mock/story/docs/benchmark/temp callers after production callers'}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => tb.tContextBundle(g, a, ctx, ts.tInspectSymbol)},
|
|
126
|
-
{cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Supports high-confidence small clones down to 12 tokens when min_tokens is lowered. Tests, classified non-product paths,
|
|
124
|
+
{cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Supports high-confidence small clones down to 12 tokens when min_tokens is lowered. Tests, classified non-product paths, all-router framework boilerplate and immutable declarative catalogs are excluded by default; opt them in explicitly.", inputSchema: {type: 'object', properties: {min_similarity: {type: 'integer', description: '50-100, default 80 (ignored in semantic mode)'}, min_tokens: {type: 'integer', minimum: 12, maximum: 400, description: 'min fragment size, 12-400; default 50'}, mode: {type: 'string', enum: ['renamed', 'strict', 'semantic'], default: 'renamed'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, include_boilerplate: {type: 'boolean', default: false, description: 'Include clone groups made entirely of conventional *.router.js/ts router symbols'}, include_declarative: {type: 'boolean', default: false, description: 'Include repeated immutable array/object catalogs that contain no executable control flow'}, include_strings: {type: 'boolean', description: 'Also clone-check large multi-line string literals', default: false}, top_n: {type: 'integer', default: 15}}}, run: (g, a, ctx) => th.tFindDuplicates(g, a, ctx)},
|
|
127
125
|
{cap: 'health', name: 'find_dead_code', description: 'Conservative review queue for statically unreferenced files, functions, methods and symbols. Returns confidence, reason, bounded evidence and explicit framework/dynamic/reflection/public-API caveats; never an auto-delete verdict. Tests, generated code, mocks, stories, docs, benchmarks and temporary roots are excluded by default.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repo-relative path prefix'}, kinds: {type: 'array', items: {type: 'string', enum: ['file', 'function', 'method', 'symbol']}, maxItems: 4, uniqueItems: true, description: 'Optional candidate kinds; defaults to all'}, min_confidence: {type: 'string', enum: ['high', 'medium', 'low'], default: 'medium', description: 'Minimum confidence to include. low explicitly includes public/framework/dynamic review candidates'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 30}}}, run: (g, a, ctx) => th.tFindDeadCode(g, a, ctx)},
|
|
128
126
|
{cap: 'health', name: 'run_audit', description: 'Core production-first repository Health review with an explicit capability/completeness matrix for structure, dependencies, bounded runtime-correctness/concurrency patterns, advisories, malware and coverage. Unsupported Maven/Gradle import verification is NOT_SUPPORTED/PARTIAL, never a clean zero. Findings whose evidence is entirely test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded are suppressed by default; opt them in with include_classified. category=dependencies is a dedicated dependency-health projection across missing, unused and duplicate declarations while preserving each finding\'s native category. With base_ref, builds and audits an immutable Git checkout and compares stable deterministic finding IDs; debt defaults to genuinely new findings. Supply-chain checks remain explicitly uncomparable across the source-only baseline. changed_files without base_ref is only changed-scope, never a new-debt claim.', inputSchema: {type: 'object', properties: {category: {type: 'string', enum: ['dependencies', 'unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category; dependencies selects dependency manifest/import findings across native categories'}, min_severity: {type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Minimum severity to include'}, max_findings: {type: 'integer', description: 'Max findings to list, default 30'}, include_classified: {type: 'boolean', default: false, description: 'Include findings whose evidence is entirely tests/e2e/generated/mocks/stories/docs/benchmarks/temp or explicitly excluded'}, include_malware_scan: {type: 'boolean', description: 'Also grep installed packages for malware heuristics (slow)', default: false}, base_ref: {type: 'string', maxLength: 200, description: 'Optional immutable Git baseline (for example HEAD~1 or origin/main). Enables honest new/existing/fixed debt comparison'}, changed_files: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 500, description: 'Optional explicit repo-relative scope. Without base_ref this is changed-scope only; when omitted with base_ref, files are derived from the Git diff'}, debt: {type: 'string', enum: ['new', 'existing', 'all'], default: 'new', description: 'Baseline comparison view. Defaults to genuinely new deterministic findings when base_ref is present'}}}, run: (g, a, ctx) => th.tRunAudit(g, a, ctx)},
|
|
129
127
|
{cap: 'health', name: 'coverage_map', description: 'Map a real existing coverage report onto the graph. If no report exists, return clearly labelled static test reachability (a test imports/reaches a source file) with actualCoverage=NOT_AVAILABLE; reachability is never presented as measured coverage.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max risk hotspots to list, default 15'}, path: {type: 'string', description: 'Optional repo-relative path prefix filter, e.g. src/query'}}}, run: (g, a, ctx) => th.tCoverageMap(g, a, ctx)},
|
|
@@ -141,10 +139,6 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
141
139
|
{cap: 'health', name: 'propose_architecture_exception', description: 'Prepare, but never apply, a bounded exception proposal for human review.', inputSchema: {type: 'object', properties: {fingerprint: {type: 'string'}, reason: {type: 'string'}, expires: {type: 'string', description: 'Optional YYYY-MM-DD'}}, required: ['fingerprint', 'reason']}, run: (g, a, ctx) => tar.tProposeArchitectureException(g, a, ctx)},
|
|
142
140
|
{cap: 'retarget', name: 'open_repo', description: 'OFFLINE RETARGET: switch this server to another local Git repository, building its graph when missing. This explicit tool call changes the active repository boundary; pass build:false to probe without building. Omitted mode/precision preserve an existing graph; a new graph uses full and the startup precision setting (lsp unless WEAVATRIX_PRECISION=off). Omit the retarget capability at registration to pin one repository.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Absolute path to a Git working tree'}, build: {type: 'boolean', description: 'Build the graph when missing (default true)', default: true}, mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], description: 'Optional build mode override; omit to preserve an existing graph'}, precision: {type: 'string', enum: ['lsp', 'off'], description: 'Optional semantic precision override; omit to preserve an existing graph or use the startup setting for a new graph'}} , required: ['path']}, run: (g, a, ctx) => ta.tOpenRepo(g, a, ctx)},
|
|
143
141
|
{cap: 'retarget', name: 'list_known_repos', description: 'OFFLINE RETARGET: list every registered local repository graph from the global per-user registry, regardless of parent folder.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tListKnownRepos(g, a, ctx)},
|
|
144
|
-
{cap: 'advisories', name: 'refresh_advisories', description: "NETWORK / explicit advisories profile: refresh the local OSV advisory store for this repo's concrete npm/PyPI/Go/Maven/Gradle/Cargo package versions. Sends package names + versions to OSV.dev — never automatic, never source code. Afterwards run_audit reads the refreshed store fully offline (~/.weavatrix/advisories.json).", inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', description: 'Per-request timeout, default 20000'}}}, run: (g, a, ctx) => ta.tRefreshAdvisories(g, a, ctx)},
|
|
145
|
-
{cap: 'hosted', name: 'pull_architecture_contract', description: 'NETWORK / explicit hosted profile: fetch the owner-approved target architecture for the active stable repository UUID, validate it and cache it locally. Sends only the opaque UUID with bearer auth; never source or paths. HTTP failures are returned as actionable AUTH_REQUIRED/FORBIDDEN/NOT_FOUND/REPOSITORY_NOT_READY states.', inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000}}}, run: (g, a, ctx) => ta.tPullArchitectureContract(g, a, ctx)},
|
|
146
|
-
{cap: 'hosted', name: 'preview_sync', description: 'LOCAL SAFETY PREVIEW / no network: serialize the exact bounded hosted payload, validate the HTTPS destination, and show hostname/path, repository UUID, fields, sections, node/edge counts, bytes and body hash. Returns a five-minute confirmation token for sync_graph. Source bodies, snippets, absolute paths, environment values, credentials, Git remotes and unknown fields are excluded.', inputSchema: {type: 'object', properties: {payload_version: {type: 'integer', enum: [2, 3], default: 3, description: '3 includes bounded architecture/health/stack/package evidence; 2 is explicit graph-only compatibility'}}}, run: (g, a, ctx) => ta.tPreviewSyncGraph(g, a, ctx)},
|
|
147
|
-
{cap: 'hosted', name: 'sync_graph', description: 'NETWORK / explicit hosted profile. Uploads only the exact payload created by preview_sync and requires dry_run:false plus its short-lived confirm_token after user approval. Calling without dry_run:false remains a no-network preview compatibility alias. Source bodies, snippets, absolute paths and unknown fields are never uploaded.', inputSchema: {type: 'object', properties: {payload_version: {type: 'integer', enum: [2, 3], default: 3, description: 'Used only when producing a compatibility preview; the approved token pins the exact serialized payload'}, dry_run: {type: 'boolean', default: true, description: 'Compatibility safety switch. Must be explicitly false to send.'}, confirm_token: {type: 'string', maxLength: 64, description: 'Short-lived token returned by preview_sync for the exact destination and payload'}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000, description: 'Network timeout in milliseconds'}}}, run: (g, a, ctx) => ta.tSyncGraph(g, a, ctx)},
|
|
148
142
|
]
|
|
149
143
|
// Every tool supports the same machine-output switch. Text mode is TextContent-only so large
|
|
150
144
|
// analysis payloads are not duplicated into an agent's context; JSON opts into structuredContent
|
|
@@ -170,7 +164,7 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
170
164
|
// Import the tool modules (cache-busted when version > 0), build the catalog, apply the caps filter.
|
|
171
165
|
// capsArg semantics: undefined/null = offline defaults; a
|
|
172
166
|
// present string (even '') is an explicit selection, so "select nothing" really exposes nothing.
|
|
173
|
-
export async function loadHotApi(version, capsArg) {
|
|
167
|
+
export async function loadHotApi(version, capsArg, {extensions: extensionDefinitions = []} = {}) {
|
|
174
168
|
const v = version ? `?v=${version}` : ''
|
|
175
169
|
const [tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv] = await Promise.all([
|
|
176
170
|
import(new URL(`./tools-graph.mjs${v}`, import.meta.url).href),
|
|
@@ -185,18 +179,50 @@ export async function loadHotApi(version, capsArg) {
|
|
|
185
179
|
import(new URL(`./tools-company.mjs${v}`, import.meta.url).href),
|
|
186
180
|
import(new URL(`./tools-verified-change.mjs${v}`, import.meta.url).href),
|
|
187
181
|
])
|
|
182
|
+
const extensions = normalizeWeavatrixExtensions(extensionDefinitions)
|
|
183
|
+
const extensionProfiles = Object.assign({}, ...extensions.map((extension) => extension.profiles))
|
|
184
|
+
for (const name of Object.keys(extensionProfiles)) {
|
|
185
|
+
if (Object.hasOwn(PROFILE_CAPS, name)) throw new TypeError(`extension profile collides with core profile: ${name}`)
|
|
186
|
+
}
|
|
187
|
+
const profiles = {...PROFILE_CAPS, ...extensionProfiles}
|
|
188
188
|
const raw = capsArg == null ? 'offline' : String(capsArg).trim()
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
189
|
+
if (MOVED_PROFILES.has(raw) && !Object.hasOwn(extensionProfiles, raw)) {
|
|
190
|
+
throw new Error(`MCP profile "${raw}" moved to weavatrix-online; the MIT core exposes only offline network-free profiles`)
|
|
191
|
+
}
|
|
192
|
+
const profile = Object.hasOwn(profiles, raw) ? raw : 'custom'
|
|
193
|
+
const selected = profiles[raw] || raw.split(',').map((s) => s.trim()).filter(Boolean)
|
|
192
194
|
const caps = new Set(selected)
|
|
193
|
-
const
|
|
195
|
+
const coreTools = buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps})
|
|
196
|
+
const extensionTools = extensions.flatMap((extension) => extension.tools.map((tool) => ({...tool, extension: extension.name})))
|
|
197
|
+
const all = [...coreTools, ...extensionTools.map((tool) => ({
|
|
198
|
+
...tool,
|
|
199
|
+
inputSchema: {
|
|
200
|
+
...(tool.inputSchema || {type: 'object'}),
|
|
201
|
+
properties: {
|
|
202
|
+
...(tool.inputSchema?.properties || {}),
|
|
203
|
+
output_format: {
|
|
204
|
+
type: 'string', enum: ['text', 'json'], default: 'text',
|
|
205
|
+
description: 'text returns concise TextContent; json also returns the stable structuredContent envelope',
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
}))]
|
|
210
|
+
const toolNames = new Set()
|
|
211
|
+
for (const tool of all) {
|
|
212
|
+
if (toolNames.has(tool.name)) throw new TypeError(`extension tool collides with an existing tool: ${tool.name}`)
|
|
213
|
+
toolNames.add(tool.name)
|
|
214
|
+
}
|
|
194
215
|
const tools = all.filter((t) => caps.has(t.cap))
|
|
195
216
|
return {
|
|
196
217
|
tools,
|
|
197
218
|
byName: new Map(tools.map((t) => [t.name, t])),
|
|
198
219
|
caps,
|
|
199
220
|
profile,
|
|
221
|
+
extensions: {
|
|
222
|
+
items: extensionRuntimeSummary(extensions),
|
|
223
|
+
auditProviders: extensions.flatMap((extension) => extension.auditProviders.map((provider) => ({...provider, extension: extension.name}))),
|
|
224
|
+
skills: extensions.flatMap((extension) => extension.skills.map((skill) => ({...skill, extension: extension.name}))),
|
|
225
|
+
},
|
|
200
226
|
stalenessLine,
|
|
201
227
|
resetStalenessCache,
|
|
202
228
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const compareText = (left, right) => String(left).localeCompare(String(right), 'en')
|
|
2
2
|
|
|
3
|
-
// Snapshot construction and
|
|
3
|
+
// Snapshot construction and source-free payload validation must use the same stable
|
|
4
4
|
// member order or equivalent evidence can hash differently across the boundary.
|
|
5
5
|
export function compareDuplicateMember(left, right) {
|
|
6
6
|
return compareText(left.file, right.file) || left.startLine - right.startLine ||
|
|
@@ -54,9 +54,9 @@ export function buildHealthSection(graph, audit, repoRoot = null) {
|
|
|
54
54
|
complexity: {thresholds: COMPLEXITY_THRESHOLDS, analyzed: 0, hotspots: []},
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
-
//
|
|
57
|
+
// Source-free extension evidence follows the same production-first path policy as
|
|
58
58
|
// run_audit. Classified-only findings remain available locally through
|
|
59
|
-
// include_classified:true, but must not turn a
|
|
59
|
+
// include_classified:true, but must not turn a production snapshot red.
|
|
60
60
|
const scopedFindings = auditFindingPathScope(audit.findings, {repoRoot}).findings
|
|
61
61
|
const scopedSummary = summarizeFindings(scopedFindings)
|
|
62
62
|
const findings = bounded(scopedFindings.map(sanitizeFinding).filter(Boolean)
|