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.
Files changed (71) hide show
  1. package/README.md +156 -53
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +102 -31
  5. package/src/analysis/architecture-contract.js +343 -0
  6. package/src/analysis/audit-debt.js +94 -0
  7. package/src/analysis/change-classification.js +534 -0
  8. package/src/analysis/cycle-route.js +14 -0
  9. package/src/analysis/dead-check.js +5 -3
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +6 -0
  12. package/src/analysis/dep-check.js +40 -6
  13. package/src/analysis/dep-rules.js +12 -9
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints.js +8 -2
  17. package/src/analysis/findings.js +8 -1
  18. package/src/analysis/git-history.js +591 -0
  19. package/src/analysis/git-ref-graph.js +74 -0
  20. package/src/analysis/graph-analysis.aggregate.js +2 -2
  21. package/src/analysis/graph-analysis.edges.js +3 -0
  22. package/src/analysis/graph-analysis.summaries.js +1 -1
  23. package/src/analysis/http-contracts.js +633 -0
  24. package/src/analysis/internal-audit.collect.js +3 -2
  25. package/src/analysis/internal-audit.reach.js +52 -1
  26. package/src/analysis/internal-audit.run.js +58 -10
  27. package/src/analysis/java-source.js +36 -0
  28. package/src/analysis/package-reachability.js +39 -0
  29. package/src/analysis/static-test-reachability.js +133 -0
  30. package/src/build-graph.js +72 -13
  31. package/src/graph/build-worker.js +3 -4
  32. package/src/graph/builder/lang-js.js +71 -12
  33. package/src/graph/community.js +10 -0
  34. package/src/graph/file-lock.js +69 -0
  35. package/src/graph/freshness-probe.js +141 -0
  36. package/src/graph/graph-filter.js +28 -11
  37. package/src/graph/incremental-refresh.js +232 -0
  38. package/src/graph/internal-builder.barrels.js +169 -0
  39. package/src/graph/internal-builder.build.js +82 -11
  40. package/src/graph/internal-builder.langs.js +2 -1
  41. package/src/graph/layout.js +21 -5
  42. package/src/graph/repo-registry.js +124 -0
  43. package/src/mcp/catalog.mjs +64 -20
  44. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  45. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  46. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  47. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  48. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  49. package/src/mcp/evidence-snapshot.mjs +50 -0
  50. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  51. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  52. package/src/mcp/graph-context.mjs +100 -16
  53. package/src/mcp/graph-diff.mjs +74 -20
  54. package/src/mcp/staleness-notice.mjs +20 -0
  55. package/src/mcp/sync-evidence.mjs +418 -0
  56. package/src/mcp/sync-payload.mjs +164 -0
  57. package/src/mcp/tool-result.mjs +54 -0
  58. package/src/mcp/tools-actions.mjs +111 -30
  59. package/src/mcp/tools-architecture.mjs +200 -0
  60. package/src/mcp/tools-company.mjs +273 -0
  61. package/src/mcp/tools-graph-hubs.mjs +22 -3
  62. package/src/mcp/tools-graph.mjs +25 -14
  63. package/src/mcp/tools-health.mjs +336 -14
  64. package/src/mcp/tools-history.mjs +28 -0
  65. package/src/mcp/tools-impact-change.mjs +261 -0
  66. package/src/mcp/tools-impact.mjs +25 -6
  67. package/src/mcp-server.mjs +102 -17
  68. package/src/mcp-source-tools.mjs +11 -3
  69. package/src/path-classification.js +206 -0
  70. package/src/path-ignore.js +69 -0
  71. 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
- const rawOuts = g.out.get(String(node.id)) || []
11
- const rawIns = g.inn.get(String(node.id)) || []
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
  }
@@ -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: ${outs.length + ins.length} (out ${outs.length}, in ${ins.length})`,
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 seeds = findSeeds(g, question, 6)
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 [s, r, t] of edges) {
207
- if (!shownIds.has(s) || !shownIds.has(t)) continue
208
- const key = `${s}|${r}|${t}`
209
- if (edgeSeen.has(key)) continue
210
- edgeSeen.add(key)
211
- shownEdges.push([s, r, t])
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
  }