weavatrix 0.2.8 → 0.2.9

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.
@@ -3,7 +3,7 @@ import {readFileSync, statSync} from 'node:fs'
3
3
  import {createRepoBoundary} from '../repo-path.js'
4
4
  import {CAPS, STATE, compareText, safeToken} from './evidence-snapshot.common.mjs'
5
5
 
6
- const LOCKFILES = ['npm-shrinkwrap.json', 'package-lock.json']
6
+ const LOCKFILES = ['npm-shrinkwrap.json', 'package-lock.json', 'bun.lock']
7
7
  const MAX_LOCKFILE_BYTES = 64 * 1024 * 1024
8
8
  const MAX_PACKAGE_RECORDS = 50_000
9
9
  const MAX_DEPENDENCY_DECLARATIONS = 200_000
@@ -191,6 +191,10 @@ function parseLock(lock, lockfile) {
191
191
  if (declarationLimitReached) reasons.push('LOCKFILE_DEPENDENCY_DECLARATION_LIMIT_REACHED')
192
192
  if (declarations.unresolved > 0) reasons.push('UNRESOLVED_LOCKFILE_DEPENDENCIES')
193
193
 
194
+ return finalizeGraph({externalNodes, edgeMap, declarations, reasons, lockfile, lockfileVersion})
195
+ }
196
+
197
+ function finalizeGraph({externalNodes, edgeMap, declarations, reasons, lockfile, lockfileVersion}) {
194
198
  const allNodes = [...externalNodes.values()].sort((a, b) =>
195
199
  Number(b.direct) - Number(a.direct) || compareText(a.name, b.name) ||
196
200
  compareText(a.version, b.version) || compareText(a.id, b.id))
@@ -223,6 +227,255 @@ function parseLock(lock, lockfile) {
223
227
  }
224
228
  }
225
229
 
