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.
@@ -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))
@@ -9,7 +9,7 @@ import {classifyChangeImpact} from '../analysis/change-classification.js'
9
9
  import {compareAuditDebt, normalizeAuditScopeFiles, scopeAuditFindings} from '../analysis/audit-debt.js'
10
10
  import {summarizeFindings} from '../analysis/findings.js'
11
11
  import {summarizeCommunities, aggregateGraph} from '../analysis/graph-analysis.js'
12
- import {detectEndpoints} from '../analysis/endpoints.js'
12
+ import {analyzeEndpointInventory} from '../analysis/endpoints.js'
13
13
  import {computeStaticTestReachability} from '../analysis/static-test-reachability.js'
14
14
  import {computeDeadCodeReview} from '../analysis/dead-code-review.js'
15
15
  import {computeHotPathReview} from '../analysis/hot-path-review.js'
@@ -86,7 +86,10 @@ export function tFindDuplicates(g, args, ctx) {
86
86
  const suppressionNote = suppressed && !includeClassified
87
87
  ? ` ${suppressed} fragment(s) classified as tests/e2e/generated/mock/story/docs/benchmark/temp or matched by .weavatrix.json exclude were suppressed; pass include_classified:true (and include_tests:true for tests) to inspect them explicitly.`
88
88
  : ''
89
- if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${smallPolicy}${suppressionNote}`
89
+ const boilerplateNote = analysis.boilerplateSuppressed
90
+ ? ` ${analysis.boilerplateSuppressed} all-router framework boilerplate group(s) were suppressed; pass include_boilerplate:true to inspect them.`
91
+ : ''
92
+ if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${smallPolicy}${suppressionNote}${boilerplateNote}`
90
93
  const top = groups.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
91
94
  const lines = top.map((grp, k) => {
92
95
  const isStr = grp.members.some((f) => f.kind === 'string')
@@ -94,7 +97,7 @@ export function tFindDuplicates(g, args, ctx) {
94
97
  const sites = grp.members.slice(0, 8).map((f) => ` ${f.file}:${f.start}-${f.end}`)
95
98
  return [head, ...sites].join('\n')
96
99
  })
97
- return `Found ${groups.length} clone group(s) (${mode} mode, ≥${simMin}%, ≥${tokMin} tok${includeStrings ? ', incl. large string literals' : ''}). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nUse read_source on any two sites to compare, then extract shared logic.${smallPolicy}${suppressionNote}`
100
+ return `Found ${groups.length} clone group(s) (${mode} mode, ≥${simMin}%, ≥${tokMin} tok${includeStrings ? ', incl. large string literals' : ''}). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nUse read_source on any two sites to compare, then extract shared logic.${smallPolicy}${suppressionNote}${boilerplateNote}`
98
101
  }
99
102
 
100
103
  // Focused dead-code review queue. Unlike the broad run_audit surface, this includes functions and
@@ -197,6 +200,7 @@ export function tFindDeadCode(g, args, ctx) {
197
200
  }
198
201
 
199
202
  const SEVERITY_RANK = {critical: 0, high: 1, medium: 2, low: 3, info: 4}
203
+ const AUDIT_NON_PRODUCT_CLASSES = ['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp']
200
204
 
201
205
  export function formatAuditFinding(f) {
202
206
  const where = f.file ? ` (${f.file}${f.symbol ? ` ${f.symbol}` : ''})` : f.package ? ` (pkg ${f.package}${f.version ? `@${f.version}` : ''}${f.manifest ? `; ${f.manifest}` : ''})` : ''
@@ -206,12 +210,40 @@ export function formatAuditFinding(f) {
206
210
  return ` [${f.severity}/${f.confidence || '?'}] ${f.rule}: ${f.title}${where}${f.reason ? `\n reason: ${f.reason}` : ''}${verification}${f.cycleRoute ? `\n route: ${f.cycleRoute}` : ''}${f.fixHint ? `\n fix: ${f.fixHint}` : ''}`
207
211
  }
208
212
 
209
- const auditFilter = (audit, args, findings = audit.findings) => {
213
+ const auditFindingPaths = (finding) => {
214
+ const paths = []
215
+ if (finding?.file) paths.push(String(finding.file))
216
+ if (Array.isArray(finding?.files)) paths.push(...finding.files.map(String))
217
+ if (finding?.cycleRoute) paths.push(...String(finding.cycleRoute).split(/\s*(?:→|→|->)\s*/))
218
+ return [...new Set(paths.map((file) => file.replace(/\\/g, '/').trim()).filter(Boolean))]
219
+ }
220
+
221
+ export function auditFindingPathScope(findings, {includeClassified = false, repoRoot = null} = {}) {
222
+ const all = Array.isArray(findings) ? findings : []
223
+ if (includeClassified) return {findings: all, suppressed: 0}
224
+ const classifier = createPathClassifier(repoRoot)
225
+ const cache = new Map()
226
+ const classified = (file) => {
227
+ if (!cache.has(file)) cache.set(file, classifier.explain(file, {content: ''}))
228
+ const info = cache.get(file)
229
+ return info?.excluded || AUDIT_NON_PRODUCT_CLASSES.some((name) => hasPathClass(info, name))
230
+ }
231
+ const kept = all.filter((finding) => {
232
+ const paths = auditFindingPaths(finding)
233
+ return !paths.length || paths.some((file) => !classified(file))
234
+ })
235
+ return {findings: kept, suppressed: all.length - kept.length}
236
+ }
237
+
238
+ const DEPENDENCY_AUDIT_RULES = new Set(['unused-dep', 'missing-dep', 'duplicate-dep', 'unresolved-import', 'lockfile-drift'])
239
+ export const isDependencyAuditFinding = (finding) => DEPENDENCY_AUDIT_RULES.has(String(finding?.rule || ''))
240
+
241
+ const auditFilter = (audit, args, findings = audit.findings, repoRoot = null) => {
210
242
  const minSev = SEVERITY_RANK[args.min_severity] ?? 4
211
243
  const category = args.category ? String(args.category) : null
212
- return findings
244
+ return auditFindingPathScope(findings, {includeClassified: args.include_classified === true, repoRoot}).findings
213
245
  .filter((finding) => (SEVERITY_RANK[finding.severity] ?? 4) <= minSev)
214
- .filter((finding) => !category || finding.category === category)
246
+ .filter((finding) => !category || (category === 'dependencies' ? isDependencyAuditFinding(finding) : finding.category === category))
215
247
  }
216
248
 
217
249
  const auditChecksLine = (audit) => {
@@ -229,11 +261,12 @@ const auditConventionLines = (audit) => {
229
261
  ].filter((line) => line != null)
230
262
  }
231
263
 
232
- const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = null) => {
264
+ const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = null, repoRoot = null) => {
233
265
  const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
234
- const filtered = auditFilter(audit, args, findings)
266
+ const pathScope = auditFindingPathScope(findings, {includeClassified: args.include_classified === true, repoRoot})
267
+ const filtered = auditFilter(audit, args, pathScope.findings, repoRoot)
235
268
  const shown = filtered.slice(0, max)
236
- const summary = summarizeFindings(findings)
269
+ const summary = summarizeFindings(pathScope.findings)
237
270
  const sev = summary.bySeverity
238
271
  const bycat = summary.byCategory
239
272
  const deps = audit.dependencyReport || {}
@@ -241,8 +274,9 @@ const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = n
241
274
  heading,
242
275
  `Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
243
276
  ...auditConventionLines(audit),
244
- `Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}. npm per-finding manifest/source/config verification: ${deps.perFindingVerification ? 'AVAILABLE' : 'UNAVAILABLE'}.`,
277
+ `Dependency manifests: ${deps.status || 'UNKNOWN'} checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}. npm per-finding manifest/source/config verification: ${deps.perFindingVerification ? 'AVAILABLE' : 'UNAVAILABLE'}.`,
245
278
  `Scoped severity: critical ${sev.critical}, high ${sev.high}, medium ${sev.medium}, low ${sev.low}, info ${sev.info}. Scoped categories: unused ${bycat.unused}, structure ${bycat.structure}, vulnerability ${bycat.vulnerability}, malware ${bycat.malware}.`,
279
+ pathScope.suppressed ? `Path policy: production-first; suppressed ${pathScope.suppressed} finding(s) whose evidence is entirely test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded. Pass include_classified:true to include them.` : 'Path policy: production-first; no classified-only findings were suppressed.',
246
280
  `Repository-level ${auditChecksLine(audit)}`,
247
281
  '',
248
282
  `Showing ${shown.length} of ${filtered.length} finding(s)${args.category ? ` in category "${args.category}"` : ''}${args.min_severity ? ` at ≥${args.min_severity}` : ''}:`,
@@ -353,12 +387,12 @@ async function runAuditWithBaseline(args, ctx, currentGraph) {
353
387
  const comparison = compareAuditDebt(currentAudit, baseline.value, changedFiles, {completeChangeSet})
354
388
  const mode = ['new', 'existing', 'all'].includes(args.debt) ? args.debt : 'new'
355
389
  const selectedRaw = mode === 'new' ? comparison.new : mode === 'existing' ? comparison.existing : comparison.all
356
- const selected = auditFilter(currentAudit, args, selectedRaw)
390
+ const selected = auditFilter(currentAudit, args, selectedRaw, ctx.repoRoot)
357
391
  const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
358
392
  const shown = selected.slice(0, max)
359
393
  const optional = comparison.optional.checks.map((check) => `${check.name.toUpperCase()} UNCOMPARABLE (current ${check.current}; baseline ${check.baseline})`).join('; ')
360
394
  const stateOf = (finding) => mode === 'all' ? finding.debtState : mode
361
- const fixedShown = auditFilter(baseline.value, args, comparison.fixed).slice(0, Math.min(10, max))
395
+ const fixedShown = auditFilter(baseline.value, args, comparison.fixed, ctx.repoRoot).slice(0, Math.min(10, max))
362
396
  const text = [
363
397
  `${mode.toUpperCase()} DEBT — deterministic internal audit vs ${resolved.ref} (${resolved.commit.slice(0, 12)})`,
364
398
  `Changed scope: ${changedFiles.length} file(s) from ${scopeSource}${changedFiles.length ? ` — ${changedFiles.slice(0, 12).join(', ')}${changedFiles.length > 12 ? ', …' : ''}` : ' — working tree matches the baseline'}.`,
@@ -411,38 +445,16 @@ export async function tRunAudit(g, args, ctx) {
411
445
  if (!normalized.ok) return `Changed-scope audit invalid: ${normalized.error}.`
412
446
  const scoped = scopeAuditFindings(audit.findings, normalized.files)
413
447
  const text = formatOrdinaryAudit(audit, args, scoped,
414
- `CHANGED-SCOPE ONLY — ${normalized.files.length} explicitly supplied file(s); no baseline was provided, so these findings are not classified as new, existing, or fixed.`)
448
+ `CHANGED-SCOPE ONLY — ${normalized.files.length} explicitly supplied file(s); no baseline was provided, so these findings are not classified as new, existing, or fixed.`, ctx.repoRoot)
415
449
  return toolResult(text, {
416
450
  status: 'COMPLETE',
417
451
  mode: 'changed-scope',
418
452
  scope: {files: normalized.files, source: 'explicit changed_files'},
419
453
  comparison: {status: 'UNAVAILABLE', reason: 'base_ref was not provided; changed-scope is not a new-debt claim'},
420
- findings: auditFilter(audit, args, scoped),
454
+ findings: auditFilter(audit, args, scoped, ctx.repoRoot),
421
455
  }, {completeness: {status: 'COMPLETE'}})
422
456
  }
423
- const minSev = SEVERITY_RANK[args.min_severity] ?? 4
424
- const cat = args.category ? String(args.category) : null
425
- const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
426
- const filtered = audit.findings
427
- .filter((f) => (SEVERITY_RANK[f.severity] ?? 4) <= minSev)
428
- .filter((f) => !cat || f.category === cat)
429
- const shown = filtered.slice(0, max)
430
- const sev = audit.summary.bySeverity
431
- const bycat = audit.summary.byCategory
432
- const deps = audit.dependencyReport || {}
433
- const check = (name, state) => `${name} ${state?.status || 'ERROR'}${state?.detail ? ` — ${state.detail}` : ''}`
434
- return [
435
- `Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
436
- `Severity: critical ${sev.critical}, high ${sev.high}, medium ${sev.medium}, low ${sev.low}, info ${sev.info}. Categories: unused ${bycat.unused}, structure ${bycat.structure}, vulnerability ${bycat.vulnerability}, malware ${bycat.malware}.`,
437
- `Structure: ${audit.structureReport?.runtimeCycles ?? audit.structureReport?.cycles ?? 0} runtime cycle(s), ${audit.structureReport?.compileTimeCouplings ?? audit.structureReport?.typeCouplings ?? 0} compile-time coupling group(s), ${audit.structureReport?.orphans ?? 0} orphan(s); import edges: ${audit.structureReport?.runtimeImportEdges ?? audit.structureReport?.importEdges ?? 0} runtime + ${audit.structureReport?.typeOnlyImportEdges ?? 0} type-only + ${audit.structureReport?.compileOnlyImportEdges ?? 0} compile-only. Dead: ${audit.deadReport.deadFiles} file(s), ${audit.deadReport.unusedExports} unused export(s).`,
438
- `Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}. npm per-finding manifest/source/config verification: ${deps.perFindingVerification ? 'AVAILABLE' : 'UNAVAILABLE'}.`,
439
- ...auditConventionLines(audit),
440
- `Checks: ${check('OSV', audit.checks?.osv)}; ${check('malware', audit.checks?.malware)}. A NOT_CHECKED/PARTIAL/ERROR check is incomplete or unknown, never a clean zero.`,
441
- ``,
442
- `Showing ${shown.length} of ${filtered.length} finding(s)${cat ? ` in category "${cat}"` : ''}${args.min_severity ? ` at ≥${args.min_severity}` : ''}:`,
443
- ...shown.map(formatAuditFinding),
444
- filtered.length > shown.length ? ` … +${filtered.length - shown.length} more (raise max_findings or filter by category/min_severity)` : null,
445
- ].filter((x) => x != null).join('\n')
457
+ return formatOrdinaryAudit(audit, args, audit.findings, null, ctx.repoRoot)
446
458
  }
