weavatrix 0.2.19 → 0.3.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 +103 -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/docs/releases/v0.3.1.md +30 -0
- package/package.json +9 -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/change-classification.js +72 -10
- 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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createRepoBoundary } from "../repo-path.js";
|
|
2
2
|
import { dependencyVerification, makeFinding } from "./findings.js";
|
|
3
3
|
import { parseGradleDependencies, parseGradleVersionCatalog, parseMavenPom } from "./jvm-manifests.js";
|
|
4
|
+
import { collectJvmArtifactIndex } from "./jvm-artifact-index.js";
|
|
4
5
|
import { listRepoFiles, readRepoText } from "./internal-audit.collect.js";
|
|
5
6
|
|
|
6
7
|
const normalize = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
@@ -24,12 +25,18 @@ function bestDependency(spec, dependencies) {
|
|
|
24
25
|
return ranked[0].dependency;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
function analyze(ecosystem, manifests, dependencies, imports, unresolvedDeclarations, mappingDependencies = dependencies, includeMissing = true) {
|
|
28
|
+
function analyze(ecosystem, manifests, dependencies, imports, unresolvedDeclarations, mappingDependencies = dependencies, includeMissing = true, artifactEvidence) {
|
|
28
29
|
const findings = [], used = new Map(), missing = new Map();
|
|
29
30
|
const owned = new Set(dependencies.map((dependency) => dependency.name));
|
|
31
|
+
let exactMappedImports = 0, heuristicMappedImports = 0, ambiguousImports = 0;
|
|
30
32
|
for (const item of imports) {
|
|
31
|
-
const
|
|
33
|
+
const exactOwners = artifactEvidence.resolve(item.spec || item.pkg);
|
|
34
|
+
if (exactOwners.length > 1) ambiguousImports++;
|
|
35
|
+
const dependency = exactOwners.length === 1
|
|
36
|
+
? mappingDependencies.find((candidate) => candidate.name === exactOwners[0])
|
|
37
|
+
: bestDependency(item.spec || item.pkg, mappingDependencies);
|
|
32
38
|
if (dependency) {
|
|
39
|
+
if (exactOwners.length === 1) exactMappedImports++; else heuristicMappedImports++;
|
|
33
40
|
if (owned.has(dependency.name)) {
|
|
34
41
|
const list = used.get(dependency.name) || [];
|
|
35
42
|
list.push(item); used.set(dependency.name, list);
|
|
@@ -69,21 +76,36 @@ function analyze(ecosystem, manifests, dependencies, imports, unresolvedDeclarat
|
|
|
69
76
|
}));
|
|
70
77
|
}
|
|
71
78
|
const present = manifests.length > 0;
|
|
79
|
+
const exactComplete = present && unresolvedDeclarations === 0 && !artifactEvidence.truncated
|
|
80
|
+
&& artifactEvidence.errors.length === 0 && artifactEvidence.artifactsMissing === 0
|
|
81
|
+
&& artifactEvidence.artifactsIndexed === artifactEvidence.artifactsRequired
|
|
82
|
+
&& exactMappedImports === imports.length && ambiguousImports === 0;
|
|
72
83
|
return {
|
|
73
84
|
present,
|
|
74
85
|
status: present ? "CHECKED" : "NOT_PRESENT",
|
|
75
|
-
completeness: present ? (
|
|
86
|
+
completeness: present ? (exactComplete ? "COMPLETE" : "PARTIAL") : "NOT_APPLICABLE",
|
|
76
87
|
manifests,
|
|
77
88
|
declared: dependencies.length,
|
|
78
89
|
mappedImports: [...used.values()].reduce((sum, list) => sum + list.length, 0),
|
|
79
90
|
unmappedImports: [...missing.values()].reduce((sum, list) => sum + list.length, 0),
|
|
80
91
|
unresolvedDeclarations,
|
|
92
|
+
exactArtifactEvidence: {
|
|
93
|
+
artifactsRequired: artifactEvidence.artifactsRequired,
|
|
94
|
+
artifactsIndexed: artifactEvidence.artifactsIndexed,
|
|
95
|
+
artifactsMissing: artifactEvidence.artifactsMissing,
|
|
96
|
+
classesIndexed: artifactEvidence.classCount,
|
|
97
|
+
exactMappedImports,
|
|
98
|
+
heuristicMappedImports,
|
|
99
|
+
ambiguousImports,
|
|
100
|
+
truncated: artifactEvidence.truncated,
|
|
101
|
+
errors: artifactEvidence.errors.slice(0, 10),
|
|
102
|
+
},
|
|
81
103
|
sample: dependencies.slice(0, 20).map(({ file, name, version }) => ({ file, identity: name, version })),
|
|
82
104
|
reason: !present
|
|
83
105
|
? `No ${ecosystem === "maven" ? "pom.xml" : "Gradle build file"} was discovered.`
|
|
84
|
-
:
|
|
85
|
-
? `${dependencies.length} declarations
|
|
86
|
-
: `${dependencies.length} declarations
|
|
106
|
+
: exactComplete
|
|
107
|
+
? `${dependencies.length} declarations were compared with every indexed non-JDK Java import using exact class ownership from ${artifactEvidence.artifactsIndexed} installed JAR(s).`
|
|
108
|
+
: `${dependencies.length} declarations and every indexed non-JDK Java import were checked, but exact artifact evidence is partial: ${unresolvedDeclarations} unresolved declaration(s), ${artifactEvidence.artifactsMissing} installed JAR(s) missing, ${heuristicMappedImports} heuristic mapping(s), ${ambiguousImports} ambiguous mapping(s). Heuristic missing/unused findings remain review evidence, never compiler proof.`,
|
|
87
109
|
findings,
|
|
88
110
|
};
|
|
89
111
|
}
|
|
@@ -108,8 +130,9 @@ export function collectJvmDependencyEvidence(repoRoot, { files = listRepoFiles(r
|
|
|
108
130
|
});
|
|
109
131
|
const javaImports = externalImports.filter((entry) => entry.ecosystem === "Maven" && entry.pkg && !entry.builtin && !entry.unresolved);
|
|
110
132
|
const allDependencies = [...mavenDependencies, ...gradleDependencies];
|
|
133
|
+
const artifactEvidence = collectJvmArtifactIndex(allDependencies);
|
|
111
134
|
return {
|
|
112
|
-
maven: analyze("maven", mavenFiles, mavenDependencies, javaImports, mavenUnresolved, allDependencies, true),
|
|
113
|
-
gradle: analyze("gradle", [...gradleFiles, ...catalogFiles], gradleDependencies, javaImports, gradleUnresolved, allDependencies, mavenFiles.length === 0),
|
|
135
|
+
maven: analyze("maven", mavenFiles, mavenDependencies, javaImports, mavenUnresolved, allDependencies, true, artifactEvidence),
|
|
136
|
+
gradle: analyze("gradle", [...gradleFiles, ...catalogFiles], gradleDependencies, javaImports, gradleUnresolved, allDependencies, mavenFiles.length === 0, artifactEvidence),
|
|
114
137
|
};
|
|
115
138
|
}
|
|
@@ -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'
|