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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -0
  3. package/dist/src/cli.js +199 -0
  4. package/dist/src/indexer/index.js +325 -0
  5. package/dist/src/languages/adapter.js +23 -0
  6. package/dist/src/languages/go/index.js +15 -0
  7. package/dist/src/languages/go/parse.js +443 -0
  8. package/dist/src/languages/go/resolve.js +313 -0
  9. package/dist/src/languages/java/enterprise/dependency-injection.js +175 -0
  10. package/dist/src/languages/java/enterprise/jpa-entity.js +89 -0
  11. package/dist/src/languages/java/enterprise/registry.js +22 -0
  12. package/dist/src/languages/java/enterprise/spring-data.js +239 -0
  13. package/dist/src/languages/java/enterprise/spring-mvc.js +183 -0
  14. package/dist/src/languages/java/enterprise/transactions.js +110 -0
  15. package/dist/src/languages/java.js +84 -0
  16. package/dist/src/languages/javascript/index.js +25 -0
  17. package/dist/src/languages/python/index.js +29 -0
  18. package/dist/src/languages/python/parse.js +415 -0
  19. package/dist/src/languages/python/resolve.js +413 -0
  20. package/dist/src/languages/rust/calls-resolve.js +1405 -0
  21. package/dist/src/languages/rust/index.js +12 -0
  22. package/dist/src/languages/rust/parse.js +545 -0
  23. package/dist/src/languages/rust/resolve.js +284 -0
  24. package/dist/src/languages/typescript/index.js +25 -0
  25. package/dist/src/languages/typescript/parse.js +793 -0
  26. package/dist/src/languages/typescript/resolve.js +463 -0
  27. package/dist/src/package-info.js +16 -0
  28. package/dist/src/parser/java-parser.js +339 -0
  29. package/dist/src/planner/budget.js +1 -0
  30. package/dist/src/planner/composition.js +372 -0
  31. package/dist/src/planner/rank.js +10 -0
  32. package/dist/src/render/compact-context.js +11 -0
  33. package/dist/src/server/mcp-server.js +154 -0
  34. package/dist/src/storage/sqlite.js +105 -0
  35. package/dist/src/types/enterprise.js +1 -0
  36. package/dist/src/types/model.js +1 -0
  37. package/dist/src/workflow/errors.js +10 -0
  38. package/dist/src/workflow/preview.js +220 -0
  39. package/dist/src/workflow/repository.js +56 -0
  40. package/mcp.json +10 -0
  41. package/package.json +76 -0
  42. package/plugin.json +15 -0
  43. package/queries/java/annotations.scm +1 -0
  44. package/queries/java/calls.scm +1 -0
  45. package/queries/java/imports.scm +1 -0
  46. package/queries/java/symbols.scm +2 -0
  47. package/skills/context-slice/SKILL.md +60 -0
