@rahmantech/knl-view 0.2.0 → 0.3.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 +194 -261
- package/bin/knl-view.js +162 -0
- package/package.json +1 -1
- package/server/consensus.js +0 -4
- package/server/graph/modules_builder.js +300 -0
- package/server/graph/modules_catalog.js +329 -0
- package/server/graph/sources.js +1 -2
- package/server/graph/syntax.js +54 -17
- package/server/scaffold.js +14 -3
package/bin/knl-view.js
CHANGED
|
@@ -22,6 +22,19 @@ import {
|
|
|
22
22
|
import { readProjectConfig, readGlobalConfig, projectSlug, anchordbClientOptions, resolveModel, resolveSettings, knlGlobalDir, globalConfigPath } from '../server/config.js'
|
|
23
23
|
import { createAnchorWireClient, mirrorStillValid } from '../server/graph/anchordb_client.js'
|
|
24
24
|
import { syncSourcesToAnchorDB, traceSources, purgeArchivedRecords } from '../server/graph/sources.js'
|
|
25
|
+
import {
|
|
26
|
+
parseModuleSpecifier,
|
|
27
|
+
buildModuleGraph,
|
|
28
|
+
syncModuleToAnchorDB,
|
|
29
|
+
traceModuleSymbol
|
|
30
|
+
} from '../server/graph/modules_builder.js'
|
|
31
|
+
import {
|
|
32
|
+
readModulesCatalog,
|
|
33
|
+
defaultEcosystemModel,
|
|
34
|
+
detectProjectManifests,
|
|
35
|
+
isModuleSynced,
|
|
36
|
+
SUPPORTED_ECOSYSTEMS
|
|
37
|
+
} from '../server/graph/modules_catalog.js'
|
|
25
38
|
import { readModelRegistry, registerModel, generateModelName, modelRegistryPath } from '../server/models_registry.js'
|
|
26
39
|
import {
|
|
27
40
|
latestRelease,
|
|
@@ -97,6 +110,11 @@ Commands:
|
|
|
97
110
|
"sources" model via the REST collector
|
|
98
111
|
sources trace --target=<path> File lineage over the sources graph
|
|
99
112
|
(--hops, --direction in|out|both)
|
|
113
|
+
modules build-graph <target> Build & sync external module graph into global
|
|
114
|
+
AnchorDB model (modules-<eco>) on-demand
|
|
115
|
+
Target: <eco>::<module_name> (e.g. npm::express)
|
|
116
|
+
modules trace <target> [sym] Trace calls/definitions for an external module/symbol
|
|
117
|
+
modules list [ecosystem] List locally indexed modules from ~/.knlview/modules
|
|
100
118
|
setup Bootstrap project in one command (scaffold base, skill, and models)
|
|
101
119
|
|
|
102
120
|
Options:
|
|
@@ -112,6 +130,7 @@ Options:
|
|
|
112
130
|
(default: both; see README "Two-way lineage")
|
|
113
131
|
--target <id> Target node id for the subgraph query
|
|
114
132
|
--hops <number> Graph trace hop radius (default: 2)
|
|
133
|
+
--force Rebuild external module graph even if already indexed
|
|
115
134
|
--init Scaffold a new base via the wizard (does not start the server)
|
|
116
135
|
--structure <layout> Layout for non-interactive --init:
|
|
117
136
|
standard | empty | custom (default: standard)
|
|
@@ -146,6 +165,7 @@ try {
|
|
|
146
165
|
target: { type: 'string' },
|
|
147
166
|
direction: { type: 'string' },
|
|
148
167
|
hops: { type: 'string', default: '2' },
|
|
168
|
+
force: { type: 'boolean', default: false },
|
|
149
169
|
init: { type: 'boolean', default: false },
|
|
150
170
|
structure: { type: 'string', default: 'standard' },
|
|
151
171
|
folders: { type: 'string', default: '' },
|
|
@@ -245,6 +265,18 @@ if (sourcesSub) {
|
|
|
245
265
|
process.exit(0)
|
|
246
266
|
}
|
|
247
267
|
|
|
268
|
+
// The "modules" command: on-demand external boundary graph in global AnchorDB
|
|
269
|
+
// modules models (modules-npm, modules-go, modules-cargo, modules-pypi).
|
|
270
|
+
const modulesSub = positionals?.[0] === 'modules' ? positionals?.[1] : null
|
|
271
|
+
if (modulesSub) {
|
|
272
|
+
try {
|
|
273
|
+
await runModulesCommand(modulesSub, positionals?.slice(2), { values, settings })
|
|
274
|
+
} catch (err) {
|
|
275
|
+
fail(`Modules command failed: ${err.message}`)
|
|
276
|
+
}
|
|
277
|
+
process.exit(0)
|
|
278
|
+
}
|
|
279
|
+
|
|
248
280
|
// Register the project's whole models map to the global registry (updating
|
|
249
281
|
// path/timestamp when this project owns them) and warn about names claimed
|
|
250
282
|
// by other projects. This local registration needs no server connection.
|
|
@@ -1247,6 +1279,136 @@ async function runSourcesCollector(base, model, { quiet }) {
|
|
|
1247
1279
|
}
|
|
1248
1280
|
}
|
|
1249
1281
|
|
|
1282
|
+
// The "modules" subcommands: on-demand external boundary graph in global
|
|
1283
|
+
// AnchorDB modules models (modules-npm, modules-go, modules-cargo, modules-pypi).
|
|
1284
|
+
async function runModulesCommand(sub, args, { values, settings }) {
|
|
1285
|
+
if (sub === 'list') {
|
|
1286
|
+
const ecoFilter = args?.[0]?.toLowerCase()
|
|
1287
|
+
if (ecoFilter && !SUPPORTED_ECOSYSTEMS.has(ecoFilter)) {
|
|
1288
|
+
fail(`Unknown ecosystem "${args[0]}". Supported: ${[...SUPPORTED_ECOSYSTEMS].join(', ')}`)
|
|
1289
|
+
}
|
|
1290
|
+
const ecosystems = ecoFilter ? [ecoFilter] : [...SUPPORTED_ECOSYSTEMS]
|
|
1291
|
+
const all = {}
|
|
1292
|
+
for (const eco of ecosystems) {
|
|
1293
|
+
all[eco] = await readModulesCatalog(eco)
|
|
1294
|
+
}
|
|
1295
|
+
process.stdout.write(JSON.stringify(all, null, 2) + '\n')
|
|
1296
|
+
return
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
if (sub === 'build-graph' || sub === 'build') {
|
|
1300
|
+
const target = args?.[0] || values.target
|
|
1301
|
+
if (!target) {
|
|
1302
|
+
fail('Target is required:\n knl-view modules build-graph <ecosystem>::<module_name> [--force]')
|
|
1303
|
+
}
|
|
1304
|
+
const parsed = parseModuleSpecifier(target)
|
|
1305
|
+
if (!parsed) {
|
|
1306
|
+
fail(`Invalid module target "${target}".\nExpected format: <ecosystem>::<module_name> (e.g. npm::express, go::github.com/gin-gonic/gin)`)
|
|
1307
|
+
}
|
|
1308
|
+
const { ecosystem, moduleName } = parsed
|
|
1309
|
+
|
|
1310
|
+
if (!settings.anchordb) {
|
|
1311
|
+
fail('External modules graphs live in AnchorDB, which is not configured.\n' +
|
|
1312
|
+
'Run "knl-view install anchordb" first (optional layer).')
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const model = defaultEcosystemModel(ecosystem)
|
|
1316
|
+
const base = anchordbClientOptions(settings.anchordb)
|
|
1317
|
+
|
|
1318
|
+
if (!values.force) {
|
|
1319
|
+
const manifestVersions = await detectProjectManifests(process.cwd())
|
|
1320
|
+
const expectedVersion = manifestVersions[ecosystem]?.[moduleName]
|
|
1321
|
+
if (expectedVersion) {
|
|
1322
|
+
const already = await isModuleSynced(ecosystem, moduleName, expectedVersion)
|
|
1323
|
+
if (already) {
|
|
1324
|
+
const catalog = await readModulesCatalog(ecosystem)
|
|
1325
|
+
const key = `${moduleName}@${expectedVersion}`
|
|
1326
|
+
process.stdout.write(`module ${ecosystem}::${key} is already synced in model ${model} (${catalog[key]?.symbolCount || 0} symbols). Use --force to rebuild.\n`)
|
|
1327
|
+
return
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
process.stdout.write(`building graph for ${ecosystem}::${moduleName} ...\n`)
|
|
1333
|
+
const moduleGraph = await buildModuleGraph(ecosystem, moduleName, { projectRoot: process.cwd() })
|
|
1334
|
+
|
|
1335
|
+
const client = await createAnchorWireClient({ ...base, model })
|
|
1336
|
+
try {
|
|
1337
|
+
const result = await syncModuleToAnchorDB(client, moduleGraph)
|
|
1338
|
+
process.stdout.write(`module synced: ${ecosystem}::${moduleGraph.name}@${moduleGraph.version} -> model "${model}" (${result.symbols} symbols, ${result.edges} edges, +${result.linked} linked)\n`)
|
|
1339
|
+
} finally {
|
|
1340
|
+
client.close()
|
|
1341
|
+
}
|
|
1342
|
+
return
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if (sub === 'trace') {
|
|
1346
|
+
const rawTarget = args?.[0] || values.target
|
|
1347
|
+
if (!rawTarget) {
|
|
1348
|
+
fail('Target is required:\n knl-view modules trace <ecosystem>::<module_name> [symbol] [--hops=2] [--direction=in|out|both]')
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
let ecosystem, symbolId
|
|
1352
|
+
if (rawTarget.startsWith('ext:')) {
|
|
1353
|
+
const m = rawTarget.match(/^ext:([a-z]+)\//)
|
|
1354
|
+
if (!m) fail(`Cannot determine ecosystem from node ID "${rawTarget}".`)
|
|
1355
|
+
ecosystem = m[1]
|
|
1356
|
+
symbolId = rawTarget
|
|
1357
|
+
} else {
|
|
1358
|
+
const parsed = parseModuleSpecifier(rawTarget)
|
|
1359
|
+
if (!parsed) {
|
|
1360
|
+
fail(`Invalid module target "${rawTarget}".\nExpected format: <ecosystem>::<module_name> [symbol]`)
|
|
1361
|
+
}
|
|
1362
|
+
ecosystem = parsed.ecosystem
|
|
1363
|
+
const catalog = await readModulesCatalog(ecosystem)
|
|
1364
|
+
const subSymbol = args?.[1]
|
|
1365
|
+
const entryKey = Object.keys(catalog).find((k) => k.startsWith(parsed.moduleName + '@') || k === parsed.moduleName)
|
|
1366
|
+
if (!entryKey) {
|
|
1367
|
+
fail(`Module "${rawTarget}" is not indexed in catalog ~/.knlview/modules/${ecosystem}.json.\nRun "knl-view modules build-graph ${rawTarget}" first.`)
|
|
1368
|
+
}
|
|
1369
|
+
const entry = catalog[entryKey]
|
|
1370
|
+
symbolId = subSymbol ? `${entry.nodeId}#${subSymbol}` : entry.nodeId
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (!settings.anchordb) {
|
|
1374
|
+
fail('The modules graph lives in AnchorDB, which is not configured.\n' +
|
|
1375
|
+
'Run "knl-view install anchordb" first.')
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const model = defaultEcosystemModel(ecosystem)
|
|
1379
|
+
const base = anchordbClientOptions(settings.anchordb)
|
|
1380
|
+
const client = await createAnchorWireClient({ ...base, model })
|
|
1381
|
+
try {
|
|
1382
|
+
let direction = 'both'
|
|
1383
|
+
if (values.direction !== undefined) {
|
|
1384
|
+
if (!['in', 'out', 'both'].includes(values.direction)) {
|
|
1385
|
+
fail(`--direction must be in, out, or both, got "${values.direction}".`)
|
|
1386
|
+
}
|
|
1387
|
+
direction = values.direction
|
|
1388
|
+
}
|
|
1389
|
+
const hops = Number.parseInt(values.hops || '2', 10)
|
|
1390
|
+
const maxHops = Number.isInteger(hops) && hops > 0 ? hops : 2
|
|
1391
|
+
const results = await traceModuleSymbol(client, symbolId, { direction, maxHops })
|
|
1392
|
+
process.stdout.write(JSON.stringify({
|
|
1393
|
+
center: symbolId,
|
|
1394
|
+
model,
|
|
1395
|
+
direction,
|
|
1396
|
+
maxHops,
|
|
1397
|
+
trace: results
|
|
1398
|
+
}, null, 2) + '\n')
|
|
1399
|
+
} finally {
|
|
1400
|
+
client.close()
|
|
1401
|
+
}
|
|
1402
|
+
return
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
fail('Unknown modules subcommand.\n' +
|
|
1406
|
+
'Usage:\n' +
|
|
1407
|
+
' knl-view modules build-graph <ecosystem>::<module_name> [--force]\n' +
|
|
1408
|
+
' knl-view modules trace <ecosystem>::<module_name> [symbol] [--hops=2] [--direction=in|out|both]\n' +
|
|
1409
|
+
' knl-view modules list [ecosystem]')
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1250
1412
|
function fail(message) {
|
|
1251
1413
|
process.stderr.write(`knl-view: ${message}\n`)
|
|
1252
1414
|
process.exit(1)
|
package/package.json
CHANGED
package/server/consensus.js
CHANGED
|
@@ -48,10 +48,6 @@ export function claimsIndexPath(model, home = null, options = {}) {
|
|
|
48
48
|
return path.join(knlGlobalDir(home, options), 'claims', `${model}.json`)
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
export function consensusMirrorPath(model, home = null, options = {}) {
|
|
52
|
-
return path.join(knlGlobalDir(home, options), 'consensus', `${model}.json`)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
51
|
/**
|
|
56
52
|
* Read the claim index. Missing file = empty index, not an error.
|
|
57
53
|
* @returns {Promise<{claims: object, error: string|null}>}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { GraphNode } from './model.js'
|
|
4
|
+
import { SYNTAX_LANGUAGES, extractCode } from './syntax.js'
|
|
5
|
+
import { desiredSourceEdgeKeys } from './sources.js'
|
|
6
|
+
import {
|
|
7
|
+
resolveModuleLocation,
|
|
8
|
+
formatExternalPackageNodeId,
|
|
9
|
+
formatExternalSymbolNodeId,
|
|
10
|
+
defaultEcosystemModel,
|
|
11
|
+
updateModulesCatalog,
|
|
12
|
+
SUPPORTED_ECOSYSTEMS,
|
|
13
|
+
isValidModuleName
|
|
14
|
+
} from './modules_catalog.js'
|
|
15
|
+
|
|
16
|
+
const MODULE_DEFAULT_IGNORES = [
|
|
17
|
+
'node_modules',
|
|
18
|
+
'.git',
|
|
19
|
+
'.github',
|
|
20
|
+
'test',
|
|
21
|
+
'tests',
|
|
22
|
+
'__tests__',
|
|
23
|
+
'docs',
|
|
24
|
+
'doc',
|
|
25
|
+
'example',
|
|
26
|
+
'examples',
|
|
27
|
+
'fixtures',
|
|
28
|
+
'coverage'
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Scan source files of an external module directory with depth and file count guard-rails.
|
|
33
|
+
*/
|
|
34
|
+
export async function scanModuleFiles(moduleDir, ecosystem, { maxDepth = 20, maxFiles = 5000 } = {}) {
|
|
35
|
+
const files = {}
|
|
36
|
+
let totalFiles = 0
|
|
37
|
+
|
|
38
|
+
const walk = async (dirAbs, rel, depth = 0) => {
|
|
39
|
+
if (depth > maxDepth || totalFiles >= maxFiles) return
|
|
40
|
+
let entries
|
|
41
|
+
try {
|
|
42
|
+
entries = await fs.readdir(dirAbs, { withFileTypes: true })
|
|
43
|
+
} catch {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (totalFiles >= maxFiles) break
|
|
48
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name
|
|
49
|
+
if (entry.isDirectory()) {
|
|
50
|
+
if (MODULE_DEFAULT_IGNORES.includes(entry.name.toLowerCase())) continue
|
|
51
|
+
await walk(path.join(dirAbs, entry.name), childRel, depth + 1)
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
if (!entry.isFile()) continue
|
|
55
|
+
const ext = path.extname(entry.name).toLowerCase()
|
|
56
|
+
if (!SYNTAX_LANGUAGES[ext]) continue
|
|
57
|
+
if (ecosystem === 'go' && entry.name.endsWith('_test.go')) continue
|
|
58
|
+
if (ecosystem === 'pypi' && entry.name.startsWith('test_')) continue
|
|
59
|
+
files[childRel] = path.join(dirAbs, entry.name)
|
|
60
|
+
totalFiles += 1
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await walk(moduleDir, '', 0)
|
|
64
|
+
return files
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse an eco::name string into components: { ecosystem, moduleName }
|
|
69
|
+
* Strictly validates ecosystem against SUPPORTED_ECOSYSTEMS and moduleName against path traversal.
|
|
70
|
+
*/
|
|
71
|
+
export function parseModuleSpecifier(target) {
|
|
72
|
+
if (!target || typeof target !== 'string') return null
|
|
73
|
+
const idx = target.indexOf('::')
|
|
74
|
+
if (idx <= 0) return null
|
|
75
|
+
const ecosystem = target.slice(0, idx).toLowerCase()
|
|
76
|
+
const moduleName = target.slice(idx + 2)
|
|
77
|
+
if (!moduleName || !SUPPORTED_ECOSYSTEMS.has(ecosystem) || !isValidModuleName(moduleName)) {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
return { ecosystem, moduleName }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build graph for an external module from disk using Tree-Sitter.
|
|
85
|
+
*/
|
|
86
|
+
export async function buildModuleGraph(ecosystem, moduleName, options = {}) {
|
|
87
|
+
const resolved = options.location
|
|
88
|
+
? { location: options.location, version: options.version || '0.0.0', name: moduleName, ecosystem }
|
|
89
|
+
: await resolveModuleLocation(ecosystem, moduleName, options)
|
|
90
|
+
|
|
91
|
+
if (!resolved || !resolved.location) {
|
|
92
|
+
throw new Error(`Could not resolve module directory for ${ecosystem}::${moduleName} on disk. Ensure it is installed.`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { location, version } = resolved
|
|
96
|
+
const model = defaultEcosystemModel(ecosystem)
|
|
97
|
+
const moduleNodeId = formatExternalPackageNodeId(ecosystem, moduleName, version)
|
|
98
|
+
|
|
99
|
+
const files = await scanModuleFiles(location, ecosystem)
|
|
100
|
+
const symbolNodes = []
|
|
101
|
+
const symbolNamesSet = new Set()
|
|
102
|
+
const edges = []
|
|
103
|
+
const symbolIndex = new Map() // name -> [id, ...]
|
|
104
|
+
const fileSymbolsMap = new Map()
|
|
105
|
+
|
|
106
|
+
for (const [rel, abs] of Object.entries(files)) {
|
|
107
|
+
const ext = path.extname(rel).toLowerCase()
|
|
108
|
+
let content
|
|
109
|
+
try {
|
|
110
|
+
content = await fs.readFile(abs, 'utf8')
|
|
111
|
+
} catch {
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let extracted
|
|
116
|
+
try {
|
|
117
|
+
extracted = await extractCode(ext, content)
|
|
118
|
+
} catch {
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const nameCounts = new Map()
|
|
123
|
+
for (const sym of extracted.symbols || []) {
|
|
124
|
+
nameCounts.set(sym.name, (nameCounts.get(sym.name) || 0) + 1)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const currentFileSymbols = []
|
|
128
|
+
for (const sym of extracted.symbols || []) {
|
|
129
|
+
const symId = nameCounts.get(sym.name) > 1
|
|
130
|
+
? formatExternalSymbolNodeId(ecosystem, moduleName, version, `${sym.name}@${sym.line}`)
|
|
131
|
+
: formatExternalSymbolNodeId(ecosystem, moduleName, version, sym.name)
|
|
132
|
+
|
|
133
|
+
symbolNamesSet.add(sym.name)
|
|
134
|
+
currentFileSymbols.push({ ...sym, id: symId })
|
|
135
|
+
symbolIndex.set(sym.name, [...(symbolIndex.get(sym.name) || []), symId])
|
|
136
|
+
|
|
137
|
+
edges.push({ from: moduleNodeId, to: symId, type: 'defines' })
|
|
138
|
+
symbolNodes.push(new GraphNode({
|
|
139
|
+
id: symId,
|
|
140
|
+
title: sym.name,
|
|
141
|
+
type: 'symbol',
|
|
142
|
+
metadata: { kind: sym.kind, line: sym.line }
|
|
143
|
+
}))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
fileSymbolsMap.set(rel, { symbols: currentFileSymbols, calls: extracted.calls || [] })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Internal calls between symbols inside the module
|
|
150
|
+
for (const [, { symbols, calls }] of fileSymbolsMap.entries()) {
|
|
151
|
+
for (const call of calls) {
|
|
152
|
+
let owner = null
|
|
153
|
+
for (const sym of symbols) {
|
|
154
|
+
const start = sym.line - 1
|
|
155
|
+
const end = start + sym.lines
|
|
156
|
+
if (call.row >= start && call.row < end) {
|
|
157
|
+
if (!owner || sym.lines < owner.lines) owner = sym
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!owner) continue
|
|
161
|
+
const targetMatches = symbolIndex.get(call.name) || []
|
|
162
|
+
if (targetMatches.length === 1 && targetMatches[0] !== owner.id) {
|
|
163
|
+
edges.push({ from: owner.id, to: targetMatches[0], type: 'calls' })
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const moduleNode = new GraphNode({
|
|
169
|
+
id: moduleNodeId,
|
|
170
|
+
title: `${moduleName}@${version}`,
|
|
171
|
+
type: 'module',
|
|
172
|
+
metadata: { ecosystem, name: moduleName, version }
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
moduleNode,
|
|
177
|
+
symbolNodes,
|
|
178
|
+
edges,
|
|
179
|
+
symbols: [...symbolNamesSet].sort(),
|
|
180
|
+
name: moduleName,
|
|
181
|
+
version,
|
|
182
|
+
ecosystem,
|
|
183
|
+
model,
|
|
184
|
+
location
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Sync the constructed module graph to AnchorDB in model modules-<ecosystem>.
|
|
190
|
+
*/
|
|
191
|
+
export async function syncModuleToAnchorDB(client, moduleGraph, options = {}) {
|
|
192
|
+
if (!client || !moduleGraph) return { linked: 0, saved: 0, failed: 0 }
|
|
193
|
+
|
|
194
|
+
const desired = desiredSourceEdgeKeys(moduleGraph.edges)
|
|
195
|
+
let linked = 0
|
|
196
|
+
let failed = 0
|
|
197
|
+
|
|
198
|
+
for (const key of desired) {
|
|
199
|
+
const [from, type, to] = key.split('\0')
|
|
200
|
+
try {
|
|
201
|
+
await client.link(from, type, to, 1.0)
|
|
202
|
+
linked += 1
|
|
203
|
+
} catch {
|
|
204
|
+
failed += 1
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const pending = [
|
|
209
|
+
{ name: moduleGraph.moduleNode.id, props: { ...moduleGraph.moduleNode.metadata, tombstone: 0 } },
|
|
210
|
+
...moduleGraph.symbolNodes.map((s) => ({ name: s.id, props: { ...s.metadata, tombstone: 0 } }))
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
let saved = 0
|
|
214
|
+
if (typeof client.saveBatch === 'function') {
|
|
215
|
+
try {
|
|
216
|
+
await client.saveBatch(pending)
|
|
217
|
+
saved = pending.length
|
|
218
|
+
} catch {
|
|
219
|
+
for (const item of pending) {
|
|
220
|
+
try {
|
|
221
|
+
await client.saveWithID(item.name, item.props)
|
|
222
|
+
saved += 1
|
|
223
|
+
} catch {
|
|
224
|
+
failed += 1
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
for (const item of pending) {
|
|
230
|
+
try {
|
|
231
|
+
await client.saveWithID(item.name, item.props)
|
|
232
|
+
saved += 1
|
|
233
|
+
} catch {
|
|
234
|
+
failed += 1
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await updateModulesCatalog(moduleGraph.ecosystem, {
|
|
240
|
+
[`${moduleGraph.name}@${moduleGraph.version}`]: {
|
|
241
|
+
name: moduleGraph.name,
|
|
242
|
+
version: moduleGraph.version,
|
|
243
|
+
ecosystem: moduleGraph.ecosystem,
|
|
244
|
+
model: moduleGraph.model,
|
|
245
|
+
nodeId: moduleGraph.moduleNode.id,
|
|
246
|
+
symbolCount: moduleGraph.symbols.length
|
|
247
|
+
}
|
|
248
|
+
}, options)
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
linked,
|
|
252
|
+
saved,
|
|
253
|
+
failed,
|
|
254
|
+
symbols: moduleGraph.symbols.length,
|
|
255
|
+
edges: moduleGraph.edges.length,
|
|
256
|
+
model: moduleGraph.model
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Trace lineage for a module or symbol in its ecosystem model.
|
|
262
|
+
*/
|
|
263
|
+
export async function traceModuleSymbol(client, targetId, { direction = 'both', maxHops = 2 } = {}) {
|
|
264
|
+
const forwardRelations = targetId.includes('#') ? ['calls'] : ['defines']
|
|
265
|
+
const backwardRelations = targetId.includes('#') ? ['called_by', 'defined_in'] : []
|
|
266
|
+
|
|
267
|
+
const rels = []
|
|
268
|
+
if (direction === 'out' || direction === 'both') rels.push(...forwardRelations)
|
|
269
|
+
if (direction === 'in' || direction === 'both') rels.push(...backwardRelations)
|
|
270
|
+
|
|
271
|
+
const visited = new Map() // id -> hop
|
|
272
|
+
visited.set(targetId, 0)
|
|
273
|
+
let frontier = [targetId]
|
|
274
|
+
|
|
275
|
+
for (let hop = 1; hop <= maxHops && frontier.length; hop++) {
|
|
276
|
+
const nextFrontier = []
|
|
277
|
+
for (const current of frontier) {
|
|
278
|
+
for (const rel of rels) {
|
|
279
|
+
let rows = []
|
|
280
|
+
try {
|
|
281
|
+
rows = await client.causalChain([current], rel, 100)
|
|
282
|
+
} catch {
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
for (const r of rows || []) {
|
|
286
|
+
if (!r?.word || visited.has(r.word)) continue
|
|
287
|
+
visited.set(r.word, hop)
|
|
288
|
+
nextFrontier.push(r.word)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
frontier = nextFrontier
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const results = []
|
|
296
|
+
for (const [id, hop] of visited.entries()) {
|
|
297
|
+
if (id !== targetId) results.push({ id, hop })
|
|
298
|
+
}
|
|
299
|
+
return results.sort((a, b) => a.hop - b.hop || a.id.localeCompare(b.id))
|
|
300
|
+
}
|