weavatrix 0.2.13 → 0.2.15
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 +99 -39
- package/SECURITY.md +2 -2
- package/docs/releases/v0.2.15.md +37 -0
- package/package.json +2 -2
- package/skill/SKILL.md +57 -38
- 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 +14 -71
- package/src/analysis/dep-rules.js +10 -303
- package/src/analysis/dependency/conventions.js +16 -0
- package/src/analysis/dependency/scoped-dependencies.js +45 -0
- package/src/analysis/dependency/source-references.js +21 -0
- package/src/analysis/duplicates.tokenize.js +12 -2
- 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.reach.js +20 -0
- package/src/analysis/internal-audit.run.js +114 -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/analysis/task-retrieval.js +3 -14
- 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 +20 -6
- package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
- package/src/mcp/evidence/duplicate-member-order.mjs +8 -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.duplicates.mjs +3 -7
- package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
- package/src/mcp/git-output.mjs +10 -0
- package/src/mcp/graph/context-core.mjs +111 -0
- package/src/mcp/graph/context-seeds.mjs +197 -0
- package/src/mcp/graph/context-state.mjs +86 -0
- package/src/mcp/graph/reverse-reach.mjs +42 -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 +97 -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 -331
- package/src/mcp/tools-health.mjs +24 -705
- package/src/mcp/tools-impact-change.mjs +2 -38
- package/src/mcp/tools-impact.mjs +2 -43
- package/src/mcp-server.mjs +35 -146
- package/src/path-classification.js +14 -0
- 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/security/malware-heuristics.sweep.js +1 -1
- package/src/security/registry-sig.classify.js +2 -1
- package/src/security/registry-sig.rules.js +3 -3
- package/src/version.js +7 -0
- package/docs/releases/v0.2.13.md +0 -48
|
@@ -1,480 +1,11 @@
|
|
|
1
|
-
import {createHash} from 'node:crypto'
|
|
2
1
|
import {readFileSync, statSync} from 'node:fs'
|
|
3
2
|
import {createRepoBoundary} from '../repo-path.js'
|
|
4
|
-
import {
|
|
3
|
+
import {STATE} from './evidence-snapshot.common.mjs'
|
|
4
|
+
import {parseBunLock, parseJsonc} from './evidence/bun-lock-graph.mjs'
|
|
5
|
+
import {MAX_LOCKFILE_BYTES, emptyGraph} from './evidence/package-graph-common.mjs'
|
|
6
|
+
import {parsePackageLock} from './evidence/package-lock-graph.mjs'
|
|
5
7
|
|
|
6
8
|
const LOCKFILES = ['npm-shrinkwrap.json', 'package-lock.json', 'bun.lock']
|
|
7
|
-
const MAX_LOCKFILE_BYTES = 64 * 1024 * 1024
|
|
8
|
-
const MAX_PACKAGE_RECORDS = 50_000
|
|
9
|
-
const MAX_DEPENDENCY_DECLARATIONS = 200_000
|
|
10
|
-
const PACKAGE_NAME = /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/i
|
|
11
|
-
const PACKAGE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+~-]*$/
|
|
12
|
-
|
|
13
|
-
const packageVersion = (value) => {
|
|
14
|
-
const version = safeToken(value, 128)
|
|
15
|
-
return version && PACKAGE_VERSION.test(version) ? version : null
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const emptyCount = () => ({total: 0, returned: 0, truncated: false})
|
|
19
|
-
const emptyDeclarations = () => ({total: 0, resolved: 0, unresolved: 0, local: 0, optionalMissing: 0})
|
|
20
|
-
|
|
21
|
-
function emptyGraph(state, reason, extras = {}) {
|
|
22
|
-
return {
|
|
23
|
-
state,
|
|
24
|
-
ecosystem: 'npm',
|
|
25
|
-
root: '(root)',
|
|
26
|
-
...extras,
|
|
27
|
-
completeness: {
|
|
28
|
-
nodes: emptyCount(),
|
|
29
|
-
edges: emptyCount(),
|
|
30
|
-
declarations: emptyDeclarations(),
|
|
31
|
-
reasons: reason ? [reason] : [],
|
|
32
|
-
},
|
|
33
|
-
nodes: [],
|
|
34
|
-
edges: [],
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function normalizeLockPath(value) {
|
|
39
|
-
if (typeof value !== 'string' || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value)) return null
|
|
40
|
-
const raw = value.replace(/\\/g, '/')
|
|
41
|
-
if (/^(?:[a-z][a-z0-9+.-]*:|\/)/i.test(raw)) return null
|
|
42
|
-
const normalized = raw.replace(/\/$/, '')
|
|
43
|
-
if (normalized === '') return ''
|
|
44
|
-
const parts = normalized.split('/')
|
|
45
|
-
return parts.every((part) => part && part !== '.' && part !== '..') ? normalized : null
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function packageNameFromPath(packagePath) {
|
|
49
|
-
const match = packagePath.match(/(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)$/)
|
|
50
|
-
const name = match?.[1]
|
|
51
|
-
return name && name.length <= 256 && PACKAGE_NAME.test(name) ? name : null
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function packageId(name, version, packagePath) {
|
|
55
|
-
const location = createHash('sha256').update(packagePath).digest('hex').slice(0, 12)
|
|
56
|
-
return `npm:${name}@${version}:${location}`
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function parentInstallPath(packagePath) {
|
|
60
|
-
const marker = packagePath.lastIndexOf('/node_modules/')
|
|
61
|
-
return marker >= 0 ? packagePath.slice(0, marker) : ''
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function dependencyDeclarations(record) {
|
|
65
|
-
const declarations = new Map()
|
|
66
|
-
const add = (values, kind, replace = false) => {
|
|
67
|
-
if (!values || typeof values !== 'object' || Array.isArray(values)) return
|
|
68
|
-
for (const name of Object.keys(values)) {
|
|
69
|
-
if (name.length > 256 || !PACKAGE_NAME.test(name)) continue
|
|
70
|
-
if (replace || !declarations.has(name)) declarations.set(name, kind)
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
add(record?.dependencies, 'runtime')
|
|
74
|
-
add(record?.devDependencies, 'dev')
|
|
75
|
-
add(record?.optionalDependencies, 'optional', true)
|
|
76
|
-
if (record?.peerDependencies && typeof record.peerDependencies === 'object' && !Array.isArray(record.peerDependencies)) {
|
|
77
|
-
for (const name of Object.keys(record.peerDependencies)) {
|
|
78
|
-
if (name.length > 256 || !PACKAGE_NAME.test(name) || declarations.has(name)) continue
|
|
79
|
-
const optional = record?.peerDependenciesMeta?.[name]?.optional === true
|
|
80
|
-
declarations.set(name, optional ? 'optional-peer' : 'peer')
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return [...declarations].map(([name, kind]) => ({name, kind}))
|
|
84
|
-
.sort((a, b) => compareText(a.name, b.name) || compareText(a.kind, b.kind))
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function resolveDependency(sourcePath, name, records, externalNodes) {
|
|
88
|
-
let cursor = sourcePath
|
|
89
|
-
const seen = new Set()
|
|
90
|
-
while (!seen.has(cursor)) {
|
|
91
|
-
seen.add(cursor)
|
|
92
|
-
const candidate = cursor ? `${cursor}/node_modules/${name}` : `node_modules/${name}`
|
|
93
|
-
const record = records.get(candidate)
|
|
94
|
-
if (record) {
|
|
95
|
-
if (record.link === true) return {kind: 'local'}
|
|
96
|
-
const node = externalNodes.get(candidate)
|
|
97
|
-
return node ? {kind: 'external', node} : {kind: 'unresolved'}
|
|
98
|
-
}
|
|
99
|
-
if (!cursor) break
|
|
100
|
-
cursor = parentInstallPath(cursor)
|
|
101
|
-
}
|
|
102
|
-
return {kind: 'unresolved'}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function parseLock(lock, lockfile) {
|
|
106
|
-
const lockfileVersion = Number(lock?.lockfileVersion || 0)
|
|
107
|
-
const extras = {lockfile, lockfileVersion: Number.isFinite(lockfileVersion) ? Math.trunc(lockfileVersion) : 0}
|
|
108
|
-
if (![2, 3].includes(lockfileVersion) || !lock?.packages || typeof lock.packages !== 'object' || Array.isArray(lock.packages)) {
|
|
109
|
-
return emptyGraph(STATE.PARTIAL, 'PACKAGE_LOCK_V2_V3_REQUIRED', extras)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const allKeys = Object.keys(lock.packages).sort(compareText)
|
|
113
|
-
const selectedKeys = allKeys.slice(0, MAX_PACKAGE_RECORDS)
|
|
114
|
-
const reasons = []
|
|
115
|
-
if (selectedKeys.length < allKeys.length) reasons.push('LOCKFILE_PACKAGE_RECORD_LIMIT_REACHED')
|
|
116
|
-
|
|
117
|
-
const records = new Map()
|
|
118
|
-
let invalidRecords = 0
|
|
119
|
-
for (const rawPath of selectedKeys) {
|
|
120
|
-
const packagePath = normalizeLockPath(rawPath)
|
|
121
|
-
const record = lock.packages[rawPath]
|
|
122
|
-
if (packagePath == null || !record || typeof record !== 'object' || Array.isArray(record)) {
|
|
123
|
-
invalidRecords++
|
|
124
|
-
continue
|
|
125
|
-
}
|
|
126
|
-
const insideNodeModules = packagePath.startsWith('node_modules/') || packagePath.includes('/node_modules/')
|
|
127
|
-
if (insideNodeModules && !packageNameFromPath(packagePath)) {
|
|
128
|
-
invalidRecords++
|
|
129
|
-
continue
|
|
130
|
-
}
|
|
131
|
-
if (records.has(packagePath)) {
|
|
132
|
-
invalidRecords++
|
|
133
|
-
continue
|
|
134
|
-
}
|
|
135
|
-
records.set(packagePath, record)
|
|
136
|
-
}
|
|
137
|
-
if (invalidRecords > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_RECORDS')
|
|
138
|
-
|
|
139
|
-
const externalNodes = new Map()
|
|
140
|
-
let invalidPackageVersions = 0
|
|
141
|
-
for (const [packagePath, record] of records) {
|
|
142
|
-
const name = packageNameFromPath(packagePath)
|
|
143
|
-
const version = packageVersion(record.version)
|
|
144
|
-
if (!name || record.link === true) continue
|
|
145
|
-
if (!version) { invalidPackageVersions++; continue }
|
|
146
|
-
externalNodes.set(packagePath, {
|
|
147
|
-
id: packageId(name, version, packagePath),
|
|
148
|
-
name,
|
|
149
|
-
version,
|
|
150
|
-
direct: false,
|
|
151
|
-
dev: record.dev === true || record.devOptional === true,
|
|
152
|
-
optional: record.optional === true || record.devOptional === true,
|
|
153
|
-
peer: record.peer === true,
|
|
154
|
-
})
|
|
155
|
-
}
|
|
156
|
-
if (invalidPackageVersions > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_VERSIONS')
|
|
157
|
-
|
|
158
|
-
const edgeMap = new Map()
|
|
159
|
-
const declarations = emptyDeclarations()
|
|
160
|
-
let declarationLimitReached = false
|
|
161
|
-
const sources = [...records].filter(([packagePath]) =>
|
|
162
|
-
packageNameFromPath(packagePath) == null || externalNodes.has(packagePath))
|
|
163
|
-
.sort(([a], [b]) => compareText(a, b))
|
|
164
|
-
|
|
165
|
-
outer: for (const [sourcePath, record] of sources) {
|
|
166
|
-
const sourceNode = externalNodes.get(sourcePath)
|
|
167
|
-
const sourceId = sourceNode?.id || '(root)'
|
|
168
|
-
for (const declaration of dependencyDeclarations(record)) {
|
|
169
|
-
if (declarations.total >= MAX_DEPENDENCY_DECLARATIONS) {
|
|
170
|
-
declarationLimitReached = true
|
|
171
|
-
break outer
|
|
172
|
-
}
|
|
173
|
-
declarations.total++
|
|
174
|
-
const resolved = resolveDependency(sourcePath, declaration.name, records, externalNodes)
|
|
175
|
-
if (resolved.kind === 'local') {
|
|
176
|
-
declarations.local++
|
|
177
|
-
continue
|
|
178
|
-
}
|
|
179
|
-
if (resolved.kind !== 'external') {
|
|
180
|
-
if (declaration.kind === 'optional' || declaration.kind === 'optional-peer') declarations.optionalMissing++
|
|
181
|
-
else declarations.unresolved++
|
|
182
|
-
continue
|
|
183
|
-
}
|
|
184
|
-
declarations.resolved++
|
|
185
|
-
if (sourceId === '(root)') resolved.node.direct = true
|
|
186
|
-
if (sourceId === resolved.node.id) continue
|
|
187
|
-
const edge = {from: sourceId, to: resolved.node.id, kind: declaration.kind}
|
|
188
|
-
edgeMap.set(`${edge.from}\0${edge.to}\0${edge.kind}`, edge)
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
if (declarationLimitReached) reasons.push('LOCKFILE_DEPENDENCY_DECLARATION_LIMIT_REACHED')
|
|
192
|
-
if (declarations.unresolved > 0) reasons.push('UNRESOLVED_LOCKFILE_DEPENDENCIES')
|
|
193
|
-
|
|
194
|
-
return finalizeGraph({externalNodes, edgeMap, declarations, reasons, lockfile, lockfileVersion})
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function finalizeGraph({externalNodes, edgeMap, declarations, reasons, lockfile, lockfileVersion}) {
|
|
198
|
-
const allNodes = [...externalNodes.values()].sort((a, b) =>
|
|
199
|
-
Number(b.direct) - Number(a.direct) || compareText(a.name, b.name) ||
|
|
200
|
-
compareText(a.version, b.version) || compareText(a.id, b.id))
|
|
201
|
-
const nodes = allNodes.slice(0, CAPS.packageGraphNodes)
|
|
202
|
-
const nodeIds = new Set(nodes.map((node) => node.id))
|
|
203
|
-
const allEdges = [...edgeMap.values()].sort((a, b) =>
|
|
204
|
-
compareText(a.from, b.from) || compareText(a.to, b.to) || compareText(a.kind, b.kind))
|
|
205
|
-
const eligibleEdges = allEdges.filter((edge) =>
|
|
206
|
-
(edge.from === '(root)' || nodeIds.has(edge.from)) && nodeIds.has(edge.to))
|
|
207
|
-
const edges = eligibleEdges.slice(0, CAPS.packageGraphEdges)
|
|
208
|
-
const nodesTruncated = nodes.length < allNodes.length
|
|
209
|
-
const edgesTruncated = edges.length < allEdges.length
|
|
210
|
-
if (nodesTruncated) reasons.push('PACKAGE_NODE_LIMIT_REACHED')
|
|
211
|
-
if (edgesTruncated) reasons.push('PACKAGE_EDGE_LIMIT_REACHED')
|
|
212
|
-
|
|
213
|
-
return {
|
|
214
|
-
state: reasons.length ? STATE.PARTIAL : STATE.COMPLETE,
|
|
215
|
-
ecosystem: 'npm',
|
|
216
|
-
lockfile,
|
|
217
|
-
lockfileVersion,
|
|
218
|
-
root: '(root)',
|
|
219
|
-
completeness: {
|
|
220
|
-
nodes: {total: allNodes.length, returned: nodes.length, truncated: nodesTruncated},
|
|
221
|
-
edges: {total: allEdges.length, returned: edges.length, truncated: edgesTruncated},
|
|
222
|
-
declarations,
|
|
223
|
-
reasons: [...new Set(reasons)].sort(compareText),
|
|
224
|
-
},
|
|
225
|
-
nodes,
|
|
226
|
-
edges,
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Bun >= 1.2 writes a text lockfile (bun.lock) as JSONC: JSON plus comments and
|
|
231
|
-
// trailing commas. parseJsonc strips both (outside of strings) with no new deps.
|
|
232
|
-
function parseJsonc(text) {
|
|
233
|
-
const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
|
234
|
-
const out = []
|
|
235
|
-
let i = 0
|
|
236
|
-
const length = source.length
|
|
237
|
-
const skipComment = (index) => {
|
|
238
|
-
if (source[index] !== '/') return index
|
|
239
|
-
if (source[index + 1] === '/') {
|
|
240
|
-
let cursor = index + 2
|
|
241
|
-
while (cursor < length && source[cursor] !== '\n') cursor++
|
|
242
|
-
return cursor
|
|
243
|
-
}
|
|
244
|
-
if (source[index + 1] === '*') {
|
|
245
|
-
let cursor = index + 2
|
|
246
|
-
while (cursor < length && !(source[cursor] === '*' && source[cursor + 1] === '/')) cursor++
|
|
247
|
-
return Math.min(cursor + 2, length)
|
|
248
|
-
}
|
|
249
|
-
return index
|
|
250
|
-
}
|
|
251
|
-
while (i < length) {
|
|
252
|
-
const char = source[i]
|
|
253
|
-
if (char === '"') {
|
|
254
|
-
const start = i
|
|
255
|
-
i++
|
|
256
|
-
while (i < length && source[i] !== '"') i += source[i] === '\\' ? 2 : 1
|
|
257
|
-
i = Math.min(i + 1, length)
|
|
258
|
-
out.push(source.slice(start, i))
|
|
259
|
-
continue
|
|
260
|
-
}
|
|
261
|
-
if (char === '/') {
|
|
262
|
-
const next = skipComment(i)
|
|
263
|
-
if (next !== i) { i = next; continue }
|
|
264
|
-
}
|
|
265
|
-
if (char === ',') {
|
|
266
|
-
let ahead = i + 1
|
|
267
|
-
while (ahead < length) {
|
|
268
|
-
if (/\s/.test(source[ahead])) { ahead++; continue }
|
|
269
|
-
const next = skipComment(ahead)
|
|
270
|
-
if (next !== ahead) { ahead = next; continue }
|
|
271
|
-
break
|
|
272
|
-
}
|
|
273
|
-
if (ahead < length && (source[ahead] === '}' || source[ahead] === ']')) { i++; continue }
|
|
274
|
-
}
|
|
275
|
-
out.push(char)
|
|
276
|
-
i++
|
|
277
|
-
}
|
|
278
|
-
return JSON.parse(out.join(''))
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// bun.lock package keys are slash-joined chains of package names describing the
|
|
282
|
-
// hoisted install position, e.g. "make-dir/semver" (semver as resolved for
|
|
283
|
-
// make-dir). Scoped names keep their own slash: "@scope/pkg/dep".
|
|
284
|
-
function bunKeySegments(key) {
|
|
285
|
-
if (typeof key !== 'string' || key.length === 0 || key.length > 4096 || /[\u0000-\u001f\u007f]/.test(key)) return null
|
|
286
|
-
const parts = key.split('/')
|
|
287
|
-
const segments = []
|
|
288
|
-
for (let index = 0; index < parts.length; index++) {
|
|
289
|
-
let segment = parts[index]
|
|
290
|
-
if (segment.startsWith('@')) {
|
|
291
|
-
if (index + 1 >= parts.length) return null
|
|
292
|
-
segment = `${segment}/${parts[++index]}`
|
|
293
|
-
}
|
|
294
|
-
if (segment.length > 256 || !PACKAGE_NAME.test(segment)) return null
|
|
295
|
-
segments.push(segment)
|
|
296
|
-
}
|
|
297
|
-
return segments
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Entry shape: "key": ["name@version", registry, {dependencies, peerDependencies,
|
|
301
|
-
// optionalPeers, ...}, integrity]. Workspace members resolve to "name@workspace:path".
|
|
302
|
-
function bunPackageRecord(entry) {
|
|
303
|
-
if (!Array.isArray(entry) || typeof entry[0] !== 'string' || entry[0].length > 1024) return null
|
|
304
|
-
const at = entry[0].lastIndexOf('@')
|
|
305
|
-
if (at <= 0) return null
|
|
306
|
-
const name = entry[0].slice(0, at)
|
|
307
|
-
if (name.length > 256 || !PACKAGE_NAME.test(name)) return null
|
|
308
|
-
const rawVersion = entry[0].slice(at + 1)
|
|
309
|
-
const meta = entry.slice(1).find((value) => value && typeof value === 'object' && !Array.isArray(value)) || {}
|
|
310
|
-
return {name, rawVersion, workspace: rawVersion.startsWith('workspace:'), meta}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Adapts a bun dependency map ({dependencies, devDependencies, optionalDependencies,
|
|
314
|
-
// peerDependencies, optionalPeers: [names]}) to the npm record shape consumed by
|
|
315
|
-
// dependencyDeclarations, so declaration kinds and precedence stay identical.
|
|
316
|
-
function bunDeclarationRecord(meta) {
|
|
317
|
-
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return {}
|
|
318
|
-
const peerDependenciesMeta = {}
|
|
319
|
-
if (Array.isArray(meta.optionalPeers)) {
|
|
320
|
-
for (const name of meta.optionalPeers) {
|
|
321
|
-
if (typeof name === 'string') peerDependenciesMeta[name] = {optional: true}
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
return {
|
|
325
|
-
dependencies: meta.dependencies,
|
|
326
|
-
devDependencies: meta.devDependencies,
|
|
327
|
-
optionalDependencies: meta.optionalDependencies,
|
|
328
|
-
peerDependencies: meta.peerDependencies,
|
|
329
|
-
peerDependenciesMeta,
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Mirrors bun resolution: the most specific override key wins ("a/b/name"),
|
|
334
|
-
// falling back segment by segment to the hoisted top-level key ("name").
|
|
335
|
-
function resolveBunDependency(sourceSegments, name, records) {
|
|
336
|
-
for (let depth = sourceSegments.length; depth >= 0; depth--) {
|
|
337
|
-
const key = [...sourceSegments.slice(0, depth), name].join('/')
|
|
338
|
-
const record = records.get(key)
|
|
339
|
-
if (record) return {key, record}
|
|
340
|
-
}
|
|
341
|
-
return null
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// bun.lock does not persist npm's per-package dev/optional/peer flags, so they are
|
|
345
|
-
// approximated from graph reachability the same way npm derives them: a package is
|
|
346
|
-
// dev when unreachable from the root without dev edges, optional (or peer) when
|
|
347
|
-
// unreachable without optional (or peer) edges.
|
|
348
|
-
function applyBunReachabilityFlags(nodes, edges) {
|
|
349
|
-
const adjacency = new Map()
|
|
350
|
-
for (const edge of edges) {
|
|
351
|
-
if (!adjacency.has(edge.from)) adjacency.set(edge.from, [])
|
|
352
|
-
adjacency.get(edge.from).push(edge)
|
|
353
|
-
}
|
|
354
|
-
const reach = (excluded) => {
|
|
355
|
-
const seen = new Set(['(root)'])
|
|
356
|
-
const queue = ['(root)']
|
|
357
|
-
while (queue.length) {
|
|
358
|
-
for (const edge of adjacency.get(queue.pop()) || []) {
|
|
359
|
-
if (excluded.has(edge.kind) || seen.has(edge.to)) continue
|
|
360
|
-
seen.add(edge.to)
|
|
361
|
-
queue.push(edge.to)
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
return seen
|
|
365
|
-
}
|
|
366
|
-
const withoutDev = reach(new Set(['dev']))
|
|
367
|
-
const withoutOptional = reach(new Set(['optional', 'optional-peer']))
|
|
368
|
-
const withoutPeer = reach(new Set(['peer', 'optional-peer']))
|
|
369
|
-
for (const node of nodes) {
|
|
370
|
-
node.dev = !withoutDev.has(node.id)
|
|
371
|
-
node.optional = !withoutOptional.has(node.id)
|
|
372
|
-
node.peer = !withoutPeer.has(node.id)
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function parseBunLock(lock, lockfile) {
|
|
377
|
-
const lockfileVersion = Number(lock?.lockfileVersion)
|
|
378
|
-
const extras = {lockfile, lockfileVersion: Number.isFinite(lockfileVersion) ? Math.trunc(lockfileVersion) : 0}
|
|
379
|
-
if (!lock?.packages || typeof lock.packages !== 'object' || Array.isArray(lock.packages)) {
|
|
380
|
-
return emptyGraph(STATE.PARTIAL, 'BUN_LOCK_PACKAGES_REQUIRED', extras)
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const allKeys = Object.keys(lock.packages).sort(compareText)
|
|
384
|
-
const selectedKeys = allKeys.slice(0, MAX_PACKAGE_RECORDS)
|
|
385
|
-
const reasons = []
|
|
386
|
-
if (selectedKeys.length < allKeys.length) reasons.push('LOCKFILE_PACKAGE_RECORD_LIMIT_REACHED')
|
|
387
|
-
|
|
388
|
-
const records = new Map()
|
|
389
|
-
let invalidRecords = 0
|
|
390
|
-
for (const key of selectedKeys) {
|
|
391
|
-
const segments = bunKeySegments(key)
|
|
392
|
-
const record = segments ? bunPackageRecord(lock.packages[key]) : null
|
|
393
|
-
if (!record) {
|
|
394
|
-
invalidRecords++
|
|
395
|
-
continue
|
|
396
|
-
}
|
|
397
|
-
records.set(key, {...record, segments})
|
|
398
|
-
}
|
|
399
|
-
if (invalidRecords > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_RECORDS')
|
|
400
|
-
|
|
401
|
-
// Node ids reuse packageId over the bun install-position key, so ids keep the
|
|
402
|
-
// exact "npm:name@version:hash12" shape the npm parser and sync sanitizer use,
|
|
403
|
-
// and node.name stays the bare package name that hosted BFS grounding matches.
|
|
404
|
-
const externalNodes = new Map()
|
|
405
|
-
let invalidPackageVersions = 0
|
|
406
|
-
for (const [key, record] of records) {
|
|
407
|
-
if (record.workspace) continue
|
|
408
|
-
const version = packageVersion(record.rawVersion)
|
|
409
|
-
if (!version) { invalidPackageVersions++; continue }
|
|
410
|
-
externalNodes.set(key, {
|
|
411
|
-
id: packageId(record.name, version, key),
|
|
412
|
-
name: record.name,
|
|
413
|
-
version,
|
|
414
|
-
direct: false,
|
|
415
|
-
dev: false,
|
|
416
|
-
optional: false,
|
|
417
|
-
peer: false,
|
|
418
|
-
})
|
|
419
|
-
}
|
|
420
|
-
if (invalidPackageVersions > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_VERSIONS')
|
|
421
|
-
|
|
422
|
-
const workspaces = lock?.workspaces && typeof lock.workspaces === 'object' && !Array.isArray(lock.workspaces)
|
|
423
|
-
? lock.workspaces
|
|
424
|
-
: null
|
|
425
|
-
if (!workspaces) reasons.push('BUN_LOCK_WORKSPACES_MISSING')
|
|
426
|
-
|
|
427
|
-
// Sources: every workspace member acts as '(root)' (matching how the npm parser
|
|
428
|
-
// treats workspace-local records), then every resolved external package.
|
|
429
|
-
const sources = []
|
|
430
|
-
for (const path of Object.keys(workspaces || {}).sort(compareText)) {
|
|
431
|
-
const meta = workspaces[path]
|
|
432
|
-
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) continue
|
|
433
|
-
const workspaceName = path === '' ? null : safeToken(meta.name)
|
|
434
|
-
const segments = workspaceName && records.has(workspaceName) ? [workspaceName] : []
|
|
435
|
-
sources.push({sourceId: '(root)', segments, declarationRecord: bunDeclarationRecord(meta)})
|
|
436
|
-
}
|
|
437
|
-
for (const [key, record] of [...records].sort(([a], [b]) => compareText(a, b))) {
|
|
438
|
-
const node = externalNodes.get(key)
|
|
439
|
-
if (!node) continue
|
|
440
|
-
sources.push({sourceId: node.id, segments: record.segments, declarationRecord: bunDeclarationRecord(record.meta)})
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const edgeMap = new Map()
|
|
444
|
-
const declarations = emptyDeclarations()
|
|
445
|
-
let declarationLimitReached = false
|
|
446
|
-
outer: for (const source of sources) {
|
|
447
|
-
for (const declaration of dependencyDeclarations(source.declarationRecord)) {
|
|
448
|
-
if (declarations.total >= MAX_DEPENDENCY_DECLARATIONS) {
|
|
449
|
-
declarationLimitReached = true
|
|
450
|
-
break outer
|
|
451
|
-
}
|
|
452
|
-
declarations.total++
|
|
453
|
-
const resolved = resolveBunDependency(source.segments, declaration.name, records)
|
|
454
|
-
if (resolved?.record.workspace) {
|
|
455
|
-
declarations.local++
|
|
456
|
-
continue
|
|
457
|
-
}
|
|
458
|
-
const node = resolved ? externalNodes.get(resolved.key) : null
|
|
459
|
-
if (!node) {
|
|
460
|
-
if (declaration.kind === 'optional' || declaration.kind === 'optional-peer') declarations.optionalMissing++
|
|
461
|
-
else declarations.unresolved++
|
|
462
|
-
continue
|
|
463
|
-
}
|
|
464
|
-
declarations.resolved++
|
|
465
|
-
if (source.sourceId === '(root)') node.direct = true
|
|
466
|
-
if (source.sourceId === node.id) continue
|
|
467
|
-
const edge = {from: source.sourceId, to: node.id, kind: declaration.kind}
|
|
468
|
-
edgeMap.set(`${edge.from}\0${edge.to}\0${edge.kind}`, edge)
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
if (declarationLimitReached) reasons.push('LOCKFILE_DEPENDENCY_DECLARATION_LIMIT_REACHED')
|
|
472
|
-
if (declarations.unresolved > 0) reasons.push('UNRESOLVED_LOCKFILE_DEPENDENCIES')
|
|
473
|
-
|
|
474
|
-
applyBunReachabilityFlags(externalNodes.values(), edgeMap.values())
|
|
475
|
-
|
|
476
|
-
return finalizeGraph({externalNodes, edgeMap, declarations, reasons, lockfile, lockfileVersion: extras.lockfileVersion})
|
|
477
|
-
}
|
|
478
9
|
|
|
479
10
|
export function buildPackageDependencyGraph(repoRoot) {
|
|
480
11
|
const boundary = createRepoBoundary(repoRoot)
|
|
@@ -496,7 +27,7 @@ export function buildPackageDependencyGraph(repoRoot) {
|
|
|
496
27
|
}
|
|
497
28
|
const raw = readFileSync(selected.path, 'utf8')
|
|
498
29
|
if (selected.lockfile === 'bun.lock') return parseBunLock(parseJsonc(raw), selected.lockfile)
|
|
499
|
-
return
|
|
30
|
+
return parsePackageLock(JSON.parse(raw), selected.lockfile)
|
|
500
31
|
} catch {
|
|
501
32
|
return emptyGraph(STATE.ERROR, 'PACKAGE_LOCK_READ_ERROR', {lockfile: selected.lockfile})
|
|
502
33
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import {spawnSync} from 'node:child_process'
|
|
2
|
+
import {childProcessEnv} from '../child-env.js'
|
|
3
|
+
|
|
4
|
+
export function gitLines(repoRoot, args) {
|
|
5
|
+
const result = spawnSync('git', ['-C', repoRoot, ...args], {
|
|
6
|
+
encoding: 'utf8', timeout: 8000, env: childProcessEnv(), windowsHide: true,
|
|
7
|
+
})
|
|
8
|
+
if (result.status !== 0) return null
|
|
9
|
+
return String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
|
10
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs'
|
|
2
|
+
import {isStructuralRelation} from '../../graph/relations.js'
|
|
3
|
+
import {edgeProvenance} from '../../graph/edge-provenance.js'
|
|
4
|
+
import {mergePrecisionOverlay, precisionSemanticInputsMatch, readPrecisionOverlay} from '../../precision/lsp-overlay.js'
|
|
5
|
+
|
|
6
|
+
export function loadGraph(path, {repoRoot = null} = {}) {
|
|
7
|
+
const saved = JSON.parse(readFileSync(path, 'utf8'))
|
|
8
|
+
const overlay = readPrecisionOverlay(path, saved)
|
|
9
|
+
const safeOverlay = repoRoot && typeof overlay?.semanticInputFingerprint === 'string'
|
|
10
|
+
&& !precisionSemanticInputsMatch(overlay, repoRoot, saved) ? null : overlay
|
|
11
|
+
const raw = mergePrecisionOverlay(saved, safeOverlay)
|
|
12
|
+
const nodes = Array.isArray(raw.nodes) ? raw.nodes : []
|
|
13
|
+
const links = Array.isArray(raw.links) ? raw.links : []
|
|
14
|
+
const byId = new Map()
|
|
15
|
+
const byLabel = new Map()
|
|
16
|
+
for (const n of nodes) {
|
|
17
|
+
if (!n || n.id == null) continue
|
|
18
|
+
byId.set(String(n.id), n)
|
|
19
|
+
const key = String(n.label ?? n.id).toLowerCase()
|
|
20
|
+
if (!byLabel.has(key)) byLabel.set(key, [])
|
|
21
|
+
byLabel.get(key).push(n)
|
|
22
|
+
}
|
|
23
|
+
const out = new Map()
|
|
24
|
+
const inn = new Map()
|
|
25
|
+
const push = (map, k, v) => {
|
|
26
|
+
if (!map.has(k)) map.set(k, [])
|
|
27
|
+
map.get(k).push(v)
|
|
28
|
+
}
|
|
29
|
+
for (const e of links) {
|
|
30
|
+
if (!e || e.source == null || e.target == null) continue
|
|
31
|
+
const s = String(e.source), t = String(e.target)
|
|
32
|
+
const metadata = {
|
|
33
|
+
relation: e.relation,
|
|
34
|
+
confidence: e.confidence,
|
|
35
|
+
provenance: edgeProvenance(e),
|
|
36
|
+
...(e.typeOnly === true ? {typeOnly: true} : {}),
|
|
37
|
+
...(e.compileOnly === true ? {compileOnly: true} : {}),
|
|
38
|
+
...(Number.isInteger(e.line) ? {line: e.line} : {}),
|
|
39
|
+
...(typeof e.specifier === 'string' ? {specifier: e.specifier} : {}),
|
|
40
|
+
...(e.barrelProxy === true ? {barrelProxy: true} : {}),
|
|
41
|
+
...(e.semanticOrigin === true ? {semanticOrigin: true} : {}),
|
|
42
|
+
...(typeof e.viaBarrel === 'string' ? {viaBarrel: e.viaBarrel} : {}),
|
|
43
|
+
}
|
|
44
|
+
push(out, s, {id: t, ...metadata})
|
|
45
|
+
push(inn, t, {id: s, ...metadata})
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
nodes, links, byId, byLabel, out, inn,
|
|
49
|
+
repoBoundaryV: Number(raw.repoBoundaryV) || 0,
|
|
50
|
+
edgeTypesV: Number(raw.edgeTypesV) || 0,
|
|
51
|
+
edgeProvenanceV: Number(raw.edgeProvenanceV) || 0,
|
|
52
|
+
barrelResolutionV: Number(raw.barrelResolutionV) || 0,
|
|
53
|
+
reExportOccurrencesV: Number(raw.reExportOccurrencesV) || 0,
|
|
54
|
+
symbolSpacesV: Number(raw.symbolSpacesV) || 0,
|
|
55
|
+
reExportOccurrences: Array.isArray(raw.reExportOccurrences) ? raw.reExportOccurrences : [],
|
|
56
|
+
jsExportRecords: raw.jsExportRecords && typeof raw.jsExportRecords === 'object' ? raw.jsExportRecords : {},
|
|
57
|
+
extractorSchemaV: Number(raw.extractorSchemaV) || 0,
|
|
58
|
+
extImportsV: Number(raw.extImportsV) || 0,
|
|
59
|
+
complexityV: Number(raw.complexityV) || 0,
|
|
60
|
+
physicalFileLocV: Number(raw.physicalFileLocV) || 0,
|
|
61
|
+
graphBuildMode: ['full', 'no-tests', 'tests-only'].includes(raw.graphBuildMode) ? raw.graphBuildMode : 'full',
|
|
62
|
+
graphBuildScope: typeof raw.graphBuildScope === 'string' ? raw.graphBuildScope : null,
|
|
63
|
+
graphRevision: typeof raw.graphRevision === 'string' ? raw.graphRevision : null,
|
|
64
|
+
repositoryFreshnessProbeV: Number(raw.repositoryFreshnessProbeV) || 0,
|
|
65
|
+
repositoryFreshnessBuilderSchemaV: Number(raw.repositoryFreshnessBuilderSchemaV) || 0,
|
|
66
|
+
repositoryFreshnessBuilderVersion: typeof raw.repositoryFreshnessBuilderVersion === 'string' ? raw.repositoryFreshnessBuilderVersion : null,
|
|
67
|
+
repositoryFreshnessProbe: typeof raw.repositoryFreshnessProbe === 'string' ? raw.repositoryFreshnessProbe : null,
|
|
68
|
+
repositoryFreshnessMode: typeof raw.repositoryFreshnessMode === 'string' ? raw.repositoryFreshnessMode : null,
|
|
69
|
+
graphPrecisionMode: raw.graphPrecisionMode === 'off' ? 'off' : 'lsp',
|
|
70
|
+
precisionOverlayV: Number(raw.precisionOverlayV) || 0,
|
|
71
|
+
precision: raw.precision || null,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const isSymbol = (id) => String(id).includes('#')
|
|
76
|
+
export const labelOf = (g, id) => {
|
|
77
|
+
const n = g.byId.get(String(id))
|
|
78
|
+
return n ? String(n.label ?? n.id) : String(id)
|
|
79
|
+
}
|
|
80
|
+
export const connList = (list) => (list || []).filter((e) => !isStructuralRelation(e.relation) && e.barrelProxy !== true)
|
|
81
|
+
export const degreeOf = (g, id) => connList(g.out.get(id)).length + connList(g.inn.get(id)).length
|
|
82
|
+
export const uniqueConnCount = (list) => new Set(connList(list).map((e) => String(e.id))).size
|
|
83
|
+
|
|
84
|
+
export function resolveNodeInfo(g, query) {
|
|
85
|
+
const q = String(query ?? '').trim()
|
|
86
|
+
if (!q) return {node: null, matches: 0, alternates: []}
|
|
87
|
+
if (g.byId.has(q)) return {node: g.byId.get(q), matches: 1, alternates: []}
|
|
88
|
+
const exactLabel = g.byLabel.get(q.toLowerCase())
|
|
89
|
+
if (exactLabel?.length) return pickBest(g, exactLabel)
|
|
90
|
+
const hits = []
|
|
91
|
+
for (const n of g.nodes) {
|
|
92
|
+
if (String(n.id).toLowerCase().includes(q.toLowerCase()) || String(n.label ?? '').toLowerCase().includes(q.toLowerCase())) hits.push(n)
|
|
93
|
+
if (hits.length > 500) break
|
|
94
|
+
}
|
|
95
|
+
return hits.length ? pickBest(g, hits) : {node: null, matches: 0, alternates: []}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const resolveNode = (g, query) => resolveNodeInfo(g, query).node
|
|
99
|
+
const bestByDegree = (g, list) => list.reduce((best, n) => (degreeOf(g, n.id) > degreeOf(g, best.id) ? n : best), list[0])
|
|
100
|
+
function pickBest(g, list) {
|
|
101
|
+
const node = bestByDegree(g, list)
|
|
102
|
+
const alternates = list.filter((n) => n !== node).sort((a, b) => degreeOf(g, b.id) - degreeOf(g, a.id))
|
|
103
|
+
.slice(0, 4).map((n) => `${n.label ?? n.id} [${n.id}]`)
|
|
104
|
+
return {node, matches: list.length, alternates}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function ambiguityNote(query, info) {
|
|
108
|
+
if (!info.node || info.matches <= 1) return null
|
|
109
|
+
const more = info.matches - 1 - info.alternates.length
|
|
110
|
+
return `Note: "${query}" matched ${info.matches} nodes; using the best-connected. Others: ${info.alternates.join(', ')}${more > 0 ? ` (+${more} more)` : ''}`
|
|
111
|
+
}
|