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.
- package/README.md +168 -63
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +108 -39
- 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 +13 -6
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +163 -0
- package/src/analysis/dep-check.js +69 -147
- package/src/analysis/dep-rules.js +47 -31
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints-rust.js +124 -0
- package/src/analysis/endpoints.js +18 -5
- 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 +29 -28
- package/src/analysis/graph-analysis.edges.js +24 -0
- package/src/analysis/graph-analysis.js +1 -1
- package/src/analysis/graph-analysis.summaries.js +4 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +43 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +66 -13
- 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-java.js +177 -14
- package/src/graph/builder/lang-js.js +71 -12
- package/src/graph/builder/lang-rust.js +129 -6
- package/src/graph/community.js +27 -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 +111 -16
- package/src/graph/internal-builder.java.js +33 -0
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/internal-builder.resolvers.js +109 -2
- package/src/graph/layout.js +21 -5
- package/src/graph/relations.js +4 -0
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +64 -21
- 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 +106 -181
- package/src/mcp/graph-diff.mjs +217 -0
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +194 -8
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +114 -33
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph-hubs.mjs +58 -0
- package/src/mcp/tools-graph.mjs +36 -68
- package/src/mcp/tools-health.mjs +354 -20
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +35 -11
- 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,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
|
+
}
|