weavatrix 0.1.4 → 0.2.1
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 +156 -53
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +102 -31
- package/src/analysis/architecture-contract.js +343 -0
- package/src/analysis/audit-debt.js +94 -0
- package/src/analysis/change-classification.js +534 -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 +591 -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 +633 -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 +64 -20
- 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 +54 -0
- package/src/mcp/tools-actions.mjs +111 -30
- package/src/mcp/tools-architecture.mjs +200 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph-hubs.mjs +22 -3
- package/src/mcp/tools-graph.mjs +25 -14
- package/src/mcp/tools-health.mjs +336 -14
- package/src/mcp/tools-history.mjs +28 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +25 -6
- package/src/mcp-server.mjs +102 -17
- package/src/mcp-source-tools.mjs +11 -3
- package/src/path-classification.js +206 -0
- package/src/path-ignore.js +69 -0
- package/src/security/typosquat.js +1 -1
|
@@ -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
|
+
}
|
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
// Connectivity-hub reporting, split from the general graph query tools.
|
|
2
2
|
import {connList} from './graph-context.mjs'
|
|
3
|
+
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
3
4
|
|
|
4
5
|
const isCompileTimeEdge = (edge) => edge?.typeOnly === true || edge?.compileOnly === true
|
|
6
|
+
const NON_PRODUCT_CLASSES = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
|
|
7
|
+
const sourceFileOf = (node) => String(node?.source_file || (node?.file_type === 'code' ? node?.id : '') || '').replace(/\\/g, '/')
|
|
5
8
|
|
|
6
|
-
export function tGodNodes(g, {top_n = 10} = {}) {
|
|
9
|
+
export function tGodNodes(g, {top_n = 10, include_classified = false} = {}, ctx = {}) {
|
|
7
10
|
const n = Math.max(1, Math.min(100, Number(top_n) || 10))
|
|
11
|
+
const classifier = createPathClassifier(ctx.repoRoot || null)
|
|
12
|
+
const classificationByFile = new Map()
|
|
13
|
+
const isNonProduct = (node) => {
|
|
14
|
+
if (include_classified === true) return false
|
|
15
|
+
const file = sourceFileOf(node)
|
|
16
|
+
if (!file) return false
|
|
17
|
+
if (!classificationByFile.has(file)) classificationByFile.set(file, classifier.explain(file))
|
|
18
|
+
const info = classificationByFile.get(file)
|
|
19
|
+
return info.excluded || hasPathClass(info, ...NON_PRODUCT_CLASSES)
|
|
20
|
+
}
|
|
21
|
+
const excludedIds = new Set(g.nodes.filter(isNonProduct).map((node) => String(node.id)))
|
|
8
22
|
const scored = g.nodes
|
|
23
|
+
.filter((node) => !excludedIds.has(String(node.id)))
|
|
9
24
|
.map((node) => {
|
|
10
|
-
|
|
11
|
-
const
|
|
25
|
+
// Coupling from a suppressed artifact must not inflate an otherwise valid product hub.
|
|
26
|
+
const rawOuts = (g.out.get(String(node.id)) || []).filter((edge) => !excludedIds.has(String(edge.id)))
|
|
27
|
+
const rawIns = (g.inn.get(String(node.id)) || []).filter((edge) => !excludedIds.has(String(edge.id)))
|
|
12
28
|
const outs = connList(rawOuts)
|
|
13
29
|
const ins = connList(rawIns)
|
|
14
30
|
const ownedMethods = new Set(rawOuts.filter((e) => e.relation === 'method').map((e) => String(e.id)))
|
|
@@ -54,5 +70,8 @@ export function tGodNodes(g, {top_n = 10} = {}) {
|
|
|
54
70
|
...occurrenceHotspots.map((r) =>
|
|
55
71
|
` ${r.node.label ?? r.node.id} (${r.occurrences} occurrences across ${r.deg} unique neighbors; ${r.occurrences - r.deg} repeats) [${r.node.id}]`
|
|
56
72
|
),
|
|
73
|
+
excludedIds.size > 0 && include_classified !== true
|
|
74
|
+
? `${excludedIds.size} node(s) classified as tests/e2e/generated/build output/mocks/stories/docs/benchmarks/temp or explicitly excluded were omitted; pass include_classified:true to inspect them.`
|
|
75
|
+
: null,
|
|
57
76
|
].filter((line) => line != null).join('\n')
|
|
58
77
|
}
|
package/src/mcp/tools-graph.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// staleness probe in graph-context. Hot-reloadable (re-imported by catalog.mjs on change).
|
|
4
4
|
import {
|
|
5
5
|
isSymbol, degreeOf, labelOf, connList,
|
|
6
|
-
resolveNodeInfo, resolveNode, ambiguityNote, findSeeds, undirectedNeighbors,
|
|
6
|
+
resolveNodeInfo, resolveNode, ambiguityNote, findSeeds, resolveSeedFiles, undirectedNeighbors,
|
|
7
7
|
graphStaleness, fileStalenessNote,
|
|
8
8
|
} from './graph-context.mjs'
|
|
9
9
|
|
|
@@ -40,6 +40,7 @@ export function tGraphStats(g, ctx) {
|
|
|
40
40
|
`- Nodes: ${g.nodes.length} (${files} files, ${symbols} symbols)`,
|
|
41
41
|
`- Edges: ${g.links.length}`,
|
|
42
42
|
g.edgeTypesV ? `- Typed-edge metadata: v${g.edgeTypesV} (${typeOnlyEdges} type-only, ${compileOnlyEdges} compile-only edges)` : `- Typed-edge metadata: unavailable (rebuild_graph required)`,
|
|
43
|
+
g.barrelResolutionV ? `- Barrel resolution: v${g.barrelResolutionV} (semantic tools look through JS/TS re-export facades)` : `- Barrel resolution: unavailable (rebuild_graph required for JS/TS barrel transparency)`,
|
|
43
44
|
`- Relations: ${fmt(relCount)}`,
|
|
44
45
|
Object.keys(confCount).length ? `- Confidence: ${fmt(confCount)}` : null,
|
|
45
46
|
`- Communities: ${comm.size} (top by size: ${topComm.map(([c, n]) => `#${c}=${n}`).join(', ')})`,
|
|
@@ -58,6 +59,8 @@ export function tGetNode(g, {label} = {}, ctx) {
|
|
|
58
59
|
const drift = ctx ? fileStalenessNote(ctx, n.source_file || (isSymbol(id) ? id.split('#')[0] : id)) : null
|
|
59
60
|
const outs = g.out.get(id) || []
|
|
60
61
|
const ins = g.inn.get(id) || []
|
|
62
|
+
const semanticOuts = connList(outs)
|
|
63
|
+
const semanticIns = connList(ins)
|
|
61
64
|
const sample = (list, dir) =>
|
|
62
65
|
list
|
|
63
66
|
.slice(0, 12)
|
|
@@ -70,7 +73,7 @@ export function tGetNode(g, {label} = {}, ctx) {
|
|
|
70
73
|
`- kind: ${isSymbol(id) ? 'symbol' : 'file'}${n.file_type ? ` (${n.file_type})` : ''}`,
|
|
71
74
|
n.source_file ? `- source: ${n.source_file}${n.source_location ? ` ${n.source_location}` : ''}` : null,
|
|
72
75
|
n.community != null ? `- community: ${n.community}` : null,
|
|
73
|
-
`- degree: ${
|
|
76
|
+
`- semantic degree: ${semanticOuts.length + semanticIns.length} (out ${semanticOuts.length}, in ${semanticIns.length})${outs.length + ins.length !== semanticOuts.length + semanticIns.length ? `; ${outs.length + ins.length} physical/structural edges retained` : ''}`,
|
|
74
77
|
`Outgoing:\n${sample(outs, 'out')}`,
|
|
75
78
|
`Incoming:\n${sample(ins, 'in')}`,
|
|
76
79
|
drift,
|
|
@@ -151,8 +154,14 @@ export function tGetCommunity(g, {community_id} = {}) {
|
|
|
151
154
|
// A plain BFS/DFS flood dumps every reached node (thousands on a real graph) at near-zero signal.
|
|
152
155
|
// Instead: traverse to record reach + distance-from-seed, then show only the closest, most-connected
|
|
153
156
|
// slice as a coherent subgraph (edges kept only among shown nodes). Honest about what was trimmed.
|
|
154
|
-
export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filter, token_budget = 2000} = {}) {
|
|
155
|
-
const
|
|
157
|
+
export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filter, seed_files, augment_seeds = false, token_budget = 2000} = {}) {
|
|
158
|
+
const pinned = resolveSeedFiles(g, seed_files)
|
|
159
|
+
// Exact seed files are a control surface, not a hint: by default they disable fuzzy keyword seeds.
|
|
160
|
+
// Callers can opt back into augmentation when they explicitly want both behaviors.
|
|
161
|
+
const automatic = pinned.seeds.length && augment_seeds !== true
|
|
162
|
+
? []
|
|
163
|
+
: findSeeds(g, question, Math.max(0, 8 - pinned.seeds.length))
|
|
164
|
+
const seeds = [...pinned.seeds, ...automatic.filter((node) => !pinned.seeds.some((seed) => String(seed.id) === String(node.id)))]
|
|
156
165
|
if (!seeds.length) return `No nodes matched "${question}".`
|
|
157
166
|
const maxDepth = Math.max(1, Math.min(6, Number(depth) || 3))
|
|
158
167
|
const ctx = Array.isArray(context_filter) && context_filter.length ? new Set(context_filter.map((c) => String(c).toLowerCase())) : null
|
|
@@ -161,7 +170,6 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
161
170
|
// node budget scales gently with the token budget; edges follow the surviving nodes.
|
|
162
171
|
const nodeBudget = Math.max(20, Math.min(120, Math.round((Number(token_budget) || 2000) / 40)))
|
|
163
172
|
const depthOf = new Map() // id -> shortest distance from any seed
|
|
164
|
-
const edges = []
|
|
165
173
|
const start = seeds.map((s) => String(s.id))
|
|
166
174
|
if (mode === 'dfs') {
|
|
167
175
|
const stack = start.map((id) => ({id, d: 0}))
|
|
@@ -174,7 +182,6 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
174
182
|
if (d >= maxDepth) continue
|
|
175
183
|
for (const [nid, rel] of undirectedNeighbors(g, id)) {
|
|
176
184
|
if (!relOk(rel)) continue
|
|
177
|
-
edges.push([id, rel, nid])
|
|
178
185
|
if (!seen.has(nid)) stack.push({id: nid, d: d + 1})
|
|
179
186
|
}
|
|
180
187
|
}
|
|
@@ -186,7 +193,6 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
186
193
|
for (const id of frontier)
|
|
187
194
|
for (const [nid, rel] of undirectedNeighbors(g, id)) {
|
|
188
195
|
if (!relOk(rel)) continue
|
|
189
|
-
edges.push([id, rel, nid])
|
|
190
196
|
if (!depthOf.has(nid)) {
|
|
191
197
|
depthOf.set(nid, d + 1)
|
|
192
198
|
next.push(nid)
|
|
@@ -203,24 +209,29 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
203
209
|
const shownIds = new Set(shown.map((n) => n.id))
|
|
204
210
|
const edgeSeen = new Set()
|
|
205
211
|
const shownEdges = []
|
|
206
|
-
for (const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
+
for (const source of shownIds) {
|
|
213
|
+
for (const edge of g.out.get(source) || []) {
|
|
214
|
+
const target = String(edge.id)
|
|
215
|
+
if (edge.barrelProxy === true || !shownIds.has(target) || !relOk(edge.relation)) continue
|
|
216
|
+
const key = `${source}|${edge.relation}|${target}`
|
|
217
|
+
if (edgeSeen.has(key)) continue
|
|
218
|
+
edgeSeen.add(key)
|
|
219
|
+
shownEdges.push([source, edge.relation, target])
|
|
220
|
+
if (shownEdges.length >= 160) break
|
|
221
|
+
}
|
|
212
222
|
if (shownEdges.length >= 160) break
|
|
213
223
|
}
|
|
214
224
|
const head = [
|
|
215
225
|
`Query: "${question}" (${mode}, depth ${maxDepth}${ctx ? `, context ${[...ctx].join('/')}` : ''})`,
|
|
216
226
|
`Seeds: ${seeds.map((s) => s.label ?? s.id).join(', ')}`,
|
|
227
|
+
pinned.missing.length ? `Unresolved pinned seed files: ${pinned.missing.join(', ')}` : null,
|
|
217
228
|
`Reached ${depthOf.size} nodes; showing ${shown.length} closest by proximity + connectivity, ${shownEdges.length} edges among them.`,
|
|
218
229
|
``,
|
|
219
230
|
`Nodes:`,
|
|
220
231
|
]
|
|
221
232
|
const nodeLines = shown.map((n) => ` [d${n.d}] ${labelOf(g, n.id)} (deg ${n.deg}) [${n.id}]`)
|
|
222
233
|
const edgeLines = ['', 'Edges:', ...shownEdges.map(([s, r, t]) => ` ${labelOf(g, s)} --${r || 'rel'}--> ${labelOf(g, t)}`)]
|
|
223
|
-
let text = [...head, ...nodeLines, ...edgeLines].join('\n')
|
|
234
|
+
let text = [...head.filter(Boolean), ...nodeLines, ...edgeLines].join('\n')
|
|
224
235
|
if (text.length > charBudget) text = text.slice(0, charBudget) + `\n... (truncated to ~${token_budget} tokens)`
|
|
225
236
|
return text
|
|
226
237
|
}
|