@rahmantech/knl-view 0.2.0 → 0.3.0
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 +17 -1
- package/bin/knl-view.js +156 -0
- package/package.json +1 -1
- package/server/graph/modules_builder.js +290 -0
- package/server/graph/modules_catalog.js +288 -0
- package/server/graph/syntax.js +54 -17
- package/server/scaffold.js +10 -0
package/README.md
CHANGED
|
@@ -398,7 +398,7 @@ Each file also defines **symbol nodes**, extracted from the same parse:
|
|
|
398
398
|
per source file that has symbols; override the location with `labelDir`
|
|
399
399
|
in the project config. Labels are regenerated and pruned on every sync.
|
|
400
400
|
|
|
401
|
-
#### Supported Languages (v0.
|
|
401
|
+
#### Supported Languages (v0.3.0)
|
|
402
402
|
|
|
403
403
|
Tree-sitter WASM grammars run in-memory within Node.js without requiring host compilers:
|
|
404
404
|
|
|
@@ -443,6 +443,22 @@ record with a normal save and relinks its edges. Sync reports
|
|
|
443
443
|
never silent. The read-only HTTP endpoint answers the same queries:
|
|
444
444
|
`GET /api/sources/trace?target=<id>&direction=in|out|both&hops=N`.
|
|
445
445
|
|
|
446
|
+
### External Modules Graph (On-Demand Module Boundary)
|
|
447
|
+
|
|
448
|
+
AI agents can parse, index, and trace third-party dependencies on-demand into dedicated global AnchorDB models (`modules-npm`, `modules-go`, `modules-cargo`, `modules-pypi`) without polluting the local project graph or re-syncing across projects:
|
|
449
|
+
|
|
450
|
+
```sh
|
|
451
|
+
# Build & sync external module graph on-demand
|
|
452
|
+
knl-view modules build-graph npm::express
|
|
453
|
+
knl-view modules build-graph go::github.com/gin-gonic/gin
|
|
454
|
+
|
|
455
|
+
# Trace definitions and call hierarchy within the module
|
|
456
|
+
knl-view modules trace npm::express Router --hops=1
|
|
457
|
+
|
|
458
|
+
# List locally indexed modules in ~/.knlview/modules
|
|
459
|
+
knl-view modules list
|
|
460
|
+
```
|
|
461
|
+
|
|
446
462
|
### CLI Query
|
|
447
463
|
|
|
448
464
|
```sh
|
package/bin/knl-view.js
CHANGED
|
@@ -22,6 +22,17 @@ 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
|
+
} from '../server/graph/modules_catalog.js'
|
|
25
36
|
import { readModelRegistry, registerModel, generateModelName, modelRegistryPath } from '../server/models_registry.js'
|
|
26
37
|
import {
|
|
27
38
|
latestRelease,
|
|
@@ -97,6 +108,11 @@ Commands:
|
|
|
97
108
|
"sources" model via the REST collector
|
|
98
109
|
sources trace --target=<path> File lineage over the sources graph
|
|
99
110
|
(--hops, --direction in|out|both)
|
|
111
|
+
modules build-graph <target> Build & sync external module graph into global
|
|
112
|
+
AnchorDB model (modules-<eco>) on-demand
|
|
113
|
+
Target: <eco>::<module_name> (e.g. npm::express)
|
|
114
|
+
modules trace <target> [sym] Trace calls/definitions for an external module/symbol
|
|
115
|
+
modules list [ecosystem] List locally indexed modules from ~/.knlview/modules
|
|
100
116
|
setup Bootstrap project in one command (scaffold base, skill, and models)
|
|
101
117
|
|
|
102
118
|
Options:
|
|
@@ -112,6 +128,7 @@ Options:
|
|
|
112
128
|
(default: both; see README "Two-way lineage")
|
|
113
129
|
--target <id> Target node id for the subgraph query
|
|
114
130
|
--hops <number> Graph trace hop radius (default: 2)
|
|
131
|
+
--force Rebuild external module graph even if already indexed
|
|
115
132
|
--init Scaffold a new base via the wizard (does not start the server)
|
|
116
133
|
--structure <layout> Layout for non-interactive --init:
|
|
117
134
|
standard | empty | custom (default: standard)
|
|
@@ -146,6 +163,7 @@ try {
|
|
|
146
163
|
target: { type: 'string' },
|
|
147
164
|
direction: { type: 'string' },
|
|
148
165
|
hops: { type: 'string', default: '2' },
|
|
166
|
+
force: { type: 'boolean', default: false },
|
|
149
167
|
init: { type: 'boolean', default: false },
|
|
150
168
|
structure: { type: 'string', default: 'standard' },
|
|
151
169
|
folders: { type: 'string', default: '' },
|
|
@@ -245,6 +263,18 @@ if (sourcesSub) {
|
|
|
245
263
|
process.exit(0)
|
|
246
264
|
}
|
|
247
265
|
|
|
266
|
+
// The "modules" command: on-demand external boundary graph in global AnchorDB
|
|
267
|
+
// modules models (modules-npm, modules-go, modules-cargo, modules-pypi).
|
|
268
|
+
const modulesSub = positionals?.[0] === 'modules' ? positionals?.[1] : null
|
|
269
|
+
if (modulesSub) {
|
|
270
|
+
try {
|
|
271
|
+
await runModulesCommand(modulesSub, positionals?.slice(2), { values, settings })
|
|
272
|
+
} catch (err) {
|
|
273
|
+
fail(`Modules command failed: ${err.message}`)
|
|
274
|
+
}
|
|
275
|
+
process.exit(0)
|
|
276
|
+
}
|
|
277
|
+
|
|
248
278
|
// Register the project's whole models map to the global registry (updating
|
|
249
279
|
// path/timestamp when this project owns them) and warn about names claimed
|
|
250
280
|
// by other projects. This local registration needs no server connection.
|
|
@@ -1247,6 +1277,132 @@ async function runSourcesCollector(base, model, { quiet }) {
|
|
|
1247
1277
|
}
|
|
1248
1278
|
}
|
|
1249
1279
|
|
|
1280
|
+
// The "modules" subcommands: on-demand external boundary graph in global
|
|
1281
|
+
// AnchorDB modules models (modules-npm, modules-go, modules-cargo, modules-pypi).
|
|
1282
|
+
async function runModulesCommand(sub, args, { values, settings }) {
|
|
1283
|
+
if (sub === 'list') {
|
|
1284
|
+
const ecoFilter = args?.[0]?.toLowerCase()
|
|
1285
|
+
const ecosystems = ecoFilter ? [ecoFilter] : ['npm', 'go', 'cargo', 'pypi']
|
|
1286
|
+
const all = {}
|
|
1287
|
+
for (const eco of ecosystems) {
|
|
1288
|
+
all[eco] = await readModulesCatalog(eco)
|
|
1289
|
+
}
|
|
1290
|
+
process.stdout.write(JSON.stringify(all, null, 2) + '\n')
|
|
1291
|
+
return
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (sub === 'build-graph' || sub === 'build') {
|
|
1295
|
+
const target = args?.[0] || values.target
|
|
1296
|
+
if (!target) {
|
|
1297
|
+
fail('Target is required:\n knl-view modules build-graph <ecosystem>::<module_name> [--force]')
|
|
1298
|
+
}
|
|
1299
|
+
const parsed = parseModuleSpecifier(target)
|
|
1300
|
+
if (!parsed) {
|
|
1301
|
+
fail(`Invalid module target "${target}".\nExpected format: <ecosystem>::<module_name> (e.g. npm::express, go::github.com/gin-gonic/gin)`)
|
|
1302
|
+
}
|
|
1303
|
+
const { ecosystem, moduleName } = parsed
|
|
1304
|
+
|
|
1305
|
+
if (!settings.anchordb) {
|
|
1306
|
+
fail('External modules graphs live in AnchorDB, which is not configured.\n' +
|
|
1307
|
+
'Run "knl-view install anchordb" first (optional layer).')
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const model = defaultEcosystemModel(ecosystem)
|
|
1311
|
+
const base = anchordbClientOptions(settings.anchordb)
|
|
1312
|
+
|
|
1313
|
+
if (!values.force) {
|
|
1314
|
+
const catalog = await readModulesCatalog(ecosystem)
|
|
1315
|
+
const manifestVersions = await detectProjectManifests(process.cwd())
|
|
1316
|
+
const expectedVersion = manifestVersions[ecosystem]?.[moduleName]
|
|
1317
|
+
if (expectedVersion) {
|
|
1318
|
+
const key = `${moduleName}@${expectedVersion}`
|
|
1319
|
+
if (catalog[key]?.syncedAt) {
|
|
1320
|
+
process.stdout.write(`module ${ecosystem}::${key} is already synced in model ${model} (${catalog[key].symbols.length} symbols). Use --force to rebuild.\n`)
|
|
1321
|
+
return
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
process.stdout.write(`building graph for ${ecosystem}::${moduleName} ...\n`)
|
|
1327
|
+
const moduleGraph = await buildModuleGraph(ecosystem, moduleName, { projectRoot: process.cwd() })
|
|
1328
|
+
|
|
1329
|
+
const client = await createAnchorWireClient({ ...base, model })
|
|
1330
|
+
try {
|
|
1331
|
+
const result = await syncModuleToAnchorDB(client, moduleGraph)
|
|
1332
|
+
process.stdout.write(`module synced: ${ecosystem}::${moduleGraph.name}@${moduleGraph.version} -> model "${model}" (${result.symbols} symbols, ${result.edges} edges, +${result.linked} linked)\n`)
|
|
1333
|
+
} finally {
|
|
1334
|
+
client.close()
|
|
1335
|
+
}
|
|
1336
|
+
return
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
if (sub === 'trace') {
|
|
1340
|
+
const rawTarget = args?.[0] || values.target
|
|
1341
|
+
if (!rawTarget) {
|
|
1342
|
+
fail('Target is required:\n knl-view modules trace <ecosystem>::<module_name> [symbol] [--hops=2] [--direction=in|out|both]')
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
let ecosystem, symbolId
|
|
1346
|
+
if (rawTarget.startsWith('ext:')) {
|
|
1347
|
+
const m = rawTarget.match(/^ext:([a-z]+)\//)
|
|
1348
|
+
if (!m) fail(`Cannot determine ecosystem from node ID "${rawTarget}".`)
|
|
1349
|
+
ecosystem = m[1]
|
|
1350
|
+
symbolId = rawTarget
|
|
1351
|
+
} else {
|
|
1352
|
+
const parsed = parseModuleSpecifier(rawTarget)
|
|
1353
|
+
if (!parsed) {
|
|
1354
|
+
fail(`Invalid module target "${rawTarget}".\nExpected format: <ecosystem>::<module_name> [symbol]`)
|
|
1355
|
+
}
|
|
1356
|
+
ecosystem = parsed.ecosystem
|
|
1357
|
+
const catalog = await readModulesCatalog(ecosystem)
|
|
1358
|
+
const subSymbol = args?.[1]
|
|
1359
|
+
const entryKey = Object.keys(catalog).find((k) => k.startsWith(parsed.moduleName + '@') || k === parsed.moduleName)
|
|
1360
|
+
if (!entryKey) {
|
|
1361
|
+
fail(`Module "${rawTarget}" is not indexed in catalog ~/.knlview/modules/${ecosystem}.json.\nRun "knl-view modules build-graph ${rawTarget}" first.`)
|
|
1362
|
+
}
|
|
1363
|
+
const entry = catalog[entryKey]
|
|
1364
|
+
symbolId = subSymbol ? `${entry.nodeId}#${subSymbol}` : entry.nodeId
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
if (!settings.anchordb) {
|
|
1368
|
+
fail('The modules graph lives in AnchorDB, which is not configured.\n' +
|
|
1369
|
+
'Run "knl-view install anchordb" first.')
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const model = defaultEcosystemModel(ecosystem)
|
|
1373
|
+
const base = anchordbClientOptions(settings.anchordb)
|
|
1374
|
+
const client = await createAnchorWireClient({ ...base, model })
|
|
1375
|
+
try {
|
|
1376
|
+
let direction = 'both'
|
|
1377
|
+
if (values.direction !== undefined) {
|
|
1378
|
+
if (!['in', 'out', 'both'].includes(values.direction)) {
|
|
1379
|
+
fail(`--direction must be in, out, or both, got "${values.direction}".`)
|
|
1380
|
+
}
|
|
1381
|
+
direction = values.direction
|
|
1382
|
+
}
|
|
1383
|
+
const hops = Number.parseInt(values.hops || '2', 10)
|
|
1384
|
+
const maxHops = Number.isInteger(hops) && hops > 0 ? hops : 2
|
|
1385
|
+
const results = await traceModuleSymbol(client, symbolId, { direction, maxHops })
|
|
1386
|
+
process.stdout.write(JSON.stringify({
|
|
1387
|
+
center: symbolId,
|
|
1388
|
+
model,
|
|
1389
|
+
direction,
|
|
1390
|
+
maxHops,
|
|
1391
|
+
trace: results
|
|
1392
|
+
}, null, 2) + '\n')
|
|
1393
|
+
} finally {
|
|
1394
|
+
client.close()
|
|
1395
|
+
}
|
|
1396
|
+
return
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
fail('Unknown modules subcommand.\n' +
|
|
1400
|
+
'Usage:\n' +
|
|
1401
|
+
' knl-view modules build-graph <ecosystem>::<module_name> [--force]\n' +
|
|
1402
|
+
' knl-view modules trace <ecosystem>::<module_name> [symbol] [--hops=2] [--direction=in|out|both]\n' +
|
|
1403
|
+
' knl-view modules list [ecosystem]')
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1250
1406
|
function fail(message) {
|
|
1251
1407
|
process.stderr.write(`knl-view: ${message}\n`)
|
|
1252
1408
|
process.exit(1)
|
package/package.json
CHANGED
|
@@ -0,0 +1,290 @@
|
|
|
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
|
+
} from './modules_catalog.js'
|
|
13
|
+
|
|
14
|
+
const MODULE_DEFAULT_IGNORES = [
|
|
15
|
+
'node_modules',
|
|
16
|
+
'.git',
|
|
17
|
+
'.github',
|
|
18
|
+
'test',
|
|
19
|
+
'tests',
|
|
20
|
+
'__tests__',
|
|
21
|
+
'docs',
|
|
22
|
+
'doc',
|
|
23
|
+
'example',
|
|
24
|
+
'examples',
|
|
25
|
+
'fixtures',
|
|
26
|
+
'coverage'
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Scan source files of an external module directory.
|
|
31
|
+
*/
|
|
32
|
+
export async function scanModuleFiles(moduleDir, ecosystem) {
|
|
33
|
+
const files = {}
|
|
34
|
+
const walk = async (dirAbs, rel) => {
|
|
35
|
+
let entries
|
|
36
|
+
try {
|
|
37
|
+
entries = await fs.readdir(dirAbs, { withFileTypes: true })
|
|
38
|
+
} catch {
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
if (MODULE_DEFAULT_IGNORES.includes(entry.name.toLowerCase())) continue
|
|
45
|
+
await walk(path.join(dirAbs, entry.name), childRel)
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (!entry.isFile()) continue
|
|
49
|
+
const ext = path.extname(entry.name).toLowerCase()
|
|
50
|
+
if (!SYNTAX_LANGUAGES[ext]) continue
|
|
51
|
+
if (ecosystem === 'go' && entry.name.endsWith('_test.go')) continue
|
|
52
|
+
if (ecosystem === 'pypi' && entry.name.startsWith('test_')) continue
|
|
53
|
+
files[childRel] = path.join(dirAbs, entry.name)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
await walk(moduleDir, '')
|
|
57
|
+
return files
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse an eco::name string into components: { ecosystem, moduleName }
|
|
62
|
+
*/
|
|
63
|
+
export function parseModuleSpecifier(target) {
|
|
64
|
+
if (!target || typeof target !== 'string') return null
|
|
65
|
+
const idx = target.indexOf('::')
|
|
66
|
+
if (idx <= 0) return null
|
|
67
|
+
const ecosystem = target.slice(0, idx).toLowerCase()
|
|
68
|
+
const moduleName = target.slice(idx + 2)
|
|
69
|
+
if (!moduleName) return null
|
|
70
|
+
return { ecosystem, moduleName }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build graph for an external module from disk using Tree-Sitter.
|
|
75
|
+
*/
|
|
76
|
+
export async function buildModuleGraph(ecosystem, moduleName, options = {}) {
|
|
77
|
+
const resolved = options.location
|
|
78
|
+
? { location: options.location, version: options.version || '0.0.0', name: moduleName, ecosystem }
|
|
79
|
+
: await resolveModuleLocation(ecosystem, moduleName, options)
|
|
80
|
+
|
|
81
|
+
if (!resolved || !resolved.location) {
|
|
82
|
+
throw new Error(`Could not resolve module directory for ${ecosystem}::${moduleName} on disk. Ensure it is installed.`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const { location, version } = resolved
|
|
86
|
+
const model = defaultEcosystemModel(ecosystem)
|
|
87
|
+
const moduleNodeId = formatExternalPackageNodeId(ecosystem, moduleName, version)
|
|
88
|
+
|
|
89
|
+
const files = await scanModuleFiles(location, ecosystem)
|
|
90
|
+
const symbolNodes = []
|
|
91
|
+
const symbolNamesSet = new Set()
|
|
92
|
+
const edges = []
|
|
93
|
+
const symbolIndex = new Map() // name -> [id, ...]
|
|
94
|
+
const fileSymbolsMap = new Map()
|
|
95
|
+
|
|
96
|
+
for (const [rel, abs] of Object.entries(files)) {
|
|
97
|
+
const ext = path.extname(rel).toLowerCase()
|
|
98
|
+
let content
|
|
99
|
+
try {
|
|
100
|
+
content = await fs.readFile(abs, 'utf8')
|
|
101
|
+
} catch {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let extracted
|
|
106
|
+
try {
|
|
107
|
+
extracted = await extractCode(ext, content)
|
|
108
|
+
} catch {
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const nameCounts = new Map()
|
|
113
|
+
for (const sym of extracted.symbols || []) {
|
|
114
|
+
nameCounts.set(sym.name, (nameCounts.get(sym.name) || 0) + 1)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const currentFileSymbols = []
|
|
118
|
+
for (const sym of extracted.symbols || []) {
|
|
119
|
+
const symId = nameCounts.get(sym.name) > 1
|
|
120
|
+
? formatExternalSymbolNodeId(ecosystem, moduleName, version, `${sym.name}@${sym.line}`)
|
|
121
|
+
: formatExternalSymbolNodeId(ecosystem, moduleName, version, sym.name)
|
|
122
|
+
|
|
123
|
+
symbolNamesSet.add(sym.name)
|
|
124
|
+
currentFileSymbols.push({ ...sym, id: symId })
|
|
125
|
+
symbolIndex.set(sym.name, [...(symbolIndex.get(sym.name) || []), symId])
|
|
126
|
+
|
|
127
|
+
edges.push({ from: moduleNodeId, to: symId, type: 'defines' })
|
|
128
|
+
symbolNodes.push(new GraphNode({
|
|
129
|
+
id: symId,
|
|
130
|
+
title: sym.name,
|
|
131
|
+
type: 'symbol',
|
|
132
|
+
metadata: { kind: sym.kind, line: sym.line }
|
|
133
|
+
}))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
fileSymbolsMap.set(rel, { symbols: currentFileSymbols, calls: extracted.calls || [] })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Internal calls between symbols inside the module
|
|
140
|
+
for (const [, { symbols, calls }] of fileSymbolsMap.entries()) {
|
|
141
|
+
for (const call of calls) {
|
|
142
|
+
let owner = null
|
|
143
|
+
for (const sym of symbols) {
|
|
144
|
+
const start = sym.line - 1
|
|
145
|
+
const end = start + sym.lines
|
|
146
|
+
if (call.row >= start && call.row < end) {
|
|
147
|
+
if (!owner || sym.lines < owner.lines) owner = sym
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!owner) continue
|
|
151
|
+
const targetMatches = symbolIndex.get(call.name) || []
|
|
152
|
+
if (targetMatches.length === 1 && targetMatches[0] !== owner.id) {
|
|
153
|
+
edges.push({ from: owner.id, to: targetMatches[0], type: 'calls' })
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const moduleNode = new GraphNode({
|
|
159
|
+
id: moduleNodeId,
|
|
160
|
+
title: `${moduleName}@${version}`,
|
|
161
|
+
type: 'module',
|
|
162
|
+
metadata: { ecosystem, name: moduleName, version }
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
moduleNode,
|
|
167
|
+
symbolNodes,
|
|
168
|
+
edges,
|
|
169
|
+
symbols: [...symbolNamesSet].sort(),
|
|
170
|
+
name: moduleName,
|
|
171
|
+
version,
|
|
172
|
+
ecosystem,
|
|
173
|
+
model,
|
|
174
|
+
location
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Sync the constructed module graph to AnchorDB in model modules-<ecosystem>.
|
|
180
|
+
*/
|
|
181
|
+
export async function syncModuleToAnchorDB(client, moduleGraph, options = {}) {
|
|
182
|
+
if (!client || !moduleGraph) return { linked: 0, saved: 0, failed: 0 }
|
|
183
|
+
|
|
184
|
+
const desired = desiredSourceEdgeKeys(moduleGraph.edges)
|
|
185
|
+
let linked = 0
|
|
186
|
+
let failed = 0
|
|
187
|
+
|
|
188
|
+
for (const key of desired) {
|
|
189
|
+
const [from, type, to] = key.split('\0')
|
|
190
|
+
try {
|
|
191
|
+
await client.link(from, type, to, 1.0)
|
|
192
|
+
linked += 1
|
|
193
|
+
} catch {
|
|
194
|
+
failed += 1
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const pending = [
|
|
199
|
+
{ name: moduleGraph.moduleNode.id, props: { ...moduleGraph.moduleNode.metadata, tombstone: 0 } },
|
|
200
|
+
...moduleGraph.symbolNodes.map((s) => ({ name: s.id, props: { ...s.metadata, tombstone: 0 } }))
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
let saved = 0
|
|
204
|
+
if (typeof client.saveBatch === 'function') {
|
|
205
|
+
try {
|
|
206
|
+
await client.saveBatch(pending)
|
|
207
|
+
saved = pending.length
|
|
208
|
+
} catch {
|
|
209
|
+
for (const item of pending) {
|
|
210
|
+
try {
|
|
211
|
+
await client.saveWithID(item.name, item.props)
|
|
212
|
+
saved += 1
|
|
213
|
+
} catch {
|
|
214
|
+
failed += 1
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
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
|
+
|
|
229
|
+
await updateModulesCatalog(moduleGraph.ecosystem, {
|
|
230
|
+
[`${moduleGraph.name}@${moduleGraph.version}`]: {
|
|
231
|
+
name: moduleGraph.name,
|
|
232
|
+
version: moduleGraph.version,
|
|
233
|
+
ecosystem: moduleGraph.ecosystem,
|
|
234
|
+
model: moduleGraph.model,
|
|
235
|
+
nodeId: moduleGraph.moduleNode.id,
|
|
236
|
+
symbols: moduleGraph.symbols
|
|
237
|
+
}
|
|
238
|
+
}, options)
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
linked,
|
|
242
|
+
saved,
|
|
243
|
+
failed,
|
|
244
|
+
symbols: moduleGraph.symbols.length,
|
|
245
|
+
edges: moduleGraph.edges.length,
|
|
246
|
+
model: moduleGraph.model
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Trace lineage for a module or symbol in its ecosystem model.
|
|
252
|
+
*/
|
|
253
|
+
export async function traceModuleSymbol(client, targetId, { direction = 'both', maxHops = 2 } = {}) {
|
|
254
|
+
const forwardRelations = targetId.includes('#') ? ['calls'] : ['defines']
|
|
255
|
+
const backwardRelations = targetId.includes('#') ? ['called_by', 'defined_in'] : []
|
|
256
|
+
|
|
257
|
+
const rels = []
|
|
258
|
+
if (direction === 'out' || direction === 'both') rels.push(...forwardRelations)
|
|
259
|
+
if (direction === 'in' || direction === 'both') rels.push(...backwardRelations)
|
|
260
|
+
|
|
261
|
+
const visited = new Map() // id -> hop
|
|
262
|
+
visited.set(targetId, 0)
|
|
263
|
+
let frontier = [targetId]
|
|
264
|
+
|
|
265
|
+
for (let hop = 1; hop <= maxHops && frontier.length; hop++) {
|
|
266
|
+
const nextFrontier = []
|
|
267
|
+
for (const current of frontier) {
|
|
268
|
+
for (const rel of rels) {
|
|
269
|
+
let rows = []
|
|
270
|
+
try {
|
|
271
|
+
rows = await client.causalChain([current], rel, 100)
|
|
272
|
+
} catch {
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
for (const r of rows || []) {
|
|
276
|
+
if (!r?.word || visited.has(r.word)) continue
|
|
277
|
+
visited.set(r.word, hop)
|
|
278
|
+
nextFrontier.push(r.word)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
frontier = nextFrontier
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const results = []
|
|
286
|
+
for (const [id, hop] of visited.entries()) {
|
|
287
|
+
if (id !== targetId) results.push({ id, hop })
|
|
288
|
+
}
|
|
289
|
+
return results.sort((a, b) => a.hop - b.hop || a.id.localeCompare(b.id))
|
|
290
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { knlGlobalDir } from '../config.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns the path to the global modules catalog directory (~/.knlview/modules).
|
|
7
|
+
*/
|
|
8
|
+
export function modulesCatalogDir(home = null, options = {}) {
|
|
9
|
+
const base = knlGlobalDir(home, options)
|
|
10
|
+
return path.join(base, 'modules')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default global AnchorDB model name for an ecosystem.
|
|
15
|
+
*/
|
|
16
|
+
export function defaultEcosystemModel(ecosystem) {
|
|
17
|
+
return `modules-${ecosystem}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatModuleKey(pkgName, version) {
|
|
21
|
+
return `${pkgName}@${version}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatExternalPackageNodeId(ecosystem, pkgName, version) {
|
|
25
|
+
return `ext:${ecosystem}/${pkgName}@${version}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatExternalSymbolNodeId(ecosystem, pkgName, version, symbol) {
|
|
29
|
+
return `ext:${ecosystem}/${pkgName}@${version}#${symbol}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read the catalog for a specific ecosystem ('npm', 'go', 'cargo', 'pypi').
|
|
34
|
+
*/
|
|
35
|
+
export async function readModulesCatalog(ecosystem, options = {}) {
|
|
36
|
+
const dir = modulesCatalogDir(options.home, options)
|
|
37
|
+
const file = path.join(dir, `${ecosystem}.json`)
|
|
38
|
+
try {
|
|
39
|
+
const raw = await fs.readFile(file, 'utf8')
|
|
40
|
+
const parsed = JSON.parse(raw)
|
|
41
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
42
|
+
} catch {
|
|
43
|
+
return {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if a module version is already indexed in the catalog.
|
|
49
|
+
*/
|
|
50
|
+
export async function isModuleSynced(ecosystem, pkgName, version, options = {}) {
|
|
51
|
+
const catalog = await readModulesCatalog(ecosystem, options)
|
|
52
|
+
const key = formatModuleKey(pkgName, version)
|
|
53
|
+
return Boolean(catalog[key]?.syncedAt)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Record discovered modules and symbols to ~/.knlview/modules/<ecosystem>.json.
|
|
58
|
+
*/
|
|
59
|
+
export async function updateModulesCatalog(ecosystem, entries, options = {}) {
|
|
60
|
+
if (!entries || !Object.keys(entries).length) return
|
|
61
|
+
const dir = modulesCatalogDir(options.home, options)
|
|
62
|
+
const file = path.join(dir, `${ecosystem}.json`)
|
|
63
|
+
try {
|
|
64
|
+
await fs.mkdir(dir, { recursive: true })
|
|
65
|
+
const existing = await readModulesCatalog(ecosystem, options)
|
|
66
|
+
for (const [key, item] of Object.entries(entries)) {
|
|
67
|
+
const prev = existing[key] || {
|
|
68
|
+
name: item.name,
|
|
69
|
+
version: item.version,
|
|
70
|
+
ecosystem,
|
|
71
|
+
model: item.model || defaultEcosystemModel(ecosystem),
|
|
72
|
+
nodeId: item.nodeId || formatExternalPackageNodeId(ecosystem, item.name, item.version),
|
|
73
|
+
symbols: []
|
|
74
|
+
}
|
|
75
|
+
const symSet = new Set([...(prev.symbols || []), ...(item.symbols || [])])
|
|
76
|
+
existing[key] = {
|
|
77
|
+
name: item.name,
|
|
78
|
+
version: item.version,
|
|
79
|
+
ecosystem,
|
|
80
|
+
model: item.model || prev.model || defaultEcosystemModel(ecosystem),
|
|
81
|
+
nodeId: item.nodeId || prev.nodeId || formatExternalPackageNodeId(ecosystem, item.name, item.version),
|
|
82
|
+
symbols: [...symSet].sort(),
|
|
83
|
+
syncedAt: item.syncedAt || prev.syncedAt || Date.now()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await fs.writeFile(file, JSON.stringify(existing, null, 2) + '\n', 'utf8')
|
|
87
|
+
} catch {
|
|
88
|
+
// Fail gracefully: catalog is an informational index, never fatal.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Detect project dependency versions from on-disk manifests:
|
|
94
|
+
* package.json, go.mod, Cargo.toml, requirements.txt.
|
|
95
|
+
*/
|
|
96
|
+
export async function detectProjectManifests(rootAbs) {
|
|
97
|
+
const manifests = {
|
|
98
|
+
npm: {},
|
|
99
|
+
go: {},
|
|
100
|
+
cargo: {},
|
|
101
|
+
pypi: {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 1. npm (package.json)
|
|
105
|
+
try {
|
|
106
|
+
const pkgRaw = await fs.readFile(path.join(rootAbs, 'package.json'), 'utf8')
|
|
107
|
+
const pkg = JSON.parse(pkgRaw)
|
|
108
|
+
const all = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
|
|
109
|
+
for (const [name, ver] of Object.entries(all)) {
|
|
110
|
+
if (typeof ver === 'string') {
|
|
111
|
+
manifests.npm[name] = ver.replace(/^[\^~>=<]/, '').trim() || 'latest'
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch {}
|
|
115
|
+
|
|
116
|
+
// 2. Go (go.mod)
|
|
117
|
+
try {
|
|
118
|
+
const text = await fs.readFile(path.join(rootAbs, 'go.mod'), 'utf8')
|
|
119
|
+
const singleMatch = text.matchAll(/^require\s+([^\s()]+)\s+([^\s()]+)/gm)
|
|
120
|
+
for (const m of singleMatch) manifests.go[m[1]] = m[2]
|
|
121
|
+
const blockMatch = text.match(/require\s*\(([\s\S]*?)\)/m)
|
|
122
|
+
if (blockMatch) {
|
|
123
|
+
for (const line of blockMatch[1].split('\n')) {
|
|
124
|
+
const parts = line.trim().split(/\s+/)
|
|
125
|
+
if (parts.length >= 2 && !parts[0].startsWith('//')) {
|
|
126
|
+
manifests.go[parts[0]] = parts[1]
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
|
|
132
|
+
// 3. Rust (Cargo.toml)
|
|
133
|
+
try {
|
|
134
|
+
const text = await fs.readFile(path.join(rootAbs, 'Cargo.toml'), 'utf8')
|
|
135
|
+
let inDeps = false
|
|
136
|
+
for (const line of text.split('\n')) {
|
|
137
|
+
const trimmed = line.trim()
|
|
138
|
+
if (
|
|
139
|
+
trimmed.startsWith('[dependencies]') ||
|
|
140
|
+
trimmed.startsWith('[dev-dependencies]') ||
|
|
141
|
+
trimmed.startsWith('[build-dependencies]')
|
|
142
|
+
) {
|
|
143
|
+
inDeps = true
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
if (trimmed.startsWith('[')) {
|
|
147
|
+
inDeps = false
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
if (inDeps && trimmed && !trimmed.startsWith('#')) {
|
|
151
|
+
const eqIdx = trimmed.indexOf('=')
|
|
152
|
+
if (eqIdx > 0) {
|
|
153
|
+
const name = trimmed.slice(0, eqIdx).trim()
|
|
154
|
+
const val = trimmed.slice(eqIdx + 1).trim()
|
|
155
|
+
let ver = 'unknown'
|
|
156
|
+
const strMatch = val.match(/^"([^"]+)"/)
|
|
157
|
+
if (strMatch) ver = strMatch[1]
|
|
158
|
+
const objMatch = val.match(/version\s*=\s*"([^"]+)"/)
|
|
159
|
+
if (objMatch) ver = objMatch[1]
|
|
160
|
+
manifests.cargo[name] = ver
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch {}
|
|
165
|
+
|
|
166
|
+
// 4. Python (requirements.txt)
|
|
167
|
+
try {
|
|
168
|
+
const reqText = await fs.readFile(path.join(rootAbs, 'requirements.txt'), 'utf8')
|
|
169
|
+
for (const line of reqText.split('\n')) {
|
|
170
|
+
const trimmed = line.trim()
|
|
171
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
172
|
+
const m = trimmed.match(/^([a-zA-Z0-9_.-]+)(?:[=<>~!\s]*([a-zA-Z0-9_.-]+))?/)
|
|
173
|
+
if (m) manifests.pypi[m[1].toLowerCase()] = m[2] || 'unknown'
|
|
174
|
+
}
|
|
175
|
+
} catch {}
|
|
176
|
+
|
|
177
|
+
return manifests
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve the directory of an installed external module on disk.
|
|
182
|
+
* Supports npm (node_modules), Go (GOPATH/pkg/mod or vendor), Cargo (cargo cache or vendor),
|
|
183
|
+
* and Python (venv site-packages).
|
|
184
|
+
*/
|
|
185
|
+
export async function resolveModuleLocation(ecosystem, moduleName, options = {}) {
|
|
186
|
+
const root = path.resolve(options.projectRoot || process.cwd())
|
|
187
|
+
const manifests = options.manifests || await detectProjectManifests(root)
|
|
188
|
+
|
|
189
|
+
if (ecosystem === 'npm') {
|
|
190
|
+
// 1. Check node_modules in projectRoot and walk up if necessary
|
|
191
|
+
let curr = root
|
|
192
|
+
let foundDir = null
|
|
193
|
+
while (true) {
|
|
194
|
+
const candidate = path.join(curr, 'node_modules', moduleName)
|
|
195
|
+
try {
|
|
196
|
+
const stat = await fs.stat(candidate)
|
|
197
|
+
if (stat.isDirectory()) {
|
|
198
|
+
foundDir = candidate
|
|
199
|
+
break
|
|
200
|
+
}
|
|
201
|
+
} catch {}
|
|
202
|
+
const parent = path.dirname(curr)
|
|
203
|
+
if (parent === curr) break
|
|
204
|
+
curr = parent
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let version = manifests.npm[moduleName] || 'unknown'
|
|
208
|
+
if (foundDir) {
|
|
209
|
+
try {
|
|
210
|
+
const pkgText = await fs.readFile(path.join(foundDir, 'package.json'), 'utf8')
|
|
211
|
+
const pkg = JSON.parse(pkgText)
|
|
212
|
+
if (pkg.version) version = pkg.version
|
|
213
|
+
} catch {}
|
|
214
|
+
return { location: foundDir, version, name: moduleName, ecosystem }
|
|
215
|
+
}
|
|
216
|
+
} else if (ecosystem === 'go') {
|
|
217
|
+
// 1. Check local vendor
|
|
218
|
+
const vendorDir = path.join(root, 'vendor', moduleName)
|
|
219
|
+
try {
|
|
220
|
+
const stat = await fs.stat(vendorDir)
|
|
221
|
+
if (stat.isDirectory()) {
|
|
222
|
+
return { location: vendorDir, version: manifests.go[moduleName] || 'vendor', name: moduleName, ecosystem }
|
|
223
|
+
}
|
|
224
|
+
} catch {}
|
|
225
|
+
|
|
226
|
+
// 2. Check GOPATH
|
|
227
|
+
const gopath = process.env.GOPATH || path.join(options.home || process.env.HOME || '', 'go')
|
|
228
|
+
const version = manifests.go[moduleName]
|
|
229
|
+
if (version) {
|
|
230
|
+
const modDir = path.join(gopath, 'pkg', 'mod', `${moduleName}@${version}`)
|
|
231
|
+
try {
|
|
232
|
+
const stat = await fs.stat(modDir)
|
|
233
|
+
if (stat.isDirectory()) {
|
|
234
|
+
return { location: modDir, version, name: moduleName, ecosystem }
|
|
235
|
+
}
|
|
236
|
+
} catch {}
|
|
237
|
+
}
|
|
238
|
+
} else if (ecosystem === 'cargo') {
|
|
239
|
+
// 1. Check local vendor
|
|
240
|
+
const vendorDir = path.join(root, 'vendor', moduleName)
|
|
241
|
+
try {
|
|
242
|
+
const stat = await fs.stat(vendorDir)
|
|
243
|
+
if (stat.isDirectory()) {
|
|
244
|
+
return { location: vendorDir, version: manifests.cargo[moduleName] || 'vendor', name: moduleName, ecosystem }
|
|
245
|
+
}
|
|
246
|
+
} catch {}
|
|
247
|
+
|
|
248
|
+
// 2. Check cargo registry cache (~/.cargo/registry/src)
|
|
249
|
+
const cargoHome = process.env.CARGO_HOME || path.join(options.home || process.env.HOME || '', '.cargo')
|
|
250
|
+
const regSrc = path.join(cargoHome, 'registry', 'src')
|
|
251
|
+
const version = manifests.cargo[moduleName]
|
|
252
|
+
try {
|
|
253
|
+
const registries = await fs.readdir(regSrc)
|
|
254
|
+
for (const reg of registries) {
|
|
255
|
+
const targetPrefix = version ? `${moduleName}-${version}` : `${moduleName}-`
|
|
256
|
+
const regDir = path.join(regSrc, reg)
|
|
257
|
+
const entries = await fs.readdir(regDir)
|
|
258
|
+
const match = entries.find((e) => version ? e === targetPrefix : e.startsWith(targetPrefix))
|
|
259
|
+
if (match) {
|
|
260
|
+
const location = path.join(regDir, match)
|
|
261
|
+
const matchedVersion = version || match.slice(moduleName.length + 1)
|
|
262
|
+
return { location, version: matchedVersion, name: moduleName, ecosystem }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} catch {}
|
|
266
|
+
} else if (ecosystem === 'pypi') {
|
|
267
|
+
const version = manifests.pypi[moduleName.toLowerCase()] || 'unknown'
|
|
268
|
+
// 1. Check .venv and venv site-packages
|
|
269
|
+
const venvDirs = ['.venv', 'venv']
|
|
270
|
+
for (const venv of venvDirs) {
|
|
271
|
+
const libDir = path.join(root, venv, 'lib')
|
|
272
|
+
try {
|
|
273
|
+
const pyDirs = await fs.readdir(libDir)
|
|
274
|
+
for (const py of pyDirs) {
|
|
275
|
+
const sp = path.join(libDir, py, 'site-packages', moduleName)
|
|
276
|
+
try {
|
|
277
|
+
const stat = await fs.stat(sp)
|
|
278
|
+
if (stat.isDirectory()) {
|
|
279
|
+
return { location: sp, version, name: moduleName, ecosystem }
|
|
280
|
+
}
|
|
281
|
+
} catch {}
|
|
282
|
+
}
|
|
283
|
+
} catch {}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return null
|
|
288
|
+
}
|
package/server/graph/syntax.js
CHANGED
|
@@ -78,12 +78,14 @@ function firstDescendant(node, type) {
|
|
|
78
78
|
function specsFromTree(grammar, tree) {
|
|
79
79
|
const specs = []
|
|
80
80
|
const names = []
|
|
81
|
+
const bindings = []
|
|
81
82
|
const visit = (node) => {
|
|
82
83
|
if (grammar === 'javascript' || grammar === 'typescript' || grammar === 'tsx') {
|
|
83
84
|
if (node.type === 'import_statement' || node.type === 'export_statement') {
|
|
84
85
|
const source = node.childForFieldName('source')
|
|
85
86
|
const fragment = source && firstDescendant(source, 'string_fragment')
|
|
86
|
-
|
|
87
|
+
const specText = fragment ? fragment.text : null
|
|
88
|
+
if (specText) specs.push(specText)
|
|
87
89
|
// Local binding names a qualified call may be addressed through:
|
|
88
90
|
// the default import, the namespace alias, and each named import.
|
|
89
91
|
for (let i = 0; i < node.childCount; i++) {
|
|
@@ -91,17 +93,25 @@ function specsFromTree(grammar, tree) {
|
|
|
91
93
|
if (clause.type !== 'import_clause') continue
|
|
92
94
|
for (let j = 0; j < clause.childCount; j++) {
|
|
93
95
|
const part = clause.child(j)
|
|
94
|
-
if (part.type === 'identifier')
|
|
95
|
-
|
|
96
|
+
if (part.type === 'identifier') {
|
|
97
|
+
names.push(part.text)
|
|
98
|
+
if (specText) bindings.push({ name: part.text, spec: specText })
|
|
99
|
+
} else if (part.type === 'namespace_import') {
|
|
96
100
|
for (let k = 0; k < part.childCount; k++) {
|
|
97
|
-
if (part.child(k).type === 'identifier')
|
|
101
|
+
if (part.child(k).type === 'identifier') {
|
|
102
|
+
names.push(part.child(k).text)
|
|
103
|
+
if (specText) bindings.push({ name: part.child(k).text, spec: specText })
|
|
104
|
+
}
|
|
98
105
|
}
|
|
99
106
|
} else if (part.type === 'named_imports') {
|
|
100
107
|
for (let k = 0; k < part.childCount; k++) {
|
|
101
108
|
const specifier = part.child(k)
|
|
102
109
|
if (specifier.type !== 'import_specifier') continue
|
|
103
110
|
const alias = specifier.childForFieldName('alias') || specifier.childForFieldName('name')
|
|
104
|
-
if (alias)
|
|
111
|
+
if (alias) {
|
|
112
|
+
names.push(alias.text)
|
|
113
|
+
if (specText) bindings.push({ name: alias.text, spec: specText })
|
|
114
|
+
}
|
|
105
115
|
}
|
|
106
116
|
}
|
|
107
117
|
}
|
|
@@ -111,7 +121,8 @@ function specsFromTree(grammar, tree) {
|
|
|
111
121
|
if (fn && (fn.text === 'require' || fn.text === 'import')) {
|
|
112
122
|
const args = node.childForFieldName('arguments')
|
|
113
123
|
const fragment = args && firstDescendant(args, 'string_fragment')
|
|
114
|
-
|
|
124
|
+
const specText = fragment ? fragment.text : null
|
|
125
|
+
if (specText) specs.push(specText)
|
|
115
126
|
// `const x = require('y')` / `const x = await import('y')`: the
|
|
116
127
|
// declarator name is the binding: the visit starts above it, so
|
|
117
128
|
// walk down from any enclosing declarator via field lookup here.
|
|
@@ -119,7 +130,10 @@ function specsFromTree(grammar, tree) {
|
|
|
119
130
|
while (parent && parent.type === 'await_expression') parent = parent.parent
|
|
120
131
|
if (parent && parent.type === 'variable_declarator') {
|
|
121
132
|
const name = parent.childForFieldName('name')
|
|
122
|
-
if (name)
|
|
133
|
+
if (name) {
|
|
134
|
+
names.push(name.text)
|
|
135
|
+
if (specText) bindings.push({ name: name.text, spec: specText })
|
|
136
|
+
}
|
|
123
137
|
}
|
|
124
138
|
}
|
|
125
139
|
}
|
|
@@ -133,8 +147,9 @@ function specsFromTree(grammar, tree) {
|
|
|
133
147
|
// The name a qualified call uses: the declared alias, or the last
|
|
134
148
|
// path segment (Go's convention when no alias is given).
|
|
135
149
|
const alias = node.childForFieldName('name')
|
|
136
|
-
|
|
137
|
-
|
|
150
|
+
const name = alias ? alias.text : path.slice(path.lastIndexOf('/') + 1)
|
|
151
|
+
names.push(name)
|
|
152
|
+
bindings.push({ name, spec: path })
|
|
138
153
|
}
|
|
139
154
|
}
|
|
140
155
|
} else if (grammar === 'python') {
|
|
@@ -144,12 +159,18 @@ function specsFromTree(grammar, tree) {
|
|
|
144
159
|
if (c.type === 'dotted_name') {
|
|
145
160
|
specs.push(c.text)
|
|
146
161
|
const firstId = c.child(0)
|
|
147
|
-
if (firstId)
|
|
162
|
+
if (firstId) {
|
|
163
|
+
names.push(firstId.text)
|
|
164
|
+
bindings.push({ name: firstId.text, spec: c.text })
|
|
165
|
+
}
|
|
148
166
|
} else if (c.type === 'aliased_import') {
|
|
149
167
|
const name = c.childForFieldName('name')
|
|
150
168
|
const alias = c.childForFieldName('alias')
|
|
151
169
|
if (name) specs.push(name.text)
|
|
152
|
-
if (alias)
|
|
170
|
+
if (alias) {
|
|
171
|
+
names.push(alias.text)
|
|
172
|
+
bindings.push({ name: alias.text, spec: name ? name.text : alias.text })
|
|
173
|
+
}
|
|
153
174
|
}
|
|
154
175
|
}
|
|
155
176
|
} else if (node.type === 'import_from_statement') {
|
|
@@ -167,10 +188,14 @@ function specsFromTree(grammar, tree) {
|
|
|
167
188
|
if (c.type === 'dotted_name') {
|
|
168
189
|
names.push(c.text)
|
|
169
190
|
if (modText) specs.push(modText + '.' + c.text)
|
|
191
|
+
bindings.push({ name: c.text, spec: modText || c.text })
|
|
170
192
|
} else if (c.type === 'aliased_import') {
|
|
171
193
|
const name = c.childForFieldName('name')
|
|
172
194
|
const alias = c.childForFieldName('alias')
|
|
173
|
-
if (alias)
|
|
195
|
+
if (alias) {
|
|
196
|
+
names.push(alias.text)
|
|
197
|
+
bindings.push({ name: alias.text, spec: modText || (name ? name.text : alias.text) })
|
|
198
|
+
}
|
|
174
199
|
if (name && modText) specs.push(modText + '.' + name.text)
|
|
175
200
|
}
|
|
176
201
|
}
|
|
@@ -182,6 +207,7 @@ function specsFromTree(grammar, tree) {
|
|
|
182
207
|
if (name) {
|
|
183
208
|
specs.push('./' + name.text)
|
|
184
209
|
names.push(name.text)
|
|
210
|
+
bindings.push({ name: name.text, spec: './' + name.text })
|
|
185
211
|
}
|
|
186
212
|
}
|
|
187
213
|
} else if (node.type === 'use_declaration') {
|
|
@@ -204,16 +230,27 @@ function specsFromTree(grammar, tree) {
|
|
|
204
230
|
const aliasNode = n.childForFieldName('alias')
|
|
205
231
|
const full = (prefix ? prefix + '::' : '') + (pathNode ? pathNode.text : '')
|
|
206
232
|
specs.push(full)
|
|
207
|
-
|
|
233
|
+
const alias = aliasNode ? aliasNode.text : null
|
|
234
|
+
if (alias) {
|
|
235
|
+
names.push(alias)
|
|
236
|
+
const crateName = full.split('::')[0]
|
|
237
|
+
bindings.push({ name: alias, spec: crateName, full })
|
|
238
|
+
}
|
|
208
239
|
} else if (n.type === 'scoped_identifier') {
|
|
209
240
|
const full = (prefix ? prefix + '::' : '') + n.text
|
|
210
241
|
specs.push(full)
|
|
211
242
|
const name = n.childForFieldName('name')
|
|
212
|
-
if (name)
|
|
243
|
+
if (name) {
|
|
244
|
+
names.push(name.text)
|
|
245
|
+
const crateName = full.split('::')[0]
|
|
246
|
+
bindings.push({ name: name.text, spec: crateName, full })
|
|
247
|
+
}
|
|
213
248
|
} else if (n.type === 'identifier') {
|
|
214
249
|
const full = (prefix ? prefix + '::' : '') + n.text
|
|
215
250
|
specs.push(full)
|
|
216
251
|
names.push(n.text)
|
|
252
|
+
const crateName = full.split('::')[0]
|
|
253
|
+
bindings.push({ name: n.text, spec: crateName, full })
|
|
217
254
|
}
|
|
218
255
|
}
|
|
219
256
|
extractRustUse(node.childForFieldName('argument'))
|
|
@@ -222,7 +259,7 @@ function specsFromTree(grammar, tree) {
|
|
|
222
259
|
for (let i = 0; i < node.childCount; i++) visit(node.child(i))
|
|
223
260
|
}
|
|
224
261
|
visit(tree.rootNode)
|
|
225
|
-
return { specs, names }
|
|
262
|
+
return { specs, names, bindings }
|
|
226
263
|
}
|
|
227
264
|
|
|
228
265
|
/**
|
|
@@ -262,9 +299,9 @@ export async function extractCode(ext, content) {
|
|
|
262
299
|
const parser = parserFor(grammar, lang)
|
|
263
300
|
const tree = parser.parse(content)
|
|
264
301
|
try {
|
|
265
|
-
const { specs: imports, names: importNames } = specsFromTree(grammar, tree)
|
|
302
|
+
const { specs: imports, names: importNames, bindings: importBindings } = specsFromTree(grammar, tree)
|
|
266
303
|
const { symbols, calls } = structureFromTree(grammar, tree)
|
|
267
|
-
return { imports, importNames, symbols, calls }
|
|
304
|
+
return { imports, importNames, importBindings, symbols, calls }
|
|
268
305
|
} finally {
|
|
269
306
|
tree.delete()
|
|
270
307
|
}
|
package/server/scaffold.js
CHANGED
|
@@ -366,6 +366,12 @@ file or symbol is tombstoned (attribute \`tombstone = 2\`) after its
|
|
|
366
366
|
edges are unlinked; if it comes back, the tombstone lifts and the edges
|
|
367
367
|
are relinked.
|
|
368
368
|
|
|
369
|
+
External modules graph (third-party dependencies): stored in global AnchorDB
|
|
370
|
+
models per ecosystem (\`modules-npm\`, \`modules-go\`, \`modules-cargo\`, \`modules-pypi\`)
|
|
371
|
+
and cataloged in \`~/.knlview/modules/<eco>.json\`. Built on-demand via
|
|
372
|
+
\`knl-view modules build-graph <eco>::<name>\`, avoiding polluting project models.
|
|
373
|
+
Traceable via \`knl-view modules trace <eco>::<name> [symbol]\`.
|
|
374
|
+
|
|
369
375
|
Decision history skill: recording architectural decisions is delegated
|
|
370
376
|
to the separate decision-history skill (its decision block format is its
|
|
371
377
|
own domain; this skill never writes decision blocks). If it is not
|
|
@@ -454,6 +460,10 @@ this.
|
|
|
454
460
|
- Before renaming a symbol or moving it to another file: remember its
|
|
455
461
|
record id contains the old name/path. Sync tombstones the stale record
|
|
456
462
|
and creates the new one; trace by the new id afterwards.
|
|
463
|
+
- Before reading raw third-party library source files:
|
|
464
|
+
run \`knl-view modules build-graph <eco>::<module>\` (e.g. \`npm::express\`, \`go::github.com/gin-gonic/gin\`)
|
|
465
|
+
to index it on-demand into global AnchorDB model \`modules-<eco>\`, then trace
|
|
466
|
+
its methods with \`knl-view modules trace <eco>::<module> [symbol]\` to save context tokens.
|
|
457
467
|
- A new architectural decision: trigger the decision-history skill
|
|
458
468
|
(install it first if missing; see "Decision history skill" above).
|
|
459
469
|
The decision block format is that skill's domain; this skill never
|