weavatrix 0.1.3 → 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 (78) hide show
  1. package/README.md +168 -63
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +108 -39
  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 +13 -6
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +163 -0
  12. package/src/analysis/dep-check.js +69 -147
  13. package/src/analysis/dep-rules.js +47 -31
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints-rust.js +124 -0
  17. package/src/analysis/endpoints.js +18 -5
  18. package/src/analysis/findings.js +8 -1
  19. package/src/analysis/git-history.js +549 -0
  20. package/src/analysis/git-ref-graph.js +74 -0
  21. package/src/analysis/graph-analysis.aggregate.js +29 -28
  22. package/src/analysis/graph-analysis.edges.js +24 -0
  23. package/src/analysis/graph-analysis.js +1 -1
  24. package/src/analysis/graph-analysis.summaries.js +4 -1
  25. package/src/analysis/http-contracts.js +581 -0
  26. package/src/analysis/internal-audit.collect.js +43 -2
  27. package/src/analysis/internal-audit.reach.js +52 -1
  28. package/src/analysis/internal-audit.run.js +66 -13
  29. package/src/analysis/java-source.js +36 -0
  30. package/src/analysis/package-reachability.js +39 -0
  31. package/src/analysis/static-test-reachability.js +133 -0
  32. package/src/build-graph.js +72 -13
  33. package/src/graph/build-worker.js +3 -4
  34. package/src/graph/builder/lang-java.js +177 -14
  35. package/src/graph/builder/lang-js.js +71 -12
  36. package/src/graph/builder/lang-rust.js +129 -6
  37. package/src/graph/community.js +27 -0
  38. package/src/graph/file-lock.js +69 -0
  39. package/src/graph/freshness-probe.js +141 -0
  40. package/src/graph/graph-filter.js +28 -11
  41. package/src/graph/incremental-refresh.js +232 -0
  42. package/src/graph/internal-builder.barrels.js +169 -0
  43. package/src/graph/internal-builder.build.js +111 -16
  44. package/src/graph/internal-builder.java.js +33 -0
  45. package/src/graph/internal-builder.langs.js +2 -1
  46. package/src/graph/internal-builder.resolvers.js +109 -2
  47. package/src/graph/layout.js +21 -5
  48. package/src/graph/relations.js +4 -0
  49. package/src/graph/repo-registry.js +124 -0
  50. package/src/mcp/catalog.mjs +64 -21
  51. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  52. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  53. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  54. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  55. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  56. package/src/mcp/evidence-snapshot.mjs +50 -0
  57. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  58. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  59. package/src/mcp/graph-context.mjs +106 -181
  60. package/src/mcp/graph-diff.mjs +217 -0
  61. package/src/mcp/staleness-notice.mjs +20 -0
  62. package/src/mcp/sync-evidence.mjs +418 -0
  63. package/src/mcp/sync-payload.mjs +194 -8
  64. package/src/mcp/tool-result.mjs +51 -0
  65. package/src/mcp/tools-actions.mjs +114 -33
  66. package/src/mcp/tools-architecture.mjs +144 -0
  67. package/src/mcp/tools-company.mjs +273 -0
  68. package/src/mcp/tools-graph-hubs.mjs +58 -0
  69. package/src/mcp/tools-graph.mjs +36 -68
  70. package/src/mcp/tools-health.mjs +354 -20
  71. package/src/mcp/tools-history.mjs +22 -0
  72. package/src/mcp/tools-impact-change.mjs +261 -0
  73. package/src/mcp/tools-impact.mjs +35 -11
  74. package/src/mcp-server.mjs +100 -17
  75. package/src/mcp-source-tools.mjs +11 -3
  76. package/src/path-classification.js +201 -0
  77. package/src/path-ignore.js +69 -0
  78. package/src/security/typosquat.js +1 -1
