weavatrix 0.3.12 → 0.3.14

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.
Files changed (34) hide show
  1. package/README.md +9 -4
  2. package/docs/releases/v0.3.13.md +34 -0
  3. package/docs/releases/v0.3.14.md +37 -0
  4. package/package.json +3 -1
  5. package/src/analysis/coverage-reports-tarpaulin.js +112 -0
  6. package/src/analysis/coverage-reports.js +13 -2
  7. package/src/analysis/dead-code-review/policy.js +1 -1
  8. package/src/analysis/duplicate-groups.js +1 -1
  9. package/src/analysis/endpoints/common.js +18 -1
  10. package/src/analysis/endpoints/extract.js +13 -0
  11. package/src/analysis/graph-analysis.aggregate.js +4 -3
  12. package/src/analysis/graph-analysis.refs.js +48 -0
  13. package/src/analysis/hot-path-review.js +1 -1
  14. package/src/analysis/internal-audit.reach.js +28 -0
  15. package/src/analysis/internal-audit.run.js +1 -1
  16. package/src/analysis/static-test-reachability.js +1 -1
  17. package/src/mcp/architecture-starter.mjs +1 -1
  18. package/src/mcp/catalog.mjs +9 -5
  19. package/src/mcp/company-contract-page.mjs +142 -0
  20. package/src/mcp/company-contract-reconcile.mjs +87 -0
  21. package/src/mcp/evidence-snapshot.duplicates.mjs +1 -1
  22. package/src/mcp/graph/context-seeds.mjs +0 -7
  23. package/src/mcp/graph/tools-query.mjs +34 -7
  24. package/src/mcp/health/audit-format.mjs +2 -2
  25. package/src/mcp/health/audit.mjs +29 -4
  26. package/src/mcp/health/duplicates.mjs +43 -7
  27. package/src/mcp/health/endpoints.mjs +1 -1
  28. package/src/mcp/health/structure.mjs +4 -3
  29. package/src/mcp/tools-company.mjs +52 -76
  30. package/src/mcp/tools-context.mjs +1 -1
  31. package/src/mcp/tools-endpoints.mjs +1 -1
  32. package/src/mcp/tools-graph-hubs.mjs +1 -1
  33. package/src/mcp/tools-verified-change.mjs +11 -4
  34. package/src/path-classification.js +7 -1
