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.
- package/README.md +9 -4
- package/docs/releases/v0.3.13.md +34 -0
- package/docs/releases/v0.3.14.md +37 -0
- package/package.json +3 -1
- package/src/analysis/coverage-reports-tarpaulin.js +112 -0
- package/src/analysis/coverage-reports.js +13 -2
- package/src/analysis/dead-code-review/policy.js +1 -1
- package/src/analysis/duplicate-groups.js +1 -1
- package/src/analysis/endpoints/common.js +18 -1
- package/src/analysis/endpoints/extract.js +13 -0
- package/src/analysis/graph-analysis.aggregate.js +4 -3
- package/src/analysis/graph-analysis.refs.js +48 -0
- package/src/analysis/hot-path-review.js +1 -1
- package/src/analysis/internal-audit.reach.js +28 -0
- package/src/analysis/internal-audit.run.js +1 -1
- package/src/analysis/static-test-reachability.js +1 -1
- package/src/mcp/architecture-starter.mjs +1 -1
- package/src/mcp/catalog.mjs +9 -5
- package/src/mcp/company-contract-page.mjs +142 -0
- package/src/mcp/company-contract-reconcile.mjs +87 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +1 -1
- package/src/mcp/graph/context-seeds.mjs +0 -7
- package/src/mcp/graph/tools-query.mjs +34 -7
- package/src/mcp/health/audit-format.mjs +2 -2
- package/src/mcp/health/audit.mjs +29 -4
- package/src/mcp/health/duplicates.mjs +43 -7
- package/src/mcp/health/endpoints.mjs +1 -1
- package/src/mcp/health/structure.mjs +4 -3
- package/src/mcp/tools-company.mjs +52 -76
- package/src/mcp/tools-context.mjs +1 -1
- package/src/mcp/tools-endpoints.mjs +1 -1
- package/src/mcp/tools-graph-hubs.mjs +1 -1
- package/src/mcp/tools-verified-change.mjs +11 -4
- 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
|
-
|
|
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
|
|
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,
|
|
247
|
+
for (const [neighbor, edge] of connectivityNeighbors(g, id)) {
|
|
232
248
|
if (previous.has(neighbor)) continue
|
|
233
249
|
previous.set(neighbor, id)
|
|
234
|
-
|
|
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
|
|
248
|
-
|
|
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}` : ''}:`,
|
package/src/mcp/health/audit.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|