weavatrix 0.1.4 → 0.2.0
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 +138 -53
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +92 -30
- package/src/analysis/architecture-contract.js +343 -0
- package/src/analysis/audit-debt.js +94 -0
- package/src/analysis/change-classification.js +509 -0
- package/src/analysis/cycle-route.js +14 -0
- package/src/analysis/dead-check.js +5 -3
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +6 -0
- package/src/analysis/dep-check.js +40 -6
- package/src/analysis/dep-rules.js +12 -9
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints.js +8 -2
- package/src/analysis/findings.js +8 -1
- package/src/analysis/git-history.js +549 -0
- package/src/analysis/git-ref-graph.js +74 -0
- package/src/analysis/graph-analysis.aggregate.js +2 -2
- package/src/analysis/graph-analysis.edges.js +3 -0
- package/src/analysis/graph-analysis.summaries.js +1 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +3 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +58 -10
- package/src/analysis/java-source.js +36 -0
- package/src/analysis/package-reachability.js +39 -0
- package/src/analysis/static-test-reachability.js +133 -0
- package/src/build-graph.js +72 -13
- package/src/graph/build-worker.js +3 -4
- package/src/graph/builder/lang-js.js +71 -12
- package/src/graph/community.js +10 -0
- package/src/graph/file-lock.js +69 -0
- package/src/graph/freshness-probe.js +141 -0
- package/src/graph/graph-filter.js +28 -11
- package/src/graph/incremental-refresh.js +232 -0
- package/src/graph/internal-builder.barrels.js +169 -0
- package/src/graph/internal-builder.build.js +82 -11
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/layout.js +21 -5
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +62 -19
- package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
- package/src/mcp/evidence-snapshot.common.mjs +160 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
- package/src/mcp/evidence-snapshot.health.mjs +95 -0
- package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
- package/src/mcp/evidence-snapshot.mjs +50 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
- package/src/mcp/evidence-snapshot.structure.mjs +81 -0
- package/src/mcp/graph-context.mjs +100 -16
- package/src/mcp/graph-diff.mjs +74 -20
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +164 -0
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +111 -30
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph.mjs +25 -14
- package/src/mcp/tools-health.mjs +336 -14
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +25 -6
- package/src/mcp-server.mjs +100 -17
- package/src/mcp-source-tools.mjs +11 -3
- package/src/path-classification.js +201 -0
- package/src/path-ignore.js +69 -0
- package/src/security/typosquat.js +1 -1
|
@@ -1,29 +1,56 @@
|
|
|
1
1
|
// Action tools: rebuild the graph, the explicit 'retarget' group, and the explicit 'online' group
|
|
2
2
|
// (the ONLY tools that ever touch the network).
|
|
3
3
|
// Hot-reloadable (re-imported by catalog.mjs on change).
|
|
4
|
-
import {readFileSync, writeFileSync, existsSync,
|
|
5
|
-
import {
|
|
6
|
-
import {prevGraphPathFor, diffGraphs, formatGraphDiff} from './graph-context.mjs'
|
|
4
|
+
import {readFileSync, writeFileSync, existsSync, statSync, realpathSync} from 'node:fs'
|
|
5
|
+
import {dirname, join, isAbsolute} from 'node:path'
|
|
6
|
+
import {prevGraphPathFor, diffGraphs, formatGraphDiff, graphStaleness} from './graph-context.mjs'
|
|
7
7
|
import {buildGraphForRepo} from '../build-graph.js'
|
|
8
|
-
import {graphOutDirForRepo} from '../graph/layout.js'
|
|
8
|
+
import {graphHomeDir, graphOutDirForModule, graphOutDirForRepo} from '../graph/layout.js'
|
|
9
|
+
import {liveRepositoryRecords, registerRepository, repositoryRecord} from '../graph/repo-registry.js'
|
|
9
10
|
import {refreshAdvisories, storeMeta, DEFAULT_STORE} from '../security/advisory-store.js'
|
|
10
11
|
import {collectInstalled} from '../security/installed.js'
|
|
11
|
-
import {createSyncPayload} from './sync-payload.mjs'
|
|
12
|
+
import {createSyncPayload, createSyncPayloadV3, MAX_SYNC_BODY_BYTES} from './sync-payload.mjs'
|
|
13
|
+
import {createEvidenceSnapshot} from './evidence-snapshot.mjs'
|
|
14
|
+
import {writeCachedArchitectureContract} from '../analysis/architecture-contract.js'
|
|
15
|
+
|
|
16
|
+
const MAX_SYNC_GRAPH_FILE_BYTES = 64 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
function syncRepoLabel(repoRoot) {
|
|
19
|
+
const basename = String(repoRoot || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'repo'
|
|
20
|
+
const safe = basename.normalize('NFKC').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '')
|
|
21
|
+
return (safe || 'repo').slice(0, 128)
|
|
22
|
+
}
|
|
12
23
|
|
|
13
24
|
export async function tRebuildGraph(g, args, ctx) {
|
|
14
25
|
if (!ctx.repoRoot) return 'Rebuild needs the repo root (not provided to this server).'
|
|
15
26
|
const mode = ['no-tests', 'tests-only', 'full'].includes(args.mode) ? args.mode : 'full'
|
|
27
|
+
const scope = String(args.scope || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
|
|
28
|
+
if (scope) {
|
|
29
|
+
const scopedDir = graphOutDirForModule(ctx.repoRoot, scope)
|
|
30
|
+
const scoped = await buildGraphForRepo(ctx.repoRoot, {mode, scope, outDir: scopedDir})
|
|
31
|
+
if (!scoped || !scoped.ok) return `Scoped graph build failed: ${(scoped && scoped.error) || 'unknown error'}`
|
|
32
|
+
return [
|
|
33
|
+
`Built an isolated scoped graph for ${scope} (${mode}). ${scoped.log || ''}.`,
|
|
34
|
+
`The active full-repository graph was not replaced, so this operation cannot report a false full→scope structural delta.`,
|
|
35
|
+
`Scoped graph: ${join(scopedDir, 'graph.json')}`,
|
|
36
|
+
].join('\n')
|
|
37
|
+
}
|
|
16
38
|
// snapshot the outgoing state: bytes → graph.prev.json (for graph_diff later), struct → inline delta
|
|
17
39
|
let prevBytes = null
|
|
18
40
|
try { prevBytes = readFileSync(ctx.graphPath) } catch { /* first build — nothing to diff against */ }
|
|
19
|
-
const before = g?.nodes ? {
|
|
20
|
-
|
|
41
|
+
const before = g?.nodes ? {
|
|
42
|
+
nodes: g.nodes, links: g.links,
|
|
43
|
+
edgeTypesV: g.edgeTypesV || 0,
|
|
44
|
+
barrelResolutionV: g.barrelResolutionV || 0,
|
|
45
|
+
extractorSchemaV: g.extractorSchemaV || 0,
|
|
46
|
+
} : null
|
|
47
|
+
const res = await buildGraphForRepo(ctx.repoRoot, {mode, scope: '', outDir: ctx.graphPath ? dirname(ctx.graphPath) : undefined})
|
|
21
48
|
if (!res || !res.ok) return `Graph rebuild failed: ${(res && res.error) || 'unknown error'}`
|
|
22
49
|
if (prevBytes) { try { writeFileSync(prevGraphPathFor(ctx.graphPath), prevBytes) } catch { /* snapshot is best-effort */ } }
|
|
23
50
|
const fresh = ctx.reload() // refresh THIS server's in-memory graph so subsequent tool calls see the new graph
|
|
24
51
|
const delta = before && fresh ? formatGraphDiff(diffGraphs(before, fresh)) : null
|
|
25
52
|
return [
|
|
26
|
-
`Rebuilt the graph (${mode}
|
|
53
|
+
`Rebuilt the graph (${mode}). ${res.log || ''}. In-memory graph reloaded — graph tools now reflect it.`,
|
|
27
54
|
delta
|
|
28
55
|
].filter(Boolean).join('\n\n')
|
|
29
56
|
}
|
|
@@ -71,31 +98,23 @@ export async function tOpenRepo(g, args, ctx) {
|
|
|
71
98
|
ctx.reload()
|
|
72
99
|
return `Failed to load ${graphPath} — still targeting the previous repo (${prev.repoRoot || 'none'}).`
|
|
73
100
|
}
|
|
101
|
+
registerRepository({repoPath, graphDir: graphOutDirForRepo(repoPath), graphHome: graphHomeDir()})
|
|
74
102
|
const buildNote = built ? (upgrade ? ' (graph upgraded to edge metadata v2)' : ' (graph built fresh)') : ''
|
|
75
103
|
return `Opened ${repoPath}${buildNote}: ${loaded.nodes.length} nodes / ${loaded.links.length} edges. All tools now target this repo.`
|
|
76
104
|
}
|
|
77
105
|
|
|
78
106
|
// Sibling repos that already have a built graph in the central weavatrix-graphs folder — open_repo candidates.
|
|
79
107
|
export function tListKnownRepos(g, args, ctx) {
|
|
80
|
-
|
|
81
|
-
const parent = dirname(ctx.repoRoot)
|
|
82
|
-
const root = join(parent, 'weavatrix-graphs')
|
|
108
|
+
const root = graphHomeDir()
|
|
83
109
|
const norm = (p) => String(p).replace(/[\\/]+/g, '/').toLowerCase()
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const graphPath = join(root, entry.name, 'graph.json')
|
|
90
|
-
try {
|
|
91
|
-
const st = statSync(graphPath)
|
|
92
|
-
rows.push({name: entry.name, repoPath: join(parent, entry.name), builtAt: st.mtime.toISOString()})
|
|
93
|
-
} catch { /* no graph built for this entry */ }
|
|
94
|
-
}
|
|
95
|
-
if (!rows.length) return `No built graphs under ${root}.`
|
|
110
|
+
const rows = liveRepositoryRecords(root).map((record) => ({
|
|
111
|
+
...record,
|
|
112
|
+
builtAt: statSync(join(record.graphDir, 'graph.json')).mtime.toISOString(),
|
|
113
|
+
}))
|
|
114
|
+
if (!rows.length) return `No registered graphs under ${root}. Build a repository once with open_repo or rebuild_graph.`
|
|
96
115
|
return [
|
|
97
|
-
`
|
|
98
|
-
...rows.map((r) => ` ${norm(r.repoPath) === norm(ctx.repoRoot) ? '»' : ' '} ${r.
|
|
116
|
+
`Known repositories (${rows.length}) in ${root}:`,
|
|
117
|
+
...rows.map((r) => ` ${norm(r.repoPath) === norm(ctx.repoRoot) ? '»' : ' '} ${r.label} [${r.repositoryId}] — graph built ${r.builtAt} (${r.repoPath})`),
|
|
99
118
|
].join('\n')
|
|
100
119
|
}
|
|
101
120
|
|
|
@@ -120,6 +139,32 @@ export async function tRefreshAdvisories(g, args, ctx) {
|
|
|
120
139
|
].filter(Boolean).join('\n')
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
export async function tPullArchitectureContract(g, args, ctx) {
|
|
143
|
+
if (!ctx.repoRoot || !ctx.graphPath) return 'No active repository graph — open_repo first.'
|
|
144
|
+
const syncUrl = process.env.WEAVATRIX_SYNC_URL
|
|
145
|
+
const token = process.env.WEAVATRIX_SYNC_TOKEN
|
|
146
|
+
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.'
|
|
147
|
+
let url
|
|
148
|
+
try { url = process.env.WEAVATRIX_ARCHITECTURE_URL || new URL('/api/v1/architecture-contract', syncUrl).toString() }
|
|
149
|
+
catch { return 'WEAVATRIX_SYNC_URL is invalid.' }
|
|
150
|
+
const registry = repositoryRecord(ctx.repoRoot, graphHomeDir())
|
|
151
|
+
|| registerRepository({repoPath: ctx.repoRoot, graphDir: graphOutDirForRepo(ctx.repoRoot), graphHome: graphHomeDir()})
|
|
152
|
+
const timeoutMs = Math.min(120000, Math.max(1000, Number(args.timeout_ms) || 30000))
|
|
153
|
+
try {
|
|
154
|
+
const res = await fetch(url, {
|
|
155
|
+
headers: {authorization: `Bearer ${token}`, 'x-weavatrix-repository-id': registry.repositoryId},
|
|
156
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
157
|
+
})
|
|
158
|
+
const body = await res.json().catch(() => null)
|
|
159
|
+
if (!res.ok) return `Hosted architecture endpoint answered HTTP ${res.status}; the local contract cache was not changed.`
|
|
160
|
+
if (body?.state === 'NOT_CONFIGURED' || !body?.contract) return 'Hosted target architecture is NOT_CONFIGURED. Define and save it in the Architecture editor first.'
|
|
161
|
+
const stored = writeCachedArchitectureContract(ctx.graphPath, body.contract)
|
|
162
|
+
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.`
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return `Hosted architecture pull failed: ${error.message}; the previous local contract, if any, remains active.`
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
123
168
|
// Push the current graph.json to a user-configured endpoint. Off until WEAVATRIX_SYNC_URL is set.
|
|
124
169
|
// The payload is graph metadata (paths, symbols/ranges, imports, edges, metrics), never file contents.
|
|
125
170
|
export async function tSyncGraph(g, args, ctx) {
|
|
@@ -130,13 +175,38 @@ export async function tSyncGraph(g, args, ctx) {
|
|
|
130
175
|
}
|
|
131
176
|
if (!g) return 'No graph loaded — build one first (open_repo / rebuild_graph).'
|
|
132
177
|
let raw
|
|
133
|
-
try {
|
|
178
|
+
try {
|
|
179
|
+
const size = statSync(ctx.graphPath).size
|
|
180
|
+
if (size > MAX_SYNC_GRAPH_FILE_BYTES) {
|
|
181
|
+
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.`
|
|
182
|
+
}
|
|
183
|
+
raw = JSON.parse(readFileSync(ctx.graphPath, 'utf8'))
|
|
184
|
+
} catch (e) { return `Cannot read ${ctx.graphPath}: ${e.message}` }
|
|
185
|
+
const requestedVersion = Number(args.payload_version) === 2 ? 2 : 3
|
|
134
186
|
let payload
|
|
135
|
-
try {
|
|
187
|
+
try {
|
|
188
|
+
if (requestedVersion === 2) {
|
|
189
|
+
payload = createSyncPayload(raw)
|
|
190
|
+
} else {
|
|
191
|
+
if (!ctx.repoRoot) return 'Cannot build evidence: no repository root is active.'
|
|
192
|
+
if (graphStaleness(ctx).stale) {
|
|
193
|
+
return 'Cannot sync evidence from a stale graph. Run rebuild_graph, then call sync_graph again.'
|
|
194
|
+
}
|
|
195
|
+
const evidence = await createEvidenceSnapshot({repoRoot: ctx.repoRoot, graph: raw})
|
|
196
|
+
payload = createSyncPayloadV3(raw, evidence)
|
|
197
|
+
}
|
|
198
|
+
} catch (e) {
|
|
136
199
|
return `Cannot sync: ${e.message}. Run rebuild_graph once before sync_graph.`
|
|
137
200
|
}
|
|
138
201
|
const body = JSON.stringify(payload)
|
|
139
|
-
const
|
|
202
|
+
const bodyBytes = Buffer.byteLength(body)
|
|
203
|
+
if (bodyBytes > MAX_SYNC_BODY_BYTES) {
|
|
204
|
+
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.`
|
|
205
|
+
}
|
|
206
|
+
const repoName = syncRepoLabel(ctx.repoRoot)
|
|
207
|
+
const registry = repositoryRecord(ctx.repoRoot, graphHomeDir())
|
|
208
|
+
|| registerRepository({repoPath: ctx.repoRoot, graphDir: graphOutDirForRepo(ctx.repoRoot), graphHome: graphHomeDir()})
|
|
209
|
+
const timeoutMs = Math.min(120000, Math.max(1000, Number(args.timeout_ms) || 30000))
|
|
140
210
|
try {
|
|
141
211
|
const res = await fetch(url, {
|
|
142
212
|
method: 'POST',
|
|
@@ -144,12 +214,23 @@ export async function tSyncGraph(g, args, ctx) {
|
|
|
144
214
|
'content-type': 'application/json',
|
|
145
215
|
'x-weavatrix-payload-version': String(payload.syncPayloadV),
|
|
146
216
|
'x-weavatrix-repo': repoName,
|
|
217
|
+
'x-weavatrix-repository-id': registry.repositoryId,
|
|
147
218
|
...(process.env.WEAVATRIX_SYNC_TOKEN ? {authorization: `Bearer ${process.env.WEAVATRIX_SYNC_TOKEN}`} : {}),
|
|
148
219
|
},
|
|
149
220
|
body,
|
|
221
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
150
222
|
})
|
|
151
|
-
if (!res.ok)
|
|
152
|
-
|
|
223
|
+
if (!res.ok) {
|
|
224
|
+
const accepted = res.headers?.get?.('x-weavatrix-accept-payload-versions')
|
|
225
|
+
const compatibility = (res.status === 415 || res.status === 422) && accepted
|
|
226
|
+
? ` Endpoint accepts payload version(s) ${accepted}; retry with payload_version:2 only if you intentionally want graph-only sync.`
|
|
227
|
+
: ''
|
|
228
|
+
return `Sync endpoint answered HTTP ${res.status} — graph NOT accepted.${compatibility}`
|
|
229
|
+
}
|
|
230
|
+
const evidenceNote = payload.syncPayloadV === 3
|
|
231
|
+
? ` + evidence ${payload.evidence?.snapshotHash?.slice(0, 12) || 'unknown'}`
|
|
232
|
+
: ''
|
|
233
|
+
return `Graph for ${repoName} (${payload.nodes.length} nodes / ${payload.links.length} edges${evidenceNote}, ${Math.round(bodyBytes / 1024)} KB) pushed to ${url}.`
|
|
153
234
|
} catch (e) {
|
|
154
235
|
return `Sync failed: ${e.message} — the graph stays local.`
|
|
155
236
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Executable target architecture for agents: read the active contract before a change, then run the
|
|
2
|
+
// same deterministic verifier after it. No tool silently edits policy or accepts debt.
|
|
3
|
+
import {contractForChange, loadArchitectureContract, normalizeArchitectureContract, verifyArchitecture} from '../analysis/architecture-contract.js'
|
|
4
|
+
import {detectRepoStack} from '../scan/discover.js'
|
|
5
|
+
import {toolResult} from './tool-result.mjs'
|
|
6
|
+
|
|
7
|
+
const stackIds = (repoRoot) => {
|
|
8
|
+
try {
|
|
9
|
+
const stack = detectRepoStack(repoRoot)
|
|
10
|
+
return ['languages', 'runtimes', 'tests', 'infra', 'deploy']
|
|
11
|
+
.flatMap((category) => (Array.isArray(stack?.[category]) ? stack[category] : []))
|
|
12
|
+
.map((item) => String(item?.id || '')).filter(Boolean)
|
|
13
|
+
} catch { return [] }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function activeContract(ctx) {
|
|
17
|
+
if (!ctx?.repoRoot) return {contract: null, source: null, error: 'no repository root is active'}
|
|
18
|
+
return loadArchitectureContract(ctx.repoRoot, ctx.graphPath)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function starterContract(g) {
|
|
22
|
+
const files = [...new Set((g?.nodes || [])
|
|
23
|
+
.filter((node) => !String(node.id).includes('#'))
|
|
24
|
+
.map((node) => String(node.source_file || node.id || '').replace(/\\/g, '/'))
|
|
25
|
+
.filter(Boolean))]
|
|
26
|
+
const groups = new Map()
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
const parts = file.split('/').filter(Boolean)
|
|
29
|
+
const depth = ['src', 'app', 'lib', 'packages', 'services'].includes(parts[0]) ? 2 : 1
|
|
30
|
+
const path = parts.slice(0, Math.max(1, Math.min(depth, parts.length - 1))).join('/') || '(root)'
|
|
31
|
+
groups.set(path, (groups.get(path) || 0) + 1)
|
|
32
|
+
}
|
|
33
|
+
const components = [...groups].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 80)
|
|
34
|
+
.filter(([path]) => path !== '(root)')
|
|
35
|
+
.map(([path], index) => ({
|
|
36
|
+
id: path.toLowerCase().replace(/[^a-z0-9._:-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100) || `component-${index + 1}`,
|
|
37
|
+
name: path,
|
|
38
|
+
paths: [path],
|
|
39
|
+
}))
|
|
40
|
+
return normalizeArchitectureContract({
|
|
41
|
+
name: 'Proposed no-regressions baseline', style: 'custom', enforcement: 'ratchet', components,
|
|
42
|
+
dependencyRules: [],
|
|
43
|
+
budgets: {runtimeCycles: 0, maxFileLoc: 300, maxFunctionLoc: 120, maxCyclomatic: 15, maxModuleFiles: 80, minModuleCohesion: .5, maxModuleBoundaryRatio: .65},
|
|
44
|
+
technologies: {required: [], forbidden: []}, exceptions: [], ratchet: {baseline: {fingerprints: [], metrics: {}}},
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function notConfiguredResult(g, action) {
|
|
49
|
+
const starter = starterContract(g)
|
|
50
|
+
return toolResult([
|
|
51
|
+
`Architecture ${action} is NOT_CONFIGURED — no target contract is active.`,
|
|
52
|
+
`A source-free starter was inferred from ${starter.components.length} path territories; review it before saving because folders are evidence, not semantic truth.`,
|
|
53
|
+
'Next: save it as .weavatrix/architecture.json (offline) or approve it in the hosted Architecture editor, then call verify_architecture.',
|
|
54
|
+
].join('\n'), {
|
|
55
|
+
state: 'NOT_CONFIGURED',
|
|
56
|
+
remediation: {
|
|
57
|
+
offlinePath: '.weavatrix/architecture.json',
|
|
58
|
+
hostedAction: 'Open Architecture → choose intended style → Save target & baseline',
|
|
59
|
+
nextTool: 'verify_architecture',
|
|
60
|
+
},
|
|
61
|
+
starterContract: starter,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function tGetArchitectureContract(g, args, ctx) {
|
|
66
|
+
const loaded = activeContract(ctx)
|
|
67
|
+
if (!loaded.contract) {
|
|
68
|
+
const text = loaded.error
|
|
69
|
+
? `Architecture contract is invalid (${loaded.source || 'unknown'}): ${loaded.error}`
|
|
70
|
+
: 'No target architecture contract is active.'
|
|
71
|
+
if (!loaded.error) return notConfiguredResult(g, 'lookup')
|
|
72
|
+
return toolResult(text, {state: 'ERROR', source: loaded.source, error: loaded.error})
|
|
73
|
+
}
|
|
74
|
+
const contract = loaded.contract
|
|
75
|
+
return toolResult([
|
|
76
|
+
`Target architecture: ${contract.name} (${contract.style}, ${contract.enforcement}).`,
|
|
77
|
+
`Contract ${contract.contractHash.slice(0, 12)} from ${loaded.source}; ${contract.components.length} components, ${contract.dependencyRules.length} dependency rules.`,
|
|
78
|
+
`Quality budgets: ${Object.entries(contract.budgets).map(([key, value]) => `${key}=${value}`).join(', ') || '(none)'}.`,
|
|
79
|
+
].join('\n'), {state: 'ACTIVE', source: loaded.source, contract})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function tPrepareChange(g, args, ctx) {
|
|
83
|
+
const loaded = activeContract(ctx)
|
|
84
|
+
if (!loaded.contract) return notConfiguredResult(g, 'preflight')
|
|
85
|
+
const prepared = contractForChange(loaded.contract, args.files)
|
|
86
|
+
const intent = String(args.intent || '').slice(0, 500)
|
|
87
|
+
return toolResult([
|
|
88
|
+
`Architecture preflight for ${prepared.files.length} file(s)${intent ? ` — ${intent}` : ''}.`,
|
|
89
|
+
`Affected target components: ${prepared.components.join(', ') || '(unmapped)'}.`,
|
|
90
|
+
`Applicable rules: ${prepared.rules.map((rule) => rule.id).join(', ') || '(none)'}.`,
|
|
91
|
+
'Run verify_architecture after the edit; do not silently add an exception or rewrite the target contract.',
|
|
92
|
+
].join('\n'), {state: 'READY', intent, contractHash: loaded.contract.contractHash, ...prepared})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function tVerifyArchitecture(g, args, ctx) {
|
|
96
|
+
const loaded = activeContract(ctx)
|
|
97
|
+
if (!loaded.contract) return notConfiguredResult(g, 'verification')
|
|
98
|
+
const verification = verifyArchitecture({graph: g, contract: loaded.contract, technologies: stackIds(ctx.repoRoot)})
|
|
99
|
+
const groups = [['new', verification.new], ['existing', verification.existing], ['fixed', verification.fixed], ['excepted', verification.excepted]]
|
|
100
|
+
const lines = [
|
|
101
|
+
`Architecture verify: ${verification.status} (${verification.enforcement}; contract ${verification.contractHash.slice(0, 12)}).`,
|
|
102
|
+
`New ${verification.new.length} · existing debt ${verification.existing.length} · fixed ${verification.fixed.length} · excepted ${verification.excepted.length}.`,
|
|
103
|
+
...groups.filter(([, values]) => values.length).flatMap(([label, values]) => [
|
|
104
|
+
`${label}:`,
|
|
105
|
+
...values.slice(0, 20).map((item) => typeof item === 'string' ? ` ${item}` : ` ${item.ruleId}: ${item.evidence}${item.current != null ? ` (${item.current} > ${item.target})` : ''}`),
|
|
106
|
+
]),
|
|
107
|
+
]
|
|
108
|
+
return toolResult(lines.join('\n'), {state: verification.status, source: loaded.source, verification}, {
|
|
109
|
+
completeness: {violationsReturned: Math.min(20, verification.new.length + verification.existing.length), violationsTotal: verification.new.length + verification.existing.length},
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function tExplainArchitectureViolation(g, args, ctx) {
|
|
114
|
+
const loaded = activeContract(ctx)
|
|
115
|
+
if (!loaded.contract) return toolResult('No active architecture contract.', {state: 'NOT_CONFIGURED'})
|
|
116
|
+
const verification = verifyArchitecture({graph: g, contract: loaded.contract, technologies: stackIds(ctx.repoRoot)})
|
|
117
|
+
const fingerprint = String(args.fingerprint || '')
|
|
118
|
+
const item = [...verification.new, ...verification.existing, ...verification.excepted]
|
|
119
|
+
.find((entry) => entry.fingerprint === fingerprint)
|
|
120
|
+
if (!item) return toolResult(`Violation ${fingerprint || '(missing)'} is not active in the current verification.`, {state: 'NOT_FOUND', fingerprint})
|
|
121
|
+
const rule = loaded.contract.dependencyRules.find((candidate) => candidate.id === item.ruleId)
|
|
122
|
+
return toolResult([
|
|
123
|
+
`${item.ruleId}: ${item.evidence}.`,
|
|
124
|
+
rule?.reason ? `Reason: ${rule.reason}` : 'The finding violates an explicit dependency or quality rule in the active target contract.',
|
|
125
|
+
'Preferred action: change the dependency/metric. If that is intentionally impossible, propose a time-bounded exception for human review.',
|
|
126
|
+
].join('\n'), {state: 'ACTIVE', violation: item, rule: rule || null})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function tProposeArchitectureException(g, args, ctx) {
|
|
130
|
+
const loaded = activeContract(ctx)
|
|
131
|
+
if (!loaded.contract) return toolResult('No active architecture contract.', {state: 'NOT_CONFIGURED'})
|
|
132
|
+
const verification = verifyArchitecture({graph: g, contract: loaded.contract, technologies: stackIds(ctx.repoRoot)})
|
|
133
|
+
const item = [...verification.new, ...verification.existing].find((entry) => entry.fingerprint === String(args.fingerprint || ''))
|
|
134
|
+
if (!item) return toolResult('Only an active violation can be proposed as an exception.', {state: 'NOT_FOUND'})
|
|
135
|
+
const proposal = {
|
|
136
|
+
fingerprint: item.fingerprint,
|
|
137
|
+
reason: String(args.reason || '').trim().slice(0, 300),
|
|
138
|
+
...(args.expires ? {expires: String(args.expires)} : {}),
|
|
139
|
+
}
|
|
140
|
+
const checked = normalizeArchitectureContract({...loaded.contract, exceptions: [...loaded.contract.exceptions, proposal]})
|
|
141
|
+
const normalized = checked.exceptions.find((entry) => entry.fingerprint === item.fingerprint)
|
|
142
|
+
if (!normalized || !normalized.reason) return toolResult('A non-empty reason and optional YYYY-MM-DD expiry are required.', {state: 'INVALID'})
|
|
143
|
+
return toolResult('Exception proposal prepared for human review; the contract was not changed.', {state: 'PROPOSED', proposal: normalized, contractHash: loaded.contract.contractHash})
|
|
144
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// Cross-repository HTTP contract intelligence. Repository paths are resolved only through the
|
|
2
|
+
// local global registry; callers can select an opaque repository UUID or an unambiguous label but
|
|
3
|
+
// can never pass an arbitrary filesystem path through this tool.
|
|
4
|
+
import {join} from 'node:path'
|
|
5
|
+
import {analyzeHttpContracts} from '../analysis/http-contracts.js'
|
|
6
|
+
import {buildGraphForRepo} from '../build-graph.js'
|
|
7
|
+
import {graphHomeDir} from '../graph/layout.js'
|
|
8
|
+
import {liveRepositoryRecords} from '../graph/repo-registry.js'
|
|
9
|
+
import {loadGraph} from './graph-context.mjs'
|
|
10
|
+
import {toolResult} from './tool-result.mjs'
|
|
11
|
+
|
|
12
|
+
const selectorText = (value) => String(value ?? '').trim()
|
|
13
|
+
|
|
14
|
+
function selectRecord(records, selector) {
|
|
15
|
+
const query = selectorText(selector)
|
|
16
|
+
if (!query) return {error: 'repository selector is required'}
|
|
17
|
+
const byId = records.find((record) => record.repositoryId === query)
|
|
18
|
+
if (byId) return {record: byId}
|
|
19
|
+
const byLabel = records.filter((record) => String(record.label || '').toLowerCase() === query.toLowerCase())
|
|
20
|
+
if (byLabel.length === 1) return {record: byLabel[0]}
|
|
21
|
+
if (byLabel.length > 1) return {
|
|
22
|
+
error: `repository label "${query}" is ambiguous; use one of these repository IDs`,
|
|
23
|
+
candidates: byLabel.map((record) => ({repositoryId: record.repositoryId, label: record.label})),
|
|
24
|
+
}
|
|
25
|
+
return {error: `repository "${query}" is not present in the live global graph registry`}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function safeAlias(record, records) {
|
|
29
|
+
const base = String(record.label || 'repo').normalize('NFKC')
|
|
30
|
+
.replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '').slice(0, 56) || 'repo'
|
|
31
|
+
const duplicates = records.filter((item) => String(item.label || '').toLowerCase() === String(record.label || '').toLowerCase())
|
|
32
|
+
return duplicates.length > 1 ? `${base}-${String(record.repositoryId).slice(0, 8)}` : base
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function publicRecord(record, alias) {
|
|
36
|
+
return {repositoryId: record.repositoryId, label: record.label, alias}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function safeRefreshReason(record, error) {
|
|
40
|
+
let message = String(error instanceof Error ? error.message : error || 'graph refresh failed')
|
|
41
|
+
for (const path of [record.repoPath, record.graphDir]) {
|
|
42
|
+
if (!path) continue
|
|
43
|
+
message = message.split(String(path)).join(record.label || 'repository')
|
|
44
|
+
message = message.split(String(path).replace(/\\/g, '/')).join(record.label || 'repository')
|
|
45
|
+
}
|
|
46
|
+
return message.replace(/[\r\n]+/g, ' ').trim().slice(0, 400) || 'graph refresh failed'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function reconcileGraph(record, alias, role, graphHome) {
|
|
50
|
+
let build
|
|
51
|
+
try {
|
|
52
|
+
build = await buildGraphForRepo(record.repoPath, {
|
|
53
|
+
mode: 'full',
|
|
54
|
+
scope: '',
|
|
55
|
+
outDir: record.graphDir,
|
|
56
|
+
graphHome,
|
|
57
|
+
})
|
|
58
|
+
} catch (error) {
|
|
59
|
+
build = {ok: false, error: safeRefreshReason(record, error)}
|
|
60
|
+
}
|
|
61
|
+
const refreshKind = build?.refresh?.kind || null
|
|
62
|
+
const changedFiles = Array.isArray(build?.refresh?.changedFiles) ? build.refresh.changedFiles : []
|
|
63
|
+
const publicStatus = {
|
|
64
|
+
role,
|
|
65
|
+
repository: publicRecord(record, alias),
|
|
66
|
+
status: build?.ok ? (refreshKind === 'none' ? 'CURRENT' : 'REFRESHED') : 'STALE_FALLBACK',
|
|
67
|
+
refresh: build?.ok ? {
|
|
68
|
+
kind: refreshKind || 'full',
|
|
69
|
+
reason: String(build?.refresh?.reason || 'graph-rebuilt'),
|
|
70
|
+
changedFileCount: changedFiles.length,
|
|
71
|
+
} : null,
|
|
72
|
+
}
|
|
73
|
+
if (build?.ok) {
|
|
74
|
+
try {
|
|
75
|
+
return {record, alias, graph: loadGraph(join(record.graphDir, 'graph.json')), publicStatus}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const reason = `refreshed graph could not be loaded: ${safeRefreshReason(record, error)}`
|
|
78
|
+
return {record, alias, graph: null, reason, publicStatus: {...publicStatus, status: 'FAILED', reason}}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const reason = `graph refresh failed: ${safeRefreshReason(record, build?.error)}`
|
|
83
|
+
try {
|
|
84
|
+
return {
|
|
85
|
+
record,
|
|
86
|
+
alias,
|
|
87
|
+
graph: loadGraph(join(record.graphDir, 'graph.json')),
|
|
88
|
+
reason: `${reason}; stale registered graph used`,
|
|
89
|
+
publicStatus: {...publicStatus, reason: `${reason}; stale registered graph used`},
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const loadReason = `${reason}; registered graph could not be loaded: ${safeRefreshReason(record, error)}`
|
|
93
|
+
return {record, alias, graph: null, reason: loadReason, publicStatus: {...publicStatus, status: 'FAILED', reason: loadReason}}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function verdictFor(analysis) {
|
|
98
|
+
const endpointsWithCallers = analysis.endpoints.filter((endpoint) => endpoint.callsites.length > 0)
|
|
99
|
+
const affectedFiles = new Set()
|
|
100
|
+
const affectedScreens = new Set()
|
|
101
|
+
for (const endpoint of endpointsWithCallers) {
|
|
102
|
+
for (const item of endpoint.affected.files || []) affectedFiles.add(`${item.client}\0${item.file}`)
|
|
103
|
+
for (const item of endpoint.affected.screens || []) affectedScreens.add(`${item.client}\0${item.file}`)
|
|
104
|
+
}
|
|
105
|
+
let code = 'NO_ENDPOINTS_MATCHED', risk = 'unknown'
|
|
106
|
+
if (analysis.totals.endpoints > 0 && analysis.totals.matches === 0) {
|
|
107
|
+
code = analysis.totals.methodMismatches > 0 ? 'HTTP_METHOD_MISMATCH' : 'NO_STATIC_CLIENT_CALLERS'
|
|
108
|
+
risk = analysis.totals.methodMismatches > 0 ? 'high' : 'unknown'
|
|
109
|
+
} else if (analysis.totals.matches > 0 && analysis.totals.methodMismatches > 0) {
|
|
110
|
+
code = 'CLIENTS_AT_RISK_WITH_METHOD_MISMATCHES'; risk = 'high'
|
|
111
|
+
} else if (analysis.totals.matches > 0) {
|
|
112
|
+
code = 'CLIENTS_AT_RISK'; risk = 'medium'
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
code,
|
|
116
|
+
risk,
|
|
117
|
+
endpointsWithCallers: endpointsWithCallers.length,
|
|
118
|
+
callsites: analysis.totals.matches,
|
|
119
|
+
affectedFiles: affectedFiles.size,
|
|
120
|
+
affectedScreens: affectedScreens.size,
|
|
121
|
+
methodMismatches: analysis.totals.methodMismatches,
|
|
122
|
+
uncertainCalls: analysis.totals.uncertainCalls,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function verdictLine(verdict, endpoints) {
|
|
127
|
+
if (verdict.code === 'NO_ENDPOINTS_MATCHED') {
|
|
128
|
+
return 'VERDICT NO_ENDPOINTS_MATCHED — no backend endpoint satisfied the requested method/path/change filter.'
|
|
129
|
+
}
|
|
130
|
+
if (verdict.code === 'NO_STATIC_CLIENT_CALLERS') {
|
|
131
|
+
return `VERDICT NO_STATIC_CLIENT_CALLERS — ${endpoints} backend endpoint(s) matched, but no bounded literal/template client call was proven; this is unknown, not proof of no consumers.`
|
|
132
|
+
}
|
|
133
|
+
if (verdict.code === 'HTTP_METHOD_MISMATCH') {
|
|
134
|
+
return `VERDICT HTTP_METHOD_MISMATCH — ${verdict.methodMismatches} client call(s) match the route shape with a different method.`
|
|
135
|
+
}
|
|
136
|
+
return `VERDICT ${verdict.code} — ${verdict.callsites} callsite(s) reach ${verdict.endpointsWithCallers} endpoint(s); ${verdict.affectedScreens} screen(s) and ${verdict.affectedFiles} file(s) are in the bounded blast radius.`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
140
|
+
const graphHome = ctx.graphHome || graphHomeDir()
|
|
141
|
+
const records = liveRepositoryRecords(graphHome)
|
|
142
|
+
if (!records.length) return toolResult(
|
|
143
|
+
'VERDICT NOT_CONFIGURED — the global repository registry has no live graphs. Open/build both the backend and client repositories first.',
|
|
144
|
+
{status: 'NOT_CONFIGURED', availableRepositories: []},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
const backendSelection = selectRecord(records, args.backend)
|
|
148
|
+
if (!backendSelection.record) return toolResult(
|
|
149
|
+
`VERDICT INVALID_REPOSITORY — ${backendSelection.error}.`,
|
|
150
|
+
{status: 'INVALID_REPOSITORY', role: 'backend', ...backendSelection, availableRepositories: records.map((record) => publicRecord(record, safeAlias(record, records)))},
|
|
151
|
+
)
|
|
152
|
+
const rawClients = Array.isArray(args.clients) ? args.clients.slice(0, 20) : []
|
|
153
|
+
if (!rawClients.length) return toolResult(
|
|
154
|
+
'VERDICT INVALID_REPOSITORY — at least one client repository ID or label is required.',
|
|
155
|
+
{status: 'INVALID_REPOSITORY', role: 'clients'},
|
|
156
|
+
)
|
|
157
|
+
const clientRecords = []
|
|
158
|
+
for (const selector of rawClients) {
|
|
159
|
+
const selected = selectRecord(records, selector)
|
|
160
|
+
if (!selected.record) return toolResult(
|
|
161
|
+
`VERDICT INVALID_REPOSITORY — ${selected.error}.`,
|
|
162
|
+
{status: 'INVALID_REPOSITORY', role: 'client', selector, ...selected},
|
|
163
|
+
)
|
|
164
|
+
if (!clientRecords.some((record) => record.repositoryId === selected.record.repositoryId)) clientRecords.push(selected.record)
|
|
165
|
+
}
|
|
166
|
+
if (clientRecords.some((record) => record.repositoryId === backendSelection.record.repositoryId)) return toolResult(
|
|
167
|
+
'VERDICT INVALID_REPOSITORY — backend and client selections must be distinct repositories.',
|
|
168
|
+
{status: 'INVALID_REPOSITORY', role: 'clients'},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const backend = backendSelection.record
|
|
172
|
+
const backendAlias = safeAlias(backend, records)
|
|
173
|
+
const clients = clientRecords.map((record) => ({record, alias: safeAlias(record, records)}))
|
|
174
|
+
const reconciled = []
|
|
175
|
+
for (const selected of [
|
|
176
|
+
{record: backend, alias: backendAlias, role: 'backend'},
|
|
177
|
+
...clients.map(({record, alias}) => ({record, alias, role: 'client'})),
|
|
178
|
+
]) {
|
|
179
|
+
reconciled.push(await reconcileGraph(selected.record, selected.alias, selected.role, graphHome))
|
|
180
|
+
}
|
|
181
|
+
const backendGraph = reconciled[0]
|
|
182
|
+
const clientGraphs = reconciled.slice(1)
|
|
183
|
+
const reconciliationReasons = reconciled.filter((item) => item.reason).map((item) => item.reason)
|
|
184
|
+
const graphReconciliation = reconciled.map((item) => item.publicStatus)
|
|
185
|
+
if (!backendGraph.graph || clientGraphs.some((item) => !item.graph)) {
|
|
186
|
+
const reasons = reconciliationReasons.length ? reconciliationReasons : ['one or more selected graphs could not be refreshed and loaded']
|
|
187
|
+
const completeness = {complete: false, status: 'PARTIAL', reasons}
|
|
188
|
+
return toolResult(
|
|
189
|
+
`VERDICT PARTIAL — selected repository graphs could not all be refreshed and loaded.\nCompleteness: partial — ${reasons.join('; ')}.`,
|
|
190
|
+
{
|
|
191
|
+
crossRepoHttpContractV: 1,
|
|
192
|
+
status: 'PARTIAL',
|
|
193
|
+
repositories: {
|
|
194
|
+
backend: publicRecord(backend, backendAlias),
|
|
195
|
+
clients: clients.map(({record, alias}) => publicRecord(record, alias)),
|
|
196
|
+
},
|
|
197
|
+
graphReconciliation,
|
|
198
|
+
completeness,
|
|
199
|
+
},
|
|
200
|
+
{completeness, warnings: [{code: 'CROSS_REPO_GRAPH_REFRESH_PARTIAL', message: reasons.join('; ')}]},
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const analysis = analyzeHttpContracts({
|
|
206
|
+
backend: {id: backendAlias, repoRoot: backend.repoPath, graph: backendGraph.graph},
|
|
207
|
+
clients: clientGraphs.map((item) => ({id: item.alias, repoRoot: item.record.repoPath, graph: item.graph})),
|
|
208
|
+
method: args.method,
|
|
209
|
+
path: args.path,
|
|
210
|
+
changedFiles: args.changed_files,
|
|
211
|
+
includeTests: args.include_tests === true,
|
|
212
|
+
maxImpactDepth: args.max_impact_depth,
|
|
213
|
+
maxEndpoints: args.max_endpoints,
|
|
214
|
+
maxMatches: args.max_matches,
|
|
215
|
+
maxAffectedFiles: args.max_affected_files,
|
|
216
|
+
})
|
|
217
|
+
const verdict = verdictFor(analysis)
|
|
218
|
+
const reasons = [...new Set([...reconciliationReasons, ...(analysis.completeness?.reasons || [])])]
|
|
219
|
+
const completeness = {
|
|
220
|
+
complete: reasons.length === 0 && analysis.completeness?.complete === true,
|
|
221
|
+
status: reasons.length === 0 && analysis.completeness?.complete === true ? 'COMPLETE' : 'PARTIAL',
|
|
222
|
+
reasons,
|
|
223
|
+
}
|
|
224
|
+
const topN = Math.max(1, Math.min(50, Number(args.top_n) || 10))
|
|
225
|
+
const ranked = [...analysis.endpoints].sort((left, right) =>
|
|
226
|
+
right.callsites.length - left.callsites.length ||
|
|
227
|
+
right.affected.files.length - left.affected.files.length ||
|
|
228
|
+
left.path.localeCompare(right.path))
|
|
229
|
+
const lines = [
|
|
230
|
+
verdictLine(verdict, analysis.totals.endpoints),
|
|
231
|
+
`Scope: ${backend.label} → ${clients.map(({record}) => record.label).join(', ')}; ${analysis.totals.endpoints} endpoint(s), ${analysis.totals.clientCalls} inspected client call(s), ${analysis.totals.uncertainCalls} uncertain.`,
|
|
232
|
+
...ranked.slice(0, topN).flatMap((endpoint) => {
|
|
233
|
+
const location = endpoint.file ? ` (${endpoint.backend}:${endpoint.file}${endpoint.line ? `:${endpoint.line}` : ''})` : ''
|
|
234
|
+
const callsites = endpoint.callsites.slice(0, 3)
|
|
235
|
+
.map((call) => ` caller ${call.clientRepo}:${call.file}:${call.line} (${call.match.confidence}, ${call.match.kind})`)
|
|
236
|
+
const screens = endpoint.affected.screens.slice(0, 3)
|
|
237
|
+
.map((screen) => ` screen ${screen.client}:${screen.file} (distance ${screen.distance})`)
|
|
238
|
+
return [
|
|
239
|
+
` ${endpoint.method} ${endpoint.path}${location} → ${endpoint.callsites.length} callsite(s), ${endpoint.affected.screens.length} screen(s), ${endpoint.affected.files.length} affected file(s)`,
|
|
240
|
+
...callsites,
|
|
241
|
+
...screens,
|
|
242
|
+
]
|
|
243
|
+
}),
|
|
244
|
+
completeness.complete
|
|
245
|
+
? 'Completeness: complete within the declared repository graphs and static HTTP-call model.'
|
|
246
|
+
: `Completeness: partial — ${completeness.reasons.join('; ')}.`,
|
|
247
|
+
]
|
|
248
|
+
const result = {
|
|
249
|
+
crossRepoHttpContractV: 1,
|
|
250
|
+
verdict,
|
|
251
|
+
repositories: {
|
|
252
|
+
backend: publicRecord(backend, backendAlias),
|
|
253
|
+
clients: clients.map(({record, alias}) => publicRecord(record, alias)),
|
|
254
|
+
},
|
|
255
|
+
...analysis,
|
|
256
|
+
status: completeness.status,
|
|
257
|
+
graphReconciliation,
|
|
258
|
+
completeness,
|
|
259
|
+
}
|
|
260
|
+
return toolResult(lines.join('\n'), result, {
|
|
261
|
+
completeness,
|
|
262
|
+
warnings: completeness.complete ? [] : [{code: 'CROSS_REPO_ANALYSIS_PARTIAL', message: completeness.reasons.join('; ')}],
|
|
263
|
+
})
|
|
264
|
+
} catch (error) {
|
|
265
|
+
const reason = error instanceof Error ? error.message : 'cross-repository analysis failed'
|
|
266
|
+
const completeness = {complete: false, status: 'PARTIAL', reasons: [reason]}
|
|
267
|
+
return toolResult(
|
|
268
|
+
`VERDICT PARTIAL — ${reason}.`,
|
|
269
|
+
{status: 'PARTIAL', graphReconciliation, completeness},
|
|
270
|
+
{completeness, warnings: [{code: 'CROSS_REPO_ANALYSIS_PARTIAL', message: reason}]},
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
}
|