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.
Files changed (132) hide show
  1. package/README.md +90 -21
  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 +108 -43
  6. package/src/analysis/allowed-test-runner.js +20 -9
  7. package/src/analysis/architecture/contract-graph.js +119 -0
  8. package/src/analysis/architecture/contract-schema.js +110 -0
  9. package/src/analysis/architecture/contract-storage.js +35 -0
  10. package/src/analysis/architecture/contract-verification.js +168 -0
  11. package/src/analysis/architecture-contract.js +16 -343
  12. package/src/analysis/change-classification/diff-parser.js +103 -0
  13. package/src/analysis/change-classification/options.js +40 -0
  14. package/src/analysis/change-classification/symbol-classifier.js +177 -0
  15. package/src/analysis/change-classification.js +120 -519
  16. package/src/analysis/dead-code-review/policy.js +23 -0
  17. package/src/analysis/dead-code-review.js +9 -19
  18. package/src/analysis/dep-check.js +10 -57
  19. package/src/analysis/dep-rules.js +10 -303
  20. package/src/analysis/dependency/scoped-dependencies.js +40 -0
  21. package/src/analysis/endpoints/common.js +62 -0
  22. package/src/analysis/endpoints/extract.js +107 -0
  23. package/src/analysis/endpoints/inventory.js +84 -0
  24. package/src/analysis/endpoints/mounts.js +129 -0
  25. package/src/analysis/endpoints-java.js +80 -3
  26. package/src/analysis/endpoints.js +3 -448
  27. package/src/analysis/git-history/analytics.js +177 -0
  28. package/src/analysis/git-history/collector.js +109 -0
  29. package/src/analysis/git-history/options.js +68 -0
  30. package/src/analysis/git-history.js +128 -577
  31. package/src/analysis/health-capabilities.js +82 -0
  32. package/src/analysis/http-contracts/analysis.js +169 -0
  33. package/src/analysis/http-contracts/client-call-detection.js +81 -0
  34. package/src/analysis/http-contracts/client-call-parser.js +255 -0
  35. package/src/analysis/http-contracts/graph-context.js +143 -0
  36. package/src/analysis/http-contracts/matching.js +61 -0
  37. package/src/analysis/http-contracts/shared.js +86 -0
  38. package/src/analysis/http-contracts.js +6 -822
  39. package/src/analysis/internal-audit/dependency-health.js +111 -0
  40. package/src/analysis/internal-audit/python-manifests.js +65 -0
  41. package/src/analysis/internal-audit/repo-files.js +55 -0
  42. package/src/analysis/internal-audit/supply-chain.js +132 -0
  43. package/src/analysis/internal-audit.collect.js +5 -106
  44. package/src/analysis/internal-audit.run.js +113 -200
  45. package/src/analysis/jvm-dependency-evidence.js +69 -0
  46. package/src/analysis/source-correctness/retry-patterns.js +93 -0
  47. package/src/analysis/source-correctness.js +225 -0
  48. package/src/analysis/structure/dependency-graph.js +156 -0
  49. package/src/analysis/structure/findings.js +142 -0
  50. package/src/graph/builder/go-receiver-resolution.js +112 -0
  51. package/src/graph/builder/js/queries.js +48 -0
  52. package/src/graph/builder/lang-go.js +89 -4
  53. package/src/graph/builder/lang-js.js +4 -44
  54. package/src/graph/builder/pass2-resolution.js +68 -0
  55. package/src/graph/freshness-probe.js +2 -1
  56. package/src/graph/incremental-refresh.js +3 -2
  57. package/src/graph/internal-builder.build.js +22 -183
  58. package/src/graph/internal-builder.pass2.js +161 -0
  59. package/src/graph/internal-builder.resolvers.js +2 -105
  60. package/src/graph/resolvers/rust.js +117 -0
  61. package/src/mcp/actions/advisories.mjs +18 -0
  62. package/src/mcp/actions/graph-lifecycle.mjs +148 -0
  63. package/src/mcp/actions/graph-sync.mjs +195 -0
  64. package/src/mcp/actions/hosted-architecture.mjs +57 -0
  65. package/src/mcp/architecture-bootstrap.mjs +168 -0
  66. package/src/mcp/architecture-starter.mjs +234 -0
  67. package/src/mcp/catalog.mjs +22 -6
  68. package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
  69. package/src/mcp/evidence/package-graph-common.mjs +115 -0
  70. package/src/mcp/evidence/package-lock-graph.mjs +92 -0
  71. package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
  72. package/src/mcp/graph/context-core.mjs +111 -0
  73. package/src/mcp/graph/context-seeds.mjs +208 -0
  74. package/src/mcp/graph/context-state.mjs +86 -0
  75. package/src/mcp/graph/tools-core.mjs +143 -0
  76. package/src/mcp/graph/tools-query.mjs +223 -0
  77. package/src/mcp/graph-context.mjs +4 -496
  78. package/src/mcp/health/audit-format.mjs +172 -0
  79. package/src/mcp/health/audit.mjs +171 -0
  80. package/src/mcp/health/dead-code.mjs +110 -0
  81. package/src/mcp/health/duplicates.mjs +83 -0
  82. package/src/mcp/health/endpoints.mjs +42 -0
  83. package/src/mcp/health/structure.mjs +219 -0
  84. package/src/mcp/runtime-version.mjs +32 -0
  85. package/src/mcp/server/auto-refresh.mjs +66 -0
  86. package/src/mcp/server/runtime-config.mjs +43 -0
  87. package/src/mcp/server/shutdown.mjs +40 -0
  88. package/src/mcp/sync/evidence-architecture.mjs +54 -0
  89. package/src/mcp/sync/evidence-common.mjs +88 -0
  90. package/src/mcp/sync/evidence-duplicates.mjs +100 -0
  91. package/src/mcp/sync/evidence-health.mjs +56 -0
  92. package/src/mcp/sync/evidence-packages.mjs +95 -0
  93. package/src/mcp/sync/payload-common.mjs +97 -0
  94. package/src/mcp/sync/payload-v2.mjs +53 -0
  95. package/src/mcp/sync/payload-v3.mjs +79 -0
  96. package/src/mcp/sync-evidence.mjs +7 -402
  97. package/src/mcp/sync-payload.mjs +10 -244
  98. package/src/mcp/tools-actions.mjs +18 -414
  99. package/src/mcp/tools-architecture.mjs +18 -70
  100. package/src/mcp/tools-context.mjs +56 -15
  101. package/src/mcp/tools-endpoints.mjs +4 -1
  102. package/src/mcp/tools-graph.mjs +17 -330
  103. package/src/mcp/tools-health.mjs +24 -705
  104. package/src/mcp/tools-verified-change.mjs +12 -4
  105. package/src/mcp-server.mjs +49 -146
  106. package/src/precision/lsp-client/constants.js +12 -0
  107. package/src/precision/lsp-client/environment.js +20 -0
  108. package/src/precision/lsp-client/errors.js +15 -0
  109. package/src/precision/lsp-client/lifecycle.js +127 -0
  110. package/src/precision/lsp-client/message-parser.js +81 -0
  111. package/src/precision/lsp-client/protocol.js +212 -0
  112. package/src/precision/lsp-client/registry.js +58 -0
  113. package/src/precision/lsp-client/repo-uri.js +60 -0
  114. package/src/precision/lsp-client/stdio-client.js +151 -0
  115. package/src/precision/lsp-client.js +10 -682
  116. package/src/precision/lsp-overlay/build.js +238 -0
  117. package/src/precision/lsp-overlay/contract.js +61 -0
  118. package/src/precision/lsp-overlay/reference-results.js +108 -0
  119. package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
  120. package/src/precision/lsp-overlay/source-session.js +134 -0
  121. package/src/precision/lsp-overlay/store.js +150 -0
  122. package/src/precision/lsp-overlay/target-index.js +147 -0
  123. package/src/precision/lsp-overlay/target-query.js +55 -0
  124. package/src/precision/lsp-overlay.js +11 -868
  125. package/src/precision/typescript-lsp-provider.js +12 -682
  126. package/src/precision/typescript-provider/client.js +69 -0
  127. package/src/precision/typescript-provider/discovery.js +124 -0
  128. package/src/precision/typescript-provider/project-host.js +249 -0
  129. package/src/precision/typescript-provider/project-safety.js +161 -0
  130. package/src/precision/typescript-provider/reference-usage.js +53 -0
  131. package/src/version.js +7 -0
  132. package/docs/releases/v0.2.12.md +0 -45
