weavatrix 0.2.7 → 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 +101 -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/dead-check.js +24 -2
- package/src/analysis/dep-check-ecosystems.js +41 -2
- package/src/analysis/duplicate-groups.js +11 -2
- package/src/analysis/endpoints.js +256 -22
- package/src/analysis/internal-audit.collect.js +53 -20
- package/src/graph/builder/lang-rust.js +49 -12
- package/src/mcp/catalog.mjs +13 -10
- package/src/mcp/evidence-snapshot.package-graph.mjs +257 -3
- package/src/mcp/graph-context.mjs +72 -2
- 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 +92 -42
- package/src/mcp/tools-source.mjs +3 -3
- package/src/mcp/tools-verified-change.mjs +16 -0
- package/src/mcp-server.mjs +30 -2
- package/src/mcp-source-tools.mjs +10 -7
- package/src/path-classification.js +1 -1
- package/src/precision/symbol-query.js +51 -0
- package/docs/releases/v0.2.7.md +0 -93
|
@@ -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
|
}
|
|
@@ -2,6 +2,7 @@ import {isStructuralRelation} from '../graph/relations.js'
|
|
|
2
2
|
import {boundedInteger} from '../bounds.js'
|
|
3
3
|
import {isSymbol, labelOf} from './graph-context.mjs'
|
|
4
4
|
import {toolResult} from './tool-result.mjs'
|
|
5
|
+
import {sourceExcerpt} from './tools-source.mjs'
|
|
5
6
|
|
|
6
7
|
const MAX_LINE_SAMPLES = 5
|
|
7
8
|
|
|
@@ -10,7 +11,7 @@ const fileOf = (g, id) => {
|
|
|
10
11
|
return String(node?.source_file || (isSymbol(id) ? String(id).split('#')[0] : id))
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
function aggregateEdges(g, edges, cap) {
|
|
14
|
+
function aggregateEdges(g, edges, cap, {callsiteFile = null} = {}) {
|
|
14
15
|
const groups = new Map()
|
|
15
16
|
for (const edge of edges || []) {
|
|
16
17
|
if (isStructuralRelation(edge.relation) || edge.barrelProxy === true) continue
|
|
@@ -19,7 +20,12 @@ function aggregateEdges(g, edges, cap) {
|
|
|
19
20
|
const key = `${id}\0${relation}`
|
|
20
21
|
let group = groups.get(key)
|
|
21
22
|
if (!group) {
|
|
22
|
-
|
|
23
|
+
const targetFile = fileOf(g, id)
|
|
24
|
+
group = {
|
|
25
|
+
id, label: labelOf(g, id), relation, count: 0, lines: [],
|
|
26
|
+
file: callsiteFile || targetFile,
|
|
27
|
+
...(callsiteFile && callsiteFile !== targetFile ? {targetFile} : {}),
|
|
28
|
+
}
|
|
23
29
|
groups.set(key, group)
|
|
24
30
|
}
|
|
25
31
|
group.count++
|
|
@@ -95,7 +101,8 @@ function linesForGroups(title, groups) {
|
|
|
95
101
|
const lines = [`${title}: ${groups.total} container(s)${groups.capped ? ` (${groups.shown.length} shown)` : ''}`]
|
|
96
102
|
for (const group of groups.shown) {
|
|
97
103
|
const sites = group.lines.length ? `:${group.lines.join(',')}` : ''
|
|
98
|
-
|
|
104
|
+
const destination = group.targetFile ? ` → ${group.targetFile}` : ''
|
|
105
|
+
lines.push(` ${group.count}× ${group.relation} ${group.label} [call site ${group.file}${sites}${destination}]`)
|
|
99
106
|
}
|
|
100
107
|
return lines
|
|
101
108
|
}
|
|
@@ -133,14 +140,24 @@ export async function tContextBundle(g, args = {}, ctx = {}, inspectSymbol) {
|
|
|
133
140
|
name: String(g.byId.get(inspection.definition.id)?.label || '').replace(/\(\)$/, ''),
|
|
134
141
|
}
|
|
135
142
|
const inbound = aggregateEdges(g, g.inn.get(definition.id), maxRelated)
|
|
136
|
-
const outbound = aggregateEdges(g, g.out.get(definition.id), maxRelated)
|
|
143
|
+
const outbound = aggregateEdges(g, g.out.get(definition.id), maxRelated, {callsiteFile: definition.file})
|
|
137
144
|
const reExports = exactReExportSites(g, definition, maxReExports)
|
|
138
145
|
const source = []
|
|
139
|
-
|
|
146
|
+
const append = (role, excerpt) => {
|
|
147
|
+
if (!excerpt || source.length >= maxSourceFiles) return
|
|
148
|
+
if (source.some((item) => item.file === excerpt.file && item.focusLine === excerpt.focusLine)) return
|
|
149
|
+
source.push({role, ...excerpt})
|
|
150
|
+
}
|
|
151
|
+
append('Definition', inspection.source.definition)
|
|
152
|
+
const contextLines = boundedInteger(args.context_lines, 4, 0, 12)
|
|
153
|
+
for (const group of outbound.shown) {
|
|
154
|
+
for (const line of group.lines) append('Outbound call site', sourceExcerpt(ctx.repoRoot, group.file, line, contextLines))
|
|
155
|
+
}
|
|
156
|
+
for (const group of inbound.shown) {
|
|
157
|
+
for (const line of group.lines) append('Inbound call site', sourceExcerpt(ctx.repoRoot, group.file, line, contextLines))
|
|
158
|
+
}
|
|
140
159
|
for (const excerpt of inspection.source.callers || []) {
|
|
141
|
-
|
|
142
|
-
if (source.some((item) => item.file === excerpt.file && item.focusLine === excerpt.focusLine)) continue
|
|
143
|
-
source.push({role: 'Caller', ...excerpt})
|
|
160
|
+
append('Reference', excerpt)
|
|
144
161
|
}
|
|
145
162
|
const result = {
|
|
146
163
|
status: 'OK', definition, evidence: inspection.evidence,
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {posix} from 'node:path'
|
|
2
|
+
import {analyzeEndpointInventory} from '../analysis/endpoints.js'
|
|
3
|
+
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
4
|
+
import {sourceExcerpt} from './tools-source.mjs'
|
|
5
|
+
import {toolResult} from './tool-result.mjs'
|
|
6
|
+
|
|
7
|
+
const NON_PRODUCT = ['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp']
|
|
8
|
+
|
|
9
|
+
const symbolLine = (node) => Number(String(node?.source_location || '').match(/L(\d+)/)?.[1]
|
|
10
|
+
|| String(node?.id || '').match(/@(\d+)$/)?.[1] || 0)
|
|
11
|
+
|
|
12
|
+
const symbolName = (node) => String(node?.label || node?.id || '')
|
|
13
|
+
.replace(/\([^)]*\).*$/, '')
|
|
14
|
+
.split(/[.#]/).at(-1)
|
|
15
|
+
.trim()
|
|
16
|
+
|
|
17
|
+
function codeFiles(graph) {
|
|
18
|
+
return [...new Set((graph?.nodes || [])
|
|
19
|
+
.filter((node) => !String(node.id).includes('#') && node.source_file && node.file_type === 'code')
|
|
20
|
+
.map((node) => node.source_file))]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function endpointCandidates(inventory, args) {
|
|
24
|
+
const path = String(args.path || '').trim()
|
|
25
|
+
const method = args.method ? String(args.method).toUpperCase() : null
|
|
26
|
+
if (!path) return []
|
|
27
|
+
const eligible = inventory.endpoints.filter((endpoint) => !method || endpoint.method === method)
|
|
28
|
+
const exact = eligible.filter((endpoint) => endpoint.path === path)
|
|
29
|
+
return exact.length ? exact : eligible.filter((endpoint) => endpoint.path.endsWith(path))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function handlerCandidates(graph, endpoint) {
|
|
33
|
+
if (!endpoint.handler) return []
|
|
34
|
+
const wanted = endpoint.handler.toLowerCase()
|
|
35
|
+
const qualifier = String(endpoint.handlerRef || '').split('.').slice(-2, -1)[0]?.toLowerCase() || ''
|
|
36
|
+
const routeDir = posix.dirname(endpoint.file)
|
|
37
|
+
const scored = (graph.nodes || [])
|
|
38
|
+
.filter((node) => String(node.id).includes('#') && node.source_file && symbolName(node).toLowerCase() === wanted)
|
|
39
|
+
.map((node) => {
|
|
40
|
+
const file = String(node.source_file).replace(/\\/g, '/')
|
|
41
|
+
let score = 0
|
|
42
|
+
if (file === endpoint.file) score += 100
|
|
43
|
+
if (posix.dirname(file) === routeDir) score += 40
|
|
44
|
+
if (file.startsWith(`${routeDir}/`)) score += 15
|
|
45
|
+
if (qualifier) {
|
|
46
|
+
const basename = posix.basename(file).replace(/\.[^.]+$/, '').toLowerCase()
|
|
47
|
+
if (basename === qualifier || basename.endsWith(`.${qualifier}`) || basename.endsWith(`-${qualifier}`)) score += 60
|
|
48
|
+
}
|
|
49
|
+
const line = symbolLine(node)
|
|
50
|
+
if (file === endpoint.file && line >= endpoint.line && line - endpoint.line <= 250) score += 20
|
|
51
|
+
return {node, score}
|
|
52
|
+
})
|
|
53
|
+
.sort((a, b) => b.score - a.score || String(a.node.id).localeCompare(String(b.node.id)))
|
|
54
|
+
if (!scored.length) return []
|
|
55
|
+
const best = scored[0].score
|
|
56
|
+
return scored.filter((item) => item.score === best).map((item) => item.node)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function traceCalls(graph, start, {maxDepth, maxNodes, includeClassified, repoRoot}) {
|
|
60
|
+
const classifier = createPathClassifier(repoRoot)
|
|
61
|
+
const allowed = (node) => {
|
|
62
|
+
if (!node?.source_file) return false
|
|
63
|
+
if (includeClassified) return true
|
|
64
|
+
const info = classifier.explain(node.source_file, {content: ''})
|
|
65
|
+
return !info?.excluded && !NON_PRODUCT.some((name) => hasPathClass(info, name))
|
|
66
|
+
}
|
|
67
|
+
const queue = [{id: String(start.id), depth: 0}]
|
|
68
|
+
const seen = new Set([String(start.id)])
|
|
69
|
+
const edges = []
|
|
70
|
+
let truncated = false
|
|
71
|
+
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
72
|
+
const current = queue[cursor]
|
|
73
|
+
if (current.depth >= maxDepth) continue
|
|
74
|
+
const outgoing = (graph.out.get(current.id) || [])
|
|
75
|
+
.filter((edge) => edge.relation === 'calls' && edge.typeOnly !== true && edge.compileOnly !== true && edge.barrelProxy !== true)
|
|
76
|
+
.map((edge) => ({edge, target: graph.byId.get(String(edge.id))}))
|
|
77
|
+
.filter(({target}) => target && String(target.id).includes('#') && allowed(target))
|
|
78
|
+
.sort((a, b) => (Number(a.edge.line) || 0) - (Number(b.edge.line) || 0) || String(a.target.id).localeCompare(String(b.target.id)))
|
|
79
|
+
const unique = new Set()
|
|
80
|
+
for (const {edge, target} of outgoing) {
|
|
81
|
+
const targetId = String(target.id)
|
|
82
|
+
if (unique.has(targetId)) continue
|
|
83
|
+
unique.add(targetId)
|
|
84
|
+
if (edges.length >= maxNodes - 1) { truncated = true; break }
|
|
85
|
+
edges.push({
|
|
86
|
+
from: current.id,
|
|
87
|
+
to: targetId,
|
|
88
|
+
depth: current.depth + 1,
|
|
89
|
+
relation: edge.relation,
|
|
90
|
+
provenance: edge.provenance || 'UNKNOWN',
|
|
91
|
+
line: Number(edge.line) || symbolLine(graph.byId.get(current.id)) || 1,
|
|
92
|
+
})
|
|
93
|
+
if (!seen.has(targetId)) {
|
|
94
|
+
seen.add(targetId)
|
|
95
|
+
queue.push({id: targetId, depth: current.depth + 1})
|
|
96
|
+
}
|
|
97
|
+
if (unique.size >= 6) { truncated = outgoing.length > unique.size; break }
|
|
98
|
+
}
|
|
99
|
+
if (truncated && edges.length >= maxNodes - 1) break
|
|
100
|
+
}
|
|
101
|
+
return {edges, truncated, nodeCount: seen.size}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function endpointLine(endpoint) {
|
|
105
|
+
const mount = endpoint.mountChain?.length
|
|
106
|
+
? endpoint.mountChain.map((item) => `${item.file}:${item.line} ${item.path}`).join(' → ')
|
|
107
|
+
: 'no static router mount chain'
|
|
108
|
+
return `${endpoint.method} ${endpoint.path} → ${endpoint.handler || 'inline/unknown'} (${endpoint.file}:${endpoint.line}; ${endpoint.mountState}/${endpoint.confidence}; declared ${endpoint.declaredPath}; ${mount})`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function tTraceEndpoint(graph, args, ctx) {
|
|
112
|
+
if (!ctx?.repoRoot) return 'Endpoint tracing needs the repo root (not provided to this server).'
|
|
113
|
+
const inventory = analyzeEndpointInventory(ctx.repoRoot, codeFiles(graph))
|
|
114
|
+
const candidates = endpointCandidates(inventory, args)
|
|
115
|
+
if (!candidates.length) return toolResult(`No endpoint matched ${args.method ? `${String(args.method).toUpperCase()} ` : ''}${args.path || '(missing path)'}.`, {
|
|
116
|
+
status: 'NOT_FOUND', query: {path: args.path || null, method: args.method || null}, inventory: inventory.stats,
|
|
117
|
+
})
|
|
118
|
+
if (candidates.length > 1) return toolResult([
|
|
119
|
+
`Endpoint query is ambiguous (${candidates.length} matches). Pass the exact composed path and method:`,
|
|
120
|
+
...candidates.slice(0, 20).map((endpoint) => ` ${endpointLine(endpoint)}`),
|
|
121
|
+
].join('\n'), {status: 'AMBIGUOUS', candidates: candidates.slice(0, 20), inventory: inventory.stats}, {
|
|
122
|
+
completeness: {status: 'PARTIAL', reason: 'ambiguous endpoint'},
|
|
123
|
+
})
|
|
124
|
+
const endpoint = candidates[0]
|
|
125
|
+
const handlers = handlerCandidates(graph, endpoint)
|
|
126
|
+
if (handlers.length !== 1) return toolResult([
|
|
127
|
+
`Endpoint resolved, but its handler symbol is ${handlers.length ? 'ambiguous' : 'not present in the graph'}:`,
|
|
128
|
+
` ${endpointLine(endpoint)}`,
|
|
129
|
+
...handlers.slice(0, 12).map((node) => ` candidate ${node.label || node.id} (${node.source_file}:${symbolLine(node) || '?'}) [${node.id}]`),
|
|
130
|
+
].join('\n'), {status: handlers.length ? 'AMBIGUOUS_HANDLER' : 'HANDLER_NOT_FOUND', endpoint, handlers}, {
|
|
131
|
+
completeness: {status: 'PARTIAL', reason: handlers.length ? 'ambiguous handler symbol' : 'handler symbol not found'},
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const maxDepth = Math.max(1, Math.min(4, Number(args.max_depth) || 3))
|
|
135
|
+
const maxNodes = Math.max(2, Math.min(40, Number(args.max_nodes) || 20))
|
|
136
|
+
const contextLines = Math.max(0, Math.min(6, Number(args.context_lines) || 2))
|
|
137
|
+
const maxExcerpts = Math.max(0, Math.min(12, Number(args.max_excerpts) || 6))
|
|
138
|
+
const handler = handlers[0]
|
|
139
|
+
const traced = traceCalls(graph, handler, {
|
|
140
|
+
maxDepth, maxNodes, includeClassified: args.include_classified === true, repoRoot: ctx.repoRoot,
|
|
141
|
+
})
|
|
142
|
+
const excerpts = traced.edges.slice(0, maxExcerpts).map((edge) => {
|
|
143
|
+
const source = graph.byId.get(edge.from)
|
|
144
|
+
return sourceExcerpt(ctx.repoRoot, source?.source_file, edge.line, contextLines)
|
|
145
|
+
}).filter(Boolean)
|
|
146
|
+
const lines = [
|
|
147
|
+
`Endpoint trace (${traced.truncated ? 'PARTIAL' : 'COMPLETE'}; depth ≤${maxDepth}, nodes ${traced.nodeCount}/${maxNodes}):`,
|
|
148
|
+
` ${endpointLine(endpoint)}`,
|
|
149
|
+
` handler ${handler.label || handler.id} (${handler.source_file}:${symbolLine(handler) || '?'}) [${handler.id}]`,
|
|
150
|
+
traced.edges.length ? 'Call graph:' : 'Call graph: no outgoing call edges from the resolved handler.',
|
|
151
|
+
...traced.edges.map((edge) => {
|
|
152
|
+
const from = graph.byId.get(edge.from), to = graph.byId.get(edge.to)
|
|
153
|
+
return ` ${' '.repeat(Math.max(0, edge.depth - 1))}${from?.label || edge.from} → ${to?.label || edge.to} (${from?.source_file}:${edge.line}; ${edge.provenance})`
|
|
154
|
+
}),
|
|
155
|
+
...excerpts.flatMap((excerpt) => ['', `Call-site excerpt (${excerpt.file}:${excerpt.startLine}-${excerpt.endLine}, focus ${excerpt.focusLine}):`, excerpt.text]),
|
|
156
|
+
]
|
|
157
|
+
return toolResult(lines.join('\n'), {
|
|
158
|
+
status: traced.truncated ? 'PARTIAL' : 'COMPLETE', endpoint, handler: {
|
|
159
|
+
id: String(handler.id), label: handler.label || String(handler.id), file: handler.source_file, line: symbolLine(handler),
|
|
160
|
+
}, trace: traced, excerpts, inventory: inventory.stats,
|
|
161
|
+
}, {completeness: {status: traced.truncated ? 'PARTIAL' : 'COMPLETE', reason: traced.truncated ? 'bounded trace cap reached' : 'bounded outgoing call graph exhausted'}})
|
|
162
|
+
}
|
package/src/mcp/tools-graph.mjs
CHANGED
|
@@ -180,7 +180,7 @@ export function tQueryGraph(g, {
|
|
|
180
180
|
// Callers can opt back into augmentation when they explicitly want both behaviors.
|
|
181
181
|
const automatic = pinned.seeds.length && augment_seeds !== true
|
|
182
182
|
? []
|
|
183
|
-
: findSeeds(g, question, Math.max(0,
|
|
183
|
+
: findSeeds(g, question, Math.max(0, 6 - pinned.seeds.length), {repoRoot: toolCtx.repoRoot || null})
|
|
184
184
|
const seeds = [...pinned.seeds, ...automatic.filter((node) => !pinned.seeds.some((seed) => String(seed.id) === String(node.id)))]
|
|
185
185
|
if (!seeds.length) return `No nodes matched "${question}".`
|
|
186
186
|
const maxDepth = Math.max(1, Math.min(6, Number(depth) || 3))
|