@@ -0,0 +1,313 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ /** Go's package boundary is per-directory, never by matching package-clause name strings. */
4
+ function directoryOf(filePath) {
5
+ const dir = dirname(filePath);
6
+ return dir === "." ? "" : dir;
7
+ }
8
+ function readModulePath(goModDir) {
9
+ try {
10
+ const text = readFileSync(join(goModDir, "go.mod"), "utf8");
11
+ return text.match(/^module\s+(\S+)/m)?.[1];
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ /** The directories a go.work file lists under `use`, supporting both the block form
18
+ * ("use (\n\t./a\n\t./b\n)") and repeated single-line directives ("use ./a\nuse ./b"). */
19
+ function parseGoWorkUseDirs(text) {
20
+ const dirs = [];
21
+ const block = text.match(/use\s*\(([^)]*)\)/s);
22
+ if (block) {
23
+ for (const line of block[1].split("\n")) {
24
+ const dir = line.trim().split(/\s+/)[0];
25
+ if (dir)
26
+ dirs.push(dir);
27
+ }
28
+ }
29
+ for (const m of text.matchAll(/^use\s+(\.\S+)/gm))
30
+ dirs.push(m[1]);
31
+ return dirs;
32
+ }
33
+ function normalizeWorkspaceDir(relDir) {
34
+ return relDir.replace(/^\.\//, "").replace(/\/$/, "").replace(/^\.$/, "");
35
+ }
36
+ /** Every module this project can resolve imports into: the modules a go.work file lists under
37
+ * `use` (a multi-module workspace), or else the single module at this project's own go.mod (the
38
+ * common case). No go.work and no go.mod means no imports resolve to project code — the correct
39
+ * safe fallback, not a special case. */
40
+ function discoverModules(root) {
41
+ let workText;
42
+ try {
43
+ workText = readFileSync(join(root, "go.work"), "utf8");
44
+ }
45
+ catch {
46
+ workText = undefined;
47
+ }
48
+ if (workText !== undefined) {
49
+ const modules = [];
50
+ for (const relDir of parseGoWorkUseDirs(workText)) {
51
+ const dir = normalizeWorkspaceDir(relDir);
52
+ const path = readModulePath(join(root, dir));
53
+ if (path)
54
+ modules.push({ path, dir });
55
+ }
56
+ return modules;
57
+ }
58
+ const rootPath = readModulePath(root);
59
+ return rootPath ? [{ path: rootPath, dir: "" }] : [];
60
+ }
61
+ /** A method's own receiver variable name, e.g. "u" in "func (u *User) Name(...)" — read directly
62
+ * off the symbol's own .source text rather than re-deriving it from parse.ts, since Phase 1
63
+ * already formats every method's signature/source starting with its receiver clause. */
64
+ function receiverVarNameOf(method) {
65
+ return method.source.match(/^func\s*\(\s*(\w+)\s+/)?.[1];
66
+ }
67
+ /** Narrow, regex-based binding-type inference over a function body — mirrors
68
+ * src/languages/python/resolve.ts's bindingClass exactly: single unambiguous assignment only,
69
+ * any reassignment or multiple bindings drop the evidence rather than guessing. Captures an
70
+ * optional package-qualified form ("pkg.Type") alongside the bare form ("Type"). */
71
+ function bindingTypeInBody(body, receiver) {
72
+ const literalOrVar = new RegExp(`(?<![\\w.])${receiver}\\s*:?=\\s*(?:&)?(?:([A-Za-z_]\\w*)\\.)?([A-Za-z_]\\w*)\\s*\\{` +
73
+ `|var\\s+${receiver}\\s+\\*?(?:([A-Za-z_]\\w*)\\.)?([A-Za-z_]\\w*)\\b`, "g");
74
+ const matches = [...body.matchAll(literalOrVar)];
75
+ const reassignments = [...body.matchAll(new RegExp(`(?<![\\w.:])${receiver}\\s*=[^=]`, "g"))].length;
76
+ if (matches.length === 1 && reassignments === 0) {
77
+ const [, literalPkg, literalType, varPkg, varType] = matches[0];
78
+ if (literalType)
79
+ return { pkg: literalPkg, type: literalType };
80
+ if (varType)
81
+ return { pkg: varPkg, type: varType };
82
+ }
83
+ if (matches.length === 0 && reassignments === 0) {
84
+ const ctor = body.match(new RegExp(`(?<![\\w.])${receiver}\\s*:=\\s*(?:([A-Za-z_]\\w*)\\.)?(New[A-Za-z_]\\w*)\\s*\\(`));
85
+ if (ctor)
86
+ return { pkg: ctor[1], ctorName: ctor[2] };
87
+ }
88
+ return undefined;
89
+ }
90
+ /** A struct's own embedded field TYPES (not all fields) — same-directory lookup only, cycle-safe. */
91
+ function embeddedTypesOf(struct, byDirectory, visited) {
92
+ const dir = directoryOf(struct.filePath);
93
+ const siblings = byDirectory.get(dir) ?? [];
94
+ const types = [];
95
+ for (const f of siblings) {
96
+ if (f.kind !== "field" || f.parentId !== struct.id || !f.metadata?.embedded)
97
+ continue;
98
+ const embeddedType = siblings.find((s) => s.kind === "class" && s.name === f.name);
99
+ if (embeddedType && !visited.has(embeddedType.id)) {
100
+ visited.add(embeddedType.id);
101
+ types.push(embeddedType);
102
+ }
103
+ }
104
+ return types;
105
+ }
106
+ /** BFS one promotion-depth level at a time; a match at a shallower depth shadows deeper ones;
107
+ * multiple matches at the SAME depth are ambiguous (Go itself rejects this at compile time). */
108
+ function findPromotedMethod(struct, methodName, byDirectory) {
109
+ const visited = new Set([struct.id]);
110
+ let frontier = embeddedTypesOf(struct, byDirectory, visited);
111
+ while (frontier.length) {
112
+ const matches = frontier
113
+ .map((t) => (byDirectory.get(directoryOf(t.filePath)) ?? []).find((s) => s.kind === "method" && s.name === methodName && s.supertypes?.includes(t.name)))
114
+ .filter((m) => Boolean(m));
115
+ if (matches.length === 1)
116
+ return matches[0];
117
+ if (matches.length > 1)
118
+ return "ambiguous";
119
+ const next = [];
120
+ for (const t of frontier)
121
+ next.push(...embeddedTypesOf(t, byDirectory, visited));
122
+ frontier = next;
123
+ }
124
+ return undefined;
125
+ }
126
+ /** Every method reachable from a struct, name -> its own normalized signature: its direct methods,
127
+ * plus every method reachable via embedding at any depth (a shallower method shadows a deeper
128
+ * same-named one, mirroring findPromotedMethod's own depth-shadowing — unlike call resolution's
129
+ * mere "is this name reachable at all", interface satisfaction needs the signature that would
130
+ * actually be promoted, so a same-name-different-signature method at a deeper level must not win). */
131
+ function methodSetOf(struct, byDirectory) {
132
+ const methods = new Map();
133
+ const collectDirect = (type) => {
134
+ for (const s of byDirectory.get(directoryOf(type.filePath)) ?? [])
135
+ if (s.kind === "method" && s.supertypes?.includes(type.name) && !methods.has(s.name))
136
+ methods.set(s.name, s.metadata?.methodSignature ?? "");
137
+ };
138
+ collectDirect(struct);
139
+ const visited = new Set([struct.id]);
140
+ let frontier = embeddedTypesOf(struct, byDirectory, visited);
141
+ while (frontier.length) {
142
+ const next = [];
143
+ for (const t of frontier) {
144
+ collectDirect(t);
145
+ next.push(...embeddedTypesOf(t, byDirectory, visited));
146
+ }
147
+ frontier = next;
148
+ }
149
+ return methods;
150
+ }
151
+ function resolveInterfaceSatisfaction(symbols, byDirectory) {
152
+ // Go's interface satisfaction is structural and project-wide, not package-scoped: a struct in
153
+ // package "impl" can satisfy an interface declared in package "contract" with no import between
154
+ // them. Interfaces and structs are therefore collected across every directory, not grouped by one.
155
+ const interfaces = symbols.filter((s) => s.kind === "interface" && s.metadata?.interfaceMethods);
156
+ const structs = symbols.filter((s) => s.kind === "class");
157
+ for (const struct of structs) {
158
+ const methods = methodSetOf(struct, byDirectory);
159
+ // Recomputed from scratch every pass, not appended to: struct.supertypes is owned entirely
160
+ // by this resolver (parse.ts never sets it on struct symbols), and ProjectIndex.rebuild()
161
+ // reuses persisted symbols across incremental rebuilds, so appending would duplicate entries
162
+ // on every rebuild and never drop a stale one.
163
+ const satisfied = [];
164
+ for (const iface of interfaces) {
165
+ const required = iface.metadata.interfaceMethods;
166
+ const signatures = iface.metadata.interfaceMethodSignatures ?? {};
167
+ // Exact-signature match: a method of the same name but a different parameter/result
168
+ // shape does not satisfy the interface, matching Go's own compile-time rule.
169
+ if (required.length > 0 && required.every((name) => methods.get(name) === signatures[name])) {
170
+ satisfied.push(iface.name);
171
+ }
172
+ }
173
+ struct.supertypes = satisfied;
174
+ }
175
+ }
176
+ function settle(call, target, kind, evidence) {
177
+ call.resolvedTargetId = target.id;
178
+ call.resolutionKind = kind;
179
+ call.confidence = "exact";
180
+ call.evidence = [...call.evidence, evidence];
181
+ }
182
+ export function resolveGoCalls(context) {
183
+ const symbolsById = new Map(context.symbols.map((s) => [s.id, s]));
184
+ const byDirectory = new Map();
185
+ for (const symbol of context.symbols) {
186
+ const dir = directoryOf(symbol.filePath);
187
+ const list = byDirectory.get(dir) ?? [];
188
+ list.push(symbol);
189
+ byDirectory.set(dir, list);
190
+ }
191
+ const modules = discoverModules(context.root);
192
+ const importsByFile = new Map();
193
+ for (const record of context.imports) {
194
+ const list = importsByFile.get(record.filePath) ?? [];
195
+ list.push(record);
196
+ importsByFile.set(record.filePath, list);
197
+ }
198
+ const resolveDirectCall = (call, caller) => {
199
+ const candidates = (byDirectory.get(directoryOf(caller.filePath)) ?? []).filter((s) => s.kind === "function" && s.name === call.calleeName);
200
+ if (candidates.length === 1)
201
+ settle(call, candidates[0], "same-file", "same-package direct call");
202
+ };
203
+ // Go modules convention: a module path at major version >=2 ends in "/vN" (go.dev/ref/mod#major-version-suffixes),
204
+ // but the package's own declared name is unaffected, e.g. "github.com/go-chi/chi/v5" is still used as "chi.Foo(...)".
205
+ // Strip that suffix before taking the last path segment for the unaliased case. Known limitation: a package whose
206
+ // real (unversioned) last segment happens to look like "vN" itself is not handled — rare enough not to special-case.
207
+ const importLocalName = (record) => record.localName ?? record.module.replace(/\/v\d+$/, "").split("/").pop();
208
+ // Shared by resolveQualifiedCall (pkg.Func()) and resolveMethodCall (x.Method() where x's type
209
+ // is pkg.Type): maps an import alias, as used in the caller's file, to that package's directory
210
+ // within THIS module. undefined means the alias is unresolvable or names an external package.
211
+ const resolveImportDirectory = (alias, callerFilePath) => {
212
+ const record = (importsByFile.get(callerFilePath) ?? []).find((r) => importLocalName(r) === alias);
213
+ if (!record)
214
+ return undefined;
215
+ for (const mod of modules) {
216
+ if (record.module === mod.path || record.module.startsWith(mod.path + "/")) {
217
+ const withinModule = record.module.slice(mod.path.length).replace(/^\//, "");
218
+ return [mod.dir, withinModule].filter(Boolean).join("/");
219
+ }
220
+ }
221
+ return undefined;
222
+ };
223
+ const resolveQualifiedCall = (call, caller) => {
224
+ if (!call.receiverText || !/^[A-Z]/.test(call.calleeName))
225
+ return false; // unexported: never a package-qualified target
226
+ const record = (importsByFile.get(caller.filePath) ?? []).find((r) => importLocalName(r) === call.receiverText);
227
+ if (!record)
228
+ return false;
229
+ const relative = resolveImportDirectory(call.receiverText, caller.filePath);
230
+ if (relative === undefined) {
231
+ call.externalPackage = record.module;
232
+ return false;
233
+ }
234
+ const candidates = (byDirectory.get(relative) ?? []).filter((s) => s.kind === "function" && s.name === call.calleeName && s.modifiers.includes("exported"));
235
+ if (candidates.length === 1) {
236
+ settle(call, candidates[0], "imported", "package-qualified import call");
237
+ return true;
238
+ }
239
+ return false;
240
+ };
241
+ const resolveMethodCall = (call, caller) => {
242
+ if (!call.receiverText)
243
+ return;
244
+ let typeName;
245
+ let pkgAlias;
246
+ if (caller.kind === "method" && caller.supertypes?.[0] && receiverVarNameOf(caller) === call.receiverText) {
247
+ typeName = caller.supertypes[0];
248
+ }
249
+ else {
250
+ const bound = bindingTypeInBody(caller.body ?? caller.source, call.receiverText);
251
+ pkgAlias = bound?.pkg;
252
+ if (bound && "type" in bound) {
253
+ typeName = bound.type;
254
+ }
255
+ else if (bound && "ctorName" in bound) {
256
+ // The real constructed type is whatever the constructor itself declares as its return
257
+ // type, not a name guessed from the constructor's own name (e.g. "NewFoo" returning *Bar).
258
+ const ctorDir = pkgAlias ? resolveImportDirectory(pkgAlias, caller.filePath) : directoryOf(caller.filePath);
259
+ const ctorFn = ctorDir === undefined
260
+ ? undefined
261
+ : (byDirectory.get(ctorDir) ?? []).find((s) => s.kind === "function" &&
262
+ s.name === bound.ctorName &&
263
+ (!pkgAlias || s.modifiers.includes("exported")));
264
+ typeName = ctorFn?.metadata?.returnType;
265
+ }
266
+ }
267
+ if (!typeName)
268
+ return;
269
+ let dir;
270
+ if (pkgAlias) {
271
+ if (!/^[A-Z]/.test(call.calleeName))
272
+ return; // unexported: never callable from another package
273
+ const resolved = resolveImportDirectory(pkgAlias, caller.filePath);
274
+ if (resolved === undefined)
275
+ return; // external or unresolvable package alias
276
+ dir = resolved;
277
+ }
278
+ else {
279
+ dir = directoryOf(caller.filePath);
280
+ }
281
+ // A cross-package match must itself be exported; a same-package match needs no such check,
282
+ // since it's reached the same way an unexported method is legitimately called in Go.
283
+ const requireExported = Boolean(pkgAlias);
284
+ const directMethod = (byDirectory.get(dir) ?? []).find((s) => s.kind === "method" &&
285
+ s.name === call.calleeName &&
286
+ s.supertypes?.includes(typeName) &&
287
+ (!requireExported || s.modifiers.includes("exported")));
288
+ if (directMethod) {
289
+ settle(call, directMethod, "same-type", "receiver-type method call");
290
+ return;
291
+ }
292
+ const struct = (byDirectory.get(dir) ?? []).find((s) => s.kind === "class" && s.name === typeName);
293
+ if (!struct)
294
+ return;
295
+ const promoted = findPromotedMethod(struct, call.calleeName, byDirectory);
296
+ if (promoted && promoted !== "ambiguous" && (!requireExported || promoted.modifiers.includes("exported"))) {
297
+ settle(call, promoted, "same-type", "struct embedding method promotion");
298
+ }
299
+ };
300
+ for (const call of context.calls) {
301
+ const caller = symbolsById.get(call.callerId);
302
+ if (!caller)
303
+ continue;
304
+ if (!call.receiverText) {
305
+ resolveDirectCall(call, caller);
306
+ continue;
307
+ }
308
+ if (resolveQualifiedCall(call, caller))
309
+ continue;
310
+ resolveMethodCall(call, caller);
311
+ }
312
+ resolveInterfaceSatisfaction(context.symbols, byDirectory);
313
+ }
@@ -0,0 +1,175 @@
1
+ import { registerEnterpriseExtractor, registerEnterpriseResolver } from "./registry.js";
2
+ // Two-phase design: the per-file extractor only records injection points (type name,
3
+ // optional @Qualifier) as provisional "unresolved" relations; bean identity (§13) needs
4
+ // every project class/interface, so resolveDependencyRelations runs as a post-pass once
5
+ // all files are parsed (see registry.ts resolveEnterpriseRelations).
6
+ const QUALIFIER_RE = /@Qualifier\s*\(\s*(?:value\s*=\s*)?"([^"]*)"\s*\)/;
7
+ const STEREOTYPES = new Set(["Component", "Service", "Repository", "Controller", "RestController", "Configuration"]);
8
+ const INJECT_ANNOTATIONS = new Set(["Autowired", "Inject", "Resource"]);
9
+ /** Strips a leading "@" and any dotted package prefix, e.g. "@org.springframework...Autowired" -> "Autowired". */
10
+ function bareName(annotation) {
11
+ return annotation.slice(annotation.lastIndexOf(".") + 1).replace("@", "");
12
+ }
13
+ /** Text between the first top-level "(" and its matching ")", string-aware. */
14
+ function firstParenGroup(text) {
15
+ let inString = false;
16
+ let depth = 0;
17
+ let start = -1;
18
+ for (let i = 0; i < text.length; i++) {
19
+ const ch = text[i];
20
+ if (ch === '"' && text[i - 1] !== "\\")
21
+ inString = !inString;
22
+ if (inString)
23
+ continue;
24
+ if (ch === "(" && depth++ === 0)
25
+ start = i + 1;
26
+ else if (ch === ")" && --depth === 0)
27
+ return text.slice(start, i);
28
+ }
29
+ return undefined;
30
+ }
31
+ /** Splits on commas outside (), <>, and strings. */
32
+ function splitTopLevel(text) {
33
+ const parts = [];
34
+ let inString = false;
35
+ let depth = 0;
36
+ let last = 0;
37
+ for (let i = 0; i < text.length; i++) {
38
+ const ch = text[i];
39
+ if (ch === '"' && text[i - 1] !== "\\")
40
+ inString = !inString;
41
+ if (inString)
42
+ continue;
43
+ if (ch === "(" || ch === "<")
44
+ depth++;
45
+ else if (ch === ")" || ch === ">")
46
+ depth--;
47
+ else if (ch === "," && depth === 0) {
48
+ parts.push(text.slice(last, i));
49
+ last = i + 1;
50
+ }
51
+ }
52
+ parts.push(text.slice(last));
53
+ return parts.map((p) => p.trim()).filter(Boolean);
54
+ }
55
+ /** "@Qualifier("x") final a.b.Repo repo" -> { type: "Repo", name: "repo", qualifier: "x" }. */
56
+ function parseDeclaration(text) {
57
+ const qualifier = text.match(QUALIFIER_RE)?.[1];
58
+ const bare = text
59
+ .replace(/@[\w.]+(?:\s*\([^)]*\))?/g, " ")
60
+ .replace(/\b(?:final|private|protected|public|static|transient|volatile)\b/g, " ")
61
+ .trim();
62
+ // ponytail: generic wrappers (List<Repo>, Optional<Repo>) resolve by the outer type, which
63
+ // is never a project class, so they produce no relation; unwrap if collection DI matters.
64
+ const match = bare.match(/^((?:[\w$]+\.)*([\w$]+))\s*(?:<.*>)?\s*(?:\[\s*\])*\s+([\w$]+)$/s);
65
+ if (!match)
66
+ return undefined;
67
+ return { type: match[2], name: match[3], qualifier };
68
+ }
69
+ function relation(source, filePath, decl, evidence) {
70
+ return {
71
+ kind: "INJECTS_DEPENDENCY",
72
+ family: "dependency-injection",
73
+ sourceSymbolId: source.id,
74
+ targetLabel: decl.type,
75
+ confidence: "unresolved", // provisional until resolveDependencyRelations runs
76
+ evidence: decl.qualifier !== undefined ? [evidence, `@Qualifier("${decl.qualifier}")`] : [evidence],
77
+ range: source.range,
78
+ filePath,
79
+ };
80
+ }
81
+ function extractDependencyInjection(symbols, filePath, _source) {
82
+ const own = symbols.filter((s) => s.filePath === filePath);
83
+ const relations = [];
84
+ // Constructor injection first (§12 preference ordering). Gated: only a Spring-managed class
85
+ // (stereotype) or an explicitly @Autowired/@Inject constructor counts, so value objects like
86
+ // Order(Customer c) never become false "exact" edges (§57).
87
+ const seen = new Set();
88
+ for (const ctor of own.filter((s) => s.kind === "constructor")) {
89
+ // parseJava can emit an annotated constructor twice (same range/source, "#2" id); keep one.
90
+ const key = `${ctor.range.startLine}:${ctor.range.startColumn}:${ctor.source}`;
91
+ if (seen.has(key))
92
+ continue;
93
+ seen.add(key);
94
+ const cls = own.find((s) => s.id === ctor.parentId);
95
+ const stereotyped = cls?.annotations.some((a) => STEREOTYPES.has(bareName(a)));
96
+ const annotated = ctor.annotations.some((a) => bareName(a) === "Autowired" || bareName(a) === "Inject");
97
+ if (!stereotyped && !annotated)
98
+ continue;
99
+ const params = firstParenGroup(ctor.source);
100
+ if (!params)
101
+ continue;
102
+ for (const param of splitTopLevel(params)) {
103
+ const decl = parseDeclaration(param);
104
+ if (decl)
105
+ relations.push(relation(ctor, filePath, decl, `constructor parameter ${decl.type} ${decl.name}`));
106
+ }
107
+ }
108
+ // Field injection, using field symbols directly (Phase 0 AST rewrite) instead of a
109
+ // regex scan over class-member text.
110
+ for (const field of own.filter((s) => s.kind === "field")) {
111
+ const annotation = field.annotations.find((a) => INJECT_ANNOTATIONS.has(bareName(a)));
112
+ if (!annotation)
113
+ continue;
114
+ const cls = own.find((s) => s.id === field.parentId);
115
+ if (!cls)
116
+ continue;
117
+ const decl = parseDeclaration(`${field.metadata?.declaredType ?? ""} ${field.name}`);
118
+ if (!decl)
119
+ continue;
120
+ const hasQualifier = field.annotations.some((a) => bareName(a) === "Qualifier");
121
+ decl.qualifier ??= hasQualifier ? field.source.match(QUALIFIER_RE)?.[1] : undefined;
122
+ relations.push(relation(cls, filePath, decl, `@${bareName(annotation)} field ${decl.type} ${field.name}`));
123
+ }
124
+ // Explicit setter injection, using method symbols directly.
125
+ for (const method of own.filter((s) => s.kind === "method")) {
126
+ const annotation = method.annotations.find((a) => INJECT_ANNOTATIONS.has(bareName(a)));
127
+ if (!annotation)
128
+ continue;
129
+ // method.source includes any leading annotations (tree-sitter's method_declaration node
130
+ // starts at the modifiers); strip them first so a parenthesized annotation argument (e.g.
131
+ // @Autowired(required = false)) isn't mistaken by firstParenGroup for the method's own params.
132
+ const bodyText = method.source.replace(/^(?:\s*@[\w.]+(?:\s*\([^)]*\))?\s*)*/, "");
133
+ const params = firstParenGroup(bodyText);
134
+ if (params === undefined)
135
+ continue;
136
+ const hasQualifier = method.annotations.some((a) => bareName(a) === "Qualifier");
137
+ const qualifier = hasQualifier ? method.source.match(QUALIFIER_RE)?.[1] : undefined;
138
+ for (const param of splitTopLevel(params)) {
139
+ const p = parseDeclaration(param);
140
+ if (!p)
141
+ continue;
142
+ p.qualifier ??= qualifier;
143
+ relations.push(relation(method, filePath, p, `${annotation} setter ${method.name}(${p.type})`));
144
+ }
145
+ }
146
+ return relations;
147
+ }
148
+ /**
149
+ * Bean identity (§13): exactly one project class/interface with the simple name -> exact;
150
+ * several -> unresolved (never a guessed winner); zero -> not a bean. A @Qualifier value is
151
+ * kept as evidence only: candidates all share the simple name, so it can't break a tie.
152
+ */
153
+ export function resolveBeanType(typeName, projectTypes) {
154
+ const candidates = projectTypes.filter((s) => s.name === typeName);
155
+ if (candidates.length === 0)
156
+ return undefined;
157
+ if (candidates.length === 1)
158
+ return { confidence: "exact", targetSymbolId: candidates[0].id };
159
+ return { confidence: "unresolved" };
160
+ }
161
+ /** Post-pass: fills in bean identity for DI relations; drops ones naming no project type. */
162
+ export function resolveDependencyRelations(relations, allSymbols) {
163
+ const projectTypes = allSymbols.filter((s) => s.kind === "class" || s.kind === "interface");
164
+ return relations.flatMap((r) => {
165
+ if (r.kind !== "INJECTS_DEPENDENCY" || r.family !== "dependency-injection")
166
+ return [r];
167
+ const resolved = resolveBeanType(r.targetLabel ?? "", projectTypes);
168
+ if (!resolved)
169
+ return [];
170
+ const { targetSymbolId: _stale, ...rest } = r;
171
+ return [{ ...rest, ...resolved }];
172
+ });
173
+ }
174
+ registerEnterpriseExtractor(extractDependencyInjection);
175
+ registerEnterpriseResolver(resolveDependencyRelations);
@@ -0,0 +1,89 @@
1
+ import { registerEnterpriseExtractor, registerEnterpriseResolver } from "./registry.js";
2
+ // Two-phase, like dependency-injection.ts: the per-file extractor emits provisional
3
+ // "unresolved" ENTITY_RELATIONs whose targetLabel carries the raw target simple name
4
+ // (the side-channel the resolver reads); resolveEntityRelations then applies the
5
+ // unique-simple-name rule once every project symbol is known.
6
+ const RELATION_ANNOTATIONS = new Set(["OneToOne", "OneToMany", "ManyToOne", "ManyToMany"]);
7
+ const COLLECTION_RE = /\b(?:List|Set|Collection)<\s*([\w.]+)\s*>/;
8
+ /** Strips a leading "@" and any dotted package prefix, e.g. "@javax.persistence.ManyToOne" -> "ManyToOne". */
9
+ function bareName(annotation) {
10
+ return annotation.slice(annotation.lastIndexOf(".") + 1).replace("@", "");
11
+ }
12
+ /**
13
+ * Matches ONE specific, already-AST-confirmed relationship annotation's own argument text
14
+ * (mappedBy/fetch/cascade). Detection of WHICH relationship annotation (if any) is present
15
+ * happens via SymbolRecord.annotations, never via this regex -- this only ever runs to
16
+ * extract an argument list for a name already known to be real. The optional
17
+ * `(?:[\w.]+\.)?` prefix tolerates a fully-qualified annotation name the same way
18
+ * bareName() already tolerates one for detection.
19
+ */
20
+ function relationArgsRegex(name) {
21
+ return new RegExp(`@(?:[\\w.]+\\.)?${name}\\b(?:\\s*\\(([^)]*)\\))?`);
22
+ }
23
+ /** Same qualified-name-tolerant pattern as relationArgsRegex, for the fixed @JoinColumn name. */
24
+ function joinColumnArgsRegex() {
25
+ return /@(?:[\w.]+\.)?JoinColumn\s*\(([^)]*)\)/;
26
+ }
27
+ /** Explicit mappedBy/fetch/cascade only (§26-27): anything absent stays absent. */
28
+ function explicitAttributes(args) {
29
+ const found = [];
30
+ const mappedBy = args.match(/\bmappedBy\s*=\s*("[^"]*")/)?.[1];
31
+ if (mappedBy)
32
+ found.push(`mappedBy = ${mappedBy}`);
33
+ const fetch = args.match(/\bfetch\s*=\s*([\w.]+)/)?.[1];
34
+ if (fetch)
35
+ found.push(`fetch = ${fetch}`);
36
+ const cascade = args.match(/\bcascade\s*=\s*(\{[^}]*\}|[\w.]+)/)?.[1];
37
+ if (cascade)
38
+ found.push(`cascade = ${cascade}`);
39
+ return found;
40
+ }
41
+ function extractEntityRelations(symbols, filePath, _source) {
42
+ const relations = [];
43
+ const entities = symbols.filter((s) => s.filePath === filePath && s.kind === "class" && s.annotations.includes("@Entity"));
44
+ for (const entity of entities) {
45
+ const fields = symbols.filter((s) => s.kind === "field" && s.parentId === entity.id);
46
+ for (const field of fields) {
47
+ const relationName = field.annotations.map(bareName).find((name) => RELATION_ANNOTATIONS.has(name));
48
+ if (!relationName)
49
+ continue;
50
+ const relationMatch = field.source.match(relationArgsRegex(relationName));
51
+ const typeText = field.metadata?.declaredType ?? "";
52
+ const rawTarget = typeText.match(COLLECTION_RE)?.[1] ?? typeText.replace(/<.*>/s, "").trim();
53
+ const hasJoinColumn = field.annotations.some((a) => bareName(a) === "JoinColumn");
54
+ const joinColumn = hasJoinColumn ? field.source.match(joinColumnArgsRegex())?.[1] : undefined;
55
+ relations.push({
56
+ kind: "ENTITY_RELATION",
57
+ family: "spring-data-jpa",
58
+ sourceSymbolId: entity.id,
59
+ targetLabel: rawTarget.slice(rawTarget.lastIndexOf(".") + 1),
60
+ confidence: "unresolved", // provisional until resolveEntityRelations runs
61
+ evidence: [
62
+ `@${relationName} on field ${field.name}`,
63
+ ...explicitAttributes(relationMatch?.[1] ?? ""),
64
+ ...(joinColumn !== undefined ? [`@JoinColumn(${joinColumn.trim()})`] : []),
65
+ ],
66
+ range: entity.range,
67
+ filePath,
68
+ });
69
+ }
70
+ }
71
+ return relations;
72
+ }
73
+ /** Same rule as DI bean identity: 1 match exact, 0 dropped, 2+ unresolved (never guessed). */
74
+ function resolveEntityRelations(relations, allSymbols) {
75
+ const projectTypes = allSymbols.filter((s) => s.kind === "class" || s.kind === "interface");
76
+ return relations.flatMap((r) => {
77
+ if (r.kind !== "ENTITY_RELATION" || r.family !== "spring-data-jpa")
78
+ return [r];
79
+ const candidates = projectTypes.filter((s) => s.name === r.targetLabel);
80
+ if (candidates.length === 0)
81
+ return [];
82
+ const { targetSymbolId: _stale, ...rest } = r;
83
+ if (candidates.length > 1)
84
+ return [{ ...rest, confidence: "unresolved" }];
85
+ return [{ ...rest, confidence: "exact", targetSymbolId: candidates[0].id }];
86
+ });
87
+ }
88
+ registerEnterpriseExtractor(extractEntityRelations);
89
+ registerEnterpriseResolver(resolveEntityRelations);
@@ -0,0 +1,22 @@
1
+ let extractors = [];
2
+ let resolvers = [];
3
+ /** Called once per family module at import time, mirroring registerLanguage in languages/adapter.ts. */
4
+ export function registerEnterpriseExtractor(extractor) {
5
+ extractors.push(extractor);
6
+ }
7
+ /** Runs every registered family extractor over one file's symbols and unions the results. */
8
+ export function extractEnterpriseRelations(symbols, filePath, source) {
9
+ return extractors.flatMap((extractor) => extractor(symbols, filePath, source));
10
+ }
11
+ export function registerEnterpriseResolver(resolver) {
12
+ resolvers.push(resolver);
13
+ }
14
+ /** Runs every registered resolver in turn over the complete relation and symbol sets. */
15
+ export function resolveEnterpriseRelations(relations, allSymbols) {
16
+ return resolvers.reduce((acc, resolver) => resolver(acc, allSymbols), relations);
17
+ }
18
+ /** Test-only: clears registrations between test files so registry state doesn't leak. */
19
+ export function __resetEnterpriseExtractorsForTests() {
20
+ extractors = [];
21
+ resolvers = [];
22
+ }