weavatrix 0.2.12 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -21
- package/SECURITY.md +2 -2
- package/docs/releases/v0.2.14.md +93 -0
- package/package.json +2 -2
- package/skill/SKILL.md +108 -43
- package/src/analysis/allowed-test-runner.js +20 -9
- package/src/analysis/architecture/contract-graph.js +119 -0
- package/src/analysis/architecture/contract-schema.js +110 -0
- package/src/analysis/architecture/contract-storage.js +35 -0
- package/src/analysis/architecture/contract-verification.js +168 -0
- package/src/analysis/architecture-contract.js +16 -343
- package/src/analysis/change-classification/diff-parser.js +103 -0
- package/src/analysis/change-classification/options.js +40 -0
- package/src/analysis/change-classification/symbol-classifier.js +177 -0
- package/src/analysis/change-classification.js +120 -519
- package/src/analysis/dead-code-review/policy.js +23 -0
- package/src/analysis/dead-code-review.js +9 -19
- package/src/analysis/dep-check.js +10 -57
- package/src/analysis/dep-rules.js +10 -303
- package/src/analysis/dependency/scoped-dependencies.js +40 -0
- package/src/analysis/endpoints/common.js +62 -0
- package/src/analysis/endpoints/extract.js +107 -0
- package/src/analysis/endpoints/inventory.js +84 -0
- package/src/analysis/endpoints/mounts.js +129 -0
- package/src/analysis/endpoints-java.js +80 -3
- package/src/analysis/endpoints.js +3 -448
- package/src/analysis/git-history/analytics.js +177 -0
- package/src/analysis/git-history/collector.js +109 -0
- package/src/analysis/git-history/options.js +68 -0
- package/src/analysis/git-history.js +128 -577
- package/src/analysis/health-capabilities.js +82 -0
- package/src/analysis/http-contracts/analysis.js +169 -0
- package/src/analysis/http-contracts/client-call-detection.js +81 -0
- package/src/analysis/http-contracts/client-call-parser.js +255 -0
- package/src/analysis/http-contracts/graph-context.js +143 -0
- package/src/analysis/http-contracts/matching.js +61 -0
- package/src/analysis/http-contracts/shared.js +86 -0
- package/src/analysis/http-contracts.js +6 -822
- package/src/analysis/internal-audit/dependency-health.js +111 -0
- package/src/analysis/internal-audit/python-manifests.js +65 -0
- package/src/analysis/internal-audit/repo-files.js +55 -0
- package/src/analysis/internal-audit/supply-chain.js +132 -0
- package/src/analysis/internal-audit.collect.js +5 -106
- package/src/analysis/internal-audit.run.js +113 -200
- package/src/analysis/jvm-dependency-evidence.js +69 -0
- package/src/analysis/source-correctness/retry-patterns.js +93 -0
- package/src/analysis/source-correctness.js +225 -0
- package/src/analysis/structure/dependency-graph.js +156 -0
- package/src/analysis/structure/findings.js +142 -0
- package/src/graph/builder/go-receiver-resolution.js +112 -0
- package/src/graph/builder/js/queries.js +48 -0
- package/src/graph/builder/lang-go.js +89 -4
- package/src/graph/builder/lang-js.js +4 -44
- package/src/graph/builder/pass2-resolution.js +68 -0
- package/src/graph/freshness-probe.js +2 -1
- package/src/graph/incremental-refresh.js +3 -2
- package/src/graph/internal-builder.build.js +22 -183
- package/src/graph/internal-builder.pass2.js +161 -0
- package/src/graph/internal-builder.resolvers.js +2 -105
- package/src/graph/resolvers/rust.js +117 -0
- package/src/mcp/actions/advisories.mjs +18 -0
- package/src/mcp/actions/graph-lifecycle.mjs +148 -0
- package/src/mcp/actions/graph-sync.mjs +195 -0
- package/src/mcp/actions/hosted-architecture.mjs +57 -0
- package/src/mcp/architecture-bootstrap.mjs +168 -0
- package/src/mcp/architecture-starter.mjs +234 -0
- package/src/mcp/catalog.mjs +22 -6
- package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
- package/src/mcp/evidence/package-graph-common.mjs +115 -0
- package/src/mcp/evidence/package-lock-graph.mjs +92 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
- package/src/mcp/graph/context-core.mjs +111 -0
- package/src/mcp/graph/context-seeds.mjs +208 -0
- package/src/mcp/graph/context-state.mjs +86 -0
- package/src/mcp/graph/tools-core.mjs +143 -0
- package/src/mcp/graph/tools-query.mjs +223 -0
- package/src/mcp/graph-context.mjs +4 -496
- package/src/mcp/health/audit-format.mjs +172 -0
- package/src/mcp/health/audit.mjs +171 -0
- package/src/mcp/health/dead-code.mjs +110 -0
- package/src/mcp/health/duplicates.mjs +83 -0
- package/src/mcp/health/endpoints.mjs +42 -0
- package/src/mcp/health/structure.mjs +219 -0
- package/src/mcp/runtime-version.mjs +32 -0
- package/src/mcp/server/auto-refresh.mjs +66 -0
- package/src/mcp/server/runtime-config.mjs +43 -0
- package/src/mcp/server/shutdown.mjs +40 -0
- package/src/mcp/sync/evidence-architecture.mjs +54 -0
- package/src/mcp/sync/evidence-common.mjs +88 -0
- package/src/mcp/sync/evidence-duplicates.mjs +100 -0
- package/src/mcp/sync/evidence-health.mjs +56 -0
- package/src/mcp/sync/evidence-packages.mjs +95 -0
- package/src/mcp/sync/payload-common.mjs +97 -0
- package/src/mcp/sync/payload-v2.mjs +53 -0
- package/src/mcp/sync/payload-v3.mjs +79 -0
- package/src/mcp/sync-evidence.mjs +7 -402
- package/src/mcp/sync-payload.mjs +10 -244
- package/src/mcp/tools-actions.mjs +18 -414
- package/src/mcp/tools-architecture.mjs +18 -70
- package/src/mcp/tools-context.mjs +56 -15
- package/src/mcp/tools-endpoints.mjs +4 -1
- package/src/mcp/tools-graph.mjs +17 -330
- package/src/mcp/tools-health.mjs +24 -705
- package/src/mcp/tools-verified-change.mjs +12 -4
- package/src/mcp-server.mjs +49 -146
- package/src/precision/lsp-client/constants.js +12 -0
- package/src/precision/lsp-client/environment.js +20 -0
- package/src/precision/lsp-client/errors.js +15 -0
- package/src/precision/lsp-client/lifecycle.js +127 -0
- package/src/precision/lsp-client/message-parser.js +81 -0
- package/src/precision/lsp-client/protocol.js +212 -0
- package/src/precision/lsp-client/registry.js +58 -0
- package/src/precision/lsp-client/repo-uri.js +60 -0
- package/src/precision/lsp-client/stdio-client.js +151 -0
- package/src/precision/lsp-client.js +10 -682
- package/src/precision/lsp-overlay/build.js +238 -0
- package/src/precision/lsp-overlay/contract.js +61 -0
- package/src/precision/lsp-overlay/reference-results.js +108 -0
- package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
- package/src/precision/lsp-overlay/source-session.js +134 -0
- package/src/precision/lsp-overlay/store.js +150 -0
- package/src/precision/lsp-overlay/target-index.js +147 -0
- package/src/precision/lsp-overlay/target-query.js +55 -0
- package/src/precision/lsp-overlay.js +11 -868
- package/src/precision/typescript-lsp-provider.js +12 -682
- package/src/precision/typescript-provider/client.js +69 -0
- package/src/precision/typescript-provider/discovery.js +124 -0
- package/src/precision/typescript-provider/project-host.js +249 -0
- package/src/precision/typescript-provider/project-safety.js +161 -0
- package/src/precision/typescript-provider/reference-usage.js +53 -0
- package/src/version.js +7 -0
- package/docs/releases/v0.2.12.md +0 -45
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto'
|
|
2
|
+
import {readFileSync, statSync} from 'node:fs'
|
|
3
|
+
import {graphStaleness} from '../graph-context.mjs'
|
|
4
|
+
import {graphHomeDir, graphOutDirForRepo} from '../../graph/layout.js'
|
|
5
|
+
import {registerRepository, repositoryRecord} from '../../graph/repo-registry.js'
|
|
6
|
+
import {createSyncPayload, createSyncPayloadV3, MAX_SYNC_BODY_BYTES} from '../sync-payload.mjs'
|
|
7
|
+
import {createEvidenceSnapshot} from '../evidence-snapshot.mjs'
|
|
8
|
+
import {toolResult} from '../tool-result.mjs'
|
|
9
|
+
|
|
10
|
+
const MAX_SYNC_GRAPH_FILE_BYTES = 64 * 1024 * 1024
|
|
11
|
+
const SYNC_PREVIEW_TTL_MS = 5 * 60 * 1000
|
|
12
|
+
const MAX_SYNC_PREVIEWS = 4
|
|
13
|
+
const syncPreviews = new Map()
|
|
14
|
+
|
|
15
|
+
function syncRepoLabel(repoRoot) {
|
|
16
|
+
const basename = String(repoRoot || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'repo'
|
|
17
|
+
const safe = basename.normalize('NFKC').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '')
|
|
18
|
+
return (safe || 'repo').slice(0, 128)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function syncDestination(raw) {
|
|
22
|
+
let url
|
|
23
|
+
try { url = new URL(raw) } catch { throw new Error('WEAVATRIX_SYNC_URL is invalid') }
|
|
24
|
+
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('WEAVATRIX_SYNC_URL must use HTTPS (or HTTP for loopback development)')
|
|
25
|
+
if (url.username || url.password) throw new Error('WEAVATRIX_SYNC_URL must not contain embedded credentials; use WEAVATRIX_SYNC_TOKEN')
|
|
26
|
+
if (url.hash) throw new Error('WEAVATRIX_SYNC_URL must not contain a fragment')
|
|
27
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(url.hostname.toLowerCase())
|
|
28
|
+
if (url.protocol !== 'https:' && !loopback) throw new Error('WEAVATRIX_SYNC_URL must use HTTPS unless the destination is loopback')
|
|
29
|
+
const display = `${url.origin}${url.pathname}${url.search ? ' (query redacted)' : ''}`
|
|
30
|
+
return {url: url.toString(), display}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function pruneSyncPreviews(now = Date.now()) {
|
|
34
|
+
for (const [token, preview] of syncPreviews) if (preview.expiresAt <= now) syncPreviews.delete(token)
|
|
35
|
+
while (syncPreviews.size >= MAX_SYNC_PREVIEWS) syncPreviews.delete(syncPreviews.keys().next().value)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function confirmationToken({url, repositoryId, payloadVersion, bodyHash}) {
|
|
39
|
+
return createHash('sha256')
|
|
40
|
+
.update(`weavatrix-sync-preview-v1\0${url}\0${repositoryId}\0${payloadVersion}\0${bodyHash}`)
|
|
41
|
+
.digest('hex').slice(0, 24)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function syncSectionSummary(payload) {
|
|
45
|
+
if (payload.syncPayloadV !== 3) return 'graph topology only (explicit V2 compatibility mode)'
|
|
46
|
+
const sections = payload.evidence?.sections || {}
|
|
47
|
+
const names = Object.entries(sections).map(([name, section]) => `${name}:${section?.state || section?.verdict || 'included'}`)
|
|
48
|
+
return names.join(', ') || 'bounded architecture/health/stack/package/duplicate evidence'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function syncPreviewText(preview, {expired = false} = {}) {
|
|
52
|
+
return [
|
|
53
|
+
`SYNC PREVIEW${expired ? ' (the supplied confirmation was missing, expired, or did not match)' : ''} — no network request was made.`,
|
|
54
|
+
`Destination: ${preview.destinationDisplay}.`,
|
|
55
|
+
`Repository: ${preview.repoName}; opaque repository UUID: ${preview.repositoryId}.`,
|
|
56
|
+
`Payload V${preview.payload.syncPayloadV}: ${preview.payload.nodes.length} nodes / ${preview.payload.links.length} edges, ${Math.round(preview.bodyBytes / 1024)} KB; body SHA-256 ${preview.bodyHash.slice(0, 12)}.`,
|
|
57
|
+
`Payload fields: ${Object.keys(preview.payload).sort().join(', ')}.`,
|
|
58
|
+
`Included sections: ${syncSectionSummary(preview.payload)}.`,
|
|
59
|
+
'Excluded by the wire allowlist: source bodies, snippets, absolute host paths, environment values, credentials, Git remotes, and unknown fields.',
|
|
60
|
+
`After the user approves this exact destination and summary, call sync_graph again within 5 minutes with dry_run:false and confirm_token: "${preview.token}".`,
|
|
61
|
+
].join('\n')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function sendSyncPreview(preview, timeoutMs) {
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(preview.url, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
'content-type': 'application/json',
|
|
70
|
+
'x-weavatrix-payload-version': String(preview.payload.syncPayloadV),
|
|
71
|
+
'x-weavatrix-repo': preview.repoName,
|
|
72
|
+
'x-weavatrix-repository-id': preview.repositoryId,
|
|
73
|
+
...(process.env.WEAVATRIX_SYNC_TOKEN ? {authorization: `Bearer ${process.env.WEAVATRIX_SYNC_TOKEN}`} : {}),
|
|
74
|
+
},
|
|
75
|
+
body: preview.body,
|
|
76
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
77
|
+
})
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
const accepted = res.headers?.get?.('x-weavatrix-accept-payload-versions')
|
|
80
|
+
const compatibility = (res.status === 415 || res.status === 422) && accepted
|
|
81
|
+
? ` Endpoint accepts payload version(s) ${accepted}; create and approve a new V2 preview only if graph-only sync is intentional.`
|
|
82
|
+
: ''
|
|
83
|
+
return `Sync endpoint ${preview.destinationDisplay} answered HTTP ${res.status} — graph NOT accepted.${compatibility}`
|
|
84
|
+
}
|
|
85
|
+
syncPreviews.delete(preview.token)
|
|
86
|
+
const evidenceNote = preview.payload.syncPayloadV === 3
|
|
87
|
+
? ` + evidence ${preview.payload.evidence?.snapshotHash?.slice(0, 12) || 'unknown'}`
|
|
88
|
+
: ''
|
|
89
|
+
return `Graph for ${preview.repoName} (${preview.payload.nodes.length} nodes / ${preview.payload.links.length} edges${evidenceNote}, ${Math.round(preview.bodyBytes / 1024)} KB) pushed to approved destination ${preview.destinationDisplay}.`
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return `Sync failed: ${error.message} — the graph stays local; the approved preview remains retryable until it expires.`
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function buildSyncPreview(g, args, ctx) {
|
|
96
|
+
const configuredUrl = process.env.WEAVATRIX_SYNC_URL
|
|
97
|
+
if (!configuredUrl) {
|
|
98
|
+
return 'Graph sync is not configured (optional feature). Set WEAVATRIX_SYNC_URL to the upload endpoint'
|
|
99
|
+
+ ' (and WEAVATRIX_SYNC_TOKEN for bearer auth) in the MCP registration env, then call again.'
|
|
100
|
+
}
|
|
101
|
+
let destination
|
|
102
|
+
try { destination = syncDestination(configuredUrl) }
|
|
103
|
+
catch (error) { return `Graph sync is not configured safely: ${error.message}.` }
|
|
104
|
+
if (!g) return 'No graph loaded — build one first (open_repo / rebuild_graph).'
|
|
105
|
+
let raw
|
|
106
|
+
try {
|
|
107
|
+
const size = statSync(ctx.graphPath).size
|
|
108
|
+
if (size > MAX_SYNC_GRAPH_FILE_BYTES) {
|
|
109
|
+
return `Cannot sync: graph.json is ${Math.ceil(size / 1024 / 1024)} MB; the local safety limit is ${MAX_SYNC_GRAPH_FILE_BYTES / 1024 / 1024} MB.`
|
|
110
|
+
}
|
|
111
|
+
raw = JSON.parse(readFileSync(ctx.graphPath, 'utf8'))
|
|
112
|
+
} catch (e) { return `Cannot read ${ctx.graphPath}: ${e.message}` }
|
|
113
|
+
const requestedVersion = Number(args.payload_version) === 2 ? 2 : 3
|
|
114
|
+
let payload
|
|
115
|
+
try {
|
|
116
|
+
if (requestedVersion === 2) {
|
|
117
|
+
payload = createSyncPayload(raw)
|
|
118
|
+
} else {
|
|
119
|
+
if (!ctx.repoRoot) return 'Cannot build evidence: no repository root is active.'
|
|
120
|
+
if (graphStaleness(ctx).stale) {
|
|
121
|
+
return 'Cannot sync evidence from a stale graph. Run rebuild_graph, then call sync_graph again.'
|
|
122
|
+
}
|
|
123
|
+
const evidence = await createEvidenceSnapshot({repoRoot: ctx.repoRoot, graph: raw})
|
|
124
|
+
payload = createSyncPayloadV3(raw, evidence)
|
|
125
|
+
}
|
|
126
|
+
} catch (e) {
|
|
127
|
+
return `Cannot sync: ${e.message}. Run rebuild_graph once before sync_graph.`
|
|
128
|
+
}
|
|
129
|
+
const body = JSON.stringify(payload)
|
|
130
|
+
const bodyBytes = Buffer.byteLength(body)
|
|
131
|
+
if (bodyBytes > MAX_SYNC_BODY_BYTES) {
|
|
132
|
+
return `Cannot sync: payload is ${Math.ceil(bodyBytes / 1024)} KB; the hosted safety limit is ${MAX_SYNC_BODY_BYTES / 1024} KB. Narrow the graph scope and rebuild before retrying.`
|
|
133
|
+
}
|
|
134
|
+
const repoName = syncRepoLabel(ctx.repoRoot)
|
|
135
|
+
const registry = repositoryRecord(ctx.repoRoot, graphHomeDir())
|
|
136
|
+
|| registerRepository({repoPath: ctx.repoRoot, graphDir: graphOutDirForRepo(ctx.repoRoot), graphHome: graphHomeDir()})
|
|
137
|
+
const bodyHash = createHash('sha256').update(body).digest('hex')
|
|
138
|
+
const token = confirmationToken({url: destination.url, repositoryId: registry.repositoryId, payloadVersion: payload.syncPayloadV, bodyHash})
|
|
139
|
+
const preview = {
|
|
140
|
+
token, url: destination.url, destinationDisplay: destination.display,
|
|
141
|
+
graphPath: ctx.graphPath, repoName, repositoryId: registry.repositoryId,
|
|
142
|
+
payload, body, bodyBytes, bodyHash, expiresAt: Date.now() + SYNC_PREVIEW_TTL_MS,
|
|
143
|
+
}
|
|
144
|
+
syncPreviews.set(token, preview)
|
|
145
|
+
return preview
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function previewResult(preview, {expired = false} = {}) {
|
|
149
|
+
if (typeof preview === 'string') return preview
|
|
150
|
+
return toolResult(syncPreviewText(preview, {expired}), {
|
|
151
|
+
status: 'PREVIEW_READY', networkRequestMade: false,
|
|
152
|
+
destination: preview.destinationDisplay, repository: preview.repoName,
|
|
153
|
+
repositoryId: preview.repositoryId, payloadVersion: preview.payload.syncPayloadV,
|
|
154
|
+
nodes: preview.payload.nodes.length, links: preview.payload.links.length,
|
|
155
|
+
bodyBytes: preview.bodyBytes, bodyHash: preview.bodyHash,
|
|
156
|
+
payloadFields: Object.keys(preview.payload).sort(), sections: syncSectionSummary(preview.payload),
|
|
157
|
+
expiresAt: new Date(preview.expiresAt).toISOString(), confirmToken: preview.token,
|
|
158
|
+
}, {completeness: {status: 'COMPLETE', reason: 'exact allowlisted payload serialized locally; no network request made'}})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Build the exact upload body and approval token without any network request. Kept separate from the
|
|
162
|
+
// mutating tool so safety layers and humans can approve a local preview without authorizing egress.
|
|
163
|
+
export async function tPreviewSyncGraph(g, args, ctx) {
|
|
164
|
+
pruneSyncPreviews()
|
|
165
|
+
return previewResult(await buildSyncPreview(g, args, ctx))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Push the exact payload previously approved through preview_sync. The old dry_run form remains a
|
|
169
|
+
// compatibility alias for one release, but can never send unless dry_run:false and the token matches.
|
|
170
|
+
export async function tSyncGraph(g, args, ctx) {
|
|
171
|
+
if (args.dry_run !== false) {
|
|
172
|
+
pruneSyncPreviews()
|
|
173
|
+
const preview = await buildSyncPreview(g, args, ctx)
|
|
174
|
+
if (typeof preview === 'string') return preview
|
|
175
|
+
const suppliedToken = String(args.confirm_token || '').trim()
|
|
176
|
+
const exact = suppliedToken && suppliedToken === preview.token
|
|
177
|
+
return `${syncPreviewText(preview, {expired: !!suppliedToken && !exact})}${exact ? '\nConfirmation token recognized, but dry_run is still true; no network request was made.' : ''}`
|
|
178
|
+
}
|
|
179
|
+
const configuredUrl = process.env.WEAVATRIX_SYNC_URL
|
|
180
|
+
if (!configuredUrl) return 'Graph sync is not configured (optional feature). Set WEAVATRIX_SYNC_URL first.'
|
|
181
|
+
let destination
|
|
182
|
+
try { destination = syncDestination(configuredUrl) }
|
|
183
|
+
catch (error) { return `Graph sync is not configured safely: ${error.message}.` }
|
|
184
|
+
pruneSyncPreviews()
|
|
185
|
+
const suppliedToken = String(args.confirm_token || '').trim()
|
|
186
|
+
const approved = suppliedToken ? syncPreviews.get(suppliedToken) : null
|
|
187
|
+
if (approved && approved.expiresAt > Date.now()
|
|
188
|
+
&& approved.url === destination.url && approved.graphPath === ctx.graphPath) {
|
|
189
|
+
const timeoutMs = Math.min(120000, Math.max(1000, Number(args.timeout_ms) || 30000))
|
|
190
|
+
return sendSyncPreview(approved, timeoutMs)
|
|
191
|
+
}
|
|
192
|
+
const preview = await buildSyncPreview(g, args, ctx)
|
|
193
|
+
return typeof preview === 'string' ? preview : syncPreviewText(preview, {expired: true})
|
|
194
|
+
}
|
|
195
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {writeCachedArchitectureContract} from '../../analysis/architecture-contract.js'
|
|
2
|
+
import {graphHomeDir, graphOutDirForRepo} from '../../graph/layout.js'
|
|
3
|
+
import {registerRepository, repositoryRecord} from '../../graph/repo-registry.js'
|
|
4
|
+
import {toolResult} from '../tool-result.mjs'
|
|
5
|
+
const syncVersion = new URL(import.meta.url).search
|
|
6
|
+
const {syncDestination} = await import(new URL(`./graph-sync.mjs${syncVersion}`, import.meta.url).href)
|
|
7
|
+
|
|
8
|
+
export async function tPullArchitectureContract(g, args, ctx) {
|
|
9
|
+
if (!ctx.repoRoot || !ctx.graphPath) return 'No active repository graph — open_repo first.'
|
|
10
|
+
const syncUrl = process.env.WEAVATRIX_SYNC_URL
|
|
11
|
+
const token = process.env.WEAVATRIX_SYNC_TOKEN
|
|
12
|
+
if (!syncUrl || !token) return 'Hosted architecture pull is not configured. Use the hosted profile with WEAVATRIX_SYNC_URL and WEAVATRIX_SYNC_TOKEN, or keep .weavatrix/architecture.json locally.'
|
|
13
|
+
let url
|
|
14
|
+
try {
|
|
15
|
+
const configured = process.env.WEAVATRIX_ARCHITECTURE_URL || new URL('/api/v1/architecture-contract', syncUrl).toString()
|
|
16
|
+
url = syncDestination(configured).url
|
|
17
|
+
} catch (error) { return `Hosted architecture pull is not configured safely: ${error.message}.` }
|
|
18
|
+
const registry = repositoryRecord(ctx.repoRoot, graphHomeDir())
|
|
19
|
+
|| registerRepository({repoPath: ctx.repoRoot, graphDir: graphOutDirForRepo(ctx.repoRoot), graphHome: graphHomeDir()})
|
|
20
|
+
const timeoutMs = Math.min(120000, Math.max(1000, Number(args.timeout_ms) || 30000))
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(url, {
|
|
23
|
+
headers: {authorization: `Bearer ${token}`, 'x-weavatrix-repository-id': registry.repositoryId},
|
|
24
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
25
|
+
})
|
|
26
|
+
const body = await res.json().catch(() => null)
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
const serverCode = String(body?.error?.code || body?.state || '').toUpperCase()
|
|
29
|
+
const state = res.status === 401 ? 'AUTH_REQUIRED'
|
|
30
|
+
: res.status === 403 ? 'FORBIDDEN'
|
|
31
|
+
: res.status === 404 && ['REPOSITORY_NOT_FOUND', 'NOT_FOUND'].includes(serverCode) ? 'REPOSITORY_NOT_REGISTERED'
|
|
32
|
+
: res.status === 404 ? 'ENDPOINT_NOT_FOUND'
|
|
33
|
+
: res.status === 409 ? 'REPOSITORY_NOT_READY'
|
|
34
|
+
: 'HTTP_ERROR'
|
|
35
|
+
const next = state === 'REPOSITORY_NOT_REGISTERED'
|
|
36
|
+
? 'The Hosted endpoint is reachable, but this UUID has not completed a preview-confirmed repository sync.'
|
|
37
|
+
: state === 'ENDPOINT_NOT_FOUND'
|
|
38
|
+
? 'The configured architecture endpoint does not exist; verify WEAVATRIX_ARCHITECTURE_URL or the URL derived from WEAVATRIX_SYNC_URL.'
|
|
39
|
+
: res.status === 401 || res.status === 403
|
|
40
|
+
? 'Check the hosted token and repository access; no local cache entry was changed.'
|
|
41
|
+
: res.status === 409
|
|
42
|
+
? 'Sync/register this repository first, then create or pull its target contract.'
|
|
43
|
+
: 'Check the hosted service status and configured endpoint before retrying.'
|
|
44
|
+
return toolResult(`Hosted architecture pull: ${state} (HTTP ${res.status}). ${next} The previous local contract cache remains unchanged.`, {
|
|
45
|
+
state, httpStatus: res.status, serverCode: serverCode || null, cacheChanged: false,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
if (body?.state === 'NOT_CONFIGURED' || !body?.contract) return toolResult(
|
|
49
|
+
'Hosted target architecture is NOT_CONFIGURED. Repository sync and authentication succeeded; define and save a target in the Architecture editor first.',
|
|
50
|
+
{state: 'NOT_CONFIGURED', repositoryId: registry.repositoryId, cacheChanged: false},
|
|
51
|
+
)
|
|
52
|
+
const stored = writeCachedArchitectureContract(ctx.graphPath, body.contract)
|
|
53
|
+
return `Pulled target architecture ${stored.contract.name} (${stored.contract.style}, ${stored.contract.enforcement}) into the local graph cache. get_architecture_contract and verify_architecture now use it.`
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return `Hosted architecture pull failed: ${error.message}; the previous local contract, if any, remains active.`
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Explicit two-step local architecture bootstrap. Preview is pure; approval writes only the exact
|
|
2
|
+
// previewed contract with a short-lived one-time token and never overwrites an active policy.
|
|
3
|
+
import {createHash, randomBytes} from 'node:crypto'
|
|
4
|
+
import {existsSync, mkdirSync, writeFileSync} from 'node:fs'
|
|
5
|
+
import {join} from 'node:path'
|
|
6
|
+
import {
|
|
7
|
+
loadArchitectureContract, normalizeArchitectureContract, verifyArchitecture,
|
|
8
|
+
} from '../analysis/architecture-contract.js'
|
|
9
|
+
import {createRepoBoundary} from '../repo-path.js'
|
|
10
|
+
import {detectRepoStack} from '../scan/discover.js'
|
|
11
|
+
import {toolResult} from './tool-result.mjs'
|
|
12
|
+
|
|
13
|
+
const version = new URL(import.meta.url).search
|
|
14
|
+
const starter = await import(new URL(`./architecture-starter.mjs${version}`, import.meta.url).href)
|
|
15
|
+
const {createArchitectureStarter, proposeObservedDependencyDirections} = starter
|
|
16
|
+
|
|
17
|
+
const PREVIEW_TTL_MS = 5 * 60_000
|
|
18
|
+
const previews = new Map()
|
|
19
|
+
|
|
20
|
+
const stackIds = (repoRoot) => {
|
|
21
|
+
try {
|
|
22
|
+
const stack = detectRepoStack(repoRoot)
|
|
23
|
+
return ['languages', 'runtimes', 'tests', 'infra', 'deploy']
|
|
24
|
+
.flatMap((category) => Array.isArray(stack?.[category]) ? stack[category] : [])
|
|
25
|
+
.map((item) => String(item?.id || '')).filter(Boolean)
|
|
26
|
+
} catch { return [] }
|
|
27
|
+
}
|
|
28
|
+
const sha256 = (value) => createHash('sha256').update(value).digest('hex')
|
|
29
|
+
|
|
30
|
+
function currentFingerprints(verification) {
|
|
31
|
+
return [...verification.new, ...verification.existing]
|
|
32
|
+
.map((item) => item.fingerprint).sort((a, b) => a.localeCompare(b))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function baselineMetrics(verification) {
|
|
36
|
+
return Object.fromEntries(['runtimeCycles', 'violations', 'componentDependencies', 'mappedComponents']
|
|
37
|
+
.map((key) => [key, verification.metrics?.[key]])
|
|
38
|
+
.filter(([, value]) => Number.isFinite(value)))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function materializeCandidate(candidate, verification, baselineMode) {
|
|
42
|
+
if (baselineMode !== 'accept-current') return normalizeArchitectureContract(candidate)
|
|
43
|
+
return normalizeArchitectureContract({
|
|
44
|
+
...candidate,
|
|
45
|
+
ratchet: {baseline: {
|
|
46
|
+
fingerprints: currentFingerprints(verification),
|
|
47
|
+
metrics: baselineMetrics(verification),
|
|
48
|
+
}},
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function activeContract(ctx) {
|
|
53
|
+
if (!ctx?.repoRoot) return {contract: null, source: null, error: 'no repository root is active'}
|
|
54
|
+
return loadArchitectureContract(ctx.repoRoot, ctx.graphPath)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function preview(g, args, ctx) {
|
|
58
|
+
const active = activeContract(ctx)
|
|
59
|
+
if (active.contract) return toolResult(
|
|
60
|
+
`Architecture bootstrap refused: an active contract already exists at ${active.source}.`,
|
|
61
|
+
{state: 'ALREADY_CONFIGURED', source: active.source, contractHash: active.contract.contractHash},
|
|
62
|
+
)
|
|
63
|
+
if (active.error) return toolResult(
|
|
64
|
+
`Architecture bootstrap refused: ${active.source || 'contract'} is invalid (${active.error}).`,
|
|
65
|
+
{state: 'ERROR', source: active.source, error: active.error},
|
|
66
|
+
)
|
|
67
|
+
const baselineMode = args?.baseline_mode === 'accept-current' ? 'accept-current' : 'none'
|
|
68
|
+
let candidate, starterProposal = null
|
|
69
|
+
try {
|
|
70
|
+
starterProposal = args?.candidate_contract ? null : createArchitectureStarter(g, ctx?.repoRoot)
|
|
71
|
+
candidate = args?.candidate_contract
|
|
72
|
+
? normalizeArchitectureContract(args.candidate_contract)
|
|
73
|
+
: starterProposal.contract
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return toolResult(`Candidate architecture contract is invalid: ${error.message}`, {state: 'INVALID', error: error.message})
|
|
76
|
+
}
|
|
77
|
+
const technologies = stackIds(ctx?.repoRoot)
|
|
78
|
+
const candidateVerification = verifyArchitecture({graph: g, contract: candidate, technologies})
|
|
79
|
+
const materializedContract = materializeCandidate(candidate, candidateVerification, baselineMode)
|
|
80
|
+
const materializedVerification = verifyArchitecture({graph: g, contract: materializedContract, technologies})
|
|
81
|
+
const contents = `${JSON.stringify(materializedContract, null, 2)}\n`
|
|
82
|
+
const contentHash = sha256(contents)
|
|
83
|
+
const token = randomBytes(12).toString('hex')
|
|
84
|
+
const expiresAt = new Date(Date.now() + PREVIEW_TTL_MS).toISOString()
|
|
85
|
+
previews.set(token, {
|
|
86
|
+
repoRoot: String(ctx?.repoRoot || ''), graphPath: String(ctx?.graphPath || ''),
|
|
87
|
+
contract: materializedContract, contents, contentHash,
|
|
88
|
+
verificationHash: materializedVerification.verificationHash,
|
|
89
|
+
expiresAt: Date.parse(expiresAt),
|
|
90
|
+
})
|
|
91
|
+
return toolResult([
|
|
92
|
+
`Architecture bootstrap PREVIEW only; no file was written. Candidate verification: ${candidateVerification.status}.`,
|
|
93
|
+
baselineMode === 'accept-current'
|
|
94
|
+
? `Explicit accept-current baseline would record ${currentFingerprints(candidateVerification).length} current finding fingerprint(s).`
|
|
95
|
+
: 'No current findings were added to the baseline.',
|
|
96
|
+
`Review the materialized contract and then approve with confirm_token "${token}" before ${expiresAt}.`,
|
|
97
|
+
].join('\n'), {
|
|
98
|
+
state: 'PREVIEW', wrote: false, baselineMode,
|
|
99
|
+
candidateVerification, materializedVerification, materializedContract,
|
|
100
|
+
observedDependencyProposals: proposeObservedDependencyDirections(g, materializedContract.components),
|
|
101
|
+
budgetProposals: starterProposal?.budgetProposals || [],
|
|
102
|
+
starterMethodology: starterProposal?.methodology,
|
|
103
|
+
patch: {operation: 'create', path: '.weavatrix/architecture.json', contentHash, contents},
|
|
104
|
+
confirmToken: token, expiresAt,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function approve(g, args, ctx) {
|
|
109
|
+
const token = String(args?.confirm_token || '')
|
|
110
|
+
const pending = previews.get(token)
|
|
111
|
+
if (!pending) return toolResult(
|
|
112
|
+
'Architecture approval refused: run a fresh preview and provide its exact confirm_token.',
|
|
113
|
+
{state: 'CONFIRMATION_REQUIRED', wrote: false},
|
|
114
|
+
)
|
|
115
|
+
previews.delete(token)
|
|
116
|
+
if (pending.expiresAt < Date.now()) return toolResult(
|
|
117
|
+
'Architecture approval refused: the preview expired; run preview again.',
|
|
118
|
+
{state: 'PREVIEW_EXPIRED', wrote: false},
|
|
119
|
+
)
|
|
120
|
+
if (pending.repoRoot !== String(ctx?.repoRoot || '') || pending.graphPath !== String(ctx?.graphPath || '')) {
|
|
121
|
+
return toolResult('Architecture approval refused: the active repository changed after preview.', {
|
|
122
|
+
state: 'REPOSITORY_CHANGED', wrote: false,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
const active = activeContract(ctx)
|
|
126
|
+
if (active.contract || active.error) return toolResult(
|
|
127
|
+
`Architecture approval refused: ${active.source || 'a contract'} appeared or changed after preview.`,
|
|
128
|
+
{state: active.error ? 'ERROR' : 'ALREADY_CONFIGURED', wrote: false, source: active.source, error: active.error || undefined},
|
|
129
|
+
)
|
|
130
|
+
const verification = verifyArchitecture({
|
|
131
|
+
graph: g, contract: pending.contract, technologies: stackIds(ctx?.repoRoot),
|
|
132
|
+
})
|
|
133
|
+
if (verification.verificationHash !== pending.verificationHash) return toolResult(
|
|
134
|
+
'Architecture approval refused: graph evidence changed after preview; review a fresh preview.',
|
|
135
|
+
{state: 'GRAPH_CHANGED', wrote: false, previewVerificationHash: pending.verificationHash, currentVerificationHash: verification.verificationHash},
|
|
136
|
+
)
|
|
137
|
+
const boundary = createRepoBoundary(ctx?.repoRoot)
|
|
138
|
+
if (!boundary.root) return toolResult('Architecture approval refused: the repository boundary is unavailable.', {
|
|
139
|
+
state: 'PATH_REFUSED', wrote: false,
|
|
140
|
+
})
|
|
141
|
+
try { mkdirSync(join(boundary.root, '.weavatrix'), {recursive: true}) }
|
|
142
|
+
catch (error) { return toolResult(`Architecture approval could not create its policy directory: ${error.message}`, {
|
|
143
|
+
state: 'WRITE_FAILED', wrote: false, error: error.message,
|
|
144
|
+
}) }
|
|
145
|
+
const policyDirectory = boundary.resolve('.weavatrix')
|
|
146
|
+
if (!policyDirectory.ok) return toolResult('Architecture approval refused: the policy directory resolves outside the active repository.', {
|
|
147
|
+
state: 'PATH_REFUSED', wrote: false,
|
|
148
|
+
})
|
|
149
|
+
const targetPath = join(policyDirectory.path, 'architecture.json')
|
|
150
|
+
if (existsSync(targetPath)) return toolResult('Architecture approval refused: the target contract already exists.', {
|
|
151
|
+
state: 'ALREADY_CONFIGURED', wrote: false, source: '.weavatrix/architecture.json',
|
|
152
|
+
})
|
|
153
|
+
try {
|
|
154
|
+
writeFileSync(targetPath, pending.contents, {encoding: 'utf8', flag: 'wx'})
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return toolResult(`Architecture approval failed without overwriting policy: ${error.message}`, {
|
|
157
|
+
state: 'WRITE_FAILED', wrote: false, error: error.message,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
return toolResult(
|
|
161
|
+
`Architecture contract approved and created at .weavatrix/architecture.json (${pending.contentHash.slice(0, 12)}).`,
|
|
162
|
+
{state: 'APPROVED', wrote: true, source: '.weavatrix/architecture.json', contentHash: pending.contentHash, contract: pending.contract, verification},
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function tBootstrapArchitecture(g, args = {}, ctx) {
|
|
167
|
+
return args.action === 'approve' ? approve(g, args, ctx) : preview(g, args, ctx)
|
|
168
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Adaptive, source-free architecture starter. It proposes component territories and separately
|
|
2
|
+
// reports observed dependency directions; observations never become enforceable policy implicitly.
|
|
3
|
+
import {normalizeArchitectureContract} from '../analysis/architecture-contract.js'
|
|
4
|
+
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
5
|
+
|
|
6
|
+
export const PROVISIONAL_BUDGETS = Object.freeze({
|
|
7
|
+
runtimeCycles: 0,
|
|
8
|
+
maxFileLoc: 300,
|
|
9
|
+
maxFunctionLoc: 120,
|
|
10
|
+
maxCyclomatic: 15,
|
|
11
|
+
maxModuleFiles: 80,
|
|
12
|
+
minModuleCohesion: .5,
|
|
13
|
+
maxModuleBoundaryRatio: .65,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export const STARTER_BUDGETS = Object.freeze({
|
|
17
|
+
runtimeCycles: PROVISIONAL_BUDGETS.runtimeCycles,
|
|
18
|
+
maxFileLoc: PROVISIONAL_BUDGETS.maxFileLoc,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const BUDGET_PROPOSALS = Object.freeze(Object.entries(PROVISIONAL_BUDGETS)
|
|
22
|
+
.filter(([key]) => !(key in STARTER_BUDGETS))
|
|
23
|
+
.map(([key, value]) => ({
|
|
24
|
+
key, value, state: 'CANDIDATE_NOT_ENFORCED',
|
|
25
|
+
reason: 'Generic review threshold; adopt only after inspecting repository-specific evidence.',
|
|
26
|
+
})))
|
|
27
|
+
|
|
28
|
+
const SOURCE_ROOTS = new Set(['src', 'app', 'lib', 'packages', 'services'])
|
|
29
|
+
const CODE_EXTENSIONS = new Set([
|
|
30
|
+
'.cjs', '.cs', '.css', '.go', '.htm', '.html', '.java', '.js', '.jsx', '.less', '.mjs', '.py', '.pyi',
|
|
31
|
+
'.rs', '.scss', '.ts', '.tsx',
|
|
32
|
+
])
|
|
33
|
+
const CLASSIFIED = ['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp']
|
|
34
|
+
const EDGE_RELATIONS = new Set(['imports', 're_exports', 'calls', 'references'])
|
|
35
|
+
|
|
36
|
+
const slash = (value) => String(value || '').replace(/\\/g, '/').replace(/^\.\//, '')
|
|
37
|
+
const extension = (file) => {
|
|
38
|
+
const name = slash(file).split('/').at(-1) || ''
|
|
39
|
+
const dot = name.lastIndexOf('.')
|
|
40
|
+
return dot >= 0 ? name.slice(dot).toLowerCase() : ''
|
|
41
|
+
}
|
|
42
|
+
const safeId = (value, fallback) => String(value || '').toLowerCase()
|
|
43
|
+
.replace(/[^a-z0-9._:-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100) || fallback
|
|
44
|
+
|
|
45
|
+
function productFiles(graph, repoRoot) {
|
|
46
|
+
const classifier = createPathClassifier(repoRoot)
|
|
47
|
+
return [...new Set((graph?.nodes || [])
|
|
48
|
+
.filter((node) => !String(node.id).includes('#'))
|
|
49
|
+
.map((node) => slash(node.source_file || node.id))
|
|
50
|
+
.filter((file) => file && CODE_EXTENSIONS.has(extension(file))))]
|
|
51
|
+
.filter((file) => {
|
|
52
|
+
const explanation = classifier.explain(file)
|
|
53
|
+
return !explanation.excluded && !hasPathClass(explanation, ...CLASSIFIED)
|
|
54
|
+
})
|
|
55
|
+
.sort((a, b) => a.localeCompare(b))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function commonDirectoryPrefix(entries) {
|
|
59
|
+
if (!entries.length) return []
|
|
60
|
+
const first = entries[0].directories
|
|
61
|
+
let length = first.length
|
|
62
|
+
for (const entry of entries.slice(1)) {
|
|
63
|
+
let index = 0
|
|
64
|
+
while (index < length && index < entry.directories.length && first[index] === entry.directories[index]) index++
|
|
65
|
+
length = index
|
|
66
|
+
}
|
|
67
|
+
// Keep at least one package segment available as an actual component territory.
|
|
68
|
+
const shortest = Math.min(...entries.map((entry) => entry.directories.length))
|
|
69
|
+
return first.slice(0, Math.min(length, Math.max(0, shortest - 1)))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function javaTerritories(files) {
|
|
73
|
+
const byRoot = new Map()
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
const match = /^(.*?)(?:\/)?src\/main\/java\/(.+\.java)$/i.exec(file)
|
|
76
|
+
if (!match) continue
|
|
77
|
+
const root = `${match[1] ? `${match[1]}/` : ''}src/main/java`
|
|
78
|
+
const tail = match[2].split('/')
|
|
79
|
+
const entry = {file, root, directories: tail.slice(0, -1)}
|
|
80
|
+
const entries = byRoot.get(root) || []
|
|
81
|
+
entries.push(entry)
|
|
82
|
+
byRoot.set(root, entries)
|
|
83
|
+
}
|
|
84
|
+
const paths = new Map()
|
|
85
|
+
for (const [root, entries] of byRoot) {
|
|
86
|
+
const common = commonDirectoryPrefix(entries)
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const next = entry.directories[common.length]
|
|
89
|
+
const path = next ? `${root}/${[...common, next].join('/')}` : entry.file
|
|
90
|
+
paths.set(entry.file, {key: path, name: path, path})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
let refined = true
|
|
94
|
+
while (refined) {
|
|
95
|
+
refined = false
|
|
96
|
+
const groups = new Map()
|
|
97
|
+
for (const [file, territory] of paths) {
|
|
98
|
+
const entries = groups.get(territory.key) || []
|
|
99
|
+
entries.push({file, territory})
|
|
100
|
+
groups.set(territory.key, entries)
|
|
101
|
+
}
|
|
102
|
+
for (const entries of groups.values()) {
|
|
103
|
+
if (entries.length <= PROVISIONAL_BUDGETS.maxModuleFiles) continue
|
|
104
|
+
const base = entries[0].territory.path
|
|
105
|
+
if (!entries.every((entry) => entry.territory.path === base)) continue
|
|
106
|
+
const nested = entries.map((entry) => ({...entry, rest: entry.file.slice(base.length + 1).split('/')}))
|
|
107
|
+
const childDirectories = new Set(nested.filter((entry) => entry.rest.length > 1).map((entry) => entry.rest[0]))
|
|
108
|
+
if (childDirectories.size < 2) continue
|
|
109
|
+
for (const entry of nested) {
|
|
110
|
+
if (entry.rest.length > 1) {
|
|
111
|
+
const path = `${base}/${entry.rest[0]}`
|
|
112
|
+
paths.set(entry.file, {key: path, name: path, path})
|
|
113
|
+
} else {
|
|
114
|
+
paths.set(entry.file, {key: `${base}::root`, name: `${base} (root files)`, path: entry.file})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
refined = true
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return paths
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function nestedSourceTerritory(file) {
|
|
124
|
+
const match = /^(.*?)\/src\/(.+)$/.exec(file)
|
|
125
|
+
if (!match || !match[1]) return null
|
|
126
|
+
const tail = match[2].split('/')
|
|
127
|
+
// Java's src/main/java package tree is handled with a package-aware common prefix above.
|
|
128
|
+
if (tail[0] === 'main' && tail[1] === 'java') return null
|
|
129
|
+
return tail.length > 1 ? `${match[1]}/src/${tail[0]}` : file
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function genericTerritory(file) {
|
|
133
|
+
const parts = file.split('/').filter(Boolean)
|
|
134
|
+
if (parts.length === 1) return {key: 'root-code', name: 'root code', path: file}
|
|
135
|
+
if (SOURCE_ROOTS.has(parts[0]) && parts.length === 2) {
|
|
136
|
+
return {key: `${parts[0]}-root`, name: `${parts[0]} (root files)`, path: file}
|
|
137
|
+
}
|
|
138
|
+
const path = parts.slice(0, SOURCE_ROOTS.has(parts[0]) ? 2 : 1).join('/')
|
|
139
|
+
return {key: path, name: path, path}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function componentsFor(graph, repoRoot) {
|
|
143
|
+
const files = productFiles(graph, repoRoot)
|
|
144
|
+
const java = javaTerritories(files)
|
|
145
|
+
const groups = new Map()
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
const adaptive = java.get(file) || nestedSourceTerritory(file)
|
|
148
|
+
const territory = adaptive
|
|
149
|
+
? typeof adaptive === 'string' ? {key: adaptive, name: adaptive, path: adaptive} : adaptive
|
|
150
|
+
: genericTerritory(file)
|
|
151
|
+
const group = groups.get(territory.key) || {name: territory.name, paths: new Set(), files: 0}
|
|
152
|
+
group.paths.add(territory.path)
|
|
153
|
+
group.files++
|
|
154
|
+
groups.set(territory.key, group)
|
|
155
|
+
}
|
|
156
|
+
return [...groups]
|
|
157
|
+
.sort((a, b) => b[1].files - a[1].files || a[0].localeCompare(b[0]))
|
|
158
|
+
.slice(0, 200)
|
|
159
|
+
.map(([key, group], index) => ({
|
|
160
|
+
id: safeId(key, `component-${index + 1}`),
|
|
161
|
+
name: group.name,
|
|
162
|
+
paths: [...group.paths].sort((a, b) => a.localeCompare(b)),
|
|
163
|
+
}))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function componentFor(file, components) {
|
|
167
|
+
const normalized = slash(file)
|
|
168
|
+
let best = null
|
|
169
|
+
for (const component of components) for (const prefix of component.paths) {
|
|
170
|
+
if (normalized !== prefix && !normalized.startsWith(`${prefix}/`)) continue
|
|
171
|
+
if (!best || prefix.length > best.prefix.length) best = {id: component.id, prefix}
|
|
172
|
+
}
|
|
173
|
+
return best?.id || null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const endpoint = (value) => value && typeof value === 'object' ? String(value.id) : String(value || '')
|
|
177
|
+
function fileOf(id, byId) {
|
|
178
|
+
const node = byId.get(id)
|
|
179
|
+
return slash(node?.source_file || id.split('#')[0])
|
|
180
|
+
}
|
|
181
|
+
function edgeKind(link) {
|
|
182
|
+
return link.typeOnly === true ? 'type-only' : link.compileOnly === true ? 'compile-only' : 'runtime'
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function observedDirections(graph, components) {
|
|
186
|
+
const byId = new Map((graph?.nodes || []).map((node) => [String(node.id), node]))
|
|
187
|
+
const pairs = new Set(), directions = new Map()
|
|
188
|
+
for (const link of graph?.links || []) {
|
|
189
|
+
if (!EDGE_RELATIONS.has(link.relation) || link.barrelProxy === true) continue
|
|
190
|
+
const fromFile = fileOf(endpoint(link.source), byId), toFile = fileOf(endpoint(link.target), byId)
|
|
191
|
+
const from = componentFor(fromFile, components), to = componentFor(toFile, components)
|
|
192
|
+
if (!from || !to || from === to) continue
|
|
193
|
+
const kind = edgeKind(link), pair = `${fromFile}\0${toFile}\0${kind}`
|
|
194
|
+
if (pairs.has(pair)) continue
|
|
195
|
+
pairs.add(pair)
|
|
196
|
+
const key = `${from}\0${to}\0${kind}`
|
|
197
|
+
const item = directions.get(key) || {from, to, kind, count: 0, samples: []}
|
|
198
|
+
item.count++
|
|
199
|
+
if (item.samples.length < 3) item.samples.push(`${fromFile} -> ${toFile}`)
|
|
200
|
+
directions.set(key, item)
|
|
201
|
+
}
|
|
202
|
+
return [...directions.values()]
|
|
203
|
+
.sort((a, b) => b.count - a.count || a.from.localeCompare(b.from) || a.to.localeCompare(b.to))
|
|
204
|
+
.slice(0, 100)
|
|
205
|
+
.map((item, index) => ({
|
|
206
|
+
...item,
|
|
207
|
+
state: 'OBSERVED_NOT_ENFORCED',
|
|
208
|
+
suggestedRule: {
|
|
209
|
+
id: safeId(`observed-${item.from}-to-${item.to}-${item.kind}`, `observed-${index + 1}`),
|
|
210
|
+
action: 'allow', kinds: [item.kind], from: [item.from], to: [item.to],
|
|
211
|
+
reason: `Observed in ${item.count} unique file dependency pair(s); review before adopting as policy.`,
|
|
212
|
+
},
|
|
213
|
+
}))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function proposeObservedDependencyDirections(graph, components) {
|
|
217
|
+
return observedDirections(graph, components)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function createArchitectureStarter(graph, repoRoot) {
|
|
221
|
+
const components = componentsFor(graph, repoRoot)
|
|
222
|
+
const contract = normalizeArchitectureContract({
|
|
223
|
+
name: 'Proposed no-regressions baseline', style: 'custom', enforcement: 'ratchet', components,
|
|
224
|
+
dependencyRules: [], budgets: STARTER_BUDGETS,
|
|
225
|
+
technologies: {required: [], forbidden: []}, exceptions: [],
|
|
226
|
+
ratchet: {baseline: {fingerprints: [], metrics: {}}},
|
|
227
|
+
})
|
|
228
|
+
return {
|
|
229
|
+
contract,
|
|
230
|
+
observedDependencyProposals: proposeObservedDependencyDirections(graph, contract.components),
|
|
231
|
+
budgetProposals: BUDGET_PROPOSALS,
|
|
232
|
+
methodology: 'adaptive product territories; only universal runtime-cycle/file-size guards are active; observed directions and generic quality thresholds are review-only',
|
|
233
|
+
}
|
|
234
|
+
}
|