@rahmantech/knl-view 0.0.0-stage → 0.2.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 +483 -2
  3. package/bin/knl-view.js +1320 -0
  4. package/dist/assets/abnfDiagram-WEYOIA3C-hoSbsMOz.js +1 -0
  5. package/dist/assets/arc-g87Np6E5.js +1 -0
  6. package/dist/assets/architecture-WOLXFQ4H-DOIq-0IA.js +1 -0
  7. package/dist/assets/architectureDiagram-ZHVYKT53-Dxwe8n7m.js +36 -0
  8. package/dist/assets/array-BifhSqXX.js +1 -0
  9. package/dist/assets/blockDiagram-RJAE7QA3-BnGng59Z.js +140 -0
  10. package/dist/assets/c4Diagram-XDPPPE54-C5LjqIIo.js +38 -0
  11. package/dist/assets/channel-BVWkRFBw.js +1 -0
  12. package/dist/assets/chunk-2E7WHVMD-BJ1dyGLq.js +88 -0
  13. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  14. package/dist/assets/chunk-4EMNKR2T-DXF02Sgw.js +1 -0
  15. package/dist/assets/chunk-55XCU76K-CM1akAbu.js +1 -0
  16. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  17. package/dist/assets/chunk-7S6XSTFO-BOUjqldL.js +2 -0
  18. package/dist/assets/chunk-AQDTSKZB-C4isdNLA.js +72 -0
  19. package/dist/assets/chunk-DSNLSHJZ-CbOTkSVM.js +129 -0
  20. package/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  21. package/dist/assets/chunk-HAGFOTED-CRfnketR.js +62 -0
  22. package/dist/assets/chunk-HNE4Q36P-rxlpGUU8.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-BOYCgTmV.js +213 -0
  27. package/dist/assets/chunk-UHW7PFBP-Cnogp1Fb.js +1 -0
  28. package/dist/assets/chunk-VQ5W62QI-Cu1WY5Uo.js +1 -0
  29. package/dist/assets/chunk-WQQL4CDK-BJc3FPE2.js +10 -0
  30. package/dist/assets/chunk-XXDRQBXY-BlPj4H7s.js +1 -0
  31. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  32. package/dist/assets/chunk-YZO67HFG-V85H31x4.js +1 -0
  33. package/dist/assets/chunk-ZAUX2LTX-C3XKXW09.js +1 -0
  34. package/dist/assets/chunk-ZCHMQSOR-B0mmpEgW.js +2 -0
  35. package/dist/assets/classDiagram-v2-4MYWR3UA-oMd7YaHP.js +217 -0
  36. package/dist/assets/cose-bilkent-JH36ORCC-J0kSVsDf.js +1 -0
  37. package/dist/assets/cynefin-EF2NZ3EQ-DK2TNhel.js +1 -0
  38. package/dist/assets/cynefinDiagram-WF24UDRP-DDvKgieA.js +62 -0
  39. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  40. package/dist/assets/dagre-NK4GIZVX-Ca2Fe-i-.js +4 -0
  41. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  42. package/dist/assets/diagram-CBK3MWTC-L6dmeQ2V.js +24 -0
  43. package/dist/assets/diagram-I5XIIV2D-DhG8R-v5.js +24 -0
  44. package/dist/assets/diagram-ODIL2UCW-BpYYbYBT.js +30 -0
  45. package/dist/assets/diagram-PFBEJUXE-DKQpHSsC.js +3 -0
  46. package/dist/assets/diagram-RJA3I4VV-DP7Dsnya.js +41 -0
  47. package/dist/assets/diagram-TYUNFSNR-CfSFewUS.js +200 -0
  48. package/dist/assets/dist-CQNjnrld.js +1 -0
  49. package/dist/assets/ebnfDiagram-V4P3TTPT-DE3MZoJO.js +1 -0
  50. package/dist/assets/elk-QONTBCTQ-p_aAQPHU.js +27 -0
  51. package/dist/assets/erDiagram-KYQIGQAA-DZO3vQrk.js +99 -0
  52. package/dist/assets/eventmodeling-K75KTNOO-BII9EPl5.js +1 -0
  53. package/dist/assets/flowDiagram-IUGLLWBU-D9uPno39.js +1 -0
  54. package/dist/assets/ganttDiagram-R7GPNTJY-BKiH3v2r.js +292 -0
  55. package/dist/assets/gitGraph-VSP46ZUC-oHL6cr73.js +1 -0
  56. package/dist/assets/gitGraphDiagram-PDYL7XHY-CqPVIEeo.js +106 -0
  57. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  58. package/dist/assets/index-CSpirV16.js +76 -0
  59. package/dist/assets/index-DLgMsUs2.css +1 -0
  60. package/dist/assets/info-OHQRW6UA-Drrbnhpd.js +1 -0
  61. package/dist/assets/infoDiagram-MVDRKAX5-DnQQFMAd.js +2 -0
  62. package/dist/assets/init-C-OQMol4.js +1 -0
  63. package/dist/assets/ishikawaDiagram-HPEHV2GE-DIpQlxRh.js +70 -0
  64. package/dist/assets/journeyDiagram-BOSHW7O7-EBR9I5h1.js +139 -0
  65. package/dist/assets/kanban-definition-FNSA4HC5-CdvNsjik.js +89 -0
  66. package/dist/assets/katex-B_JcIwqj.js +257 -0
  67. package/dist/assets/line-CDLzgxDc.js +1 -0
  68. package/dist/assets/linear-DfXKql9u.js +1 -0
  69. package/dist/assets/mermaid-parser.core-DnXGphT2.js +7 -0
  70. package/dist/assets/mermaid.core-h6fiCaSZ.js +44 -0
  71. package/dist/assets/mindmap-definition-6OY57UG3-BiNfio-j.js +98 -0
  72. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  73. package/dist/assets/packet-JDAUHWVQ-D76byv1S.js +1 -0
  74. package/dist/assets/path-fybaL0A-.js +1 -0
  75. package/dist/assets/pegDiagram-ECIUJNBX-Drs8Pxdt.js +1 -0
  76. package/dist/assets/pie-XZMESJXO-D_83z1lM.js +1 -0
  77. package/dist/assets/pieDiagram-GNMPY74T-wvzIphSf.js +39 -0
  78. package/dist/assets/quadrantDiagram-D57K4MC3-B7EfMRWb.js +7 -0
  79. package/dist/assets/radar-ABXABTNO-Bs6dVdAR.js +1 -0
  80. package/dist/assets/railroad-I3PHUGI6-lKaN4miw.js +1 -0
  81. package/dist/assets/railroad-abnf-LNEFI6M7-Bhh16b7Z.js +1 -0
  82. package/dist/assets/railroad-ebnf-DWYD2UWJ-m6ybW-Y_.js +1 -0
  83. package/dist/assets/railroad-peg-JR7BVNR7-DNn72e7r.js +1 -0
  84. package/dist/assets/railroadDiagram-Y72NRBL7-DNeop1iZ.js +1 -0
  85. package/dist/assets/requirementDiagram-EQYSW5RA-BVYZ2XYJ.js +84 -0
  86. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  87. package/dist/assets/sankeyDiagram-SY24JHO5-B2MP51on.js +40 -0
  88. package/dist/assets/sequenceDiagram-BLEHNHQU-BUvxstXG.js +169 -0
  89. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  90. package/dist/assets/src-BrR0lqlG.js +1 -0
  91. package/dist/assets/stateDiagram-v2-A6IESKWZ-Ba84OGKE.js +291 -0
  92. package/dist/assets/swimlanes-B3ENRKFQ-BMSl2PNA.js +1 -0
  93. package/dist/assets/swimlanesDiagram-AYQRV4AK-M1dmvq7m.js +8 -0
  94. package/dist/assets/timeline-definition-JXYKGLGB-DOdW-dxq.js +120 -0
  95. package/dist/assets/treeView-D4JQ5SDB-DZ9KQyn5.js +1 -0
  96. package/dist/assets/treemap-SAJKECNS-WEC5wtDV.js +1 -0
  97. package/dist/assets/usecaseDiagram-WPOLBNNX-cldTztFL.js +328 -0
  98. package/dist/assets/vennDiagram-Q3Y3IDCW-c41KySoY.js +34 -0
  99. package/dist/assets/wardley-7MLQ67FV-Bf9_aSSZ.js +1 -0
  100. package/dist/assets/wardleyDiagram-IVAIDKWA-BeKEvEBG.js +78 -0
  101. package/dist/assets/xychartDiagram-4URNHDU4-tNkAxTxG.js +7 -0
  102. package/dist/index.html +22 -0
  103. package/package.json +50 -4
  104. package/server/config.js +389 -0
  105. package/server/consensus.js +180 -0
  106. package/server/graph/anchordb_client.js +253 -0
  107. package/server/graph/model.js +157 -0
  108. package/server/graph/parser.js +327 -0
  109. package/server/graph/sources.js +801 -0
  110. package/server/graph/syntax.js +437 -0
  111. package/server/index.js +742 -0
  112. package/server/install.js +437 -0
  113. package/server/map.js +126 -0
  114. package/server/models_registry.js +109 -0
  115. package/server/scaffold.js +466 -0