@@ -0,0 +1,217 @@
1
+ // Structural graph delta helpers split from graph-context so the process-lifetime graph indexes stay small.
2
+ import {buildFileImportGraph, findSccs} from '../analysis/dep-rules.js'
3
+ import {folderModuleOf} from '../analysis/graph-analysis.aggregate.js'
4
+ import {isStructuralRelation} from '../graph/relations.js'
5
+
6
+ // ---- graph diff ----------------------------------------------------------------------------------
7
+ // One previous state is enough (4-13 MB per repo — cheap): rebuild_graph snapshots the outgoing
8
+ // graph.json as graph.prev.json and reports the structural delta inline, at the exact moment the fix
9
+ // is being verified. graph_diff re-queries the same pair later. Raw node/edge dumps would be noise —
10
+ // the signal is aggregated: module-dependency drift, cycle count changes, newly orphaned symbols.
11
+ export const prevGraphPathFor = (graphPath) => String(graphPath).replace(/\.json$/, '.prev.json')
12
+ export const edgeEndpoint = (v) => String(v && typeof v === 'object' ? v.id : v)
13
+ export const fileOfId = (id) => { const s = String(id); const h = s.indexOf('#'); return h < 0 ? s : s.slice(0, h) }
14
+ const folderOfFile = folderModuleOf
15
+
16
+ const terminalLineSuffix = (id) => {
17
+ const raw = String(id)
18
+ return raw.includes('#') ? raw.replace(/@\d+$/, '') : raw
19
+ }
20
+
21
+ const sourceLine = (node) => {
22
+ const explicit = String(node?.source_location || '').match(/^L(\d+)/i)
23
+ if (explicit) return Number(explicit[1])
24
+ const suffix = String(node?.id || '').match(/@(\d+)$/)
25
+ return suffix ? Number(suffix[1]) : Number.MAX_SAFE_INTEGER
26
+ }
27
+
28
+ // Node ids retain source lines for precise read_source jumps, but location is not identity. This
29
+ // comparison-only index keeps file/name/kind/export/signature shape stable across line shifts and
30
+ // uses an ordinal only for true same-name overloads. Raw ids remain the user-facing evidence.
31
+ function stableNodeIndex(graph) {
32
+ const groups = new Map()
33
+ for (const node of graph.nodes || []) {
34
+ const raw = String(node?.id ?? '')
35
+ if (!raw) continue
36
+ const stem = terminalLineSuffix(raw)
37
+ const params = Number.isInteger(node?.complexity?.params) ? node.complexity.params : ''
38
+ const signature = [stem, node?.symbol_kind || '', node?.exported === true ? 'exported' : '', params].join('\u0000')
39
+ if (!groups.has(signature)) groups.set(signature, [])
40
+ groups.get(signature).push(node)
41
+ }
42
+ const byRaw = new Map()
43
+ const rawByStable = new Map()
44
+ for (const [signature, nodes] of groups) {
45
+ nodes.sort((a, b) => sourceLine(a) - sourceLine(b) || String(a.id).localeCompare(String(b.id)))
46
+ nodes.forEach((node, ordinal) => {
47
+ const stable = `${signature}\u0000${ordinal}`
48
+ const raw = String(node.id)
49
+ byRaw.set(raw, stable)
50
+ rawByStable.set(stable, raw)
51
+ })
52
+ }
53
+ return {byRaw, rawByStable, keys: new Set(rawByStable.keys())}
54
+ }
55
+
56
+ // Works on anything with {nodes, links}: the raw graph.json shape and the loadGraph struct alike.
57
+ export function diffGraphs(oldG, newG) {
58
+ const oldEdgeTypesV = Number(oldG.edgeTypesV) || 0
59
+ const newEdgeTypesV = Number(newG.edgeTypesV) || 0
60
+ const oldBarrelResolutionV = Number(oldG.barrelResolutionV) || 0
61
+ const newBarrelResolutionV = Number(newG.barrelResolutionV) || 0
62
+ const oldExtractorSchemaV = Number(oldG.extractorSchemaV) || 0
63
+ const newExtractorSchemaV = Number(newG.extractorSchemaV) || 0
64
+ const schemaChanges = [
65
+ oldEdgeTypesV !== newEdgeTypesV ? `edges v${oldEdgeTypesV}→v${newEdgeTypesV}` : null,
66
+ oldBarrelResolutionV !== newBarrelResolutionV ? `barrels v${oldBarrelResolutionV}→v${newBarrelResolutionV}` : null,
67
+ oldExtractorSchemaV !== newExtractorSchemaV ? `extractor v${oldExtractorSchemaV}→v${newExtractorSchemaV}` : null,
68
+ ].filter(Boolean)
69
+ const schemaMigration = schemaChanges.length > 0
70
+ const oldNodeIndex = stableNodeIndex(oldG)
71
+ const newNodeIndex = stableNodeIndex(newG)
72
+
73
+ const edgeClass = (l) => l.typeOnly === true ? 'type' : l.compileOnly === true ? 'compile' : 'runtime'
74
+ const stableEndpoint = (index, value) => {
75
+ const raw = edgeEndpoint(value)
76
+ return index.byRaw.get(raw) || raw
77
+ }
78
+ const edgeKey = (l, index) => `${stableEndpoint(index, l.source)}|${l.relation || ''}|${schemaMigration ? 'untyped' : edgeClass(l)}|${stableEndpoint(index, l.target)}`
79
+ const edgeSet = (graph, index) => new Set((graph.links || []).map((l) => edgeKey(l, index)))
80
+ const oldEdges = edgeSet(oldG, oldNodeIndex)
81
+ const newEdges = edgeSet(newG, newNodeIndex)
82
+
83
+ const moduleEdges = (graph, classification) => {
84
+ const set = new Set()
85
+ for (const l of graph.links || []) {
86
+ if (isStructuralRelation(l.relation) || l.barrelProxy === true) continue
87
+ if (edgeClass(l) !== classification) continue
88
+ const a = folderOfFile(fileOfId(edgeEndpoint(l.source)))
89
+ const b = folderOfFile(fileOfId(edgeEndpoint(l.target)))
90
+ if (a !== b) set.add(`${a} → ${b}`)
91
+ }
92
+ return set
93
+ }
94
+ // A parser/schema upgrade can discover edges that the old graph could not represent (Rust use/mod in
95
+ // edgeTypesV2). Do not call that architecture drift: establish a fresh classification baseline.
96
+ const oldMods = schemaMigration ? new Set() : moduleEdges(oldG, 'runtime')
97
+ const newMods = schemaMigration ? new Set() : moduleEdges(newG, 'runtime')
98
+ const oldTypeMods = schemaMigration ? new Set() : moduleEdges(oldG, 'type')
99
+ const newTypeMods = schemaMigration ? new Set() : moduleEdges(newG, 'type')
100
+ const oldCompileMods = schemaMigration ? new Set() : moduleEdges(oldG, 'compile')
101
+ const newCompileMods = schemaMigration ? new Set() : moduleEdges(newG, 'compile')
102
+
103
+ const incoming = (graph, index) => {
104
+ const m = new Map()
105
+ for (const l of graph.links || []) {
106
+ if (isStructuralRelation(l.relation) || l.barrelProxy === true) continue
107
+ const t = stableEndpoint(index, l.target)
108
+ m.set(t, (m.get(t) || 0) + 1)
109
+ }
110
+ return m
111
+ }
112
+ const oldIn = incoming(oldG, oldNodeIndex)
113
+ const newIn = incoming(newG, newNodeIndex)
114
+
115
+ const cycles = (graph, includeTypeOnly) => {
116
+ try {
117
+ const sccs = findSccs(buildFileImportGraph(graph, {includeTypeOnly}).adj)
118
+ .map((members) => members.map(String).sort())
119
+ .sort((a, b) => b.length - a.length || a.join('\n').localeCompare(b.join('\n')))
120
+ return {
121
+ count: sccs.length,
122
+ largest: sccs[0]?.length || 0,
123
+ groups: sccs,
124
+ }
125
+ } catch {
126
+ return null
127
+ }
128
+ }
129
+ const cycleDelta = (before, after) => {
130
+ if (!before || !after) return null
131
+ const key = (group) => group.join('|')
132
+ const beforeKeys = new Set(before.groups.map(key))
133
+ const afterKeys = new Set(after.groups.map(key))
134
+ const overlap = (a, b) => {
135
+ const bSet = new Set(b)
136
+ return a.reduce((n, member) => n + (bSet.has(member) ? 1 : 0), 0)
137
+ }
138
+ const unmatchedBefore = before.groups.filter((group) => !afterKeys.has(key(group)))
139
+ const unmatchedAfter = after.groups.filter((group) => !beforeKeys.has(key(group)))
140
+ const changed = unmatchedAfter.filter((group) => unmatchedBefore.some((old) => overlap(group, old) >= 2))
141
+ const introduced = unmatchedAfter.filter((group) => !unmatchedBefore.some((old) => overlap(group, old) >= 2))
142
+ const resolved = unmatchedBefore.filter((group) => !unmatchedAfter.some((next) => overlap(group, next) >= 2))
143
+ return {
144
+ before: before.count,
145
+ after: after.count,
146
+ largestBefore: before.largest,
147
+ largestAfter: after.largest,
148
+ introduced: introduced.map(key),
149
+ resolved: resolved.map(key),
150
+ membershipChanged: changed.length,
151
+ }
152
+ }
153
+
154
+ return {
155
+ schemaMigration: schemaMigration ? {from: oldEdgeTypesV, to: newEdgeTypesV, changes: schemaChanges} : null,
156
+ nodes: {
157
+ added: [...newNodeIndex.keys].filter((key) => !oldNodeIndex.keys.has(key)).map((key) => newNodeIndex.rawByStable.get(key)),
158
+ removed: [...oldNodeIndex.keys].filter((key) => !newNodeIndex.keys.has(key)).map((key) => oldNodeIndex.rawByStable.get(key))
159
+ },
160
+ edges: {
161
+ added: [...newEdges].filter((k) => !oldEdges.has(k)).length,
162
+ removed: [...oldEdges].filter((k) => !newEdges.has(k)).length
163
+ },
164
+ moduleEdges: {
165
+ added: [...newMods].filter((k) => !oldMods.has(k)),
166
+ removed: [...oldMods].filter((k) => !newMods.has(k)),
167
+ typeAdded: [...newTypeMods].filter((k) => !oldTypeMods.has(k)),
168
+ typeRemoved: [...oldTypeMods].filter((k) => !newTypeMods.has(k)),
169
+ compileAdded: [...newCompileMods].filter((k) => !oldCompileMods.has(k)),
170
+ compileRemoved: [...oldCompileMods].filter((k) => !newCompileMods.has(k)),
171
+ },
172
+ // survived the rebuild but lost every caller/importer — likely made dead by the change
173
+ orphaned: [...oldIn.keys()]
174
+ .filter((key) => newNodeIndex.keys.has(key) && !newIn.has(key))
175
+ .map((key) => newNodeIndex.rawByStable.get(key) || oldNodeIndex.rawByStable.get(key)),
176
+ cycles: {
177
+ runtime: schemaMigration ? null : cycleDelta(cycles(oldG, false), cycles(newG, false)),
178
+ // Backward-compatible field name: since edgeTypesV 2 this includes typeOnly and compileOnly.
179
+ typeInclusive: schemaMigration ? null : cycleDelta(cycles(oldG, true), cycles(newG, true)),
180
+ }
181
+ }
182
+ }
183
+
184
+ export function formatGraphDiff(d) {
185
+ if (!d.nodes.added.length && !d.nodes.removed.length && !d.edges.added && !d.edges.removed) {
186
+ return d.schemaMigration
187
+ ? `Graph extractor/schema upgraded (${d.schemaMigration.changes.join(', ')}); compile-time baseline established. Runtime/compile-time cycle and module classifications are intentionally not compared on this rebuild.`
188
+ : 'No structural change between the two graph states.'
189
+ }
190
+ const cap = (list, n) => list.slice(0, n).map((x) => ` ${x}`).concat(list.length > n ? [` … +${list.length - n} more`] : [])
191
+ const lines = [`Structural delta: nodes +${d.nodes.added.length}/−${d.nodes.removed.length}, edges +${d.edges.added}/−${d.edges.removed}.`]
192
+ if (d.schemaMigration) lines.push(`Graph extractor/schema upgraded (${d.schemaMigration.changes.join(', ')}); runtime/compile-time cycle and module classifications are intentionally not compared until the next rebuild.`)
193
+ const runtime = d.cycles?.runtime
194
+ if (runtime && (runtime.before !== runtime.after || runtime.largestBefore !== runtime.largestAfter || runtime.introduced.length || runtime.resolved.length || runtime.membershipChanged)) {
195
+ const changes = []
196
+ if (runtime.introduced.length) changes.push(`${runtime.introduced.length} genuinely new runtime SCC(s) — review`)
197
+ if (runtime.resolved.length) changes.push(`${runtime.resolved.length} runtime SCC(s) resolved`)
198
+ if (runtime.membershipChanged) changes.push(`${runtime.membershipChanged} SCC membership change(s)`)
199
+ const verdict = changes.length ? `; ${changes.join('; ')}` : ''
200
+ lines.push(`Runtime import cycles: count ${runtime.before} → ${runtime.after}, largest SCC ${runtime.largestBefore} → ${runtime.largestAfter}${verdict}.`)
201
+ }
202
+ const all = d.cycles?.typeInclusive
203
+ if (all && (all.before !== all.after || all.largestBefore !== all.largestAfter) &&
204
+ (!runtime || all.before !== runtime.before || all.after !== runtime.after || all.largestBefore !== runtime.largestBefore || all.largestAfter !== runtime.largestAfter)) {
205
+ lines.push(`Compile-time-inclusive dependency SCCs: count ${all.before} → ${all.after}, largest ${all.largestBefore} → ${all.largestAfter} (compile-time coupling, not necessarily a runtime cycle).`)
206
+ }
207
+ if (d.moduleEdges.added.length) lines.push('NEW module dependencies (architecture drift — review):', ...cap(d.moduleEdges.added, 12))
208
+ if (d.moduleEdges.removed.length) lines.push('Removed module dependencies (decoupling confirmed):', ...cap(d.moduleEdges.removed, 12))
209
+ if (d.moduleEdges.typeAdded.length) lines.push('New type-only module dependencies (compile-time coupling):', ...cap(d.moduleEdges.typeAdded, 12))
210
+ if (d.moduleEdges.typeRemoved.length) lines.push('Removed type-only module dependencies:', ...cap(d.moduleEdges.typeRemoved, 12))
211
+ if (d.moduleEdges.compileAdded.length) lines.push('New compile-only module dependencies (compile-time coupling):', ...cap(d.moduleEdges.compileAdded, 12))
212
+ if (d.moduleEdges.compileRemoved.length) lines.push('Removed compile-only module dependencies:', ...cap(d.moduleEdges.compileRemoved, 12))
213
+ if (d.orphaned.length) lines.push('Symbols that lost their last caller/importer (now dead?):', ...cap(d.orphaned, 10))
214
+ if (d.nodes.added.length) lines.push('Added nodes:', ...cap(d.nodes.added, 12))
215
+ if (d.nodes.removed.length) lines.push('Removed nodes:', ...cap(d.nodes.removed, 12))
216
+ return lines.join('\n')
217
+ }
@@ -0,0 +1,20 @@
1
+ // Staleness is important, but repeating an identical paragraph after every graph call drowns the
2
+ // actual answer. Surface a changed condition immediately, then remind periodically. graph_stats can
3
+ // force the notice because freshness is the purpose of that tool.
4
+ export function createStalenessNoticeGate(cooldownMs = 5 * 60_000) {
5
+ let lastKey = ''
6
+ let lastShownAt = 0
7
+ return {
8
+ shouldShow({line, graphPath = '', force = false, now = Date.now()} = {}) {
9
+ if (!line) return false
10
+ const key = `${graphPath}\u0000${line}`
11
+ if (force || key !== lastKey || now - lastShownAt >= cooldownMs) {
12
+ lastKey = key
13
+ lastShownAt = now
14
+ return true
15
+ }
16
+ return false
17
+ },
18
+ reset() { lastKey = ''; lastShownAt = 0 },
19
+ }
20
+ }
@@ -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
+ }