447
459
 
448
460
  // Named module clusters: graph communities labeled by their dominant folder instead of bare numbers.
@@ -665,12 +677,29 @@ export function tListEndpoints(g, args, ctx) {
665
677
  .filter((n) => !String(n.id).includes('#') && n.source_file && n.file_type === 'code')
666
678
  .map((n) => n.source_file)
667
679
  )]
668
- const eps = detectEndpoints(ctx.repoRoot, codeFiles)
680
+ const inventory = analyzeEndpointInventory(ctx.repoRoot, codeFiles)
681
+ let eps = inventory.endpoints
682
+ const method = args.method ? String(args.method).toUpperCase() : null
683
+ const path = args.path ? String(args.path) : null
684
+ if (method) eps = eps.filter((endpoint) => endpoint.method === method)
685
+ if (path) eps = eps.filter((endpoint) => endpoint.path === path || endpoint.path.endsWith(path))
669
686
  if (!eps.length) return 'No HTTP endpoints detected in the indexed code files.'
670
687
  const max = Math.max(1, Math.min(300, Number(args.max_results) || 100))
671
688
  const shown = eps.slice(0, max)
672
- return [
673
- `${eps.length} endpoint(s) detected${eps.length > shown.length ? `, showing ${shown.length}` : ''}:`,
674
- ...shown.map((e) => ` ${e.method.toUpperCase().padEnd(6)} ${e.path}${e.handler ? ` → ${e.handler}` : ''} (${e.file}${e.line ? `:${e.line}` : ''})`),
689
+ const stats = inventory.stats
690
+ const text = [
691
+ `${eps.length} endpoint(s) matched${eps.length > shown.length ? `, showing ${shown.length}` : ''}. Inventory: ${stats.declaredRoutes} declaration(s); ${stats.reachableStaticRoutes} statically reachable composed route(s); ${stats.localDeclarations} local/root declaration candidate(s); ${stats.staticMounts} static router mount(s)${stats.truncated ? `; TRUNCATED at ${stats.maxEndpoints}` : ''}.`,
692
+ ...shown.map((e) => {
693
+ const via = e.mountChain?.length
694
+ ? `\n declared ${e.declaredPath} in ${e.file}${e.line ? `:${e.line}` : ''}; mount chain ${e.mountChain.map((mount) => `${mount.file}:${mount.line} ${mount.path}`).join(' → ')}`
695
+ : ''
696
+ return ` ${e.method.toUpperCase().padEnd(6)} ${e.path}${e.handler ? ` → ${e.handler}` : ''} (${e.file}${e.line ? `:${e.line}` : ''}; ${e.mountState}/${e.confidence})${via}`
697
+ }),
675
698
  ].join('\n')
699
+ return toolResult(text, {
700
+ filters: {method, path},
701
+ stats,
702
+ endpoints: shown,
703
+ page: {shown: shown.length, total: eps.length, truncated: eps.length > shown.length || stats.truncated},
704
+ }, {completeness: {status: stats.truncated ? 'PARTIAL' : 'COMPLETE', reason: stats.truncated ? `endpoint cap ${stats.maxEndpoints} reached` : 'all indexed code files scanned'}})
676
705
  }
