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
+ 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
+ }