@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 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.2.0)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rahmantech/knl-view",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Local viewer for .knowledges knowledge bases: a CLI and a web app, with an optional AnchorDB graph layer.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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
+ }
@@ -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
- if (fragment) specs.push(fragment.text)
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') names.push(part.text)
95
- else if (part.type === 'namespace_import') {
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') names.push(part.child(k).text)
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) names.push(alias.text)
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
- if (fragment) specs.push(fragment.text)
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) names.push(name.text)
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
- if (alias) names.push(alias.text)
137
- else names.push(path.slice(path.lastIndexOf('/') + 1))
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) names.push(firstId.text)
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) names.push(alias.text)
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) names.push(alias.text)
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
- if (aliasNode) names.push(aliasNode.text)
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) names.push(name.text)
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
  }
@@ -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