weavatrix 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -63
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +108 -39
- package/src/analysis/architecture-contract.js +343 -0
- package/src/analysis/audit-debt.js +94 -0
- package/src/analysis/change-classification.js +509 -0
- package/src/analysis/cycle-route.js +14 -0
- package/src/analysis/dead-check.js +13 -6
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +163 -0
- package/src/analysis/dep-check.js +69 -147
- package/src/analysis/dep-rules.js +47 -31
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints-rust.js +124 -0
- package/src/analysis/endpoints.js +18 -5
- package/src/analysis/findings.js +8 -1
- package/src/analysis/git-history.js +549 -0
- package/src/analysis/git-ref-graph.js +74 -0
- package/src/analysis/graph-analysis.aggregate.js +29 -28
- package/src/analysis/graph-analysis.edges.js +24 -0
- package/src/analysis/graph-analysis.js +1 -1
- package/src/analysis/graph-analysis.summaries.js +4 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +43 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +66 -13
- package/src/analysis/java-source.js +36 -0
- package/src/analysis/package-reachability.js +39 -0
- package/src/analysis/static-test-reachability.js +133 -0
- package/src/build-graph.js +72 -13
- package/src/graph/build-worker.js +3 -4
- package/src/graph/builder/lang-java.js +177 -14
- package/src/graph/builder/lang-js.js +71 -12
- package/src/graph/builder/lang-rust.js +129 -6
- package/src/graph/community.js +27 -0
- package/src/graph/file-lock.js +69 -0
- package/src/graph/freshness-probe.js +141 -0
- package/src/graph/graph-filter.js +28 -11
- package/src/graph/incremental-refresh.js +232 -0
- package/src/graph/internal-builder.barrels.js +169 -0
- package/src/graph/internal-builder.build.js +111 -16
- package/src/graph/internal-builder.java.js +33 -0
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/internal-builder.resolvers.js +109 -2
- package/src/graph/layout.js +21 -5
- package/src/graph/relations.js +4 -0
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +64 -21
- package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
- package/src/mcp/evidence-snapshot.common.mjs +160 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
- package/src/mcp/evidence-snapshot.health.mjs +95 -0
- package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
- package/src/mcp/evidence-snapshot.mjs +50 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
- package/src/mcp/evidence-snapshot.structure.mjs +81 -0
- package/src/mcp/graph-context.mjs +106 -181
- package/src/mcp/graph-diff.mjs +217 -0
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +194 -8
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +114 -33
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph-hubs.mjs +58 -0
- package/src/mcp/tools-graph.mjs +36 -68
- package/src/mcp/tools-health.mjs +354 -20
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +35 -11
- package/src/mcp-server.mjs +100 -17
- package/src/mcp-source-tools.mjs +11 -3
- package/src/path-classification.js +201 -0
- package/src/path-ignore.js +69 -0
- package/src/security/typosquat.js +1 -1
package/src/mcp/sync-payload.mjs
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// Versioned, explicit wire schema for sync_graph. Never forward graph.json wholesale: it is a local
|
|
2
2
|
// cache file and may contain future fields or attacker-injected data that are not safe to upload.
|
|
3
|
+
import {sanitizeEvidenceSnapshot} from './sync-evidence.mjs';
|
|
3
4
|
|
|
4
5
|
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
6
|
+
const ABSOLUTE_PATH_FRAGMENT = /(?:^|[\/\s"'`(=])[a-z]:[\\/]|(?:^|[\s"'`(=])(?:\\\\[^\\/\s]+(?:[\\/]|$)|file:(?:\/\/)?[\\/]|\/(?!\/)[^\s])/i;
|
|
7
|
+
|
|
8
|
+
export const MAX_SYNC_BODY_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
export const MAX_SYNC_NODES = 25_000;
|
|
10
|
+
export const MAX_SYNC_LINKS = 100_000;
|
|
11
|
+
export const MAX_SYNC_EXTERNAL_IMPORTS = 50_000;
|
|
5
12
|
|
|
6
13
|
function metadataString(value, max = 4096) {
|
|
7
14
|
return typeof value === 'string' && value.length > 0 && value.length <= max && !CONTROL_CHARS.test(value)
|
|
@@ -9,6 +16,72 @@ function metadataString(value, max = 4096) {
|
|
|
9
16
|
: undefined;
|
|
10
17
|
}
|
|
11
18
|
|
|
19
|
+
// graph.json is derived data and may be edited independently of the repository. Never trust a path
|
|
20
|
+
// merely because it occupies an allowlisted field: sync only accepts canonical-looking repo-relative
|
|
21
|
+
// paths, on every host OS. Graph IDs append `#symbol@line`, so validate their file portion separately
|
|
22
|
+
// while preserving the complete ID on the wire.
|
|
23
|
+
function repoRelativePathString(value, max = 4096) {
|
|
24
|
+
const path = metadataString(value, max);
|
|
25
|
+
if (!path) return undefined;
|
|
26
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|[\\/])/i.test(path)) return undefined; // URI, drive path, POSIX or UNC absolute
|
|
27
|
+
const segments = path.split(/[\\/]/);
|
|
28
|
+
if (segments.some((segment) => segment === '.' || segment === '..')) return undefined;
|
|
29
|
+
return path;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function graphIdString(value) {
|
|
33
|
+
const id = metadataString(value);
|
|
34
|
+
if (!id) return undefined;
|
|
35
|
+
const hash = id.indexOf('#');
|
|
36
|
+
const file = hash < 0 ? id : id.slice(0, hash);
|
|
37
|
+
return repoRelativePathString(file) ? id : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Optional display metadata is still attacker-controlled graph data. Keep useful labels/import
|
|
41
|
+
// specifiers, but never let an absolute host path hide inside one of those free-text fields.
|
|
42
|
+
function privacySafeText(value, max = 4096) {
|
|
43
|
+
const text = metadataString(value, max);
|
|
44
|
+
if (!text) return undefined;
|
|
45
|
+
return ABSOLUTE_PATH_FRAGMENT.test(text) ? undefined : text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function repoPathV3(value, max = 4096) {
|
|
49
|
+
const path = repoRelativePathString(value, max);
|
|
50
|
+
return path ? path.replace(/\\/g, '/') : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function graphIdV3(value) {
|
|
54
|
+
const id = metadataString(value);
|
|
55
|
+
if (!id) return undefined;
|
|
56
|
+
const hash = id.indexOf('#');
|
|
57
|
+
const file = hash < 0 ? id : id.slice(0, hash);
|
|
58
|
+
const safeFile = repoPathV3(file);
|
|
59
|
+
if (!safeFile) return undefined;
|
|
60
|
+
if (hash < 0) return safeFile;
|
|
61
|
+
const suffix = id.slice(hash);
|
|
62
|
+
// Builder IDs are `#symbol@line` (optionally with a short collision suffix). A symbol suffix
|
|
63
|
+
// never needs a path separator or whitespace; rejecting both closes an otherwise opaque channel.
|
|
64
|
+
if (suffix.length > 512 || !/^#[^\\/\s\u0000-\u001f\u007f]{1,511}$/u.test(suffix)) return undefined;
|
|
65
|
+
return `${safeFile}${suffix}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function safeToken(value, max = 256) {
|
|
69
|
+
const token = metadataString(value, max);
|
|
70
|
+
if (!token || !/^[\p{L}\p{N}_.:@+\-#$<>()\[\],]+$/u.test(token)) return undefined;
|
|
71
|
+
return token;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function packageName(value) {
|
|
75
|
+
const name = metadataString(value, 256);
|
|
76
|
+
return name && /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/i.test(name) ? name : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function externalSpecifier(value) {
|
|
80
|
+
const spec = metadataString(value, 512);
|
|
81
|
+
if (!spec || ABSOLUTE_PATH_FRAGMENT.test(spec) || /^(?:[a-z]:[\\/]|[\\/]|\.\.?[\\/])/i.test(spec)) return undefined;
|
|
82
|
+
return /^[a-z0-9@][a-z0-9@._:/+\-]*$/i.test(spec) ? spec : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
12
85
|
function finiteNumber(value) {
|
|
13
86
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
14
87
|
}
|
|
@@ -37,12 +110,12 @@ function sanitizeComplexity(value) {
|
|
|
37
110
|
|
|
38
111
|
function sanitizeNode(value) {
|
|
39
112
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
40
|
-
const id =
|
|
113
|
+
const id = graphIdString(value.id);
|
|
41
114
|
if (!id) return null;
|
|
42
115
|
const out = {id};
|
|
43
116
|
setIf(out, 'label', metadataString(value.label, 1024));
|
|
44
117
|
setIf(out, 'file_type', metadataString(value.file_type, 32));
|
|
45
|
-
setIf(out, 'source_file',
|
|
118
|
+
setIf(out, 'source_file', repoRelativePathString(value.source_file));
|
|
46
119
|
const sourceLocation = metadataString(value.source_location, 32);
|
|
47
120
|
const sourceEnd = metadataString(value.source_end, 32);
|
|
48
121
|
if (sourceLocation && /^L\d+$/.test(sourceLocation)) out.source_location = sourceLocation;
|
|
@@ -57,13 +130,14 @@ function sanitizeNode(value) {
|
|
|
57
130
|
|
|
58
131
|
function sanitizeLink(value) {
|
|
59
132
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
60
|
-
const source =
|
|
61
|
-
const target =
|
|
133
|
+
const source = graphIdString(value.source);
|
|
134
|
+
const target = graphIdString(value.target);
|
|
62
135
|
if (!source || !target) return null;
|
|
63
136
|
const out = {source, target};
|
|
64
137
|
setIf(out, 'relation', metadataString(value.relation, 32));
|
|
65
138
|
setIf(out, 'confidence', metadataString(value.confidence, 32));
|
|
66
139
|
if (value.typeOnly === true) out.typeOnly = true;
|
|
140
|
+
if (value.compileOnly === true) out.compileOnly = true;
|
|
67
141
|
const line = finiteNumber(value.line);
|
|
68
142
|
if (line !== undefined && Number.isInteger(line) && line >= 0) out.line = line;
|
|
69
143
|
setIf(out, 'specifier', metadataString(value.specifier));
|
|
@@ -72,7 +146,7 @@ function sanitizeLink(value) {
|
|
|
72
146
|
|
|
73
147
|
function sanitizeExternalImport(value) {
|
|
74
148
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
75
|
-
const file =
|
|
149
|
+
const file = repoRelativePathString(value.file);
|
|
76
150
|
if (!file) return null;
|
|
77
151
|
const out = {file};
|
|
78
152
|
for (const key of ['spec', 'target']) setIf(out, key, metadataString(value[key]));
|
|
@@ -85,12 +159,90 @@ function sanitizeExternalImport(value) {
|
|
|
85
159
|
return out;
|
|
86
160
|
}
|
|
87
161
|
|
|
162
|
+
function sanitizeNodeV3(value) {
|
|
163
|
+
const out = sanitizeNode(value);
|
|
164
|
+
if (!out) return null;
|
|
165
|
+
const id = graphIdV3(value.id);
|
|
166
|
+
if (!id) return null;
|
|
167
|
+
const sourceFile = value.source_file == null ? undefined : repoPathV3(value.source_file);
|
|
168
|
+
if (value.source_file != null && !sourceFile) return null;
|
|
169
|
+
const hash = id.indexOf('#');
|
|
170
|
+
const idFile = hash < 0 ? id : id.slice(0, hash);
|
|
171
|
+
if (sourceFile && idFile !== sourceFile) return null;
|
|
172
|
+
out.id = id;
|
|
173
|
+
const label = privacySafeText(value.label, 1024);
|
|
174
|
+
if (label) out.label = label;
|
|
175
|
+
else delete out.label;
|
|
176
|
+
const fileType = safeToken(value.file_type, 32);
|
|
177
|
+
if (fileType) out.file_type = fileType;
|
|
178
|
+
else delete out.file_type;
|
|
179
|
+
if (sourceFile) out.source_file = sourceFile;
|
|
180
|
+
else delete out.source_file;
|
|
181
|
+
for (const key of ['symbol_kind', 'member_of', 'visibility']) setIf(out, key, safeToken(value[key], 128));
|
|
182
|
+
if (out.complexity) {
|
|
183
|
+
for (const key of ['family', 'scope', 'complexityScope', 'confidence']) {
|
|
184
|
+
const safe = safeToken(value.complexity?.[key], 32);
|
|
185
|
+
if (safe) out.complexity[key] = safe;
|
|
186
|
+
else delete out.complexity[key];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function sanitizeLinkV3(value) {
|
|
193
|
+
const out = sanitizeLink(value);
|
|
194
|
+
if (!out) return null;
|
|
195
|
+
const source = graphIdV3(value.source);
|
|
196
|
+
const target = graphIdV3(value.target);
|
|
197
|
+
if (!source || !target) return null;
|
|
198
|
+
out.source = source;
|
|
199
|
+
out.target = target;
|
|
200
|
+
for (const key of ['relation', 'confidence']) {
|
|
201
|
+
const safe = safeToken(value[key], 32);
|
|
202
|
+
if (safe) out[key] = safe;
|
|
203
|
+
else delete out[key];
|
|
204
|
+
}
|
|
205
|
+
const specifier = privacySafeText(value.specifier, 1024);
|
|
206
|
+
if (specifier) out.specifier = specifier;
|
|
207
|
+
else delete out.specifier;
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sanitizeExternalImportV3(value) {
|
|
212
|
+
const out = sanitizeExternalImport(value);
|
|
213
|
+
if (!out) return null;
|
|
214
|
+
const file = repoPathV3(value.file);
|
|
215
|
+
if (!file) return null;
|
|
216
|
+
out.file = file;
|
|
217
|
+
setIf(out, 'spec', externalSpecifier(value.spec));
|
|
218
|
+
if (!externalSpecifier(value.spec)) delete out.spec;
|
|
219
|
+
setIf(out, 'pkg', packageName(value.pkg));
|
|
220
|
+
if (!packageName(value.pkg)) delete out.pkg;
|
|
221
|
+
for (const key of ['kind', 'ecosystem']) {
|
|
222
|
+
const safe = safeToken(value[key], 64);
|
|
223
|
+
if (safe) out[key] = safe;
|
|
224
|
+
else delete out[key];
|
|
225
|
+
}
|
|
226
|
+
if (value.target != null) {
|
|
227
|
+
const target = repoPathV3(value.target);
|
|
228
|
+
if (target) out.target = target;
|
|
229
|
+
else delete out.target;
|
|
230
|
+
}
|
|
231
|
+
if (value.typeOnly === true) out.typeOnly = true;
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function assertArrayLimit(raw, key, limit) {
|
|
236
|
+
const count = Array.isArray(raw[key]) ? raw[key].length : 0;
|
|
237
|
+
if (count > limit) throw new Error(`${key} has ${count} entries; maximum is ${limit}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
88
240
|
export function createSyncPayload(raw) {
|
|
89
241
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || raw.repoBoundaryV !== 1) {
|
|
90
242
|
throw new Error('graph predates repository-boundary hardening');
|
|
91
243
|
}
|
|
92
|
-
if (!Number.isInteger(raw.edgeTypesV) || raw.edgeTypesV <
|
|
93
|
-
throw new Error('graph predates
|
|
244
|
+
if (!Number.isInteger(raw.edgeTypesV) || raw.edgeTypesV < 2) {
|
|
245
|
+
throw new Error('graph predates compile-only edge metadata');
|
|
94
246
|
}
|
|
95
247
|
const nodes = Array.isArray(raw.nodes) ? raw.nodes.map(sanitizeNode).filter(Boolean) : [];
|
|
96
248
|
const links = Array.isArray(raw.links) ? raw.links.map(sanitizeLink).filter(Boolean) : [];
|
|
@@ -100,7 +252,7 @@ export function createSyncPayload(raw) {
|
|
|
100
252
|
return {
|
|
101
253
|
syncPayloadV: 2,
|
|
102
254
|
repoBoundaryV: 1,
|
|
103
|
-
edgeTypesV:
|
|
255
|
+
edgeTypesV: 2,
|
|
104
256
|
extImportsV: Number.isInteger(raw.extImportsV) ? raw.extImportsV : 0,
|
|
105
257
|
complexityV: Number.isInteger(raw.complexityV) ? raw.complexityV : 0,
|
|
106
258
|
nodes,
|
|
@@ -108,3 +260,37 @@ export function createSyncPayload(raw) {
|
|
|
108
260
|
externalImports,
|
|
109
261
|
};
|
|
110
262
|
}
|
|
263
|
+
|
|
264
|
+
export function createSyncPayloadV3(raw, evidence) {
|
|
265
|
+
// Reuse the v2 schema gates, but construct v3 arrays independently so the stricter path/identity
|
|
266
|
+
// rules cannot change graph-only compatibility for existing endpoints.
|
|
267
|
+
const base = createSyncPayload(raw);
|
|
268
|
+
assertArrayLimit(raw, 'nodes', MAX_SYNC_NODES);
|
|
269
|
+
assertArrayLimit(raw, 'links', MAX_SYNC_LINKS);
|
|
270
|
+
assertArrayLimit(raw, 'externalImports', MAX_SYNC_EXTERNAL_IMPORTS);
|
|
271
|
+
const nodes = (raw.nodes || []).map(sanitizeNodeV3).filter(Boolean);
|
|
272
|
+
const nodeIds = new Set();
|
|
273
|
+
for (const node of nodes) {
|
|
274
|
+
if (nodeIds.has(node.id)) throw new Error(`duplicate node id in graph: ${node.id}`);
|
|
275
|
+
nodeIds.add(node.id);
|
|
276
|
+
}
|
|
277
|
+
const links = (raw.links || []).map(sanitizeLinkV3).filter(Boolean);
|
|
278
|
+
for (const link of links) {
|
|
279
|
+
if (!nodeIds.has(link.source) || !nodeIds.has(link.target)) {
|
|
280
|
+
throw new Error(`dangling link in graph: ${link.source} -> ${link.target}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const externalImports = (raw.externalImports || []).map(sanitizeExternalImportV3).filter(Boolean);
|
|
284
|
+
return {
|
|
285
|
+
syncPayloadV: 3,
|
|
286
|
+
repoBoundaryV: base.repoBoundaryV,
|
|
287
|
+
edgeTypesV: base.edgeTypesV,
|
|
288
|
+
extImportsV: base.extImportsV,
|
|
289
|
+
complexityV: base.complexityV,
|
|
290
|
+
evidenceV: 1,
|
|
291
|
+
nodes,
|
|
292
|
+
links,
|
|
293
|
+
externalImports,
|
|
294
|
+
evidence: sanitizeEvidenceSnapshot(evidence),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// MCP tool results keep a concise human summary and a stable machine-readable envelope together.
|
|
2
|
+
// Older clients continue to consume TextContent; MCP 2025-06 clients can use structuredContent
|
|
3
|
+
// directly without scraping prose. Tool implementations may opt into richer `result` data through
|
|
4
|
+
// toolResult(), while legacy string-returning tools still receive a deterministic envelope.
|
|
5
|
+
|
|
6
|
+
export const TOOL_RESULT_SCHEMA = 'weavatrix.tool.v1'
|
|
7
|
+
|
|
8
|
+
export function toolResult(text, result, extra = {}) {
|
|
9
|
+
return {
|
|
10
|
+
__weavatrixToolResult: true,
|
|
11
|
+
text: String(text ?? ''),
|
|
12
|
+
result: result && typeof result === 'object' ? result : {},
|
|
13
|
+
warnings: Array.isArray(extra.warnings) ? extra.warnings : [],
|
|
14
|
+
page: extra.page && typeof extra.page === 'object' ? extra.page : undefined,
|
|
15
|
+
completeness: extra.completeness && typeof extra.completeness === 'object' ? extra.completeness : undefined,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function repoName(repoRoot) {
|
|
20
|
+
const parts = String(repoRoot || '').replace(/[\\/]+$/, '').split(/[\\/]/)
|
|
21
|
+
const name = parts.pop() || 'unknown'
|
|
22
|
+
return name.normalize('NFKC').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '').slice(0, 128) || 'unknown'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function normalizeToolResult({toolName, value, args, ctx, refresh, warnings = [], freshness = 'unknown'}) {
|
|
26
|
+
const rich = value && typeof value === 'object' && value.__weavatrixToolResult === true
|
|
27
|
+
const text = rich ? value.text : String(value ?? '')
|
|
28
|
+
const resultWarnings = [...(rich ? value.warnings : []), ...warnings]
|
|
29
|
+
if (refresh?.notice) resultWarnings.unshift({code: 'GRAPH_AUTO_REFRESHED', message: refresh.notice})
|
|
30
|
+
if (refresh?.error) resultWarnings.unshift({code: 'GRAPH_AUTO_REFRESH_FAILED', message: refresh.error})
|
|
31
|
+
const structured = {
|
|
32
|
+
schemaVersion: TOOL_RESULT_SCHEMA,
|
|
33
|
+
tool: String(toolName || ''),
|
|
34
|
+
repo: {name: repoName(ctx?.repoRoot)},
|
|
35
|
+
graph: {
|
|
36
|
+
revision: refresh?.revision || null,
|
|
37
|
+
freshness: refresh?.error ? 'stale' : (refresh?.kind ? 'fresh' : freshness),
|
|
38
|
+
update: refresh?.kind || 'none',
|
|
39
|
+
changedFiles: Number(refresh?.changedFiles) || 0,
|
|
40
|
+
},
|
|
41
|
+
result: rich ? value.result : {text},
|
|
42
|
+
evidence: [],
|
|
43
|
+
warnings: resultWarnings,
|
|
44
|
+
page: rich ? (value.page || {}) : {},
|
|
45
|
+
...(rich && value.completeness ? {completeness: value.completeness} : {}),
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
text: args?.output_format === 'json' ? JSON.stringify(structured, null, 2) : `Repository: ${structured.repo.name}\n${text}`,
|
|
49
|
+
structured,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -1,29 +1,56 @@
|
|
|
1
1
|
// Action tools: rebuild the graph, the explicit 'retarget' group, and the explicit 'online' group
|
|
2
2
|
// (the ONLY tools that ever touch the network).
|
|
3
3
|
// Hot-reloadable (re-imported by catalog.mjs on change).
|
|
4
|
-
import {readFileSync, writeFileSync, existsSync,
|
|
5
|
-
import {
|
|
6
|
-
import {prevGraphPathFor, diffGraphs, formatGraphDiff} from './graph-context.mjs'
|
|
4
|
+
import {readFileSync, writeFileSync, existsSync, statSync, realpathSync} from 'node:fs'
|
|
5
|
+
import {dirname, join, isAbsolute} from 'node:path'
|
|
6
|
+
import {prevGraphPathFor, diffGraphs, formatGraphDiff, graphStaleness} from './graph-context.mjs'
|
|
7
7
|
import {buildGraphForRepo} from '../build-graph.js'
|
|
8
|
-
import {graphOutDirForRepo} from '../graph/layout.js'
|
|
8
|
+
import {graphHomeDir, graphOutDirForModule, graphOutDirForRepo} from '../graph/layout.js'
|
|
9
|
+
import {liveRepositoryRecords, registerRepository, repositoryRecord} from '../graph/repo-registry.js'
|
|
9
10
|
import {refreshAdvisories, storeMeta, DEFAULT_STORE} from '../security/advisory-store.js'
|
|
10
11
|
import {collectInstalled} from '../security/installed.js'
|
|
11
|
-
import {createSyncPayload} from './sync-payload.mjs'
|
|
12
|
+
import {createSyncPayload, createSyncPayloadV3, MAX_SYNC_BODY_BYTES} from './sync-payload.mjs'
|
|
13
|
+
import {createEvidenceSnapshot} from './evidence-snapshot.mjs'
|
|
14
|
+
import {writeCachedArchitectureContract} from '../analysis/architecture-contract.js'
|
|
15
|
+
|
|
16
|
+
const MAX_SYNC_GRAPH_FILE_BYTES = 64 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
function syncRepoLabel(repoRoot) {
|
|
19
|
+
const basename = String(repoRoot || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'repo'
|
|
20
|
+
const safe = basename.normalize('NFKC').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '')
|
|
21
|
+
return (safe || 'repo').slice(0, 128)
|
|
22
|
+
}
|
|
12
23
|
|
|
13
24
|
export async function tRebuildGraph(g, args, ctx) {
|
|
14
25
|
if (!ctx.repoRoot) return 'Rebuild needs the repo root (not provided to this server).'
|
|
15
26
|
const mode = ['no-tests', 'tests-only', 'full'].includes(args.mode) ? args.mode : 'full'
|
|
27
|
+
const scope = String(args.scope || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
|
|
28
|
+
if (scope) {
|
|
29
|
+
const scopedDir = graphOutDirForModule(ctx.repoRoot, scope)
|
|
30
|
+
const scoped = await buildGraphForRepo(ctx.repoRoot, {mode, scope, outDir: scopedDir})
|
|
31
|
+
if (!scoped || !scoped.ok) return `Scoped graph build failed: ${(scoped && scoped.error) || 'unknown error'}`
|
|
32
|
+
return [
|
|
33
|
+
`Built an isolated scoped graph for ${scope} (${mode}). ${scoped.log || ''}.`,
|
|
34
|
+
`The active full-repository graph was not replaced, so this operation cannot report a false full→scope structural delta.`,
|
|
35
|
+
`Scoped graph: ${join(scopedDir, 'graph.json')}`,
|
|
36
|
+
].join('\n')
|
|
37
|
+
}
|
|
16
38
|
// snapshot the outgoing state: bytes → graph.prev.json (for graph_diff later), struct → inline delta
|
|
17
39
|
let prevBytes = null
|
|
18
40
|
try { prevBytes = readFileSync(ctx.graphPath) } catch { /* first build — nothing to diff against */ }
|
|
19
|
-
const before = g?.nodes ? {
|
|
20
|
-
|
|
41
|
+
const before = g?.nodes ? {
|
|
42
|
+
nodes: g.nodes, links: g.links,
|
|
43
|
+
edgeTypesV: g.edgeTypesV || 0,
|
|
44
|
+
barrelResolutionV: g.barrelResolutionV || 0,
|
|
45
|
+
extractorSchemaV: g.extractorSchemaV || 0,
|
|
46
|
+
} : null
|
|
47
|
+
const res = await buildGraphForRepo(ctx.repoRoot, {mode, scope: '', outDir: ctx.graphPath ? dirname(ctx.graphPath) : undefined})
|
|
21
48
|
if (!res || !res.ok) return `Graph rebuild failed: ${(res && res.error) || 'unknown error'}`
|
|
22
49
|
if (prevBytes) { try { writeFileSync(prevGraphPathFor(ctx.graphPath), prevBytes) } catch { /* snapshot is best-effort */ } }
|
|
23
50
|
const fresh = ctx.reload() // refresh THIS server's in-memory graph so subsequent tool calls see the new graph
|
|
24
51
|
const delta = before && fresh ? formatGraphDiff(diffGraphs(before, fresh)) : null
|
|
25
52
|
return [
|
|
26
|
-
`Rebuilt the graph (${mode}
|
|
53
|
+
`Rebuilt the graph (${mode}). ${res.log || ''}. In-memory graph reloaded — graph tools now reflect it.`,
|
|
27
54
|
delta
|
|
28
55
|
].filter(Boolean).join('\n\n')
|
|
29
56
|
}
|
|
@@ -45,7 +72,7 @@ export async function tOpenRepo(g, args, ctx) {
|
|
|
45
72
|
if (existsSync(graphPath)) {
|
|
46
73
|
try {
|
|
47
74
|
const saved = JSON.parse(readFileSync(graphPath, 'utf8'))
|
|
48
|
-
upgrade = !Number.isInteger(saved.edgeTypesV) || saved.edgeTypesV <
|
|
75
|
+
upgrade = !Number.isInteger(saved.edgeTypesV) || saved.edgeTypesV < 2
|
|
49
76
|
} catch {
|
|
50
77
|
upgrade = true
|
|
51
78
|
}
|
|
@@ -53,7 +80,7 @@ export async function tOpenRepo(g, args, ctx) {
|
|
|
53
80
|
if (!existsSync(graphPath) || upgrade) {
|
|
54
81
|
if (args.build === false) {
|
|
55
82
|
return upgrade
|
|
56
|
-
? `The existing graph for ${repoPath} predates
|
|
83
|
+
? `The existing graph for ${repoPath} predates compile-only edge metadata (edge schema v2). Re-call without build:false to upgrade it before switching.`
|
|
57
84
|
: `No graph yet for ${repoPath} (expected at ${graphPath}). Re-call without build:false to build one — large repos can take minutes.`
|
|
58
85
|
}
|
|
59
86
|
const mode = ['no-tests', 'tests-only', 'full'].includes(args.mode) ? args.mode : 'full'
|
|
@@ -71,31 +98,23 @@ export async function tOpenRepo(g, args, ctx) {
|
|
|
71
98
|
ctx.reload()
|
|
72
99
|
return `Failed to load ${graphPath} — still targeting the previous repo (${prev.repoRoot || 'none'}).`
|
|
73
100
|
}
|
|
74
|
-
|
|
101
|
+
registerRepository({repoPath, graphDir: graphOutDirForRepo(repoPath), graphHome: graphHomeDir()})
|
|
102
|
+
const buildNote = built ? (upgrade ? ' (graph upgraded to edge metadata v2)' : ' (graph built fresh)') : ''
|
|
75
103
|
return `Opened ${repoPath}${buildNote}: ${loaded.nodes.length} nodes / ${loaded.links.length} edges. All tools now target this repo.`
|
|
76
104
|
}
|
|
77
105
|
|
|
78
106
|
// Sibling repos that already have a built graph in the central weavatrix-graphs folder — open_repo candidates.
|
|
79
107
|
export function tListKnownRepos(g, args, ctx) {
|
|
80
|
-
|
|
81
|
-
const parent = dirname(ctx.repoRoot)
|
|
82
|
-
const root = join(parent, 'weavatrix-graphs')
|
|
108
|
+
const root = graphHomeDir()
|
|
83
109
|
const norm = (p) => String(p).replace(/[\\/]+/g, '/').toLowerCase()
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const graphPath = join(root, entry.name, 'graph.json')
|
|
90
|
-
try {
|
|
91
|
-
const st = statSync(graphPath)
|
|
92
|
-
rows.push({name: entry.name, repoPath: join(parent, entry.name), builtAt: st.mtime.toISOString()})
|
|
93
|
-
} catch { /* no graph built for this entry */ }
|
|
94
|
-
}
|
|
95
|
-
if (!rows.length) return `No built graphs under ${root}.`
|
|
110
|
+
const rows = liveRepositoryRecords(root).map((record) => ({
|
|
111
|
+
...record,
|
|
112
|
+
builtAt: statSync(join(record.graphDir, 'graph.json')).mtime.toISOString(),
|
|
113
|
+
}))
|
|
114
|
+
if (!rows.length) return `No registered graphs under ${root}. Build a repository once with open_repo or rebuild_graph.`
|
|
96
115
|
return [
|
|
97
|
-
`
|
|
98
|
-
...rows.map((r) => ` ${norm(r.repoPath) === norm(ctx.repoRoot) ? '»' : ' '} ${r.
|
|
116
|
+
`Known repositories (${rows.length}) in ${root}:`,
|
|
117
|
+
...rows.map((r) => ` ${norm(r.repoPath) === norm(ctx.repoRoot) ? '»' : ' '} ${r.label} [${r.repositoryId}] — graph built ${r.builtAt} (${r.repoPath})`),
|
|
99
118
|
].join('\n')
|
|
100
119
|
}
|
|
101
120
|
|
|
@@ -120,6 +139,32 @@ export async function tRefreshAdvisories(g, args, ctx) {
|
|
|
120
139
|
].filter(Boolean).join('\n')
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
export async function tPullArchitectureContract(g, args, ctx) {
|
|
143
|
+
if (!ctx.repoRoot || !ctx.graphPath) return 'No active repository graph — open_repo first.'
|
|
144
|
+
const syncUrl = process.env.WEAVATRIX_SYNC_URL
|
|
145
|
+
const token = process.env.WEAVATRIX_SYNC_TOKEN
|
|
146
|
+
if (!syncUrl || !token) return 'Hosted architecture pull is not configured. Use the hosted profile with WEAVATRIX_SYNC_URL and WEAVATRIX_SYNC_TOKEN, or keep .weavatrix/architecture.json locally.'
|
|
147
|
+
let url
|
|
148
|
+
try { url = process.env.WEAVATRIX_ARCHITECTURE_URL || new URL('/api/v1/architecture-contract', syncUrl).toString() }
|
|
149
|
+
catch { return 'WEAVATRIX_SYNC_URL is invalid.' }
|
|
150
|
+
const registry = repositoryRecord(ctx.repoRoot, graphHomeDir())
|
|
151
|
+
|| registerRepository({repoPath: ctx.repoRoot, graphDir: graphOutDirForRepo(ctx.repoRoot), graphHome: graphHomeDir()})
|
|
152
|
+
const timeoutMs = Math.min(120000, Math.max(1000, Number(args.timeout_ms) || 30000))
|
|
153
|
+
try {
|
|
154
|
+
const res = await fetch(url, {
|
|
155
|
+
headers: {authorization: `Bearer ${token}`, 'x-weavatrix-repository-id': registry.repositoryId},
|
|
156
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
157
|
+
})
|
|
158
|
+
const body = await res.json().catch(() => null)
|
|
159
|
+
if (!res.ok) return `Hosted architecture endpoint answered HTTP ${res.status}; the local contract cache was not changed.`
|
|
160
|
+
if (body?.state === 'NOT_CONFIGURED' || !body?.contract) return 'Hosted target architecture is NOT_CONFIGURED. Define and save it in the Architecture editor first.'
|
|
161
|
+
const stored = writeCachedArchitectureContract(ctx.graphPath, body.contract)
|
|
162
|
+
return `Pulled target architecture ${stored.contract.name} (${stored.contract.style}, ${stored.contract.enforcement}) into the local graph cache. get_architecture_contract and verify_architecture now use it.`
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return `Hosted architecture pull failed: ${error.message}; the previous local contract, if any, remains active.`
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
123
168
|
// Push the current graph.json to a user-configured endpoint. Off until WEAVATRIX_SYNC_URL is set.
|
|
124
169
|
// The payload is graph metadata (paths, symbols/ranges, imports, edges, metrics), never file contents.
|
|
125
170
|
export async function tSyncGraph(g, args, ctx) {
|
|
@@ -130,13 +175,38 @@ export async function tSyncGraph(g, args, ctx) {
|
|
|
130
175
|
}
|
|
131
176
|
if (!g) return 'No graph loaded — build one first (open_repo / rebuild_graph).'
|
|
132
177
|
let raw
|
|
133
|
-
try {
|
|
178
|
+
try {
|
|
179
|
+
const size = statSync(ctx.graphPath).size
|
|
180
|
+
if (size > MAX_SYNC_GRAPH_FILE_BYTES) {
|
|
181
|
+
return `Cannot sync: graph.json is ${Math.ceil(size / 1024 / 1024)} MB; the local safety limit is ${MAX_SYNC_GRAPH_FILE_BYTES / 1024 / 1024} MB.`
|
|
182
|
+
}
|
|
183
|
+
raw = JSON.parse(readFileSync(ctx.graphPath, 'utf8'))
|
|
184
|
+
} catch (e) { return `Cannot read ${ctx.graphPath}: ${e.message}` }
|
|
185
|
+
const requestedVersion = Number(args.payload_version) === 2 ? 2 : 3
|
|
134
186
|
let payload
|
|
135
|
-
try {
|
|
187
|
+
try {
|
|
188
|
+
if (requestedVersion === 2) {
|
|
189
|
+
payload = createSyncPayload(raw)
|
|
190
|
+
} else {
|
|
191
|
+
if (!ctx.repoRoot) return 'Cannot build evidence: no repository root is active.'
|
|
192
|
+
if (graphStaleness(ctx).stale) {
|
|
193
|
+
return 'Cannot sync evidence from a stale graph. Run rebuild_graph, then call sync_graph again.'
|
|
194
|
+
}
|
|
195
|
+
const evidence = await createEvidenceSnapshot({repoRoot: ctx.repoRoot, graph: raw})
|
|
196
|
+
payload = createSyncPayloadV3(raw, evidence)
|
|
197
|
+
}
|
|
198
|
+
} catch (e) {
|
|
136
199
|
return `Cannot sync: ${e.message}. Run rebuild_graph once before sync_graph.`
|
|
137
200
|
}
|
|
138
201
|
const body = JSON.stringify(payload)
|
|
139
|
-
const
|
|
202
|
+
const bodyBytes = Buffer.byteLength(body)
|
|
203
|
+
if (bodyBytes > MAX_SYNC_BODY_BYTES) {
|
|
204
|
+
return `Cannot sync: payload is ${Math.ceil(bodyBytes / 1024)} KB; the hosted safety limit is ${MAX_SYNC_BODY_BYTES / 1024} KB. Narrow the graph scope and rebuild before retrying.`
|
|
205
|
+
}
|
|
206
|
+
const repoName = syncRepoLabel(ctx.repoRoot)
|
|
207
|
+
const registry = repositoryRecord(ctx.repoRoot, graphHomeDir())
|
|
208
|
+
|| registerRepository({repoPath: ctx.repoRoot, graphDir: graphOutDirForRepo(ctx.repoRoot), graphHome: graphHomeDir()})
|
|
209
|
+
const timeoutMs = Math.min(120000, Math.max(1000, Number(args.timeout_ms) || 30000))
|
|
140
210
|
try {
|
|
141
211
|
const res = await fetch(url, {
|
|
142
212
|
method: 'POST',
|
|
@@ -144,12 +214,23 @@ export async function tSyncGraph(g, args, ctx) {
|
|
|
144
214
|
'content-type': 'application/json',
|
|
145
215
|
'x-weavatrix-payload-version': String(payload.syncPayloadV),
|
|
146
216
|
'x-weavatrix-repo': repoName,
|
|
217
|
+
'x-weavatrix-repository-id': registry.repositoryId,
|
|
147
218
|
...(process.env.WEAVATRIX_SYNC_TOKEN ? {authorization: `Bearer ${process.env.WEAVATRIX_SYNC_TOKEN}`} : {}),
|
|
148
219
|
},
|
|
149
220
|
body,
|
|
221
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
150
222
|
})
|
|
151
|
-
if (!res.ok)
|
|
152
|
-
|
|
223
|
+
if (!res.ok) {
|
|
224
|
+
const accepted = res.headers?.get?.('x-weavatrix-accept-payload-versions')
|
|
225
|
+
const compatibility = (res.status === 415 || res.status === 422) && accepted
|
|
226
|
+
? ` Endpoint accepts payload version(s) ${accepted}; retry with payload_version:2 only if you intentionally want graph-only sync.`
|
|
227
|
+
: ''
|
|
228
|
+
return `Sync endpoint answered HTTP ${res.status} — graph NOT accepted.${compatibility}`
|
|
229
|
+
}
|
|
230
|
+
const evidenceNote = payload.syncPayloadV === 3
|
|
231
|
+
? ` + evidence ${payload.evidence?.snapshotHash?.slice(0, 12) || 'unknown'}`
|
|
232
|
+
: ''
|
|
233
|
+
return `Graph for ${repoName} (${payload.nodes.length} nodes / ${payload.links.length} edges${evidenceNote}, ${Math.round(bodyBytes / 1024)} KB) pushed to ${url}.`
|
|
153
234
|
} catch (e) {
|
|
154
235
|
return `Sync failed: ${e.message} — the graph stays local.`
|
|
155
236
|
}
|