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.
- package/README.md +82 -27
- package/SECURITY.md +30 -7
- package/docs/releases/v0.2.9.md +123 -0
- package/package.json +3 -3
- package/skill/SKILL.md +12 -7
- package/src/analysis/duplicate-groups.js +11 -2
- package/src/analysis/endpoints.js +209 -9
- package/src/mcp/catalog.mjs +13 -10
- package/src/mcp/evidence-snapshot.package-graph.mjs +257 -3
- package/src/mcp/graph-context.mjs +36 -1
- package/src/mcp/tools-actions.mjs +170 -36
- package/src/mcp/tools-context.mjs +25 -8
- package/src/mcp/tools-endpoints.mjs +162 -0
- package/src/mcp/tools-graph.mjs +1 -1
- package/src/mcp/tools-health.mjs +70 -41
- package/src/mcp/tools-source.mjs +3 -3
- package/src/mcp-server.mjs +30 -2
- package/src/mcp-source-tools.mjs +10 -7
- package/docs/releases/v0.2.8.md +0 -82
|
@@ -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
|
|
245
|
-
return
|
|
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
|
-
|
|
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 {
|
|
192
|
-
|
|
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)
|
|
203
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
}
|