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.
@@ -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
  }
@@ -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
- group = {id, label: labelOf(g, id), file: fileOf(g, id), relation, count: 0, lines: []}
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
- lines.push(` ${group.count}× ${group.relation} ${group.label} [${group.file}${sites}]`)
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
- if (inspection.source.definition) source.push({role: 'Definition', ...inspection.source.definition})
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
- if (source.length >= maxSourceFiles) break
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
+ }
@@ -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, 8 - pinned.seeds.length), {repoRoot: toolCtx.repoRoot || null})
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))