weavatrix 0.2.13 → 0.2.15

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 (145) hide show
  1. package/README.md +99 -39
  2. package/SECURITY.md +2 -2
  3. package/docs/releases/v0.2.15.md +37 -0
  4. package/package.json +2 -2
  5. package/skill/SKILL.md +57 -38
  6. package/src/analysis/architecture/contract-graph.js +119 -0
  7. package/src/analysis/architecture/contract-schema.js +110 -0
  8. package/src/analysis/architecture/contract-storage.js +35 -0
  9. package/src/analysis/architecture/contract-verification.js +168 -0
  10. package/src/analysis/architecture-contract.js +16 -343
  11. package/src/analysis/change-classification/diff-parser.js +103 -0
  12. package/src/analysis/change-classification/options.js +40 -0
  13. package/src/analysis/change-classification/symbol-classifier.js +177 -0
  14. package/src/analysis/change-classification.js +120 -519
  15. package/src/analysis/dead-code-review/policy.js +23 -0
  16. package/src/analysis/dead-code-review.js +9 -19
  17. package/src/analysis/dep-check.js +14 -71
  18. package/src/analysis/dep-rules.js +10 -303
  19. package/src/analysis/dependency/conventions.js +16 -0
  20. package/src/analysis/dependency/scoped-dependencies.js +45 -0
  21. package/src/analysis/dependency/source-references.js +21 -0
  22. package/src/analysis/duplicates.tokenize.js +12 -2
  23. package/src/analysis/endpoints/common.js +62 -0
  24. package/src/analysis/endpoints/extract.js +107 -0
  25. package/src/analysis/endpoints/inventory.js +84 -0
  26. package/src/analysis/endpoints/mounts.js +129 -0
  27. package/src/analysis/endpoints-java.js +80 -3
  28. package/src/analysis/endpoints.js +3 -448
  29. package/src/analysis/git-history/analytics.js +177 -0
  30. package/src/analysis/git-history/collector.js +109 -0
  31. package/src/analysis/git-history/options.js +68 -0
  32. package/src/analysis/git-history.js +128 -577
  33. package/src/analysis/health-capabilities.js +82 -0
  34. package/src/analysis/http-contracts/analysis.js +169 -0
  35. package/src/analysis/http-contracts/client-call-detection.js +81 -0
  36. package/src/analysis/http-contracts/client-call-parser.js +255 -0
  37. package/src/analysis/http-contracts/graph-context.js +143 -0
  38. package/src/analysis/http-contracts/matching.js +61 -0
  39. package/src/analysis/http-contracts/shared.js +86 -0
  40. package/src/analysis/http-contracts.js +6 -822
  41. package/src/analysis/internal-audit/dependency-health.js +111 -0
  42. package/src/analysis/internal-audit/python-manifests.js +65 -0
  43. package/src/analysis/internal-audit/repo-files.js +55 -0
  44. package/src/analysis/internal-audit/supply-chain.js +132 -0
  45. package/src/analysis/internal-audit.collect.js +5 -106
  46. package/src/analysis/internal-audit.reach.js +20 -0
  47. package/src/analysis/internal-audit.run.js +114 -200
  48. package/src/analysis/jvm-dependency-evidence.js +69 -0
  49. package/src/analysis/source-correctness/retry-patterns.js +93 -0
  50. package/src/analysis/source-correctness.js +225 -0
  51. package/src/analysis/structure/dependency-graph.js +156 -0
  52. package/src/analysis/structure/findings.js +142 -0
  53. package/src/analysis/task-retrieval.js +3 -14
  54. package/src/graph/builder/go-receiver-resolution.js +112 -0
  55. package/src/graph/builder/js/queries.js +48 -0
  56. package/src/graph/builder/lang-go.js +89 -4
  57. package/src/graph/builder/lang-js.js +4 -44
  58. package/src/graph/builder/pass2-resolution.js +68 -0
  59. package/src/graph/freshness-probe.js +2 -1
  60. package/src/graph/incremental-refresh.js +3 -2
  61. package/src/graph/internal-builder.build.js +22 -183
  62. package/src/graph/internal-builder.pass2.js +161 -0
  63. package/src/graph/internal-builder.resolvers.js +2 -105
  64. package/src/graph/resolvers/rust.js +117 -0
  65. package/src/mcp/actions/advisories.mjs +18 -0
  66. package/src/mcp/actions/graph-lifecycle.mjs +148 -0
  67. package/src/mcp/actions/graph-sync.mjs +195 -0
  68. package/src/mcp/actions/hosted-architecture.mjs +57 -0
  69. package/src/mcp/architecture-bootstrap.mjs +168 -0
  70. package/src/mcp/architecture-starter.mjs +234 -0
  71. package/src/mcp/catalog.mjs +20 -6
  72. package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
  73. package/src/mcp/evidence/duplicate-member-order.mjs +8 -0
  74. package/src/mcp/evidence/package-graph-common.mjs +115 -0
  75. package/src/mcp/evidence/package-lock-graph.mjs +92 -0
  76. package/src/mcp/evidence-snapshot.duplicates.mjs +3 -7
  77. package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
  78. package/src/mcp/git-output.mjs +10 -0
  79. package/src/mcp/graph/context-core.mjs +111 -0
  80. package/src/mcp/graph/context-seeds.mjs +197 -0
  81. package/src/mcp/graph/context-state.mjs +86 -0
  82. package/src/mcp/graph/reverse-reach.mjs +42 -0
  83. package/src/mcp/graph/tools-core.mjs +143 -0
  84. package/src/mcp/graph/tools-query.mjs +223 -0
  85. package/src/mcp/graph-context.mjs +4 -496
  86. package/src/mcp/health/audit-format.mjs +172 -0
  87. package/src/mcp/health/audit.mjs +171 -0
  88. package/src/mcp/health/dead-code.mjs +110 -0
  89. package/src/mcp/health/duplicates.mjs +83 -0
  90. package/src/mcp/health/endpoints.mjs +42 -0
  91. package/src/mcp/health/structure.mjs +219 -0
  92. package/src/mcp/runtime-version.mjs +32 -0
  93. package/src/mcp/server/auto-refresh.mjs +66 -0
  94. package/src/mcp/server/runtime-config.mjs +43 -0
  95. package/src/mcp/server/shutdown.mjs +40 -0
  96. package/src/mcp/sync/evidence-architecture.mjs +54 -0
  97. package/src/mcp/sync/evidence-common.mjs +88 -0
  98. package/src/mcp/sync/evidence-duplicates.mjs +97 -0
  99. package/src/mcp/sync/evidence-health.mjs +56 -0
  100. package/src/mcp/sync/evidence-packages.mjs +95 -0
  101. package/src/mcp/sync/payload-common.mjs +97 -0
  102. package/src/mcp/sync/payload-v2.mjs +53 -0
  103. package/src/mcp/sync/payload-v3.mjs +79 -0
  104. package/src/mcp/sync-evidence.mjs +7 -402
  105. package/src/mcp/sync-payload.mjs +10 -244
  106. package/src/mcp/tools-actions.mjs +18 -414
  107. package/src/mcp/tools-architecture.mjs +18 -70
  108. package/src/mcp/tools-context.mjs +56 -15
  109. package/src/mcp/tools-endpoints.mjs +4 -1
  110. package/src/mcp/tools-graph.mjs +17 -331
  111. package/src/mcp/tools-health.mjs +24 -705
  112. package/src/mcp/tools-impact-change.mjs +2 -38
  113. package/src/mcp/tools-impact.mjs +2 -43
  114. package/src/mcp-server.mjs +35 -146
  115. package/src/path-classification.js +14 -0
  116. package/src/precision/lsp-client/constants.js +12 -0
  117. package/src/precision/lsp-client/environment.js +20 -0
  118. package/src/precision/lsp-client/errors.js +15 -0
  119. package/src/precision/lsp-client/lifecycle.js +127 -0
  120. package/src/precision/lsp-client/message-parser.js +81 -0
  121. package/src/precision/lsp-client/protocol.js +212 -0
  122. package/src/precision/lsp-client/registry.js +58 -0
  123. package/src/precision/lsp-client/repo-uri.js +60 -0
  124. package/src/precision/lsp-client/stdio-client.js +151 -0
  125. package/src/precision/lsp-client.js +10 -682
  126. package/src/precision/lsp-overlay/build.js +238 -0
  127. package/src/precision/lsp-overlay/contract.js +61 -0
  128. package/src/precision/lsp-overlay/reference-results.js +108 -0
  129. package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
  130. package/src/precision/lsp-overlay/source-session.js +134 -0
  131. package/src/precision/lsp-overlay/store.js +150 -0
  132. package/src/precision/lsp-overlay/target-index.js +147 -0
  133. package/src/precision/lsp-overlay/target-query.js +55 -0
  134. package/src/precision/lsp-overlay.js +11 -868
  135. package/src/precision/typescript-lsp-provider.js +12 -682
  136. package/src/precision/typescript-provider/client.js +69 -0
  137. package/src/precision/typescript-provider/discovery.js +124 -0
  138. package/src/precision/typescript-provider/project-host.js +249 -0
  139. package/src/precision/typescript-provider/project-safety.js +161 -0
  140. package/src/precision/typescript-provider/reference-usage.js +53 -0
  141. package/src/security/malware-heuristics.sweep.js +1 -1
  142. package/src/security/registry-sig.classify.js +2 -1
  143. package/src/security/registry-sig.rules.js +3 -3
  144. package/src/version.js +7 -0
  145. package/docs/releases/v0.2.13.md +0 -48
@@ -1,343 +1,16 @@
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) : [] }
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
+ }