@@ -0,0 +1,801 @@
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
+ import { createPathMatcher } from '../config.js'
7
+
8
+ // Sources graph: the whole project as a dependency graph in AnchorDB, not
9
+ // just the .knowledges markdown. Nodes are the files (id = repo relative
10
+ // path) and the named symbols they define (id = `<path>#<name>`, from
11
+ // tree-sitter syntax extraction: JS family and Go today, one uniform API
12
+ // per language). Edges: file imports file, file defines symbol, symbol
13
+ // calls symbol, each stored in both directions because causalChain walks
14
+ // forward only. Hidden paths (dot-prefixed) are skipped except .github;
15
+ // node_modules, dist, and .gitignore patterns are skipped so dependency
16
+ // folders cannot flood the graph.
17
+ //
18
+ // Everything lives in the server. Nodes are records with numeric attributes
19
+ // only (AnchorDB admits no text), enumerated with list(); sync is
20
+ // server-authoritative: it enumerates what the server already has instead
21
+ // of trusting a local mirror. Label text (symbol names and kinds) lives in
22
+ // the project's label dir (.knowledges/.knl-data by default, overridable
23
+ // via the project config's labelDir), regenerated on every sync.
24
+ //
25
+ // Removed files: the record is archived with opcode 2 (TOMBSTONE, hidden
26
+ // from list and lookups) and purged by a collector run; a save revives it.
27
+
28
+ export const SOURCE_RELATIONS = ['imports', 'imported_by']
29
+ export const SOURCE_TOMBSTONE = 2
30
+ // OpStatus on the wire: 1 = Active, 2 = Archived (TOMBSTONE).
31
+ export const OP_ACTIVE = 1
32
+ export const OP_ARCHIVED = 2
33
+ // Symbol layer, same AnchorDB model as the files: a file defines symbols,
34
+ // symbols call symbols. Mirrored both directions like imports, for the same
35
+ // reason: causalChain walks forward only.
36
+ export const SYMBOL_RELATIONS = ['defines', 'defined_in', 'calls', 'called_by']
37
+ export const SYNC_RELATIONS = [...SOURCE_RELATIONS, ...SYMBOL_RELATIONS]
38
+
39
+ const REVERSE_EDGE = {
40
+ imports: 'imported_by',
41
+ defines: 'defined_in',
42
+ calls: 'called_by'
43
+ }
44
+
45
+ // Where generated label files live inside the target project: the label
46
+ // text AnchorDB cannot hold (numeric-only attributes). Overridable via the
47
+ // project config's labelDir.
48
+ const DEFAULT_LABEL_DIR = '.knowledges/.knl-data'
49
+ const DEFAULT_IGNORES = ['node_modules', 'dist', 'build', 'coverage']
50
+
51
+ const JS_TS_EXTS = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.mts', '.cts', '.tsx'])
52
+
53
+ /** One ignore rule after parsing a .gitignore line. */
54
+ function ignoreRule(line) {
55
+ const trimmed = line.trim()
56
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) return null
57
+ const dirOnly = trimmed.endsWith('/')
58
+ const pattern = trimmed.replace(/\/+$/, '')
59
+ return { dirOnly, pattern }
60
+ }
61
+
62
+ /**
63
+ * Size in whole KB and mtime in whole days since the epoch: the two numeric
64
+ * attributes AnchorDB can hold for a file. They are informational; change
65
+ * detection never depends on them, it diffs the edge sets.
66
+ */
67
+ function fileAttrs(stat) {
68
+ return { size: Math.ceil(stat.size / 1024), mtime: Math.floor(stat.mtimeMs / 86400000) }
69
+ }
70
+
71
+ /**
72
+ * Walk the project root and return every file that belongs in the sources
73
+ * graph: { rel -> { size, mtime } }. Skips dot-prefixed folders except
74
+ * .github, the default ignore list, and basic .gitignore patterns.
75
+ */
76
+ function compileIgnorePattern(pattern) {
77
+ if (!pattern || !pattern.includes('*')) return null
78
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')
79
+ try {
80
+ return new RegExp('^' + escaped + '$')
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ export async function scanSourceFiles(rootAbs, { include = null, exclude = null } = {}) {
87
+ const excludeFilter = createPathMatcher(exclude)
88
+ const includeFilter = createPathMatcher(include)
89
+
90
+ const rules = []
91
+ try {
92
+ const gitignore = await fs.readFile(path.join(rootAbs, '.gitignore'), 'utf8')
93
+ for (const line of gitignore.split('\n')) {
94
+ const rule = ignoreRule(line)
95
+ if (rule) {
96
+ rule.regex = compileIgnorePattern(rule.pattern)
97
+ rules.push(rule)
98
+ }
99
+ }
100
+ } catch {
101
+ // No .gitignore: the default list still applies.
102
+ }
103
+ rules.push(...DEFAULT_IGNORES.map((pattern) => ({ dirOnly: true, pattern, regex: compileIgnorePattern(pattern) })))
104
+
105
+ const isDefaultIgnored = (rel, isDir) => {
106
+ const segments = rel.split('/')
107
+ if (segments.some((s) => s.startsWith('.') && s !== '.github')) return true
108
+ return segments.some((segment) =>
109
+ rules.some((rule) => {
110
+ if (rule.regex) {
111
+ return rule.regex.test(segment) || rule.regex.test(rel)
112
+ }
113
+ if (rule.dirOnly) return segments.includes(rule.pattern)
114
+ return segment === rule.pattern || rel === rule.pattern
115
+ })
116
+ )
117
+ }
118
+
119
+ const files = {}
120
+ const walk = async (dirAbs, rel) => {
121
+ let entries
122
+ try {
123
+ entries = await fs.readdir(dirAbs, { withFileTypes: true })
124
+ } catch {
125
+ return
126
+ }
127
+ for (const entry of entries) {
128
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name
129
+
130
+ // 1. Hard exclude: knl-config exclude is truly excluded, always wins
131
+ if (excludeFilter && excludeFilter.matches(childRel, entry.name)) {
132
+ continue
133
+ }
134
+
135
+ // 2. Explicit include overrides default excludes
136
+ const isExplicitlyIncluded = includeFilter && includeFilter.matches(childRel, entry.name)
137
+ const isDirPrefixOfInclude = entry.isDirectory() && includeFilter && includeFilter.isPrefixOfAny(childRel)
138
+
139
+ if (entry.isDirectory()) {
140
+ if (!isExplicitlyIncluded && !isDirPrefixOfInclude) {
141
+ if (includeFilter) {
142
+ continue
143
+ }
144
+ if (isDefaultIgnored(childRel, true)) {
145
+ continue
146
+ }
147
+ }
148
+ await walk(path.join(dirAbs, entry.name), childRel)
149
+ continue
150
+ }
151
+
152
+ if (!entry.isFile()) continue
153
+
154
+ if (includeFilter) {
155
+ if (!isExplicitlyIncluded) continue
156
+ } else {
157
+ if (isDefaultIgnored(childRel, false)) continue
158
+ }
159
+
160
+ const childAbs = path.join(dirAbs, entry.name)
161
+ try {
162
+ files[childRel] = fileAttrs(await fs.stat(childAbs))
163
+ } catch {
164
+ // Raced with a deletion between readdir and stat: skip the file.
165
+ }
166
+ }
167
+ }
168
+ await walk(rootAbs, '')
169
+ return files
170
+ }
171
+
172
+ /**
173
+ * Relative specifiers from one JS file's text: ESM import/export sources,
174
+ * dynamic import(), and CommonJS require(). Bare and URL specifiers are
175
+ * skipped; only './' and '../' resolve to project files.
176
+ *
177
+ * Extraction itself is tree-sitter (syntax.js); this function is the JS
178
+ * half of resolution: which extracted specifiers point at project files.
179
+ */
180
+ export function resolveJsSpecifiers(fromRel, specs, files) {
181
+ const relative = specs.filter((spec) => spec.startsWith('./') || spec.startsWith('../'))
182
+ return relative.map((spec) => resolveImportSpecifier(fromRel, spec, files)).filter(Boolean)
183
+ }
184
+
185
+ /**
186
+ * Module path declared by the project's go.mod, or null when there is
187
+ * none. Go imports only resolve against it: a specifier equal to the
188
+ * module path or starting with it is local, everything else (standard
189
+ * library, remote modules) is external to the graph.
190
+ */
191
+ export async function goModulePath(rootAbs) {
192
+ try {
193
+ const text = await fs.readFile(path.join(rootAbs, 'go.mod'), 'utf8')
194
+ const match = text.match(/^module\s+(\S+)/m)
195
+ return match ? match[1] : null
196
+ } catch {
197
+ return null
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Resolve one Go import specifier to the local files of the imported
203
+ * package: every .go file in the specifier's directory below the module
204
+ * root. Returns [] for anything outside the module.
205
+ */
206
+ export function resolveGoSpecifiers(spec, modulePath, files) {
207
+ if (!modulePath) return []
208
+ let sub
209
+ if (spec === modulePath) sub = ''
210
+ else if (spec.startsWith(modulePath + '/')) sub = spec.slice(modulePath.length + 1)
211
+ else return []
212
+ const dir = sub === '' ? '.' : sub
213
+ return Object.keys(files).filter((rel) => rel.endsWith('.go') && path.posix.dirname(rel) === dir)
214
+ }
215
+
216
+ /**
217
+ * Resolve one relative specifier from file `fromRel` against the set of
218
+ * scanned files. Tries the exact path, JS/TS extension fallbacks (including
219
+ * mapping .js specifiers in TS ESM to .ts/.tsx on disk), and directory index files.
220
+ */
221
+ export function resolveImportSpecifier(fromRel, spec, files) {
222
+ const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromRel), spec))
223
+ const candidates = [base]
224
+ if (base.endsWith('.js')) {
225
+ candidates.push(base.slice(0, -3) + '.ts', base.slice(0, -3) + '.tsx')
226
+ } else if (base.endsWith('.mjs')) {
227
+ candidates.push(base.slice(0, -4) + '.mts')
228
+ } else if (base.endsWith('.cjs')) {
229
+ candidates.push(base.slice(0, -4) + '.cts')
230
+ }
231
+ for (const ext of JS_TS_EXTS) candidates.push(base + ext)
232
+ for (const ext of JS_TS_EXTS) candidates.push(`${base}/index${ext}`)
233
+ return candidates.find((candidate) => files[candidate]) || null
234
+ }
235
+
236
+ /**
237
+ * Resolve Python import specifiers: relative dots (e.g. .local, ..utils) and
238
+ * module paths (e.g. pkg.sub) to .py or /__init__.py files in scanned files.
239
+ */
240
+ export function resolvePySpecifiers(fromRel, specs, files) {
241
+ const dir = path.posix.dirname(fromRel)
242
+ const resolved = []
243
+ for (const spec of specs) {
244
+ const candidates = []
245
+ const m = spec.match(/^(\.+)(.*)$/)
246
+ if (m) {
247
+ const dotCount = m[1].length
248
+ const rest = m[2].replace(/\./g, '/')
249
+ let targetDir = dir
250
+ for (let i = 1; i < dotCount; i++) targetDir = path.posix.dirname(targetDir)
251
+ const base = rest ? path.posix.normalize(path.posix.join(targetDir, rest)) : targetDir
252
+ candidates.push(base + '.py', path.posix.join(base, '__init__.py'))
253
+ } else {
254
+ const asPath = spec.replace(/\./g, '/')
255
+ candidates.push(
256
+ asPath + '.py',
257
+ path.posix.join(asPath, '__init__.py'),
258
+ path.posix.normalize(path.posix.join(dir, asPath)) + '.py',
259
+ path.posix.normalize(path.posix.join(dir, asPath, '__init__.py'))
260
+ )
261
+ }
262
+ const found = candidates.find((c) => files[c])
263
+ if (found && !resolved.includes(found)) resolved.push(found)
264
+ }
265
+ return resolved
266
+ }
267
+
268
+ /**
269
+ * Resolve Rust import specifiers: `crate::`, `super::`, `self::`, and `./`
270
+ * (from `mod foo;`) to .rs or /mod.rs files in scanned files.
271
+ */
272
+ export function resolveRustSpecifiers(fromRel, specs, files) {
273
+ const dir = path.posix.dirname(fromRel)
274
+ let crateRoot = '.'
275
+ const srcIdx = fromRel.indexOf('src/')
276
+ if (srcIdx >= 0) {
277
+ crateRoot = fromRel.slice(0, srcIdx + 3)
278
+ }
279
+ const resolved = []
280
+ for (const spec of specs) {
281
+ const candidates = []
282
+ if (spec.startsWith('crate::')) {
283
+ const rest = spec.slice('crate::'.length).split('::').join('/')
284
+ const parts = rest.split('/')
285
+ for (let i = parts.length; i >= 1; i--) {
286
+ const sub = parts.slice(0, i).join('/')
287
+ candidates.push(
288
+ path.posix.normalize(path.posix.join(crateRoot, sub)) + '.rs',
289
+ path.posix.normalize(path.posix.join(crateRoot, sub, 'mod.rs'))
290
+ )
291
+ }
292
+ } else if (spec.startsWith('super::')) {
293
+ const rest = spec.slice('super::'.length).split('::').join('/')
294
+ const parentDir = path.posix.dirname(dir)
295
+ const parts = rest.split('/')
296
+ for (let i = parts.length; i >= 1; i--) {
297
+ const sub = parts.slice(0, i).join('/')
298
+ candidates.push(
299
+ path.posix.normalize(path.posix.join(parentDir, sub)) + '.rs',
300
+ path.posix.normalize(path.posix.join(parentDir, sub, 'mod.rs'))
301
+ )
302
+ }
303
+ } else if (spec.startsWith('self::') || spec.startsWith('./')) {
304
+ const raw = spec.startsWith('self::') ? spec.slice('self::'.length).split('::').join('/') : spec.slice(2)
305
+ const parts = raw.split('/')
306
+ for (let i = parts.length; i >= 1; i--) {
307
+ const sub = parts.slice(0, i).join('/')
308
+ candidates.push(
309
+ path.posix.normalize(path.posix.join(dir, sub)) + '.rs',
310
+ path.posix.normalize(path.posix.join(dir, sub, 'mod.rs'))
311
+ )
312
+ }
313
+ }
314
+ const found = candidates.find((c) => files[c])
315
+ if (found && !resolved.includes(found)) resolved.push(found)
316
+ }
317
+ return resolved
318
+ }
319
+
320
+ /**
321
+ * Build the desired sources graph: nodes for every scanned file plus one
322
+ * node per named symbol, and edges from tree-sitter extraction (file-level
323
+ * imports, file-defines-symbol, symbol-calls-symbol). Symbol node ids are
324
+ * `<path>#<name>`; class methods are `Class.method`. Returns plain arrays
325
+ * so callers can diff without depending on the DirectedGraph type.
326
+ */
327
+ export async function buildSourcesGraph(rootAbs, files = null, options = {}) {
328
+ const scanned = files || await scanSourceFiles(rootAbs, options)
329
+ const modulePath = await goModulePath(rootAbs)
330
+ const edges = []
331
+ const symbolIndex = new Map() // symbol name -> [node id, ...] for call resolution
332
+ const symbolsByFile = new Map() // file rel -> symbol nodes
333
+
334
+ const structure = {}
335
+ for (const [rel] of Object.entries(scanned)) {
336
+ const ext = path.posix.extname(rel).toLowerCase()
337
+ if (!SYNTAX_LANGUAGES[ext]) continue
338
+ let content
339
+ try {
340
+ content = await fs.readFile(path.join(rootAbs, rel), 'utf8')
341
+ } catch {
342
+ continue
343
+ }
344
+ let extracted
345
+ try {
346
+ extracted = await extractCode(ext, content)
347
+ } catch {
348
+ continue
349
+ }
350
+ structure[rel] = { ext, modulePath, ...extracted }
351
+
352
+ // A name defined more than once in one file would collide on one
353
+ // AnchorDB record id, so repeated names get a line suffix: `@<line>`.
354
+ const nameCounts = new Map()
355
+ for (const symbol of extracted.symbols) {
356
+ nameCounts.set(symbol.name, (nameCounts.get(symbol.name) || 0) + 1)
357
+ }
358
+ const fileSymbols = extracted.symbols.map((symbol) => ({
359
+ ...symbol,
360
+ id: nameCounts.get(symbol.name) > 1
361
+ ? `${rel}#${symbol.name}@${symbol.line}`
362
+ : `${rel}#${symbol.name}`
363
+ }))
364
+ if (fileSymbols.length) symbolsByFile.set(rel, fileSymbols)
365
+ // ResolveCallTargets builds call edges *from* these symbols, so store
366
+ // the id-carrying list (duplicate names already line-suffixed here).
367
+ structure[rel].symbols = fileSymbols
368
+ for (const symbol of fileSymbols) {
369
+ symbolIndex.set(symbol.name, [...(symbolIndex.get(symbol.name) || []), symbol.id])
370
+ edges.push({ from: rel, to: symbol.id, type: 'defines' })
371
+ }
372
+ }
373
+
374
+ for (const [rel, file] of Object.entries(structure)) {
375
+ let targets = []
376
+ if (file.ext === '.go') {
377
+ targets = file.imports.flatMap((spec) => resolveGoSpecifiers(spec, file.modulePath, scanned))
378
+ } else if (file.ext === '.py') {
379
+ targets = resolvePySpecifiers(rel, file.imports, scanned)
380
+ } else if (file.ext === '.rs') {
381
+ targets = resolveRustSpecifiers(rel, file.imports, scanned)
382
+ } else {
383
+ targets = resolveJsSpecifiers(rel, file.imports, scanned)
384
+ }
385
+ for (const target of targets) {
386
+ if (target && target !== rel) edges.push({ from: rel, to: target, type: 'imports' })
387
+ }
388
+ for (const call of resolveCallTargets(rel, file, symbolIndex, scanned)) {
389
+ edges.push({ from: call.from, to: call.to, type: 'calls' })
390
+ }
391
+ }
392
+
393
+ const nodes = Object.entries(scanned).map(([rel, attrs]) => new GraphNode({
394
+ id: rel,
395
+ title: rel,
396
+ type: 'file',
397
+ path: rel,
398
+ metadata: attrs
399
+ }))
400
+ for (const [rel, list] of symbolsByFile.entries()) {
401
+ for (const symbol of list) {
402
+ nodes.push(new GraphNode({
403
+ id: symbol.id,
404
+ title: symbol.id,
405
+ type: 'symbol',
406
+ path: rel,
407
+ metadata: { line: symbol.line, lines: symbol.lines }
408
+ }))
409
+ }
410
+ }
411
+ return { nodes, edges, symbolsByFile }
412
+ }
413
+
414
+ /**
415
+ * Resolve a file's call sites to symbol nodes. The call is attributed to
416
+ * the innermost enclosing symbol by line range; the target is resolved by
417
+ * name: a name defined exactly once project-wide is unambiguous, anything
418
+ * else is skipped rather than guessed. Go additionally accepts a qualified
419
+ * callee (`Recv.Method`, `pkg.Fn`) and prefers a symbol defined in the
420
+ * same package directory.
421
+ */
422
+ export function resolveCallTargets(fromRel, file, symbolIndex, scanned) {
423
+ const symbols = file.symbols || []
424
+ const dir = path.posix.dirname(fromRel)
425
+ // A candidate resolves when exactly one definition matches, preferring
426
+ // definitions in the same package directory; anything ambiguous or
427
+ // external stays out of the graph.
428
+ const resolve = (key) => {
429
+ const ids = symbolIndex.get(key) || []
430
+ const local = ids.filter((id) => path.posix.dirname(id.split('#')[0]) === dir)
431
+ const pick = local.length === 1 ? local : ids
432
+ return pick.length === 1 ? pick[0] : null
433
+ }
434
+ const importNames = file.importNames instanceof Set
435
+ ? file.importNames
436
+ : new Set(file.importNames || [])
437
+ const targets = []
438
+ for (const call of file.calls || []) {
439
+ // Innermost symbol containing the call row: same file, so ordering by
440
+ // start line and taking the narrowest match is enough.
441
+ let owner = null
442
+ for (const symbol of symbols) {
443
+ const start = symbol.line - 1
444
+ const end = start + symbol.lines
445
+ if (call.row >= start && call.row < end) {
446
+ if (!owner || symbol.lines < owner.lines) owner = symbol
447
+ }
448
+ }
449
+ if (!owner) continue
450
+ const from = owner.id || `${fromRel}#${owner.name}`
451
+ // Bare-name fallback needs import evidence: an unproven receiver like
452
+ // `s.logger.Log()` would be absorbed by the one project-wide `Log()`.
453
+ if (call.recv && !importNames.has(call.recv)) continue
454
+ const candidates = []
455
+ if (call.recv) candidates.push(`${call.recv}.${call.name}`)
456
+ candidates.push(call.name)
457
+ for (const key of candidates) {
458
+ const to = resolve(key)
459
+ if (to && to !== from) {
460
+ targets.push({ from, to })
461
+ break
462
+ }
463
+ }
464
+ }
465
+ return targets
466
+ }
467
+
468
+ /**
469
+ * Desired edge keys in both directions for every relation type, for
470
+ * diffing against the server.
471
+ */
472
+ export function desiredSourceEdgeKeys(edges) {
473
+ const keys = new Set()
474
+ for (const { from, to, type = 'imports' } of edges) {
475
+ keys.add(edgeKey(from, type, to))
476
+ keys.add(edgeKey(to, REVERSE_EDGE[type], from))
477
+ }
478
+ return keys
479
+ }
480
+
481
+ /**
482
+ * Label text for one file's symbols: the information AnchorDB cannot hold
483
+ * (attributes are numeric-only). Written under the label dir as a mirror
484
+ * of the repo-relative path, one .md per file that has symbols.
485
+ */
486
+ export function labelContent(rel, symbols) {
487
+ const lines = [`# ${rel}`, '']
488
+ for (const symbol of symbols) {
489
+ lines.push(`- \`${symbol.name}\` (${symbol.kind}, line ${symbol.line})`)
490
+ }
491
+ return lines.join('\n') + '\n'
492
+ }
493
+
494
+ /**
495
+ * Reconcile the label files on disk with the current symbol state:
496
+ * write files whose content changed, delete files whose file or last
497
+ * symbol is gone. Generated files only; never touches anything else.
498
+ */
499
+ export async function writeLabelFiles(rootAbs, labelDir = DEFAULT_LABEL_DIR, symbolsByFile) {
500
+ const base = path.resolve(rootAbs, labelDir)
501
+ if (base !== rootAbs && !base.startsWith(rootAbs + path.sep)) {
502
+ throw new Error(`labelDir "${labelDir}" resolves outside the project root`)
503
+ }
504
+ const expected = new Map()
505
+ for (const [rel, symbols] of symbolsByFile) expected.set(rel, labelContent(rel, symbols))
506
+
507
+ const existing = new Set()
508
+ const walk = async (dir, rel) => {
509
+ let entries
510
+ try {
511
+ entries = await fs.readdir(dir, { withFileTypes: true })
512
+ } catch {
513
+ return
514
+ }
515
+ for (const entry of entries) {
516
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name
517
+ if (entry.isDirectory()) await walk(path.join(dir, entry.name), childRel)
518
+ else if (entry.isFile() && entry.name.endsWith('.md')) existing.add(childRel.slice(0, -3))
519
+ }
520
+ }
521
+ await walk(base, '')
522
+
523
+ const written = []
524
+ const deleted = []
525
+ for (const [rel, content] of expected) {
526
+ const abs = path.join(base, `${rel}.md`)
527
+ let current = null
528
+ try {
529
+ current = await fs.readFile(abs, 'utf8')
530
+ } catch {
531
+ // No file yet: it will be written below.
532
+ }
533
+ if (current === content) continue
534
+ try {
535
+ await fs.mkdir(path.dirname(abs), { recursive: true })
536
+ await fs.writeFile(abs, content, 'utf8')
537
+ written.push(rel)
538
+ } catch {
539
+ // Label files are a convenience; a failed write is not a sync failure.
540
+ }
541
+ }
542
+ for (const rel of existing) {
543
+ if (expected.has(rel)) continue
544
+ try {
545
+ await fs.rm(path.join(base, `${rel}.md`), { force: true })
546
+ deleted.push(rel)
547
+ } catch {
548
+ // Best effort too: a failed delete leaves one stale label file.
549
+ }
550
+ }
551
+ return { written, deleted }
552
+ }
553
+
554
+ /**
555
+ * Server-authoritative sync. Enumerates the server's own nodes (list) and
556
+ * edges (one causalChain per node per relation), diffs them against the
557
+ * freshly scanned disk state, and applies the difference. No local mirror:
558
+ * the server is the single source of truth.
559
+ *
560
+ * Records of removed files and removed symbols are not deletable (the
561
+ * protocol has no delete), so their edges are unlinked and the record is
562
+ * tombstoned, invisible to every later sync and trace.
563
+ *
564
+ * @returns {Promise<{linked, unlinked, saved, tombstoned, failed, orphans,
565
+ * files, symbols, edges, labels}>}
566
+ */
567
+ export async function syncSourcesToAnchorDB(client, rootAbs, { labelDir, include = null, exclude = null } = {}) {
568
+ const { nodes, edges, symbolsByFile } = await buildSourcesGraph(rootAbs, null, { include, exclude })
569
+ const desired = desiredSourceEdgeKeys(edges)
570
+ const diskIds = new Set(nodes.map((node) => node.id))
571
+
572
+ // Page through the full record list: server truth for which nodes exist.
573
+ // Records the server already archived (opcode 2) never appear here, so a
574
+ // tombstone is written once and never revisited.
575
+ const serverAttrs = new Map()
576
+ let offset = 0
577
+ for (;;) {
578
+ const page = await client.list({ limit: 500, offset })
579
+ for (const item of page.items || []) {
580
+ serverAttrs.set(item.name, item.props || {})
581
+ }
582
+ if (!page.hasMore || !page.items?.length) break
583
+ offset += page.items.length
584
+ }
585
+
586
+ // Server truth for edges: walk every known node once per relation.
587
+ const existing = new Set()
588
+ const seeds = new Set([...serverAttrs.keys()])
589
+ for (const { from, to } of edges) {
590
+ seeds.add(from)
591
+ seeds.add(to)
592
+ }
593
+ for (const seed of seeds) {
594
+ for (const relation of SYNC_RELATIONS) {
595
+ let rows
596
+ try {
597
+ rows = await client.causalChain([seed], relation, 1000)
598
+ } catch {
599
+ continue
600
+ }
601
+ for (const row of rows || []) existing.add(edgeKey(seed, relation, row.word))
602
+ }
603
+ }
604
+
605
+ let linked = 0
606
+ let unlinked = 0
607
+ let saved = 0
608
+ let tombstoned = 0
609
+ let failed = 0
610
+
611
+ for (const key of desired) {
612
+ if (existing.has(key)) continue
613
+ const [from, type, to] = key.split('\u0000')
614
+ try {
615
+ await client.link(from, type, to, 1.0)
616
+ linked += 1
617
+ } catch {
618
+ failed += 1
619
+ }
620
+ }
621
+ for (const key of existing) {
622
+ if (desired.has(key)) continue
623
+ const [from, type, to] = key.split('\u0000')
624
+ try {
625
+ await client.unlink(from, type, to)
626
+ unlinked += 1
627
+ } catch {
628
+ failed += 1
629
+ }
630
+ }
631
+
632
+ // Files gone from disk: edges are already unlinked; archive the record
633
+ // (opcode 2) and keep the tombstone attr for servers without opcodes.
634
+ const pending = []
635
+ for (const name of serverAttrs.keys()) {
636
+ if (diskIds.has(name)) continue
637
+ pending.push({ name, props: { tombstone: SOURCE_TOMBSTONE }, operation: OP_ARCHIVED, tombstone: true })
638
+ }
639
+
640
+ // Node records carry the numeric attributes. Existing records are only
641
+ // re-saved when their attributes changed; tombstone 0 keeps the alive
642
+ // state explicit even if the server merges attributes.
643
+ for (const node of nodes) {
644
+ const current = serverAttrs.get(node.id)
645
+ if (current && current.size === node.metadata.size && current.mtime === node.metadata.mtime
646
+ && current.line === node.metadata.line && current.lines === node.metadata.lines) continue
647
+ pending.push({ name: node.id, props: { ...node.metadata, tombstone: 0 } })
648
+ }
649
+
650
+ // Records alive on the server after this sync: pre-existing ones plus
651
+ // whatever this sync successfully saved.
652
+ const live = new Set(serverAttrs.keys())
653
+
654
+ // One transactional batch when the wire client supports it (faster than
655
+ // per-record saves at scale); per-record fallback keeps a single bad
656
+ // record from losing the rest.
657
+ const saveOne = async (item) => {
658
+ await client.saveWithID(item.name, item.props)
659
+ if (item.tombstone) tombstoned += 1
660
+ else {
661
+ saved += 1
662
+ live.add(item.name)
663
+ }
664
+ }
665
+ if (pending.length) {
666
+ if (typeof client.saveBatch === 'function') {
667
+ try {
668
+ await client.saveBatch(pending)
669
+ for (const item of pending) {
670
+ if (item.tombstone) tombstoned += 1
671
+ else {
672
+ saved += 1
673
+ live.add(item.name)
674
+ }
675
+ }
676
+ } catch {
677
+ // Batch refused as a whole: retry one by one.
678
+ for (const item of pending) {
679
+ try {
680
+ await saveOne(item)
681
+ } catch {
682
+ failed += 1
683
+ }
684
+ }
685
+ }
686
+ } else {
687
+ for (const item of pending) {
688
+ try {
689
+ await saveOne(item)
690
+ } catch {
691
+ failed += 1
692
+ }
693
+ }
694
+ }
695
+ }
696
+
697
+ // Integrity check: every desired edge endpoint must exist as a live
698
+ // record on the server. An edge whose endpoint failed to save would
699
+ // dangle; count those, never hide.
700
+ let orphans = 0
701
+ for (const { from, to } of edges) {
702
+ if (!live.has(from) || !live.has(to)) orphans += 1
703
+ }
704
+
705
+ const labels = await writeLabelFiles(rootAbs, labelDir || DEFAULT_LABEL_DIR, symbolsByFile)
706
+
707
+ const files = nodes.filter((node) => node.type === 'file').length
708
+ const symbols = nodes.filter((node) => node.type === 'symbol').length
709
+ return {
710
+ linked,
711
+ unlinked,
712
+ saved,
713
+ tombstoned,
714
+ failed,
715
+ orphans,
716
+ files,
717
+ symbols,
718
+ edges: desired.size / 2,
719
+ labels: { written: labels.written.length, deleted: labels.deleted.length }
720
+ }
721
+ }
722
+
723
+ /**
724
+ * Lineage over the sources graph, entirely server-side: files walk
725
+ * imports/imported_by, symbols (ids containing '#') walk calls/called_by.
726
+ * causalChain is direct-only, so hops beyond one are walked level by
727
+ * level. Returns [id, hop] entries per direction; callers shape the
728
+ * response.
729
+ */
730
+ export async function traceSources(client, target, direction = 'both', maxHops = 2) {
731
+ const symbol = target.includes('#')
732
+ const outRelation = symbol ? 'calls' : 'imports'
733
+ const inRelation = symbol ? 'called_by' : 'imported_by'
734
+ const walk = async (start, relation) => {
735
+ const seen = new Map()
736
+ let frontier = [start]
737
+ for (let hop = 1; hop <= maxHops; hop++) {
738
+ const next = []
739
+ for (const node of frontier) {
740
+ let rows
741
+ try {
742
+ rows = await client.causalChain([node], relation, 1000)
743
+ } catch {
744
+ continue
745
+ }
746
+ for (const row of rows || []) {
747
+ if (!seen.has(row.word)) {
748
+ seen.set(row.word, hop)
749
+ next.push(row.word)
750
+ }
751
+ }
752
+ }
753
+ frontier = next
754
+ if (!next.length) break
755
+ }
756
+ return [...seen.entries()].filter(([id]) => id !== start)
757
+ }
758
+ const out = direction === 'in' ? [] : await walk(target, outRelation)
759
+ const inc = direction === 'out' ? [] : await walk(target, inRelation)
760
+ return { out, in: inc }
761
+ }
762
+
763
+ /**
764
+ * HTTP URL of the administration endpoint for a wire:// URL. AnchorDB
765
+ * listens wire on one port and REST on the port below it by default
766
+ * (7448/7447); both are plain host:port.
767
+ */
768
+ export function collectorHttpUrl(wireUrl) {
769
+ const match = /^wire:\/\/([^?]+)/i.exec(String(wireUrl))
770
+ if (!match) return null
771
+ const hostPort = match[1]
772
+ const at = hostPort.lastIndexOf(':')
773
+ if (at <= 0) return null
774
+ const host = hostPort.slice(0, at)
775
+ const port = Number.parseInt(hostPort.slice(at + 1), 10)
776
+ if (!Number.isInteger(port) || port <= 1) return null
777
+ return `http://${host}:${port - 1}/v1/collector/run`
778
+ }
779
+
780
+ /**
781
+ * Ask the server's collector to purge archived (opcode 2) records of one
782
+ * model. Wire cannot start collector jobs; this is the documented HTTP
783
+ * route, scoped to the model so no other model's tombstones are touched.
784
+ * @returns {Promise<number>} records purged
785
+ */
786
+ export async function purgeArchivedRecords(wireUrl, token, model) {
787
+ const url = collectorHttpUrl(wireUrl)
788
+ if (!url) throw new Error(`cannot derive the collector endpoint from "${wireUrl}"`)
789
+ const res = await fetch(url, {
790
+ method: 'POST',
791
+ headers: {
792
+ Authorization: `Bearer ${token}`,
793
+ 'Content-Type': 'application/json'
794
+ },
795
+ body: JSON.stringify({ model })
796
+ })
797
+ if (!res.ok) throw new Error(`collector run failed (HTTP ${res.status})`)
798
+ const body = await res.json()
799
+ if (!body.success) throw new Error(`collector run failed: ${body.message || 'unknown error'}`)
800
+ return body.purged_records ?? 0
801
+ }