@@ -0,0 +1,142 @@
1
+ import {createHash} from 'node:crypto'
2
+
3
+ const DEFAULT_PAGE_SIZE = 10
4
+ const MAX_PAGE_SIZE = 50
5
+ const DEFAULT_RELATED_LIMIT = 5
6
+ const MAX_RELATED_LIMIT = 25
7
+
8
+ export class ContractCursorError extends Error {
9
+ constructor(message) {
10
+ super(message)
11
+ this.name = 'ContractCursorError'
12
+ this.code = 'INVALID_CURSOR'
13
+ }
14
+ }
15
+
16
+ const boundedInteger = (value, fallback, minimum, maximum) => {
17
+ const parsed = Number(value)
18
+ return Number.isInteger(parsed) ? Math.max(minimum, Math.min(maximum, parsed)) : fallback
19
+ }
20
+
21
+ const pageFingerprint = (parts) => createHash('sha256')
22
+ .update(JSON.stringify(parts))
23
+ .digest('hex')
24
+ .slice(0, 24)
25
+
26
+ const encodeCursor = (fingerprint, offset) => Buffer
27
+ .from(JSON.stringify({v: 1, fingerprint, offset}), 'utf8')
28
+ .toString('base64url')
29
+
30
+ function decodeCursor(raw, fingerprint, total) {
31
+ if (!raw) return 0
32
+ try {
33
+ const parsed = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'))
34
+ if (parsed?.v !== 1 || parsed.fingerprint !== fingerprint) {
35
+ throw new ContractCursorError('cursor does not belong to this repository revision and filter set')
36
+ }
37
+ if (!Number.isInteger(parsed.offset) || parsed.offset < 0 || parsed.offset > total) {
38
+ throw new ContractCursorError('cursor offset is outside the available evidence')
39
+ }
40
+ return parsed.offset
41
+ } catch (error) {
42
+ if (error instanceof ContractCursorError) throw error
43
+ throw new ContractCursorError('cursor is malformed')
44
+ }
45
+ }
46
+
47
+ const sample = (items, limit) => ({
48
+ total: Array.isArray(items) ? items.length : 0,
49
+ items: Array.isArray(items) ? items.slice(0, limit) : [],
50
+ truncated: Array.isArray(items) && items.length > limit,
51
+ })
52
+
53
+ function compactEndpoint(endpoint, relatedLimit) {
54
+ const affected = endpoint?.affected || {}
55
+ return {
56
+ kind: 'http-endpoint',
57
+ backend: endpoint.backend,
58
+ method: endpoint.method,
59
+ path: endpoint.path,
60
+ normalizedPath: endpoint.normalizedPath,
61
+ handler: endpoint.handler,
62
+ handlerNodeId: endpoint.handlerNodeId,
63
+ handlerResolution: endpoint.handlerResolution,
64
+ file: endpoint.file,
65
+ line: endpoint.line,
66
+ callsites: sample(endpoint.callsites, relatedLimit),
67
+ methodMismatches: endpoint.methodMismatches,
68
+ methodMismatchSites: sample(endpoint.methodMismatchSites, relatedLimit),
69
+ liveness: endpoint.liveness,
70
+ affected: {
71
+ complete: affected.complete === true,
72
+ files: sample(affected.files, relatedLimit),
73
+ screens: sample(affected.screens, relatedLimit),
74
+ modules: sample(affected.modules, relatedLimit),
75
+ traversalTruncated: affected.truncated || {},
76
+ },
77
+ }
78
+ }
79
+
80
+ function compactTransport(contract, relatedLimit) {
81
+ const affected = contract?.affected || {}
82
+ return {
83
+ kind: 'transport-contract',
84
+ transport: contract.transport,
85
+ side: contract.side,
86
+ service: contract.service,
87
+ operation: contract.operation,
88
+ name: contract.name,
89
+ file: contract.file,
90
+ line: contract.line,
91
+ detector: contract.detector,
92
+ liveness: contract.liveness,
93
+ runtimeObserved: contract.runtimeObserved === true,
94
+ callsites: sample(contract.callsites, relatedLimit),
95
+ affected: {
96
+ complete: affected.complete === true,
97
+ files: sample(affected.files, relatedLimit),
98
+ screens: sample(affected.screens, relatedLimit),
99
+ modules: sample(affected.modules, relatedLimit),
100
+ traversalTruncated: affected.truncated || {},
101
+ },
102
+ }
103
+ }
104
+
105
+ function evidenceItems(analysis, transportAnalysis) {
106
+ return [
107
+ ...(analysis.endpoints || []).map((value) => ({kind: 'http-endpoint', value})),
108
+ ...(transportAnalysis.contracts || []).map((value) => ({kind: 'transport-contract', value})),
109
+ ...(analysis.uncertain || []).map((value) => ({kind: 'http-uncertain', value})),
110
+ ...(transportAnalysis.uncertain || []).map((value) => ({kind: 'transport-uncertain', value})),
111
+ ]
112
+ }
113
+
114
+ function projectItem(item, detail, relatedLimit) {
115
+ if (detail === 'full') return {kind: item.kind, ...item.value}
116
+ if (item.kind === 'http-endpoint') return compactEndpoint(item.value, relatedLimit)
117
+ if (item.kind === 'transport-contract') return compactTransport(item.value, relatedLimit)
118
+ return {kind: item.kind, ...item.value}
119
+ }
120
+
121
+ export function paginateContractEvidence({analysis, transportAnalysis, args = {}, fingerprintParts = []}) {
122
+ const detail = args.response_detail === 'full' ? 'full' : 'compact'
123
+ const pageSize = boundedInteger(args.page_size, DEFAULT_PAGE_SIZE, 1, MAX_PAGE_SIZE)
124
+ const relatedLimit = boundedInteger(args.per_item_limit, DEFAULT_RELATED_LIMIT, 1, MAX_RELATED_LIMIT)
125
+ const all = evidenceItems(analysis, transportAnalysis)
126
+ const fingerprint = pageFingerprint(fingerprintParts)
127
+ const offset = decodeCursor(args.cursor, fingerprint, all.length)
128
+ const selected = all.slice(offset, offset + pageSize)
129
+ const nextOffset = offset + selected.length
130
+ const nextCursor = nextOffset < all.length ? encodeCursor(fingerprint, nextOffset) : null
131
+ return {
132
+ detail,
133
+ offset,
134
+ pageSize,
135
+ perItemLimit: detail === 'compact' ? relatedLimit : null,
136
+ totalItems: all.length,
137
+ returnedItems: selected.length,
138
+ hasMore: nextCursor !== null,
139
+ nextCursor,
140
+ items: selected.map((item) => projectItem(item, detail, relatedLimit)),
141
+ }
142
+ }
@@ -0,0 +1,87 @@
1
+ import {join} from 'node:path'
2
+ import {buildGraphForRepo} from '../build-graph.js'
3
+ import {persistedFreshnessMatches, repositoryFreshnessProbe} from '../graph/freshness-probe.js'
4
+ import {loadGraph} from './graph-context.mjs'
5
+
6
+ export const publicRecord = (record, alias) => ({repositoryId: record.repositoryId, label: record.label, alias})
7
+
8
+ function safeRefreshReason(record, error) {
9
+ let message = String(error instanceof Error ? error.message : error || 'graph refresh failed')
10
+ for (const path of [record.repoPath, record.graphDir]) {
11
+ if (!path) continue
12
+ message = message.split(String(path)).join(record.label || 'repository')
13
+ message = message.split(String(path).replace(/\\/g, '/')).join(record.label || 'repository')
14
+ }
15
+ return message.replace(/[\r\n]+/g, ' ').trim().slice(0, 400) || 'graph refresh failed'
16
+ }
17
+
18
+ export async function reconcileGraph(record, alias, role, graphHome) {
19
+ let registeredGraph = null
20
+ try { registeredGraph = loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}) } catch { /* refresh may repair it */ }
21
+ const buildMode = ['full', 'no-tests', 'tests-only'].includes(registeredGraph?.graphBuildMode)
22
+ ? registeredGraph.graphBuildMode
23
+ : 'full'
24
+ const precision = registeredGraph?.graphPrecisionMode === 'off' ? 'off' : 'lsp'
25
+ const freshnessProbe = repositoryFreshnessProbe(record.repoPath)
26
+ if (registeredGraph && freshnessProbe && persistedFreshnessMatches(registeredGraph, freshnessProbe, buildMode)) {
27
+ return {
28
+ record,
29
+ alias,
30
+ graph: registeredGraph,
31
+ publicStatus: {
32
+ role,
33
+ repository: publicRecord(record, alias),
34
+ buildMode,
35
+ status: 'CURRENT',
36
+ refresh: {kind: 'none', reason: 'persisted-freshness-match', changedFileCount: 0},
37
+ },
38
+ }
39
+ }
40
+ let build
41
+ try {
42
+ build = await buildGraphForRepo(record.repoPath, {
43
+ mode: buildMode,
44
+ precision,
45
+ scope: '',
46
+ outDir: record.graphDir,
47
+ graphHome,
48
+ })
49
+ } catch (error) {
50
+ build = {ok: false, error: safeRefreshReason(record, error)}
51
+ }
52
+ const refreshKind = build?.refresh?.kind || null
53
+ const changedFiles = Array.isArray(build?.refresh?.changedFiles) ? build.refresh.changedFiles : []
54
+ const publicStatus = {
55
+ role,
56
+ repository: publicRecord(record, alias),
57
+ buildMode,
58
+ status: build?.ok ? (refreshKind === 'none' ? 'CURRENT' : 'REFRESHED') : 'STALE_FALLBACK',
59
+ refresh: build?.ok ? {
60
+ kind: refreshKind || 'full',
61
+ reason: String(build?.refresh?.reason || 'graph-rebuilt'),
62
+ changedFileCount: changedFiles.length,
63
+ } : null,
64
+ }
65
+ if (build?.ok) {
66
+ try {
67
+ return {record, alias, graph: loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}), publicStatus}
68
+ } catch (error) {
69
+ const reason = `refreshed graph could not be loaded: ${safeRefreshReason(record, error)}`
70
+ return {record, alias, graph: null, reason, publicStatus: {...publicStatus, status: 'FAILED', reason}}
71
+ }
72
+ }
73
+
74
+ const reason = `graph refresh failed: ${safeRefreshReason(record, build?.error)}`
75
+ try {
76
+ return {
77
+ record,
78
+ alias,
79
+ graph: loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}),
80
+ reason: `${reason}; stale registered graph used`,
81
+ publicStatus: {...publicStatus, reason: `${reason}; stale registered graph used`},
82
+ }
83
+ } catch (error) {
84
+ const loadReason = `${reason}; registered graph could not be loaded: ${safeRefreshReason(record, error)}`
85
+ return {record, alias, graph: null, reason: loadReason, publicStatus: {...publicStatus, status: 'FAILED', reason: loadReason}}
86
+ }
87
+ }
@@ -8,7 +8,7 @@ import {compareDuplicateMember} from './evidence/duplicate-member-order.mjs'
8
8
  const CLONE_MIN_SIMILARITY = 80
