@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.
- package/LICENSE +201 -0
- package/README.md +483 -2
- package/bin/knl-view.js +1320 -0
- package/dist/assets/abnfDiagram-WEYOIA3C-hoSbsMOz.js +1 -0
- package/dist/assets/arc-g87Np6E5.js +1 -0
- package/dist/assets/architecture-WOLXFQ4H-DOIq-0IA.js +1 -0
- package/dist/assets/architectureDiagram-ZHVYKT53-Dxwe8n7m.js +36 -0
- package/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/assets/blockDiagram-RJAE7QA3-BnGng59Z.js +140 -0
- package/dist/assets/c4Diagram-XDPPPE54-C5LjqIIo.js +38 -0
- package/dist/assets/channel-BVWkRFBw.js +1 -0
- package/dist/assets/chunk-2E7WHVMD-BJ1dyGLq.js +88 -0
- package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/assets/chunk-4EMNKR2T-DXF02Sgw.js +1 -0
- package/dist/assets/chunk-55XCU76K-CM1akAbu.js +1 -0
- package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/assets/chunk-7S6XSTFO-BOUjqldL.js +2 -0
- package/dist/assets/chunk-AQDTSKZB-C4isdNLA.js +72 -0
- package/dist/assets/chunk-DSNLSHJZ-CbOTkSVM.js +129 -0
- package/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/dist/assets/chunk-HAGFOTED-CRfnketR.js +62 -0
- package/dist/assets/chunk-HNE4Q36P-rxlpGUU8.js +1 -0
- package/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/dist/assets/chunk-NQOKRVIF-BOYCgTmV.js +213 -0
- package/dist/assets/chunk-UHW7PFBP-Cnogp1Fb.js +1 -0
- package/dist/assets/chunk-VQ5W62QI-Cu1WY5Uo.js +1 -0
- package/dist/assets/chunk-WQQL4CDK-BJc3FPE2.js +10 -0
- package/dist/assets/chunk-XXDRQBXY-BlPj4H7s.js +1 -0
- package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/assets/chunk-YZO67HFG-V85H31x4.js +1 -0
- package/dist/assets/chunk-ZAUX2LTX-C3XKXW09.js +1 -0
- package/dist/assets/chunk-ZCHMQSOR-B0mmpEgW.js +2 -0
- package/dist/assets/classDiagram-v2-4MYWR3UA-oMd7YaHP.js +217 -0
- package/dist/assets/cose-bilkent-JH36ORCC-J0kSVsDf.js +1 -0
- package/dist/assets/cynefin-EF2NZ3EQ-DK2TNhel.js +1 -0
- package/dist/assets/cynefinDiagram-WF24UDRP-DDvKgieA.js +62 -0
- package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/assets/dagre-NK4GIZVX-Ca2Fe-i-.js +4 -0
- package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/assets/diagram-CBK3MWTC-L6dmeQ2V.js +24 -0
- package/dist/assets/diagram-I5XIIV2D-DhG8R-v5.js +24 -0
- package/dist/assets/diagram-ODIL2UCW-BpYYbYBT.js +30 -0
- package/dist/assets/diagram-PFBEJUXE-DKQpHSsC.js +3 -0
- package/dist/assets/diagram-RJA3I4VV-DP7Dsnya.js +41 -0
- package/dist/assets/diagram-TYUNFSNR-CfSFewUS.js +200 -0
- package/dist/assets/dist-CQNjnrld.js +1 -0
- package/dist/assets/ebnfDiagram-V4P3TTPT-DE3MZoJO.js +1 -0
- package/dist/assets/elk-QONTBCTQ-p_aAQPHU.js +27 -0
- package/dist/assets/erDiagram-KYQIGQAA-DZO3vQrk.js +99 -0
- package/dist/assets/eventmodeling-K75KTNOO-BII9EPl5.js +1 -0
- package/dist/assets/flowDiagram-IUGLLWBU-D9uPno39.js +1 -0
- package/dist/assets/ganttDiagram-R7GPNTJY-BKiH3v2r.js +292 -0
- package/dist/assets/gitGraph-VSP46ZUC-oHL6cr73.js +1 -0
- package/dist/assets/gitGraphDiagram-PDYL7XHY-CqPVIEeo.js +106 -0
- package/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/assets/index-CSpirV16.js +76 -0
- package/dist/assets/index-DLgMsUs2.css +1 -0
- package/dist/assets/info-OHQRW6UA-Drrbnhpd.js +1 -0
- package/dist/assets/infoDiagram-MVDRKAX5-DnQQFMAd.js +2 -0
- package/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/assets/ishikawaDiagram-HPEHV2GE-DIpQlxRh.js +70 -0
- package/dist/assets/journeyDiagram-BOSHW7O7-EBR9I5h1.js +139 -0
- package/dist/assets/kanban-definition-FNSA4HC5-CdvNsjik.js +89 -0
- package/dist/assets/katex-B_JcIwqj.js +257 -0
- package/dist/assets/line-CDLzgxDc.js +1 -0
- package/dist/assets/linear-DfXKql9u.js +1 -0
- package/dist/assets/mermaid-parser.core-DnXGphT2.js +7 -0
- package/dist/assets/mermaid.core-h6fiCaSZ.js +44 -0
- package/dist/assets/mindmap-definition-6OY57UG3-BiNfio-j.js +98 -0
- package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/assets/packet-JDAUHWVQ-D76byv1S.js +1 -0
- package/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/assets/pegDiagram-ECIUJNBX-Drs8Pxdt.js +1 -0
- package/dist/assets/pie-XZMESJXO-D_83z1lM.js +1 -0
- package/dist/assets/pieDiagram-GNMPY74T-wvzIphSf.js +39 -0
- package/dist/assets/quadrantDiagram-D57K4MC3-B7EfMRWb.js +7 -0
- package/dist/assets/radar-ABXABTNO-Bs6dVdAR.js +1 -0
- package/dist/assets/railroad-I3PHUGI6-lKaN4miw.js +1 -0
- package/dist/assets/railroad-abnf-LNEFI6M7-Bhh16b7Z.js +1 -0
- package/dist/assets/railroad-ebnf-DWYD2UWJ-m6ybW-Y_.js +1 -0
- package/dist/assets/railroad-peg-JR7BVNR7-DNn72e7r.js +1 -0
- package/dist/assets/railroadDiagram-Y72NRBL7-DNeop1iZ.js +1 -0
- package/dist/assets/requirementDiagram-EQYSW5RA-BVYZ2XYJ.js +84 -0
- package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/assets/sankeyDiagram-SY24JHO5-B2MP51on.js +40 -0
- package/dist/assets/sequenceDiagram-BLEHNHQU-BUvxstXG.js +169 -0
- package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/assets/src-BrR0lqlG.js +1 -0
- package/dist/assets/stateDiagram-v2-A6IESKWZ-Ba84OGKE.js +291 -0
- package/dist/assets/swimlanes-B3ENRKFQ-BMSl2PNA.js +1 -0
- package/dist/assets/swimlanesDiagram-AYQRV4AK-M1dmvq7m.js +8 -0
- package/dist/assets/timeline-definition-JXYKGLGB-DOdW-dxq.js +120 -0
- package/dist/assets/treeView-D4JQ5SDB-DZ9KQyn5.js +1 -0
- package/dist/assets/treemap-SAJKECNS-WEC5wtDV.js +1 -0
- package/dist/assets/usecaseDiagram-WPOLBNNX-cldTztFL.js +328 -0
- package/dist/assets/vennDiagram-Q3Y3IDCW-c41KySoY.js +34 -0
- package/dist/assets/wardley-7MLQ67FV-Bf9_aSSZ.js +1 -0
- package/dist/assets/wardleyDiagram-IVAIDKWA-BeKEvEBG.js +78 -0
- package/dist/assets/xychartDiagram-4URNHDU4-tNkAxTxG.js +7 -0
- package/dist/index.html +22 -0
- package/package.json +50 -4
- package/server/config.js +389 -0
- package/server/consensus.js +180 -0
- package/server/graph/anchordb_client.js +253 -0
- package/server/graph/model.js +157 -0
- package/server/graph/parser.js +327 -0
- package/server/graph/sources.js +801 -0
- package/server/graph/syntax.js +437 -0
- package/server/index.js +742 -0
- package/server/install.js +437 -0
- package/server/map.js +126 -0
- package/server/models_registry.js +109 -0
- 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
|
+
}
|