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,343 @@
|
|
|
1
|
+
// Executable target-architecture contract and no-regressions ratchet.
|
|
2
|
+
// The contract contains selectors and budgets only; verification is pure over graph metadata and never
|
|
3
|
+
// needs source bodies. Fingerprints deliberately exclude line numbers so edits do not churn baselines.
|
|
4
|
+
import {createHash} from 'node:crypto'
|
|
5
|
+
import {existsSync, readFileSync, writeFileSync, mkdirSync} from 'node:fs'
|
|
6
|
+
import {dirname, join} from 'node:path'
|
|
7
|
+
import {createRepoBoundary} from '../repo-path.js'
|
|
8
|
+
|
|
9
|
+
export const ARCHITECTURE_CONTRACT_V = 1
|
|
10
|
+
export const CONTRACT_PATHS = ['.weavatrix/architecture.json', '.weavatrix-architecture.json']
|
|
11
|
+
|
|
12
|
+
const RELATION_KIND = new Set(['runtime', 'type-only', 'compile-only', 'any'])
|
|
13
|
+
const ACTION = new Set(['allow', 'forbid'])
|
|
14
|
+
const ENFORCEMENT = new Set(['ratchet', 'strict', 'advisory'])
|
|
15
|
+
const safeId = (value, fallback = '') => {
|
|
16
|
+
const text = String(value ?? '').trim()
|
|
17
|
+
return /^[a-z0-9][a-z0-9._:-]{0,127}$/i.test(text) ? text : fallback
|
|
18
|
+
}
|
|
19
|
+
const pathPrefix = (value) => {
|
|
20
|
+
const text = String(value ?? '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+|\/+$/g, '')
|
|
21
|
+
return text && !text.split('/').some((part) => !part || part === '.' || part === '..') ? text : null
|
|
22
|
+
}
|
|
23
|
+
const stringList = (value, sanitize, cap = 100) => [...new Set((Array.isArray(value) ? value : [])
|
|
24
|
+
.slice(0, cap).map(sanitize).filter(Boolean))]
|
|
25
|
+
const finiteBudget = (value, fallback = null) => {
|
|
26
|
+
const number = Number(value)
|
|
27
|
+
return Number.isFinite(number) && number >= 0 ? number : fallback
|
|
28
|
+
}
|
|
29
|
+
const endpoint = (value) => value && typeof value === 'object' ? String(value.id) : String(value ?? '')
|
|
30
|
+
const isSymbol = (id) => String(id).includes('#')
|
|
31
|
+
const fileOf = (id, byId) => {
|
|
32
|
+
const node = byId.get(String(id))
|
|
33
|
+
if (node?.source_file) return String(node.source_file).replace(/\\/g, '/')
|
|
34
|
+
return String(id).split('#')[0].replace(/\\/g, '/')
|
|
35
|
+
}
|
|
36
|
+
const stable = (value) => Array.isArray(value)
|
|
37
|
+
? `[${value.map(stable).join(',')}]`
|
|
38
|
+
: value && typeof value === 'object'
|
|
39
|
+
? `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`
|
|
40
|
+
: JSON.stringify(value)
|
|
41
|
+
const hash = (value) => createHash('sha256').update(stable(value)).digest('hex')
|
|
42
|
+
|
|
43
|
+
function sanitizeComponent(value, index) {
|
|
44
|
+
const id = safeId(value?.id, `component-${index + 1}`)
|
|
45
|
+
const paths = stringList(value?.paths ?? [value?.path], pathPrefix, 64)
|
|
46
|
+
if (!paths.length) return null
|
|
47
|
+
return {id, name: String(value?.name || id).slice(0, 128), paths}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sanitizeRule(value, index) {
|
|
51
|
+
const from = stringList(value?.from, (item) => item === '*' ? '*' : safeId(item), 64)
|
|
52
|
+
const to = stringList(value?.to, (item) => item === '*' ? '*' : safeId(item), 64)
|
|
53
|
+
if (!from.length || !to.length) return null
|
|
54
|
+
return {
|
|
55
|
+
id: safeId(value?.id, `dependency-${index + 1}`),
|
|
56
|
+
action: ACTION.has(value?.action) ? value.action : 'forbid',
|
|
57
|
+
kinds: stringList(value?.kinds ?? ['runtime'], (kind) => RELATION_KIND.has(kind) ? kind : '', 4),
|
|
58
|
+
from,
|
|
59
|
+
to,
|
|
60
|
+
...(value?.reason ? {reason: String(value.reason).slice(0, 300)} : {}),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sanitizeException(value) {
|
|
65
|
+
const fingerprint = /^[a-f0-9]{16,64}$/i.test(String(value?.fingerprint || '')) ? String(value.fingerprint) : null
|
|
66
|
+
if (!fingerprint) return null
|
|
67
|
+
const expires = /^\d{4}-\d{2}-\d{2}$/.test(String(value?.expires || '')) ? String(value.expires) : null
|
|
68
|
+
return {fingerprint, reason: String(value?.reason || '').slice(0, 300), ...(expires ? {expires} : {})}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function normalizeArchitectureContract(input) {
|
|
72
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('architecture contract must be an object')
|
|
73
|
+
const components = (Array.isArray(input.components) ? input.components : [])
|
|
74
|
+
.slice(0, 200).map(sanitizeComponent).filter(Boolean)
|
|
75
|
+
const componentIds = new Set(components.map((item) => item.id))
|
|
76
|
+
const dependencyRules = (Array.isArray(input.dependencyRules) ? input.dependencyRules : [])
|
|
77
|
+
.slice(0, 500).map(sanitizeRule).filter(Boolean)
|
|
78
|
+
.filter((rule) => [...rule.from, ...rule.to].every((id) => id === '*' || componentIds.has(id)))
|
|
79
|
+
const rawBudgets = input.budgets && typeof input.budgets === 'object' ? input.budgets : {}
|
|
80
|
+
const budgets = {
|
|
81
|
+
runtimeCycles: finiteBudget(rawBudgets.runtimeCycles),
|
|
82
|
+
maxFunctionLoc: finiteBudget(rawBudgets.maxFunctionLoc),
|
|
83
|
+
maxFileLoc: finiteBudget(rawBudgets.maxFileLoc),
|
|
84
|
+
maxCyclomatic: finiteBudget(rawBudgets.maxCyclomatic),
|
|
85
|
+
maxParams: finiteBudget(rawBudgets.maxParams),
|
|
86
|
+
maxRuntimeDependenciesPerComponent: finiteBudget(rawBudgets.maxRuntimeDependenciesPerComponent),
|
|
87
|
+
maxModuleFiles: finiteBudget(rawBudgets.maxModuleFiles),
|
|
88
|
+
minModuleCohesion: finiteBudget(rawBudgets.minModuleCohesion),
|
|
89
|
+
maxModuleBoundaryRatio: finiteBudget(rawBudgets.maxModuleBoundaryRatio),
|
|
90
|
+
}
|
|
91
|
+
for (const key of Object.keys(budgets)) if (budgets[key] == null) delete budgets[key]
|
|
92
|
+
const technologies = {
|
|
93
|
+
required: stringList(input.technologies?.required, (item) => safeId(item), 100),
|
|
94
|
+
forbidden: stringList(input.technologies?.forbidden, (item) => safeId(item), 100),
|
|
95
|
+
}
|
|
96
|
+
const baseline = input.ratchet?.baseline && typeof input.ratchet.baseline === 'object'
|
|
97
|
+
? {
|
|
98
|
+
fingerprints: stringList(input.ratchet.baseline.fingerprints, (item) => /^[a-f0-9]{16,64}$/i.test(String(item)) ? String(item) : '', 5_000),
|
|
99
|
+
metrics: Object.fromEntries(Object.entries(input.ratchet.baseline.metrics || {}).slice(0, 500)
|
|
100
|
+
.map(([key, value]) => [safeId(key), finiteBudget(value)]).filter(([key, value]) => key && value != null)),
|
|
101
|
+
}
|
|
102
|
+
: {fingerprints: [], metrics: {}}
|
|
103
|
+
const contract = {
|
|
104
|
+
architectureContractV: ARCHITECTURE_CONTRACT_V,
|
|
105
|
+
name: String(input.name || 'Target architecture').slice(0, 128),
|
|
106
|
+
style: safeId(input.style, 'custom'),
|
|
107
|
+
enforcement: ENFORCEMENT.has(input.enforcement) ? input.enforcement : 'ratchet',
|
|
108
|
+
components,
|
|
109
|
+
dependencyRules,
|
|
110
|
+
budgets,
|
|
111
|
+
technologies,
|
|
112
|
+
exceptions: (Array.isArray(input.exceptions) ? input.exceptions : []).slice(0, 500).map(sanitizeException).filter(Boolean),
|
|
113
|
+
ratchet: {baseline},
|
|
114
|
+
}
|
|
115
|
+
return {...contract, contractHash: hash(contract)}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function loadArchitectureContract(repoRoot, graphPath) {
|
|
119
|
+
const boundary = createRepoBoundary(repoRoot)
|
|
120
|
+
for (const relative of CONTRACT_PATHS) {
|
|
121
|
+
const resolved = boundary.resolve(relative)
|
|
122
|
+
if (!resolved.ok || !existsSync(resolved.path)) continue
|
|
123
|
+
try { return {contract: normalizeArchitectureContract(JSON.parse(readFileSync(resolved.path, 'utf8'))), source: relative} }
|
|
124
|
+
catch (error) { return {contract: null, source: relative, error: error.message} }
|
|
125
|
+
}
|
|
126
|
+
const cached = graphPath ? join(dirname(graphPath), 'architecture.contract.json') : null
|
|
127
|
+
if (cached && existsSync(cached)) {
|
|
128
|
+
try { return {contract: normalizeArchitectureContract(JSON.parse(readFileSync(cached, 'utf8'))), source: 'hosted-cache'} }
|
|
129
|
+
catch (error) { return {contract: null, source: 'hosted-cache', error: error.message} }
|
|
130
|
+
}
|
|
131
|
+
return {contract: null, source: null, error: null}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function writeCachedArchitectureContract(graphPath, input) {
|
|
135
|
+
if (!graphPath) throw new Error('graph path is required for hosted contract cache')
|
|
136
|
+
const contract = normalizeArchitectureContract(input)
|
|
137
|
+
const path = join(dirname(graphPath), 'architecture.contract.json')
|
|
138
|
+
mkdirSync(dirname(path), {recursive: true})
|
|
139
|
+
writeFileSync(path, JSON.stringify(contract, null, 2), 'utf8')
|
|
140
|
+
return {path, contract}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function componentFor(file, components) {
|
|
144
|
+
const normalized = String(file || '').replace(/\\/g, '/')
|
|
145
|
+
let best = null
|
|
146
|
+
for (const component of components) for (const prefix of component.paths) {
|
|
147
|
+
if (normalized !== prefix && !normalized.startsWith(`${prefix}/`)) continue
|
|
148
|
+
if (!best || prefix.length > best.prefix.length) best = {id: component.id, prefix}
|
|
149
|
+
}
|
|
150
|
+
return best?.id || '(unmapped)'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function relationKind(link) {
|
|
154
|
+
if (link.typeOnly === true) return 'type-only'
|
|
155
|
+
if (link.compileOnly === true) return 'compile-only'
|
|
156
|
+
return 'runtime'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function runtimeFileGraph(graph) {
|
|
160
|
+
const byId = new Map((graph.nodes || []).map((node) => [String(node.id), node]))
|
|
161
|
+
const files = new Set((graph.nodes || []).filter((node) => !isSymbol(node.id)).map((node) => String(node.id)))
|
|
162
|
+
const adjacency = new Map([...files].map((file) => [file, new Set()]))
|
|
163
|
+
for (const link of graph.links || []) {
|
|
164
|
+
if (relationKind(link) !== 'runtime' || !['imports', 're_exports'].includes(link.relation) || link.barrelProxy === true) continue
|
|
165
|
+
const source = fileOf(endpoint(link.source), byId)
|
|
166
|
+
const target = fileOf(endpoint(link.target), byId)
|
|
167
|
+
if (source && target && source !== target && files.has(source) && files.has(target)) adjacency.get(source)?.add(target)
|
|
168
|
+
}
|
|
169
|
+
return adjacency
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function stronglyConnected(adjacency) {
|
|
173
|
+
let index = 0
|
|
174
|
+
const indexes = new Map(), low = new Map(), stack = [], onStack = new Set(), out = []
|
|
175
|
+
const visit = (node) => {
|
|
176
|
+
indexes.set(node, index); low.set(node, index); index++; stack.push(node); onStack.add(node)
|
|
177
|
+
for (const target of adjacency.get(node) || []) {
|
|
178
|
+
if (!indexes.has(target)) { visit(target); low.set(node, Math.min(low.get(node), low.get(target))) }
|
|
179
|
+
else if (onStack.has(target)) low.set(node, Math.min(low.get(node), indexes.get(target)))
|
|
180
|
+
}
|
|
181
|
+
if (low.get(node) !== indexes.get(node)) return
|
|
182
|
+
const component = []
|
|
183
|
+
while (stack.length) { const value = stack.pop(); onStack.delete(value); component.push(value); if (value === node) break }
|
|
184
|
+
if (component.length > 1 || adjacency.get(node)?.has(node)) out.push(component.sort())
|
|
185
|
+
}
|
|
186
|
+
for (const node of [...adjacency.keys()].sort()) if (!indexes.has(node)) visit(node)
|
|
187
|
+
return out.sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function violation(ruleId, kind, evidence, current, target) {
|
|
191
|
+
const normalizedEvidence = String(evidence).replace(/:\d+(?=\b|$)/g, '')
|
|
192
|
+
const fingerprint = hash({ruleId, kind, evidence: normalizedEvidence}).slice(0, 32)
|
|
193
|
+
return {fingerprint, ruleId, kind, evidence, ...(current != null ? {current} : {}), ...(target != null ? {target} : {})}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function matchSelector(selector, value) { return selector.includes('*') || selector.includes(value) }
|
|
197
|
+
|
|
198
|
+
export function verifyArchitecture({graph, contract: rawContract, technologies = []}) {
|
|
199
|
+
const contract = rawContract?.contractHash ? rawContract : normalizeArchitectureContract(rawContract)
|
|
200
|
+
const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []
|
|
201
|
+
const links = Array.isArray(graph?.links) ? graph.links : []
|
|
202
|
+
const byId = new Map(nodes.map((node) => [String(node.id), node]))
|
|
203
|
+
const exceptions = new Set(contract.exceptions
|
|
204
|
+
.filter((item) => !item.expires || item.expires >= new Date().toISOString().slice(0, 10))
|
|
205
|
+
.map((item) => item.fingerprint))
|
|
206
|
+
const violations = []
|
|
207
|
+
const componentEdges = new Map()
|
|
208
|
+
for (const link of links) {
|
|
209
|
+
if (!['imports', 're_exports', 'calls', 'references'].includes(link.relation) || link.barrelProxy === true) continue
|
|
210
|
+
const fromFile = fileOf(endpoint(link.source), byId), toFile = fileOf(endpoint(link.target), byId)
|
|
211
|
+
if (!fromFile || !toFile || fromFile === toFile) continue
|
|
212
|
+
const from = componentFor(fromFile, contract.components), to = componentFor(toFile, contract.components)
|
|
213
|
+
if (from === to) continue
|
|
214
|
+
const kind = relationKind(link)
|
|
215
|
+
const key = `${from}\0${to}\0${kind}`
|
|
216
|
+
const record = componentEdges.get(key) || {from, to, kind, count: 0, samples: []}
|
|
217
|
+
record.count++
|
|
218
|
+
if (record.samples.length < 5) record.samples.push(`${fromFile} -> ${toFile}`)
|
|
219
|
+
componentEdges.set(key, record)
|
|
220
|
+
}
|
|
221
|
+
for (const edge of componentEdges.values()) {
|
|
222
|
+
const applicable = contract.dependencyRules.filter((rule) =>
|
|
223
|
+
rule.kinds.some((kind) => kind === 'any' || kind === edge.kind) && matchSelector(rule.from, edge.from))
|
|
224
|
+
for (const rule of applicable) {
|
|
225
|
+
if (rule.action === 'forbid' && matchSelector(rule.to, edge.to)) {
|
|
226
|
+
violations.push(violation(rule.id, 'dependency', `${edge.from} -> ${edge.to} (${edge.kind})`, edge.count, 0))
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const allowRules = applicable.filter((rule) => rule.action === 'allow')
|
|
230
|
+
if (allowRules.length && !allowRules.some((rule) => matchSelector(rule.to, edge.to))) {
|
|
231
|
+
const ruleId = allowRules.map((rule) => rule.id).sort().join('+')
|
|
232
|
+
violations.push(violation(ruleId, 'dependency', `${edge.from} -> ${edge.to} (${edge.kind}; outside allow-list)`, edge.count, 0))
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const runtimeCycles = stronglyConnected(runtimeFileGraph(graph))
|
|
236
|
+
if (contract.budgets.runtimeCycles != null && runtimeCycles.length > contract.budgets.runtimeCycles) {
|
|
237
|
+
violations.push(violation('budget.runtimeCycles', 'budget', `runtime cycles: ${runtimeCycles.length}`, runtimeCycles.length, contract.budgets.runtimeCycles))
|
|
238
|
+
}
|
|
239
|
+
const componentRuntimeTargets = new Map()
|
|
240
|
+
for (const edge of componentEdges.values()) if (edge.kind === 'runtime') {
|
|
241
|
+
const targets = componentRuntimeTargets.get(edge.from) || new Set(); targets.add(edge.to); componentRuntimeTargets.set(edge.from, targets)
|
|
242
|
+
}
|
|
243
|
+
if (contract.budgets.maxRuntimeDependenciesPerComponent != null) for (const [component, targets] of componentRuntimeTargets) {
|
|
244
|
+
if (targets.size > contract.budgets.maxRuntimeDependenciesPerComponent) violations.push(violation(
|
|
245
|
+
'budget.maxRuntimeDependenciesPerComponent', 'budget', `${component} runtime dependencies`, targets.size,
|
|
246
|
+
contract.budgets.maxRuntimeDependenciesPerComponent,
|
|
247
|
+
))
|
|
248
|
+
}
|
|
249
|
+
const componentStats = new Map(contract.components.map((component) => [component.id, {
|
|
250
|
+
files: new Set(), internalPairs: new Set(), boundaryPairs: new Set(),
|
|
251
|
+
}]))
|
|
252
|
+
for (const node of nodes.filter((item) => !isSymbol(item.id))) {
|
|
253
|
+
const file = String(node.source_file || node.id).replace(/\\/g, '/')
|
|
254
|
+
const component = componentFor(file, contract.components)
|
|
255
|
+
componentStats.get(component)?.files.add(file)
|
|
256
|
+
}
|
|
257
|
+
const runtimePairs = new Set()
|
|
258
|
+
for (const link of links) {
|
|
259
|
+
if (relationKind(link) !== 'runtime' || !['imports', 're_exports', 'calls', 'references'].includes(link.relation) || link.barrelProxy === true) continue
|
|
260
|
+
const source = fileOf(endpoint(link.source), byId), target = fileOf(endpoint(link.target), byId)
|
|
261
|
+
if (!source || !target || source === target) continue
|
|
262
|
+
const pair = `${source}\0${target}`
|
|
263
|
+
if (runtimePairs.has(pair)) continue
|
|
264
|
+
runtimePairs.add(pair)
|
|
265
|
+
const from = componentFor(source, contract.components), to = componentFor(target, contract.components)
|
|
266
|
+
if (from === to) componentStats.get(from)?.internalPairs.add(pair)
|
|
267
|
+
else {
|
|
268
|
+
componentStats.get(from)?.boundaryPairs.add(pair)
|
|
269
|
+
componentStats.get(to)?.boundaryPairs.add(pair)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
for (const [component, stats] of componentStats) {
|
|
273
|
+
const internal = stats.internalPairs.size, boundary = stats.boundaryPairs.size
|
|
274
|
+
const cohesion = internal + boundary ? internal / (internal + boundary) : 1
|
|
275
|
+
const boundaryRatio = internal + boundary ? boundary / (internal + boundary) : 0
|
|
276
|
+
if (contract.budgets.maxModuleFiles != null && stats.files.size > contract.budgets.maxModuleFiles) violations.push(violation('budget.maxModuleFiles', 'budget', `${component} files`, stats.files.size, contract.budgets.maxModuleFiles))
|
|
277
|
+
if (contract.budgets.minModuleCohesion != null && cohesion < contract.budgets.minModuleCohesion) violations.push(violation('budget.minModuleCohesion', 'budget', `${component} cohesion`, Number(cohesion.toFixed(4)), contract.budgets.minModuleCohesion))
|
|
278
|
+
if (contract.budgets.maxModuleBoundaryRatio != null && boundaryRatio > contract.budgets.maxModuleBoundaryRatio) violations.push(violation('budget.maxModuleBoundaryRatio', 'budget', `${component} boundary ratio`, Number(boundaryRatio.toFixed(4)), contract.budgets.maxModuleBoundaryRatio))
|
|
279
|
+
}
|
|
280
|
+
for (const node of nodes) {
|
|
281
|
+
const loc = finiteBudget(node?.complexity?.loc)
|
|
282
|
+
const cyclomatic = finiteBudget(node?.complexity?.cyclomatic)
|
|
283
|
+
const params = finiteBudget(node?.complexity?.params)
|
|
284
|
+
const evidence = `${node.source_file || fileOf(node.id, byId)}#${node.label || node.id}`
|
|
285
|
+
if (contract.budgets.maxFunctionLoc != null && loc != null && loc > contract.budgets.maxFunctionLoc) violations.push(violation('budget.maxFunctionLoc', 'budget', evidence, loc, contract.budgets.maxFunctionLoc))
|
|
286
|
+
if (contract.budgets.maxCyclomatic != null && cyclomatic != null && cyclomatic > contract.budgets.maxCyclomatic) violations.push(violation('budget.maxCyclomatic', 'budget', evidence, cyclomatic, contract.budgets.maxCyclomatic))
|
|
287
|
+
if (contract.budgets.maxParams != null && params != null && params > contract.budgets.maxParams) violations.push(violation('budget.maxParams', 'budget', evidence, params, contract.budgets.maxParams))
|
|
288
|
+
}
|
|
289
|
+
if (contract.budgets.maxFileLoc != null) for (const node of nodes.filter((item) => !isSymbol(item.id))) {
|
|
290
|
+
const file = String(node.source_file || node.id)
|
|
291
|
+
const maxEnd = nodes.filter((item) => item.source_file === file && isSymbol(item.id))
|
|
292
|
+
.reduce((max, item) => Math.max(max, Number(String(item.source_end || '').replace(/^L/, '')) || 0), 0)
|
|
293
|
+
if (maxEnd > contract.budgets.maxFileLoc) violations.push(violation('budget.maxFileLoc', 'budget', file, maxEnd, contract.budgets.maxFileLoc))
|
|
294
|
+
}
|
|
295
|
+
const techSet = new Set(technologies.map((item) => safeId(item)).filter(Boolean))
|
|
296
|
+
for (const required of contract.technologies.required) if (!techSet.has(required)) violations.push(violation('technology.required', 'technology', `missing ${required}`))
|
|
297
|
+
for (const forbidden of contract.technologies.forbidden) if (techSet.has(forbidden)) violations.push(violation('technology.forbidden', 'technology', `forbidden ${forbidden}`))
|
|
298
|
+
|
|
299
|
+
const active = violations.filter((item) => !exceptions.has(item.fingerprint))
|
|
300
|
+
const baseline = new Set(contract.ratchet.baseline.fingerprints)
|
|
301
|
+
const current = new Set(active.map((item) => item.fingerprint))
|
|
302
|
+
const fresh = active.filter((item) => !baseline.has(item.fingerprint))
|
|
303
|
+
const existing = active.filter((item) => baseline.has(item.fingerprint))
|
|
304
|
+
const fixed = [...baseline].filter((fingerprint) => !current.has(fingerprint))
|
|
305
|
+
const status = contract.enforcement === 'advisory'
|
|
306
|
+
? 'ADVISORY'
|
|
307
|
+
: contract.enforcement === 'strict'
|
|
308
|
+
? (active.length ? 'FAIL' : 'PASS')
|
|
309
|
+
: (fresh.length ? 'FAIL' : 'PASS')
|
|
310
|
+
const metrics = {
|
|
311
|
+
runtimeCycles: runtimeCycles.length,
|
|
312
|
+
violations: active.length,
|
|
313
|
+
componentDependencies: componentEdges.size,
|
|
314
|
+
mappedComponents: contract.components.length,
|
|
315
|
+
componentFitness: Object.fromEntries([...componentStats].map(([component, stats]) => {
|
|
316
|
+
const internal = stats.internalPairs.size, boundary = stats.boundaryPairs.size
|
|
317
|
+
return [component, {files: stats.files.size, cohesion: internal + boundary ? Number((internal / (internal + boundary)).toFixed(4)) : 1, boundaryRatio: internal + boundary ? Number((boundary / (internal + boundary)).toFixed(4)) : 0}]
|
|
318
|
+
})),
|
|
319
|
+
}
|
|
320
|
+
const result = {
|
|
321
|
+
architectureVerificationV: 1,
|
|
322
|
+
contractHash: contract.contractHash,
|
|
323
|
+
status,
|
|
324
|
+
enforcement: contract.enforcement,
|
|
325
|
+
metrics,
|
|
326
|
+
new: fresh,
|
|
327
|
+
existing,
|
|
328
|
+
fixed,
|
|
329
|
+
excepted: violations.filter((item) => exceptions.has(item.fingerprint)),
|
|
330
|
+
componentEdges: [...componentEdges.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.kind.localeCompare(b.kind)),
|
|
331
|
+
runtimeCycles: runtimeCycles.slice(0, 100),
|
|
332
|
+
}
|
|
333
|
+
return {...result, verificationHash: hash(result)}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function contractForChange(contract, files = []) {
|
|
337
|
+
const normalized = rawArray(files).map(pathPrefix).filter(Boolean)
|
|
338
|
+
const components = [...new Set(normalized.map((file) => componentFor(file, contract.components)))]
|
|
339
|
+
const rules = contract.dependencyRules.filter((rule) => components.some((component) => matchSelector(rule.from, component) || matchSelector(rule.to, component)))
|
|
340
|
+
return {files: normalized, components, rules, budgets: contract.budgets, technologies: contract.technologies}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function rawArray(value) { return Array.isArray(value) ? value.slice(0, 200) : [] }
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Deterministic health-debt comparison. Supply-chain findings are deliberately excluded: an
|
|
2
|
+
// immutable source checkout has no trustworthy per-repository OSV refresh stamp or installed-package
|
|
3
|
+
// malware state, so calling those findings new/fixed would manufacture certainty.
|
|
4
|
+
const OPTIONAL_CATEGORIES = new Set(["vulnerability", "malware"]);
|
|
5
|
+
|
|
6
|
+
const normalizePath = (value) => String(value || "").trim()
|
|
7
|
+
.replace(/\\/g, "/")
|
|
8
|
+
.replace(/^\.\//, "")
|
|
9
|
+
.replace(/\/{2,}/g, "/")
|
|
10
|
+
.replace(/\/$/, "");
|
|
11
|
+
|
|
12
|
+
export function normalizeAuditScopeFiles(input, maxFiles = 500) {
|
|
13
|
+
if (!Array.isArray(input)) return { ok: true, files: null };
|
|
14
|
+
const files = [];
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
for (const raw of input) {
|
|
17
|
+
const file = normalizePath(raw);
|
|
18
|
+
if (!file || file.includes("\0") || file.startsWith("/") || /^[A-Za-z]:\//.test(file) || file === ".." || file.startsWith("../") || file.includes("/../") || file.startsWith("-")) {
|
|
19
|
+
return { ok: false, files: [], error: `changed_files contains an invalid repo-relative path: ${String(raw || "(empty)")}` };
|
|
20
|
+
}
|
|
21
|
+
if (!seen.has(file)) { seen.add(file); files.push(file); }
|
|
22
|
+
if (files.length > maxFiles) return { ok: false, files: [], error: `changed_files exceeds the ${maxFiles}-file safety bound` };
|
|
23
|
+
}
|
|
24
|
+
if (!files.length) return { ok: false, files: [], error: "changed_files was provided but empty" };
|
|
25
|
+
return { ok: true, files: files.sort((a, b) => a.localeCompare(b)) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function auditFindingFiles(finding) {
|
|
29
|
+
const values = [finding?.file, finding?.manifest, finding?.graphNodeId];
|
|
30
|
+
for (const evidence of finding?.evidence || []) values.push(evidence?.file);
|
|
31
|
+
return [...new Set(values
|
|
32
|
+
.map((value) => normalizePath(String(value || "").split("#", 1)[0]))
|
|
33
|
+
.filter(Boolean))]
|
|
34
|
+
.sort((a, b) => a.localeCompare(b));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function findingTouchesAuditScope(finding, changedFiles) {
|
|
38
|
+
if (changedFiles == null) return true;
|
|
39
|
+
const scope = changedFiles instanceof Set ? changedFiles : new Set(changedFiles.map(normalizePath));
|
|
40
|
+
return auditFindingFiles(finding).some((file) => scope.has(file));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function scopeAuditFindings(findings, changedFiles) {
|
|
44
|
+
const scope = changedFiles == null ? null : new Set(changedFiles.map(normalizePath));
|
|
45
|
+
return (findings || []).filter((finding) => findingTouchesAuditScope(finding, scope));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const comparable = (findings) => (findings || []).filter((finding) => !OPTIONAL_CATEGORIES.has(finding.category));
|
|
49
|
+
const optional = (findings) => (findings || []).filter((finding) => OPTIONAL_CATEGORIES.has(finding.category));
|
|
50
|
+
|
|
51
|
+
// Findings already carry stable IDs from makeFinding(). Compare globally first, then apply the
|
|
52
|
+
// changed-file scope. This prevents pre-existing debt in an edited file from becoming "new".
|
|
53
|
+
export function compareAuditDebt(currentAudit, baselineAudit, changedFiles = null, { completeChangeSet = false } = {}) {
|
|
54
|
+
const current = comparable(currentAudit?.findings);
|
|
55
|
+
const baseline = comparable(baselineAudit?.findings);
|
|
56
|
+
const currentIds = new Set(current.map((finding) => String(finding.id)));
|
|
57
|
+
const baselineIds = new Set(baseline.map((finding) => String(finding.id)));
|
|
58
|
+
const scopedCurrent = scopeAuditFindings(current, changedFiles);
|
|
59
|
+
const scopedBaseline = scopeAuditFindings(baseline, changedFiles);
|
|
60
|
+
const globalFresh = current.filter((finding) => !baselineIds.has(String(finding.id)));
|
|
61
|
+
const globalFixedFindings = baseline.filter((finding) => !currentIds.has(String(finding.id)));
|
|
62
|
+
// A complete, automatically derived Git diff is the causal boundary: a new manifest-level finding
|
|
63
|
+
// can be caused by editing only its former importer, so path intersection would hide it. Explicit
|
|
64
|
+
// changed_files may be a subset of other working-tree changes and therefore stays path-scoped.
|
|
65
|
+
const fresh = completeChangeSet ? globalFresh : scopedCurrent.filter((finding) => !baselineIds.has(String(finding.id)));
|
|
66
|
+
const existing = scopedCurrent.filter((finding) => baselineIds.has(String(finding.id)));
|
|
67
|
+
const fixed = completeChangeSet ? globalFixedFindings : scopedBaseline.filter((finding) => !currentIds.has(String(finding.id)));
|
|
68
|
+
const all = [...fresh, ...existing].sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
69
|
+
const globalNew = globalFresh.length;
|
|
70
|
+
const globalExisting = current.length - globalNew;
|
|
71
|
+
const globalFixed = globalFixedFindings.length;
|
|
72
|
+
return {
|
|
73
|
+
scope: {strategy: completeChangeSet ? "complete-change-set-causality" : "path-intersection"},
|
|
74
|
+
new: fresh,
|
|
75
|
+
existing,
|
|
76
|
+
fixed,
|
|
77
|
+
all: all.map((finding) => ({ ...finding, debtState: baselineIds.has(String(finding.id)) ? "existing" : "new" })),
|
|
78
|
+
optional: {
|
|
79
|
+
current: optional(currentAudit?.findings),
|
|
80
|
+
baseline: optional(baselineAudit?.findings),
|
|
81
|
+
checks: ["osv", "malware"].map((name) => ({
|
|
82
|
+
name,
|
|
83
|
+
status: "UNCOMPARABLE",
|
|
84
|
+
current: currentAudit?.checks?.[name]?.status || "ERROR",
|
|
85
|
+
baseline: baselineAudit?.checks?.[name]?.status || "ERROR",
|
|
86
|
+
reason: "The immutable source checkout cannot reproduce the current advisory-refresh or installed-package scan state.",
|
|
87
|
+
})),
|
|
88
|
+
},
|
|
89
|
+
totals: {
|
|
90
|
+
scope: { new: fresh.length, existing: existing.length, fixed: fixed.length, active: fresh.length + existing.length },
|
|
91
|
+
repository: { new: globalNew, existing: globalExisting, fixed: globalFixed, active: current.length },
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|