9
9
  const DIVERGENCE_MAX_SIMILARITY = 45
10
10
  const MIN_TOKENS = 50
11
- const FILTERED_CLASSES = new Set(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
11
+ const FILTERED_CLASSES = new Set(['test', 'e2e', 'generated', 'vendored', 'mock', 'story', 'docs', 'benchmark', 'temp'])
12
12
  const SAFE_SYMBOL = /^[A-Za-z_$][A-Za-z0-9_$]*$/
13
13
 
14
14
  const DUPLICATE_EVIDENCE_THRESHOLDS = Object.freeze({
@@ -205,10 +205,3 @@ export function resolveSeedFiles(g, requested, limit = 12) {
205
205
  }
206
206
  return {seeds, missing}
207
207
  }
208
-
209
- export function undirectedNeighbors(g, id) {
210
- const seen = new Map()
211
- for (const e of g.out.get(id) || []) if (e.barrelProxy !== true) seen.set(e.id, e.relation)
212
- for (const e of g.inn.get(id) || []) if (e.barrelProxy !== true && !seen.has(e.id)) seen.set(e.id, e.relation)
213
- return seen
214
- }
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  isSymbol, degreeOf, labelOf, resolveNode, findSeeds, resolveSeedFiles,
3
- undirectedNeighbors, requestedPathClasses,
3
+ requestedPathClasses,
4
4
  } from '../graph-context.mjs'
5
5
  import {createPathClassifier, hasPathClass} from '../../path-classification.js'
6
6
 
7
- const QUERY_NON_PRODUCT = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
7
+ const QUERY_NON_PRODUCT = Object.freeze(['test', 'e2e', 'generated', 'vendored', 'mock', 'story', 'docs', 'benchmark', 'temp'])
8
8
  const LOW_SIGNAL_SYMBOL_RE = /^(?:const(?:ant)?|variable|property|field|enum_member)$/i
9
9
  const AMBIGUOUS_FILE_BASENAME_RE = /^(mod|lib|main)\.rs$|^index\.[a-z0-9]+$|^__init__\.py$/
10
10
  const querySourceFile = (node) => String(node?.source_file || String(node?.id || '').split('#', 1)[0]).replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
@@ -52,6 +52,22 @@ const makeEdgeLabel = (g, shownIds) => {
52
52
  }
53
53
  }
