weavatrix 0.2.15 → 0.2.16
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 +53 -13
- package/docs/releases/v0.2.16.md +53 -0
- package/package.json +6 -2
- package/src/analysis/cargo-dependency-evidence.js +111 -0
- package/src/analysis/cargo-manifests.js +74 -0
- package/src/analysis/dep-check-ecosystems.js +3 -0
- package/src/analysis/endpoints-java.js +2 -5
- package/src/analysis/findings.js +12 -0
- package/src/analysis/go-dependency-evidence.js +68 -0
- package/src/analysis/health-capabilities.js +1 -1
- package/src/analysis/http-contracts/analysis.js +2 -0
- package/src/analysis/http-contracts/client-call-detection.js +1 -0
- package/src/analysis/http-contracts/client-call-parser.js +18 -4
- package/src/analysis/internal-audit/dependency-health.js +15 -13
- package/src/analysis/internal-audit/python-manifests.js +18 -5
- package/src/analysis/internal-audit/supply-chain.js +2 -0
- package/src/analysis/internal-audit.run.js +12 -7
- package/src/analysis/jvm-dependency-evidence.js +102 -56
- package/src/analysis/jvm-manifests.js +114 -0
- package/src/analysis/source-correctness.js +2 -5
- package/src/analysis/transport-contracts.js +157 -0
- package/src/graph/builder/lang-go.js +4 -2
- package/src/graph/builder/lang-java.js +12 -3
- package/src/graph/builder/lang-rust.js +22 -3
- package/src/graph/freshness-probe.js +1 -1
- package/src/graph/internal-builder.build.js +2 -2
- package/src/graph/internal-builder.resolvers.js +18 -8
- package/src/mcp/actions/advisories.mjs +2 -3
- package/src/mcp/catalog.mjs +7 -4
- package/src/mcp/company-contract-verdict.mjs +42 -0
- package/src/mcp/tools-company.mjs +28 -53
- package/src/mcp/tools-impact-precision.mjs +89 -0
- package/src/mcp/tools-impact.mjs +52 -94
- package/src/precision/lsp-overlay/build.js +10 -0
- package/src/precision/lsp-overlay/contract.js +1 -1
- package/src/precision/symbol-query.js +39 -0
- package/src/precision/typescript-provider/client.js +16 -3
- package/src/precision/typescript-provider/discovery.js +1 -1
- package/src/precision/typescript-provider/isolated-runtime.js +39 -0
- package/src/precision/typescript-provider/project-host.js +11 -6
- package/src/precision/typescript-provider/project-safety.js +5 -0
- package/src/security/advisory-store.js +2 -2
- package/src/security/installed-jvm-rust.js +46 -0
- package/src/security/installed.js +21 -1
- package/src/util.js +8 -0
- package/docs/releases/v0.2.15.md +0 -37
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createRepoBoundary } from "../repo-path.js";
|
|
2
|
+
import { lineNumberAt, uniqueBy } from "../util.js";
|
|
3
|
+
import { listRepoFiles, readRepoText } from "./internal-audit/repo-files.js";
|
|
4
|
+
import { affectedForEndpoint, reverseRuntimeImports } from "./http-contracts/graph-context.js";
|
|
5
|
+
|
|
6
|
+
const TEST_RE = /(^|\/)(?:test|tests|__tests__|spec|e2e|fixtures?)(\/|$)|[._-](?:test|spec)\.[a-z0-9]+$/i;
|
|
7
|
+
const SOURCE_RE = /\.(?:[cm]?[jt]sx?|py|go|java|rs|cs|proto|graphql|gql)$/i;
|
|
8
|
+
const lineAt = lineNumberAt;
|
|
9
|
+
const norm = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
10
|
+
|
|
11
|
+
function sourcesFor(descriptor, { includeTests, maxFiles }) {
|
|
12
|
+
const boundary = createRepoBoundary(descriptor.repoRoot);
|
|
13
|
+
if (!boundary.root) return { sources: [], reasons: [`${descriptor.id}: repository boundary is unreadable`] };
|
|
14
|
+
const candidates = listRepoFiles(boundary.root).filter((file) => SOURCE_RE.test(file) && (includeTests || !TEST_RE.test(file))).sort();
|
|
15
|
+
const sources = [];
|
|
16
|
+
for (const file of candidates.slice(0, maxFiles)) {
|
|
17
|
+
const text = readRepoText(boundary, file);
|
|
18
|
+
if (text != null) sources.push({ file, text });
|
|
19
|
+
}
|
|
20
|
+
return { sources, reasons: candidates.length > maxFiles ? [`${descriptor.id}: transport file cap reached`] : [] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function graphQlEvidence(source, role) {
|
|
24
|
+
const contracts = [], uncertain = [];
|
|
25
|
+
for (const schema of source.text.matchAll(/\btype\s+(Query|Mutation|Subscription)\s*(?:extends\s+\w+\s*)?\{([\s\S]*?)\}/gi)) {
|
|
26
|
+
for (const field of schema[2].matchAll(/^\s*([A-Za-z_]\w*)\s*(?:\([^)]*\))?\s*:/gm)) contracts.push({
|
|
27
|
+
transport: "graphql", side: "server", operation: schema[1].toUpperCase(), name: field[1], file: source.file,
|
|
28
|
+
line: lineAt(source.text, schema.index + schema[0].indexOf(field[0])), detector: "graphql-schema",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
for (const operation of source.text.matchAll(/\b(query|mutation|subscription)\s*(?:[A-Za-z_]\w*\s*)?(?:\([^)]*\)\s*)?\{\s*([A-Za-z_]\w*)/gi)) contracts.push({
|
|
32
|
+
transport: "graphql", side: role === "backend" ? "server-call" : "client", operation: operation[1].toUpperCase(), name: operation[2],
|
|
33
|
+
file: source.file, line: lineAt(source.text, operation.index), detector: "graphql-operation",
|
|
34
|
+
});
|
|
35
|
+
for (const dynamic of source.text.matchAll(/\b(?:gql|graphql)\s*(?:\(|`)\s*\$\{|\b(?:query|mutate)\s*\(\s*(?!["'`])/gi)) uncertain.push({
|
|
36
|
+
transport: "graphql", file: source.file, line: lineAt(source.text, dynamic.index), reason: "GraphQL document or operation is runtime-computed",
|
|
37
|
+
});
|
|
38
|
+
return { contracts, uncertain };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function grpcEvidence(source, role) {
|
|
42
|
+
const contracts = [], uncertain = [], aliases = new Map();
|
|
43
|
+
for (const service of source.text.matchAll(/\bservice\s+([A-Za-z_]\w*)\s*\{([\s\S]*?)\}/g)) {
|
|
44
|
+
for (const rpc of service[2].matchAll(/\brpc\s+([A-Za-z_]\w*)\s*\(/g)) contracts.push({
|
|
45
|
+
transport: "grpc", side: "server", service: service[1], name: rpc[1], file: source.file,
|
|
46
|
+
line: lineAt(source.text, service.index + service[0].indexOf(rpc[0])), detector: "proto-service",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const aliasPatterns = [
|
|
50
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+([A-Za-z_]\w*(?:Service)?Client)\b/g,
|
|
51
|
+
/\b([A-Za-z_]\w*)\s*=\s*([A-Za-z_]\w*Stub)\s*\(/g,
|
|
52
|
+
/\b([A-Za-z_]\w*)\s*:?=\s*\w*\.New([A-Za-z_]\w*)Client\s*\(/g,
|
|
53
|
+
/\b(?:[A-Za-z_]\w*\.)*([A-Za-z_]\w*(?:Blocking|Future|Async)?Stub)\s+([A-Za-z_]\w*)\s*[=;]/g,
|
|
54
|
+
];
|
|
55
|
+
for (const [index, pattern] of aliasPatterns.entries()) for (const match of source.text.matchAll(pattern)) {
|
|
56
|
+
const alias = index === 3 ? match[2] : match[1];
|
|
57
|
+
const type = index === 3 ? match[1] : match[2];
|
|
58
|
+
aliases.set(alias, type.replace(/(?:Service)?Client$|(?:Blocking|Future|Async)?Stub$/i, ""));
|
|
59
|
+
}
|
|
60
|
+
for (const call of source.text.matchAll(/\b([A-Za-z_$][\w$]*)\s*(?:\.|->)\s*([A-Za-z_]\w*)\s*\(/g)) {
|
|
61
|
+
if (!aliases.has(call[1])) continue;
|
|
62
|
+
contracts.push({ transport: "grpc", side: role === "backend" ? "server-call" : "client", service: aliases.get(call[1]), name: call[2], file: source.file, line: lineAt(source.text, call.index), detector: "grpc-stub-call" });
|
|
63
|
+
}
|
|
64
|
+
for (const dynamic of source.text.matchAll(/\b(?:ServerReflection|ProtoReflection|grpc\.reflection|Class\.forName|Method\.invoke|reflect\.Value|dynamicStub)\b/g)) uncertain.push({
|
|
65
|
+
transport: "grpc", file: source.file, line: lineAt(source.text, dynamic.index), reason: "gRPC/reflection target is runtime-resolved",
|
|
66
|
+
});
|
|
67
|
+
return { contracts, uncertain };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function eventEvidence(source, role) {
|
|
71
|
+
const contracts = [], uncertain = [];
|
|
72
|
+
const add = (side, kind, match, topicIndex = 2) => contracts.push({
|
|
73
|
+
transport: "event", side, kind, name: match[topicIndex], file: source.file, line: lineAt(source.text, match.index), detector: "static-topic",
|
|
74
|
+
});
|
|
75
|
+
const subscriptions = [
|
|
76
|
+
/\b(?:consumer|kafka|bus|events?|eventBus)\s*(?:\.|->)\s*(subscribe|on|consume)\s*\(\s*["'`]([^"'`]+)["'`]/gi,
|
|
77
|
+
/@KafkaListener\s*\([^)]*\btopics?\s*=\s*["']([^"']+)["']/gi,
|
|
78
|
+
/\bsubscribe\s*\(\s*\{\s*topic\s*:\s*["'`]([^"'`]+)["'`]/gi,
|
|
79
|
+
];
|
|
80
|
+
for (const [index, pattern] of subscriptions.entries()) for (const match of source.text.matchAll(pattern)) add("subscriber", index === 0 ? match[1] : "subscribe", match, index === 0 ? 2 : 1);
|
|
81
|
+
const publications = [
|
|
82
|
+
/\b(?:producer|kafka|bus|events?|eventBus|kafkaTemplate)\s*(?:\.|->)\s*(publish|emit|send|produce)\s*\(\s*["'`]([^"'`]+)["'`]/gi,
|
|
83
|
+
/\b(?:producer|kafka)\s*(?:\.|->)\s*send\s*\(\s*\{\s*topic\s*:\s*["'`]([^"'`]+)["'`]/gi,
|
|
84
|
+
];
|
|
85
|
+
for (const [index, pattern] of publications.entries()) for (const match of source.text.matchAll(pattern)) add("publisher", index === 0 ? match[1] : "send", match, index === 0 ? 2 : 1);
|
|
86
|
+
for (const dynamic of source.text.matchAll(/\b(?:subscribe|publish|emit|consume|produce)\s*\(\s*(?!["'`{])([A-Za-z_$][\w$]*)/gi)) uncertain.push({
|
|
87
|
+
transport: "event", file: source.file, line: lineAt(source.text, dynamic.index), reason: `Event topic is runtime-computed (${dynamic[1]})`, role,
|
|
88
|
+
});
|
|
89
|
+
return { contracts, uncertain };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function detect(descriptor, role, options) {
|
|
93
|
+
const loaded = sourcesFor(descriptor, options), contracts = [], uncertain = [];
|
|
94
|
+
for (const source of loaded.sources) {
|
|
95
|
+
for (const detector of [graphQlEvidence, grpcEvidence, eventEvidence]) {
|
|
96
|
+
const result = detector(source, role);
|
|
97
|
+
contracts.push(...result.contracts); uncertain.push(...result.uncertain);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
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,
|
|
104
|
+
filesScanned: loaded.sources.length,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function contractMatch(server, caller, backendServers) {
|
|
109
|
+
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" };
|
|
111
|
+
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" };
|
|
113
|
+
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" };
|
|
115
|
+
}
|
|
116
|
+
if (server.transport === "event" && server.name === caller.name && server.side !== caller.side) return { confidence: "high", kind: "topic-direction" };
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function analyzeTransportContracts(input = {}) {
|
|
121
|
+
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)) };
|
|
123
|
+
const backend = detect(input.backend, "backend", options);
|
|
124
|
+
const clients = (input.clients || []).map((descriptor) => ({ descriptor, evidence: detect(descriptor, "client", options), reverse: reverseRuntimeImports(descriptor.graph) }));
|
|
125
|
+
const selected = (item) => transport === "all" || item.transport === transport;
|
|
126
|
+
const backendContracts = backend.contracts.filter(selected);
|
|
127
|
+
const servers = backendContracts.filter((item) => item.side === "server" || item.transport === "event");
|
|
128
|
+
const results = [];
|
|
129
|
+
for (const server of servers) {
|
|
130
|
+
const callsites = [];
|
|
131
|
+
for (const client of clients) for (const caller of client.evidence.contracts.filter(selected)) {
|
|
132
|
+
const match = contractMatch(server, caller, servers);
|
|
133
|
+
if (!match) continue;
|
|
134
|
+
callsites.push({ clientRepo: client.descriptor.id, file: caller.file, line: caller.line, detector: caller.detector, match });
|
|
135
|
+
}
|
|
136
|
+
const affected = affectedForEndpoint(callsites, clients.map((item) => ({ id: item.descriptor.id, reverse: item.reverse })), {
|
|
137
|
+
maxImpactDepth: Math.max(0, Math.min(5, Number(input.maxImpactDepth) || 2)),
|
|
138
|
+
maxAffectedFiles: Math.max(1, Math.min(500, Number(input.maxAffectedFiles) || 100)), maxScreens: 50, maxModules: 50,
|
|
139
|
+
});
|
|
140
|
+
results.push({ ...server, callsites, affected, liveness: callsites.length ? "NOT_DEAD_EXTERNAL_USE" : "UNKNOWN" });
|
|
141
|
+
}
|
|
142
|
+
const uncertain = [
|
|
143
|
+
...backend.uncertain.map((item) => ({ repository: input.backend.id, ...item })),
|
|
144
|
+
...clients.flatMap((item) => item.evidence.uncertain.map((entry) => ({ repository: item.descriptor.id, ...entry }))),
|
|
145
|
+
].filter(selected);
|
|
146
|
+
const reasons = [...backend.reasons, ...clients.flatMap((item) => item.evidence.reasons)];
|
|
147
|
+
if (uncertain.length) reasons.push(`${uncertain.length} dynamic/reflection contract expression(s) remain UNKNOWN`);
|
|
148
|
+
return {
|
|
149
|
+
transportContractsV: 1,
|
|
150
|
+
transport,
|
|
151
|
+
status: reasons.length ? "PARTIAL" : "COMPLETE",
|
|
152
|
+
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) },
|
|
154
|
+
contracts: results,
|
|
155
|
+
uncertain: uncertain.slice(0, 200),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -72,7 +72,9 @@ export default {
|
|
|
72
72
|
heritage: [],
|
|
73
73
|
|
|
74
74
|
pass1(ctx) {
|
|
75
|
-
const { grammar, tree, fileRel, caps, addSym, addImportEdge, addExternalImport, imports, resolveGoImport, dirFiles, goModule, goRequires } = ctx;
|
|
75
|
+
const { grammar, tree, fileRel, caps, addSym, addImportEdge, addExternalImport, imports, resolveGoImport, dirFiles, goModule, goModules, goRequires } = ctx;
|
|
76
|
+
const owningModule = (goModules || []).filter((item) => !item.root || fileRel === item.root || fileRel.startsWith(`${item.root}/`))
|
|
77
|
+
.sort((left, right) => right.root.length - left.root.length)[0];
|
|
76
78
|
|
|
77
79
|
// ---- symbols ----
|
|
78
80
|
for (const cap of caps(grammar, `(function_declaration name: (identifier) @fn)`, tree.rootNode)) {
|
|
@@ -124,7 +126,7 @@ export default {
|
|
|
124
126
|
if (!d) {
|
|
125
127
|
// not a repo package: stdlib (builtin) or an external module — record for dependency analysis
|
|
126
128
|
const line = pathNode.startPosition.row + 1;
|
|
127
|
-
const r = goSpecToPkg(importPath, { requires: goRequires || [], ownModule: goModule || "" });
|
|
129
|
+
const r = goSpecToPkg(importPath, { requires: owningModule?.requires || goRequires || [], ownModule: owningModule?.module || goModule || "" });
|
|
128
130
|
if (r) addExternalImport({ spec: importPath, pkg: r.pkg, builtin: r.builtin, ecosystem: "Go", kind: "go-import", line });
|
|
129
131
|
else addExternalImport({ spec: importPath, kind: "go-import", line, unresolved: true }); // own-module path with no matching dir
|
|
130
132
|
continue;
|
|
@@ -83,7 +83,7 @@ export default {
|
|
|
83
83
|
pass1(ctx) {
|
|
84
84
|
const {
|
|
85
85
|
grammar, tree, fileRel, caps, field, addSym, addImportEdge, imports,
|
|
86
|
-
resolveJavaImport, fileSet, links, nodeIds,
|
|
86
|
+
resolveJavaImport, fileSet, links, nodeIds, addExternalImport,
|
|
87
87
|
} = ctx;
|
|
88
88
|
const ownerIds = new Map();
|
|
89
89
|
const addJavaSym = (nameNode, callable, extra) => {
|
|
@@ -143,11 +143,17 @@ export default {
|
|
|
143
143
|
const line = lineOf(cap.node);
|
|
144
144
|
if (!isStatic && wildcard) {
|
|
145
145
|
wildcardPackages.push(parts);
|
|
146
|
+
const packagePath = `${parts.join("/")}/`;
|
|
147
|
+
const projectLocal = [...fileSet].some((file) => file.startsWith(packagePath) || file.includes(`/${packagePath}`));
|
|
148
|
+
if (!projectLocal) addExternalImport({ spec: `${parts.join(".")}.*`, pkg: parts.join("."), builtin: ["java", "jdk", "sun"].includes(parts[0]), ecosystem: "Maven", kind: "java-import", line });
|
|
146
149
|
continue;
|
|
147
150
|
}
|
|
148
151
|
if (!isStatic) {
|
|
149
152
|
const target = exactJavaTarget(resolveJavaImport, parts);
|
|
150
|
-
if (!target)
|
|
153
|
+
if (!target) {
|
|
154
|
+
addExternalImport({ spec: parts.join("."), pkg: parts.join("."), builtin: ["java", "jdk", "sun"].includes(parts[0]), ecosystem: "Maven", kind: "java-import", line });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
151
157
|
const local = parts[parts.length - 1];
|
|
152
158
|
addImportEdge(target, { line, specifier: parts.join("."), compileOnly: true });
|
|
153
159
|
imports.set(local, { imported: local, targetFile: target });
|
|
@@ -162,7 +168,10 @@ export default {
|
|
|
162
168
|
target = exactJavaTarget(resolveJavaImport, candidate);
|
|
163
169
|
if (target) classParts = candidate;
|
|
164
170
|
}
|
|
165
|
-
if (!target)
|
|
171
|
+
if (!target) {
|
|
172
|
+
addExternalImport({ spec: parts.join("."), pkg: parts.join("."), builtin: ["java", "jdk", "sun"].includes(parts[0]), ecosystem: "Maven", kind: "java-static-import", line });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
166
175
|
addImportEdge(target, { line, specifier: `${isStatic ? "static " : ""}${parts.join(".")}${wildcard ? ".*" : ""}`, compileOnly: true });
|
|
167
176
|
if (wildcard) staticWildcardTargets.push(target);
|
|
168
177
|
else {
|
|
@@ -113,7 +113,7 @@ export default {
|
|
|
113
113
|
],
|
|
114
114
|
|
|
115
115
|
pass1(ctx) {
|
|
116
|
-
const { grammar, tree, fileRel, caps, field, addSym, addImportEdge, imports, resolveRustMod, resolveRustPath, links, nameToId } = ctx;
|
|
116
|
+
const { grammar, tree, fileRel, caps, field, addSym, addImportEdge, addExternalImport, imports, resolveRustMod, resolveRustPath, links, nameToId } = ctx;
|
|
117
117
|
const owned = [];
|
|
118
118
|
for (const src of [SYMS_CORE, ...SYMS_OPTIONAL]) {
|
|
119
119
|
for (const cap of caps(grammar, src, tree.rootNode)) {
|
|
@@ -175,7 +175,13 @@ export default {
|
|
|
175
175
|
const ancestors = inlineAncestors(use);
|
|
176
176
|
for (const leaf of useLeaves(use)) {
|
|
177
177
|
const resolved = resolveRustPath(fileRel, leaf.segments, { inlineModules: ancestors, unqualified: true });
|
|
178
|
-
if (!resolved)
|
|
178
|
+
if (!resolved) {
|
|
179
|
+
const crate = cleanSegment(leaf.segments[0]);
|
|
180
|
+
if (crate && !["crate", "self", "super", "std", "core", "alloc"].includes(crate) && /^[a-z_][\w]*$/.test(crate)) {
|
|
181
|
+
addExternalImport({ spec: leaf.segments.join("::"), pkg: crate.replace(/_/g, "-"), builtin: false, ecosystem: "crates.io", kind: "rust-use", line: use.startPosition.row + 1 });
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
179
185
|
emit(resolved.targetFile, { relation, line: use.startPosition.row + 1, specifier: use.text.replace(/;\s*$/, "") });
|
|
180
186
|
if (!leaf.wildcard && leaf.local && leaf.local !== "_") {
|
|
181
187
|
const imported = resolved.remaining.length ? cleanSegment(resolved.remaining.at(-1)) : "*";
|
|
@@ -192,7 +198,13 @@ export default {
|
|
|
192
198
|
if (under(node, "use_declaration")) continue;
|
|
193
199
|
if (["scoped_identifier", "scoped_type_identifier"].includes(node.parent?.type)) continue;
|
|
194
200
|
const segments = pathParts(node);
|
|
195
|
-
if (!["crate", "self", "super"].includes(segments[0]))
|
|
201
|
+
if (!["crate", "self", "super"].includes(segments[0])) {
|
|
202
|
+
const crate = cleanSegment(segments[0]);
|
|
203
|
+
if (crate && !["std", "core", "alloc"].includes(crate) && /^[a-z_][\w]*$/.test(crate)) {
|
|
204
|
+
addExternalImport({ spec: segments.join("::"), pkg: crate.replace(/_/g, "-"), builtin: false, ecosystem: "crates.io", kind: "rust-path", line: node.startPosition.row + 1 });
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
196
208
|
const resolved = resolveRustPath(fileRel, segments, { inlineModules: inlineAncestors(node), unqualified: false });
|
|
197
209
|
if (!resolved) continue;
|
|
198
210
|
emit(resolved.targetFile, { line: node.startPosition.row + 1, specifier: node.text });
|
|
@@ -201,5 +213,12 @@ export default {
|
|
|
201
213
|
imports.set(finalName, { imported: finalName, targetFile: resolved.targetFile, rustQualified: true });
|
|
202
214
|
}
|
|
203
215
|
}
|
|
216
|
+
|
|
217
|
+
for (const cap of caps(grammar, `(extern_crate_declaration name: (identifier) @crate)`, tree.rootNode)) {
|
|
218
|
+
const crate = cleanSegment(cap.node.text);
|
|
219
|
+
if (crate && !["std", "core", "alloc"].includes(crate)) {
|
|
220
|
+
addExternalImport({ spec: crate, pkg: crate.replace(/_/g, "-"), builtin: false, ecosystem: "crates.io", kind: "rust-extern-crate", line: cap.node.startPosition.row + 1 });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
204
223
|
},
|
|
205
224
|
};
|
|
@@ -19,7 +19,7 @@ export const GRAPH_BUILDER_VERSION = (() => {
|
|
|
19
19
|
// version invalidates stamps across releases; the explicit schema requirements also fail closed when
|
|
20
20
|
// a saved graph is hand-edited or a development build bumps a structural schema without a version bump.
|
|
21
21
|
const CURRENT_GRAPH_SCHEMA = Object.freeze({
|
|
22
|
-
extImportsV:
|
|
22
|
+
extImportsV: 3,
|
|
23
23
|
edgeTypesV: 2,
|
|
24
24
|
edgeProvenanceV: 1,
|
|
25
25
|
complexityV: 2,
|
|
@@ -248,7 +248,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
248
248
|
assignDeterministicCommunities(nodes);
|
|
249
249
|
stampEdgeProvenance(links);
|
|
250
250
|
|
|
251
|
-
// extImportsV: bump when the externalImports schema/coverage changes (
|
|
251
|
+
// extImportsV: bump when the externalImports schema/coverage changes (v3 = Java/Rust ecosystems) —
|
|
252
252
|
// deps-engine rebuilds in memory when a saved graph is older than this.
|
|
253
253
|
// edgeTypesV 2 adds language-neutral compile-only edges (currently Rust mod/use/re-export) on top
|
|
254
254
|
// of v1's TypeScript typeOnly classification.
|
|
@@ -256,7 +256,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
256
256
|
nodes,
|
|
257
257
|
links,
|
|
258
258
|
externalImports,
|
|
259
|
-
extImportsV:
|
|
259
|
+
extImportsV: 3,
|
|
260
260
|
edgeTypesV: 2,
|
|
261
261
|
edgeProvenanceV: EDGE_PROVENANCE_V,
|
|
262
262
|
complexityV: 2,
|
|
@@ -6,6 +6,7 @@ import { readFileSync } from "node:fs";
|
|
|
6
6
|
import { join, dirname } from "node:path";
|
|
7
7
|
import { parseGoMod } from "../analysis/manifests.js";
|
|
8
8
|
import { createRepoBoundary } from "../repo-path.js";
|
|
9
|
+
import { listRepoFiles } from "../analysis/internal-audit/repo-files.js";
|
|
9
10
|
import { createRustResolvers } from "./resolvers/rust.js";
|
|
10
11
|
|
|
11
12
|
export function buildResolvers(repoDir, fileSet) {
|
|
@@ -19,11 +20,18 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
19
20
|
// go.mod requires also feed goSpecToPkg so external Go imports map to their declared module.
|
|
20
21
|
let goModule = "";
|
|
21
22
|
let goRequires = [];
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const goModules = [];
|
|
24
|
+
for (const manifest of listRepoFiles(repoDir).filter((file) => /(^|\/)go\.mod$/i.test(file))) {
|
|
25
|
+
try {
|
|
26
|
+
const gomod = parseGoMod(readLocal(manifest));
|
|
27
|
+
if (!gomod.module) continue;
|
|
28
|
+
const root = manifest.includes("/") ? manifest.slice(0, manifest.lastIndexOf("/")) : "";
|
|
29
|
+
goModules.push({ root, module: gomod.module, requires: gomod.requires.map((item) => item.path) });
|
|
30
|
+
} catch { /* unreadable module */ }
|
|
31
|
+
}
|
|
32
|
+
goModules.sort((left, right) => right.module.length - left.module.length);
|
|
33
|
+
goModule = goModules.find((item) => !item.root)?.module || goModules[0]?.module || "";
|
|
34
|
+
goRequires = [...new Set(goModules.flatMap((item) => item.requires))];
|
|
27
35
|
const dirFiles = new Map();
|
|
28
36
|
const filesByBase = new Map();
|
|
29
37
|
for (const fr of fileSet) {
|
|
@@ -32,8 +40,10 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
32
40
|
if (fr.endsWith(".go")) { const d = fr.includes("/") ? fr.slice(0, fr.lastIndexOf("/")) : ""; (dirFiles.get(d) || dirFiles.set(d, []).get(d)).push(fr); }
|
|
33
41
|
}
|
|
34
42
|
const resolveGoImport = (importPath) => {
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
for (const own of goModules) {
|
|
44
|
+
if (importPath !== own.module && !importPath.startsWith(own.module + "/")) continue;
|
|
45
|
+
const subpath = importPath === own.module ? "" : importPath.slice(own.module.length + 1);
|
|
46
|
+
const d = [own.root, subpath].filter(Boolean).join("/");
|
|
37
47
|
if (dirFiles.has(d)) return d;
|
|
38
48
|
}
|
|
39
49
|
// a module DECLARED in go.mod is external by definition — never let the suffix fallback hijack it
|
|
@@ -207,5 +217,5 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
207
217
|
return fileSet.has(cand) ? cand : null;
|
|
208
218
|
};
|
|
209
219
|
|
|
210
|
-
return { resolveJsImport, resolveAlias, resolvePyPath, pyBaseDir, pyTopDirs, resolveGoImport, dirFiles, resolveRustMod, resolveRustPath, resolveJavaImport, resolveHref, selectorIndex, htmlUsages, goModule, goRequires };
|
|
220
|
+
return { resolveJsImport, resolveAlias, resolvePyPath, pyBaseDir, pyTopDirs, resolveGoImport, dirFiles, resolveRustMod, resolveRustPath, resolveJavaImport, resolveHref, selectorIndex, htmlUsages, goModule, goModules, goRequires };
|
|
211
221
|
}
|
|
@@ -4,15 +4,14 @@ import {collectInstalled} from '../../security/installed.js'
|
|
|
4
4
|
export async function tRefreshAdvisories(g, args, ctx) {
|
|
5
5
|
if (!ctx.repoRoot) return 'No repo root — cannot collect installed packages.'
|
|
6
6
|
const {installed} = collectInstalled(ctx.repoRoot)
|
|
7
|
-
if (!installed.length) return 'No pinned packages found in lockfiles (npm/yarn/pip/poetry/uv/go) — nothing to query.'
|
|
7
|
+
if (!installed.length) return 'No pinned packages found in supported manifests/lockfiles (npm/yarn/pip/poetry/uv/go/Maven/Gradle/Cargo) — nothing to query.'
|
|
8
8
|
const res = await refreshAdvisories({installed, repoKey: ctx.repoRoot, timeoutMs: Number(args.timeout_ms) || undefined})
|
|
9
9
|
if (res.ok === false) return `Advisory refresh failed: ${res.error}`
|
|
10
10
|
const meta = storeMeta()
|
|
11
11
|
return [
|
|
12
12
|
`Advisory store ${res.status === 'PARTIAL' ? 'partially refreshed' : 'refreshed'} from OSV.dev: ${res.queriedOk ?? res.queried}/${res.queried} package versions queried successfully, ${res.vulnerable} with known advisories (${res.fetched} advisory records fetched).`,
|
|
13
|
-
res.unsupported ? `${res.unsupported} packages skipped (ecosystem not OSV-queryable — npm/PyPI/Go
|
|
13
|
+
res.unsupported ? `${res.unsupported} packages skipped (ecosystem not OSV-queryable — supported: npm/PyPI/Go/Maven/crates.io).` : null,
|
|
14
14
|
res.errors?.length ? `Partial: ${res.errors.length} request error(s), first: ${res.errors[0]}` : null,
|
|
15
15
|
`Store: ${DEFAULT_STORE} (${meta.advisoryCount} advisories, fetched ${meta.fetchedAt}). run_audit now reflects it — offline.`,
|
|
16
16
|
].filter(Boolean).join('\n')
|
|
17
17
|
}
|
|
18
|
-
|
package/src/mcp/catalog.mjs
CHANGED
|
@@ -39,6 +39,7 @@ const HOT_OWNERS = [
|
|
|
39
39
|
'actions/graph-lifecycle.mjs', 'actions/advisories.mjs',
|
|
40
40
|
'actions/hosted-architecture.mjs', 'actions/graph-sync.mjs',
|
|
41
41
|
'architecture-starter.mjs', 'architecture-bootstrap.mjs',
|
|
42
|
+
'company-contract-verdict.mjs',
|
|
42
43
|
]
|
|
43
44
|
export const HOT_FILES = [...HOT_FACADES, ...HOT_OWNERS, 'catalog.mjs']
|
|
44
45
|
|
|
@@ -50,8 +51,8 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
50
51
|
{cap: 'graph', name: 'query_graph', description: 'Explore a focused production-first graph around a concept or exact symbols (BFS/DFS). Exact seed files/symbols stay pinned; relation_filter and flow_direction support bounded event/data-flow views without a separate tool. Classified paths and unreferenced constant/field leaves stay suppressed unless explicitly requested.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Optional natural-language question or keyword search when exact seeds are not sufficient'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Exact repo-relative file paths. Resolved exact seeds remain pinned unless augment_seeds is true'}, seed_symbols: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Exact node IDs or unambiguous symbol labels; enables focused flows without fuzzy query seeds'}, relation_filter: {oneOf: [{type: 'array', items: {type: 'string'}}, {type: 'string'}], description: 'Optional relation allow-list, e.g. calls,references,imports'}, flow_direction: {type: 'string', enum: ['forward', 'backward', 'both'], default: 'both', description: 'Traverse outgoing, incoming, or both directions'}, augment_seeds: {type: 'boolean', default: false, description: 'With exact seeds, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, include_classified: {type: 'boolean', default: false, description: 'Allow traversal through tests/e2e/generated/mocks/stories/docs/benchmarks/temp and explicitly excluded paths. An explicit class term in the question enables only that class.'}, include_low_signal: {type: 'boolean', default: false, description: 'Include unreferenced constant/field leaf symbols that do not match a query term'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, anyOf: [{required: ['question']}, {required: ['seed_files']}, {required: ['seed_symbols']}]}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
|
|
51
52
|
{cap: 'graph', name: 'god_nodes', description: 'Rank production-code connectivity hubs by unique call/import/reference neighbors, with class/method ownership reported separately from runtime connectivity. Repeated call sites do not inflate the rank; classified tests, generated/build output and other non-product paths are excluded by default.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}, include_classified: {type: 'boolean', default: false, description: 'Include tests/e2e/generated/build output/mocks/stories/docs/benchmarks/temp and paths explicitly excluded by repository classification'}}}, run: (g, a, ctx) => tg.tGodNodes(g, a, ctx)},
|
|
52
53
|
{cap: 'graph', name: 'shortest_path', description: 'Find the shortest path between two concepts in the knowledge graph.', inputSchema: {type: 'object', properties: {source: {type: 'string'}, target: {type: 'string'}, max_hops: {type: 'integer', default: 8}}, required: ['source', 'target']}, run: (g, a) => tg.tShortestPath(g, a)},
|
|
53
|
-
{cap: 'graph', name: 'get_dependents', description: 'Transitive blast-radius of ONE node
|
|
54
|
-
{cap: 'graph', name: 'change_impact', description: 'Verdict-first, symbol-aware blast radius. Parses a zero-context git diff
|
|
54
|
+
{cap: 'graph', name: 'get_dependents', description: 'Transitive blast-radius of ONE node. JavaScript/TypeScript symbols use a cached on-demand EXACT_LSP point query by default, then traverse exact direct callers through the wider graph; incomplete precision is labelled and never silently presented as exact. Set precision=graph to skip LSP or include_container_importers for a conservative module-wide radius.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID'}, depth: {type: 'integer', description: 'Max reverse hops, default 3', default: 3}, max_nodes: {type: 'integer', description: 'Max dependents to list, default 40', default: 40}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto', description: 'auto uses exact JS/TS point queries when precision is enabled; graph skips LSP; lsp forces an attempt'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}, include_container_importers: {type: 'boolean', description: 'Also seed importers of the symbol containing file (broader, conservative; default false)', default: false}}, required: ['label']}, run: (g, a, ctx) => ti.tGetDependents(g, a, ctx)},
|
|
55
|
+
{cap: 'graph', name: 'change_impact', description: 'Verdict-first, symbol-aware blast radius. Parses a zero-context git diff and uses one bounded EXACT_LSP batch query for direct references to changed JavaScript/TypeScript symbols; transitive hops stay explicitly graph-backed. Additive exports do not inherit legacy file importers. Measured coverage is used when present; otherwise static reachability is labelled, not treated as coverage.', inputSchema: {type: 'object', properties: {base: {type: 'string', description: 'Base ref, e.g. origin/main or HEAD~1 (default: first existing of origin/HEAD, origin/main, origin/master, main, master)'}, diff: {type: 'string', maxLength: 2097152, description: 'Optional unified diff (prefer --unified=0) for a PR/change that is not checked out; enables symbol-level classification'}, files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional repo-relative changed-file hints. Without diff evidence these are classified conservatively rather than guessed additive'}, depth: {type: 'integer', description: 'Max reverse hops, default 2'}, max_nodes: {type: 'integer', description: 'Max impacted nodes to list, default 40'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 16384, default: 5000}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 45000}}}, run: (g, a, ctx) => ti.tChangeImpact(g, a, ctx)},
|
|
55
56
|
{cap: 'graph', name: 'git_history', description: 'Behavioral architecture evidence from bounded local git history: churn × connectivity hotspots, hidden co-change coupling, and expected test/source coupling. Reads numstat only — never commit messages, authors, or source bodies.', inputSchema: {type: 'object', properties: {months: {type: 'integer', enum: [3, 6, 12], default: 6}, max_commits: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, min_pair_count: {type: 'integer', minimum: 2, maximum: 100, default: 3}, max_pairs: {type: 'integer', minimum: 1, maximum: 500, default: 100}, top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10}}}, run: (g, a, ctx) => thi.tGitHistory(g, a, ctx)},
|
|
56
57
|
{
|
|
57
58
|
cap: 'graph', refreshGraph: true, name: 'verified_change',
|
|
@@ -75,12 +76,13 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
75
76
|
{
|
|
76
77
|
cap: 'crossrepo',
|
|
77
78
|
name: 'trace_api_contract',
|
|
78
|
-
description: 'Cross-repository HTTP contract, handler-liveness and blast-radius evidence.
|
|
79
|
+
description: 'Cross-repository HTTP, GraphQL, gRPC and event/topic contract, handler-liveness and blast-radius evidence. Joins static backend contracts to registered client repositories; dynamic URLs/topics, reflection and unresolved runtime configuration remain explicit UNKNOWN evidence. Medium/high-confidence external matches mark a handler/contract NOT_DEAD_EXTERNAL_USE. Repository paths stay local and cannot be supplied through this tool.',
|
|
79
80
|
inputSchema: {
|
|
80
81
|
type: 'object',
|
|
81
82
|
properties: {
|
|
82
83
|
backend: {type: 'string', description: 'Backend repository UUID or exact unambiguous registry label'},
|
|
83
84
|
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 static detector'},
|
|
84
86
|
method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']},
|
|
85
87
|
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'},
|
|
86
88
|
changed_files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional backend repo-relative changed files; only endpoints declared in those files are traced'},
|
|
@@ -104,6 +106,7 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
104
106
|
},
|
|
105
107
|
},
|
|
106
108
|
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
|
+
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.'},
|
|
107
110
|
include_tests: {type: 'boolean', default: false},
|
|
108
111
|
max_impact_depth: {type: 'integer', minimum: 0, maximum: 5, default: 2},
|
|
109
112
|
max_endpoints: {type: 'integer', minimum: 1, maximum: 500, default: 250},
|
|
@@ -138,7 +141,7 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
138
141
|
{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)},
|
|
139
142
|
{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)},
|
|
140
143
|
{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)},
|
|
141
|
-
{cap: 'advisories', name: 'refresh_advisories', description: "NETWORK / explicit advisories profile: refresh the local OSV advisory store for this repo's
|
|
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)},
|
|
142
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)},
|
|
143
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)},
|
|
144
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)},
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function contractVerdict(analysis, transportAnalysis) {
|
|
2
|
+
const endpointsWithCallers = analysis.endpoints.filter((endpoint) => endpoint.callsites.length > 0)
|
|
3
|
+
const transportWithCallers = (transportAnalysis.contracts || []).filter((contract) => contract.callsites.length > 0)
|
|
4
|
+
const affectedFiles = new Set(), affectedScreens = new Set()
|
|
5
|
+
for (const contract of [...endpointsWithCallers, ...transportWithCallers]) {
|
|
6
|
+
for (const item of contract.affected.files || []) affectedFiles.add(`${item.client}\0${item.file}`)
|
|
7
|
+
for (const item of contract.affected.screens || []) affectedScreens.add(`${item.client}\0${item.file}`)
|
|
8
|
+
}
|
|
9
|
+
const totalContracts = analysis.totals.endpoints + transportAnalysis.totals.contracts
|
|
10
|
+
const totalMatches = analysis.totals.matches + transportAnalysis.totals.matches
|
|
11
|
+
let code = 'NO_ENDPOINTS_MATCHED', risk = 'unknown'
|
|
12
|
+
if (totalContracts > 0 && totalMatches === 0) {
|
|
13
|
+
code = analysis.totals.methodMismatches > 0 ? 'HTTP_METHOD_MISMATCH' : 'NO_STATIC_CLIENT_CALLERS'
|
|
14
|
+
risk = analysis.totals.methodMismatches > 0 ? 'high' : 'unknown'
|
|
15
|
+
} else if (totalMatches > 0 && analysis.totals.methodMismatches > 0) {
|
|
16
|
+
code = 'CLIENTS_AT_RISK_WITH_METHOD_MISMATCHES'; risk = 'high'
|
|
17
|
+
} else if (totalMatches > 0) {
|
|
18
|
+
code = 'CLIENTS_AT_RISK'; risk = 'medium'
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
code, risk,
|
|
22
|
+
endpointsWithCallers: endpointsWithCallers.length + transportWithCallers.length,
|
|
23
|
+
callsites: totalMatches,
|
|
24
|
+
affectedFiles: affectedFiles.size,
|
|
25
|
+
affectedScreens: affectedScreens.size,
|
|
26
|
+
methodMismatches: analysis.totals.methodMismatches,
|
|
27
|
+
uncertainCalls: analysis.totals.uncertainCalls + transportAnalysis.totals.uncertain,
|
|
28
|
+
transportContracts: transportAnalysis.totals.contracts,
|
|
29
|
+
transportMatches: transportAnalysis.totals.matches,
|
|
30
|
+
notDeadExternalUse: analysis.totals.notDeadExternalUse,
|
|
31
|
+
notDeadExternalHandlers: analysis.totals.notDeadExternalHandlers,
|
|
32
|
+
possibleExternalUse: analysis.totals.possibleExternalUse,
|
|
33
|
+
unknownLiveness: analysis.totals.unknownLiveness,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function contractVerdictLine(verdict, contracts) {
|
|
38
|
+
if (verdict.code === 'NO_ENDPOINTS_MATCHED') return 'VERDICT NO_ENDPOINTS_MATCHED — no backend contract satisfied the requested transport/method/path/change filter.'
|
|
39
|
+
if (verdict.code === 'NO_STATIC_CLIENT_CALLERS') return `VERDICT NO_STATIC_CLIENT_CALLERS — ${contracts} backend contract(s) matched, but no bounded static client call was proven; this is unknown, not proof of no consumers.`
|
|
40
|
+
if (verdict.code === 'HTTP_METHOD_MISMATCH') return `VERDICT HTTP_METHOD_MISMATCH — ${verdict.methodMismatches} client call(s) match the route shape with a different method.`
|
|
41
|
+
return `VERDICT ${verdict.code} — ${verdict.callsites} callsite(s) reach ${verdict.endpointsWithCallers} contract(s); ${verdict.affectedScreens} screen(s) and ${verdict.affectedFiles} file(s) are in the bounded blast radius.`
|
|
42
|
+
}
|