context-slice 1.8.2
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 +21 -0
- package/README.md +349 -0
- package/dist/src/cli.js +199 -0
- package/dist/src/indexer/index.js +325 -0
- package/dist/src/languages/adapter.js +23 -0
- package/dist/src/languages/go/index.js +15 -0
- package/dist/src/languages/go/parse.js +443 -0
- package/dist/src/languages/go/resolve.js +313 -0
- package/dist/src/languages/java/enterprise/dependency-injection.js +175 -0
- package/dist/src/languages/java/enterprise/jpa-entity.js +89 -0
- package/dist/src/languages/java/enterprise/registry.js +22 -0
- package/dist/src/languages/java/enterprise/spring-data.js +239 -0
- package/dist/src/languages/java/enterprise/spring-mvc.js +183 -0
- package/dist/src/languages/java/enterprise/transactions.js +110 -0
- package/dist/src/languages/java.js +84 -0
- package/dist/src/languages/javascript/index.js +25 -0
- package/dist/src/languages/python/index.js +29 -0
- package/dist/src/languages/python/parse.js +415 -0
- package/dist/src/languages/python/resolve.js +413 -0
- package/dist/src/languages/rust/calls-resolve.js +1405 -0
- package/dist/src/languages/rust/index.js +12 -0
- package/dist/src/languages/rust/parse.js +545 -0
- package/dist/src/languages/rust/resolve.js +284 -0
- package/dist/src/languages/typescript/index.js +25 -0
- package/dist/src/languages/typescript/parse.js +793 -0
- package/dist/src/languages/typescript/resolve.js +463 -0
- package/dist/src/package-info.js +16 -0
- package/dist/src/parser/java-parser.js +339 -0
- package/dist/src/planner/budget.js +1 -0
- package/dist/src/planner/composition.js +372 -0
- package/dist/src/planner/rank.js +10 -0
- package/dist/src/render/compact-context.js +11 -0
- package/dist/src/server/mcp-server.js +154 -0
- package/dist/src/storage/sqlite.js +105 -0
- package/dist/src/types/enterprise.js +1 -0
- package/dist/src/types/model.js +1 -0
- package/dist/src/workflow/errors.js +10 -0
- package/dist/src/workflow/preview.js +220 -0
- package/dist/src/workflow/repository.js +56 -0
- package/mcp.json +10 -0
- package/package.json +76 -0
- package/plugin.json +15 -0
- package/queries/java/annotations.scm +1 -0
- package/queries/java/calls.scm +1 -0
- package/queries/java/imports.scm +1 -0
- package/queries/java/symbols.scm +2 -0
- package/skills/context-slice/SKILL.md +60 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { estimateTokens } from "./budget.js";
|
|
2
|
+
const TYPE_KINDS = new Set([
|
|
3
|
+
"class",
|
|
4
|
+
"interface",
|
|
5
|
+
"enum",
|
|
6
|
+
"record",
|
|
7
|
+
"namespace",
|
|
8
|
+
]);
|
|
9
|
+
/** A skeleton stays compact even for a large class. */
|
|
10
|
+
const MAX_SKELETON_MEMBERS = 12;
|
|
11
|
+
/** The first line of a declaration, exactly as written, without its opening brace. */
|
|
12
|
+
export function declarationLine(symbol) {
|
|
13
|
+
return symbol.source
|
|
14
|
+
.split("\n")[0]
|
|
15
|
+
.trim()
|
|
16
|
+
.replace(/\s*\{\s*$/, "")
|
|
17
|
+
.replace(/\s*=>\s*$/, "")
|
|
18
|
+
.trim();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Java fields are not indexed as symbols, so read their declarations from the
|
|
22
|
+
* enclosing type's source. Analysis only: nothing is added to the index, so
|
|
23
|
+
* retrieval and ranking are untouched.
|
|
24
|
+
*/
|
|
25
|
+
function javaFieldDeclarations(parent) {
|
|
26
|
+
if (parent.language !== "java")
|
|
27
|
+
return [];
|
|
28
|
+
return [
|
|
29
|
+
...(parent.body ?? parent.source).matchAll(/^\s*(?:(?:public|protected|private|static|final|transient|volatile)\s+)*[A-Za-z_$][\w$<>,.[\]\s]*?\s+[A-Za-z_$][\w$]*\s*(?:=[^;]*)?;/gm),
|
|
30
|
+
]
|
|
31
|
+
.map((match) => match[0].trim())
|
|
32
|
+
.filter((declaration) => !/[()]/.test(declaration));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Compose a compact skeleton of the target's enclosing type: its own
|
|
36
|
+
* declaration line, its field declarations and up to 12 member declaration
|
|
37
|
+
* lines. Never a body, so this can never become a whole-class dump.
|
|
38
|
+
*/
|
|
39
|
+
export function composeSiblings(index, target,
|
|
40
|
+
/** Symbols already in the slice: the skeleton must not repeat them. */
|
|
41
|
+
alreadyIncluded = new Set()) {
|
|
42
|
+
const parent = target.parentId
|
|
43
|
+
? index.symbols.find((symbol) => symbol.id === target.parentId)
|
|
44
|
+
: undefined;
|
|
45
|
+
if (!parent || !TYPE_KINDS.has(parent.kind))
|
|
46
|
+
return [];
|
|
47
|
+
const members = index.symbols.filter((symbol) => symbol.parentId === parent.id && symbol.id !== target.id);
|
|
48
|
+
if (!members.length)
|
|
49
|
+
return [];
|
|
50
|
+
const remaining = members.filter((member) => !alreadyIncluded.has(member.id));
|
|
51
|
+
const shown = remaining
|
|
52
|
+
.sort((a, b) => a.range.startLine - b.range.startLine)
|
|
53
|
+
.slice(0, MAX_SKELETON_MEMBERS);
|
|
54
|
+
const lines = [
|
|
55
|
+
`// Enclosing type of ${target.name}`,
|
|
56
|
+
declarationLine(parent),
|
|
57
|
+
...javaFieldDeclarations(parent).map((declaration) => ` ${declaration}`),
|
|
58
|
+
...shown.map((member) => ` ${declarationLine(member)}`),
|
|
59
|
+
];
|
|
60
|
+
if (remaining.length > shown.length)
|
|
61
|
+
lines.push(` // … ${remaining.length - shown.length} more members`);
|
|
62
|
+
const rendered = lines.join("\n");
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
symbol: parent,
|
|
66
|
+
label: parent.qualifiedName ?? parent.name,
|
|
67
|
+
reason: "enclosing type",
|
|
68
|
+
evidence: [
|
|
69
|
+
`${target.name} is declared in ${parent.name}; declaration lines only, ${shown.length} of ${members.length} members${members.length - remaining.length > 0
|
|
70
|
+
? `, ${members.length - remaining.length} already in the slice`
|
|
71
|
+
: ""}`,
|
|
72
|
+
],
|
|
73
|
+
rendered,
|
|
74
|
+
estimatedTokens: estimateTokens(rendered),
|
|
75
|
+
filePath: parent.filePath,
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Compose each related file's top-level `use` declarations (Task 1's
|
|
81
|
+
* synthetic per-file import symbol) into the slice, so a caller's imports
|
|
82
|
+
* explain names the caller's skeleton references. Rust only: other
|
|
83
|
+
* languages don't get a synthetic import symbol to find.
|
|
84
|
+
*/
|
|
85
|
+
export function composeImportContext(index, target, relatedFiles, alreadyIncluded) {
|
|
86
|
+
if (target.language !== "rust")
|
|
87
|
+
return [];
|
|
88
|
+
const candidates = [];
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
for (const file of relatedFiles) {
|
|
91
|
+
if (seen.has(file))
|
|
92
|
+
continue;
|
|
93
|
+
seen.add(file);
|
|
94
|
+
const symbol = index.symbols.find((s) => s.kind === "namespace" && s.metadata?.moduleScope === true && s.filePath === file);
|
|
95
|
+
if (!symbol || alreadyIncluded.has(symbol.id) || !symbol.source)
|
|
96
|
+
continue;
|
|
97
|
+
candidates.push({
|
|
98
|
+
symbol,
|
|
99
|
+
label: `Imports in ${file}`,
|
|
100
|
+
reason: "file imports",
|
|
101
|
+
evidence: [`file-level use declarations for ${file}`],
|
|
102
|
+
rendered: symbol.source,
|
|
103
|
+
estimatedTokens: estimateTokens(symbol.source),
|
|
104
|
+
filePath: file,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return candidates;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Compose the route(s) that reach the target: a ROUTE_TO_HANDLER relation
|
|
111
|
+
* whose source is the target itself, or an already-included caller of it
|
|
112
|
+
* (recovering the controller→service direction when the target is a
|
|
113
|
+
* service method one hop below the route handler). Java only: other
|
|
114
|
+
* languages have no Spring MVC extractor to find relations from.
|
|
115
|
+
*/
|
|
116
|
+
/** Pull the injected field/param/setter name out of a DI relation's evidence text. */
|
|
117
|
+
function fieldOrParamName(evidence) {
|
|
118
|
+
return (evidence.match(/constructor parameter \S+ (\S+)/)?.[1] ??
|
|
119
|
+
evidence.match(/@\w+ field \S+ (\S+)/)?.[1] ??
|
|
120
|
+
evidence.match(/@\w+ setter (\w+)\(/)?.[1]);
|
|
121
|
+
}
|
|
122
|
+
function injectionMechanism(evidence) {
|
|
123
|
+
if (evidence.startsWith("constructor parameter"))
|
|
124
|
+
return "constructor-injected";
|
|
125
|
+
if (/@\w+ field /.test(evidence))
|
|
126
|
+
return "field-injected";
|
|
127
|
+
if (/@\w+ setter /.test(evidence))
|
|
128
|
+
return "setter-injected";
|
|
129
|
+
return "dependency-injected";
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Compose the dependencies the target's own type injects, but only the ones
|
|
133
|
+
* relevant to this task: a dependency is surfaced only when the resolved
|
|
134
|
+
* type owns a member already present in `relatedIds` (a direct caller or
|
|
135
|
+
* callee already in the slice) — otherwise it's annotation spam. Java only:
|
|
136
|
+
* other languages have no DI extractor to find relations from.
|
|
137
|
+
*/
|
|
138
|
+
export function composeDependencyContext(index, target, relatedIds, alreadyIncluded) {
|
|
139
|
+
if (target.language !== "java")
|
|
140
|
+
return [];
|
|
141
|
+
let owner = target.kind === "class" ? target : undefined;
|
|
142
|
+
let cursor = target.parentId
|
|
143
|
+
? index.symbols.find((s) => s.id === target.parentId)
|
|
144
|
+
: undefined;
|
|
145
|
+
while (!owner && cursor) {
|
|
146
|
+
if (cursor.kind === "class")
|
|
147
|
+
owner = cursor;
|
|
148
|
+
else
|
|
149
|
+
cursor = cursor.parentId ? index.symbols.find((s) => s.id === cursor.parentId) : undefined;
|
|
150
|
+
}
|
|
151
|
+
if (!owner)
|
|
152
|
+
return [];
|
|
153
|
+
const ownSymbolIds = new Set(index.symbols
|
|
154
|
+
.filter((s) => s.parentId === owner.id &&
|
|
155
|
+
(s.kind === "constructor" || s.kind === "method"))
|
|
156
|
+
.map((s) => s.id));
|
|
157
|
+
ownSymbolIds.add(owner.id);
|
|
158
|
+
const candidates = [];
|
|
159
|
+
const seenRelations = new Set();
|
|
160
|
+
for (const relation of index.enterpriseRelations) {
|
|
161
|
+
if (relation.kind !== "INJECTS_DEPENDENCY")
|
|
162
|
+
continue;
|
|
163
|
+
if (relation.confidence === "unresolved")
|
|
164
|
+
continue;
|
|
165
|
+
if (!ownSymbolIds.has(relation.sourceSymbolId))
|
|
166
|
+
continue;
|
|
167
|
+
if (!relation.targetSymbolId)
|
|
168
|
+
continue;
|
|
169
|
+
const dependencyMembers = index.symbols.filter((s) => s.parentId === relation.targetSymbolId);
|
|
170
|
+
const isRelevant = dependencyMembers.some((s) => relatedIds.has(s.id));
|
|
171
|
+
if (!isRelevant)
|
|
172
|
+
continue;
|
|
173
|
+
const key = `${relation.sourceSymbolId}:${relation.targetSymbolId}`;
|
|
174
|
+
if (seenRelations.has(key))
|
|
175
|
+
continue;
|
|
176
|
+
seenRelations.add(key);
|
|
177
|
+
const evidence = relation.evidence[0] ?? "";
|
|
178
|
+
const name = fieldOrParamName(evidence);
|
|
179
|
+
const mechanism = injectionMechanism(evidence);
|
|
180
|
+
const rendered = `// Dependency\n${owner.name}.${name ?? relation.targetLabel} → ${relation.targetLabel} (${mechanism})`;
|
|
181
|
+
candidates.push({
|
|
182
|
+
label: relation.targetLabel ?? "dependency",
|
|
183
|
+
reason: "enterprise relation",
|
|
184
|
+
evidence: relation.evidence,
|
|
185
|
+
rendered,
|
|
186
|
+
estimatedTokens: estimateTokens(rendered),
|
|
187
|
+
filePath: relation.filePath,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return candidates;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Compose the transaction attributes carried by the target itself or by an
|
|
194
|
+
* already-included caller/callee — a method's own @Transactional attributes
|
|
195
|
+
* matter regardless of which symbol in the slice they belong to, since this
|
|
196
|
+
* family's relations are already keyed directly to the method (no
|
|
197
|
+
* class-ownership walk needed, unlike composeDependencyContext). Java only:
|
|
198
|
+
* other languages have no @Transactional extractor to find relations from.
|
|
199
|
+
*/
|
|
200
|
+
export function composeTransactionContext(index, target, relatedIds, alreadyIncluded) {
|
|
201
|
+
if (target.language !== "java")
|
|
202
|
+
return [];
|
|
203
|
+
const candidateIds = new Set([target.id, ...relatedIds]);
|
|
204
|
+
const candidates = [];
|
|
205
|
+
for (const relation of index.enterpriseRelations) {
|
|
206
|
+
if (relation.kind !== "TRANSACTION_BOUNDARY")
|
|
207
|
+
continue;
|
|
208
|
+
if (!candidateIds.has(relation.sourceSymbolId))
|
|
209
|
+
continue;
|
|
210
|
+
// The method symbol is looked up only for a friendlier label/name; it is
|
|
211
|
+
// never attached as `symbol` here, since it is always already in the
|
|
212
|
+
// slice (as the target itself, or as an already-included caller/callee)
|
|
213
|
+
// and the shared composition loop in buildPreview dedupes candidates by
|
|
214
|
+
// symbol id — attaching it would silently drop this very candidate.
|
|
215
|
+
const methodSymbol = index.symbols.find((s) => s.id === relation.sourceSymbolId);
|
|
216
|
+
const name = methodSymbol?.qualifiedName ?? methodSymbol?.name ?? target.name;
|
|
217
|
+
const rendered = `// Transaction\n${name} (${relation.targetLabel})`;
|
|
218
|
+
candidates.push({
|
|
219
|
+
label: relation.targetLabel ?? "transaction",
|
|
220
|
+
reason: "enterprise relation",
|
|
221
|
+
evidence: relation.evidence,
|
|
222
|
+
rendered,
|
|
223
|
+
estimatedTokens: estimateTokens(rendered),
|
|
224
|
+
filePath: methodSymbol?.filePath ?? relation.filePath,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return candidates;
|
|
228
|
+
}
|
|
229
|
+
const JPA_RELATION_KINDS = new Set([
|
|
230
|
+
"ENTITY_RELATION",
|
|
231
|
+
"PERSISTS_ENTITY",
|
|
232
|
+
"REPOSITORY_QUERY",
|
|
233
|
+
]);
|
|
234
|
+
/** `"@OneToMany on field pets"` → `{ relKind: "OneToMany", field: "pets" }`. */
|
|
235
|
+
function entityRelationParts(evidence) {
|
|
236
|
+
const match = evidence.match(/^@(\w+) on field (\w+)/);
|
|
237
|
+
return { relKind: match?.[1], field: match?.[2] };
|
|
238
|
+
}
|
|
239
|
+
/** `"extends JpaRepository<Owner, Integer>"` → `"JpaRepository"`. */
|
|
240
|
+
function baseRepositoryName(evidence) {
|
|
241
|
+
return evidence.match(/extends\s+(\w+)/)?.[1];
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Compose the JPA entity relationships and Spring Data repository/query
|
|
245
|
+
* facts relevant to the target: an ENTITY_RELATION owned by the target's
|
|
246
|
+
* enclosing entity class (fields aren't indexed as symbols, so a member
|
|
247
|
+
* target needs the class-ownership walk, same as composeDependencyContext),
|
|
248
|
+
* or a PERSISTS_ENTITY/REPOSITORY_QUERY keyed directly to the target itself
|
|
249
|
+
* (repository interface or query method are already indexed symbols, so no
|
|
250
|
+
* walk is needed for those two). An ENTITY_RELATION is further gated by
|
|
251
|
+
* relevance (its resolved target entity must own a member already in
|
|
252
|
+
* `relatedIds`), same "no annotation spam" rule composeDependencyContext
|
|
253
|
+
* applies — PERSISTS_ENTITY/REPOSITORY_QUERY don't need this, they're
|
|
254
|
+
* already scoped to the single interface/method, not an owning class with
|
|
255
|
+
* multiple children. Java only: other languages have no JPA/Spring Data
|
|
256
|
+
* extractor to find relations from.
|
|
257
|
+
*/
|
|
258
|
+
export function composeJpaContext(index, target, relatedIds, alreadyIncluded) {
|
|
259
|
+
if (target.language !== "java")
|
|
260
|
+
return [];
|
|
261
|
+
let owner = target.kind === "class" ? target : undefined;
|
|
262
|
+
let cursor = target.parentId
|
|
263
|
+
? index.symbols.find((s) => s.id === target.parentId)
|
|
264
|
+
: undefined;
|
|
265
|
+
while (!owner && cursor) {
|
|
266
|
+
if (cursor.kind === "class")
|
|
267
|
+
owner = cursor;
|
|
268
|
+
else
|
|
269
|
+
cursor = cursor.parentId ? index.symbols.find((s) => s.id === cursor.parentId) : undefined;
|
|
270
|
+
}
|
|
271
|
+
const candidateIds = new Set([target.id, ...relatedIds]);
|
|
272
|
+
if (owner)
|
|
273
|
+
candidateIds.add(owner.id);
|
|
274
|
+
const candidates = [];
|
|
275
|
+
const seenRelations = new Set();
|
|
276
|
+
for (const relation of index.enterpriseRelations) {
|
|
277
|
+
if (!JPA_RELATION_KINDS.has(relation.kind))
|
|
278
|
+
continue;
|
|
279
|
+
if (relation.confidence === "unresolved")
|
|
280
|
+
continue;
|
|
281
|
+
if (!candidateIds.has(relation.sourceSymbolId))
|
|
282
|
+
continue;
|
|
283
|
+
// Unlike PERSISTS_ENTITY/REPOSITORY_QUERY (already keyed directly to the
|
|
284
|
+
// interface/method, no class-ownership walk involved), an
|
|
285
|
+
// ENTITY_RELATION is keyed to the whole owning class, so without a
|
|
286
|
+
// relevance gate every relationship field on that class would surface
|
|
287
|
+
// for any member target — same "no annotation spam" discipline as
|
|
288
|
+
// composeDependencyContext: only surface it when its resolved target
|
|
289
|
+
// entity type owns a member already in the slice.
|
|
290
|
+
if (relation.kind === "ENTITY_RELATION") {
|
|
291
|
+
if (!relation.targetSymbolId)
|
|
292
|
+
continue;
|
|
293
|
+
const targetMembers = index.symbols.filter((s) => s.parentId === relation.targetSymbolId);
|
|
294
|
+
if (!targetMembers.some((s) => relatedIds.has(s.id)))
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
// evidence[0] names the declaring field (e.g. "@ManyToOne on field
|
|
298
|
+
// billingAddress"): without it, two distinct fields of the same kind on
|
|
299
|
+
// the same class pointing at the same target type (billingAddress and
|
|
300
|
+
// shippingAddress, both @ManyToOne Address) would collapse into one key
|
|
301
|
+
// and silently drop the second relation.
|
|
302
|
+
const key = `${relation.sourceSymbolId}:${relation.kind}:${relation.targetLabel ?? ""}:${relation.evidence[0] ?? ""}`;
|
|
303
|
+
if (seenRelations.has(key))
|
|
304
|
+
continue;
|
|
305
|
+
seenRelations.add(key);
|
|
306
|
+
// The source symbol is looked up only for a friendlier label/name; it is
|
|
307
|
+
// never attached as `symbol` here, since it may already be in the slice
|
|
308
|
+
// (as the target itself, its enclosing class, or an already-included
|
|
309
|
+
// relative) and the shared composition loop in buildPreview dedupes
|
|
310
|
+
// candidates by symbol id — attaching it would silently drop this very
|
|
311
|
+
// candidate.
|
|
312
|
+
const sourceSymbol = index.symbols.find((s) => s.id === relation.sourceSymbolId);
|
|
313
|
+
const name = sourceSymbol?.name ?? target.name;
|
|
314
|
+
let rendered;
|
|
315
|
+
if (relation.kind === "ENTITY_RELATION") {
|
|
316
|
+
const { relKind, field } = entityRelationParts(relation.evidence[0] ?? "");
|
|
317
|
+
rendered = `// Entity relationship\n${name}${field ? `.${field}` : ""} → ${relation.targetLabel}${relKind ? ` (${relKind})` : ""}`;
|
|
318
|
+
}
|
|
319
|
+
else if (relation.kind === "PERSISTS_ENTITY") {
|
|
320
|
+
const base = baseRepositoryName(relation.evidence[0] ?? "");
|
|
321
|
+
rendered = `// Repository\n${name} → ${relation.targetLabel}${base ? ` (${base})` : ""}`;
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
rendered = `// Query\n${name} → ${relation.evidence.join(", ")}`;
|
|
325
|
+
}
|
|
326
|
+
candidates.push({
|
|
327
|
+
label: relation.targetLabel ?? relation.kind,
|
|
328
|
+
reason: "enterprise relation",
|
|
329
|
+
evidence: relation.evidence,
|
|
330
|
+
rendered,
|
|
331
|
+
estimatedTokens: estimateTokens(rendered),
|
|
332
|
+
filePath: sourceSymbol?.filePath ?? relation.filePath,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
return candidates;
|
|
336
|
+
}
|
|
337
|
+
export function composeRouteContext(index, target, relatedIds, alreadyIncluded) {
|
|
338
|
+
if (target.language !== "java")
|
|
339
|
+
return [];
|
|
340
|
+
const candidateIds = new Set([target.id, ...relatedIds]);
|
|
341
|
+
const candidates = [];
|
|
342
|
+
const seenRelations = new Set();
|
|
343
|
+
for (const relation of index.enterpriseRelations) {
|
|
344
|
+
if (relation.kind !== "ROUTE_TO_HANDLER")
|
|
345
|
+
continue;
|
|
346
|
+
if (relation.confidence === "unresolved")
|
|
347
|
+
continue;
|
|
348
|
+
if (!candidateIds.has(relation.sourceSymbolId))
|
|
349
|
+
continue;
|
|
350
|
+
const key = `${relation.sourceSymbolId}:${relation.kind}:${relation.targetLabel ?? ""}`;
|
|
351
|
+
if (seenRelations.has(key))
|
|
352
|
+
continue;
|
|
353
|
+
seenRelations.add(key);
|
|
354
|
+
// The handler symbol is looked up only for a friendlier filePath; it is
|
|
355
|
+
// never attached as `symbol` here, since it may already be in the slice
|
|
356
|
+
// (as the target itself, or as an already-included caller) and the
|
|
357
|
+
// shared composition loop in buildPreview dedupes candidates by symbol
|
|
358
|
+
// id — attaching it would silently drop the very route line this
|
|
359
|
+
// function exists to surface.
|
|
360
|
+
const handlerSymbol = index.symbols.find((s) => s.id === relation.sourceSymbolId);
|
|
361
|
+
const rendered = `// Route\n${relation.targetLabel} → ${handlerSymbol?.name ?? target.name}`;
|
|
362
|
+
candidates.push({
|
|
363
|
+
label: relation.targetLabel ?? "route",
|
|
364
|
+
reason: "enterprise relation",
|
|
365
|
+
evidence: relation.evidence,
|
|
366
|
+
rendered,
|
|
367
|
+
estimatedTokens: estimateTokens(rendered),
|
|
368
|
+
filePath: handlerSymbol?.filePath ?? relation.filePath,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return candidates;
|
|
372
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function rankSymbol(symbol, query, intent = "") {
|
|
2
|
+
const terms = `${query} ${intent}`
|
|
3
|
+
.toLowerCase()
|
|
4
|
+
.split(/[^a-z0-9]+/)
|
|
5
|
+
.filter(Boolean);
|
|
6
|
+
const text = `${symbol.name} ${symbol.qualifiedName ?? ""} ${symbol.signature ?? ""} ${symbol.filePath} ${symbol.annotations.join(" ")}`.toLowerCase();
|
|
7
|
+
const matches = terms.filter((term) => text.includes(term)).length;
|
|
8
|
+
const exact = symbol.name.toLowerCase() === query.trim().toLowerCase() ? 0.5 : 0;
|
|
9
|
+
return Number((matches / Math.max(terms.length, 1) + exact).toFixed(4));
|
|
10
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const renderSignature = (s) => s.signature ?? `${s.kind} ${s.name}`;
|
|
2
|
+
export function renderSkeleton(s, calls = []) {
|
|
3
|
+
return [
|
|
4
|
+
renderSignature(s),
|
|
5
|
+
s.metadata?.exported ? `\nEXPORT\n${s.metadata.exported}` : "",
|
|
6
|
+
s.annotations.length ? `\nANNOTATIONS\n${s.annotations.join("\n")}` : "",
|
|
7
|
+
calls.length ? `\nCALLS\n${calls.join("\n")}` : "",
|
|
8
|
+
]
|
|
9
|
+
.join("\n")
|
|
10
|
+
.trim();
|
|
11
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { ProjectIndex } from "../indexer/index.js";
|
|
8
|
+
import { packageInfo } from "../package-info.js";
|
|
9
|
+
import { estimateTokens } from "../planner/budget.js";
|
|
10
|
+
import { renderSignature, renderSkeleton } from "../render/compact-context.js";
|
|
11
|
+
import { buildPreview } from "../workflow/preview.js";
|
|
12
|
+
const result = (value) => ({
|
|
13
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
14
|
+
});
|
|
15
|
+
export function gitDiffArgs(base, head) {
|
|
16
|
+
return base || head ? [base ?? "HEAD", ...(head ? [head] : [])] : ["HEAD"];
|
|
17
|
+
}
|
|
18
|
+
export async function startMcpServer(root = process.env.CONTEXT_SLICE_ROOT ?? process.cwd()) {
|
|
19
|
+
const index = new ProjectIndex(root);
|
|
20
|
+
const server = new McpServer({
|
|
21
|
+
name: packageInfo.name,
|
|
22
|
+
version: packageInfo.version,
|
|
23
|
+
});
|
|
24
|
+
const refresh = () => index.refresh();
|
|
25
|
+
const one = (symbol) => {
|
|
26
|
+
const candidates = index.resolveSymbol(symbol);
|
|
27
|
+
if (!candidates.length)
|
|
28
|
+
throw new Error(`Symbol not found: ${symbol}`);
|
|
29
|
+
if (candidates.length > 1)
|
|
30
|
+
throw new Error(`Ambiguous symbol: ${symbol}. Candidates: ${candidates.map((candidate) => candidate.id).join(", ")}`);
|
|
31
|
+
return candidates[0];
|
|
32
|
+
};
|
|
33
|
+
server.tool("context.search", "Find relevant Java symbols without reading every source file.", {
|
|
34
|
+
query: z.string(),
|
|
35
|
+
limit: z.number().int().positive().max(100).optional(),
|
|
36
|
+
}, async ({ query, limit }) => {
|
|
37
|
+
const refreshed = refresh();
|
|
38
|
+
return result({
|
|
39
|
+
refresh: refreshed,
|
|
40
|
+
results: index.search(query, limit ?? 10),
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
server.tool("context.symbol", "Read a Java symbol as a signature, skeleton, body, or full source.", {
|
|
44
|
+
symbol: z.string(),
|
|
45
|
+
detail: z.enum(["signature", "skeleton", "body", "full"]).optional(),
|
|
46
|
+
}, async ({ symbol, detail }) => {
|
|
47
|
+
const refreshed = refresh();
|
|
48
|
+
const target = one(symbol);
|
|
49
|
+
const level = detail ?? "skeleton";
|
|
50
|
+
const calls = index.calls
|
|
51
|
+
.filter((call) => call.callerId === target.id)
|
|
52
|
+
.map((call) => `${call.receiverText ? `${call.receiverText}.` : ""}${call.calleeName} [${call.confidence}]`);
|
|
53
|
+
let rendered = renderSignature(target);
|
|
54
|
+
if (level === "skeleton")
|
|
55
|
+
rendered = renderSkeleton(target, calls);
|
|
56
|
+
if (level === "body")
|
|
57
|
+
rendered = target.body ?? target.source;
|
|
58
|
+
if (level === "full")
|
|
59
|
+
rendered = target.source;
|
|
60
|
+
return result({
|
|
61
|
+
refresh: refreshed,
|
|
62
|
+
symbol: target,
|
|
63
|
+
detail: level,
|
|
64
|
+
rendered,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
server.tool("context.callers", "Find callers with bounded traversal depth.", {
|
|
68
|
+
symbol: z.string(),
|
|
69
|
+
depth: z.number().int().min(1).max(5).optional(),
|
|
70
|
+
limit: z.number().int().positive().max(100).optional(),
|
|
71
|
+
}, async ({ symbol, depth, limit }) => {
|
|
72
|
+
const refreshed = refresh();
|
|
73
|
+
const target = one(symbol);
|
|
74
|
+
const actualDepth = depth ?? 1;
|
|
75
|
+
return result({
|
|
76
|
+
refresh: refreshed,
|
|
77
|
+
target: renderSignature(target),
|
|
78
|
+
depth: actualDepth,
|
|
79
|
+
callers: index
|
|
80
|
+
.callersAtDepth(target, actualDepth)
|
|
81
|
+
.slice(0, limit ?? 10)
|
|
82
|
+
.map((caller) => ({
|
|
83
|
+
id: caller.id,
|
|
84
|
+
signature: caller.signature,
|
|
85
|
+
filePath: caller.filePath,
|
|
86
|
+
})),
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
server.tool("context.preview", "Compose an explainable, budget-bounded developer context preview.", {
|
|
90
|
+
task: z.string(),
|
|
91
|
+
budget: z.number().int().positive().optional(),
|
|
92
|
+
depth: z.number().int().min(1).max(5).optional(),
|
|
93
|
+
}, async ({ task, budget, depth }) => {
|
|
94
|
+
const refreshed = refresh();
|
|
95
|
+
return result({
|
|
96
|
+
refresh: refreshed,
|
|
97
|
+
...buildPreview(index, task, { budget, depth }),
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
server.tool("context.slice", "Compose a strict-budget context slice for a symbol.", {
|
|
101
|
+
symbol: z.string(),
|
|
102
|
+
intent: z.string().optional(),
|
|
103
|
+
budget: z.number().int().positive().optional(),
|
|
104
|
+
depth: z.number().int().min(1).max(5).optional(),
|
|
105
|
+
}, async ({ symbol, intent, budget, depth }) => {
|
|
106
|
+
const refreshed = refresh();
|
|
107
|
+
const target = one(symbol);
|
|
108
|
+
return result({
|
|
109
|
+
refresh: refreshed,
|
|
110
|
+
...buildPreview(index, target.id, { budget, depth, intent }),
|
|
111
|
+
intent,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
server.tool("context.diff", "Show a Git diff with a strict token budget.", {
|
|
115
|
+
base: z.string().optional(),
|
|
116
|
+
head: z.string().optional(),
|
|
117
|
+
budget: z.number().int().positive().optional(),
|
|
118
|
+
}, async ({ base, head, budget }) => {
|
|
119
|
+
const refreshed = refresh();
|
|
120
|
+
const args = gitDiffArgs(base, head);
|
|
121
|
+
let diff = "";
|
|
122
|
+
try {
|
|
123
|
+
diff = execFileSync("git", ["diff", ...args], {
|
|
124
|
+
cwd: root,
|
|
125
|
+
encoding: "utf8",
|
|
126
|
+
maxBuffer: 2_000_000,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
throw new Error(`Git unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
131
|
+
}
|
|
132
|
+
const max = budget ?? estimateTokens(diff);
|
|
133
|
+
const truncated = estimateTokens(diff) > max;
|
|
134
|
+
if (truncated)
|
|
135
|
+
diff = diff.slice(0, Math.max(1, max * 4));
|
|
136
|
+
return result({
|
|
137
|
+
refresh: refreshed,
|
|
138
|
+
base: base ?? "HEAD",
|
|
139
|
+
head: head ?? "working tree",
|
|
140
|
+
budget: max,
|
|
141
|
+
truncated,
|
|
142
|
+
changedFiles: [
|
|
143
|
+
...diff.matchAll(/^diff --git a\/(.*?) b\/(.*?)$/gm),
|
|
144
|
+
].map((match) => match[2]),
|
|
145
|
+
estimatedTokens: estimateTokens(diff),
|
|
146
|
+
diff,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
await server.connect(new StdioServerTransport());
|
|
150
|
+
return server;
|
|
151
|
+
}
|
|
152
|
+
if (process.argv[1] &&
|
|
153
|
+
resolve(process.argv[1]) === fileURLToPath(import.meta.url))
|
|
154
|
+
await startMcpServer();
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { WorkflowError } from "../workflow/errors.js";
|
|
5
|
+
// Bump whenever any language adapter's parse OR resolve output changes: unchanged files and their cached (resolved)
|
|
6
|
+
// call edges are otherwise reused from cache. Rust parse and resolve output is guarded by tests/rust-parse-snapshot.test.ts,
|
|
7
|
+
// which refuses to regenerate its snapshot for changed output without a bump here.
|
|
8
|
+
export const INDEX_VERSION = "1.12.0";
|
|
9
|
+
const SCHEMA = `
|
|
10
|
+
CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
11
|
+
CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, content_hash TEXT NOT NULL, language TEXT NOT NULL, parse_error INTEGER NOT NULL, indexing_version TEXT NOT NULL);
|
|
12
|
+
CREATE TABLE IF NOT EXISTS symbols (id TEXT PRIMARY KEY, file_path TEXT NOT NULL, language TEXT NOT NULL, payload TEXT NOT NULL);
|
|
13
|
+
CREATE TABLE IF NOT EXISTS calls (id INTEGER PRIMARY KEY AUTOINCREMENT, caller_id TEXT NOT NULL, callee_name TEXT NOT NULL, file_path TEXT NOT NULL, language TEXT NOT NULL, payload TEXT NOT NULL);
|
|
14
|
+
CREATE TABLE IF NOT EXISTS imports (id INTEGER PRIMARY KEY AUTOINCREMENT, file_path TEXT NOT NULL, language TEXT NOT NULL, payload TEXT NOT NULL);
|
|
15
|
+
CREATE TABLE IF NOT EXISTS exports (id INTEGER PRIMARY KEY AUTOINCREMENT, file_path TEXT NOT NULL, language TEXT NOT NULL, payload TEXT NOT NULL);
|
|
16
|
+
`;
|
|
17
|
+
export class IndexStorage {
|
|
18
|
+
db;
|
|
19
|
+
constructor(root) {
|
|
20
|
+
const directory = join(root, ".context-slice");
|
|
21
|
+
mkdirSync(directory, { recursive: true });
|
|
22
|
+
// Self-ignoring cache: keeps `git status` clean without editing the repository's own .gitignore.
|
|
23
|
+
if (!existsSync(join(directory, ".gitignore")))
|
|
24
|
+
writeFileSync(join(directory, ".gitignore"), "*\n");
|
|
25
|
+
this.db = new Database(join(directory, "index.sqlite"));
|
|
26
|
+
try {
|
|
27
|
+
this.db.pragma("schema_version");
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
this.db.close();
|
|
31
|
+
throw new WorkflowError("INDEX_CORRUPT", `Unreadable index cache: ${join(directory, "index.sqlite")} (${error instanceof Error ? error.message : String(error)})`, "Delete the cache and rebuild it: rm -rf .context-slice && context-slice init");
|
|
32
|
+
}
|
|
33
|
+
this.db.exec("CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);");
|
|
34
|
+
const version = this.db
|
|
35
|
+
.prepare("SELECT value FROM metadata WHERE key = 'schema_version'")
|
|
36
|
+
.get();
|
|
37
|
+
// A cache written by any other schema is dropped and rebuilt, never reused.
|
|
38
|
+
if (version?.value !== INDEX_VERSION)
|
|
39
|
+
this.db.exec("DROP TABLE IF EXISTS files; DROP TABLE IF EXISTS symbols; DROP TABLE IF EXISTS calls; DROP TABLE IF EXISTS imports; DROP TABLE IF EXISTS exports;");
|
|
40
|
+
this.db.exec(SCHEMA);
|
|
41
|
+
if (version?.value !== INDEX_VERSION)
|
|
42
|
+
this.db
|
|
43
|
+
.prepare("INSERT OR REPLACE INTO metadata(key, value) VALUES ('schema_version', ?)")
|
|
44
|
+
.run(INDEX_VERSION);
|
|
45
|
+
}
|
|
46
|
+
load() {
|
|
47
|
+
const files = new Map();
|
|
48
|
+
for (const row of this.db
|
|
49
|
+
.prepare("SELECT path, content_hash, language FROM files")
|
|
50
|
+
.all())
|
|
51
|
+
files.set(row.path, { hash: row.content_hash, language: row.language });
|
|
52
|
+
const rows = (table) => this.db.prepare(`SELECT payload FROM ${table}`).all().map((row) => {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(row.payload);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
throw new WorkflowError("INDEX_CORRUPT", `Unreadable index cache payload in ${table}: ${error instanceof Error ? error.message : String(error)}`, "Delete the cache and rebuild it: rm -rf .context-slice && context-slice init");
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
files,
|
|
62
|
+
symbols: rows("symbols"),
|
|
63
|
+
calls: rows("calls"),
|
|
64
|
+
imports: rows("imports"),
|
|
65
|
+
exports: rows("exports"),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
metadata() {
|
|
69
|
+
const rows = this.db
|
|
70
|
+
.prepare("SELECT key, value FROM metadata")
|
|
71
|
+
.all();
|
|
72
|
+
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
|
73
|
+
}
|
|
74
|
+
save(files, symbols, calls, imports = [], exports = []) {
|
|
75
|
+
const transaction = this.db.transaction(() => {
|
|
76
|
+
this.db.prepare("DELETE FROM files").run();
|
|
77
|
+
this.db.prepare("DELETE FROM symbols").run();
|
|
78
|
+
this.db.prepare("DELETE FROM calls").run();
|
|
79
|
+
this.db.prepare("DELETE FROM imports").run();
|
|
80
|
+
this.db.prepare("DELETE FROM exports").run();
|
|
81
|
+
const fileStatement = this.db.prepare("INSERT OR REPLACE INTO files(path, content_hash, language, parse_error, indexing_version) VALUES (?, ?, ?, 0, ?)");
|
|
82
|
+
for (const [path, record] of files)
|
|
83
|
+
fileStatement.run(path, record.hash, record.language, INDEX_VERSION);
|
|
84
|
+
const symbolStatement = this.db.prepare("INSERT INTO symbols(id, file_path, language, payload) VALUES (?, ?, ?, ?)");
|
|
85
|
+
for (const symbol of symbols)
|
|
86
|
+
symbolStatement.run(symbol.id, symbol.filePath, symbol.language, JSON.stringify(symbol));
|
|
87
|
+
const callStatement = this.db.prepare("INSERT INTO calls(caller_id, callee_name, file_path, language, payload) VALUES (?, ?, ?, ?, ?)");
|
|
88
|
+
for (const call of calls)
|
|
89
|
+
callStatement.run(call.callerId, call.calleeName, call.filePath, call.language ?? "java", JSON.stringify(call));
|
|
90
|
+
const importStatement = this.db.prepare("INSERT INTO imports(file_path, language, payload) VALUES (?, ?, ?)");
|
|
91
|
+
for (const record of imports)
|
|
92
|
+
importStatement.run(record.filePath, record.language, JSON.stringify(record));
|
|
93
|
+
const exportStatement = this.db.prepare("INSERT INTO exports(file_path, language, payload) VALUES (?, ?, ?)");
|
|
94
|
+
for (const record of exports)
|
|
95
|
+
exportStatement.run(record.filePath, record.language, JSON.stringify(record));
|
|
96
|
+
this.db
|
|
97
|
+
.prepare("INSERT OR REPLACE INTO metadata(key, value) VALUES ('last_refreshed_at', ?)")
|
|
98
|
+
.run(new Date().toISOString());
|
|
99
|
+
});
|
|
100
|
+
transaction();
|
|
101
|
+
}
|
|
102
|
+
close() {
|
|
103
|
+
this.db.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|