54
54
 
55
+ // shortest_path is intentionally a connectivity query, so it may walk an edge in either
56
+ // direction. Keep the stored direction on every hop so the rendered path never turns a reverse
57
+ // traversal into a false caller/importer direction.
58
+ const connectivityNeighbors = (g, id) => {
59
+ const seen = new Map()
60
+ for (const edge of g.out.get(id) || []) {
61
+ if (edge.barrelProxy !== true) seen.set(String(edge.id), {relation: edge.relation, direction: 'forward'})
62
+ }
63
+ for (const edge of g.inn.get(id) || []) {
64
+ if (edge.barrelProxy !== true && !seen.has(String(edge.id))) {
65
+ seen.set(String(edge.id), {relation: edge.relation, direction: 'backward'})
66
+ }
67
+ }
68
+ return seen
69
+ }
70
+
55
71
  const relationSet = (relationFilter, legacyFilter) => {
56
72
  const raw = relationFilter ?? legacyFilter
57
73
  const values = Array.isArray(raw) ? raw : (raw == null ? [] : String(raw).split(','))
@@ -221,17 +237,17 @@ export function tShortestPath(g, {source, target, max_hops = 8} = {}) {
221
237
  if (sourceId === targetId) return `Source and target are the same node: ${sourceNode.label ?? sourceId}.`
222
238
  const limit = Math.max(1, Math.min(20, Number(max_hops) || 8))
223
239
  const previous = new Map([[sourceId, null]])
224
- const relationTo = new Map()
240
+ const edgeTo = new Map()
225
241
  let frontier = [sourceId]
226
242
  let hops = 0
227
243
  let found = false
228
244
  while (frontier.length && hops < limit && !found) {
229
245
  const next = []
230
246
  for (const id of frontier) {
231
- for (const [neighbor, relation] of undirectedNeighbors(g, id)) {
247
+ for (const [neighbor, edge] of connectivityNeighbors(g, id)) {
232
248
  if (previous.has(neighbor)) continue
233
249
  previous.set(neighbor, id)
234
- relationTo.set(neighbor, relation)
250
+ edgeTo.set(neighbor, edge)
235
251
  if (neighbor === targetId) { found = true; break }
236
252
  next.push(neighbor)
237
253
  }
@@ -244,6 +260,17 @@ export function tShortestPath(g, {source, target, max_hops = 8} = {}) {
244
260
  const path = []
245
261
  for (let current = targetId; current != null; current = previous.get(current)) path.unshift(current)
246
262
  const edgeLabel = makeEdgeLabel(g, path)
247
- const lines = path.map((id, index) => index === 0 ? ` ${edgeLabel(id)}` : ` --${relationTo.get(id) || 'rel'}--> ${edgeLabel(id)}`)
248
- return [`Shortest path (${path.length - 1} hops): ${sourceNode.label ?? sourceId} → ${targetNode.label ?? targetId}`, ...lines].join('\n')
263
+ const hopCount = path.length - 1
264
+ const lines = path.map((id, index) => {
265
+ if (index === 0) return ` ${edgeLabel(id)}`
266
+ const edge = edgeTo.get(id) || {}
267
+ return edge.direction === 'backward'
268
+ ? ` <--${edge.relation || 'rel'}-- ${edgeLabel(id)}`
269
+ : ` --${edge.relation || 'rel'}--> ${edgeLabel(id)}`
270
+ })
271
+ return [
272
+ `Shortest path (${hopCount} ${hopCount === 1 ? 'hop' : 'hops'}; undirected connectivity) from ${edgeLabel(sourceId)} to ${edgeLabel(targetId)}`,
273
+ 'Edge arrows preserve stored graph direction; traversal may move with or against them.',
274
+ ...lines,
275
+ ].join('\n')
249
276
  }
@@ -3,7 +3,7 @@ import {runGit} from '../../git-exec.js'
3
3
  import {createPathClassifier, hasPathClass} from '../../path-classification.js'
4
4
 
5
5
  const SEVERITY_RANK = {critical: 0, high: 1, medium: 2, low: 3, info: 4}
6
- const AUDIT_NON_PRODUCT_CLASSES = ['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp']
6
+ const AUDIT_NON_PRODUCT_CLASSES = ['test', 'e2e', 'generated', 'vendored', 'mock', 'story', 'docs', 'benchmark', 'temp']
7
7
 
8
8
  export function formatAuditFinding(f) {
9
9
  const where = f.file ? ` (${f.file}${f.symbol ? ` ${f.symbol}` : ''})` : f.package ? ` (pkg ${f.package}${f.version ? `@${f.version}` : ''}${f.manifest ? `; ${f.manifest}` : ''})` : ''
@@ -168,7 +168,7 @@ export const formatOrdinaryAudit = (audit, args, findings = audit.findings, head
168
168
  ...auditCapabilityLines(audit),
169
169
  ...auditExtensionLines(audit),
170
170
  `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}.`,
171
- 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.',
171
+ pathScope.suppressed ? `Path policy: production-first; suppressed ${pathScope.suppressed} finding(s) whose evidence is entirely test/e2e/generated/vendored/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.',
172
172
  `Repository-level ${auditChecksLine(audit)}`,
173
173
  '',
174
174
  `Showing ${shown.length} of ${filtered.length} finding(s)${args.category ? ` in category "${args.category}"` : ''}${args.min_severity ? ` at ≥${args.min_severity}` : ''}:`,
@@ -11,6 +11,7 @@ import {buildInternalGraph} from '../../graph/internal-builder.js'
11
11
  import {resolveGitCommit, withGitRefCheckout} from '../../analysis/git-ref-graph.js'
12
12
  import {filterGraphForMode} from '../../graph/graph-filter.js'
13
13
  import {toolResult} from '../tool-result.mjs'
14
+ import {summarizeFindings} from '../../analysis/findings.js'
14
15
  const auditFormatVersion = new URL(import.meta.url).search
15
16
  const {
16
17
  auditFilter,
@@ -147,16 +148,22 @@ async function runAuditWithBaseline(args, ctx, currentGraph) {
147
148
  // unused deps), structure (import cycles / orphans / boundary rules), supply-chain (offline OSV
148
149
  // advisories, typosquat, lockfile drift), optional malware heuristics.
149
150
  export async function tRunAudit(g, args, ctx) {
150
- if (!ctx.repoRoot) return 'Audit needs the repo root (not provided to this server).'
151
+ if (!ctx.repoRoot) return toolResult('Audit needs the repo root (not provided to this server).', {
152
+ status: 'INVALID', findings: [], reason: 'repo root unavailable',
153
+ }, {completeness: {status: 'PARTIAL', reason: 'repo root unavailable'}})
151
154
  if (args.base_ref) return runAuditWithBaseline(args, ctx, rawGraph(ctx))
152
155
  const graph = effectiveRawGraph(ctx)
153
156
  const audit = await runAudit(ctx.repoRoot, graph, args, ctx, {
154
157
  skipMalwareScan: !args.include_malware_scan, // greps installed packages — slow, so opt-in
155
158
  })
156
- if (!audit.ok) return `Audit failed: ${audit.error}`
159
+ if (!audit.ok) return toolResult(`Audit failed: ${audit.error}`, {
160
+ status: 'ERROR', findings: [], reason: audit.error,
161
+ }, {completeness: {status: 'PARTIAL', reason: audit.error}})
157
162
  if (Array.isArray(args.changed_files)) {
158
163
  const normalized = normalizeAuditScopeFiles(args.changed_files)
159
- if (!normalized.ok) return `Changed-scope audit invalid: ${normalized.error}.`
164
+ if (!normalized.ok) return toolResult(`Changed-scope audit invalid: ${normalized.error}.`, {
165
+ status: 'INVALID', mode: 'changed-scope', findings: [], reason: normalized.error,
166
+ }, {completeness: {status: 'PARTIAL', reason: normalized.error}})
160
167
  const scoped = scopeAuditFindings(audit.findings, normalized.files)
161
168
  const text = formatOrdinaryAudit(audit, args, scoped,
162
169
  `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)
@@ -168,7 +175,25 @@ export async function tRunAudit(g, args, ctx) {
168
175
  findings: auditFilter(audit, args, scoped, ctx.repoRoot),
169
176
  }, {completeness: {status: 'COMPLETE'}})
170
177
  }
171
- return formatOrdinaryAudit(audit, args, audit.findings, null, ctx.repoRoot)
178
+ const filtered = auditFilter(audit, args, audit.findings, ctx.repoRoot)
179
+ const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
180
+ const shown = filtered.slice(0, max)
181
+ return toolResult(formatOrdinaryAudit(audit, args, audit.findings, null, ctx.repoRoot), {
182
+ status: 'COMPLETE',
183
+ mode: 'ordinary',
184
+ repo: audit.repo,
185
+ scanned: audit.scanned,
186
+ summary: summarizeFindings(filtered),
187
+ findings: shown,
188
+ healthCapabilities: audit.healthCapabilities,
189
+ extensionCapabilities: audit.extensionCapabilities || [],
190
+ dependencyReport: audit.dependencyReport,
191
+ conventionReachability: audit.conventionReachability,
192
+ checks: audit.checks,
193
+ }, {
194
+ page: {shown: shown.length, total: filtered.length, capped: shown.length < filtered.length},
195
+ completeness: {status: 'COMPLETE'},
196
+ })
172
197
  }
173
198
 
174
199
  // Named module clusters: graph communities labeled by their dominant folder instead of bare numbers.
@@ -1,7 +1,8 @@
1
1
  import {computeDuplicates} from '../../analysis/duplicates.js'
2
2
  import {analyzeDuplicateGroups} from '../../analysis/duplicate-groups.js'
3
+ import {toolResult} from '../tool-result.mjs'
3
4
 
4
- const NON_PRODUCT_DUPLICATE_CLASSES = new Set(['generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
5
+ const NON_PRODUCT_DUPLICATE_CLASSES = new Set(['generated', 'vendored', 'mock', 'story', 'docs', 'benchmark', 'temp'])
5
6
  const fragmentEligible = (fragment, {tokMin, skipTests, includeClassified}) => {
6
7
  if (fragment.n < tokMin) return false
7
8
  const classes = new Set(fragment.classes || [])
@@ -11,7 +12,9 @@ const fragmentEligible = (fragment, {tokMin, skipTests, includeClassified}) => {
11
12
  }
12
13
 
13
14
  export function tFindDuplicates(g, args, ctx) {
14
- if (!ctx.repoRoot) return 'Duplicate scan needs the repo root (not provided to this server).'
15
+ if (!ctx.repoRoot) return toolResult('Duplicate scan needs the repo root (not provided to this server).', {
16
+ status: 'INVALID', groups: [], reason: 'repo root unavailable',
17
+ }, {completeness: {status: 'PARTIAL', reason: 'repo root unavailable'}})
15
18
  const simMin = Math.min(100, Math.max(50, Number(args.min_similarity) || 80))
16
19
  const tokMin = Math.min(400, Math.max(12, Number(args.min_tokens) || 50))
17
20
  const mode = args.mode === 'strict' ? 'strict' : 'renamed'
@@ -40,7 +43,11 @@ export function tFindDuplicates(g, args, ctx) {
40
43
  ? b.pair.similarity - a.pair.similarity || b.tokens - a.tokens
41
44
  : b.tokens - a.tokens || a.pair.similarity - b.pair.similarity
42
45
  })
43
- if (!candidates.length) return 'No actionable same-name pairs across files (semantic mode; ambiguous middle-similarity pairs are suppressed).'
46
+ if (!candidates.length) return toolResult(
47
+ 'No actionable same-name pairs across files (semantic mode; ambiguous middle-similarity pairs are suppressed).',
48
+ {status: 'COMPLETE', mode: 'semantic', total: 0, groups: [], thresholds: {minTokens: tokMin}},
49
+ {page: {shown: 0, total: 0, capped: false}, completeness: {status: 'COMPLETE'}},
50
+ )
44
51
  const top = candidates.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
45
52
  const lines = top.map((item, k) => {
46
53
  const a = frags[item.pair.a]
@@ -54,14 +61,23 @@ export function tFindDuplicates(g, args, ctx) {
54
61
  ` ${b.file}:${b.start}-${b.end} (${b.n} tok)`,
55
62
  ].join('\n')
56
63
  })
57
- return `Found ${candidates.length} actionable same-name pair(s) across files (semantic mode; one closest clone and/or farthest collision per name). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nThese are review candidates, not automatic refactors. Use read_source on both sites before changing code.`
64
+ const text = `Found ${candidates.length} actionable same-name pair(s) across files (semantic mode; one closest clone and/or farthest collision per name). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nThese are review candidates, not automatic refactors. Use read_source on both sites before changing code.`
65
+ return toolResult(text, {
66
+ status: 'COMPLETE', mode: 'semantic', total: candidates.length, thresholds: {minTokens: tokMin},
67
+ groups: top.map((item) => ({
68
+ kind: item.kind, label: item.label, similarity: item.pair.similarity,
69
+ members: [frags[item.pair.a], frags[item.pair.b]].map((fragment) => ({
70
+ file: fragment.file, start: fragment.start, end: fragment.end, tokens: fragment.n, label: fragment.label,
71
+ })),
72
+ })),
73
+ }, {page: {shown: top.length, total: candidates.length, capped: top.length < candidates.length}, completeness: {status: 'COMPLETE'}})
58
74
  }
59
75
  const analysis = analyzeDuplicateGroups(ctx.repoRoot, ctx.graphPath, args)
60
76
  const groups = analysis.groups
61
77
  const smallPolicy = tokMin < 30 ? ' Small fragments below 30 tokens require at least 95% similarity and two shared bounded fingerprints.' : ''
62
78
  const suppressed = analysis.suppressed
63
79
  const suppressionNote = suppressed && !includeClassified
64
- ? ` ${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.`
80
+ ? ` ${suppressed} fragment(s) classified as tests/e2e/generated/vendored/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.`
65
81
  : ''
66
82
  const boilerplateNote = analysis.boilerplateSuppressed
67
83
  ? ` ${analysis.boilerplateSuppressed} all-router framework boilerplate group(s) were suppressed; pass include_boilerplate:true to inspect them.`
@@ -69,7 +85,14 @@ export function tFindDuplicates(g, args, ctx) {
69
85
  const declarativeNote = analysis.declarativeSuppressed
70
86
  ? ` ${analysis.declarativeSuppressed} immutable declarative catalog group(s) were suppressed; pass include_declarative:true to inspect repeated data shapes.`
71
87
  : ''
72
- if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${smallPolicy}${suppressionNote}${boilerplateNote}${declarativeNote}`
88
+ if (!groups.length) return toolResult(
89
+ `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${smallPolicy}${suppressionNote}${boilerplateNote}${declarativeNote}`,
90
+ {
91
+ status: 'COMPLETE', mode, total: 0, groups: [], thresholds: {minSimilarity: simMin, minTokens: tokMin},
92
+ suppressed: {classifiedFragments: suppressed, boilerplateGroups: analysis.boilerplateSuppressed, declarativeGroups: analysis.declarativeSuppressed},
93
+ },
94
+ {page: {shown: 0, total: 0, capped: false}, completeness: {status: 'COMPLETE'}},
95
+ )
73
96
  const top = groups.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
74
97
  const lines = top.map((grp, k) => {
75
98
  const isStr = grp.members.some((f) => f.kind === 'string')
@@ -77,7 +100,20 @@ export function tFindDuplicates(g, args, ctx) {
77
100
  const sites = grp.members.slice(0, 8).map((f) => ` ${f.file}:${f.start}-${f.end}`)
78
101
  return [head, ...sites].join('\n')
79
102
  })
80
- 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}${declarativeNote}`
103
+ const text = `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}${declarativeNote}`
104
+ return toolResult(text, {
105
+ status: 'COMPLETE', mode, total: groups.length, thresholds: {minSimilarity: simMin, minTokens: tokMin},
106
+ includeStrings,
107
+ suppressed: {classifiedFragments: suppressed, boilerplateGroups: analysis.boilerplateSuppressed, declarativeGroups: analysis.declarativeSuppressed},
108
+ groups: top.map((group) => ({
109
+ similarity: group.maxSim, duplicatedTokens: group.tokens,
110
+ members: group.members.slice(0, 8).map((fragment) => ({
111
+ file: fragment.file, start: fragment.start, end: fragment.end, tokens: fragment.n,
112
+ label: fragment.label, kind: fragment.kind || 'symbol', classes: fragment.classes || [],
113
+ })),
114
+ memberCount: group.members.length,
115
+ })),
116
+ }, {page: {shown: top.length, total: groups.length, capped: top.length < groups.length}, completeness: {status: 'COMPLETE'}})
81
117
  }
82
118
 
83
119
  // Focused dead-code review queue. Unlike the broad run_audit surface, this includes functions and
@@ -35,7 +35,7 @@ export function tListEndpoints(g, args, ctx) {
35
35
  const suppressed = includeClassified ? 0 : classified.length
36
36
  eps = includeClassified ? [...production, ...classified] : production
37
37
  const suppressionNote = suppressed
38
- ? `${suppressed} endpoint(s) in classified test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded paths were suppressed; pass include_classified:true to inspect them.`
38
+ ? `${suppressed} endpoint(s) in classified test/e2e/generated/vendored/mock/story/docs/benchmark/temp or explicitly excluded paths were suppressed; pass include_classified:true to inspect them.`
39
39
  : ''
40
40
  if (!eps.length) return suppressed
41
41
  ? `No production HTTP endpoints detected; ${suppressionNote}`
@@ -31,7 +31,7 @@ export function tModuleMap(g, args, ctx) {
31
31
  if (classifiedFiles.has(sourceFile)) continue
32
32
  classifiedFiles.add(sourceFile)
33
33
  const explanation = classifier.explain(sourceFile)
34
- if (explanation.excluded || hasPathClass(explanation, 'test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp')) {
34
+ if (explanation.excluded || hasPathClass(explanation, 'test', 'e2e', 'generated', 'vendored', 'mock', 'story', 'docs', 'benchmark', 'temp')) {
35
35
  nonProductFiles.add(sourceFile)
36
36
  }
37
37
  }
@@ -173,7 +173,9 @@ export function tCoverageMap(g, args, ctx) {
173
173
  ' JS/TS: npx vitest run --coverage (or jest --coverage)',
174
174
  ' Python: pytest --cov --cov-report=json',
175
175
  ' Go: go test ./... -coverprofile=coverage.out',
176
- 'Read locations: coverage/coverage-summary.json, coverage/coverage-final.json, (coverage/)lcov.info, coverage.json, coverage.out.',
176
+ ' Rust: cargo tarpaulin --out Json --output-dir .',
177
+ ' or cargo llvm-cov --lcov --output-path lcov.info',
178
+ 'Read locations: coverage/coverage-summary.json, coverage/coverage-final.json, (coverage/)lcov.info, coverage.json, tarpaulin-report.json, coverage.out.',
177
179
  ].filter((line) => line != null).join('\n')
178
180
  }
179
181
  const pctStr = (v) => (v == null ? 'n/a' : `${Math.round(v * 100)}%`)
@@ -216,4 +218,3 @@ export function tCoverageMap(g, args, ctx) {
216
218
  }
217
219
 
218
220
  // HTTP endpoint inventory: Express/Fastify/Nest/Flask/FastAPI/Go/Rust/Spring route definitions.
219
-