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