@@ -19,29 +19,40 @@ function packageManager(repoRoot) {
19
19
  return 'npm'
20
20
  }
21
21
 
22
- export function validateTestRequests(repoRoot, requests = []) {
23
- const manifest = manifestAt(repoRoot)
24
- if (!manifest) return {ok: false, reason: 'package.json is missing or unreadable', tests: []}
22
+ function normalizeTestRequests(requests = []) {
25
23
  const tests = []
26
- for (const request of requests.slice(0, 5)) {
24
+ for (const request of (Array.isArray(requests) ? requests : []).slice(0, 5)) {
27
25
  const script = String(request?.script || '')
28
26
  const args = Array.isArray(request?.args) ? request.args.slice(0, 40).map(String) : []
29
27
  if (!SAFE_SCRIPT.test(script)) return {ok: false, reason: `script ${script || '(missing)'} is outside the test/check/verify allowlist`, tests}
30
- if (!Object.hasOwn(manifest.scripts || {}, script)) return {ok: false, reason: `package.json has no script named ${script}`, tests}
31
28
  if (args.some((arg) => arg.length > 300 || UNSAFE_SHELL_ARG.test(arg))) return {ok: false, reason: `script ${script} has an invalid or shell-sensitive argument`, tests}
32
29
  tests.push({script, args})
33
30
  }
31
+ return {ok: true, tests}
32
+ }
33
+
34
+ export function validateTestRequests(repoRoot, requests = []) {
35
+ const normalized = normalizeTestRequests(requests)
36
+ if (!normalized.ok || !normalized.tests.length) return normalized
37
+ const manifest = manifestAt(repoRoot)
38
+ if (!manifest) return {ok: false, reason: 'package.json is missing or unreadable', tests: []}
39
+ for (const test of normalized.tests) {
40
+ if (!Object.hasOwn(manifest.scripts || {}, test.script)) return {ok: false, reason: `package.json has no script named ${test.script}`, tests: []}
41
+ }
42
+ const tests = normalized.tests
34
43
  return {ok: true, tests, packageManager: packageManager(repoRoot)}
35
44
  }
36
45
 
37
46
  export async function runAllowedTests(repoRoot, requests = [], {enabled = false, timeoutMs = 60_000} = {}) {
38
- const checked = validateTestRequests(repoRoot, requests)
39
- if (!checked.ok) return {state: 'BLOCKED', reason: checked.reason, results: []}
40
- if (!checked.tests.length) return {state: 'NOT_REQUESTED', reason: 'no package scripts were requested', results: []}
47
+ const normalized = normalizeTestRequests(requests)
48
+ if (!normalized.ok) return {state: 'BLOCKED', reason: normalized.reason, results: []}
49
+ if (!normalized.tests.length) return {state: 'NOT_REQUESTED', reason: 'no package scripts were requested', plan: [], results: []}
41
50
  if (!enabled || process.env.WEAVATRIX_ALLOW_TEST_RUNS !== '1') return {
42
51
  state: 'DISABLED', reason: 'set WEAVATRIX_ALLOW_TEST_RUNS=1 and pass run_tests:true to execute allowlisted package scripts',
43
- plan: checked.tests, results: [],
52
+ plan: normalized.tests, results: [],
44
53
  }
54
+ const checked = validateTestRequests(repoRoot, normalized.tests)
55
+ if (!checked.ok) return {state: 'BLOCKED', reason: checked.reason, results: []}
45
56
  const results = []
46
57
  const timeout = Math.max(1000, Math.min(300_000, Number(timeoutMs) || 60_000))
47
58
  for (const test of checked.tests) {
@@ -0,0 +1,119 @@
1
+ import {architectureHash} from './contract-schema.js'
2
+
3
+ export const isSymbolNode = (id) => String(id).includes('#')
4
+ const endpointId = (value) => value && typeof value === 'object' ? String(value.id) : String(value ?? '')
5
+
6
+ export function fileOfNode(id, byId) {
7
+ const node = byId.get(String(id))
8
+ if (node?.source_file) return String(node.source_file).replace(/\\/g, '/')
9
+ return String(id).split('#')[0].replace(/\\/g, '/')
10
+ }
11
+
12
+ export function componentForFile(file, components) {
13
+ const normalized = String(file || '').replace(/\\/g, '/')
14
+ let best = null
15
+ for (const component of components) for (const prefix of component.paths) {
16
+ if (normalized !== prefix && !normalized.startsWith(`${prefix}/`)) continue
17
+ if (!best || prefix.length > best.prefix.length) best = {id: component.id, prefix}
18
+ }
19
+ return best?.id || '(unmapped)'
20
+ }
21
+
22
+ export function relationKind(link) {
23
+ if (link.typeOnly === true) return 'type-only'
24
+ if (link.compileOnly === true) return 'compile-only'
25
+ return 'runtime'
26
+ }
27
+
28
+ export function runtimeFileGraph(graph) {
29
+ const byId = new Map((graph.nodes || []).map((node) => [String(node.id), node]))
30
+ const files = new Set((graph.nodes || []).filter((node) => !isSymbolNode(node.id)).map((node) => String(node.id)))
31
+ const adjacency = new Map([...files].map((file) => [file, new Set()]))
32
+ for (const link of graph.links || []) {
33
+ if (relationKind(link) !== 'runtime' || !['imports', 're_exports'].includes(link.relation) || link.barrelProxy === true) continue
34
+ const source = fileOfNode(endpointId(link.source), byId)
35
+ const target = fileOfNode(endpointId(link.target), byId)
36
+ if (source && target && source !== target && files.has(source) && files.has(target)) adjacency.get(source)?.add(target)
37
+ }
38
+ return adjacency
39
+ }
40
+
41
+ export function stronglyConnected(adjacency) {
42
+ let index = 0
43
+ const indexes = new Map(), low = new Map(), stack = [], onStack = new Set(), out = []
44
+ const visit = (node) => {
45
+ indexes.set(node, index); low.set(node, index); index++; stack.push(node); onStack.add(node)
46
+ for (const target of adjacency.get(node) || []) {
47
+ if (!indexes.has(target)) { visit(target); low.set(node, Math.min(low.get(node), low.get(target))) }
48
+ else if (onStack.has(target)) low.set(node, Math.min(low.get(node), indexes.get(target)))
49
+ }
50
+ if (low.get(node) !== indexes.get(node)) return
51
+ const component = []
52
+ while (stack.length) {
53
+ const value = stack.pop()
54
+ onStack.delete(value)
55
+ component.push(value)
56
+ if (value === node) break
57
+ }
58
+ if (component.length > 1 || adjacency.get(node)?.has(node)) out.push(component.sort())
59
+ }
60
+ for (const node of [...adjacency.keys()].sort()) if (!indexes.has(node)) visit(node)
61
+ return out.sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]))
62
+ }
63
+
64
+ export function architectureViolation(ruleId, kind, evidence, current, target) {
65
+ const normalizedEvidence = String(evidence).replace(/:\d+(?=\b|$)/g, '')
66
+ const fingerprint = architectureHash({ruleId, kind, evidence: normalizedEvidence}).slice(0, 32)
67
+ return {fingerprint, ruleId, kind, evidence, ...(current != null ? {current} : {}), ...(target != null ? {target} : {})}
68
+ }
69
+
70
+ export const matchComponentSelector = (selector, value) => selector.includes('*') || selector.includes(value)
71
+
72
+ export function collectComponentEdges(graph, contract) {
73
+ const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []
74
+ const links = Array.isArray(graph?.links) ? graph.links : []
75
+ const byId = new Map(nodes.map((node) => [String(node.id), node]))
76
+ const componentEdges = new Map()
77
+ for (const link of links) {
78
+ if (!['imports', 're_exports', 'calls', 'references'].includes(link.relation) || link.barrelProxy === true) continue
79
+ const fromFile = fileOfNode(endpointId(link.source), byId)
80
+ const toFile = fileOfNode(endpointId(link.target), byId)
81
+ if (!fromFile || !toFile || fromFile === toFile) continue
82
+ const from = componentForFile(fromFile, contract.components)
83
+ const to = componentForFile(toFile, contract.components)
84
+ if (from === to) continue
85
+ const kind = relationKind(link)
86
+ const key = `${from}\0${to}\0${kind}`
87
+ const record = componentEdges.get(key) || {from, to, kind, count: 0, samples: []}
88
+ record.count++
89
+ if (record.samples.length < 5) record.samples.push(`${fromFile} -> ${toFile}`)
90
+ componentEdges.set(key, record)
91
+ }
92
+ return {nodes, links, byId, componentEdges}
93
+ }
94
+
95
+ export function collectComponentFitness(nodes, links, byId, contract) {
96
+ const stats = new Map(contract.components.map((component) => [component.id, {
97
+ files: new Set(), internalPairs: new Set(), boundaryPairs: new Set(),
98
+ }]))
99
+ for (const node of nodes.filter((item) => !isSymbolNode(item.id))) {
100
+ const file = String(node.source_file || node.id).replace(/\\/g, '/')
101
+ stats.get(componentForFile(file, contract.components))?.files.add(file)
102
+ }
103
+ const runtimePairs = new Set()
104
+ for (const link of links) {
105
+ if (relationKind(link) !== 'runtime' || !['imports', 're_exports', 'calls', 'references'].includes(link.relation) || link.barrelProxy === true) continue
106
+ const source = fileOfNode(endpointId(link.source), byId), target = fileOfNode(endpointId(link.target), byId)
107
+ if (!source || !target || source === target) continue
108
+ const pair = `${source}\0${target}`
109
+ if (runtimePairs.has(pair)) continue
110
+ runtimePairs.add(pair)
111
+ const from = componentForFile(source, contract.components), to = componentForFile(target, contract.components)
112
+ if (from === to) stats.get(from)?.internalPairs.add(pair)
113
+ else {
114
+ stats.get(from)?.boundaryPairs.add(pair)
115
+ stats.get(to)?.boundaryPairs.add(pair)
116
+ }
117
+ }
118
+ return stats
119
+ }
@@ -0,0 +1,110 @@
1
+ import {createHash} from 'node:crypto'
2
+
3
+ export const ARCHITECTURE_CONTRACT_V = 1
4
+
5
+ const RELATION_KIND = new Set(['runtime', 'type-only', 'compile-only', 'any'])
6
+ const ACTION = new Set(['allow', 'forbid'])
7
+ const ENFORCEMENT = new Set(['ratchet', 'strict', 'advisory'])
8
+
9
+ export const safeArchitectureId = (value, fallback = '') => {
10
+ const text = String(value ?? '').trim()
11
+ return /^[a-z0-9][a-z0-9._:-]{0,127}$/i.test(text) ? text : fallback
12
+ }
13
+
14
+ export const architecturePathPrefix = (value) => {
15
+ const text = String(value ?? '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+|\/+$/g, '')
16
+ return text && !text.split('/').some((part) => !part || part === '.' || part === '..') ? text : null
17
+ }
18
+
19
+ export const finiteArchitectureBudget = (value, fallback = null) => {
20
+ const number = Number(value)
21
+ return Number.isFinite(number) && number >= 0 ? number : fallback
22
+ }
23
+
24
+ const stringList = (value, sanitize, cap = 100) => [...new Set((Array.isArray(value) ? value : [])
25
+ .slice(0, cap).map(sanitize).filter(Boolean))]
26
+
27
+ const stable = (value) => Array.isArray(value)
28
+ ? `[${value.map(stable).join(',')}]`
29
+ : value && typeof value === 'object'
30
+ ? `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`
31
+ : JSON.stringify(value)
32
+
33
+ export const architectureHash = (value) => createHash('sha256').update(stable(value)).digest('hex')
34
+
35
+ function sanitizeComponent(value, index) {
36
+ const id = safeArchitectureId(value?.id, `component-${index + 1}`)
37
+ const paths = stringList(value?.paths ?? [value?.path], architecturePathPrefix, 64)
38
+ if (!paths.length) return null
39
+ return {id, name: String(value?.name || id).slice(0, 128), paths}
40
+ }
41
+
42
+ function sanitizeRule(value, index) {
43
+ const from = stringList(value?.from, (item) => item === '*' ? '*' : safeArchitectureId(item), 64)
44
+ const to = stringList(value?.to, (item) => item === '*' ? '*' : safeArchitectureId(item), 64)
45
+ if (!from.length || !to.length) return null
46
+ return {
47
+ id: safeArchitectureId(value?.id, `dependency-${index + 1}`),
48
+ action: ACTION.has(value?.action) ? value.action : 'forbid',
49
+ kinds: stringList(value?.kinds ?? ['runtime'], (kind) => RELATION_KIND.has(kind) ? kind : '', 4),
50
+ from,
51
+ to,
52
+ ...(value?.reason ? {reason: String(value.reason).slice(0, 300)} : {}),
53
+ }
54
+ }
55
+
56
+ function sanitizeException(value) {
57
+ const fingerprint = /^[a-f0-9]{16,64}$/i.test(String(value?.fingerprint || '')) ? String(value.fingerprint) : null
58
+ if (!fingerprint) return null
59
+ const expires = /^\d{4}-\d{2}-\d{2}$/.test(String(value?.expires || '')) ? String(value.expires) : null
60
+ return {fingerprint, reason: String(value?.reason || '').slice(0, 300), ...(expires ? {expires} : {})}
61
+ }
62
+
63
+ function normalizeBudgets(raw = {}) {
64
+ const budgets = {
65
+ runtimeCycles: finiteArchitectureBudget(raw.runtimeCycles),
66
+ maxFunctionLoc: finiteArchitectureBudget(raw.maxFunctionLoc),
67
+ maxFileLoc: finiteArchitectureBudget(raw.maxFileLoc),
68
+ maxCyclomatic: finiteArchitectureBudget(raw.maxCyclomatic),
69
+ maxParams: finiteArchitectureBudget(raw.maxParams),
70
+ maxRuntimeDependenciesPerComponent: finiteArchitectureBudget(raw.maxRuntimeDependenciesPerComponent),
71
+ maxModuleFiles: finiteArchitectureBudget(raw.maxModuleFiles),
72
+ minModuleCohesion: finiteArchitectureBudget(raw.minModuleCohesion),
73
+ maxModuleBoundaryRatio: finiteArchitectureBudget(raw.maxModuleBoundaryRatio),
74
+ }
75
+ for (const key of Object.keys(budgets)) if (budgets[key] == null) delete budgets[key]
76
+ return budgets
77
+ }
78
+
79
+ export function normalizeArchitectureContract(input) {
80
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('architecture contract must be an object')
81
+ const components = (Array.isArray(input.components) ? input.components : [])
82
+ .slice(0, 200).map(sanitizeComponent).filter(Boolean)
83
+ const componentIds = new Set(components.map((item) => item.id))
84
+ const dependencyRules = (Array.isArray(input.dependencyRules) ? input.dependencyRules : [])
85
+ .slice(0, 500).map(sanitizeRule).filter(Boolean)
86
+ .filter((rule) => [...rule.from, ...rule.to].every((id) => id === '*' || componentIds.has(id)))
87
+ const baseline = input.ratchet?.baseline && typeof input.ratchet.baseline === 'object'
88
+ ? {
89
+ fingerprints: stringList(input.ratchet.baseline.fingerprints, (item) => /^[a-f0-9]{16,64}$/i.test(String(item)) ? String(item) : '', 5_000),
90
+ metrics: Object.fromEntries(Object.entries(input.ratchet.baseline.metrics || {}).slice(0, 500)
91
+ .map(([key, value]) => [safeArchitectureId(key), finiteArchitectureBudget(value)]).filter(([key, value]) => key && value != null)),
92
+ }
93
+ : {fingerprints: [], metrics: {}}
94
+ const contract = {
95
+ architectureContractV: ARCHITECTURE_CONTRACT_V,
96
+ name: String(input.name || 'Target architecture').slice(0, 128),
97
+ style: safeArchitectureId(input.style, 'custom'),
98
+ enforcement: ENFORCEMENT.has(input.enforcement) ? input.enforcement : 'ratchet',
99
+ components,
100
+ dependencyRules,
101
+ budgets: normalizeBudgets(input.budgets),
102
+ technologies: {
103
+ required: stringList(input.technologies?.required, (item) => safeArchitectureId(item), 100),
104
+ forbidden: stringList(input.technologies?.forbidden, (item) => safeArchitectureId(item), 100),
105
+ },
106
+ exceptions: (Array.isArray(input.exceptions) ? input.exceptions : []).slice(0, 500).map(sanitizeException).filter(Boolean),
107
+ ratchet: {baseline},
108
+ }
109
+ return {...contract, contractHash: architectureHash(contract)}
110
+ }
@@ -0,0 +1,35 @@
1
+ import {existsSync, readFileSync, writeFileSync, mkdirSync} from 'node:fs'
2
+ import {dirname, join} from 'node:path'
3
+ import {createRepoBoundary} from '../../repo-path.js'
4
+ import {normalizeArchitectureContract} from './contract-schema.js'
5
+
6
+ export const CONTRACT_PATHS = ['.weavatrix/architecture.json', '.weavatrix-architecture.json']
7
+
8
+ const readContract = (path, source) => {
9
+ try {
10
+ return {contract: normalizeArchitectureContract(JSON.parse(readFileSync(path, 'utf8'))), source}
11
+ } catch (error) {
12
+ return {contract: null, source, error: error.message}
13
+ }
14
+ }
15
+
16
+ export function loadArchitectureContract(repoRoot, graphPath) {
17
+ const boundary = createRepoBoundary(repoRoot)
18
+ for (const relative of CONTRACT_PATHS) {
19
+ const resolved = boundary.resolve(relative)
20
+ if (resolved.ok && existsSync(resolved.path)) return readContract(resolved.path, relative)
21
+ }
22
+ const cached = graphPath ? join(dirname(graphPath), 'architecture.contract.json') : null
23
+ return cached && existsSync(cached)
24
+ ? readContract(cached, 'hosted-cache')
25
+ : {contract: null, source: null, error: null}
26
+ }
27
+
28
+ export function writeCachedArchitectureContract(graphPath, input) {
29
+ if (!graphPath) throw new Error('graph path is required for hosted contract cache')
30
+ const contract = normalizeArchitectureContract(input)
31
+ const path = join(dirname(graphPath), 'architecture.contract.json')
32
+ mkdirSync(dirname(path), {recursive: true})
33
+ writeFileSync(path, JSON.stringify(contract, null, 2), 'utf8')
34
+ return {path, contract}
35
+ }
@@ -0,0 +1,168 @@
1
+ import {
2
+ architectureHash,
3
+ architecturePathPrefix,
4
+ finiteArchitectureBudget,
5
+ normalizeArchitectureContract,
6
+ } from './contract-schema.js'
7
+ import {
8
+ architectureViolation as violation,
9
+ collectComponentEdges,
10
+ collectComponentFitness,
11
+ componentForFile,
12
+ fileOfNode,
13
+ isSymbolNode,
14
+ matchComponentSelector as matches,
15
+ runtimeFileGraph,
16
+ stronglyConnected,
17
+ } from './contract-graph.js'
18
+
19
+ function dependencyViolations(componentEdges, contract) {
20
+ const violations = []
21
+ for (const edge of componentEdges.values()) {
22
+ const applicable = contract.dependencyRules.filter((rule) =>
23
+ rule.kinds.some((kind) => kind === 'any' || kind === edge.kind) && matches(rule.from, edge.from))
24
+ for (const rule of applicable) if (rule.action === 'forbid' && matches(rule.to, edge.to)) {
25
+ violations.push(violation(rule.id, 'dependency', `${edge.from} -> ${edge.to} (${edge.kind})`, edge.count, 0))
26
+ }
27
+ const allowRules = applicable.filter((rule) => rule.action === 'allow')
28
+ if (allowRules.length && !allowRules.some((rule) => matches(rule.to, edge.to))) {
29
+ const ruleId = allowRules.map((rule) => rule.id).sort().join('+')
30
+ violations.push(violation(ruleId, 'dependency', `${edge.from} -> ${edge.to} (${edge.kind}; outside allow-list)`, edge.count, 0))
31
+ }
32
+ }
33
+ return violations
34
+ }
35
+
36
+ function componentBudgetViolations(componentEdges, stats, contract) {
37
+ const violations = []
38
+ const targetsByComponent = new Map()
39
+ for (const edge of componentEdges.values()) if (edge.kind === 'runtime') {
40
+ const targets = targetsByComponent.get(edge.from) || new Set()
41
+ targets.add(edge.to)
42
+ targetsByComponent.set(edge.from, targets)
43
+ }
44
+ if (contract.budgets.maxRuntimeDependenciesPerComponent != null) for (const [component, targets] of targetsByComponent) {
45
+ if (targets.size > contract.budgets.maxRuntimeDependenciesPerComponent) violations.push(violation(
46
+ 'budget.maxRuntimeDependenciesPerComponent', 'budget', `${component} runtime dependencies`, targets.size,
47
+ contract.budgets.maxRuntimeDependenciesPerComponent,
48
+ ))
49
+ }
50
+ for (const [component, item] of stats) {
51
+ const internal = item.internalPairs.size, boundary = item.boundaryPairs.size
52
+ const cohesion = internal + boundary ? internal / (internal + boundary) : 1
53
+ const boundaryRatio = internal + boundary ? boundary / (internal + boundary) : 0
54
+ if (contract.budgets.maxModuleFiles != null && item.files.size > contract.budgets.maxModuleFiles) {
55
+ violations.push(violation('budget.maxModuleFiles', 'budget', `${component} files`, item.files.size, contract.budgets.maxModuleFiles))
56
+ }
57
+ if (contract.budgets.minModuleCohesion != null && cohesion < contract.budgets.minModuleCohesion) {
58
+ violations.push(violation('budget.minModuleCohesion', 'budget', `${component} cohesion`, Number(cohesion.toFixed(4)), contract.budgets.minModuleCohesion))
59
+ }
60
+ if (contract.budgets.maxModuleBoundaryRatio != null && boundaryRatio > contract.budgets.maxModuleBoundaryRatio) {
61
+ violations.push(violation('budget.maxModuleBoundaryRatio', 'budget', `${component} boundary ratio`, Number(boundaryRatio.toFixed(4)), contract.budgets.maxModuleBoundaryRatio))
62
+ }
63
+ }
64
+ return violations
65
+ }
66
+
67
+ function codeBudgetViolations(nodes, byId, contract) {
68
+ const violations = []
69
+ for (const node of nodes) {
70
+ const loc = finiteArchitectureBudget(node?.complexity?.loc)
71
+ const cyclomatic = finiteArchitectureBudget(node?.complexity?.cyclomatic)
72
+ const params = finiteArchitectureBudget(node?.complexity?.params)
73
+ const evidence = `${node.source_file || fileOfNode(node.id, byId)}#${node.label || node.id}`
74
+ if (contract.budgets.maxFunctionLoc != null && loc != null && loc > contract.budgets.maxFunctionLoc) {
75
+ violations.push(violation('budget.maxFunctionLoc', 'budget', evidence, loc, contract.budgets.maxFunctionLoc))
76
+ }
77
+ if (contract.budgets.maxCyclomatic != null && cyclomatic != null && cyclomatic > contract.budgets.maxCyclomatic) {
78
+ violations.push(violation('budget.maxCyclomatic', 'budget', evidence, cyclomatic, contract.budgets.maxCyclomatic))
79
+ }
80
+ if (contract.budgets.maxParams != null && params != null && params > contract.budgets.maxParams) {
81
+ violations.push(violation('budget.maxParams', 'budget', evidence, params, contract.budgets.maxParams))
82
+ }
83
+ }
84
+ if (contract.budgets.maxFileLoc != null) for (const node of nodes.filter((item) => !isSymbolNode(item.id))) {
85
+ const file = String(node.source_file || node.id)
86
+ const physicalLoc = finiteArchitectureBudget(node.physical_loc)
87
+ if (physicalLoc != null && physicalLoc > contract.budgets.maxFileLoc) {
88
+ violations.push(violation('budget.maxFileLoc', 'budget', file, physicalLoc, contract.budgets.maxFileLoc))
89
+ }
90
+ }
91
+ return violations
92
+ }
93
+
94
+ function technologyViolations(contract, technologies) {
95
+ const techSet = new Set(technologies.map((item) => String(item ?? '').trim()).filter(Boolean))
96
+ const violations = []
97
+ for (const required of contract.technologies.required) if (!techSet.has(required)) {
98
+ violations.push(violation('technology.required', 'technology', `missing ${required}`))
99
+ }
100
+ for (const forbidden of contract.technologies.forbidden) if (techSet.has(forbidden)) {
101
+ violations.push(violation('technology.forbidden', 'technology', `forbidden ${forbidden}`))
102
+ }
103
+ return violations
104
+ }
105
+
106
+ const componentFitnessMetrics = (stats) => Object.fromEntries([...stats].map(([component, item]) => {
107
+ const internal = item.internalPairs.size, boundary = item.boundaryPairs.size
108
+ return [component, {
109
+ files: item.files.size,
110
+ cohesion: internal + boundary ? Number((internal / (internal + boundary)).toFixed(4)) : 1,
111
+ boundaryRatio: internal + boundary ? Number((boundary / (internal + boundary)).toFixed(4)) : 0,
112
+ }]
113
+ }))
114
+
115
+ export function verifyArchitecture({graph, contract: rawContract, technologies = []}) {
116
+ const contract = rawContract?.contractHash ? rawContract : normalizeArchitectureContract(rawContract)
117
+ const {nodes, links, byId, componentEdges} = collectComponentEdges(graph, contract)
118
+ const stats = collectComponentFitness(nodes, links, byId, contract)
119
+ const runtimeCycles = stronglyConnected(runtimeFileGraph(graph))
120
+ const violations = [
121
+ ...dependencyViolations(componentEdges, contract),
122
+ ...componentBudgetViolations(componentEdges, stats, contract),
123
+ ...codeBudgetViolations(nodes, byId, contract),
124
+ ...technologyViolations(contract, technologies),
125
+ ]
126
+ if (contract.budgets.runtimeCycles != null && runtimeCycles.length > contract.budgets.runtimeCycles) {
127
+ violations.push(violation('budget.runtimeCycles', 'budget', `runtime cycles: ${runtimeCycles.length}`, runtimeCycles.length, contract.budgets.runtimeCycles))
128
+ }
129
+ const exceptions = new Set(contract.exceptions
130
+ .filter((item) => !item.expires || item.expires >= new Date().toISOString().slice(0, 10))
131
+ .map((item) => item.fingerprint))
132
+ const active = violations.filter((item) => !exceptions.has(item.fingerprint))
133
+ const baseline = new Set(contract.ratchet.baseline.fingerprints)
134
+ const current = new Set(active.map((item) => item.fingerprint))
135
+ const fresh = active.filter((item) => !baseline.has(item.fingerprint))
136
+ const existing = active.filter((item) => baseline.has(item.fingerprint))
137
+ const fixed = [...baseline].filter((fingerprint) => !current.has(fingerprint))
138
+ const status = contract.enforcement === 'advisory'
139
+ ? 'ADVISORY'
140
+ : contract.enforcement === 'strict' ? (active.length ? 'FAIL' : 'PASS') : (fresh.length ? 'FAIL' : 'PASS')
141
+ const result = {
142
+ architectureVerificationV: 1,
143
+ contractHash: contract.contractHash,
144
+ status,
145
+ enforcement: contract.enforcement,
146
+ metrics: {
147
+ runtimeCycles: runtimeCycles.length,
148
+ violations: active.length,
149
+ componentDependencies: componentEdges.size,
150
+ mappedComponents: contract.components.length,
151
+ componentFitness: componentFitnessMetrics(stats),
152
+ },
153
+ new: fresh,
154
+ existing,
155
+ fixed,
156
+ excepted: violations.filter((item) => exceptions.has(item.fingerprint)),
157
+ componentEdges: [...componentEdges.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.kind.localeCompare(b.kind)),
158
+ runtimeCycles: runtimeCycles.slice(0, 100),
159
+ }
160
+ return {...result, verificationHash: architectureHash(result)}
161
+ }
162
+
163
+ export function contractForChange(contract, files = []) {
164
+ const normalized = (Array.isArray(files) ? files.slice(0, 200) : []).map(architecturePathPrefix).filter(Boolean)
165
+ const components = [...new Set(normalized.map((file) => componentForFile(file, contract.components)))]
166
+ const rules = contract.dependencyRules.filter((rule) => components.some((component) => matches(rule.from, component) || matches(rule.to, component)))
167
+ return {files: normalized, components, rules, budgets: contract.budgets, technologies: contract.technologies}
168
+ }