syncstaff-mcp 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/dist/lib/agent-state.js +119 -0
- package/dist/lib/blast.js +462 -0
- package/dist/lib/client-config.js +81 -0
- package/dist/lib/env-compat.js +66 -0
- package/dist/lib/globs.js +0 -0
- package/dist/lib/ids.js +24 -0
- package/dist/lib/index/aliases.js +244 -0
- package/dist/lib/index/call-sites.js +178 -0
- package/dist/lib/index/checker-resolver.js +257 -0
- package/dist/lib/index/context-card.js +140 -0
- package/dist/lib/index/coverage.js +218 -0
- package/dist/lib/index/delivery.js +66 -0
- package/dist/lib/index/discovery.js +90 -0
- package/dist/lib/index/embedding.js +110 -0
- package/dist/lib/index/file-index.js +222 -0
- package/dist/lib/index/fingerprint.js +0 -0
- package/dist/lib/index/git-history.js +136 -0
- package/dist/lib/index/graph.js +234 -0
- package/dist/lib/index/impact.js +174 -0
- package/dist/lib/index/incremental.js +332 -0
- package/dist/lib/index/lexical.js +462 -0
- package/dist/lib/index/order.js +43 -0
- package/dist/lib/index/pages.js +357 -0
- package/dist/lib/index/persistence.js +233 -0
- package/dist/lib/index/pipeline.js +527 -0
- package/dist/lib/index/registry.js +106 -0
- package/dist/lib/index/resolve.js +280 -0
- package/dist/lib/index/semantic.js +381 -0
- package/dist/lib/index/surfaces.js +27 -0
- package/dist/lib/index/symbols.js +426 -0
- package/dist/lib/index/transformers-embedder.js +73 -0
- package/dist/lib/index/typescript-parser.js +532 -0
- package/dist/lib/index/vector-cache.js +176 -0
- package/dist/lib/index/verification.js +58 -0
- package/dist/lib/mcp-compaction.js +241 -0
- package/dist/lib/model-roles.js +206 -0
- package/dist/lib/path-warnings.js +90 -0
- package/dist/lib/protocol.js +95 -0
- package/dist/lib/types.js +69 -0
- package/dist/lib/version.js +21 -0
- package/dist/lib/worktree.js +211 -0
- package/dist/mcp/approval.js +0 -0
- package/dist/mcp/cloud-connector.js +99 -0
- package/dist/mcp/daemon-client.js +156 -0
- package/dist/mcp/daemon-protocol.js +100 -0
- package/dist/mcp/escalation-waiter.js +183 -0
- package/dist/mcp/graph-ops.js +169 -0
- package/dist/mcp/index.js +1151 -0
- package/dist/mcp/login.js +169 -0
- package/dist/mcp/setup.js +90 -0
- package/package.json +42 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Discover useful non-TypeScript relationships without executing application code. */
|
|
2
|
+
export function discoverSurfaceEdges(sources) {
|
|
3
|
+
const edges = [];
|
|
4
|
+
const css = new Set(sources.filter((source) => /\.(?:css|scss|less)$/.test(source.path)).map((source) => source.path));
|
|
5
|
+
const tokens = new Set();
|
|
6
|
+
for (const source of sources) {
|
|
7
|
+
for (const match of source.content.matchAll(/--([A-Za-z0-9_-]+)\s*:/g))
|
|
8
|
+
tokens.add(`--${match[1]}`);
|
|
9
|
+
}
|
|
10
|
+
for (const source of sources) {
|
|
11
|
+
for (const match of source.content.matchAll(/(?:import|require)\s*\(?\s*["']([^"']+\.(?:css|scss|less))["']/g)) {
|
|
12
|
+
const targetPath = join(dirname(source.path), match[1]).replaceAll("\\", "/");
|
|
13
|
+
const target = sources.find((candidate) => candidate.path === targetPath)?.path ?? targetPath;
|
|
14
|
+
edges.push({ from: source.path, to: target, kind: "stylesheet", evidence: match[0] });
|
|
15
|
+
}
|
|
16
|
+
for (const match of source.content.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)) {
|
|
17
|
+
if (tokens.has(match[1]))
|
|
18
|
+
edges.push({ from: source.path, to: match[1], kind: "token", evidence: match[0] });
|
|
19
|
+
}
|
|
20
|
+
for (const match of source.content.matchAll(/(?:app|router)\.(?:get|post|put|patch|delete)\(\s*["']([^"']+)["']\s*,\s*([A-Za-z_$][\w$]*)/g)) {
|
|
21
|
+
edges.push({ from: source.path, to: `${match[1]} -> ${match[2]}`, kind: "route-handler", evidence: match[0] });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return edges.sort((a, b) => byCodeUnit(`${a.from}\0${a.kind}\0${a.to}`, `${b.from}\0${b.kind}\0${b.to}`));
|
|
25
|
+
}
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { byCodeUnit } from "./order.js";
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { resolveSpecifier } from "./resolve.js";
|
|
2
|
+
import { byCodeUnit } from "./order.js";
|
|
3
|
+
import { tokenize } from "./embedding.js";
|
|
4
|
+
/** Index the bindings visible in each file. Pure. */
|
|
5
|
+
export function buildSymbolTable(index) {
|
|
6
|
+
const known = new Set(index.files.map((file) => file.path));
|
|
7
|
+
const table = new Map();
|
|
8
|
+
/** path -> files it re-exports wholesale via `export *`. */
|
|
9
|
+
const starReExports = new Map();
|
|
10
|
+
/** path -> specifiers that were star re-exports, from the parser's diagnostic. */
|
|
11
|
+
const starTargets = new Map();
|
|
12
|
+
for (const file of index.files) {
|
|
13
|
+
const stars = file.result.diagnostics.filter((d) => d.code === "star-reexport");
|
|
14
|
+
if (stars.length === 0)
|
|
15
|
+
continue;
|
|
16
|
+
// The parser records a star re-export as a diagnostic on the same line as
|
|
17
|
+
// the import it also emitted; match them by line so the right specifier is
|
|
18
|
+
// expanded rather than every specifier in the file.
|
|
19
|
+
const lines = new Set(stars.map((d) => d.line));
|
|
20
|
+
starTargets.set(file.path, new Set(file.result.imports.filter((i) => lines.has(i.line)).map((i) => i.specifier)));
|
|
21
|
+
}
|
|
22
|
+
for (const file of index.files) {
|
|
23
|
+
const defined = new Map();
|
|
24
|
+
for (const definition of file.result.definitions ?? []) {
|
|
25
|
+
// First definition wins. A duplicate name in one file is either an
|
|
26
|
+
// overload or shadowing, and picking arbitrarily between them would make
|
|
27
|
+
// the table depend on parse order.
|
|
28
|
+
if (!defined.has(definition.name))
|
|
29
|
+
defined.set(definition.name, definition);
|
|
30
|
+
}
|
|
31
|
+
const imported = new Map();
|
|
32
|
+
for (const importRef of file.result.imports) {
|
|
33
|
+
if (importRef.dynamic)
|
|
34
|
+
continue; // recorded as unresolved by the graph
|
|
35
|
+
const resolution = resolveSpecifier(importRef.specifier, file.path, known, index.aliases ?? []);
|
|
36
|
+
const resolvedPath = resolution.kind === "internal" ? resolution.path : null;
|
|
37
|
+
for (const name of importRef.names) {
|
|
38
|
+
imported.set(name, {
|
|
39
|
+
specifier: importRef.specifier,
|
|
40
|
+
resolvedPath,
|
|
41
|
+
exportedAs: importRef.aliases?.[name] ?? name,
|
|
42
|
+
confidence: importRef.confidence,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const exported = new Map();
|
|
47
|
+
for (const exportRef of file.result.exports) {
|
|
48
|
+
if (!exported.has(exportRef.name))
|
|
49
|
+
exported.set(exportRef.name, exportRef);
|
|
50
|
+
}
|
|
51
|
+
// `export * from "./x.js"` names nothing the parser can see — the names
|
|
52
|
+
// live in x. Recorded here so the second pass below can expand them once
|
|
53
|
+
// every file's own exports are known.
|
|
54
|
+
starReExports.set(file.path, file.result.imports
|
|
55
|
+
.filter((i) => !i.dynamic && starTargets.get(file.path)?.has(i.specifier))
|
|
56
|
+
.map((i) => resolveSpecifier(i.specifier, file.path, known, index.aliases ?? []))
|
|
57
|
+
.flatMap((r) => (r.kind === "internal" ? [r.path] : [])));
|
|
58
|
+
table.set(file.path, {
|
|
59
|
+
path: file.path,
|
|
60
|
+
defined,
|
|
61
|
+
imported,
|
|
62
|
+
exported,
|
|
63
|
+
extractsDefinitions: file.result.definitions !== undefined,
|
|
64
|
+
extractsCalls: file.result.calls !== undefined,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// Expand `export *` now that every file's own exports are known. Bounded
|
|
68
|
+
// depth: barrels legitimately chain, and a cycle would otherwise spin.
|
|
69
|
+
for (let pass = 0; pass < 4; pass += 1) {
|
|
70
|
+
let grew = false;
|
|
71
|
+
for (const [path, targets] of starReExports) {
|
|
72
|
+
const entry = table.get(path);
|
|
73
|
+
if (!entry)
|
|
74
|
+
continue;
|
|
75
|
+
for (const target of targets) {
|
|
76
|
+
const source = table.get(target);
|
|
77
|
+
if (!source)
|
|
78
|
+
continue;
|
|
79
|
+
for (const [name, ref] of source.exported) {
|
|
80
|
+
if (name === "default")
|
|
81
|
+
continue; // `export *` never re-exports default
|
|
82
|
+
if (entry.exported.has(name))
|
|
83
|
+
continue;
|
|
84
|
+
entry.exported.set(name, {
|
|
85
|
+
...ref,
|
|
86
|
+
from: target,
|
|
87
|
+
// The name is certainly exported by the barrel; which file
|
|
88
|
+
// ultimately owns it took a hop this pass did not verify.
|
|
89
|
+
confidence: "probable",
|
|
90
|
+
});
|
|
91
|
+
grew = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!grew)
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
return table;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolve every recorded call site against the symbol table.
|
|
102
|
+
*
|
|
103
|
+
* Deterministic: edges and unresolved entries are sorted, so two machines with
|
|
104
|
+
* the same index produce the same bytes.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveCallEdges(index, table) {
|
|
107
|
+
const edges = [];
|
|
108
|
+
const unresolved = [];
|
|
109
|
+
const unanalyzed = [];
|
|
110
|
+
for (const file of index.files) {
|
|
111
|
+
const entry = table.get(file.path);
|
|
112
|
+
if (!entry)
|
|
113
|
+
continue;
|
|
114
|
+
if (!entry.extractsCalls) {
|
|
115
|
+
// Only worth reporting for files a backend actually read. A markdown
|
|
116
|
+
// file having no call extraction is not a gap in the call graph.
|
|
117
|
+
if (file.parser)
|
|
118
|
+
unanalyzed.push(file.path);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
for (const call of file.result.calls ?? []) {
|
|
122
|
+
const resolvedEdge = resolveOne(call.callee, call.receiver, entry, table, call.ownerClass);
|
|
123
|
+
if (resolvedEdge.kind === "resolved") {
|
|
124
|
+
edges.push({
|
|
125
|
+
from: file.path,
|
|
126
|
+
to: resolvedEdge.to,
|
|
127
|
+
symbol: resolvedEdge.symbol,
|
|
128
|
+
scope: resolvedEdge.scope,
|
|
129
|
+
...(call.enclosing ? { enclosing: call.enclosing } : {}),
|
|
130
|
+
// An edge inherits the weaker of the call's confidence and the
|
|
131
|
+
// binding's. A speculative import cannot yield an exact call.
|
|
132
|
+
confidence: weaker(call.confidence, resolvedEdge.confidence),
|
|
133
|
+
line: call.line,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
unresolved.push({
|
|
138
|
+
from: file.path,
|
|
139
|
+
callee: call.callee,
|
|
140
|
+
...(call.receiver ? { receiver: call.receiver } : {}),
|
|
141
|
+
reason: resolvedEdge.reason,
|
|
142
|
+
line: call.line,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const byCall = (a, b) => byCodeUnit(a.from, b.from) || byCodeUnit(a.to, b.to) || byCodeUnit(a.symbol, b.symbol) ||
|
|
148
|
+
(a.line ?? 0) - (b.line ?? 0);
|
|
149
|
+
const byUnresolved = (a, b) => byCodeUnit(a.from, b.from) || byCodeUnit(a.callee, b.callee) || (a.line ?? 0) - (b.line ?? 0);
|
|
150
|
+
return {
|
|
151
|
+
edges: edges.sort(byCall),
|
|
152
|
+
unresolved: unresolved.sort(byUnresolved),
|
|
153
|
+
unanalyzed: unanalyzed.sort(),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Resolve every recorded reference site — reads, `new`, types, JSX, imports and
|
|
158
|
+
* calls — against the symbol table.
|
|
159
|
+
*
|
|
160
|
+
* This is the answer to "what would break if I changed this symbol", and it is
|
|
161
|
+
* the single path both blast-radius surfaces read. Before it existed, the graph
|
|
162
|
+
* could name every caller of a function and not one user of a constant: the
|
|
163
|
+
* extractor only fired inside `isCallExpression`, so `KEEL_RELEASE_VERSION`,
|
|
164
|
+
* read in four files, resolved to silence.
|
|
165
|
+
*
|
|
166
|
+
* Deterministic, for the same reason as resolveCallEdges: two machines with the
|
|
167
|
+
* same index must produce the same bytes.
|
|
168
|
+
*/
|
|
169
|
+
export function resolveReferenceEdges(index, table) {
|
|
170
|
+
const edges = [];
|
|
171
|
+
const unresolved = [];
|
|
172
|
+
const unanalyzed = [];
|
|
173
|
+
// Names defined anywhere in the index. See ReferenceResolution.unresolved.
|
|
174
|
+
const definedAnywhere = new Set();
|
|
175
|
+
for (const file of index.files) {
|
|
176
|
+
for (const definition of file.result.definitions ?? [])
|
|
177
|
+
definedAnywhere.add(definition.name);
|
|
178
|
+
}
|
|
179
|
+
for (const file of index.files) {
|
|
180
|
+
const entry = table.get(file.path);
|
|
181
|
+
if (!entry)
|
|
182
|
+
continue;
|
|
183
|
+
if (file.result.references === undefined) {
|
|
184
|
+
if (file.parser)
|
|
185
|
+
unanalyzed.push(file.path);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
for (const reference of file.result.references) {
|
|
189
|
+
const resolved = resolveOne(reference.name, reference.receiver, entry, table, reference.ownerClass);
|
|
190
|
+
if (resolved.kind === "resolved") {
|
|
191
|
+
edges.push({
|
|
192
|
+
from: file.path,
|
|
193
|
+
to: resolved.to,
|
|
194
|
+
symbol: resolved.symbol,
|
|
195
|
+
scope: resolved.scope,
|
|
196
|
+
kind: reference.kind,
|
|
197
|
+
...(reference.enclosing ? { enclosing: reference.enclosing } : {}),
|
|
198
|
+
confidence: weaker(reference.confidence, resolved.confidence),
|
|
199
|
+
line: reference.line,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
else if (definedAnywhere.has(reference.name)) {
|
|
203
|
+
unresolved.push({
|
|
204
|
+
from: file.path,
|
|
205
|
+
callee: reference.name,
|
|
206
|
+
...(reference.receiver ? { receiver: reference.receiver } : {}),
|
|
207
|
+
reason: resolved.reason,
|
|
208
|
+
line: reference.line,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const byReference = (a, b) => byCodeUnit(a.from, b.from) || byCodeUnit(a.to, b.to) || byCodeUnit(a.symbol, b.symbol) ||
|
|
214
|
+
(a.line ?? 0) - (b.line ?? 0) || byCodeUnit(a.kind, b.kind);
|
|
215
|
+
const byUnresolved = (a, b) => byCodeUnit(a.from, b.from) || byCodeUnit(a.callee, b.callee) || (a.line ?? 0) - (b.line ?? 0);
|
|
216
|
+
return {
|
|
217
|
+
edges: edges.sort(byReference),
|
|
218
|
+
unresolved: unresolved.sort(byUnresolved),
|
|
219
|
+
unanalyzed: unanalyzed.sort(),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function resolveOne(callee, receiver, entry, table, ownerClass) {
|
|
223
|
+
const dot = callee.indexOf(".");
|
|
224
|
+
if (dot > 0 || receiver) {
|
|
225
|
+
const base = receiver ?? callee.slice(0, dot);
|
|
226
|
+
const member = callee.slice(callee.indexOf(".") + 1);
|
|
227
|
+
// `this.flush()` inside class Store needs no type inference: the class is
|
|
228
|
+
// in the AST at the call site. Resolve against a method that class defines
|
|
229
|
+
// in this same file. Inherited methods are not followed — that needs the
|
|
230
|
+
// heritage chain, and guessing across it would invent edges.
|
|
231
|
+
if (base === "this" && ownerClass) {
|
|
232
|
+
const method = entry.defined.get(member);
|
|
233
|
+
if (method && method.owner === ownerClass) {
|
|
234
|
+
return { kind: "resolved", to: entry.path, symbol: member, scope: "local", confidence: "exact" };
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
kind: "unresolved",
|
|
238
|
+
reason: `"this.${member}" is not defined on ${ownerClass} in this file (inherited, or assigned at runtime)`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
// A namespace import is the one member call that is statically knowable:
|
|
242
|
+
// `import * as db from "./db.js"; db.query()`. Everything else needs the
|
|
243
|
+
// receiver's type, which this layer deliberately does not infer.
|
|
244
|
+
const binding = entry.imported.get(base);
|
|
245
|
+
if (binding?.resolvedPath) {
|
|
246
|
+
const target = table.get(binding.resolvedPath);
|
|
247
|
+
if (target?.exported.has(member)) {
|
|
248
|
+
return {
|
|
249
|
+
kind: "resolved",
|
|
250
|
+
to: binding.resolvedPath,
|
|
251
|
+
symbol: member,
|
|
252
|
+
scope: "imported",
|
|
253
|
+
// Probable, not exact: the base could have been reassigned between
|
|
254
|
+
// the import and the call, and nothing here proves it was not.
|
|
255
|
+
confidence: "probable",
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
kind: "unresolved",
|
|
261
|
+
reason: `member call on "${base}" whose type is not tracked`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const local = entry.defined.get(callee);
|
|
265
|
+
if (local) {
|
|
266
|
+
return { kind: "resolved", to: entry.path, symbol: callee, scope: "local", confidence: "exact" };
|
|
267
|
+
}
|
|
268
|
+
const binding = entry.imported.get(callee);
|
|
269
|
+
if (binding) {
|
|
270
|
+
if (!binding.resolvedPath) {
|
|
271
|
+
return {
|
|
272
|
+
kind: "unresolved",
|
|
273
|
+
reason: `imported from "${binding.specifier}", which is outside the index`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const target = table.get(binding.resolvedPath);
|
|
277
|
+
if (target?.exported.has(binding.exportedAs)) {
|
|
278
|
+
return {
|
|
279
|
+
kind: "resolved",
|
|
280
|
+
to: binding.resolvedPath,
|
|
281
|
+
// The TARGET's name for it. `import { query as run }` then `run()`
|
|
282
|
+
// reaches `query`, and reporting `run` would name a symbol the target
|
|
283
|
+
// does not have.
|
|
284
|
+
symbol: binding.exportedAs,
|
|
285
|
+
scope: "imported",
|
|
286
|
+
confidence: binding.confidence,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
// Imported by this name, but the target does not export it. An alias or a
|
|
290
|
+
// re-export through a barrel. Both are real edges this layer cannot follow,
|
|
291
|
+
// and guessing which file behind the barrel owns the symbol is exactly the
|
|
292
|
+
// guess that produces wrong leases.
|
|
293
|
+
return {
|
|
294
|
+
kind: "unresolved",
|
|
295
|
+
reason: `"${callee}" is imported from ${binding.resolvedPath} as "${binding.exportedAs}", which that file does not export (re-export through a barrel)`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
// Not bound in this file. A global, a builtin, or a name this backend saw
|
|
299
|
+
// without seeing its declaration. Emphatically not "the function of the same
|
|
300
|
+
// name over in some other directory".
|
|
301
|
+
return { kind: "unresolved", reason: `"${callee}" is not defined in this file and not imported into it` };
|
|
302
|
+
}
|
|
303
|
+
function weaker(a, b) {
|
|
304
|
+
const rank = { exact: 2, probable: 1, speculative: 0 };
|
|
305
|
+
return rank[a] <= rank[b] ? a : b;
|
|
306
|
+
}
|
|
307
|
+
export function extractContracts(index) {
|
|
308
|
+
const contracts = [];
|
|
309
|
+
for (const file of index.files) {
|
|
310
|
+
for (const exported of file.result.exports) {
|
|
311
|
+
contracts.push({
|
|
312
|
+
path: file.path,
|
|
313
|
+
name: exported.name,
|
|
314
|
+
kind: exported.kind,
|
|
315
|
+
confidence: exported.confidence,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return contracts.sort((a, b) => byCodeUnit(a.path, b.path) || byCodeUnit(a.name, b.name) || byCodeUnit(a.kind, b.kind));
|
|
320
|
+
}
|
|
321
|
+
/** Files that export a given symbol name. Used to explain a blast radius. */
|
|
322
|
+
export function definersOf(index, symbol) {
|
|
323
|
+
return index.files
|
|
324
|
+
.filter((file) => file.result.exports.some((e) => e.name === symbol))
|
|
325
|
+
.map((file) => file.path)
|
|
326
|
+
.sort();
|
|
327
|
+
}
|
|
328
|
+
const symbolCorpusCache = new WeakMap();
|
|
329
|
+
function corpusFor(index) {
|
|
330
|
+
const cached = symbolCorpusCache.get(index);
|
|
331
|
+
if (cached)
|
|
332
|
+
return cached;
|
|
333
|
+
const byKey = new Map();
|
|
334
|
+
for (const file of index.files) {
|
|
335
|
+
const names = [
|
|
336
|
+
...(file.result.definitions ?? []).map((definition) => definition.name),
|
|
337
|
+
...file.result.exports.map((exported) => exported.name),
|
|
338
|
+
];
|
|
339
|
+
for (const symbol of new Set(names)) {
|
|
340
|
+
const terms = new Set(tokenize(symbol));
|
|
341
|
+
if (terms.size === 0)
|
|
342
|
+
continue;
|
|
343
|
+
const key = `${file.path}\0${symbol}`;
|
|
344
|
+
byKey.set(key, {
|
|
345
|
+
path: file.path,
|
|
346
|
+
symbol,
|
|
347
|
+
terms,
|
|
348
|
+
leaf: [...terms].at(-1) ?? symbol.toLowerCase(),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const candidates = [...byKey.values()].sort((a, b) => byCodeUnit(a.path, b.path) || byCodeUnit(a.symbol, b.symbol));
|
|
353
|
+
const postings = new Map();
|
|
354
|
+
for (const candidate of candidates) {
|
|
355
|
+
for (const term of candidate.terms) {
|
|
356
|
+
const matches = postings.get(term) ?? [];
|
|
357
|
+
matches.push(candidate);
|
|
358
|
+
postings.set(term, matches);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const corpus = { candidates, postings };
|
|
362
|
+
symbolCorpusCache.set(index, corpus);
|
|
363
|
+
return corpus;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Search the local symbol table from prose without an embedding model.
|
|
367
|
+
*
|
|
368
|
+
* This deliberately uses per-term candidate windows. A common word such as
|
|
369
|
+
* "update" should contribute a quarter-weight signal when it matches many
|
|
370
|
+
* symbols, while a pair of less-saturated terms can corroborate a candidate.
|
|
371
|
+
* A leaf-name match is sufficient on its own because it is the strongest
|
|
372
|
+
* evidence available without type inference.
|
|
373
|
+
*/
|
|
374
|
+
export function searchSymbols(index, query, options = {}) {
|
|
375
|
+
const limit = options.limit ?? 20;
|
|
376
|
+
const maxFiles = options.maxFiles ?? 8;
|
|
377
|
+
const candidateWindow = options.candidateWindow ?? 20;
|
|
378
|
+
if (limit < 1 || maxFiles < 1 || candidateWindow < 1)
|
|
379
|
+
return [];
|
|
380
|
+
const { candidates, postings } = corpusFor(index);
|
|
381
|
+
const queryTerms = [...new Set(tokenize(query))];
|
|
382
|
+
const scored = [];
|
|
383
|
+
for (const candidate of candidates) {
|
|
384
|
+
let score = 0;
|
|
385
|
+
let unsaturatedMatches = 0;
|
|
386
|
+
let exactLeaf = false;
|
|
387
|
+
const matched = new Set();
|
|
388
|
+
for (const term of queryTerms) {
|
|
389
|
+
if (!candidate.terms.has(term))
|
|
390
|
+
continue;
|
|
391
|
+
const matches = postings.get(term) ?? [];
|
|
392
|
+
const saturated = matches.length >= candidateWindow;
|
|
393
|
+
const weight = saturated ? 0.25 : 1;
|
|
394
|
+
const leaf = candidate.leaf === term;
|
|
395
|
+
score += weight * (leaf ? 2 : 1);
|
|
396
|
+
if (!saturated)
|
|
397
|
+
unsaturatedMatches += 1;
|
|
398
|
+
if (leaf)
|
|
399
|
+
exactLeaf = true;
|
|
400
|
+
matched.add(term);
|
|
401
|
+
}
|
|
402
|
+
// Corroboration prevents one broad prose term from producing a noisy
|
|
403
|
+
// symbol leg; a direct leaf-name mention is the intentional exception.
|
|
404
|
+
if (!exactLeaf && unsaturatedMatches < 2)
|
|
405
|
+
continue;
|
|
406
|
+
scored.push({
|
|
407
|
+
path: candidate.path,
|
|
408
|
+
symbol: candidate.symbol,
|
|
409
|
+
score,
|
|
410
|
+
matched_terms: [...matched].sort(byCodeUnit),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
scored.sort((a, b) => b.score - a.score || b.matched_terms.length - a.matched_terms.length ||
|
|
414
|
+
byCodeUnit(a.path, b.path) || byCodeUnit(a.symbol, b.symbol));
|
|
415
|
+
const paths = new Set();
|
|
416
|
+
const results = [];
|
|
417
|
+
for (const hit of scored) {
|
|
418
|
+
if (!paths.has(hit.path) && paths.size >= maxFiles)
|
|
419
|
+
continue;
|
|
420
|
+
paths.add(hit.path);
|
|
421
|
+
results.push(hit);
|
|
422
|
+
if (results.length >= limit)
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
return results;
|
|
426
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A local sentence embedding model running on CPU using @xenova/transformers.
|
|
3
|
+
* Fulfills R4.1.
|
|
4
|
+
*/
|
|
5
|
+
export class TransformersEmbedder {
|
|
6
|
+
modelId;
|
|
7
|
+
queryPrefix;
|
|
8
|
+
documentPrefix;
|
|
9
|
+
_dimensions = 0;
|
|
10
|
+
pipelinePromise = null;
|
|
11
|
+
constructor(modelId = "Xenova/bge-small-en-v1.5", queryPrefix = "", documentPrefix = "") {
|
|
12
|
+
this.modelId = modelId;
|
|
13
|
+
this.queryPrefix = queryPrefix;
|
|
14
|
+
this.documentPrefix = documentPrefix;
|
|
15
|
+
}
|
|
16
|
+
get dimensions() {
|
|
17
|
+
return this._dimensions;
|
|
18
|
+
}
|
|
19
|
+
get id() {
|
|
20
|
+
return `transformers:${this.modelId}:${this.queryPrefix}:${this.documentPrefix}`;
|
|
21
|
+
}
|
|
22
|
+
async getPipeline() {
|
|
23
|
+
if (!this.pipelinePromise) {
|
|
24
|
+
this.pipelinePromise = (async () => {
|
|
25
|
+
try {
|
|
26
|
+
// Dynamic import allows Keel to run if the dependency is missing (R4.2)
|
|
27
|
+
const { pipeline } = await import("@xenova/transformers");
|
|
28
|
+
return await pipeline("feature-extraction", this.modelId, {
|
|
29
|
+
quantized: true // Use INT8 quantization for smaller size and faster CPU inference
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
throw new Error("Failed to load @xenova/transformers. Vector leg will be disabled.", { cause: error });
|
|
34
|
+
}
|
|
35
|
+
})();
|
|
36
|
+
}
|
|
37
|
+
return this.pipelinePromise;
|
|
38
|
+
}
|
|
39
|
+
async embedText(text) {
|
|
40
|
+
const extractor = await this.getPipeline();
|
|
41
|
+
const output = await extractor(text, { pooling: "mean", normalize: true });
|
|
42
|
+
const vector = Array.from(output.data);
|
|
43
|
+
this._dimensions = vector.length;
|
|
44
|
+
return vector;
|
|
45
|
+
}
|
|
46
|
+
async embed(text) {
|
|
47
|
+
return this.embedText(text);
|
|
48
|
+
}
|
|
49
|
+
async embedQuery(text) {
|
|
50
|
+
return this.embedText(`${this.queryPrefix}${text}`);
|
|
51
|
+
}
|
|
52
|
+
async embedDocument(text) {
|
|
53
|
+
return this.embedText(`${this.documentPrefix}${text}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function createTransformersEmbedder(modelId = "Xenova/bge-small-en-v1.5") {
|
|
57
|
+
const asymmetric = modelId.includes("bge-small-en-v1.5")
|
|
58
|
+
? { query: "Represent this sentence for searching relevant passages: ", document: "" }
|
|
59
|
+
: modelId.includes("nomic-embed-text")
|
|
60
|
+
? { query: "search_query: ", document: "search_document: " }
|
|
61
|
+
: modelId.includes("embeddinggemma")
|
|
62
|
+
? { query: "task: search result | query: ", document: "title: none | text: " }
|
|
63
|
+
: { query: "", document: "" };
|
|
64
|
+
const embedder = new TransformersEmbedder(modelId, asymmetric.query, asymmetric.document);
|
|
65
|
+
try {
|
|
66
|
+
// Eagerly try to load the pipeline. If it fails, degrade cleanly to null.
|
|
67
|
+
await embedder.embed("warmup");
|
|
68
|
+
return embedder;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|