230
+ // Bun >= 1.2 writes a text lockfile (bun.lock) as JSONC: JSON plus comments and
231
+ // trailing commas. parseJsonc strips both (outside of strings) with no new deps.
232
+ function parseJsonc(text) {
233
+ const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
234
+ const out = []
235
+ let i = 0
236
+ const length = source.length
237
+ const skipComment = (index) => {
238
+ if (source[index] !== '/') return index
239
+ if (source[index + 1] === '/') {
240
+ let cursor = index + 2
241
+ while (cursor < length && source[cursor] !== '\n') cursor++
242
+ return cursor
243
+ }
244
+ if (source[index + 1] === '*') {
245
+ let cursor = index + 2
246
+ while (cursor < length && !(source[cursor] === '*' && source[cursor + 1] === '/')) cursor++
247
+ return Math.min(cursor + 2, length)
248
+ }
249
+ return index
250
+ }
251
+ while (i < length) {
252
+ const char = source[i]
253
+ if (char === '"') {
254
+ const start = i
255
+ i++
256
+ while (i < length && source[i] !== '"') i += source[i] === '\\' ? 2 : 1
257
+ i = Math.min(i + 1, length)
258
+ out.push(source.slice(start, i))
259
+ continue
260
+ }
261
+ if (char === '/') {
262
+ const next = skipComment(i)
263
+ if (next !== i) { i = next; continue }
264
+ }
265
+ if (char === ',') {
266
+ let ahead = i + 1
267
+ while (ahead < length) {
268
+ if (/\s/.test(source[ahead])) { ahead++; continue }
269
+ const next = skipComment(ahead)
270
+ if (next !== ahead) { ahead = next; continue }
271
+ break
272
+ }
273
+ if (ahead < length && (source[ahead] === '}' || source[ahead] === ']')) { i++; continue }
274
+ }
275
+ out.push(char)
276
+ i++
277
+ }
278
+ return JSON.parse(out.join(''))
279
+ }
280
+
281
+ // bun.lock package keys are slash-joined chains of package names describing the
282
+ // hoisted install position, e.g. "make-dir/semver" (semver as resolved for
283
+ // make-dir). Scoped names keep their own slash: "@scope/pkg/dep".
284
+ function bunKeySegments(key) {
285
+ if (typeof key !== 'string' || key.length === 0 || key.length > 4096 || /[\u0000-\u001f\u007f]/.test(key)) return null
286
+ const parts = key.split('/')
287
+ const segments = []
288
+ for (let index = 0; index < parts.length; index++) {
289
+ let segment = parts[index]
290
+ if (segment.startsWith('@')) {
291
+ if (index + 1 >= parts.length) return null
292
+ segment = `${segment}/${parts[++index]}`
293
+ }
294
+ if (segment.length > 256 || !PACKAGE_NAME.test(segment)) return null
295
+ segments.push(segment)
296
+ }
297
+ return segments
298
+ }
299
+
300
+ // Entry shape: "key": ["name@version", registry, {dependencies, peerDependencies,
301
+ // optionalPeers, ...}, integrity]. Workspace members resolve to "name@workspace:path".
302
+ function bunPackageRecord(entry) {
303
+ if (!Array.isArray(entry) || typeof entry[0] !== 'string' || entry[0].length > 1024) return null
304
+ const at = entry[0].lastIndexOf('@')
305
+ if (at <= 0) return null
306
+ const name = entry[0].slice(0, at)
307
+ if (name.length > 256 || !PACKAGE_NAME.test(name)) return null
308
+ const rawVersion = entry[0].slice(at + 1)
309
+ const meta = entry.slice(1).find((value) => value && typeof value === 'object' && !Array.isArray(value)) || {}
310
+ return {name, rawVersion, workspace: rawVersion.startsWith('workspace:'), meta}
311
+ }
312
+
313
+ // Adapts a bun dependency map ({dependencies, devDependencies, optionalDependencies,
314
+ // peerDependencies, optionalPeers: [names]}) to the npm record shape consumed by
315
+ // dependencyDeclarations, so declaration kinds and precedence stay identical.
316
+ function bunDeclarationRecord(meta) {
317
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return {}
318
+ const peerDependenciesMeta = {}
319
+ if (Array.isArray(meta.optionalPeers)) {
320
+ for (const name of meta.optionalPeers) {
321
+ if (typeof name === 'string') peerDependenciesMeta[name] = {optional: true}
322
+ }
323
+ }
324
+ return {
325
+ dependencies: meta.dependencies,
326
+ devDependencies: meta.devDependencies,
327
+ optionalDependencies: meta.optionalDependencies,
328
+ peerDependencies: meta.peerDependencies,
329
+ peerDependenciesMeta,
330
+ }
331
+ }
332
+
333
+ // Mirrors bun resolution: the most specific override key wins ("a/b/name"),
334
+ // falling back segment by segment to the hoisted top-level key ("name").
335
+ function resolveBunDependency(sourceSegments, name, records) {
336
+ for (let depth = sourceSegments.length; depth >= 0; depth--) {
337
+ const key = [...sourceSegments.slice(0, depth), name].join('/')
338
+ const record = records.get(key)
339
+ if (record) return {key, record}
340
+ }
341
+ return null
342
+ }
343
+
344
+ // bun.lock does not persist npm's per-package dev/optional/peer flags, so they are
345
+ // approximated from graph reachability the same way npm derives them: a package is
346
+ // dev when unreachable from the root without dev edges, optional (or peer) when
347
+ // unreachable without optional (or peer) edges.
348
+ function applyBunReachabilityFlags(nodes, edges) {
349
+ const adjacency = new Map()
350
+ for (const edge of edges) {
351
+ if (!adjacency.has(edge.from)) adjacency.set(edge.from, [])
352
+ adjacency.get(edge.from).push(edge)
353
+ }
354
+ const reach = (excluded) => {
355
+ const seen = new Set(['(root)'])
356
+ const queue = ['(root)']
357
+ while (queue.length) {
358
+ for (const edge of adjacency.get(queue.pop()) || []) {
359
+ if (excluded.has(edge.kind) || seen.has(edge.to)) continue
360
+ seen.add(edge.to)
361
+ queue.push(edge.to)
362
+ }
363
+ }
364
+ return seen
365
+ }
366
+ const withoutDev = reach(new Set(['dev']))
367
+ const withoutOptional = reach(new Set(['optional', 'optional-peer']))
368
+ const withoutPeer = reach(new Set(['peer', 'optional-peer']))
369
+ for (const node of nodes) {
370
+ node.dev = !withoutDev.has(node.id)
371
+ node.optional = !withoutOptional.has(node.id)
372
+ node.peer = !withoutPeer.has(node.id)
373
+ }
374
+ }
375
+
376
+ function parseBunLock(lock, lockfile) {
377
+ const lockfileVersion = Number(lock?.lockfileVersion)
378
+ const extras = {lockfile, lockfileVersion: Number.isFinite(lockfileVersion) ? Math.trunc(lockfileVersion) : 0}
379
+ if (!lock?.packages || typeof lock.packages !== 'object' || Array.isArray(lock.packages)) {
380
+ return emptyGraph(STATE.PARTIAL, 'BUN_LOCK_PACKAGES_REQUIRED', extras)
381
+ }
382
+
383
+ const allKeys = Object.keys(lock.packages).sort(compareText)
384
+ const selectedKeys = allKeys.slice(0, MAX_PACKAGE_RECORDS)
385
+ const reasons = []
386
+ if (selectedKeys.length < allKeys.length) reasons.push('LOCKFILE_PACKAGE_RECORD_LIMIT_REACHED')
387
+
388
+ const records = new Map()
389
+ let invalidRecords = 0
390
+ for (const key of selectedKeys) {
391
+ const segments = bunKeySegments(key)
392
+ const record = segments ? bunPackageRecord(lock.packages[key]) : null
393
+ if (!record) {
394
+ invalidRecords++
395
+ continue
396
+ }
397
+ records.set(key, {...record, segments})
398
+ }
399
+ if (invalidRecords > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_RECORDS')
400
+
401
+ // Node ids reuse packageId over the bun install-position key, so ids keep the
402
+ // exact "npm:name@version:hash12" shape the npm parser and sync sanitizer use,
403
+ // and node.name stays the bare package name that hosted BFS grounding matches.
404
+ const externalNodes = new Map()
405
+ let invalidPackageVersions = 0
406
+ for (const [key, record] of records) {
407
+ if (record.workspace) continue
408
+ const version = packageVersion(record.rawVersion)
409
+ if (!version) { invalidPackageVersions++; continue }
410
+ externalNodes.set(key, {
411
+ id: packageId(record.name, version, key),
412
+ name: record.name,
413
+ version,
414
+ direct: false,
415
+ dev: false,
416
+ optional: false,
417
+ peer: false,
418
+ })
419
+ }
420
+ if (invalidPackageVersions > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_VERSIONS')
421
+
422
+ const workspaces = lock?.workspaces && typeof lock.workspaces === 'object' && !Array.isArray(lock.workspaces)
423
+ ? lock.workspaces
424
+ : null
425
+ if (!workspaces) reasons.push('BUN_LOCK_WORKSPACES_MISSING')
426
+
427
+ // Sources: every workspace member acts as '(root)' (matching how the npm parser
428
+ // treats workspace-local records), then every resolved external package.
429
+ const sources = []
430
+ for (const path of Object.keys(workspaces || {}).sort(compareText)) {
431
+ const meta = workspaces[path]
432
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) continue
433
+ const workspaceName = path === '' ? null : safeToken(meta.name)
434
+ const segments = workspaceName && records.has(workspaceName) ? [workspaceName] : []
435
+ sources.push({sourceId: '(root)', segments, declarationRecord: bunDeclarationRecord(meta)})
436
+ }
437
+ for (const [key, record] of [...records].sort(([a], [b]) => compareText(a, b))) {
438
+ const node = externalNodes.get(key)
439
+ if (!node) continue
440
+ sources.push({sourceId: node.id, segments: record.segments, declarationRecord: bunDeclarationRecord(record.meta)})
441
+ }
442
+
443
+ const edgeMap = new Map()
444
+ const declarations = emptyDeclarations()
445
+ let declarationLimitReached = false
446
+ outer: for (const source of sources) {
447
+ for (const declaration of dependencyDeclarations(source.declarationRecord)) {
448
+ if (declarations.total >= MAX_DEPENDENCY_DECLARATIONS) {
449
+ declarationLimitReached = true
450
+ break outer
451
+ }
452
+ declarations.total++
453
+ const resolved = resolveBunDependency(source.segments, declaration.name, records)
454
+ if (resolved?.record.workspace) {
455
+ declarations.local++
456
+ continue
457
+ }
458
+ const node = resolved ? externalNodes.get(resolved.key) : null
459
+ if (!node) {
460
+ if (declaration.kind === 'optional' || declaration.kind === 'optional-peer') declarations.optionalMissing++
461
+ else declarations.unresolved++
462
+ continue
463
+ }
464
+ declarations.resolved++
465
+ if (source.sourceId === '(root)') node.direct = true
466
+ if (source.sourceId === node.id) continue
467
+ const edge = {from: source.sourceId, to: node.id, kind: declaration.kind}
468
+ edgeMap.set(`${edge.from}\0${edge.to}\0${edge.kind}`, edge)
469
+ }
470
+ }
471
+ if (declarationLimitReached) reasons.push('LOCKFILE_DEPENDENCY_DECLARATION_LIMIT_REACHED')
472
+ if (declarations.unresolved > 0) reasons.push('UNRESOLVED_LOCKFILE_DEPENDENCIES')
473
+
474
+ applyBunReachabilityFlags(externalNodes.values(), edgeMap.values())
475
+
476
+ return finalizeGraph({externalNodes, edgeMap, declarations, reasons, lockfile, lockfileVersion: extras.lockfileVersion})
477
+ }
478
+
226
479
  export function buildPackageDependencyGraph(repoRoot) {
227
480
  const boundary = createRepoBoundary(repoRoot)
228
481
  if (!boundary.root) return emptyGraph(STATE.ERROR, 'INVALID_REPOSITORY_ROOT')
@@ -241,8 +494,9 @@ export function buildPackageDependencyGraph(repoRoot) {
241
494
  if (statSync(selected.path).size > MAX_LOCKFILE_BYTES) {
242
495
  return emptyGraph(STATE.ERROR, 'PACKAGE_LOCK_SIZE_LIMIT_REACHED', {lockfile: selected.lockfile})
243
496
  }
244
- const lock = JSON.parse(readFileSync(selected.path, 'utf8'))
245
- return parseLock(lock, selected.lockfile)
497
+ const raw = readFileSync(selected.path, 'utf8')
498
+ if (selected.lockfile === 'bun.lock') return parseBunLock(parseJsonc(raw), selected.lockfile)
499
+ return parseLock(JSON.parse(raw), selected.lockfile)
246
500
  } catch {
247
501
  return emptyGraph(STATE.ERROR, 'PACKAGE_LOCK_READ_ERROR', {lockfile: selected.lockfile})
248
502
  }
@@ -133,7 +133,10 @@ export function ambiguityNote(query, info) {
133
133
  const bestByDegree = (g, list) =>
134
134
  list.reduce((best, n) => (degreeOf(g, n.id) > degreeOf(g, best.id) ? n : best), list[0])
135
135
 
136
- const QUERY_STOP = new Set('a an and are around architecture code do does explain find for from how in is me of or project repository show the through to trace what where which with'.split(' '))
136
+ // Instructional words describe the requested answer, not repository concepts. Letting them become
137
+ // fuzzy seeds made broad prompts select symbols such as `jest.config.cjs#path` for an HTTP request
138
+ // path. Exact file/symbol questions still have seed_files / inspect_symbol as explicit controls.
139
+ const QUERY_STOP = new Set('a an and are around architecture best code do does exact explain find focus focused for from how identify in inspect inspection is logic me of or path production project repository request requests rest show symbol symbols the through to trace what where which with'.split(' '))
137
140
  const QUERY_INTENTS = [
138
141
  ['bootstrap', ['bootstrap', 'startup', 'entrypoint', 'entry', 'main', 'root', 'app', 'application', 'applications', 'index', 'server', 'cli']],
139
142
  ['tool-execution', ['tool', 'tools', 'tooling', 'mcp', 'execution', 'execute', 'invocation', 'invoke', 'dispatch', 'dispatcher', 'handler', 'catalog', 'registry']],
@@ -275,6 +278,36 @@ function queryConcepts(query) {
275
278
  return concepts
276
279
  }
277
280
 
281
+ // A code-shaped identifier in prose is stronger evidence than surrounding architecture words.
282
+ // For example, `startMitigate` should seed its exact controller/service/messaging declarations,
283
+ // not one arbitrary file for each of "controller", "service", and "flow". This remains bounded
284
+ // label matching rather than semantic search; ambiguous identifiers are deliberately all retained.
285
+ function exactIdentifierSeeds(g, query, limit, {repoRoot = null} = {}) {
286
+ const identifiers = [...new Set((String(query || '').match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [])
287
+ .filter((token) => /(?:[a-z0-9][A-Z]|_)/.test(token))
288
+ .map((token) => token.toLowerCase()))]
289
+ if (!identifiers.length) return []
290
+ const requestedClasses = requestedPathClasses(query)
291
+ const languageExtensions = requestedLanguages(query)
292
+ const classifier = createPathClassifier(repoRoot)
293
+ const classificationCache = new Map()
294
+ const matches = []
295
+ for (const identifier of identifiers) {
296
+ const candidates = g.nodes.filter((node) => {
297
+ const label = String(node.label || '').replace(/\(\)$/, '').toLowerCase()
298
+ return label === identifier
299
+ && matchesLanguage(node, languageExtensions)
300
+ && isQueryEligible(node, requestedClasses, classificationCache, classifier)
301
+ }).sort((left, right) => degreeOf(g, right.id) - degreeOf(g, left.id)
302
+ || String(left.id).localeCompare(String(right.id)))
303
+ for (const node of candidates) {
304
+ if (!matches.some((existing) => String(existing.id) === String(node.id))) matches.push(node)
305
+ if (matches.length >= limit) return matches
306
+ }
307
+ }
308
+ return matches
309
+ }
310
+
278
311
  function conceptScore(g, node, concept, queryContext) {
279
312
  const id = normPath(node.id)
280
313
  const label = String(node.label ?? '').toLowerCase()
@@ -317,6 +350,8 @@ function conceptScore(g, node, concept, queryContext) {
317
350
  // Natural-language graph search keeps one strong candidate per concept before filling by aggregate
318
351
  // score. This prevents a broad architecture question from spending every seed on one dense API area.
319
352
  export function findSeeds(g, query, limit = 8, {repoRoot = null} = {}) {
353
+ const exact = exactIdentifierSeeds(g, query, limit, {repoRoot})
354
+ if (exact.length) return exact
320
355
  const concepts = queryConcepts(query)
321
356
  if (!concepts.length || limit <= 0) return []
322
357
  const requestedClasses = requestedPathClasses(query)
@@ -3,6 +3,7 @@
3
3
  // Hot-reloadable (re-imported by catalog.mjs on change).
4
4
  import {readFileSync, writeFileSync, existsSync, statSync, realpathSync} from 'node:fs'
5
5
  import {dirname, join, isAbsolute} from 'node:path'
6
+ import {createHash} from 'node:crypto'
6
7
  import {prevGraphPathFor, diffGraphs, formatGraphDiff, graphStaleness} from './graph-context.mjs'
7
8
  import {buildGraphForRepo, defaultPrecisionMode} from '../build-graph.js'
8
9
  import {graphHomeDir, graphOutDirForModule, graphOutDirForRepo} from '../graph/layout.js'
@@ -13,8 +14,12 @@ import {createSyncPayload, createSyncPayloadV3, MAX_SYNC_BODY_BYTES} from './syn
13
14
  import {createEvidenceSnapshot} from './evidence-snapshot.mjs'
14
15
  import {writeCachedArchitectureContract} from '../analysis/architecture-contract.js'
15
16
  import {precisionSemanticInputsMatch, readPrecisionOverlay} from '../precision/lsp-overlay.js'
17
+ import {toolResult} from './tool-result.mjs'
16
18
 
17
19
  const MAX_SYNC_GRAPH_FILE_BYTES = 64 * 1024 * 1024
20
+ const SYNC_PREVIEW_TTL_MS = 5 * 60 * 1000
21
+ const MAX_SYNC_PREVIEWS = 4
22
+ const syncPreviews = new Map()
18
23
 
19
24
  function syncRepoLabel(repoRoot) {
20
25
  const basename = String(repoRoot || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'repo'
@@ -22,6 +27,80 @@ function syncRepoLabel(repoRoot) {
22
27
  return (safe || 'repo').slice(0, 128)
23
28
  }
24
29
 
30
+ function syncDestination(raw) {
31
+ let url
32
+ try { url = new URL(raw) } catch { throw new Error('WEAVATRIX_SYNC_URL is invalid') }
33
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('WEAVATRIX_SYNC_URL must use HTTPS (or HTTP for loopback development)')
34
+ if (url.username || url.password) throw new Error('WEAVATRIX_SYNC_URL must not contain embedded credentials; use WEAVATRIX_SYNC_TOKEN')
35
+ if (url.hash) throw new Error('WEAVATRIX_SYNC_URL must not contain a fragment')
36
+ const loopback = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(url.hostname.toLowerCase())
37
+ if (url.protocol !== 'https:' && !loopback) throw new Error('WEAVATRIX_SYNC_URL must use HTTPS unless the destination is loopback')
38
+ const display = `${url.origin}${url.pathname}${url.search ? ' (query redacted)' : ''}`
39
+ return {url: url.toString(), display}
40
+ }
41
+
42
+ function pruneSyncPreviews(now = Date.now()) {
43
+ for (const [token, preview] of syncPreviews) if (preview.expiresAt <= now) syncPreviews.delete(token)
44
+ while (syncPreviews.size >= MAX_SYNC_PREVIEWS) syncPreviews.delete(syncPreviews.keys().next().value)
45
+ }
46
+
47
+ function confirmationToken({url, repositoryId, payloadVersion, bodyHash}) {
48
+ return createHash('sha256')
49
+ .update(`weavatrix-sync-preview-v1\0${url}\0${repositoryId}\0${payloadVersion}\0${bodyHash}`)
50
+ .digest('hex').slice(0, 24)
51
+ }
52
+
53
+ function syncSectionSummary(payload) {
54
+ if (payload.syncPayloadV !== 3) return 'graph topology only (explicit V2 compatibility mode)'
55
+ const sections = payload.evidence?.sections || {}
56
+ const names = Object.entries(sections).map(([name, section]) => `${name}:${section?.state || section?.verdict || 'included'}`)
57
+ return names.join(', ') || 'bounded architecture/health/stack/package/duplicate evidence'
58
+ }
59
+
60
+ function syncPreviewText(preview, {expired = false} = {}) {
61
+ return [
62
+ `SYNC PREVIEW${expired ? ' (the supplied confirmation was missing, expired, or did not match)' : ''} — no network request was made.`,
63
+ `Destination: ${preview.destinationDisplay}.`,
64
+ `Repository: ${preview.repoName}; opaque repository UUID: ${preview.repositoryId}.`,
65
+ `Payload V${preview.payload.syncPayloadV}: ${preview.payload.nodes.length} nodes / ${preview.payload.links.length} edges, ${Math.round(preview.bodyBytes / 1024)} KB; body SHA-256 ${preview.bodyHash.slice(0, 12)}.`,
66
+ `Payload fields: ${Object.keys(preview.payload).sort().join(', ')}.`,
67
+ `Included sections: ${syncSectionSummary(preview.payload)}.`,
68
+ 'Excluded by the wire allowlist: source bodies, snippets, absolute host paths, environment values, credentials, Git remotes, and unknown fields.',
69
+ `After the user approves this exact destination and summary, call sync_graph again within 5 minutes with dry_run:false and confirm_token: "${preview.token}".`,
70
+ ].join('\n')
71
+ }
72
+
73
+ async function sendSyncPreview(preview, timeoutMs) {
74
+ try {
75
+ const res = await fetch(preview.url, {
76
+ method: 'POST',
77
+ headers: {
78
+ 'content-type': 'application/json',
79
+ 'x-weavatrix-payload-version': String(preview.payload.syncPayloadV),
80
+ 'x-weavatrix-repo': preview.repoName,
81
+ 'x-weavatrix-repository-id': preview.repositoryId,
82
+ ...(process.env.WEAVATRIX_SYNC_TOKEN ? {authorization: `Bearer ${process.env.WEAVATRIX_SYNC_TOKEN}`} : {}),
83
+ },
84
+ body: preview.body,
85
+ signal: AbortSignal.timeout(timeoutMs),
86
+ })
87
+ if (!res.ok) {
88
+ const accepted = res.headers?.get?.('x-weavatrix-accept-payload-versions')
89
+ const compatibility = (res.status === 415 || res.status === 422) && accepted
90
+ ? ` Endpoint accepts payload version(s) ${accepted}; create and approve a new V2 preview only if graph-only sync is intentional.`
91
+ : ''
92
+ return `Sync endpoint ${preview.destinationDisplay} answered HTTP ${res.status} — graph NOT accepted.${compatibility}`
93
+ }
94
+ syncPreviews.delete(preview.token)
95
+ const evidenceNote = preview.payload.syncPayloadV === 3
96
+ ? ` + evidence ${preview.payload.evidence?.snapshotHash?.slice(0, 12) || 'unknown'}`
97
+ : ''
98
+ return `Graph for ${preview.repoName} (${preview.payload.nodes.length} nodes / ${preview.payload.links.length} edges${evidenceNote}, ${Math.round(preview.bodyBytes / 1024)} KB) pushed to approved destination ${preview.destinationDisplay}.`
99
+ } catch (error) {
100
+ return `Sync failed: ${error.message} — the graph stays local; the approved preview remains retryable until it expires.`
101
+ }
102
+ }
103
+
25
104
  export async function tRebuildGraph(g, args, ctx) {
26
105
  if (!ctx.repoRoot) return 'Rebuild needs the repo root (not provided to this server).'
27
106
  const mode = ['no-tests', 'tests-only', 'full'].includes(args.mode)
@@ -188,8 +267,10 @@ export async function tPullArchitectureContract(g, args, ctx) {
188
267
  const token = process.env.WEAVATRIX_SYNC_TOKEN
189
268
  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.'
190
269
  let url
191
- try { url = process.env.WEAVATRIX_ARCHITECTURE_URL || new URL('/api/v1/architecture-contract', syncUrl).toString() }
192
- catch { return 'WEAVATRIX_SYNC_URL is invalid.' }
270
+ try {
271
+ const configured = process.env.WEAVATRIX_ARCHITECTURE_URL || new URL('/api/v1/architecture-contract', syncUrl).toString()
272
+ url = syncDestination(configured).url
273
+ } catch (error) { return `Hosted architecture pull is not configured safely: ${error.message}.` }
193
274
  const registry = repositoryRecord(ctx.repoRoot, graphHomeDir())
194
275
  || registerRepository({repoPath: ctx.repoRoot, graphDir: graphOutDirForRepo(ctx.repoRoot), graphHome: graphHomeDir()})
195
276
  const timeoutMs = Math.min(120000, Math.max(1000, Number(args.timeout_ms) || 30000))
@@ -199,8 +280,31 @@ export async function tPullArchitectureContract(g, args, ctx) {
199
280
  signal: AbortSignal.timeout(timeoutMs),
200
281
  })
201
282
  const body = await res.json().catch(() => null)
202
- if (!res.ok) return `Hosted architecture endpoint answered HTTP ${res.status}; the local contract cache was not changed.`
203
- if (body?.state === 'NOT_CONFIGURED' || !body?.contract) return 'Hosted target architecture is NOT_CONFIGURED. Define and save it in the Architecture editor first.'
283
+ if (!res.ok) {
284
+ const serverCode = String(body?.error?.code || body?.state || '').toUpperCase()
285
+ const state = res.status === 401 ? 'AUTH_REQUIRED'
286
+ : res.status === 403 ? 'FORBIDDEN'
287
+ : res.status === 404 && ['REPOSITORY_NOT_FOUND', 'NOT_FOUND'].includes(serverCode) ? 'REPOSITORY_NOT_REGISTERED'
288
+ : res.status === 404 ? 'ENDPOINT_NOT_FOUND'
289
+ : res.status === 409 ? 'REPOSITORY_NOT_READY'
290
+ : 'HTTP_ERROR'
291
+ const next = state === 'REPOSITORY_NOT_REGISTERED'
292
+ ? 'The Hosted endpoint is reachable, but this UUID has not completed a preview-confirmed repository sync.'
293
+ : state === 'ENDPOINT_NOT_FOUND'
294
+ ? 'The configured architecture endpoint does not exist; verify WEAVATRIX_ARCHITECTURE_URL or the URL derived from WEAVATRIX_SYNC_URL.'
295
+ : res.status === 401 || res.status === 403
296
+ ? 'Check the hosted token and repository access; no local cache entry was changed.'
297
+ : res.status === 409
298
+ ? 'Sync/register this repository first, then create or pull its target contract.'
299
+ : 'Check the hosted service status and configured endpoint before retrying.'
300
+ return toolResult(`Hosted architecture pull: ${state} (HTTP ${res.status}). ${next} The previous local contract cache remains unchanged.`, {
301
+ state, httpStatus: res.status, serverCode: serverCode || null, cacheChanged: false,
302
+ })
303
+ }
304
+ if (body?.state === 'NOT_CONFIGURED' || !body?.contract) return toolResult(
305
+ 'Hosted target architecture is NOT_CONFIGURED. Repository sync and authentication succeeded; define and save a target in the Architecture editor first.',
306
+ {state: 'NOT_CONFIGURED', repositoryId: registry.repositoryId, cacheChanged: false},
307
+ )
204
308
  const stored = writeCachedArchitectureContract(ctx.graphPath, body.contract)
205
309
  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.`
206
310
  } catch (error) {
@@ -208,14 +312,15 @@ export async function tPullArchitectureContract(g, args, ctx) {
208
312
  }
209
313
  }
210
314
 
211
- // Push the current graph.json to a user-configured endpoint. Off until WEAVATRIX_SYNC_URL is set.
212
- // The payload is graph metadata (paths, symbols/ranges, imports, edges, metrics), never file contents.
213
- export async function tSyncGraph(g, args, ctx) {
214
- const url = process.env.WEAVATRIX_SYNC_URL
215
- if (!url) {
315
+ async function buildSyncPreview(g, args, ctx) {
316
+ const configuredUrl = process.env.WEAVATRIX_SYNC_URL
317
+ if (!configuredUrl) {
216
318
  return 'Graph sync is not configured (optional feature). Set WEAVATRIX_SYNC_URL to the upload endpoint'
217
319
  + ' (and WEAVATRIX_SYNC_TOKEN for bearer auth) in the MCP registration env, then call again.'
218
320
  }
321
+ let destination
322
+ try { destination = syncDestination(configuredUrl) }
323
+ catch (error) { return `Graph sync is not configured safely: ${error.message}.` }
219
324
  if (!g) return 'No graph loaded — build one first (open_repo / rebuild_graph).'
220
325
  let raw
221
326
  try {
@@ -249,32 +354,61 @@ export async function tSyncGraph(g, args, ctx) {
249
354
  const repoName = syncRepoLabel(ctx.repoRoot)
250
355
  const registry = repositoryRecord(ctx.repoRoot, graphHomeDir())
251
356
  || registerRepository({repoPath: ctx.repoRoot, graphDir: graphOutDirForRepo(ctx.repoRoot), graphHome: graphHomeDir()})
252
- const timeoutMs = Math.min(120000, Math.max(1000, Number(args.timeout_ms) || 30000))
253
- try {
254
- const res = await fetch(url, {
255
- method: 'POST',
256
- headers: {
257
- 'content-type': 'application/json',
258
- 'x-weavatrix-payload-version': String(payload.syncPayloadV),
259
- 'x-weavatrix-repo': repoName,
260
- 'x-weavatrix-repository-id': registry.repositoryId,
261
- ...(process.env.WEAVATRIX_SYNC_TOKEN ? {authorization: `Bearer ${process.env.WEAVATRIX_SYNC_TOKEN}`} : {}),
262
- },
263
- body,
264
- signal: AbortSignal.timeout(timeoutMs),
265
- })
266
- if (!res.ok) {
267
- const accepted = res.headers?.get?.('x-weavatrix-accept-payload-versions')
268
- const compatibility = (res.status === 415 || res.status === 422) && accepted
269
- ? ` Endpoint accepts payload version(s) ${accepted}; retry with payload_version:2 only if you intentionally want graph-only sync.`
270
- : ''
271
- return `Sync endpoint answered HTTP ${res.status} — graph NOT accepted.${compatibility}`
272
- }
273
- const evidenceNote = payload.syncPayloadV === 3
274
- ? ` + evidence ${payload.evidence?.snapshotHash?.slice(0, 12) || 'unknown'}`
275
- : ''
276
- return `Graph for ${repoName} (${payload.nodes.length} nodes / ${payload.links.length} edges${evidenceNote}, ${Math.round(bodyBytes / 1024)} KB) pushed to ${url}.`
277
- } catch (e) {
278
- return `Sync failed: ${e.message} the graph stays local.`
357
+ const bodyHash = createHash('sha256').update(body).digest('hex')
358
+ const token = confirmationToken({url: destination.url, repositoryId: registry.repositoryId, payloadVersion: payload.syncPayloadV, bodyHash})
359
+ const preview = {
360
+ token, url: destination.url, destinationDisplay: destination.display,
361
+ graphPath: ctx.graphPath, repoName, repositoryId: registry.repositoryId,
362
+ payload, body, bodyBytes, bodyHash, expiresAt: Date.now() + SYNC_PREVIEW_TTL_MS,
363
+ }
364
+ syncPreviews.set(token, preview)
365
+ return preview
366
+ }
367
+
368
+ function previewResult(preview, {expired = false} = {}) {
369
+ if (typeof preview === 'string') return preview
370
+ return toolResult(syncPreviewText(preview, {expired}), {
371
+ status: 'PREVIEW_READY', networkRequestMade: false,
372
+ destination: preview.destinationDisplay, repository: preview.repoName,
373
+ repositoryId: preview.repositoryId, payloadVersion: preview.payload.syncPayloadV,
374
+ nodes: preview.payload.nodes.length, links: preview.payload.links.length,
375
+ bodyBytes: preview.bodyBytes, bodyHash: preview.bodyHash,
376
+ payloadFields: Object.keys(preview.payload).sort(), sections: syncSectionSummary(preview.payload),
377
+ expiresAt: new Date(preview.expiresAt).toISOString(), confirmToken: preview.token,
378
+ }, {completeness: {status: 'COMPLETE', reason: 'exact allowlisted payload serialized locally; no network request made'}})
379
+ }
380
+
381
+ // Build the exact upload body and approval token without any network request. Kept separate from the
382
+ // mutating tool so safety layers and humans can approve a local preview without authorizing egress.
383
+ export async function tPreviewSyncGraph(g, args, ctx) {
384
+ pruneSyncPreviews()
385
+ return previewResult(await buildSyncPreview(g, args, ctx))
386
+ }
387
+
388
+ // Push the exact payload previously approved through preview_sync. The old dry_run form remains a
389
+ // compatibility alias for one release, but can never send unless dry_run:false and the token matches.
390
+ export async function tSyncGraph(g, args, ctx) {
391
+ if (args.dry_run !== false) {
392
+ pruneSyncPreviews()
393
+ const preview = await buildSyncPreview(g, args, ctx)
394
+ if (typeof preview === 'string') return preview
395
+ const suppliedToken = String(args.confirm_token || '').trim()
396
+ const exact = suppliedToken && suppliedToken === preview.token
397
+ return `${syncPreviewText(preview, {expired: !!suppliedToken && !exact})}${exact ? '\nConfirmation token recognized, but dry_run is still true; no network request was made.' : ''}`
398
+ }
399
+ const configuredUrl = process.env.WEAVATRIX_SYNC_URL
400
+ if (!configuredUrl) return 'Graph sync is not configured (optional feature). Set WEAVATRIX_SYNC_URL first.'
401
+ let destination
402
+ try { destination = syncDestination(configuredUrl) }
403
+ catch (error) { return `Graph sync is not configured safely: ${error.message}.` }
404
+ pruneSyncPreviews()
405
+ const suppliedToken = String(args.confirm_token || '').trim()
406
+ const approved = suppliedToken ? syncPreviews.get(suppliedToken) : null
407
+ if (approved && approved.expiresAt > Date.now()
408
+ && approved.url === destination.url && approved.graphPath === ctx.graphPath) {
409
+ const timeoutMs = Math.min(120000, Math.max(1000, Number(args.timeout_ms) || 30000))
410
+ return sendSyncPreview(approved, timeoutMs)
279
411
  }
412
+ const preview = await buildSyncPreview(g, args, ctx)
413
+ return typeof preview === 'string' ? preview : syncPreviewText(preview, {expired: true})
280
414
  }