weavatrix 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -53
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +92 -30
- package/src/analysis/architecture-contract.js +343 -0
- package/src/analysis/audit-debt.js +94 -0
- package/src/analysis/change-classification.js +509 -0
- package/src/analysis/cycle-route.js +14 -0
- package/src/analysis/dead-check.js +5 -3
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +6 -0
- package/src/analysis/dep-check.js +40 -6
- package/src/analysis/dep-rules.js +12 -9
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints.js +8 -2
- package/src/analysis/findings.js +8 -1
- package/src/analysis/git-history.js +549 -0
- package/src/analysis/git-ref-graph.js +74 -0
- package/src/analysis/graph-analysis.aggregate.js +2 -2
- package/src/analysis/graph-analysis.edges.js +3 -0
- package/src/analysis/graph-analysis.summaries.js +1 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +3 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +58 -10
- package/src/analysis/java-source.js +36 -0
- package/src/analysis/package-reachability.js +39 -0
- package/src/analysis/static-test-reachability.js +133 -0
- package/src/build-graph.js +72 -13
- package/src/graph/build-worker.js +3 -4
- package/src/graph/builder/lang-js.js +71 -12
- package/src/graph/community.js +10 -0
- package/src/graph/file-lock.js +69 -0
- package/src/graph/freshness-probe.js +141 -0
- package/src/graph/graph-filter.js +28 -11
- package/src/graph/incremental-refresh.js +232 -0
- package/src/graph/internal-builder.barrels.js +169 -0
- package/src/graph/internal-builder.build.js +82 -11
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/layout.js +21 -5
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +62 -19
- package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
- package/src/mcp/evidence-snapshot.common.mjs +160 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
- package/src/mcp/evidence-snapshot.health.mjs +95 -0
- package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
- package/src/mcp/evidence-snapshot.mjs +50 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
- package/src/mcp/evidence-snapshot.structure.mjs +81 -0
- package/src/mcp/graph-context.mjs +100 -16
- package/src/mcp/graph-diff.mjs +74 -20
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +164 -0
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +111 -30
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph.mjs +25 -14
- package/src/mcp/tools-health.mjs +336 -14
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +25 -6
- package/src/mcp-server.mjs +100 -17
- package/src/mcp-source-tools.mjs +11 -3
- package/src/path-classification.js +201 -0
- package/src/path-ignore.js +69 -0
- package/src/security/typosquat.js +1 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto'
|
|
2
|
+
import {computeDuplicates} from '../analysis/duplicates.js'
|
|
3
|
+
import {
|
|
4
|
+
CAPS, STATE, VERDICT, bounded, compareText, graphId, repoRelativePath,
|
|
5
|
+
} from './evidence-snapshot.common.mjs'
|
|
6
|
+
|
|
7
|
+
const CLONE_MIN_SIMILARITY = 80
|
|
8
|
+
const DIVERGENCE_MAX_SIMILARITY = 45
|
|
9
|
+
const MIN_TOKENS = 50
|
|
10
|
+
const FILTERED_CLASSES = new Set(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
|
|
11
|
+
const SAFE_SYMBOL = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
12
|
+
|
|
13
|
+
export const DUPLICATE_EVIDENCE_THRESHOLDS = Object.freeze({
|
|
14
|
+
clones: Object.freeze({mode: 'renamed', minSimilarityPercent: CLONE_MIN_SIMILARITY, minTokens: MIN_TOKENS}),
|
|
15
|
+
divergence: Object.freeze({
|
|
16
|
+
sameName: true,
|
|
17
|
+
maxSimilarityPercent: DIVERGENCE_MAX_SIMILARITY,
|
|
18
|
+
minTokens: MIN_TOKENS,
|
|
19
|
+
maxImplementationsPerName: 12,
|
|
20
|
+
}),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const hash = (value) => createHash('sha256').update(value).digest('hex').slice(0, 24)
|
|
24
|
+
const positiveInteger = (value) => Number.isFinite(value) && value >= 1 ? Math.trunc(value) : undefined
|
|
25
|
+
|
|
26
|
+
function safeGraphNodeId(value, file) {
|
|
27
|
+
const id = graphId(value)
|
|
28
|
+
if (!id) return undefined
|
|
29
|
+
const hashIndex = id.indexOf('#')
|
|
30
|
+
if (hashIndex < 1 || id.slice(0, hashIndex) !== file) return undefined
|
|
31
|
+
const suffix = id.slice(hashIndex)
|
|
32
|
+
return /^#[^\s\\/\u0000-\u001f\u007f]{1,511}$/u.test(suffix) ? id : undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function member(fragment) {
|
|
36
|
+
if (!fragment || typeof fragment !== 'object' || fragment.kind === 'string' || fragment.excluded === true) return null
|
|
37
|
+
const classes = Array.isArray(fragment.classes) ? fragment.classes : []
|
|
38
|
+
if (fragment.test === true || classes.some((value) => FILTERED_CLASSES.has(value))) return null
|
|
39
|
+
const file = repoRelativePath(fragment.file)
|
|
40
|
+
const startLine = positiveInteger(fragment.start)
|
|
41
|
+
const endLine = positiveInteger(fragment.end)
|
|
42
|
+
const tokens = positiveInteger(fragment.n)
|
|
43
|
+
if (!file || !startLine || !endLine || endLine < startLine || !tokens || tokens < MIN_TOKENS) return null
|
|
44
|
+
const out = {file, startLine, endLine, tokens}
|
|
45
|
+
const nodeId = safeGraphNodeId(fragment.id, file)
|
|
46
|
+
if (nodeId) out.graphNodeId = nodeId
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function memberIdentity(value) {
|
|
51
|
+
return `${value.file}\0${value.startLine}\0${value.endLine}\0${value.graphNodeId || ''}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function compareMember(a, b) {
|
|
55
|
+
return compareText(a.file, b.file) || a.startLine - b.startLine || a.endLine - b.endLine ||
|
|
56
|
+
compareText(a.graphNodeId || '', b.graphNodeId || '')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function eligibleMembers(fragments) {
|
|
60
|
+
const out = new Map()
|
|
61
|
+
for (let index = 0; index < fragments.length; index++) {
|
|
62
|
+
const value = member(fragments[index])
|
|
63
|
+
if (value) out.set(index, value)
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function cloneGroups(data, membersByIndex) {
|
|
69
|
+
const parent = new Map([...membersByIndex.keys()].map((index) => [index, index]))
|
|
70
|
+
const find = (index) => {
|
|
71
|
+
let root = index
|
|
72
|
+
while (parent.get(root) !== root) root = parent.get(root)
|
|
73
|
+
while (parent.get(index) !== index) {
|
|
74
|
+
const next = parent.get(index)
|
|
75
|
+
parent.set(index, root)
|
|
76
|
+
index = next
|
|
77
|
+
}
|
|
78
|
+
return root
|
|
79
|
+
}
|
|
80
|
+
const union = (left, right) => {
|
|
81
|
+
const a = find(left), b = find(right)
|
|
82
|
+
if (a !== b) parent.set(a > b ? a : b, a > b ? b : a)
|
|
83
|
+
}
|
|
84
|
+
const links = []
|
|
85
|
+
for (const pair of Array.isArray(data?.modes?.renamed) ? data.modes.renamed : []) {
|
|
86
|
+
if (!Array.isArray(pair) || pair.length < 3) continue
|
|
87
|
+
const left = Number(pair[0]), right = Number(pair[1]), similarity = Number(pair[2])
|
|
88
|
+
if (!Number.isInteger(left) || !Number.isInteger(right) || left === right ||
|
|
89
|
+
!membersByIndex.has(left) || !membersByIndex.has(right) ||
|
|
90
|
+
!Number.isFinite(similarity) || similarity < CLONE_MIN_SIMILARITY || similarity > 100) continue
|
|
91
|
+
union(left, right)
|
|
92
|
+
links.push({left, right, similarity: Math.trunc(similarity)})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const components = new Map()
|
|
96
|
+
for (const index of membersByIndex.keys()) {
|
|
97
|
+
const root = find(index)
|
|
98
|
+
if (!components.has(root)) components.set(root, [])
|
|
99
|
+
components.get(root).push(index)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const groups = []
|
|
103
|
+
for (const indices of components.values()) {
|
|
104
|
+
if (indices.length < 2) continue
|
|
105
|
+
const unique = new Map()
|
|
106
|
+
for (const index of indices) {
|
|
107
|
+
const value = membersByIndex.get(index)
|
|
108
|
+
unique.set(memberIdentity(value), value)
|
|
109
|
+
}
|
|
110
|
+
const members = [...unique.values()].sort(compareMember)
|
|
111
|
+
if (members.length < 2) continue
|
|
112
|
+
const indexSet = new Set(indices)
|
|
113
|
+
const similarities = links
|
|
114
|
+
.filter((link) => indexSet.has(link.left) && indexSet.has(link.right))
|
|
115
|
+
.map((link) => link.similarity)
|
|
116
|
+
if (!similarities.length) continue
|
|
117
|
+
const identity = members.map(memberIdentity).join('\0')
|
|
118
|
+
groups.push({
|
|
119
|
+
id: hash(`clone\0${identity}`),
|
|
120
|
+
memberCount: members.length,
|
|
121
|
+
totalTokens: members.reduce((sum, value) => sum + value.tokens, 0),
|
|
122
|
+
strongestSimilarity: Math.max(...similarities),
|
|
123
|
+
weakestLinkedSimilarity: Math.min(...similarities),
|
|
124
|
+
membersTruncated: members.length > CAPS.duplicateMembers,
|
|
125
|
+
members: members.slice(0, CAPS.duplicateMembers),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
return groups.sort((a, b) => b.totalTokens - a.totalTokens || b.memberCount - a.memberCount ||
|
|
129
|
+
b.strongestSimilarity - a.strongestSimilarity || compareText(a.id, b.id))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function divergenceCandidates(data, membersByIndex) {
|
|
133
|
+
const candidates = []
|
|
134
|
+
for (const twin of Array.isArray(data?.nameTwins) ? data.nameTwins : []) {
|
|
135
|
+
const symbol = typeof twin?.label === 'string' && SAFE_SYMBOL.test(twin.label) ? twin.label : undefined
|
|
136
|
+
if (!symbol) continue
|
|
137
|
+
const pairs = []
|
|
138
|
+
for (const pair of Array.isArray(twin.pairs) ? twin.pairs : []) {
|
|
139
|
+
const left = Number(pair?.a), right = Number(pair?.b), similarity = Number(pair?.similarity)
|
|
140
|
+
const leftMember = membersByIndex.get(left), rightMember = membersByIndex.get(right)
|
|
141
|
+
if (!Number.isInteger(left) || !Number.isInteger(right) || left === right || !leftMember || !rightMember ||
|
|
142
|
+
leftMember.file === rightMember.file || !Number.isFinite(similarity) ||
|
|
143
|
+
similarity < 0 || similarity > DIVERGENCE_MAX_SIMILARITY) continue
|
|
144
|
+
const members = [leftMember, rightMember].sort(compareMember)
|
|
145
|
+
pairs.push({
|
|
146
|
+
similarity: Math.trunc(similarity),
|
|
147
|
+
totalTokens: members[0].tokens + members[1].tokens,
|
|
148
|
+
members,
|
|
149
|
+
identity: members.map(memberIdentity).join('\0'),
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
pairs.sort((a, b) => a.similarity - b.similarity || b.totalTokens - a.totalTokens || compareText(a.identity, b.identity))
|
|
153
|
+
const selected = pairs[0]
|
|
154
|
+
if (!selected) continue
|
|
155
|
+
candidates.push({
|
|
156
|
+
id: hash(`divergence\0${symbol.toLowerCase()}\0${selected.identity}`),
|
|
157
|
+
symbol,
|
|
158
|
+
similarity: selected.similarity,
|
|
159
|
+
totalTokens: selected.totalTokens,
|
|
160
|
+
members: selected.members,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
return candidates.sort((a, b) => b.totalTokens - a.totalTokens || a.similarity - b.similarity ||
|
|
164
|
+
compareText(a.symbol, b.symbol) || compareText(a.id, b.id))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function errorSection(reason = 'DUPLICATE_ANALYSIS_ERROR') {
|
|
168
|
+
return {
|
|
169
|
+
state: STATE.ERROR,
|
|
170
|
+
verdict: VERDICT.UNKNOWN,
|
|
171
|
+
thresholds: DUPLICATE_EVIDENCE_THRESHOLDS,
|
|
172
|
+
completeness: {
|
|
173
|
+
fragments: {total: 0, eligible: 0, filtered: 0},
|
|
174
|
+
cloneGroups: {total: 0, returned: 0, truncated: false},
|
|
175
|
+
divergenceCandidates: {total: 0, returned: 0, truncated: false},
|
|
176
|
+
reasons: [reason],
|
|
177
|
+
},
|
|
178
|
+
cloneGroups: [],
|
|
179
|
+
divergenceCandidates: [],
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function buildDuplicatesSection(repoRoot, graph) {
|
|
184
|
+
try {
|
|
185
|
+
const data = computeDuplicates(repoRoot, graph, {nameTwins: true, includeStrings: false})
|
|
186
|
+
if (!data?.ok || !Array.isArray(data.frags)) return errorSection()
|
|
187
|
+
const membersByIndex = eligibleMembers(data.frags)
|
|
188
|
+
const groups = bounded(cloneGroups(data, membersByIndex), CAPS.duplicateGroups)
|
|
189
|
+
const divergence = bounded(divergenceCandidates(data, membersByIndex), CAPS.divergenceCandidates)
|
|
190
|
+
const reasons = []
|
|
191
|
+
if (!Number.isFinite(data.graphSymbols) || data.graphSymbols < 1) reasons.push('NO_GRAPH_SYMBOLS')
|
|
192
|
+
if (data.completeness?.assetFiles?.truncated) reasons.push('ASSET_SCAN_TRUNCATED')
|
|
193
|
+
if (data.completeness?.nameTwinsTruncated) reasons.push('NAME_TWIN_SCAN_TRUNCATED')
|
|
194
|
+
if (groups.items.some((group) => group.membersTruncated)) reasons.push('CLONE_MEMBERS_TRUNCATED')
|
|
195
|
+
if (groups.completeness.truncated) reasons.push('CLONE_GROUPS_TRUNCATED')
|
|
196
|
+
if (divergence.completeness.truncated) reasons.push('DIVERGENCE_CANDIDATES_TRUNCATED')
|
|
197
|
+
return {
|
|
198
|
+
state: reasons.length ? STATE.PARTIAL : STATE.COMPLETE,
|
|
199
|
+
verdict: VERDICT.UNKNOWN,
|
|
200
|
+
thresholds: DUPLICATE_EVIDENCE_THRESHOLDS,
|
|
201
|
+
completeness: {
|
|
202
|
+
fragments: {
|
|
203
|
+
total: data.frags.length,
|
|
204
|
+
eligible: membersByIndex.size,
|
|
205
|
+
filtered: data.frags.length - membersByIndex.size,
|
|
206
|
+
},
|
|
207
|
+
cloneGroups: groups.completeness,
|
|
208
|
+
divergenceCandidates: divergence.completeness,
|
|
209
|
+
reasons,
|
|
210
|
+
},
|
|
211
|
+
cloneGroups: groups.items,
|
|
212
|
+
divergenceCandidates: divergence.items,
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
return errorSection()
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CAPS, COMPLEXITY_THRESHOLDS, STATE, VERDICT, addIf, bounded, compareText, graphId,
|
|
3
|
+
normalizeCheckState, numericRecord, optionalNonNegativeInteger, privacySafeText, repoRelativePath,
|
|
4
|
+
sanitizeFinding,
|
|
5
|
+
} from './evidence-snapshot.common.mjs'
|
|
6
|
+
|
|
7
|
+
function buildHotspots(graph) {
|
|
8
|
+
let analyzed = 0
|
|
9
|
+
const facts = []
|
|
10
|
+
for (const node of Array.isArray(graph?.nodes) ? graph.nodes : []) {
|
|
11
|
+
if (!node || typeof node !== 'object' || !node.complexity || typeof node.complexity !== 'object') continue
|
|
12
|
+
const id = graphId(node.id)
|
|
13
|
+
const file = repoRelativePath(node.source_file)
|
|
14
|
+
if (!id || !file) continue
|
|
15
|
+
analyzed++
|
|
16
|
+
const loc = optionalNonNegativeInteger(node.complexity.loc)
|
|
17
|
+
const cyclomatic = optionalNonNegativeInteger(node.complexity.cyclomatic)
|
|
18
|
+
const params = optionalNonNegativeInteger(node.complexity.params)
|
|
19
|
+
const breaches = []
|
|
20
|
+
let severity = 'medium'
|
|
21
|
+
if (loc !== undefined && loc >= COMPLEXITY_THRESHOLDS.loc.high) { breaches.push('LOC_HIGH'); severity = 'high' }
|
|
22
|
+
else if (loc !== undefined && loc >= COMPLEXITY_THRESHOLDS.loc.warning) breaches.push('LOC_WARNING')
|
|
23
|
+
if (cyclomatic !== undefined && cyclomatic >= COMPLEXITY_THRESHOLDS.cyclomatic.high) { breaches.push('CYCLOMATIC_HIGH'); severity = 'high' }
|
|
24
|
+
else if (cyclomatic !== undefined && cyclomatic >= COMPLEXITY_THRESHOLDS.cyclomatic.warning) breaches.push('CYCLOMATIC_WARNING')
|
|
25
|
+
if (params !== undefined && params >= COMPLEXITY_THRESHOLDS.params.high) { breaches.push('PARAMS_HIGH'); severity = 'high' }
|
|
26
|
+
else if (params !== undefined && params >= COMPLEXITY_THRESHOLDS.params.warning) breaches.push('PARAMS_WARNING')
|
|
27
|
+
if (!breaches.length) continue
|
|
28
|
+
const fact = {id, file, severity, breaches: breaches.sort(compareText)}
|
|
29
|
+
addIf(fact, 'symbol', privacySafeText(node.label, 256))
|
|
30
|
+
addIf(fact, 'startLine', optionalNonNegativeInteger(node.complexity.startLine))
|
|
31
|
+
addIf(fact, 'endLine', optionalNonNegativeInteger(node.complexity.endLine))
|
|
32
|
+
addIf(fact, 'loc', loc)
|
|
33
|
+
addIf(fact, 'cyclomatic', cyclomatic)
|
|
34
|
+
addIf(fact, 'params', params)
|
|
35
|
+
facts.push(fact)
|
|
36
|
+
}
|
|
37
|
+
facts.sort((a, b) => (a.severity === b.severity ? 0 : a.severity === 'high' ? -1 : 1) ||
|
|
38
|
+
(b.loc || 0) - (a.loc || 0) || (b.cyclomatic || 0) - (a.cyclomatic || 0) ||
|
|
39
|
+
(b.params || 0) - (a.params || 0) || compareText(a.id, b.id))
|
|
40
|
+
return {analyzed, ...bounded(facts, CAPS.hotspots)}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildHealthSection(graph, audit) {
|
|
44
|
+
if (!audit?.ok) {
|
|
45
|
+
return {
|
|
46
|
+
state: STATE.ERROR,
|
|
47
|
+
verdict: VERDICT.UNKNOWN,
|
|
48
|
+
completeness: {reasons: ['AUDIT_ERROR']},
|
|
49
|
+
summary: {bySeverity: {}, byCategory: {}},
|
|
50
|
+
checks: {osv: STATE.ERROR, malware: STATE.ERROR},
|
|
51
|
+
findings: [],
|
|
52
|
+
complexity: {thresholds: COMPLEXITY_THRESHOLDS, analyzed: 0, hotspots: []},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const findings = bounded((audit.findings || []).map(sanitizeFinding).filter(Boolean)
|
|
56
|
+
.sort((a, b) => compareText(a.severity, b.severity) || compareText(a.category, b.category) ||
|
|
57
|
+
compareText(a.rule, b.rule) || compareText(a.file || a.package || '', b.file || b.package || '') || compareText(a.id, b.id)),
|
|
58
|
+
CAPS.findings)
|
|
59
|
+
const hotspots = buildHotspots(graph)
|
|
60
|
+
const checks = {
|
|
61
|
+
osv: normalizeCheckState(audit.checks?.osv?.status),
|
|
62
|
+
malware: normalizeCheckState(audit.checks?.malware?.status),
|
|
63
|
+
}
|
|
64
|
+
const state = Object.values(checks).every((value) => value === STATE.COMPLETE || value === STATE.NOT_APPLICABLE)
|
|
65
|
+
? STATE.COMPLETE
|
|
66
|
+
: STATE.PARTIAL
|
|
67
|
+
return {
|
|
68
|
+
state,
|
|
69
|
+
verdict: findings.completeness.total > 0 || hotspots.completeness.total > 0
|
|
70
|
+
? VERDICT.FAIL
|
|
71
|
+
: state === STATE.COMPLETE ? VERDICT.PASS : VERDICT.UNKNOWN,
|
|
72
|
+
completeness: {
|
|
73
|
+
findings: findings.completeness,
|
|
74
|
+
hotspots: hotspots.completeness,
|
|
75
|
+
complexity: {analyzed: hotspots.analyzed},
|
|
76
|
+
reasons: state === STATE.PARTIAL ? ['OPTIONAL_CHECKS_INCOMPLETE'] : [],
|
|
77
|
+
},
|
|
78
|
+
summary: {
|
|
79
|
+
bySeverity: numericRecord(audit.summary?.bySeverity, ['critical', 'high', 'medium', 'low', 'info']),
|
|
80
|
+
byCategory: numericRecord(audit.summary?.byCategory, ['unused', 'structure', 'vulnerability', 'malware']),
|
|
81
|
+
dead: numericRecord(audit.deadReport, ['deadSymbols', 'deadFiles', 'unusedExports']),
|
|
82
|
+
structure: numericRecord(audit.structureReport, [
|
|
83
|
+
'runtimeImportEdges', 'typeOnlyImportEdges', 'compileOnlyImportEdges', 'runtimeCycles',
|
|
84
|
+
'compileTimeCouplings', 'largestCycle', 'largestCompileTimeCoupling', 'orphans', 'boundaryViolations',
|
|
85
|
+
]),
|
|
86
|
+
},
|
|
87
|
+
checks,
|
|
88
|
+
findings: findings.items,
|
|
89
|
+
complexity: {
|
|
90
|
+
thresholds: COMPLEXITY_THRESHOLDS,
|
|
91
|
+
analyzed: hotspots.analyzed,
|
|
92
|
+
hotspots: hotspots.items,
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CAPS, STATE, VERDICT, addIf, bounded, compareText, metadataString, normalizeCheckState,
|
|
3
|
+
repoRelativePath, safeToken,
|
|
4
|
+
} from './evidence-snapshot.common.mjs'
|
|
5
|
+
import {buildPackageDependencyGraph} from './evidence-snapshot.package-graph.mjs'
|
|
6
|
+
|
|
7
|
+
const FIXED_PACKAGE_SOURCES = new Set([
|
|
8
|
+
'package-lock', 'yarn-lock', 'requirements', 'venv', 'poetry-lock', 'uv-lock',
|
|
9
|
+
'pipfile-lock', 'go-sum', 'go-mod', 'node_modules',
|
|
10
|
+
])
|
|
11
|
+
const PACKAGE_SOURCE_FILE = /(^|\/)(?: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
|
|
12
|
+
|
|
13
|
+
function sanitizeBadge(category, value) {
|
|
14
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
15
|
+
const id = safeToken(value.id, 128)
|
|
16
|
+
if (!id) return null
|
|
17
|
+
const out = {category, id}
|
|
18
|
+
addIf(out, 'name', metadataString(value.name || value.label, 128))
|
|
19
|
+
addIf(out, 'kind', safeToken(value.kind, 64))
|
|
20
|
+
addIf(out, 'title', metadataString(value.title, 128))
|
|
21
|
+
addIf(out, 'version', safeToken(value.version, 64))
|
|
22
|
+
return out
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildTechnologiesSection(stack, stackError) {
|
|
26
|
+
if (stackError) {
|
|
27
|
+
return {
|
|
28
|
+
state: STATE.ERROR,
|
|
29
|
+
verdict: VERDICT.UNKNOWN,
|
|
30
|
+
completeness: {badges: {total: 0, returned: 0, truncated: false}, reasons: ['STACK_DETECTION_ERROR']},
|
|
31
|
+
badges: [],
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const categories = ['languages', 'runtimes', 'tests', 'infra', 'deploy']
|
|
35
|
+
const facts = categories.flatMap((category) => (Array.isArray(stack?.[category]) ? stack[category] : [])
|
|
36
|
+
.map((badge) => sanitizeBadge(category, badge)).filter(Boolean))
|
|
37
|
+
.sort((a, b) => compareText(a.category, b.category) || compareText(a.id, b.id))
|
|
38
|
+
const badges = bounded(facts, CAPS.stackBadges)
|
|
39
|
+
return {
|
|
40
|
+
state: STATE.PARTIAL,
|
|
41
|
+
verdict: VERDICT.UNKNOWN,
|
|
42
|
+
completeness: {
|
|
43
|
+
badges: badges.completeness,
|
|
44
|
+
reasons: ['MANIFEST_AND_FILE_HEURISTICS_ONLY', 'INFRA_IMPORT_DETECTION_DISABLED'],
|
|
45
|
+
},
|
|
46
|
+
badges: badges.items,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function packageSource(value) {
|
|
51
|
+
const raw = metadataString(value, 512)
|
|
52
|
+
if (!raw) return undefined
|
|
53
|
+
const normalized = raw.replace(/\\/g, '/')
|
|
54
|
+
if (FIXED_PACKAGE_SOURCES.has(normalized)) return normalized
|
|
55
|
+
const relative = repoRelativePath(normalized, 512)
|
|
56
|
+
return relative && PACKAGE_SOURCE_FILE.test(relative) ? relative : undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sanitizePackage(value) {
|
|
60
|
+
const name = safeToken(value?.name)
|
|
61
|
+
const version = safeToken(value?.version, 128)
|
|
62
|
+
const ecosystem = safeToken(value?.ecosystem, 64)
|
|
63
|
+
const source = packageSource(value?.source)
|
|
64
|
+
if (!name || !version || !ecosystem || !source) return null
|
|
65
|
+
return {name, version, ecosystem, dev: value.dev === true, source}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildDirectUsage(externalImports) {
|
|
69
|
+
const usage = new Map()
|
|
70
|
+
for (const entry of Array.isArray(externalImports) ? externalImports : []) {
|
|
71
|
+
if (!entry || typeof entry !== 'object' || entry.builtin === true || entry.unresolved === true) continue
|
|
72
|
+
const name = safeToken(entry.pkg)
|
|
73
|
+
const ecosystem = safeToken(entry.ecosystem || 'npm', 64)
|
|
74
|
+
const file = repoRelativePath(entry.file)
|
|
75
|
+
if (!name || !ecosystem || !file) continue
|
|
76
|
+
const key = `${ecosystem}\0${name}`
|
|
77
|
+
let fact = usage.get(key)
|
|
78
|
+
if (!fact) usage.set(key, (fact = {name, ecosystem, importCount: 0, files: new Set(), kinds: new Set()}))
|
|
79
|
+
fact.importCount++
|
|
80
|
+
fact.files.add(file)
|
|
81
|
+
const kind = safeToken(entry.kind, 64)
|
|
82
|
+
if (kind) fact.kinds.add(kind)
|
|
83
|
+
}
|
|
84
|
+
return [...usage.values()].map((fact) => {
|
|
85
|
+
const files = [...fact.files].sort(compareText)
|
|
86
|
+
return {
|
|
87
|
+
name: fact.name,
|
|
88
|
+
ecosystem: fact.ecosystem,
|
|
89
|
+
importCount: fact.importCount,
|
|
90
|
+
fileCount: files.length,
|
|
91
|
+
files: files.slice(0, CAPS.usageFiles),
|
|
92
|
+
filesTruncated: files.length > CAPS.usageFiles,
|
|
93
|
+
kinds: [...fact.kinds].sort(compareText),
|
|
94
|
+
}
|
|
95
|
+
}).sort((a, b) => compareText(a.ecosystem, b.ecosystem) || compareText(a.name, b.name))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildPackagesSection(installedResult, installedError, graph, audit, repoRoot) {
|
|
99
|
+
const dependencyGraph = buildPackageDependencyGraph(repoRoot)
|
|
100
|
+
const dependencyGraphCounts = {
|
|
101
|
+
dependencyGraphNodes: dependencyGraph.completeness.nodes,
|
|
102
|
+
dependencyGraphEdges: dependencyGraph.completeness.edges,
|
|
103
|
+
}
|
|
104
|
+
if (installedError) {
|
|
105
|
+
return {
|
|
106
|
+
state: STATE.ERROR,
|
|
107
|
+
verdict: VERDICT.UNKNOWN,
|
|
108
|
+
completeness: {...dependencyGraphCounts, reasons: ['PACKAGE_INVENTORY_ERROR']},
|
|
109
|
+
checks: {osv: normalizeCheckState(audit?.checks?.osv?.status), malware: normalizeCheckState(audit?.checks?.malware?.status)},
|
|
110
|
+
inventory: [],
|
|
111
|
+
directUsage: [],
|
|
112
|
+
dependencyGraph,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const inventory = bounded((installedResult?.installed || []).map(sanitizePackage).filter(Boolean)
|
|
116
|
+
.sort((a, b) => compareText(a.ecosystem, b.ecosystem) || compareText(a.name, b.name) ||
|
|
117
|
+
compareText(a.version, b.version) || compareText(a.source, b.source) || Number(a.dev) - Number(b.dev)),
|
|
118
|
+
CAPS.packages)
|
|
119
|
+
const usage = bounded(buildDirectUsage(graph?.externalImports), CAPS.directUsage)
|
|
120
|
+
const packageRules = new Set(['unused-dep', 'missing-dep', 'duplicate-dep', 'lockfile-drift', 'known-vuln', 'malicious-package', 'typosquat'])
|
|
121
|
+
const packageFailure = (audit?.findings || []).some((finding) => packageRules.has(finding?.rule))
|
|
122
|
+
const checks = {
|
|
123
|
+
osv: normalizeCheckState(audit?.checks?.osv?.status),
|
|
124
|
+
malware: normalizeCheckState(audit?.checks?.malware?.status),
|
|
125
|
+
}
|
|
126
|
+
const reasons = []
|
|
127
|
+
if (inventory.completeness.truncated) reasons.push('PACKAGE_INVENTORY_TRUNCATED')
|
|
128
|
+
if (usage.completeness.truncated) reasons.push('DIRECT_USAGE_TRUNCATED')
|
|
129
|
+
if (Object.values(checks).some((value) => ![STATE.COMPLETE, STATE.NOT_APPLICABLE].includes(value))) {
|
|
130
|
+
reasons.push('OPTIONAL_CHECKS_INCOMPLETE')
|
|
131
|
+
}
|
|
132
|
+
if ([STATE.PARTIAL, STATE.ERROR].includes(dependencyGraph.state)) reasons.push('PACKAGE_DEPENDENCY_GRAPH_PARTIAL')
|
|
133
|
+
const state = reasons.length ? STATE.PARTIAL : STATE.COMPLETE
|
|
134
|
+
return {
|
|
135
|
+
state,
|
|
136
|
+
verdict: packageFailure ? VERDICT.FAIL : state === STATE.COMPLETE ? VERDICT.PASS : VERDICT.UNKNOWN,
|
|
137
|
+
completeness: {
|
|
138
|
+
inventory: inventory.completeness,
|
|
139
|
+
directUsage: usage.completeness,
|
|
140
|
+
...dependencyGraphCounts,
|
|
141
|
+
reasons,
|
|
142
|
+
},
|
|
143
|
+
checks,
|
|
144
|
+
inventory: inventory.items,
|
|
145
|
+
directUsage: usage.items,
|
|
146
|
+
dependencyGraph,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Bounded, deterministic and source-free evidence derived locally from a repository and its graph.
|
|
2
|
+
// The sync wire layer applies a second allowlist because cached graph/evidence data is untrusted.
|
|
3
|
+
import {aggregateGraph} from '../analysis/graph-analysis.js'
|
|
4
|
+
import {runInternalAudit} from '../analysis/internal-audit.js'
|
|
5
|
+
import {detectRepoStack} from '../scan/discover.js'
|
|
6
|
+
import {collectInstalled} from '../security/installed.js'
|
|
7
|
+
import {STATE, hashSnapshot} from './evidence-snapshot.common.mjs'
|
|
8
|
+
import {buildArchitectureSection} from './evidence-snapshot.architecture.mjs'
|
|
9
|
+
import {buildDuplicatesSection} from './evidence-snapshot.duplicates.mjs'
|
|
10
|
+
import {buildHealthSection} from './evidence-snapshot.health.mjs'
|
|
11
|
+
import {buildPackagesSection, buildTechnologiesSection} from './evidence-snapshot.inventory.mjs'
|
|
12
|
+
import {buildStructureEvidence} from './evidence-snapshot.structure.mjs'
|
|
13
|
+
|
|
14
|
+
export async function createEvidenceSnapshot({repoRoot, graph}) {
|
|
15
|
+
const inputGraph = graph && typeof graph === 'object' && !Array.isArray(graph)
|
|
16
|
+
? graph
|
|
17
|
+
: {nodes: [], links: [], externalImports: []}
|
|
18
|
+
|
|
19
|
+
let aggregate = null
|
|
20
|
+
try { aggregate = aggregateGraph(inputGraph) } catch { aggregate = null }
|
|
21
|
+
|
|
22
|
+
let audit = null
|
|
23
|
+
try { audit = await runInternalAudit(repoRoot, {graph: inputGraph, skipMalwareScan: true}) } catch { audit = null }
|
|
24
|
+
|
|
25
|
+
let stack = null
|
|
26
|
+
let stackError = false
|
|
27
|
+
try { stack = detectRepoStack(repoRoot) } catch { stackError = true }
|
|
28
|
+
|
|
29
|
+
let installedResult = null
|
|
30
|
+
let installedError = false
|
|
31
|
+
try { installedResult = collectInstalled(repoRoot) } catch { installedError = true }
|
|
32
|
+
|
|
33
|
+
const structure = buildStructureEvidence(inputGraph, repoRoot)
|
|
34
|
+
|
|
35
|
+
const sections = {
|
|
36
|
+
architecture: buildArchitectureSection(inputGraph, aggregate, audit, structure),
|
|
37
|
+
duplicates: buildDuplicatesSection(repoRoot, inputGraph),
|
|
38
|
+
health: buildHealthSection(inputGraph, audit),
|
|
39
|
+
technologies: buildTechnologiesSection(stack, stackError),
|
|
40
|
+
packages: buildPackagesSection(installedResult, installedError, inputGraph, audit, repoRoot),
|
|
41
|
+
}
|
|
42
|
+
const sectionStates = Object.values(sections).map((section) => section.state)
|
|
43
|
+
const state = sectionStates.every((value) => value === STATE.ERROR)
|
|
44
|
+
? STATE.ERROR
|
|
45
|
+
: sectionStates.every((value) => value === STATE.COMPLETE || value === STATE.NOT_APPLICABLE)
|
|
46
|
+
? STATE.COMPLETE
|
|
47
|
+
: STATE.PARTIAL
|
|
48
|
+
const snapshot = {evidenceSnapshotV: 1, state, sections}
|
|
49
|
+
return {...snapshot, snapshotHash: hashSnapshot(snapshot)}
|
|
50
|
+
}
|