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.
Files changed (70) hide show
  1. package/README.md +138 -53
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +92 -30
  5. package/src/analysis/architecture-contract.js +343 -0
  6. package/src/analysis/audit-debt.js +94 -0
  7. package/src/analysis/change-classification.js +509 -0
  8. package/src/analysis/cycle-route.js +14 -0
  9. package/src/analysis/dead-check.js +5 -3
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +6 -0
  12. package/src/analysis/dep-check.js +40 -6
  13. package/src/analysis/dep-rules.js +12 -9
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints.js +8 -2
  17. package/src/analysis/findings.js +8 -1
  18. package/src/analysis/git-history.js +549 -0
  19. package/src/analysis/git-ref-graph.js +74 -0
  20. package/src/analysis/graph-analysis.aggregate.js +2 -2
  21. package/src/analysis/graph-analysis.edges.js +3 -0
  22. package/src/analysis/graph-analysis.summaries.js +1 -1
  23. package/src/analysis/http-contracts.js +581 -0
  24. package/src/analysis/internal-audit.collect.js +3 -2
  25. package/src/analysis/internal-audit.reach.js +52 -1
  26. package/src/analysis/internal-audit.run.js +58 -10
  27. package/src/analysis/java-source.js +36 -0
  28. package/src/analysis/package-reachability.js +39 -0
  29. package/src/analysis/static-test-reachability.js +133 -0
  30. package/src/build-graph.js +72 -13
  31. package/src/graph/build-worker.js +3 -4
  32. package/src/graph/builder/lang-js.js +71 -12
  33. package/src/graph/community.js +10 -0
  34. package/src/graph/file-lock.js +69 -0
  35. package/src/graph/freshness-probe.js +141 -0
  36. package/src/graph/graph-filter.js +28 -11
  37. package/src/graph/incremental-refresh.js +232 -0
  38. package/src/graph/internal-builder.barrels.js +169 -0
  39. package/src/graph/internal-builder.build.js +82 -11
  40. package/src/graph/internal-builder.langs.js +2 -1
  41. package/src/graph/layout.js +21 -5
  42. package/src/graph/repo-registry.js +124 -0
  43. package/src/mcp/catalog.mjs +62 -19
  44. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  45. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  46. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  47. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  48. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  49. package/src/mcp/evidence-snapshot.mjs +50 -0
  50. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  51. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  52. package/src/mcp/graph-context.mjs +100 -16
  53. package/src/mcp/graph-diff.mjs +74 -20
  54. package/src/mcp/staleness-notice.mjs +20 -0
  55. package/src/mcp/sync-evidence.mjs +418 -0
  56. package/src/mcp/sync-payload.mjs +164 -0
  57. package/src/mcp/tool-result.mjs +51 -0
  58. package/src/mcp/tools-actions.mjs +111 -30
  59. package/src/mcp/tools-architecture.mjs +144 -0
  60. package/src/mcp/tools-company.mjs +273 -0
  61. package/src/mcp/tools-graph.mjs +25 -14
  62. package/src/mcp/tools-health.mjs +336 -14
  63. package/src/mcp/tools-history.mjs +22 -0
  64. package/src/mcp/tools-impact-change.mjs +261 -0
  65. package/src/mcp/tools-impact.mjs +25 -6
  66. package/src/mcp-server.mjs +100 -17
  67. package/src/mcp-source-tools.mjs +11 -3
  68. package/src/path-classification.js +201 -0
  69. package/src/path-ignore.js +69 -0
  70. 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
+ }
@@ -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
+ }