@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/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rahmantech/knl-view",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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",
@@ -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
+ }