@rahmantech/knl-view 0.0.0-stage → 0.1.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.
Files changed (115) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +398 -2
  3. package/bin/knl-view.js +943 -0
  4. package/dist/assets/abnfDiagram-WEYOIA3C-B1gaBaSI.js +1 -0
  5. package/dist/assets/arc-NZ9uRaCl.js +1 -0
  6. package/dist/assets/architecture-WOLXFQ4H-8MalLwTd.js +1 -0
  7. package/dist/assets/architectureDiagram-ZHVYKT53-eZ8i5cl4.js +36 -0
  8. package/dist/assets/array-BifhSqXX.js +1 -0
  9. package/dist/assets/blockDiagram-RJAE7QA3-BavOE8BM.js +140 -0
  10. package/dist/assets/c4Diagram-XDPPPE54-DqMaTAzx.js +38 -0
  11. package/dist/assets/channel-BZ2_TTTJ.js +1 -0
  12. package/dist/assets/chunk-2E7WHVMD-B1Hj2BjJ.js +88 -0
  13. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  14. package/dist/assets/chunk-4EMNKR2T-HDscrCiq.js +1 -0
  15. package/dist/assets/chunk-55XCU76K-QR_ARj8I.js +1 -0
  16. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  17. package/dist/assets/chunk-7S6XSTFO-B0p3o547.js +2 -0
  18. package/dist/assets/chunk-AQDTSKZB-CCxwNyyA.js +72 -0
  19. package/dist/assets/chunk-DSNLSHJZ-BM5PBFmz.js +129 -0
  20. package/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  21. package/dist/assets/chunk-HAGFOTED-UCDVFuN_.js +62 -0
  22. package/dist/assets/chunk-HNE4Q36P-C_1sakp2.js +1 -0
  23. package/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  24. package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  25. package/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  26. package/dist/assets/chunk-NQOKRVIF-b2D1Z4oK.js +213 -0
  27. package/dist/assets/chunk-UHW7PFBP-CJs0VwBY.js +1 -0
  28. package/dist/assets/chunk-VQ5W62QI-DQxjgE4_.js +1 -0
  29. package/dist/assets/chunk-WQQL4CDK-DcjPeAfQ.js +10 -0
  30. package/dist/assets/chunk-XXDRQBXY-glA0kB5s.js +1 -0
  31. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  32. package/dist/assets/chunk-YZO67HFG-DJqACL5V.js +1 -0
  33. package/dist/assets/chunk-ZAUX2LTX-CWfaYtwb.js +1 -0
  34. package/dist/assets/chunk-ZCHMQSOR-BGX73Hho.js +2 -0
  35. package/dist/assets/classDiagram-v2-4MYWR3UA-BpYXPz-e.js +217 -0
  36. package/dist/assets/cose-bilkent-JH36ORCC-e1quKm0Q.js +1 -0
  37. package/dist/assets/cynefin-EF2NZ3EQ-hb3oUmFO.js +1 -0
  38. package/dist/assets/cynefinDiagram-WF24UDRP-CnMQvLqi.js +62 -0
  39. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  40. package/dist/assets/dagre-NK4GIZVX-BH_Y8y3F.js +4 -0
  41. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  42. package/dist/assets/diagram-CBK3MWTC-Dh79VetZ.js +24 -0
  43. package/dist/assets/diagram-I5XIIV2D-D3u-o1RV.js +24 -0
  44. package/dist/assets/diagram-ODIL2UCW-Bfuo-OKS.js +30 -0
  45. package/dist/assets/diagram-PFBEJUXE-CwHasIbs.js +3 -0
  46. package/dist/assets/diagram-RJA3I4VV-D8ttmuE8.js +41 -0
  47. package/dist/assets/diagram-TYUNFSNR-CzC-8RZ0.js +200 -0
  48. package/dist/assets/dist-CUJYGnnI.js +1 -0
  49. package/dist/assets/ebnfDiagram-V4P3TTPT-9AAhU7In.js +1 -0
  50. package/dist/assets/elk-QONTBCTQ-BgvnD1v_.js +27 -0
  51. package/dist/assets/erDiagram-KYQIGQAA-RLtbiLlP.js +99 -0
  52. package/dist/assets/eventmodeling-K75KTNOO-DVY22vNZ.js +1 -0
  53. package/dist/assets/flowDiagram-IUGLLWBU-l7L5DTb5.js +1 -0
  54. package/dist/assets/ganttDiagram-R7GPNTJY-hHiBGa9W.js +292 -0
  55. package/dist/assets/gitGraph-VSP46ZUC-Cvxay1wh.js +1 -0
  56. package/dist/assets/gitGraphDiagram-PDYL7XHY-AOhlpSqg.js +106 -0
  57. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  58. package/dist/assets/index-CBLNQvK3.js +76 -0
  59. package/dist/assets/index-DLgMsUs2.css +1 -0
  60. package/dist/assets/info-OHQRW6UA-CJ8EUO1m.js +1 -0
  61. package/dist/assets/infoDiagram-MVDRKAX5-DOQu7oYI.js +2 -0
  62. package/dist/assets/init-C-OQMol4.js +1 -0
  63. package/dist/assets/ishikawaDiagram-HPEHV2GE-DcVMvGmx.js +70 -0
  64. package/dist/assets/journeyDiagram-BOSHW7O7-luMeqEqz.js +139 -0
  65. package/dist/assets/kanban-definition-FNSA4HC5-KTZbmrW2.js +89 -0
  66. package/dist/assets/katex-B_JcIwqj.js +257 -0
  67. package/dist/assets/line-CmPHiOVm.js +1 -0
  68. package/dist/assets/linear-DHYlKXBA.js +1 -0
  69. package/dist/assets/mermaid-parser.core-BRDI-WhC.js +7 -0
  70. package/dist/assets/mermaid.core-DPTRaYLy.js +44 -0
  71. package/dist/assets/mindmap-definition-6OY57UG3-70EN5Kw_.js +98 -0
  72. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  73. package/dist/assets/packet-JDAUHWVQ-CXWz83oS.js +1 -0
  74. package/dist/assets/path-fybaL0A-.js +1 -0
  75. package/dist/assets/pegDiagram-ECIUJNBX-z5UXKmp4.js +1 -0
  76. package/dist/assets/pie-XZMESJXO-rWI1HIQ7.js +1 -0
  77. package/dist/assets/pieDiagram-GNMPY74T-Bh4Kv3NQ.js +39 -0
  78. package/dist/assets/quadrantDiagram-D57K4MC3-wGM4Ckig.js +7 -0
  79. package/dist/assets/radar-ABXABTNO-CIJ92p5N.js +1 -0
  80. package/dist/assets/railroad-I3PHUGI6-8bxua-Hf.js +1 -0
  81. package/dist/assets/railroad-abnf-LNEFI6M7-B9KVI10m.js +1 -0
  82. package/dist/assets/railroad-ebnf-DWYD2UWJ-Bt73rnyJ.js +1 -0
  83. package/dist/assets/railroad-peg-JR7BVNR7-Cie82UPT.js +1 -0
  84. package/dist/assets/railroadDiagram-Y72NRBL7-C1nmi_tl.js +1 -0
  85. package/dist/assets/requirementDiagram-EQYSW5RA-BSianW40.js +84 -0
  86. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  87. package/dist/assets/sankeyDiagram-SY24JHO5-DIa11-h7.js +40 -0
  88. package/dist/assets/sequenceDiagram-BLEHNHQU-Df5rZKz2.js +169 -0
  89. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  90. package/dist/assets/src-dClg85lX.js +1 -0
  91. package/dist/assets/stateDiagram-v2-A6IESKWZ-B15PTSvX.js +291 -0
  92. package/dist/assets/swimlanes-B3ENRKFQ-C9mgRCMi.js +1 -0
  93. package/dist/assets/swimlanesDiagram-AYQRV4AK-BGFq4t7O.js +8 -0
  94. package/dist/assets/timeline-definition-JXYKGLGB-BDVf_mIv.js +120 -0
  95. package/dist/assets/treeView-D4JQ5SDB-BbBK-25J.js +1 -0
  96. package/dist/assets/treemap-SAJKECNS-CEDX8apk.js +1 -0
  97. package/dist/assets/usecaseDiagram-WPOLBNNX-D1QkwFF8.js +328 -0
  98. package/dist/assets/vennDiagram-Q3Y3IDCW-5PeGZvEO.js +34 -0
  99. package/dist/assets/wardley-7MLQ67FV-BatN94pO.js +1 -0
  100. package/dist/assets/wardleyDiagram-IVAIDKWA-DyCcDS3Y.js +78 -0
  101. package/dist/assets/xychartDiagram-4URNHDU4-CbCXcRlg.js +7 -0
  102. package/dist/index.html +22 -0
  103. package/package.json +46 -4
  104. package/server/config.js +263 -0
  105. package/server/consensus.js +175 -0
  106. package/server/graph/anchordb_client.js +253 -0
  107. package/server/graph/model.js +157 -0
  108. package/server/graph/parser.js +326 -0
  109. package/server/graph/sources.js +660 -0
  110. package/server/graph/syntax.js +257 -0
  111. package/server/index.js +679 -0
  112. package/server/install.js +97 -0
  113. package/server/map.js +126 -0
  114. package/server/models_registry.js +84 -0
  115. package/server/scaffold.js +376 -0
