weavatrix 0.2.12 → 0.2.14
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 +90 -21
- package/SECURITY.md +2 -2
- package/docs/releases/v0.2.14.md +93 -0
- package/package.json +2 -2
- package/skill/SKILL.md +108 -43
- package/src/analysis/allowed-test-runner.js +20 -9
- package/src/analysis/architecture/contract-graph.js +119 -0
- package/src/analysis/architecture/contract-schema.js +110 -0
- package/src/analysis/architecture/contract-storage.js +35 -0
- package/src/analysis/architecture/contract-verification.js +168 -0
- package/src/analysis/architecture-contract.js +16 -343
- package/src/analysis/change-classification/diff-parser.js +103 -0
- package/src/analysis/change-classification/options.js +40 -0
- package/src/analysis/change-classification/symbol-classifier.js +177 -0
- package/src/analysis/change-classification.js +120 -519
- package/src/analysis/dead-code-review/policy.js +23 -0
- package/src/analysis/dead-code-review.js +9 -19
- package/src/analysis/dep-check.js +10 -57
- package/src/analysis/dep-rules.js +10 -303
- package/src/analysis/dependency/scoped-dependencies.js +40 -0
- package/src/analysis/endpoints/common.js +62 -0
- package/src/analysis/endpoints/extract.js +107 -0
- package/src/analysis/endpoints/inventory.js +84 -0
- package/src/analysis/endpoints/mounts.js +129 -0
- package/src/analysis/endpoints-java.js +80 -3
- package/src/analysis/endpoints.js +3 -448
- package/src/analysis/git-history/analytics.js +177 -0
- package/src/analysis/git-history/collector.js +109 -0
- package/src/analysis/git-history/options.js +68 -0
- package/src/analysis/git-history.js +128 -577
- package/src/analysis/health-capabilities.js +82 -0
- package/src/analysis/http-contracts/analysis.js +169 -0
- package/src/analysis/http-contracts/client-call-detection.js +81 -0
- package/src/analysis/http-contracts/client-call-parser.js +255 -0
- package/src/analysis/http-contracts/graph-context.js +143 -0
- package/src/analysis/http-contracts/matching.js +61 -0
- package/src/analysis/http-contracts/shared.js +86 -0
- package/src/analysis/http-contracts.js +6 -822
- package/src/analysis/internal-audit/dependency-health.js +111 -0
- package/src/analysis/internal-audit/python-manifests.js +65 -0
- package/src/analysis/internal-audit/repo-files.js +55 -0
- package/src/analysis/internal-audit/supply-chain.js +132 -0
- package/src/analysis/internal-audit.collect.js +5 -106
- package/src/analysis/internal-audit.run.js +113 -200
- package/src/analysis/jvm-dependency-evidence.js +69 -0
- package/src/analysis/source-correctness/retry-patterns.js +93 -0
- package/src/analysis/source-correctness.js +225 -0
- package/src/analysis/structure/dependency-graph.js +156 -0
- package/src/analysis/structure/findings.js +142 -0
- package/src/graph/builder/go-receiver-resolution.js +112 -0
- package/src/graph/builder/js/queries.js +48 -0
- package/src/graph/builder/lang-go.js +89 -4
- package/src/graph/builder/lang-js.js +4 -44
- package/src/graph/builder/pass2-resolution.js +68 -0
- package/src/graph/freshness-probe.js +2 -1
- package/src/graph/incremental-refresh.js +3 -2
- package/src/graph/internal-builder.build.js +22 -183
- package/src/graph/internal-builder.pass2.js +161 -0
- package/src/graph/internal-builder.resolvers.js +2 -105
- package/src/graph/resolvers/rust.js +117 -0
- package/src/mcp/actions/advisories.mjs +18 -0
- package/src/mcp/actions/graph-lifecycle.mjs +148 -0
- package/src/mcp/actions/graph-sync.mjs +195 -0
- package/src/mcp/actions/hosted-architecture.mjs +57 -0
- package/src/mcp/architecture-bootstrap.mjs +168 -0
- package/src/mcp/architecture-starter.mjs +234 -0
- package/src/mcp/catalog.mjs +22 -6
- package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
- package/src/mcp/evidence/package-graph-common.mjs +115 -0
- package/src/mcp/evidence/package-lock-graph.mjs +92 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
- package/src/mcp/graph/context-core.mjs +111 -0
- package/src/mcp/graph/context-seeds.mjs +208 -0
- package/src/mcp/graph/context-state.mjs +86 -0
- package/src/mcp/graph/tools-core.mjs +143 -0
- package/src/mcp/graph/tools-query.mjs +223 -0
- package/src/mcp/graph-context.mjs +4 -496
- package/src/mcp/health/audit-format.mjs +172 -0
- package/src/mcp/health/audit.mjs +171 -0
- package/src/mcp/health/dead-code.mjs +110 -0
- package/src/mcp/health/duplicates.mjs +83 -0
- package/src/mcp/health/endpoints.mjs +42 -0
- package/src/mcp/health/structure.mjs +219 -0
- package/src/mcp/runtime-version.mjs +32 -0
- package/src/mcp/server/auto-refresh.mjs +66 -0
- package/src/mcp/server/runtime-config.mjs +43 -0
- package/src/mcp/server/shutdown.mjs +40 -0
- package/src/mcp/sync/evidence-architecture.mjs +54 -0
- package/src/mcp/sync/evidence-common.mjs +88 -0
- package/src/mcp/sync/evidence-duplicates.mjs +100 -0
- package/src/mcp/sync/evidence-health.mjs +56 -0
- package/src/mcp/sync/evidence-packages.mjs +95 -0
- package/src/mcp/sync/payload-common.mjs +97 -0
- package/src/mcp/sync/payload-v2.mjs +53 -0
- package/src/mcp/sync/payload-v3.mjs +79 -0
- package/src/mcp/sync-evidence.mjs +7 -402
- package/src/mcp/sync-payload.mjs +10 -244
- package/src/mcp/tools-actions.mjs +18 -414
- package/src/mcp/tools-architecture.mjs +18 -70
- package/src/mcp/tools-context.mjs +56 -15
- package/src/mcp/tools-endpoints.mjs +4 -1
- package/src/mcp/tools-graph.mjs +17 -330
- package/src/mcp/tools-health.mjs +24 -705
- package/src/mcp/tools-verified-change.mjs +12 -4
- package/src/mcp-server.mjs +49 -146
- package/src/precision/lsp-client/constants.js +12 -0
- package/src/precision/lsp-client/environment.js +20 -0
- package/src/precision/lsp-client/errors.js +15 -0
- package/src/precision/lsp-client/lifecycle.js +127 -0
- package/src/precision/lsp-client/message-parser.js +81 -0
- package/src/precision/lsp-client/protocol.js +212 -0
- package/src/precision/lsp-client/registry.js +58 -0
- package/src/precision/lsp-client/repo-uri.js +60 -0
- package/src/precision/lsp-client/stdio-client.js +151 -0
- package/src/precision/lsp-client.js +10 -682
- package/src/precision/lsp-overlay/build.js +238 -0
- package/src/precision/lsp-overlay/contract.js +61 -0
- package/src/precision/lsp-overlay/reference-results.js +108 -0
- package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
- package/src/precision/lsp-overlay/source-session.js +134 -0
- package/src/precision/lsp-overlay/store.js +150 -0
- package/src/precision/lsp-overlay/target-index.js +147 -0
- package/src/precision/lsp-overlay/target-query.js +55 -0
- package/src/precision/lsp-overlay.js +11 -868
- package/src/precision/typescript-lsp-provider.js +12 -682
- package/src/precision/typescript-provider/client.js +69 -0
- package/src/precision/typescript-provider/discovery.js +124 -0
- package/src/precision/typescript-provider/project-host.js +249 -0
- package/src/precision/typescript-provider/project-safety.js +161 -0
- package/src/precision/typescript-provider/reference-usage.js +53 -0
- package/src/version.js +7 -0
- package/docs/releases/v0.2.12.md +0 -45
|
@@ -1,343 +1,16 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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) : [] }
|
|
1
|
+
// Stable public facade for target-architecture contracts.
|
|
2
|
+
// Owner-specific implementation lives under analysis/architecture so storage,
|
|
3
|
+
// normalization and graph verification can evolve independently.
|
|
4
|
+
export {
|
|
5
|
+
ARCHITECTURE_CONTRACT_V,
|
|
6
|
+
normalizeArchitectureContract,
|
|
7
|
+
} from './architecture/contract-schema.js'
|
|
8
|
+
export {
|
|
9
|
+
CONTRACT_PATHS,
|
|
10
|
+
loadArchitectureContract,
|
|
11
|
+
writeCachedArchitectureContract,
|
|
12
|
+
} from './architecture/contract-storage.js'
|
|
13
|
+
export {
|
|
14
|
+
contractForChange,
|
|
15
|
+
verifyArchitecture,
|
|
16
|
+
} from './architecture/contract-verification.js'
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {changeLimits, normalizeChangePath} from './options.js'
|
|
2
|
+
|
|
3
|
+
function decodeGitQuoted(value) {
|
|
4
|
+
const input = String(value || '').trim()
|
|
5
|
+
if (!input.startsWith('"')) return input.split('\t', 1)[0]
|
|
6
|
+
try { return JSON.parse(input) } catch { /* Git octal escapes are not JSON. */ }
|
|
7
|
+
const bytes = []
|
|
8
|
+
for (let index = 1; index < input.length - 1; index++) {
|
|
9
|
+
const char = input[index]
|
|
10
|
+
if (char !== '\\') { bytes.push(...Buffer.from(char)); continue }
|
|
11
|
+
const next = input[++index] || ''
|
|
12
|
+
if (/[0-7]/.test(next)) {
|
|
13
|
+
let octal = next
|
|
14
|
+
while (octal.length < 3 && /[0-7]/.test(input[index + 1] || '')) octal += input[++index]
|
|
15
|
+
bytes.push(parseInt(octal, 8))
|
|
16
|
+
} else {
|
|
17
|
+
const escapes = {n: 10, r: 13, t: 9, b: 8, f: 12, v: 11, '\\': 92, '"': 34}
|
|
18
|
+
bytes.push(escapes[next] ?? next.charCodeAt(0))
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return Buffer.from(bytes).toString('utf8')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function diffPath(raw, prefix) {
|
|
25
|
+
const decoded = decodeGitQuoted(raw)
|
|
26
|
+
if (!decoded || decoded === '/dev/null') return null
|
|
27
|
+
return normalizeChangePath(decoded.startsWith(`${prefix}/`) ? decoded.slice(2) : decoded)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function headerPaths(line) {
|
|
31
|
+
const match = /^diff --git ("(?:\\.|[^"])*"|\S+) ("(?:\\.|[^"])*"|\S+)$/.exec(line)
|
|
32
|
+
return match
|
|
33
|
+
? {oldPath: diffPath(match[1], 'a'), newPath: diffPath(match[2], 'b')}
|
|
34
|
+
: {oldPath: null, newPath: null}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const emptyFile = (paths = {}) => ({
|
|
38
|
+
oldPath: paths.oldPath || null,
|
|
39
|
+
newPath: paths.newPath || null,
|
|
40
|
+
newFile: false,
|
|
41
|
+
deletedFile: false,
|
|
42
|
+
renamed: false,
|
|
43
|
+
binary: false,
|
|
44
|
+
hunks: [],
|
|
45
|
+
additions: [],
|
|
46
|
+
removals: [],
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
export function parseZeroContextDiff(diffText, options = {}) {
|
|
50
|
+
const limits = changeLimits(options)
|
|
51
|
+
const original = String(diffText ?? '')
|
|
52
|
+
const byteLength = Buffer.byteLength(original)
|
|
53
|
+
const oversized = byteLength > limits.maxDiffBytes
|
|
54
|
+
const text = oversized ? original.slice(0, limits.maxDiffBytes) : original
|
|
55
|
+
const files = []
|
|
56
|
+
let file = null, hunk = null, changedLines = 0, truncated = oversized
|
|
57
|
+
const finish = () => {
|
|
58
|
+
if (!file) return
|
|
59
|
+
if ((file.oldPath || file.newPath) && files.length < limits.maxFiles) files.push(file)
|
|
60
|
+
else if (files.length >= limits.maxFiles) truncated = true
|
|
61
|
+
file = null; hunk = null
|
|
62
|
+
}
|
|
63
|
+
const addChange = (kind, line, oldLine, newLine, mappedNewLine) => {
|
|
64
|
+
changedLines++
|
|
65
|
+
if (changedLines > limits.maxChangedLines) { truncated = true; return }
|
|
66
|
+
const change = {
|
|
67
|
+
kind,
|
|
68
|
+
text: String(line).slice(0, limits.maxLineLength),
|
|
69
|
+
...(oldLine != null ? {oldLine} : {}),
|
|
70
|
+
...(newLine != null ? {newLine} : {}),
|
|
71
|
+
mappedNewLine,
|
|
72
|
+
}
|
|
73
|
+
;(kind === 'added' ? file.additions : file.removals).push(change)
|
|
74
|
+
}
|
|
75
|
+
for (const line of text.split(/\r?\n/)) {
|
|
76
|
+
if (line.startsWith('diff --git ')) { finish(); file = emptyFile(headerPaths(line)); continue }
|
|
77
|
+
if (!file && line.startsWith('--- ')) file = emptyFile()
|
|
78
|
+
if (!file) continue
|
|
79
|
+
if (line.startsWith('new file mode ')) { file.newFile = true; continue }
|
|
80
|
+
if (line.startsWith('deleted file mode ')) { file.deletedFile = true; continue }
|
|
81
|
+
if (line.startsWith('rename from ')) { file.oldPath = normalizeChangePath(decodeGitQuoted(line.slice(12))); file.renamed = true; continue }
|
|
82
|
+
if (line.startsWith('rename to ')) { file.newPath = normalizeChangePath(decodeGitQuoted(line.slice(10))); file.renamed = true; continue }
|
|
83
|
+
if (line.startsWith('Binary files ') || line === 'GIT binary patch') { file.binary = true; continue }
|
|
84
|
+
if (!hunk && line.startsWith('--- ')) { file.oldPath = diffPath(line.slice(4), 'a'); if (!file.oldPath) file.newFile = true; continue }
|
|
85
|
+
if (!hunk && line.startsWith('+++ ')) { file.newPath = diffPath(line.slice(4), 'b'); if (!file.newPath) file.deletedFile = true; continue }
|
|
86
|
+
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line)
|
|
87
|
+
if (match) {
|
|
88
|
+
hunk = {
|
|
89
|
+
oldStart: Number(match[1]), oldCount: match[2] == null ? 1 : Number(match[2]),
|
|
90
|
+
newStart: Number(match[3]), newCount: match[4] == null ? 1 : Number(match[4]),
|
|
91
|
+
oldCursor: Number(match[1]), newCursor: Number(match[3]),
|
|
92
|
+
}
|
|
93
|
+
file.hunks.push({oldStart: hunk.oldStart, oldCount: hunk.oldCount, newStart: hunk.newStart, newCount: hunk.newCount})
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if (!hunk || line.startsWith('\\ No newline')) continue
|
|
97
|
+
if (line.startsWith('+')) { addChange('added', line.slice(1), null, hunk.newCursor, hunk.newCursor); hunk.newCursor++ }
|
|
98
|
+
else if (line.startsWith('-')) { addChange('removed', line.slice(1), hunk.oldCursor, null, hunk.newCursor); hunk.oldCursor++ }
|
|
99
|
+
else { hunk.oldCursor++; hunk.newCursor++ }
|
|
100
|
+
}
|
|
101
|
+
finish()
|
|
102
|
+
return {files, byteLength, changedLines: Math.min(changedLines, limits.maxChangedLines), truncated, oversized, limits}
|
|
103
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const CHANGE_CLASSIFICATION_LIMITS = Object.freeze({
|
|
2
|
+
maxDiffBytes: 2 * 1024 * 1024,
|
|
3
|
+
maxFiles: 500,
|
|
4
|
+
maxChangedLines: 20_000,
|
|
5
|
+
maxLineLength: 4_000,
|
|
6
|
+
maxSymbolsPerFile: 250,
|
|
7
|
+
maxSeeds: 1_000,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
export const CHANGE_CLASS_RANK = Object.freeze({
|
|
11
|
+
'metadata-only': 0,
|
|
12
|
+
'test-only': 0,
|
|
13
|
+
added: 1,
|
|
14
|
+
'body-changed': 2,
|
|
15
|
+
'signature-changed': 3,
|
|
16
|
+
removed: 4,
|
|
17
|
+
unknown: 5,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export const normalizeChangePath = (value) => String(value || '').replace(/\\/g, '/').replace(/^\.\//, '')
|
|
21
|
+
export const changeLineNumber = (value) => Number((String(value || '').match(/(?:^L|@)(\d+)$/) || [])[1] || 0)
|
|
22
|
+
export const bareGraphLabel = (value) => String(value || '').replace(/\(.*$/, '').replace(/[^A-Za-z0-9_$].*$/, '').trim()
|
|
23
|
+
|
|
24
|
+
export function changeLimits(value = {}) {
|
|
25
|
+
const bounded = (item, fallback, min, max) => Math.max(min, Math.min(max, Number(item) || fallback))
|
|
26
|
+
const defaults = CHANGE_CLASSIFICATION_LIMITS
|
|
27
|
+
return {
|
|
28
|
+
maxDiffBytes: bounded(value.maxDiffBytes, defaults.maxDiffBytes, 1_024, defaults.maxDiffBytes),
|
|
29
|
+
maxFiles: bounded(value.maxFiles, defaults.maxFiles, 1, defaults.maxFiles),
|
|
30
|
+
maxChangedLines: bounded(value.maxChangedLines, defaults.maxChangedLines, 10, defaults.maxChangedLines),
|
|
31
|
+
maxLineLength: bounded(value.maxLineLength, defaults.maxLineLength, 80, defaults.maxLineLength),
|
|
32
|
+
maxSymbolsPerFile: bounded(value.maxSymbolsPerFile, defaults.maxSymbolsPerFile, 1, defaults.maxSymbolsPerFile),
|
|
33
|
+
maxSeeds: bounded(value.maxSeeds, defaults.maxSeeds, 1, defaults.maxSeeds),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function uniqueChangeSeeds(values, limit) {
|
|
38
|
+
const all = [...new Set(values.filter(Boolean).map(String))].sort((a, b) => a.localeCompare(b))
|
|
39
|
+
return {items: all.slice(0, limit), truncated: all.length > limit, total: all.length}
|
|
40
|
+
}
|