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.
Files changed (41) hide show
  1. package/README.md +103 -93
  2. package/SECURITY.md +13 -31
  3. package/bin/weavatrix-mcp.mjs +2 -1
  4. package/docs/adr/0001-v0.3-offline-online-split.md +9 -7
  5. package/docs/releases/v0.3.0.md +43 -0
  6. package/docs/releases/v0.3.1.md +30 -0
  7. package/package.json +9 -2
  8. package/skill/SKILL.md +34 -59
  9. package/src/analysis/architecture/contract-storage.js +2 -2
  10. package/src/analysis/audit-extensions.js +60 -0
  11. package/src/analysis/change-classification.js +72 -10
  12. package/src/analysis/duplicate-groups.js +13 -2
  13. package/src/analysis/duplicates.compute.js +7 -2
  14. package/src/analysis/health-capabilities.js +2 -1
  15. package/src/analysis/internal-audit/supply-chain.js +17 -2
  16. package/src/analysis/jvm-artifact-index.js +87 -0
  17. package/src/analysis/jvm-dependency-evidence.js +31 -8
  18. package/src/analysis/transport-contracts.js +78 -11
  19. package/src/analysis/transport-runtime-evidence.js +155 -0
  20. package/src/child-env.js +2 -2
  21. package/src/extension/local-services.mjs +75 -0
  22. package/src/graph/internal-builder.langs.js +2 -0
  23. package/src/graph/repo-registry.js +2 -2
  24. package/src/mcp/catalog.mjs +47 -21
  25. package/src/mcp/evidence/duplicate-member-order.mjs +1 -1
  26. package/src/mcp/evidence-snapshot.health.mjs +2 -2
  27. package/src/mcp/extension-api.mjs +77 -0
  28. package/src/mcp/health/audit-format.mjs +11 -1
  29. package/src/mcp/health/audit.mjs +10 -7
  30. package/src/mcp/health/duplicates.mjs +5 -3
  31. package/src/mcp/sync-payload.mjs +1 -1
  32. package/src/mcp/tools-actions.mjs +1 -8
  33. package/src/mcp/tools-architecture.mjs +2 -2
  34. package/src/mcp/tools-company.mjs +4 -2
  35. package/src/mcp-runtime.mjs +2 -0
  36. package/src/mcp-server.mjs +28 -17
  37. package/src/security/advisory-store.js +133 -181
  38. package/src/security/rust-advisory-report.js +60 -0
  39. package/src/mcp/actions/advisories.mjs +0 -17
  40. package/src/mcp/actions/graph-sync.mjs +0 -195
  41. 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 dependency = bestDependency(item.spec || item.pkg, mappingDependencies);
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 ? (unresolvedDeclarations ? "PARTIAL" : "COMPLETE") : "NOT_APPLICABLE",
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
- : unresolvedDeclarations
85
- ? `${dependencies.length} declarations and all indexed Java imports were checked, but ${unresolvedDeclarations} dynamic/property/catalog declaration(s) could not be resolved statically.`
86
- : `${dependencies.length} declarations were compared with every indexed non-JDK Java import using bounded group-prefix and artifact-token evidence. Missing and unused results remain review evidence, not compiler proof.`,
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: uniqueBy(contracts, (item) => `${item.transport}|${item.side}|${item.service || ""}|${item.operation || ""}|${item.name}|${item.file}|${item.line}`),
102
- uncertain: uniqueBy(uncertain, (item) => `${item.transport}|${item.file}|${item.line}|${item.reason}`),
103
- reasons: loaded.reasons,
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
- if (server.transport === "graphql" && caller.side === "client" && server.operation === caller.operation && server.name === caller.name) return { confidence: "high", kind: "operation-field" };
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 { confidence: "high", kind: "service-method" };
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 { confidence: "high", kind: "topic-direction" };
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 = { includeTests: input.includeTests === true, maxFiles: Math.max(1, Math.min(10_000, Number(input.maxFiles) || 3_000)) };
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: 1,
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: { contracts: results.length, matches: results.reduce((sum, item) => sum + item.callsites.length, 0), uncertain: uncertain.length, filesScanned: backend.filesScanned + clients.reduce((sum, item) => sum + item.evidence.filesScanned, 0) },
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 do not need the hosted-sync bearer token.
2
- // Keep it in the MCP process so only sync_graph can attach it to an HTTP request.
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; hosted sync
2
- // uses the opaque UUID. Identity is anchored in each canonical graph folder so simultaneous MCP
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'