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.
Files changed (78) hide show
  1. package/README.md +168 -63
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +108 -39
  5. package/src/analysis/architecture-contract.js +343 -0
  6. package/src/analysis/audit-debt.js +94 -0
  7. package/src/analysis/change-classification.js +509 -0
  8. package/src/analysis/cycle-route.js +14 -0
  9. package/src/analysis/dead-check.js +13 -6
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +163 -0
  12. package/src/analysis/dep-check.js +69 -147
  13. package/src/analysis/dep-rules.js +47 -31
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints-rust.js +124 -0
  17. package/src/analysis/endpoints.js +18 -5
  18. package/src/analysis/findings.js +8 -1
  19. package/src/analysis/git-history.js +549 -0
  20. package/src/analysis/git-ref-graph.js +74 -0
  21. package/src/analysis/graph-analysis.aggregate.js +29 -28
  22. package/src/analysis/graph-analysis.edges.js +24 -0
  23. package/src/analysis/graph-analysis.js +1 -1
  24. package/src/analysis/graph-analysis.summaries.js +4 -1
  25. package/src/analysis/http-contracts.js +581 -0
  26. package/src/analysis/internal-audit.collect.js +43 -2
  27. package/src/analysis/internal-audit.reach.js +52 -1
  28. package/src/analysis/internal-audit.run.js +66 -13
  29. package/src/analysis/java-source.js +36 -0
  30. package/src/analysis/package-reachability.js +39 -0
  31. package/src/analysis/static-test-reachability.js +133 -0
  32. package/src/build-graph.js +72 -13
  33. package/src/graph/build-worker.js +3 -4
  34. package/src/graph/builder/lang-java.js +177 -14
  35. package/src/graph/builder/lang-js.js +71 -12
  36. package/src/graph/builder/lang-rust.js +129 -6
  37. package/src/graph/community.js +27 -0
  38. package/src/graph/file-lock.js +69 -0
  39. package/src/graph/freshness-probe.js +141 -0
  40. package/src/graph/graph-filter.js +28 -11
  41. package/src/graph/incremental-refresh.js +232 -0
  42. package/src/graph/internal-builder.barrels.js +169 -0
  43. package/src/graph/internal-builder.build.js +111 -16
  44. package/src/graph/internal-builder.java.js +33 -0
  45. package/src/graph/internal-builder.langs.js +2 -1
  46. package/src/graph/internal-builder.resolvers.js +109 -2
  47. package/src/graph/layout.js +21 -5
  48. package/src/graph/relations.js +4 -0
  49. package/src/graph/repo-registry.js +124 -0
  50. package/src/mcp/catalog.mjs +64 -21
  51. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  52. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  53. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  54. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  55. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  56. package/src/mcp/evidence-snapshot.mjs +50 -0
  57. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  58. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  59. package/src/mcp/graph-context.mjs +106 -181
  60. package/src/mcp/graph-diff.mjs +217 -0
  61. package/src/mcp/staleness-notice.mjs +20 -0
  62. package/src/mcp/sync-evidence.mjs +418 -0
  63. package/src/mcp/sync-payload.mjs +194 -8
  64. package/src/mcp/tool-result.mjs +51 -0
  65. package/src/mcp/tools-actions.mjs +114 -33
  66. package/src/mcp/tools-architecture.mjs +144 -0
  67. package/src/mcp/tools-company.mjs +273 -0
  68. package/src/mcp/tools-graph-hubs.mjs +58 -0
  69. package/src/mcp/tools-graph.mjs +36 -68
  70. package/src/mcp/tools-health.mjs +354 -20
  71. package/src/mcp/tools-history.mjs +22 -0
  72. package/src/mcp/tools-impact-change.mjs +261 -0
  73. package/src/mcp/tools-impact.mjs +35 -11
  74. package/src/mcp-server.mjs +100 -17
  75. package/src/mcp-source-tools.mjs +11 -3
  76. package/src/path-classification.js +201 -0
  77. package/src/path-ignore.js +69 -0
  78. package/src/security/typosquat.js +1 -1
@@ -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 = metadataString(value.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', metadataString(value.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 = metadataString(value.source);
61
- const target = metadataString(value.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 = metadataString(value.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 < 1) {
93
- throw new Error('graph predates typed import edges');
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: 1,
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, readdirSync, statSync, realpathSync} from 'node:fs'
5
- import {join, dirname, isAbsolute} from 'node:path'
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 ? {nodes: g.nodes, links: g.links, edgeTypesV: g.edgeTypesV || 0} : null
20
- const res = await buildGraphForRepo(ctx.repoRoot, {mode, scope: args.scope || ''})
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}${args.scope ? `, scope=${args.scope}` : ''}). ${res.log || ''}. In-memory graph reloaded — graph tools now reflect it.`,
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 < 1
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 typed import edges. Re-call without build:false to upgrade it before switching.`
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
- const buildNote = built ? (upgrade ? ' (graph upgraded to typed import edges)' : ' (graph built fresh)') : ''
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
- if (!ctx.repoRoot) return 'No repo root cannot locate the central graphs folder.'
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
- let entries = []
85
- try { entries = readdirSync(root, {withFileTypes: true}) } catch { return `No central graphs folder at ${root}.` }
86
- const rows = []
87
- for (const entry of entries) {
88
- if (!entry.isDirectory()) continue
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
- `Repos with built graphs under ${root} (switch with open_repo):`,
98
- ...rows.map((r) => ` ${norm(r.repoPath) === norm(ctx.repoRoot) ? '»' : ' '} ${r.name} — graph built ${r.builtAt} (${r.repoPath})`),
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 { raw = JSON.parse(readFileSync(ctx.graphPath, 'utf8')) } catch (e) { return `Cannot read ${ctx.graphPath}: ${e.message}` }
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 { payload = createSyncPayload(raw) } catch (e) {
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 repoName = String(ctx.repoRoot || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'repo'
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) return `Sync endpoint answered HTTP ${res.status} — graph NOT accepted.`
152
- return `Graph for ${repoName} (${payload.nodes.length} nodes / ${payload.links.length} edges, ${Math.round(Buffer.byteLength(body) / 1024)} KB) pushed to ${url}.`
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
  }