weavatrix 0.1.4 → 0.2.0
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 +138 -53
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +92 -30
- package/src/analysis/architecture-contract.js +343 -0
- package/src/analysis/audit-debt.js +94 -0
- package/src/analysis/change-classification.js +509 -0
- package/src/analysis/cycle-route.js +14 -0
- package/src/analysis/dead-check.js +5 -3
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +6 -0
- package/src/analysis/dep-check.js +40 -6
- package/src/analysis/dep-rules.js +12 -9
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints.js +8 -2
- package/src/analysis/findings.js +8 -1
- package/src/analysis/git-history.js +549 -0
- package/src/analysis/git-ref-graph.js +74 -0
- package/src/analysis/graph-analysis.aggregate.js +2 -2
- package/src/analysis/graph-analysis.edges.js +3 -0
- package/src/analysis/graph-analysis.summaries.js +1 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +3 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +58 -10
- package/src/analysis/java-source.js +36 -0
- package/src/analysis/package-reachability.js +39 -0
- package/src/analysis/static-test-reachability.js +133 -0
- package/src/build-graph.js +72 -13
- package/src/graph/build-worker.js +3 -4
- package/src/graph/builder/lang-js.js +71 -12
- package/src/graph/community.js +10 -0
- package/src/graph/file-lock.js +69 -0
- package/src/graph/freshness-probe.js +141 -0
- package/src/graph/graph-filter.js +28 -11
- package/src/graph/incremental-refresh.js +232 -0
- package/src/graph/internal-builder.barrels.js +169 -0
- package/src/graph/internal-builder.build.js +82 -11
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/layout.js +21 -5
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +62 -19
- package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
- package/src/mcp/evidence-snapshot.common.mjs +160 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
- package/src/mcp/evidence-snapshot.health.mjs +95 -0
- package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
- package/src/mcp/evidence-snapshot.mjs +50 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
- package/src/mcp/evidence-snapshot.structure.mjs +81 -0
- package/src/mcp/graph-context.mjs +100 -16
- package/src/mcp/graph-diff.mjs +74 -20
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +164 -0
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +111 -30
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph.mjs +25 -14
- package/src/mcp/tools-health.mjs +336 -14
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +25 -6
- package/src/mcp-server.mjs +100 -17
- package/src/mcp-source-tools.mjs +11 -3
- package/src/path-classification.js +201 -0
- package/src/path-ignore.js +69 -0
- package/src/security/typosquat.js +1 -1
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// Defense-in-depth wire sanitizer for evidence snapshots. The local evidence builder is trusted code,
|
|
2
|
+
// but its inputs (graph.json, manifests and cached advisory metadata) are not. Reconstruct every field
|
|
3
|
+
// from a small schema here so source text, absolute paths and future analyzer fields cannot hitchhike.
|
|
4
|
+
import {createHash} from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
const STATES = new Set(['COMPLETE', 'PARTIAL', 'NOT_CHECKED', 'NOT_APPLICABLE', 'ERROR'])
|
|
7
|
+
const VERDICTS = new Set(['PASS', 'FAIL', 'UNKNOWN'])
|
|
8
|
+
const SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'info'])
|
|
9
|
+
const CONFIDENCE = new Set(['high', 'medium', 'low'])
|
|
10
|
+
const CATEGORIES = new Set(['unused', 'structure', 'vulnerability', 'malware'])
|
|
11
|
+
const CHECK_KEYS = ['osv', 'malware']
|
|
12
|
+
const PACKAGE_DEPENDENCY_KINDS = new Set(['runtime', 'dev', 'optional', 'peer', 'optional-peer'])
|
|
13
|
+
const CONTROL = /[\u0000-\u001f\u007f]/
|
|
14
|
+
const ABSOLUTE_PATH_FRAGMENT = /(?:^|[\/\s"'`(=])[a-z]:[\\/]|(?:^|[\s"'`(=])(?:\\\\[^\\/\s]+(?:[\\/]|$)|file:(?:\/\/)?[\\/]|\/(?!\/)[^\s])/i
|
|
15
|
+
const TOKEN = /^[\p{L}\p{N}_.:@+\-#$<>()\[\],]+$/u
|
|
16
|
+
const PACKAGE = /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/i
|
|
17
|
+
const CAPS = Object.freeze({
|
|
18
|
+
modules: 500, dependencies: 2000, findings: 500, hotspots: 250, badges: 100,
|
|
19
|
+
packages: 5000, usage: 1000, files: 20, packageGraphNodes: 5000, packageGraphEdges: 20000,
|
|
20
|
+
duplicateGroups: 100, duplicateMembers: 12, divergenceCandidates: 100,
|
|
21
|
+
})
|
|
22
|
+
const DUPLICATE_THRESHOLDS = Object.freeze({
|
|
23
|
+
clones: Object.freeze({mode: 'renamed', minSimilarityPercent: 80, minTokens: 50}),
|
|
24
|
+
divergence: Object.freeze({sameName: true, maxSimilarityPercent: 45, minTokens: 50, maxImplementationsPerName: 12}),
|
|
25
|
+
})
|
|
26
|
+
const PACKAGE_SOURCES = new Set(['package-lock', 'yarn-lock', 'requirements', 'venv', 'poetry-lock', 'uv-lock', 'pipfile-lock', 'go-sum', 'go-mod', 'node_modules'])
|
|
27
|
+
|
|
28
|
+
const int = (value) => Number.isFinite(value) && value >= 0 ? Math.trunc(value) : 0
|
|
29
|
+
const bool = (value) => value === true
|
|
30
|
+
const text = (value, max = 256) => typeof value === 'string' && value.length > 0 && value.length <= max && !CONTROL.test(value) ? value : undefined
|
|
31
|
+
const token = (value, max = 256) => { const valueText = text(value, max); return valueText && TOKEN.test(valueText) ? valueText : undefined }
|
|
32
|
+
const privacySafeText = (value, max = 256) => { const valueText = text(value, max); return valueText && !ABSOLUTE_PATH_FRAGMENT.test(valueText) ? valueText : undefined }
|
|
33
|
+
const packageName = (value) => { const valueText = text(value, 256); return valueText && PACKAGE.test(valueText) ? valueText : undefined }
|
|
34
|
+
const packageVersion = (value) => { const valueText = text(value, 128); return valueText && /^[A-Za-z0-9][A-Za-z0-9._+~-]*$/.test(valueText) ? valueText : undefined }
|
|
35
|
+
const state = (value) => STATES.has(value) ? value : 'ERROR'
|
|
36
|
+
const verdict = (value) => VERDICTS.has(value) ? value : 'UNKNOWN'
|
|
37
|
+
const compare = (a, b) => String(a).localeCompare(String(b), 'en')
|
|
38
|
+
|
|
39
|
+
function path(value, max = 4096) {
|
|
40
|
+
const raw = text(value, max)
|
|
41
|
+
if (!raw) return undefined
|
|
42
|
+
const normalized = raw.replace(/\\/g, '/')
|
|
43
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/)/i.test(normalized)) return undefined
|
|
44
|
+
const parts = normalized.split('/')
|
|
45
|
+
return parts.length && parts.every((part) => part && part !== '.' && part !== '..') ? normalized : undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function graphId(value) {
|
|
49
|
+
const id = text(value, 4096)
|
|
50
|
+
if (!id) return undefined
|
|
51
|
+
const hash = id.indexOf('#')
|
|
52
|
+
const file = hash < 0 ? id : id.slice(0, hash)
|
|
53
|
+
const safeFile = path(file)
|
|
54
|
+
if (!safeFile) return undefined
|
|
55
|
+
if (hash < 0) return safeFile
|
|
56
|
+
const suffix = id.slice(hash)
|
|
57
|
+
return suffix.length <= 512 && /^#[^\\/\s\u0000-\u001f\u007f]{1,511}$/u.test(suffix) ? `${safeFile}${suffix}` : undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function moduleId(value) { return value === '(root)' ? value : path(value) }
|
|
61
|
+
function set(out, key, value) { if (value !== undefined) out[key] = value }
|
|
62
|
+
|
|
63
|
+
function stableStringify(value) {
|
|
64
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
|
65
|
+
if (value && typeof value === 'object') return `{${Object.keys(value).sort(compare).map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`
|
|
66
|
+
return JSON.stringify(value)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function list(values, cap, mapper, sorter) {
|
|
70
|
+
const all = (Array.isArray(values) ? values : []).map(mapper).filter(Boolean).sort(sorter)
|
|
71
|
+
return {items: all.slice(0, cap), total: all.length, truncated: all.length > cap}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function count(value, fallbackTotal, returned) {
|
|
75
|
+
const total = Math.max(int(value?.total), fallbackTotal)
|
|
76
|
+
return {
|
|
77
|
+
total,
|
|
78
|
+
returned,
|
|
79
|
+
truncated: bool(value?.truncated) || total > returned,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function reasons(values) {
|
|
84
|
+
return [...new Set((Array.isArray(values) ? values : []).map((value) => token(value, 96)).filter(Boolean))].sort(compare).slice(0, 32)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function finding(value) {
|
|
88
|
+
if (!value || typeof value !== 'object') return null
|
|
89
|
+
const id = text(value.id, 64), category = token(value.category, 32), rule = token(value.rule, 64), severity = token(value.severity, 16)
|
|
90
|
+
if (!id || !/^[a-f0-9]{8,64}$/i.test(id) || !CATEGORIES.has(category) || !rule || !SEVERITIES.has(severity)) return null
|
|
91
|
+
const out = {id, category, rule, severity}
|
|
92
|
+
if (CONFIDENCE.has(value.confidence)) out.confidence = value.confidence
|
|
93
|
+
set(out, 'file', path(value.file)); set(out, 'line', Number.isFinite(value.line) && value.line >= 0 ? Math.trunc(value.line) : undefined)
|
|
94
|
+
set(out, 'symbol', privacySafeText(value.symbol)); set(out, 'package', packageName(value.package)); set(out, 'version', token(value.version, 128)); set(out, 'graphNodeId', graphId(value.graphNodeId))
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function moduleFact(value) {
|
|
99
|
+
const id = moduleId(value?.id || value?.name)
|
|
100
|
+
return id ? {id, fileCount: int(value.fileCount), nodeCount: int(value.nodeCount), symbolCount: int(value.symbolCount)} : null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function dependency(value) {
|
|
104
|
+
const from = moduleId(value?.from), to = moduleId(value?.to)
|
|
105
|
+
return from && to && from !== to ? {from, to, count: int(value.count)} : null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function cycleFact(value) {
|
|
109
|
+
const id = text(value?.id, 64), kind = value?.kind
|
|
110
|
+
if (!id || !/^[a-f0-9]{16,64}$/i.test(id) || !['runtime', 'compile-time'].includes(kind)) return null
|
|
111
|
+
const members = [...new Set((value.members || []).map((item) => path(item)).filter(Boolean))].sort(compare).slice(0, 200)
|
|
112
|
+
const representativePath = (value.representativePath || []).map((item) => path(item)).filter(Boolean).slice(0, 201)
|
|
113
|
+
if (members.length < 2 || representativePath.length < 2) return null
|
|
114
|
+
return {id, kind, size: Math.max(int(value.size), members.length), members, membersTruncated: bool(value.membersTruncated) || int(value.size) > members.length, representativePath}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function boundaryFact(value) {
|
|
118
|
+
const ruleId = token(value?.ruleId, 96), from = path(value?.from), to = path(value?.to)
|
|
119
|
+
if (!ruleId || !from || !to || !['forbidden', 'allowedOnly'].includes(value?.kind) || !SEVERITIES.has(value?.severity)) return null
|
|
120
|
+
return {kind: value.kind, ruleId, severity: value.severity, from, to}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sanitizeArchitecture(value) {
|
|
124
|
+
const modules = list(value?.modules, CAPS.modules, moduleFact, (a, b) => compare(a.id, b.id))
|
|
125
|
+
const dependencies = {}
|
|
126
|
+
let truncated = modules.truncated
|
|
127
|
+
const dependencyMeta = {}
|
|
128
|
+
for (const kind of ['runtime', 'typeOnly', 'compileOnly']) {
|
|
129
|
+
const result = list(value?.dependencies?.[kind], CAPS.dependencies, dependency, (a, b) => compare(a.from, b.from) || compare(a.to, b.to))
|
|
130
|
+
dependencies[kind] = result.items
|
|
131
|
+
const completenessKey = `${kind}Dependencies`
|
|
132
|
+
dependencyMeta[completenessKey] = count(value?.completeness?.[completenessKey], result.total, result.items.length)
|
|
133
|
+
truncated ||= result.truncated
|
|
134
|
+
}
|
|
135
|
+
const cycles = list(value?.cycles, CAPS.findings, cycleFact, (a, b) => compare(a.kind, b.kind) || b.size - a.size || compare(a.id, b.id))
|
|
136
|
+
const boundaries = list(value?.boundaryViolations, CAPS.findings, boundaryFact, (a, b) => compare(a.ruleId, b.ruleId) || compare(a.from, b.from) || compare(a.to, b.to))
|
|
137
|
+
truncated ||= cycles.truncated || boundaries.truncated
|
|
138
|
+
const outState = state(value?.state)
|
|
139
|
+
return {
|
|
140
|
+
state: truncated && outState === 'COMPLETE' ? 'PARTIAL' : outState,
|
|
141
|
+
verdict: verdict(value?.verdict),
|
|
142
|
+
completeness: {modules: count(value?.completeness?.modules, modules.total, modules.items.length), ...dependencyMeta, cycles: count(value?.completeness?.cycles, cycles.total, cycles.items.length), boundaryViolations: count(value?.completeness?.boundaryViolations, boundaries.total, boundaries.items.length), reasons: reasons(value?.completeness?.reasons)},
|
|
143
|
+
modules: modules.items, dependencies, cycles: cycles.items, boundaryViolations: boundaries.items,
|
|
144
|
+
boundaryRulesState: state(value?.boundaryRulesState),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function duplicateEvidenceId(value) {
|
|
149
|
+
const id = text(value, 64)
|
|
150
|
+
return id && /^[a-f0-9]{24,64}$/i.test(id) ? id : undefined
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function duplicateMember(value) {
|
|
154
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
155
|
+
const file = path(value.file)
|
|
156
|
+
const startLine = int(value.startLine), endLine = int(value.endLine), tokens = int(value.tokens)
|
|
157
|
+
if (!file || startLine < 1 || endLine < startLine || tokens < DUPLICATE_THRESHOLDS.clones.minTokens) return null
|
|
158
|
+
const out = {file, startLine, endLine, tokens}
|
|
159
|
+
const nodeId = graphId(value.graphNodeId)
|
|
160
|
+
if (nodeId?.startsWith(`${file}#`)) out.graphNodeId = nodeId
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function compareDuplicateMember(a, b) {
|
|
165
|
+
return compare(a.file, b.file) || a.startLine - b.startLine || a.endLine - b.endLine ||
|
|
166
|
+
compare(a.graphNodeId || '', b.graphNodeId || '')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function duplicateMembers(values, cap = CAPS.duplicateMembers) {
|
|
170
|
+
const raw = Array.isArray(values) ? values : []
|
|
171
|
+
const unique = new Map()
|
|
172
|
+
for (const value of raw) {
|
|
173
|
+
const item = duplicateMember(value)
|
|
174
|
+
if (!item) continue
|
|
175
|
+
const key = `${item.file}\0${item.startLine}\0${item.endLine}\0${item.graphNodeId || ''}`
|
|
176
|
+
unique.set(key, item)
|
|
177
|
+
}
|
|
178
|
+
const all = [...unique.values()].sort(compareDuplicateMember)
|
|
179
|
+
return {items: all.slice(0, cap), total: all.length, invalid: raw.length - all.length, truncated: all.length > cap}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function cloneGroup(value) {
|
|
183
|
+
const id = duplicateEvidenceId(value?.id)
|
|
184
|
+
const members = duplicateMembers(value?.members)
|
|
185
|
+
const rawStrongestSimilarity = Number(value?.strongestSimilarity)
|
|
186
|
+
const rawWeakestLinkedSimilarity = Number(value?.weakestLinkedSimilarity)
|
|
187
|
+
const strongestSimilarity = Math.trunc(rawStrongestSimilarity)
|
|
188
|
+
const weakestLinkedSimilarity = Math.trunc(rawWeakestLinkedSimilarity)
|
|
189
|
+
if (!id || members.items.length < 2 || strongestSimilarity < DUPLICATE_THRESHOLDS.clones.minSimilarityPercent ||
|
|
190
|
+
!Number.isFinite(rawStrongestSimilarity) || rawStrongestSimilarity > 100 ||
|
|
191
|
+
!Number.isFinite(rawWeakestLinkedSimilarity) || weakestLinkedSimilarity < DUPLICATE_THRESHOLDS.clones.minSimilarityPercent ||
|
|
192
|
+
rawWeakestLinkedSimilarity > 100 ||
|
|
193
|
+
weakestLinkedSimilarity > strongestSimilarity) return null
|
|
194
|
+
const memberCount = Math.max(int(value?.memberCount), members.total)
|
|
195
|
+
const returnedTokens = members.items.reduce((sum, member) => sum + member.tokens, 0)
|
|
196
|
+
return {
|
|
197
|
+
id,
|
|
198
|
+
memberCount,
|
|
199
|
+
totalTokens: Math.max(int(value?.totalTokens), returnedTokens),
|
|
200
|
+
strongestSimilarity,
|
|
201
|
+
weakestLinkedSimilarity,
|
|
202
|
+
membersTruncated: bool(value?.membersTruncated) || members.truncated || members.invalid > 0 || memberCount > members.items.length,
|
|
203
|
+
members: members.items,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function divergenceCandidate(value) {
|
|
208
|
+
const id = duplicateEvidenceId(value?.id)
|
|
209
|
+
const symbol = text(value?.symbol, 256)
|
|
210
|
+
const rawSimilarity = Number(value?.similarity)
|
|
211
|
+
const similarity = Math.trunc(rawSimilarity)
|
|
212
|
+
const members = duplicateMembers(value?.members, 2)
|
|
213
|
+
if (!id || !symbol || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(symbol) || members.total !== 2 || members.invalid > 0 ||
|
|
214
|
+
!Number.isFinite(rawSimilarity) || rawSimilarity < 0 || rawSimilarity > DUPLICATE_THRESHOLDS.divergence.maxSimilarityPercent ||
|
|
215
|
+
members.items[0].file === members.items[1].file) return null
|
|
216
|
+
return {
|
|
217
|
+
id,
|
|
218
|
+
symbol,
|
|
219
|
+
similarity,
|
|
220
|
+
totalTokens: Math.max(int(value?.totalTokens), members.items[0].tokens + members.items[1].tokens),
|
|
221
|
+
members: members.items,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function sanitizeDuplicates(value) {
|
|
226
|
+
const rawGroups = Array.isArray(value?.cloneGroups) ? value.cloneGroups : []
|
|
227
|
+
const rawDivergence = Array.isArray(value?.divergenceCandidates) ? value.divergenceCandidates : []
|
|
228
|
+
const cloneGroups = list(rawGroups, CAPS.duplicateGroups, cloneGroup,
|
|
229
|
+
(a, b) => b.totalTokens - a.totalTokens || b.memberCount - a.memberCount || compare(a.id, b.id))
|
|
230
|
+
const divergence = list(rawDivergence, CAPS.divergenceCandidates, divergenceCandidate,
|
|
231
|
+
(a, b) => b.totalTokens - a.totalTokens || a.similarity - b.similarity || compare(a.symbol, b.symbol) || compare(a.id, b.id))
|
|
232
|
+
const invalid = rawGroups.length - cloneGroups.total + rawDivergence.length - divergence.total
|
|
233
|
+
const membersTruncated = cloneGroups.items.some((group) => group.membersTruncated)
|
|
234
|
+
const groupCompleteness = count(value?.completeness?.cloneGroups, rawGroups.length, cloneGroups.items.length)
|
|
235
|
+
const divergenceCompleteness = count(value?.completeness?.divergenceCandidates, rawDivergence.length, divergence.items.length)
|
|
236
|
+
const truncated = cloneGroups.truncated || divergence.truncated || groupCompleteness.truncated ||
|
|
237
|
+
divergenceCompleteness.truncated || membersTruncated || invalid > 0
|
|
238
|
+
const outReasons = reasons([
|
|
239
|
+
...(Array.isArray(value?.completeness?.reasons) ? value.completeness.reasons : []),
|
|
240
|
+
...(invalid > 0 ? ['INVALID_DUPLICATE_EVIDENCE_DROPPED'] : []),
|
|
241
|
+
...(membersTruncated ? ['CLONE_MEMBERS_TRUNCATED'] : []),
|
|
242
|
+
...(cloneGroups.truncated ? ['CLONE_GROUPS_TRUNCATED'] : []),
|
|
243
|
+
...(divergence.truncated ? ['DIVERGENCE_CANDIDATES_TRUNCATED'] : []),
|
|
244
|
+
])
|
|
245
|
+
const rawFragments = value?.completeness?.fragments
|
|
246
|
+
const eligible = int(rawFragments?.eligible), filtered = int(rawFragments?.filtered)
|
|
247
|
+
const total = Math.max(int(rawFragments?.total), eligible + filtered)
|
|
248
|
+
const outState = state(value?.state)
|
|
249
|
+
return {
|
|
250
|
+
state: truncated && outState === 'COMPLETE' ? 'PARTIAL' : outState,
|
|
251
|
+
verdict: verdict(value?.verdict),
|
|
252
|
+
thresholds: DUPLICATE_THRESHOLDS,
|
|
253
|
+
completeness: {
|
|
254
|
+
fragments: {total, eligible: Math.min(eligible, total), filtered: Math.max(filtered, total - eligible)},
|
|
255
|
+
cloneGroups: groupCompleteness,
|
|
256
|
+
divergenceCandidates: divergenceCompleteness,
|
|
257
|
+
reasons: outReasons,
|
|
258
|
+
},
|
|
259
|
+
cloneGroups: cloneGroups.items,
|
|
260
|
+
divergenceCandidates: divergence.items,
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function numericRecord(value, keys) { const out = {}; for (const key of keys) out[key] = int(value?.[key]); return out }
|
|
265
|
+
function checks(value) { const out = {}; for (const key of CHECK_KEYS) out[key] = state(value?.[key]); return out }
|
|
266
|
+
|
|
267
|
+
function hotspot(value) {
|
|
268
|
+
const id = graphId(value?.id), file = path(value?.file), severity = token(value?.severity, 16)
|
|
269
|
+
if (!id || !file || !['medium', 'high'].includes(severity)) return null
|
|
270
|
+
const out = {id, file, severity, breaches: [...new Set((value.breaches || []).map((item) => token(item, 48)).filter(Boolean))].sort(compare).slice(0, 12)}
|
|
271
|
+
set(out, 'symbol', privacySafeText(value.symbol)); for (const key of ['startLine', 'endLine', 'loc', 'cyclomatic', 'params']) if (Number.isFinite(value[key]) && value[key] >= 0) out[key] = Math.trunc(value[key])
|
|
272
|
+
return out
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function sanitizeHealth(value) {
|
|
276
|
+
const findings = list(value?.findings, CAPS.findings, finding, (a, b) => compare(a.severity, b.severity) || compare(a.id, b.id))
|
|
277
|
+
const hotspots = list(value?.complexity?.hotspots, CAPS.hotspots, hotspot, (a, b) => compare(a.severity, b.severity) || compare(a.id, b.id))
|
|
278
|
+
const truncated = findings.truncated || hotspots.truncated
|
|
279
|
+
const outState = state(value?.state)
|
|
280
|
+
return {
|
|
281
|
+
state: truncated && outState === 'COMPLETE' ? 'PARTIAL' : outState, verdict: verdict(value?.verdict),
|
|
282
|
+
completeness: {findings: count(value?.completeness?.findings, findings.total, findings.items.length), hotspots: count(value?.completeness?.hotspots, hotspots.total, hotspots.items.length), reasons: reasons(value?.completeness?.reasons)},
|
|
283
|
+
summary: {
|
|
284
|
+
bySeverity: numericRecord(value?.summary?.bySeverity, ['critical', 'high', 'medium', 'low', 'info']),
|
|
285
|
+
byCategory: numericRecord(value?.summary?.byCategory, ['unused', 'structure', 'vulnerability', 'malware']),
|
|
286
|
+
dead: numericRecord(value?.summary?.dead, ['deadSymbols', 'deadFiles', 'unusedExports']),
|
|
287
|
+
structure: numericRecord(value?.summary?.structure, ['runtimeImportEdges', 'typeOnlyImportEdges', 'compileOnlyImportEdges', 'runtimeCycles', 'compileTimeCouplings', 'largestCycle', 'largestCompileTimeCoupling', 'orphans', 'boundaryViolations']),
|
|
288
|
+
},
|
|
289
|
+
checks: checks(value?.checks), findings: findings.items,
|
|
290
|
+
complexity: {thresholds: {loc: {warning: int(value?.complexity?.thresholds?.loc?.warning), high: int(value?.complexity?.thresholds?.loc?.high)}, cyclomatic: {warning: int(value?.complexity?.thresholds?.cyclomatic?.warning), high: int(value?.complexity?.thresholds?.cyclomatic?.high)}, params: {warning: int(value?.complexity?.thresholds?.params?.warning), high: int(value?.complexity?.thresholds?.params?.high)}}, analyzed: int(value?.complexity?.analyzed), hotspots: hotspots.items},
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function badge(value) {
|
|
295
|
+
const category = token(value?.category, 32), id = token(value?.id, 128)
|
|
296
|
+
if (!['languages', 'runtimes', 'tests', 'infra', 'deploy'].includes(category) || !id) return null
|
|
297
|
+
const out = {category, id}; set(out, 'kind', token(value.kind, 64)); set(out, 'version', token(value.version, 64)); return out
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function sanitizeTechnologies(value) {
|
|
301
|
+
const badges = list(value?.badges, CAPS.badges, badge, (a, b) => compare(a.category, b.category) || compare(a.id, b.id))
|
|
302
|
+
const outState = state(value?.state)
|
|
303
|
+
return {state: badges.truncated && outState === 'COMPLETE' ? 'PARTIAL' : outState, verdict: verdict(value?.verdict), completeness: {badges: count(value?.completeness?.badges, badges.total, badges.items.length), reasons: reasons(value?.completeness?.reasons)}, badges: badges.items}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function packageSource(value) {
|
|
307
|
+
const source = text(value, 512)
|
|
308
|
+
if (PACKAGE_SOURCES.has(source)) return source
|
|
309
|
+
const relative = path(source, 512)
|
|
310
|
+
return relative && /(^|\/)(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.ya?ml|requirements[\w.-]*\.(?:txt|in)|poetry\.lock|uv\.lock|Pipfile\.lock|go\.(?:mod|sum))$/i.test(relative) ? relative : undefined
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function packageFact(value) {
|
|
314
|
+
const name = packageName(value?.name), version = token(value?.version, 128), ecosystem = token(value?.ecosystem, 64), source = packageSource(value?.source)
|
|
315
|
+
return name && version && ecosystem && source ? {name, version, ecosystem, dev: bool(value.dev), source} : null
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function usage(value) {
|
|
319
|
+
const name = packageName(value?.name), ecosystem = token(value?.ecosystem, 64)
|
|
320
|
+
if (!name || !ecosystem) return null
|
|
321
|
+
const files = [...new Set((value.files || []).map((item) => path(item)).filter(Boolean))].sort(compare)
|
|
322
|
+
return {name, ecosystem, importCount: int(value.importCount), fileCount: int(value.fileCount), files: files.slice(0, CAPS.files), filesTruncated: bool(value.filesTruncated) || files.length > CAPS.files, kinds: [...new Set((value.kinds || []).map((item) => token(item, 64)).filter(Boolean))].sort(compare).slice(0, 32)}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function packageGraphNode(value) {
|
|
326
|
+
const name = packageName(value?.name), version = packageVersion(value?.version), id = text(value?.id, 512)
|
|
327
|
+
if (!name || !version || !id) return null
|
|
328
|
+
const prefix = `npm:${name}@${version}:`
|
|
329
|
+
if (!id.startsWith(prefix) || !/^[a-f0-9]{12}$/i.test(id.slice(prefix.length))) return null
|
|
330
|
+
return {
|
|
331
|
+
id, name, version,
|
|
332
|
+
direct: bool(value.direct), dev: bool(value.dev), optional: bool(value.optional), peer: bool(value.peer),
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function sanitizePackageDependencyGraph(value) {
|
|
337
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
338
|
+
return {
|
|
339
|
+
state: 'NOT_CHECKED', ecosystem: 'npm', root: '(root)',
|
|
340
|
+
completeness: {
|
|
341
|
+
nodes: count(null, 0, 0), edges: count(null, 0, 0),
|
|
342
|
+
declarations: numericRecord(null, ['total', 'resolved', 'unresolved', 'local', 'optionalMissing']),
|
|
343
|
+
reasons: ['DEPENDENCY_GRAPH_NOT_PROVIDED'],
|
|
344
|
+
},
|
|
345
|
+
nodes: [], edges: [],
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const nodes = list(value.nodes, CAPS.packageGraphNodes, packageGraphNode,
|
|
350
|
+
(a, b) => Number(b.direct) - Number(a.direct) || compare(a.name, b.name) || compare(a.version, b.version) || compare(a.id, b.id))
|
|
351
|
+
const nodeIds = new Set(nodes.items.map((node) => node.id))
|
|
352
|
+
const edge = (candidate) => {
|
|
353
|
+
const from = candidate?.from === '(root)' ? '(root)' : text(candidate?.from, 512)
|
|
354
|
+
const to = text(candidate?.to, 512), kind = token(candidate?.kind, 32)
|
|
355
|
+
if (!from || !to || !PACKAGE_DEPENDENCY_KINDS.has(kind) || from === to ||
|
|
356
|
+
(from !== '(root)' && !nodeIds.has(from)) || !nodeIds.has(to)) return null
|
|
357
|
+
return {from, to, kind}
|
|
358
|
+
}
|
|
359
|
+
const edges = list(value.edges, CAPS.packageGraphEdges, edge,
|
|
360
|
+
(a, b) => compare(a.from, b.from) || compare(a.to, b.to) || compare(a.kind, b.kind))
|
|
361
|
+
const lockfile = path(value.lockfile, 512)
|
|
362
|
+
const safeLockfile = lockfile && /(^|\/)(?:package-lock\.json|npm-shrinkwrap\.json)$/i.test(lockfile) ? lockfile : undefined
|
|
363
|
+
const graphState = state(value.state)
|
|
364
|
+
const truncated = nodes.truncated || edges.truncated
|
|
365
|
+
const out = {
|
|
366
|
+
state: truncated && graphState === 'COMPLETE' ? 'PARTIAL' : graphState,
|
|
367
|
+
ecosystem: 'npm',
|
|
368
|
+
root: '(root)',
|
|
369
|
+
completeness: {
|
|
370
|
+
nodes: count(value?.completeness?.nodes, nodes.total, nodes.items.length),
|
|
371
|
+
edges: count(value?.completeness?.edges, edges.total, edges.items.length),
|
|
372
|
+
declarations: numericRecord(value?.completeness?.declarations, ['total', 'resolved', 'unresolved', 'local', 'optionalMissing']),
|
|
373
|
+
reasons: reasons(value?.completeness?.reasons),
|
|
374
|
+
},
|
|
375
|
+
nodes: nodes.items,
|
|
376
|
+
edges: edges.items,
|
|
377
|
+
}
|
|
378
|
+
set(out, 'lockfile', safeLockfile)
|
|
379
|
+
const lockfileVersion = int(value.lockfileVersion)
|
|
380
|
+
if (lockfileVersion > 0) out.lockfileVersion = lockfileVersion
|
|
381
|
+
return out
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function sanitizePackages(value) {
|
|
385
|
+
const inventory = list(value?.inventory, CAPS.packages, packageFact, (a, b) => compare(a.ecosystem, b.ecosystem) || compare(a.name, b.name) || compare(a.version, b.version))
|
|
386
|
+
const directUsage = list(value?.directUsage, CAPS.usage, usage, (a, b) => compare(a.ecosystem, b.ecosystem) || compare(a.name, b.name))
|
|
387
|
+
const dependencyGraph = sanitizePackageDependencyGraph(value?.dependencyGraph)
|
|
388
|
+
const outState = state(value?.state)
|
|
389
|
+
const truncated = inventory.truncated || directUsage.truncated ||
|
|
390
|
+
(dependencyGraph.state === 'PARTIAL' && value?.dependencyGraph?.state === 'COMPLETE')
|
|
391
|
+
return {
|
|
392
|
+
state: truncated && outState === 'COMPLETE' ? 'PARTIAL' : outState,
|
|
393
|
+
verdict: verdict(value?.verdict),
|
|
394
|
+
completeness: {
|
|
395
|
+
inventory: count(value?.completeness?.inventory, inventory.total, inventory.items.length),
|
|
396
|
+
directUsage: count(value?.completeness?.directUsage, directUsage.total, directUsage.items.length),
|
|
397
|
+
dependencyGraphNodes: count(value?.completeness?.dependencyGraphNodes, dependencyGraph.completeness.nodes.total, dependencyGraph.nodes.length),
|
|
398
|
+
dependencyGraphEdges: count(value?.completeness?.dependencyGraphEdges, dependencyGraph.completeness.edges.total, dependencyGraph.edges.length),
|
|
399
|
+
reasons: reasons(value?.completeness?.reasons),
|
|
400
|
+
},
|
|
401
|
+
checks: checks(value?.checks), inventory: inventory.items, directUsage: directUsage.items, dependencyGraph,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function sanitizeEvidenceSnapshot(value) {
|
|
406
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || value.evidenceSnapshotV !== 1) throw new Error('invalid evidence snapshot')
|
|
407
|
+
const sections = {
|
|
408
|
+
architecture: sanitizeArchitecture(value.sections?.architecture),
|
|
409
|
+
duplicates: sanitizeDuplicates(value.sections?.duplicates),
|
|
410
|
+
health: sanitizeHealth(value.sections?.health),
|
|
411
|
+
technologies: sanitizeTechnologies(value.sections?.technologies),
|
|
412
|
+
packages: sanitizePackages(value.sections?.packages),
|
|
413
|
+
}
|
|
414
|
+
const states = Object.values(sections).map((section) => section.state)
|
|
415
|
+
const snapshotState = states.every((item) => item === 'ERROR') ? 'ERROR' : states.every((item) => item === 'COMPLETE' || item === 'NOT_APPLICABLE') ? 'COMPLETE' : 'PARTIAL'
|
|
416
|
+
const snapshot = {evidenceSnapshotV: 1, state: snapshotState, sections}
|
|
417
|
+
return {...snapshot, snapshotHash: createHash('sha256').update(stableStringify(snapshot)).digest('hex')}
|
|
418
|
+
}
|
package/src/mcp/sync-payload.mjs
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// Versioned, explicit wire schema for sync_graph. Never forward graph.json wholesale: it is a local
|
|
2
2
|
// cache file and may contain future fields or attacker-injected data that are not safe to upload.
|
|
3
|
+
import {sanitizeEvidenceSnapshot} from './sync-evidence.mjs';
|
|
3
4
|
|
|
4
5
|
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
6
|
+
const ABSOLUTE_PATH_FRAGMENT = /(?:^|[\/\s"'`(=])[a-z]:[\\/]|(?:^|[\s"'`(=])(?:\\\\[^\\/\s]+(?:[\\/]|$)|file:(?:\/\/)?[\\/]|\/(?!\/)[^\s])/i;
|
|
7
|
+
|
|
8
|
+
export const MAX_SYNC_BODY_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
export const MAX_SYNC_NODES = 25_000;
|
|
10
|
+
export const MAX_SYNC_LINKS = 100_000;
|
|
11
|
+
export const MAX_SYNC_EXTERNAL_IMPORTS = 50_000;
|
|
5
12
|
|
|
6
13
|
function metadataString(value, max = 4096) {
|
|
7
14
|
return typeof value === 'string' && value.length > 0 && value.length <= max && !CONTROL_CHARS.test(value)
|
|
@@ -30,6 +37,51 @@ function graphIdString(value) {
|
|
|
30
37
|
return repoRelativePathString(file) ? id : undefined;
|
|
31
38
|
}
|
|
32
39
|
|
|
40
|
+
// Optional display metadata is still attacker-controlled graph data. Keep useful labels/import
|
|
41
|
+
// specifiers, but never let an absolute host path hide inside one of those free-text fields.
|
|
42
|
+
function privacySafeText(value, max = 4096) {
|
|
43
|
+
const text = metadataString(value, max);
|
|
44
|
+
if (!text) return undefined;
|
|
45
|
+
return ABSOLUTE_PATH_FRAGMENT.test(text) ? undefined : text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function repoPathV3(value, max = 4096) {
|
|
49
|
+
const path = repoRelativePathString(value, max);
|
|
50
|
+
return path ? path.replace(/\\/g, '/') : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function graphIdV3(value) {
|
|
54
|
+
const id = metadataString(value);
|
|
55
|
+
if (!id) return undefined;
|
|
56
|
+
const hash = id.indexOf('#');
|
|
57
|
+
const file = hash < 0 ? id : id.slice(0, hash);
|
|
58
|
+
const safeFile = repoPathV3(file);
|
|
59
|
+
if (!safeFile) return undefined;
|
|
60
|
+
if (hash < 0) return safeFile;
|
|
61
|
+
const suffix = id.slice(hash);
|
|
62
|
+
// Builder IDs are `#symbol@line` (optionally with a short collision suffix). A symbol suffix
|
|
63
|
+
// never needs a path separator or whitespace; rejecting both closes an otherwise opaque channel.
|
|
64
|
+
if (suffix.length > 512 || !/^#[^\\/\s\u0000-\u001f\u007f]{1,511}$/u.test(suffix)) return undefined;
|
|
65
|
+
return `${safeFile}${suffix}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function safeToken(value, max = 256) {
|
|
69
|
+
const token = metadataString(value, max);
|
|
70
|
+
if (!token || !/^[\p{L}\p{N}_.:@+\-#$<>()\[\],]+$/u.test(token)) return undefined;
|
|
71
|
+
return token;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function packageName(value) {
|
|
75
|
+
const name = metadataString(value, 256);
|
|
76
|
+
return name && /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/i.test(name) ? name : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function externalSpecifier(value) {
|
|
80
|
+
const spec = metadataString(value, 512);
|
|
81
|
+
if (!spec || ABSOLUTE_PATH_FRAGMENT.test(spec) || /^(?:[a-z]:[\\/]|[\\/]|\.\.?[\\/])/i.test(spec)) return undefined;
|
|
82
|
+
return /^[a-z0-9@][a-z0-9@._:/+\-]*$/i.test(spec) ? spec : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
33
85
|
function finiteNumber(value) {
|
|
34
86
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
35
87
|
}
|
|
@@ -107,6 +159,84 @@ function sanitizeExternalImport(value) {
|
|
|
107
159
|
return out;
|
|
108
160
|
}
|
|
109
161
|
|
|
162
|
+
function sanitizeNodeV3(value) {
|
|
163
|
+
const out = sanitizeNode(value);
|
|
164
|
+
if (!out) return null;
|
|
165
|
+
const id = graphIdV3(value.id);
|
|
166
|
+
if (!id) return null;
|
|
167
|
+
const sourceFile = value.source_file == null ? undefined : repoPathV3(value.source_file);
|
|
168
|
+
if (value.source_file != null && !sourceFile) return null;
|
|
169
|
+
const hash = id.indexOf('#');
|
|
170
|
+
const idFile = hash < 0 ? id : id.slice(0, hash);
|
|
171
|
+
if (sourceFile && idFile !== sourceFile) return null;
|
|
172
|
+
out.id = id;
|
|
173
|
+
const label = privacySafeText(value.label, 1024);
|
|
174
|
+
if (label) out.label = label;
|
|
175
|
+
else delete out.label;
|
|
176
|
+
const fileType = safeToken(value.file_type, 32);
|
|
177
|
+
if (fileType) out.file_type = fileType;
|
|
178
|
+
else delete out.file_type;
|
|
179
|
+
if (sourceFile) out.source_file = sourceFile;
|
|
180
|
+
else delete out.source_file;
|
|
181
|
+
for (const key of ['symbol_kind', 'member_of', 'visibility']) setIf(out, key, safeToken(value[key], 128));
|
|
182
|
+
if (out.complexity) {
|
|
183
|
+
for (const key of ['family', 'scope', 'complexityScope', 'confidence']) {
|
|
184
|
+
const safe = safeToken(value.complexity?.[key], 32);
|
|
185
|
+
if (safe) out.complexity[key] = safe;
|
|
186
|
+
else delete out.complexity[key];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function sanitizeLinkV3(value) {
|
|
193
|
+
const out = sanitizeLink(value);
|
|
194
|
+
if (!out) return null;
|
|
195
|
+
const source = graphIdV3(value.source);
|
|
196
|
+
const target = graphIdV3(value.target);
|
|
197
|
+
if (!source || !target) return null;
|
|
198
|
+
out.source = source;
|
|
199
|
+
out.target = target;
|
|
200
|
+
for (const key of ['relation', 'confidence']) {
|
|
201
|
+
const safe = safeToken(value[key], 32);
|
|
202
|
+
if (safe) out[key] = safe;
|
|
203
|
+
else delete out[key];
|
|
204
|
+
}
|
|
205
|
+
const specifier = privacySafeText(value.specifier, 1024);
|
|
206
|
+
if (specifier) out.specifier = specifier;
|
|
207
|
+
else delete out.specifier;
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sanitizeExternalImportV3(value) {
|
|
212
|
+
const out = sanitizeExternalImport(value);
|
|
213
|
+
if (!out) return null;
|
|
214
|
+
const file = repoPathV3(value.file);
|
|
215
|
+
if (!file) return null;
|
|
216
|
+
out.file = file;
|
|
217
|
+
setIf(out, 'spec', externalSpecifier(value.spec));
|
|
218
|
+
if (!externalSpecifier(value.spec)) delete out.spec;
|
|
219
|
+
setIf(out, 'pkg', packageName(value.pkg));
|
|
220
|
+
if (!packageName(value.pkg)) delete out.pkg;
|
|
221
|
+
for (const key of ['kind', 'ecosystem']) {
|
|
222
|
+
const safe = safeToken(value[key], 64);
|
|
223
|
+
if (safe) out[key] = safe;
|
|
224
|
+
else delete out[key];
|
|
225
|
+
}
|
|
226
|
+
if (value.target != null) {
|
|
227
|
+
const target = repoPathV3(value.target);
|
|
228
|
+
if (target) out.target = target;
|
|
229
|
+
else delete out.target;
|
|
230
|
+
}
|
|
231
|
+
if (value.typeOnly === true) out.typeOnly = true;
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function assertArrayLimit(raw, key, limit) {
|
|
236
|
+
const count = Array.isArray(raw[key]) ? raw[key].length : 0;
|
|
237
|
+
if (count > limit) throw new Error(`${key} has ${count} entries; maximum is ${limit}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
110
240
|
export function createSyncPayload(raw) {
|
|
111
241
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || raw.repoBoundaryV !== 1) {
|
|
112
242
|
throw new Error('graph predates repository-boundary hardening');
|
|
@@ -130,3 +260,37 @@ export function createSyncPayload(raw) {
|
|
|
130
260
|
externalImports,
|
|
131
261
|
};
|
|
132
262
|
}
|
|
263
|
+
|
|
264
|
+
export function createSyncPayloadV3(raw, evidence) {
|
|
265
|
+
// Reuse the v2 schema gates, but construct v3 arrays independently so the stricter path/identity
|
|
266
|
+
// rules cannot change graph-only compatibility for existing endpoints.
|
|
267
|
+
const base = createSyncPayload(raw);
|
|
268
|
+
assertArrayLimit(raw, 'nodes', MAX_SYNC_NODES);
|
|
269
|
+
assertArrayLimit(raw, 'links', MAX_SYNC_LINKS);
|
|
270
|
+
assertArrayLimit(raw, 'externalImports', MAX_SYNC_EXTERNAL_IMPORTS);
|
|
271
|
+
const nodes = (raw.nodes || []).map(sanitizeNodeV3).filter(Boolean);
|
|
272
|
+
const nodeIds = new Set();
|
|
273
|
+
for (const node of nodes) {
|
|
274
|
+
if (nodeIds.has(node.id)) throw new Error(`duplicate node id in graph: ${node.id}`);
|
|
275
|
+
nodeIds.add(node.id);
|
|
276
|
+
}
|
|
277
|
+
const links = (raw.links || []).map(sanitizeLinkV3).filter(Boolean);
|
|
278
|
+
for (const link of links) {
|
|
279
|
+
if (!nodeIds.has(link.source) || !nodeIds.has(link.target)) {
|
|
280
|
+
throw new Error(`dangling link in graph: ${link.source} -> ${link.target}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const externalImports = (raw.externalImports || []).map(sanitizeExternalImportV3).filter(Boolean);
|
|
284
|
+
return {
|
|
285
|
+
syncPayloadV: 3,
|
|
286
|
+
repoBoundaryV: base.repoBoundaryV,
|
|
287
|
+
edgeTypesV: base.edgeTypesV,
|
|
288
|
+
extImportsV: base.extImportsV,
|
|
289
|
+
complexityV: base.complexityV,
|
|
290
|
+
evidenceV: 1,
|
|
291
|
+
nodes,
|
|
292
|
+
links,
|
|
293
|
+
externalImports,
|
|
294
|
+
evidence: sanitizeEvidenceSnapshot(evidence),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// MCP tool results keep a concise human summary and a stable machine-readable envelope together.
|
|
2
|
+
// Older clients continue to consume TextContent; MCP 2025-06 clients can use structuredContent
|
|
3
|
+
// directly without scraping prose. Tool implementations may opt into richer `result` data through
|
|
4
|
+
// toolResult(), while legacy string-returning tools still receive a deterministic envelope.
|
|
5
|
+
|
|
6
|
+
export const TOOL_RESULT_SCHEMA = 'weavatrix.tool.v1'
|
|
7
|
+
|
|
8
|
+
export function toolResult(text, result, extra = {}) {
|
|
9
|
+
return {
|
|
10
|
+
__weavatrixToolResult: true,
|
|
11
|
+
text: String(text ?? ''),
|
|
12
|
+
result: result && typeof result === 'object' ? result : {},
|
|
13
|
+
warnings: Array.isArray(extra.warnings) ? extra.warnings : [],
|
|
14
|
+
page: extra.page && typeof extra.page === 'object' ? extra.page : undefined,
|
|
15
|
+
completeness: extra.completeness && typeof extra.completeness === 'object' ? extra.completeness : undefined,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function repoName(repoRoot) {
|
|
20
|
+
const parts = String(repoRoot || '').replace(/[\\/]+$/, '').split(/[\\/]/)
|
|
21
|
+
const name = parts.pop() || 'unknown'
|
|
22
|
+
return name.normalize('NFKC').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '').slice(0, 128) || 'unknown'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function normalizeToolResult({toolName, value, args, ctx, refresh, warnings = [], freshness = 'unknown'}) {
|
|
26
|
+
const rich = value && typeof value === 'object' && value.__weavatrixToolResult === true
|
|
27
|
+
const text = rich ? value.text : String(value ?? '')
|
|
28
|
+
const resultWarnings = [...(rich ? value.warnings : []), ...warnings]
|
|
29
|
+
if (refresh?.notice) resultWarnings.unshift({code: 'GRAPH_AUTO_REFRESHED', message: refresh.notice})
|
|
30
|
+
if (refresh?.error) resultWarnings.unshift({code: 'GRAPH_AUTO_REFRESH_FAILED', message: refresh.error})
|
|
31
|
+
const structured = {
|
|
32
|
+
schemaVersion: TOOL_RESULT_SCHEMA,
|
|
33
|
+
tool: String(toolName || ''),
|
|
34
|
+
repo: {name: repoName(ctx?.repoRoot)},
|
|
35
|
+
graph: {
|
|
36
|
+
revision: refresh?.revision || null,
|
|
37
|
+
freshness: refresh?.error ? 'stale' : (refresh?.kind ? 'fresh' : freshness),
|
|
38
|
+
update: refresh?.kind || 'none',
|
|
39
|
+
changedFiles: Number(refresh?.changedFiles) || 0,
|
|
40
|
+
},
|
|
41
|
+
result: rich ? value.result : {text},
|
|
42
|
+
evidence: [],
|
|
43
|
+
warnings: resultWarnings,
|
|
44
|
+
page: rich ? (value.page || {}) : {},
|
|
45
|
+
...(rich && value.completeness ? {completeness: value.completeness} : {}),
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
text: args?.output_format === 'json' ? JSON.stringify(structured, null, 2) : `Repository: ${structured.repo.name}\n${text}`,
|
|
49
|
+
structured,
|
|
50
|
+
}
|
|
51
|
+
}
|