@@ -27,7 +27,7 @@ function candidateNodes(g, query, limit = 20) {
27
27
  }))
28
28
  }
29
29
 
30
- function excerpt(repoRoot, file, focusLine, contextLines) {
30
+ export function sourceExcerpt(repoRoot, file, focusLine, contextLines) {
31
31
  const resolved = resolveRepoPath(repoRoot, file)
32
32
  if (!resolved.ok) return null
33
33
  let size
@@ -198,8 +198,8 @@ export async function tInspectSymbol(g, args = {}, ctx = {}) {
198
198
  ...(node.source_range ? {range: node.source_range} : {}),
199
199
  ...(node.complexity ? {complexity: node.complexity} : {}),
200
200
  }
201
- const definitionSource = excerpt(ctx.repoRoot, definition.file, definition.line, contextLines)
202
- const callerSources = grouped.shown.slice(0, 5).map((group) => excerpt(ctx.repoRoot, group.file, group.locations[0]?.line || 1, contextLines)).filter(Boolean)
201
+ const definitionSource = sourceExcerpt(ctx.repoRoot, definition.file, definition.line, contextLines)
202
+ const callerSources = grouped.shown.slice(0, 5).map((group) => sourceExcerpt(ctx.repoRoot, group.file, group.locations[0]?.line || 1, contextLines)).filter(Boolean)
203
203
  const result = {
204
204
  status: 'OK',
205
205
  definition,
@@ -270,6 +270,7 @@ async function main() {
270
270
  const graphSnapshot = graph
271
271
  const callCtx = mutatesTarget ? ctx : {...ctx}
272
272
  const execute = async () => {
273
+ const startedAt = performance.now()
273
274
  try {
274
275
  // A queued rebuild must see a preceding retarget's graph, while non-mutating calls stay
275
276
  // pinned to the graph that was active when their request arrived.
@@ -302,12 +303,39 @@ async function main() {
302
303
  if (schemaWarning) text += `\n\nWarning: ${schemaWarning.message}`
303
304
  if (staleLine && staleNotices.shouldShow({line: staleLine, graphPath: callCtx.graphPath, force: tool.name === 'graph_stats'})) text += `\n\n${staleLine}`
304
305
  }
305
- const response = {content: [{type: 'text', text}]}
306
+ const structuredBytes = normalized.structured ? Buffer.byteLength(JSON.stringify(normalized.structured)) : 0
307
+ const textBytes = Buffer.byteLength(text)
308
+ const durationMs = Math.max(0, performance.now() - startedAt)
309
+ const response = {
310
+ content: [{type: 'text', text}],
311
+ _meta: {
312
+ 'weavatrix/metrics': {
313
+ schemaVersion: 'weavatrix.metrics.v1',
314
+ durationMs: Math.round(durationMs * 10) / 10,
315
+ textBytes,
316
+ structuredBytes,
317
+ estimatedOutputTokens: Math.ceil((textBytes + structuredBytes) / 4),
318
+ graphFreshness: staleLine ? 'stale' : (refresh?.error ? 'stale' : 'fresh'),
319
+ graphUpdate: refresh?.kind || 'none',
320
+ graphRevision: refresh?.revision || callGraph?.graphRevision || null,
321
+ cache: refreshesGraph ? (refresh?.kind === 'none' ? 'graph-hit' : 'graph-refreshed') : 'not-applicable',
322
+ },
323
+ },
324
+ }
306
325
  if (normalized.structured) response.structuredContent = normalized.structured
307
326
  return reply(id, response)
308
327
  } catch (e) {
309
328
  log(`tool ${params?.name} threw: ${e.stack || e.message}`)
310
- return reply(id, {content: [{type: 'text', text: `Tool error: ${e.message}`}], isError: true})
329
+ const text = `Tool error: ${e.message}`
330
+ return reply(id, {
331
+ content: [{type: 'text', text}], isError: true,
332
+ _meta: {'weavatrix/metrics': {
333
+ schemaVersion: 'weavatrix.metrics.v1', durationMs: Math.round(Math.max(0, performance.now() - startedAt) * 10) / 10,
334
+ textBytes: Buffer.byteLength(text), structuredBytes: 0,
335
+ estimatedOutputTokens: Math.ceil(Buffer.byteLength(text) / 4), graphFreshness: 'unknown',
336
+ graphUpdate: 'none', graphRevision: graphSnapshot?.graphRevision || null, cache: 'not-applicable',
337
+ }},
338
+ })
311
339
  }
312
340
  }
313
341
  if (!mutatesTarget && !refreshesGraph) return execute()
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
2
  import { spawnSync } from 'node:child_process'
3
- import { extname, join, relative } from 'node:path'
3
+ import { extname, isAbsolute, join, relative, resolve } from 'node:path'
4
4
  import { resolveRepoPath } from './repo-path.js'
5
5
  import { childProcessEnv } from './child-env.js'
6
6
  import { toolResult } from './mcp/tool-result.mjs'
@@ -11,21 +11,24 @@ const MAX_SEARCH_FILE_BYTES = 1024 * 1024
11
11
  const MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024
12
12
  const MAX_SOURCE_CONTEXT_LINES = 1000
13
13
 
14
- function rgSearch(repoRoot, resolveRg, query, { isRegex, glob, maxResults }) {
14
+ function rgSearch(repoRoot, resolveRg, query, { isRegex, glob, maxResults }, spawnRg = spawnSync) {
15
15
  const rg = resolveRg()
16
16
  if (!rg) return null
17
17
  const args = ['--line-number', '--no-heading', '--color', 'never', '--hidden', '-g', '!.git/**', '-m', '100', '-i']
18
18
  if (!isRegex) args.push('--fixed-strings')
19
19
  if (glob) args.push('-g', glob)
20
- args.push('--', query, repoRoot)
21
- const res = spawnSync(rg, args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: 15000, env: childProcessEnv() })
20
+ // Run from the repository root and search `.` so path globs such as `src/**` are evaluated
21
+ // against repository-relative paths on Windows too. Passing an absolute search root makes rg
22
+ // compare the glob with a drive-prefixed path and silently return no matches.
23
+ args.push('--', query, '.')
24
+ const res = spawnRg(rg, args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: 15000, env: childProcessEnv() })
22
25
  if (res.status !== 0 && res.status !== 1) return null
23
26
  const out = []
24
27
  for (const line of (res.stdout || '').split(/\r?\n/)) {
25
28
  const match = line.match(/^(.*?):(\d+):(.*)$/)
26
29
  if (!match) continue
27
30
  out.push({
28
- file: relative(repoRoot, match[1]).replace(/\\/g, '/'),
31
+ file: relative(repoRoot, isAbsolute(match[1]) ? match[1] : resolve(repoRoot, match[1])).replace(/\\/g, '/'),
29
32
  line: Number(match[2]),
30
33
  text: match[3].trim().slice(0, 300),
31
34
  })
@@ -87,12 +90,12 @@ function nodeGrep(repoRoot, query, { isRegex, glob, maxResults }) {
87
90
  return out
88
91
  }
89
92
 
90
- export function searchCode({ repoRoot, resolveRg }, { query, is_regex = false, max_results = 40, glob } = {}) {
93
+ export function searchCode({ repoRoot, resolveRg, spawnRg = spawnSync }, { query, is_regex = false, max_results = 40, glob } = {}) {
91
94
  if (!query) return 'Provide a "query" string.'
92
95
  if (!repoRoot || !existsSync(repoRoot)) return 'Source search unavailable: repo root not provided to this MCP server.'
93
96
  const max = Math.max(1, Math.min(200, Number(max_results) || 40))
94
97
  const opts = { isRegex: !!is_regex, glob: glob || null, maxResults: max }
95
- let matches = rgSearch(repoRoot, resolveRg, query, opts)
98
+ let matches = rgSearch(repoRoot, resolveRg, query, opts, spawnRg)
96
99
  const engine = matches ? 'ripgrep' : 'node'
97
100
  if (!matches) matches = nodeGrep(repoRoot, query, opts)
98
101
  const what = is_regex ? `/${query}/i` : `"${query}"`