weavatrix 0.2.12 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -21
- package/SECURITY.md +2 -2
- package/docs/releases/v0.2.14.md +93 -0
- package/package.json +2 -2
- package/skill/SKILL.md +108 -43
- package/src/analysis/allowed-test-runner.js +20 -9
- package/src/analysis/architecture/contract-graph.js +119 -0
- package/src/analysis/architecture/contract-schema.js +110 -0
- package/src/analysis/architecture/contract-storage.js +35 -0
- package/src/analysis/architecture/contract-verification.js +168 -0
- package/src/analysis/architecture-contract.js +16 -343
- package/src/analysis/change-classification/diff-parser.js +103 -0
- package/src/analysis/change-classification/options.js +40 -0
- package/src/analysis/change-classification/symbol-classifier.js +177 -0
- package/src/analysis/change-classification.js +120 -519
- package/src/analysis/dead-code-review/policy.js +23 -0
- package/src/analysis/dead-code-review.js +9 -19
- package/src/analysis/dep-check.js +10 -57
- package/src/analysis/dep-rules.js +10 -303
- package/src/analysis/dependency/scoped-dependencies.js +40 -0
- package/src/analysis/endpoints/common.js +62 -0
- package/src/analysis/endpoints/extract.js +107 -0
- package/src/analysis/endpoints/inventory.js +84 -0
- package/src/analysis/endpoints/mounts.js +129 -0
- package/src/analysis/endpoints-java.js +80 -3
- package/src/analysis/endpoints.js +3 -448
- package/src/analysis/git-history/analytics.js +177 -0
- package/src/analysis/git-history/collector.js +109 -0
- package/src/analysis/git-history/options.js +68 -0
- package/src/analysis/git-history.js +128 -577
- package/src/analysis/health-capabilities.js +82 -0
- package/src/analysis/http-contracts/analysis.js +169 -0
- package/src/analysis/http-contracts/client-call-detection.js +81 -0
- package/src/analysis/http-contracts/client-call-parser.js +255 -0
- package/src/analysis/http-contracts/graph-context.js +143 -0
- package/src/analysis/http-contracts/matching.js +61 -0
- package/src/analysis/http-contracts/shared.js +86 -0
- package/src/analysis/http-contracts.js +6 -822
- package/src/analysis/internal-audit/dependency-health.js +111 -0
- package/src/analysis/internal-audit/python-manifests.js +65 -0
- package/src/analysis/internal-audit/repo-files.js +55 -0
- package/src/analysis/internal-audit/supply-chain.js +132 -0
- package/src/analysis/internal-audit.collect.js +5 -106
- package/src/analysis/internal-audit.run.js +113 -200
- package/src/analysis/jvm-dependency-evidence.js +69 -0
- package/src/analysis/source-correctness/retry-patterns.js +93 -0
- package/src/analysis/source-correctness.js +225 -0
- package/src/analysis/structure/dependency-graph.js +156 -0
- package/src/analysis/structure/findings.js +142 -0
- package/src/graph/builder/go-receiver-resolution.js +112 -0
- package/src/graph/builder/js/queries.js +48 -0
- package/src/graph/builder/lang-go.js +89 -4
- package/src/graph/builder/lang-js.js +4 -44
- package/src/graph/builder/pass2-resolution.js +68 -0
- package/src/graph/freshness-probe.js +2 -1
- package/src/graph/incremental-refresh.js +3 -2
- package/src/graph/internal-builder.build.js +22 -183
- package/src/graph/internal-builder.pass2.js +161 -0
- package/src/graph/internal-builder.resolvers.js +2 -105
- package/src/graph/resolvers/rust.js +117 -0
- package/src/mcp/actions/advisories.mjs +18 -0
- package/src/mcp/actions/graph-lifecycle.mjs +148 -0
- package/src/mcp/actions/graph-sync.mjs +195 -0
- package/src/mcp/actions/hosted-architecture.mjs +57 -0
- package/src/mcp/architecture-bootstrap.mjs +168 -0
- package/src/mcp/architecture-starter.mjs +234 -0
- package/src/mcp/catalog.mjs +22 -6
- package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
- package/src/mcp/evidence/package-graph-common.mjs +115 -0
- package/src/mcp/evidence/package-lock-graph.mjs +92 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
- package/src/mcp/graph/context-core.mjs +111 -0
- package/src/mcp/graph/context-seeds.mjs +208 -0
- package/src/mcp/graph/context-state.mjs +86 -0
- package/src/mcp/graph/tools-core.mjs +143 -0
- package/src/mcp/graph/tools-query.mjs +223 -0
- package/src/mcp/graph-context.mjs +4 -496
- package/src/mcp/health/audit-format.mjs +172 -0
- package/src/mcp/health/audit.mjs +171 -0
- package/src/mcp/health/dead-code.mjs +110 -0
- package/src/mcp/health/duplicates.mjs +83 -0
- package/src/mcp/health/endpoints.mjs +42 -0
- package/src/mcp/health/structure.mjs +219 -0
- package/src/mcp/runtime-version.mjs +32 -0
- package/src/mcp/server/auto-refresh.mjs +66 -0
- package/src/mcp/server/runtime-config.mjs +43 -0
- package/src/mcp/server/shutdown.mjs +40 -0
- package/src/mcp/sync/evidence-architecture.mjs +54 -0
- package/src/mcp/sync/evidence-common.mjs +88 -0
- package/src/mcp/sync/evidence-duplicates.mjs +100 -0
- package/src/mcp/sync/evidence-health.mjs +56 -0
- package/src/mcp/sync/evidence-packages.mjs +95 -0
- package/src/mcp/sync/payload-common.mjs +97 -0
- package/src/mcp/sync/payload-v2.mjs +53 -0
- package/src/mcp/sync/payload-v3.mjs +79 -0
- package/src/mcp/sync-evidence.mjs +7 -402
- package/src/mcp/sync-payload.mjs +10 -244
- package/src/mcp/tools-actions.mjs +18 -414
- package/src/mcp/tools-architecture.mjs +18 -70
- package/src/mcp/tools-context.mjs +56 -15
- package/src/mcp/tools-endpoints.mjs +4 -1
- package/src/mcp/tools-graph.mjs +17 -330
- package/src/mcp/tools-health.mjs +24 -705
- package/src/mcp/tools-verified-change.mjs +12 -4
- package/src/mcp-server.mjs +49 -146
- package/src/precision/lsp-client/constants.js +12 -0
- package/src/precision/lsp-client/environment.js +20 -0
- package/src/precision/lsp-client/errors.js +15 -0
- package/src/precision/lsp-client/lifecycle.js +127 -0
- package/src/precision/lsp-client/message-parser.js +81 -0
- package/src/precision/lsp-client/protocol.js +212 -0
- package/src/precision/lsp-client/registry.js +58 -0
- package/src/precision/lsp-client/repo-uri.js +60 -0
- package/src/precision/lsp-client/stdio-client.js +151 -0
- package/src/precision/lsp-client.js +10 -682
- package/src/precision/lsp-overlay/build.js +238 -0
- package/src/precision/lsp-overlay/contract.js +61 -0
- package/src/precision/lsp-overlay/reference-results.js +108 -0
- package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
- package/src/precision/lsp-overlay/source-session.js +134 -0
- package/src/precision/lsp-overlay/store.js +150 -0
- package/src/precision/lsp-overlay/target-index.js +147 -0
- package/src/precision/lsp-overlay/target-query.js +55 -0
- package/src/precision/lsp-overlay.js +11 -868
- package/src/precision/typescript-lsp-provider.js +12 -682
- package/src/precision/typescript-provider/client.js +69 -0
- package/src/precision/typescript-provider/discovery.js +124 -0
- package/src/precision/typescript-provider/project-host.js +249 -0
- package/src/precision/typescript-provider/project-safety.js +161 -0
- package/src/precision/typescript-provider/reference-usage.js +53 -0
- package/src/version.js +7 -0
- package/docs/releases/v0.2.12.md +0 -45
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isSymbol, degreeOf, labelOf, resolveNode, findSeeds, resolveSeedFiles,
|
|
3
|
+
undirectedNeighbors, requestedPathClasses,
|
|
4
|
+
} from '../graph-context.mjs'
|
|
5
|
+
import {createPathClassifier, hasPathClass} from '../../path-classification.js'
|
|
6
|
+
|
|
7
|
+
const QUERY_NON_PRODUCT = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
|
|
8
|
+
const LOW_SIGNAL_SYMBOL_RE = /^(?:const(?:ant)?|variable|property|field|enum_member)$/i
|
|
9
|
+
const querySourceFile = (node) => String(node?.source_file || String(node?.id || '').split('#', 1)[0]).replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
|
|
10
|
+
const queryWords = (value) => new Set(String(value || '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean))
|
|
11
|
+
const exactSymbolName = (node) => {
|
|
12
|
+
const match = String(node?.id || '').match(/#([^@]+)@/)
|
|
13
|
+
return match ? match[1] : String(node?.label || '').replace(/\(\)$/, '')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolveExactSeedSymbols(g, requested, limit = 12) {
|
|
17
|
+
const values = Array.isArray(requested) ? requested.slice(0, limit) : []
|
|
18
|
+
const seeds = []
|
|
19
|
+
const missing = []
|
|
20
|
+
const ambiguous = []
|
|
21
|
+
for (const raw of values) {
|
|
22
|
+
const wanted = String(raw || '').trim()
|
|
23
|
+
if (!wanted) continue
|
|
24
|
+
let matches = []
|
|
25
|
+
const byId = g.byId.get(wanted)
|
|
26
|
+
if (byId && isSymbol(byId.id)) matches = [byId]
|
|
27
|
+
else matches = g.nodes.filter((node) => isSymbol(node.id)
|
|
28
|
+
&& (String(node.label || '') === wanted || exactSymbolName(node) === wanted))
|
|
29
|
+
if (matches.length === 1) {
|
|
30
|
+
if (!seeds.some((seed) => String(seed.id) === String(matches[0].id))) seeds.push(matches[0])
|
|
31
|
+
} else if (matches.length > 1) ambiguous.push(`${wanted} (${matches.length} exact matches; pass a symbol id)`)
|
|
32
|
+
else missing.push(wanted)
|
|
33
|
+
}
|
|
34
|
+
return {seeds, missing, ambiguous}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const relationSet = (relationFilter, legacyFilter) => {
|
|
38
|
+
const raw = relationFilter ?? legacyFilter
|
|
39
|
+
const values = Array.isArray(raw) ? raw : (raw == null ? [] : String(raw).split(','))
|
|
40
|
+
const normalized = values.map((value) => String(value).trim().toLowerCase()).filter(Boolean)
|
|
41
|
+
return normalized.length ? new Set(normalized) : null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function tQueryGraph(g, {
|
|
45
|
+
question, mode = 'bfs', depth = 3, context_filter, relation_filter, seed_files, seed_symbols, augment_seeds = false,
|
|
46
|
+
flow_direction = 'both', include_classified = false, include_low_signal = false, token_budget = 2000,
|
|
47
|
+
} = {}, toolCtx = {}) {
|
|
48
|
+
const pinned = resolveSeedFiles(g, seed_files)
|
|
49
|
+
const exactSymbols = resolveExactSeedSymbols(g, seed_symbols)
|
|
50
|
+
const pinnedSeeds = [...pinned.seeds, ...exactSymbols.seeds.filter((node) => !pinned.seeds.some((seed) => String(seed.id) === String(node.id)))]
|
|
51
|
+
const automatic = pinnedSeeds.length && augment_seeds !== true
|
|
52
|
+
? []
|
|
53
|
+
: findSeeds(g, question, Math.max(0, 6 - pinnedSeeds.length), {repoRoot: toolCtx.repoRoot || null})
|
|
54
|
+
const seeds = [...pinnedSeeds, ...automatic.filter((node) => !pinnedSeeds.some((seed) => String(seed.id) === String(node.id)))]
|
|
55
|
+
if (!seeds.length) {
|
|
56
|
+
const details = [
|
|
57
|
+
pinned.missing.length ? `Unresolved seed files: ${pinned.missing.join(', ')}` : null,
|
|
58
|
+
exactSymbols.missing.length ? `Unresolved exact symbols: ${exactSymbols.missing.join(', ')}` : null,
|
|
59
|
+
exactSymbols.ambiguous.length ? `Ambiguous exact symbols: ${exactSymbols.ambiguous.join(', ')}` : null,
|
|
60
|
+
].filter(Boolean)
|
|
61
|
+
return [`No nodes matched "${question || ''}".`, ...details].join('\n')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const maxDepth = Math.max(1, Math.min(6, Number(depth) || 3))
|
|
65
|
+
const relations = relationSet(relation_filter, context_filter)
|
|
66
|
+
const relationAllowed = (relation) => !relations || relations.has(String(relation ?? '').toLowerCase())
|
|
67
|
+
const direction = ['forward', 'backward', 'both'].includes(String(flow_direction).toLowerCase())
|
|
68
|
+
? String(flow_direction).toLowerCase() : 'both'
|
|
69
|
+
const neighbors = (id) => {
|
|
70
|
+
const found = new Map()
|
|
71
|
+
const add = (edges) => {
|
|
72
|
+
for (const edge of edges || []) {
|
|
73
|
+
const neighbor = String(edge.id)
|
|
74
|
+
if (edge.barrelProxy === true || !relationAllowed(edge.relation) || found.has(neighbor)) continue
|
|
75
|
+
found.set(neighbor, edge.relation)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (direction !== 'backward') add(g.out.get(id))
|
|
79
|
+
if (direction !== 'forward') add(g.inn.get(id))
|
|
80
|
+
return found
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const requestedClasses = requestedPathClasses(question)
|
|
84
|
+
const classifier = createPathClassifier(toolCtx.repoRoot || null)
|
|
85
|
+
const classificationCache = new Map()
|
|
86
|
+
const pinnedFiles = new Set(pinnedSeeds.map(querySourceFile))
|
|
87
|
+
const classifiedSuppressed = new Set()
|
|
88
|
+
const pathPolicy = (id) => {
|
|
89
|
+
const node = g.byId.get(String(id))
|
|
90
|
+
const file = querySourceFile(node)
|
|
91
|
+
if (!file || pinnedFiles.has(file) || include_classified === true) return {ok: true}
|
|
92
|
+
if (!classificationCache.has(file)) classificationCache.set(file, classifier.explain(file, {content: ''}))
|
|
93
|
+
const info = classificationCache.get(file)
|
|
94
|
+
const classes = QUERY_NON_PRODUCT.filter((name) => hasPathClass(info, name))
|
|
95
|
+
if (!classes.length && !info?.excluded) return {ok: true}
|
|
96
|
+
if (classes.some((name) => requestedClasses.has(name))) return {ok: true}
|
|
97
|
+
classifiedSuppressed.add(String(id))
|
|
98
|
+
return {ok: false, bucket: 'classified'}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const questionTerms = queryWords(question)
|
|
102
|
+
const start = seeds.map((seed) => String(seed.id))
|
|
103
|
+
const isLowSignal = (id) => {
|
|
104
|
+
if (include_low_signal === true || start.includes(String(id))) return false
|
|
105
|
+
const node = g.byId.get(String(id))
|
|
106
|
+
if (!node || !isSymbol(node.id) || !LOW_SIGNAL_SYMBOL_RE.test(String(node.symbol_kind || ''))) return false
|
|
107
|
+
const labelTerms = queryWords(node.label || String(node.id || '').split('#').pop() || '')
|
|
108
|
+
if ([...questionTerms].some((term) => labelTerms.has(term))) return false
|
|
109
|
+
return degreeOf(g, id) === 0
|
|
110
|
+
}
|
|
111
|
+
const charBudget = Math.max(400, (Number(token_budget) || 2000) * 4)
|
|
112
|
+
const nodeBudget = Math.max(20, Math.min(120, Math.round((Number(token_budget) || 2000) / 40)))
|
|
113
|
+
const depthOf = new Map()
|
|
114
|
+
|
|
115
|
+
if (mode === 'dfs') {
|
|
116
|
+
const stack = start.map((id) => ({id, depth: 0}))
|
|
117
|
+
const seen = new Set()
|
|
118
|
+
while (stack.length) {
|
|
119
|
+
const {id, depth: currentDepth} = stack.pop()
|
|
120
|
+
if (!depthOf.has(id) || currentDepth < depthOf.get(id)) depthOf.set(id, currentDepth)
|
|
121
|
+
if (seen.has(id)) continue
|
|
122
|
+
seen.add(id)
|
|
123
|
+
if (currentDepth >= maxDepth) continue
|
|
124
|
+
for (const [neighbor, relation] of neighbors(id)) {
|
|
125
|
+
if (!relationAllowed(relation) || !pathPolicy(neighbor).ok) continue
|
|
126
|
+
if (!seen.has(neighbor)) stack.push({id: neighbor, depth: currentDepth + 1})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
let frontier = start.slice()
|
|
131
|
+
start.forEach((id) => depthOf.set(id, 0))
|
|
132
|
+
for (let currentDepth = 0; currentDepth < maxDepth && frontier.length; currentDepth++) {
|
|
133
|
+
const next = []
|
|
134
|
+
for (const id of frontier) {
|
|
135
|
+
for (const [neighbor, relation] of neighbors(id)) {
|
|
136
|
+
if (!relationAllowed(relation) || !pathPolicy(neighbor).ok) continue
|
|
137
|
+
if (!depthOf.has(neighbor)) {
|
|
138
|
+
depthOf.set(neighbor, currentDepth + 1)
|
|
139
|
+
next.push(neighbor)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
frontier = next
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const reachedBeforeSignalFilter = depthOf.size
|
|
148
|
+
const lowSignalSuppressed = [...depthOf.keys()].filter(isLowSignal).length
|
|
149
|
+
const ranked = [...depthOf.entries()]
|
|
150
|
+
.filter(([id]) => !isLowSignal(id))
|
|
151
|
+
.map(([id, distance]) => ({id, distance, degree: degreeOf(g, id)}))
|
|
152
|
+
.sort((a, b) => a.distance - b.distance || b.degree - a.degree)
|
|
153
|
+
const shown = ranked.slice(0, nodeBudget)
|
|
154
|
+
const shownIds = new Set(shown.map((node) => node.id))
|
|
155
|
+
const edgeSeen = new Set()
|
|
156
|
+
const shownEdges = []
|
|
157
|
+
for (const source of shownIds) {
|
|
158
|
+
for (const edge of g.out.get(source) || []) {
|
|
159
|
+
const target = String(edge.id)
|
|
160
|
+
if (edge.barrelProxy === true || !shownIds.has(target) || !relationAllowed(edge.relation)) continue
|
|
161
|
+
const key = `${source}|${edge.relation}|${target}`
|
|
162
|
+
if (edgeSeen.has(key)) continue
|
|
163
|
+
edgeSeen.add(key)
|
|
164
|
+
shownEdges.push([source, edge.relation, target])
|
|
165
|
+
if (shownEdges.length >= 160) break
|
|
166
|
+
}
|
|
167
|
+
if (shownEdges.length >= 160) break
|
|
168
|
+
}
|
|
169
|
+
const head = [
|
|
170
|
+
`Query: "${question || ''}" (${mode}, depth ${maxDepth}, flow ${direction}${relations ? `, relations ${[...relations].join('/')}` : ''})`,
|
|
171
|
+
`Seeds: ${seeds.map((seed) => seed.label ?? seed.id).join(', ')}`,
|
|
172
|
+
pinned.missing.length ? `Unresolved pinned seed files: ${pinned.missing.join(', ')}` : null,
|
|
173
|
+
exactSymbols.missing.length ? `Unresolved exact seed symbols: ${exactSymbols.missing.join(', ')}` : null,
|
|
174
|
+
exactSymbols.ambiguous.length ? `Ambiguous exact seed symbols: ${exactSymbols.ambiguous.join(', ')}` : null,
|
|
175
|
+
`Reached ${reachedBeforeSignalFilter} policy-eligible nodes; showing ${shown.length} closest by proximity + connectivity, ${shownEdges.length} edges among them.`,
|
|
176
|
+
classifiedSuppressed.size ? `Suppressed ${classifiedSuppressed.size} classified/non-product traversal node(s); ask for that class or pass include_classified:true.` : null,
|
|
177
|
+
lowSignalSuppressed ? `Suppressed ${lowSignalSuppressed} unreferenced constant/field node(s) with no query-term match; pass include_low_signal:true to inspect them.` : null,
|
|
178
|
+
include_classified === true ? 'Path policy: classified/non-product traversal explicitly enabled.' : `Path policy: production-first${requestedClasses.size ? `; explicit question classes enabled: ${[...requestedClasses].join(', ')}` : ''}.`,
|
|
179
|
+
'',
|
|
180
|
+
'Nodes:',
|
|
181
|
+
]
|
|
182
|
+
const nodeLines = shown.map((node) => ` [d${node.distance}] ${labelOf(g, node.id)} (deg ${node.degree}) [${node.id}]`)
|
|
183
|
+
const edgeLines = ['', 'Edges:', ...shownEdges.map(([source, relation, target]) => ` ${labelOf(g, source)} --${relation || 'rel'}--> ${labelOf(g, target)}`)]
|
|
184
|
+
let text = [...head.filter(Boolean), ...nodeLines, ...edgeLines].join('\n')
|
|
185
|
+
if (text.length > charBudget) text = text.slice(0, charBudget) + `\n... (truncated to ~${token_budget} tokens)`
|
|
186
|
+
return text
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function tShortestPath(g, {source, target, max_hops = 8} = {}) {
|
|
190
|
+
const sourceNode = resolveNode(g, source)
|
|
191
|
+
const targetNode = resolveNode(g, target)
|
|
192
|
+
if (!sourceNode) return `Source "${source}" not found.`
|
|
193
|
+
if (!targetNode) return `Target "${target}" not found.`
|
|
194
|
+
const sourceId = String(sourceNode.id)
|
|
195
|
+
const targetId = String(targetNode.id)
|
|
196
|
+
if (sourceId === targetId) return `Source and target are the same node: ${sourceNode.label ?? sourceId}.`
|
|
197
|
+
const limit = Math.max(1, Math.min(20, Number(max_hops) || 8))
|
|
198
|
+
const previous = new Map([[sourceId, null]])
|
|
199
|
+
const relationTo = new Map()
|
|
200
|
+
let frontier = [sourceId]
|
|
201
|
+
let hops = 0
|
|
202
|
+
let found = false
|
|
203
|
+
while (frontier.length && hops < limit && !found) {
|
|
204
|
+
const next = []
|
|
205
|
+
for (const id of frontier) {
|
|
206
|
+
for (const [neighbor, relation] of undirectedNeighbors(g, id)) {
|
|
207
|
+
if (previous.has(neighbor)) continue
|
|
208
|
+
previous.set(neighbor, id)
|
|
209
|
+
relationTo.set(neighbor, relation)
|
|
210
|
+
if (neighbor === targetId) { found = true; break }
|
|
211
|
+
next.push(neighbor)
|
|
212
|
+
}
|
|
213
|
+
if (found) break
|
|
214
|
+
}
|
|
215
|
+
frontier = next
|
|
216
|
+
hops++
|
|
217
|
+
}
|
|
218
|
+
if (!previous.has(targetId)) return `No path found between "${sourceNode.label ?? sourceId}" and "${targetNode.label ?? targetId}" within ${limit} hops.`
|
|
219
|
+
const path = []
|
|
220
|
+
for (let current = targetId; current != null; current = previous.get(current)) path.unshift(current)
|
|
221
|
+
const lines = path.map((id, index) => index === 0 ? ` ${labelOf(g, id)}` : ` --${relationTo.get(id) || 'rel'}--> ${labelOf(g, id)}`)
|
|
222
|
+
return [`Shortest path (${path.length - 1} hops): ${sourceNode.label ?? sourceId} → ${targetNode.label ?? targetId}`, ...lines].join('\n')
|
|
223
|
+
}
|