@@ -0,0 +1,660 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { GraphNode } from './model.js'
4
+ import { edgeKey } from './anchordb_client.js'
5
+ import { SYNTAX_LANGUAGES, extractCode } from './syntax.js'
6
+
7
+ // Sources graph: the whole project as a dependency graph in AnchorDB, not
8
+ // just the .knowledges markdown. Nodes are the files (id = repo relative
9
+ // path) and the named symbols they define (id = `<path>#<name>`, from
10
+ // tree-sitter syntax extraction: JS family and Go today, one uniform API
11
+ // per language). Edges: file imports file, file defines symbol, symbol
12
+ // calls symbol, each stored in both directions because causalChain walks
13
+ // forward only. Hidden paths (dot-prefixed) are skipped except .github;
14
+ // node_modules, dist, and .gitignore patterns are skipped so dependency
15
+ // folders cannot flood the graph.
16
+ //
17
+ // Everything lives in the server. Nodes are records with numeric attributes
18
+ // only (AnchorDB admits no text), enumerated with list(); sync is
19
+ // server-authoritative: it enumerates what the server already has instead
20
+ // of trusting a local mirror. Label text (symbol names and kinds) lives in
21
+ // the project's label dir (.knowledges/.knl-data by default, overridable
22
+ // via the project config's labelDir), regenerated on every sync.
23
+ //
24
+ // Removed files: the record is archived with opcode 2 (TOMBSTONE, hidden
25
+ // from list and lookups) and purged by a collector run; a save revives it.
26
+
27
+ export const SOURCE_RELATIONS = ['imports', 'imported_by']
28
+ export const SOURCE_TOMBSTONE = 2
29
+ // OpStatus on the wire: 1 = Active, 2 = Archived (TOMBSTONE).
30
+ export const OP_ACTIVE = 1
31
+ export const OP_ARCHIVED = 2
32
+ // Symbol layer, same AnchorDB model as the files: a file defines symbols,
33
+ // symbols call symbols. Mirrored both directions like imports, for the same
34
+ // reason: causalChain walks forward only.
35
+ export const SYMBOL_RELATIONS = ['defines', 'defined_in', 'calls', 'called_by']
36
+ export const SYNC_RELATIONS = [...SOURCE_RELATIONS, ...SYMBOL_RELATIONS]
37
+
38
+ const REVERSE_EDGE = {
39
+ imports: 'imported_by',
40
+ defines: 'defined_in',
41
+ calls: 'called_by'
42
+ }
43
+
44
+ // Where generated label files live inside the target project: the label
45
+ // text AnchorDB cannot hold (numeric-only attributes). Overridable via the
46
+ // project config's labelDir.
47
+ const DEFAULT_LABEL_DIR = '.knowledges/.knl-data'
48
+ const DEFAULT_IGNORES = ['node_modules', 'dist', 'build', 'coverage']
49
+
50
+ const JS_EXTS = new Set(['.js', '.mjs', '.cjs'])
51
+
52
+ /** One ignore rule after parsing a .gitignore line. */
53
+ function ignoreRule(line) {
54
+ const trimmed = line.trim()
55
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) return null
56
+ const dirOnly = trimmed.endsWith('/')
57
+ const pattern = trimmed.replace(/\/+$/, '')
58
+ return { dirOnly, pattern }
59
+ }
60
+
61
+ /**
62
+ * Size in whole KB and mtime in whole days since the epoch: the two numeric
63
+ * attributes AnchorDB can hold for a file. They are informational; change
64
+ * detection never depends on them, it diffs the edge sets.
65
+ */
66
+ function fileAttrs(stat) {
67
+ return { size: Math.ceil(stat.size / 1024), mtime: Math.floor(stat.mtimeMs / 86400000) }
68
+ }
69
+
70
+ /**
71
+ * Walk the project root and return every file that belongs in the sources
72
+ * graph: { rel -> { size, mtime } }. Skips dot-prefixed folders except
73
+ * .github, the default ignore list, and basic .gitignore patterns.
74
+ */
75
+ export async function scanSourceFiles(rootAbs) {
76
+ const rules = []
77
+ try {
78
+ const gitignore = await fs.readFile(path.join(rootAbs, '.gitignore'), 'utf8')
79
+ for (const line of gitignore.split('\n')) {
80
+ const rule = ignoreRule(line)
81
+ if (rule) rules.push(rule)
82
+ }
83
+ } catch {
84
+ // No .gitignore: the default list still applies.
85
+ }
86
+ rules.push(...DEFAULT_IGNORES.map((pattern) => ({ dirOnly: true, pattern })))
87
+
88
+ const ignored = (rel) => {
89
+ const segments = rel.split('/')
90
+ if (segments.some((s) => s.startsWith('.') && s !== '.github')) return true
91
+ return segments.some((segment) =>
92
+ rules.some((rule) => {
93
+ if (rule.pattern.includes('*')) {
94
+ const re = new RegExp('^' + rule.pattern.replace(/\*/g, '[^/]*') + '$')
95
+ return re.test(segment) || re.test(rel)
96
+ }
97
+ if (rule.dirOnly) return segments.includes(rule.pattern)
98
+ return segment === rule.pattern || rel === rule.pattern
99
+ })
100
+ )
101
+ }
102
+
103
+ const files = {}
104
+ const walk = async (dirAbs, rel) => {
105
+ let entries
106
+ try {
107
+ entries = await fs.readdir(dirAbs, { withFileTypes: true })
108
+ } catch {
109
+ return
110
+ }
111
+ for (const entry of entries) {
112
+ if (entry.name.startsWith('.') && entry.name !== '.github') continue
113
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name
114
+ if (ignored(childRel)) continue
115
+ const childAbs = path.join(dirAbs, entry.name)
116
+ if (entry.isDirectory()) {
117
+ await walk(childAbs, childRel)
118
+ continue
119
+ }
120
+ if (!entry.isFile()) continue
121
+ try {
122
+ files[childRel] = fileAttrs(await fs.stat(childAbs))
123
+ } catch {
124
+ // Raced with a deletion between readdir and stat: skip the file.
125
+ }
126
+ }
127
+ }
128
+ await walk(rootAbs, '')
129
+ return files
130
+ }
131
+
132
+ /**
133
+ * Relative specifiers from one JS file's text: ESM import/export sources,
134
+ * dynamic import(), and CommonJS require(). Bare and URL specifiers are
135
+ * skipped; only './' and '../' resolve to project files.
136
+ *
137
+ * Extraction itself is tree-sitter (syntax.js); this function is the JS
138
+ * half of resolution: which extracted specifiers point at project files.
139
+ */
140
+ export function resolveJsSpecifiers(fromRel, specs, files) {
141
+ const relative = specs.filter((spec) => spec.startsWith('./') || spec.startsWith('../'))
142
+ return relative.map((spec) => resolveImportSpecifier(fromRel, spec, files)).filter(Boolean)
143
+ }
144
+
145
+ /**
146
+ * Module path declared by the project's go.mod, or null when there is
147
+ * none. Go imports only resolve against it: a specifier equal to the
148
+ * module path or starting with it is local, everything else (standard
149
+ * library, remote modules) is external to the graph.
150
+ */
151
+ export async function goModulePath(rootAbs) {
152
+ try {
153
+ const text = await fs.readFile(path.join(rootAbs, 'go.mod'), 'utf8')
154
+ const match = text.match(/^module\s+(\S+)/m)
155
+ return match ? match[1] : null
156
+ } catch {
157
+ return null
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Resolve one Go import specifier to the local files of the imported
163
+ * package: every .go file in the specifier's directory below the module
164
+ * root. Returns [] for anything outside the module.
165
+ */
166
+ export function resolveGoSpecifiers(spec, modulePath, files) {
167
+ if (!modulePath) return []
168
+ let sub
169
+ if (spec === modulePath) sub = ''
170
+ else if (spec.startsWith(modulePath + '/')) sub = spec.slice(modulePath.length + 1)
171
+ else return []
172
+ const dir = sub === '' ? '.' : sub
173
+ return Object.keys(files).filter((rel) => rel.endsWith('.go') && path.posix.dirname(rel) === dir)
174
+ }
175
+
176
+ /**
177
+ * Resolve one relative specifier from file `fromRel` against the set of
178
+ * scanned files. Tries the exact path, the JS extensions, and an index
179
+ * file inside the directory.
180
+ */
181
+ export function resolveImportSpecifier(fromRel, spec, files) {
182
+ const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromRel), spec))
183
+ const candidates = [base]
184
+ for (const ext of JS_EXTS) candidates.push(base + ext)
185
+ for (const ext of JS_EXTS) candidates.push(`${base}/index${ext}`)
186
+ return candidates.find((candidate) => files[candidate]) || null
187
+ }
188
+
189
+ /**
190
+ * Build the desired sources graph: nodes for every scanned file plus one
191
+ * node per named symbol, and edges from tree-sitter extraction (file-level
192
+ * imports, file-defines-symbol, symbol-calls-symbol). Symbol node ids are
193
+ * `<path>#<name>`; class methods are `Class.method`. Returns plain arrays
194
+ * so callers can diff without depending on the DirectedGraph type.
195
+ */
196
+ export async function buildSourcesGraph(rootAbs, files = null) {
197
+ const scanned = files || await scanSourceFiles(rootAbs)
198
+ const modulePath = await goModulePath(rootAbs)
199
+ const edges = []
200
+ const symbolIndex = new Map() // symbol name -> [node id, ...] for call resolution
201
+ const symbolsByFile = new Map() // file rel -> symbol nodes
202
+
203
+ const structure = {}
204
+ for (const [rel] of Object.entries(scanned)) {
205
+ const ext = path.posix.extname(rel).toLowerCase()
206
+ if (!SYNTAX_LANGUAGES[ext]) continue
207
+ let content
208
+ try {
209
+ content = await fs.readFile(path.join(rootAbs, rel), 'utf8')
210
+ } catch {
211
+ continue
212
+ }
213
+ let extracted
214
+ try {
215
+ extracted = await extractCode(ext, content)
216
+ } catch {
217
+ continue
218
+ }
219
+ structure[rel] = { ext, modulePath, ...extracted }
220
+
221
+ // A name defined more than once in one file would collide on one
222
+ // AnchorDB record id, so repeated names get a line suffix: `@<line>`.
223
+ const nameCounts = new Map()
224
+ for (const symbol of extracted.symbols) {
225
+ nameCounts.set(symbol.name, (nameCounts.get(symbol.name) || 0) + 1)
226
+ }
227
+ const fileSymbols = extracted.symbols.map((symbol) => ({
228
+ ...symbol,
229
+ id: nameCounts.get(symbol.name) > 1
230
+ ? `${rel}#${symbol.name}@${symbol.line}`
231
+ : `${rel}#${symbol.name}`
232
+ }))
233
+ if (fileSymbols.length) symbolsByFile.set(rel, fileSymbols)
234
+ // ResolveCallTargets builds call edges *from* these symbols, so store
235
+ // the id-carrying list (duplicate names already line-suffixed here).
236
+ structure[rel].symbols = fileSymbols
237
+ for (const symbol of fileSymbols) {
238
+ symbolIndex.set(symbol.name, [...(symbolIndex.get(symbol.name) || []), symbol.id])
239
+ edges.push({ from: rel, to: symbol.id, type: 'defines' })
240
+ }
241
+ }
242
+
243
+ for (const [rel, file] of Object.entries(structure)) {
244
+ const targets = file.ext === '.go'
245
+ ? file.imports.flatMap((spec) => resolveGoSpecifiers(spec, file.modulePath, scanned))
246
+ : resolveJsSpecifiers(rel, file.imports, scanned)
247
+ for (const target of targets) {
248
+ if (target && target !== rel) edges.push({ from: rel, to: target, type: 'imports' })
249
+ }
250
+ for (const call of resolveCallTargets(rel, file, symbolIndex, scanned)) {
251
+ edges.push({ from: call.from, to: call.to, type: 'calls' })
252
+ }
253
+ }
254
+
255
+ const nodes = Object.entries(scanned).map(([rel, attrs]) => new GraphNode({
256
+ id: rel,
257
+ title: rel,
258
+ type: 'file',
259
+ path: rel,
260
+ metadata: attrs
261
+ }))
262
+ for (const [rel, list] of symbolsByFile.entries()) {
263
+ for (const symbol of list) {
264
+ nodes.push(new GraphNode({
265
+ id: symbol.id,
266
+ title: symbol.id,
267
+ type: 'symbol',
268
+ path: rel,
269
+ metadata: { line: symbol.line, lines: symbol.lines }
270
+ }))
271
+ }
272
+ }
273
+ return { nodes, edges, symbolsByFile }
274
+ }
275
+
276
+ /**
277
+ * Resolve a file's call sites to symbol nodes. The call is attributed to
278
+ * the innermost enclosing symbol by line range; the target is resolved by
279
+ * name: a name defined exactly once project-wide is unambiguous, anything
280
+ * else is skipped rather than guessed. Go additionally accepts a qualified
281
+ * callee (`Recv.Method`, `pkg.Fn`) and prefers a symbol defined in the
282
+ * same package directory.
283
+ */
284
+ export function resolveCallTargets(fromRel, file, symbolIndex, scanned) {
285
+ const symbols = file.symbols || []
286
+ const dir = path.posix.dirname(fromRel)
287
+ // A candidate resolves when exactly one definition matches, preferring
288
+ // definitions in the same package directory; anything ambiguous or
289
+ // external stays out of the graph.
290
+ const resolve = (key) => {
291
+ const ids = symbolIndex.get(key) || []
292
+ const local = ids.filter((id) => path.posix.dirname(id.split('#')[0]) === dir)
293
+ const pick = local.length === 1 ? local : ids
294
+ return pick.length === 1 ? pick[0] : null
295
+ }
296
+ const importNames = file.importNames instanceof Set
297
+ ? file.importNames
298
+ : new Set(file.importNames || [])
299
+ const targets = []
300
+ for (const call of file.calls || []) {
301
+ // Innermost symbol containing the call row: same file, so ordering by
302
+ // start line and taking the narrowest match is enough.
303
+ let owner = null
304
+ for (const symbol of symbols) {
305
+ const start = symbol.line - 1
306
+ const end = start + symbol.lines
307
+ if (call.row >= start && call.row < end) {
308
+ if (!owner || symbol.lines < owner.lines) owner = symbol
309
+ }
310
+ }
311
+ if (!owner) continue
312
+ const from = owner.id || `${fromRel}#${owner.name}`
313
+ // Bare-name fallback needs import evidence: an unproven receiver like
314
+ // `s.logger.Log()` would be absorbed by the one project-wide `Log()`.
315
+ if (call.recv && !importNames.has(call.recv)) continue
316
+ const candidates = []
317
+ if (call.recv) candidates.push(`${call.recv}.${call.name}`)
318
+ candidates.push(call.name)
319
+ for (const key of candidates) {
320
+ const to = resolve(key)
321
+ if (to && to !== from) {
322
+ targets.push({ from, to })
323
+ break
324
+ }
325
+ }
326
+ }
327
+ return targets
328
+ }
329
+
330
+ /**
331
+ * Desired edge keys in both directions for every relation type, for
332
+ * diffing against the server.
333
+ */
334
+ export function desiredSourceEdgeKeys(edges) {
335
+ const keys = new Set()
336
+ for (const { from, to, type = 'imports' } of edges) {
337
+ keys.add(edgeKey(from, type, to))
338
+ keys.add(edgeKey(to, REVERSE_EDGE[type], from))
339
+ }
340
+ return keys
341
+ }
342
+
343
+ /**
344
+ * Label text for one file's symbols: the information AnchorDB cannot hold
345
+ * (attributes are numeric-only). Written under the label dir as a mirror
346
+ * of the repo-relative path, one .md per file that has symbols.
347
+ */
348
+ export function labelContent(rel, symbols) {
349
+ const lines = [`# ${rel}`, '']
350
+ for (const symbol of symbols) {
351
+ lines.push(`- \`${symbol.name}\` (${symbol.kind}, line ${symbol.line})`)
352
+ }
353
+ return lines.join('\n') + '\n'
354
+ }
355
+
356
+ /**
357
+ * Reconcile the label files on disk with the current symbol state:
358
+ * write files whose content changed, delete files whose file or last
359
+ * symbol is gone. Generated files only; never touches anything else.
360
+ */
361
+ export async function writeLabelFiles(rootAbs, labelDir = DEFAULT_LABEL_DIR, symbolsByFile) {
362
+ const base = path.join(rootAbs, labelDir)
363
+ const expected = new Map()
364
+ for (const [rel, symbols] of symbolsByFile) expected.set(rel, labelContent(rel, symbols))
365
+
366
+ const existing = new Set()
367
+ const walk = async (dir, rel) => {
368
+ let entries
369
+ try {
370
+ entries = await fs.readdir(dir, { withFileTypes: true })
371
+ } catch {
372
+ return
373
+ }
374
+ for (const entry of entries) {
375
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name
376
+ if (entry.isDirectory()) await walk(path.join(dir, entry.name), childRel)
377
+ else if (entry.isFile() && entry.name.endsWith('.md')) existing.add(childRel.slice(0, -3))
378
+ }
379
+ }
380
+ await walk(base, '')
381
+
382
+ const written = []
383
+ const deleted = []
384
+ for (const [rel, content] of expected) {
385
+ const abs = path.join(base, `${rel}.md`)
386
+ let current = null
387
+ try {
388
+ current = await fs.readFile(abs, 'utf8')
389
+ } catch {
390
+ // No file yet: it will be written below.
391
+ }
392
+ if (current === content) continue
393
+ try {
394
+ await fs.mkdir(path.dirname(abs), { recursive: true })
395
+ await fs.writeFile(abs, content, 'utf8')
396
+ written.push(rel)
397
+ } catch {
398
+ // Label files are a convenience; a failed write is not a sync failure.
399
+ }
400
+ }
401
+ for (const rel of existing) {
402
+ if (expected.has(rel)) continue
403
+ try {
404
+ await fs.rm(path.join(base, `${rel}.md`), { force: true })
405
+ deleted.push(rel)
406
+ } catch {
407
+ // Best effort too: a failed delete leaves one stale label file.
408
+ }
409
+ }
410
+ return { written, deleted }
411
+ }
412
+
413
+ /**
414
+ * Server-authoritative sync. Enumerates the server's own nodes (list) and
415
+ * edges (one causalChain per node per relation), diffs them against the
416
+ * freshly scanned disk state, and applies the difference. No local mirror:
417
+ * the server is the single source of truth.
418
+ *
419
+ * Records of removed files and removed symbols are not deletable (the
420
+ * protocol has no delete), so their edges are unlinked and the record is
421
+ * tombstoned, invisible to every later sync and trace.
422
+ *
423
+ * @returns {Promise<{linked, unlinked, saved, tombstoned, failed, orphans,
424
+ * files, symbols, edges, labels}>}
425
+ */
426
+ export async function syncSourcesToAnchorDB(client, rootAbs, { labelDir } = {}) {
427
+ const { nodes, edges, symbolsByFile } = await buildSourcesGraph(rootAbs)
428
+ const desired = desiredSourceEdgeKeys(edges)
429
+ const diskIds = new Set(nodes.map((node) => node.id))
430
+
431
+ // Page through the full record list: server truth for which nodes exist.
432
+ // Records the server already archived (opcode 2) never appear here, so a
433
+ // tombstone is written once and never revisited.
434
+ const serverAttrs = new Map()
435
+ let offset = 0
436
+ for (;;) {
437
+ const page = await client.list({ limit: 500, offset })
438
+ for (const item of page.items || []) {
439
+ serverAttrs.set(item.name, item.props || {})
440
+ }
441
+ if (!page.hasMore || !page.items?.length) break
442
+ offset += page.items.length
443
+ }
444
+
445
+ // Server truth for edges: walk every known node once per relation.
446
+ const existing = new Set()
447
+ const seeds = new Set([...serverAttrs.keys()])
448
+ for (const { from, to } of edges) {
449
+ seeds.add(from)
450
+ seeds.add(to)
451
+ }
452
+ for (const seed of seeds) {
453
+ for (const relation of SYNC_RELATIONS) {
454
+ let rows
455
+ try {
456
+ rows = await client.causalChain([seed], relation, 1000)
457
+ } catch {
458
+ continue
459
+ }
460
+ for (const row of rows || []) existing.add(edgeKey(seed, relation, row.word))
461
+ }
462
+ }
463
+
464
+ let linked = 0
465
+ let unlinked = 0
466
+ let saved = 0
467
+ let tombstoned = 0
468
+ let failed = 0
469
+
470
+ for (const key of desired) {
471
+ if (existing.has(key)) continue
472
+ const [from, type, to] = key.split('\u0000')
473
+ try {
474
+ await client.link(from, type, to, 1.0)
475
+ linked += 1
476
+ } catch {
477
+ failed += 1
478
+ }
479
+ }
480
+ for (const key of existing) {
481
+ if (desired.has(key)) continue
482
+ const [from, type, to] = key.split('\u0000')
483
+ try {
484
+ await client.unlink(from, type, to)
485
+ unlinked += 1
486
+ } catch {
487
+ failed += 1
488
+ }
489
+ }
490
+
491
+ // Files gone from disk: edges are already unlinked; archive the record
492
+ // (opcode 2) and keep the tombstone attr for servers without opcodes.
493
+ const pending = []
494
+ for (const name of serverAttrs.keys()) {
495
+ if (diskIds.has(name)) continue
496
+ pending.push({ name, props: { tombstone: SOURCE_TOMBSTONE }, operation: OP_ARCHIVED, tombstone: true })
497
+ }
498
+
499
+ // Node records carry the numeric attributes. Existing records are only
500
+ // re-saved when their attributes changed; tombstone 0 keeps the alive
501
+ // state explicit even if the server merges attributes.
502
+ for (const node of nodes) {
503
+ const current = serverAttrs.get(node.id)
504
+ if (current && current.size === node.metadata.size && current.mtime === node.metadata.mtime
505
+ && current.line === node.metadata.line && current.lines === node.metadata.lines) continue
506
+ pending.push({ name: node.id, props: { ...node.metadata, tombstone: 0 } })
507
+ }
508
+
509
+ // Records alive on the server after this sync: pre-existing ones plus
510
+ // whatever this sync successfully saved.
511
+ const live = new Set(serverAttrs.keys())
512
+
513
+ // One transactional batch when the wire client supports it (faster than
514
+ // per-record saves at scale); per-record fallback keeps a single bad
515
+ // record from losing the rest.
516
+ const saveOne = async (item) => {
517
+ await client.saveWithID(item.name, item.props)
518
+ if (item.tombstone) tombstoned += 1
519
+ else {
520
+ saved += 1
521
+ live.add(item.name)
522
+ }
523
+ }
524
+ if (pending.length) {
525
+ if (typeof client.saveBatch === 'function') {
526
+ try {
527
+ await client.saveBatch(pending)
528
+ for (const item of pending) {
529
+ if (item.tombstone) tombstoned += 1
530
+ else {
531
+ saved += 1
532
+ live.add(item.name)
533
+ }
534
+ }
535
+ } catch {
536
+ // Batch refused as a whole: retry one by one.
537
+ for (const item of pending) {
538
+ try {
539
+ await saveOne(item)
540
+ } catch {
541
+ failed += 1
542
+ }
543
+ }
544
+ }
545
+ } else {
546
+ for (const item of pending) {
547
+ try {
548
+ await saveOne(item)
549
+ } catch {
550
+ failed += 1
551
+ }
552
+ }
553
+ }
554
+ }
555
+
556
+ // Integrity check: every desired edge endpoint must exist as a live
557
+ // record on the server. An edge whose endpoint failed to save would
558
+ // dangle; count those, never hide.
559
+ let orphans = 0
560
+ for (const { from, to } of edges) {
561
+ if (!live.has(from) || !live.has(to)) orphans += 1
562
+ }
563
+
564
+ const labels = await writeLabelFiles(rootAbs, labelDir || DEFAULT_LABEL_DIR, symbolsByFile)
565
+
566
+ const files = nodes.filter((node) => node.type === 'file').length
567
+ const symbols = nodes.filter((node) => node.type === 'symbol').length
568
+ return {
569
+ linked,
570
+ unlinked,
571
+ saved,
572
+ tombstoned,
573
+ failed,
574
+ orphans,
575
+ files,
576
+ symbols,
577
+ edges: desired.size / 2,
578
+ labels: { written: labels.written.length, deleted: labels.deleted.length }
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Lineage over the sources graph, entirely server-side: files walk
584
+ * imports/imported_by, symbols (ids containing '#') walk calls/called_by.
585
+ * causalChain is direct-only, so hops beyond one are walked level by
586
+ * level. Returns [id, hop] entries per direction; callers shape the
587
+ * response.
588
+ */
589
+ export async function traceSources(client, target, direction = 'both', maxHops = 2) {
590
+ const symbol = target.includes('#')
591
+ const outRelation = symbol ? 'calls' : 'imports'
592
+ const inRelation = symbol ? 'called_by' : 'imported_by'
593
+ const walk = async (start, relation) => {
594
+ const seen = new Map()
595
+ let frontier = [start]
596
+ for (let hop = 1; hop <= maxHops; hop++) {
597
+ const next = []
598
+ for (const node of frontier) {
599
+ let rows
600
+ try {
601
+ rows = await client.causalChain([node], relation, 1000)
602
+ } catch {
603
+ continue
604
+ }
605
+ for (const row of rows || []) {
606
+ if (!seen.has(row.word)) {
607
+ seen.set(row.word, hop)
608
+ next.push(row.word)
609
+ }
610
+ }
611
+ }
612
+ frontier = next
613
+ if (!next.length) break
614
+ }
615
+ return [...seen.entries()].filter(([id]) => id !== start)
616
+ }
617
+ const out = direction === 'in' ? [] : await walk(target, outRelation)
618
+ const inc = direction === 'out' ? [] : await walk(target, inRelation)
619
+ return { out, in: inc }
620
+ }
621
+
622
+ /**
623
+ * HTTP URL of the administration endpoint for a wire:// URL. AnchorDB
624
+ * listens wire on one port and REST on the port below it by default
625
+ * (7448/7447); both are plain host:port.
626
+ */
627
+ export function collectorHttpUrl(wireUrl) {
628
+ const match = /^wire:\/\/([^?]+)/i.exec(String(wireUrl))
629
+ if (!match) return null
630
+ const hostPort = match[1]
631
+ const at = hostPort.lastIndexOf(':')
632
+ if (at <= 0) return null
633
+ const host = hostPort.slice(0, at)
634
+ const port = Number.parseInt(hostPort.slice(at + 1), 10)
635
+ if (!Number.isInteger(port) || port <= 1) return null
636
+ return `http://${host}:${port - 1}/v1/collector/run`
637
+ }
638
+
639
+ /**
640
+ * Ask the server's collector to purge archived (opcode 2) records of one
641
+ * model. Wire cannot start collector jobs; this is the documented HTTP
642
+ * route, scoped to the model so no other model's tombstones are touched.
643
+ * @returns {Promise<number>} records purged
644
+ */
645
+ export async function purgeArchivedRecords(wireUrl, token, model) {
646
+ const url = collectorHttpUrl(wireUrl)
647
+ if (!url) throw new Error(`cannot derive the collector endpoint from "${wireUrl}"`)
648
+ const res = await fetch(url, {
649
+ method: 'POST',
650
+ headers: {
651
+ Authorization: `Bearer ${token}`,
652
+ 'Content-Type': 'application/json'
653
+ },
654
+ body: JSON.stringify({ model })
655
+ })
656
+ if (!res.ok) throw new Error(`collector run failed (HTTP ${res.status})`)
657
+ const body = await res.json()
658
+ if (!body.success) throw new Error(`collector run failed: ${body.message || 'unknown error'}`)
659
+ return body.purged_records ?? 0
660
+ }