gitnexus 1.6.6-rc.37 → 1.6.6-rc.39
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/dist/cli/ai-context.js +16 -7
- package/dist/core/ingestion/languages/kotlin/captures.js +264 -7
- package/dist/core/ingestion/languages/kotlin/import-target.d.ts +1 -1
- package/dist/core/ingestion/languages/kotlin/import-target.js +87 -15
- package/dist/core/ingestion/languages/kotlin/owners.js +24 -0
- package/dist/core/ingestion/languages/kotlin/query.js +13 -0
- package/dist/core/ingestion/languages/kotlin/scope-resolver.js +88 -1
- package/dist/core/ingestion/languages/kotlin/simple-hooks.js +7 -0
- package/package.json +1 -1
package/dist/cli/ai-context.js
CHANGED
|
@@ -156,7 +156,7 @@ async function fileExists(filePath) {
|
|
|
156
156
|
* - If file exists without GitNexus section: append
|
|
157
157
|
* - If file exists with GitNexus section: replace that section
|
|
158
158
|
*/
|
|
159
|
-
async function upsertGitNexusSection(filePath, content, projectName, stats) {
|
|
159
|
+
async function upsertGitNexusSection(filePath, content, projectName, stats, noStats) {
|
|
160
160
|
const exists = await fileExists(filePath);
|
|
161
161
|
if (!exists) {
|
|
162
162
|
await fs.writeFile(filePath, content, 'utf-8');
|
|
@@ -189,13 +189,22 @@ async function upsertGitNexusSection(filePath, content, projectName, stats) {
|
|
|
189
189
|
// like `({target: "symbolName", direction: "upstream"})`
|
|
190
190
|
// when noStats is set
|
|
191
191
|
// Passing projectName + stats explicitly makes the contract obvious.
|
|
192
|
-
//
|
|
192
|
+
// --no-stats wins in the keep path too (#1706): a lean block committed
|
|
193
|
+
// to git would otherwise churn the volatile counts on every analyze,
|
|
194
|
+
// producing no-value merge conflicts between branches. Under noStats we
|
|
195
|
+
// drop the parenthetical but still refresh the project name so renames
|
|
196
|
+
// propagate.
|
|
193
197
|
const newStatsInner = `${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows`;
|
|
194
|
-
const statsLine =
|
|
198
|
+
const statsLine = noStats
|
|
199
|
+
? `Indexed as **${projectName}**`
|
|
200
|
+
: `Indexed as **${projectName}** (${newStatsInner})`;
|
|
195
201
|
// Match either canonical phrasing at line start (`^` with `m` flag) so we
|
|
196
202
|
// cannot replace prose embedded mid-paragraph. Deliberately no `$`: text
|
|
197
|
-
// after the
|
|
198
|
-
|
|
203
|
+
// after the line on the same line (e.g. ". MCP tools.") stays intact.
|
|
204
|
+
// The parenthetical is optional so a count-free line left by a prior
|
|
205
|
+
// --no-stats run still matches — letting the name refresh, and letting
|
|
206
|
+
// counts return if --no-stats is later dropped.
|
|
207
|
+
const statsPattern = /^(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\*(?: \([^)]+\))?/m;
|
|
199
208
|
if (statsPattern.test(existingSection)) {
|
|
200
209
|
const updatedSection = existingSection.replace(statsPattern, statsLine);
|
|
201
210
|
const before = existingContent.substring(0, startIdx);
|
|
@@ -300,11 +309,11 @@ export async function generateAIContextFiles(repoPath, _storagePath, projectName
|
|
|
300
309
|
if (!options?.skipAgentsMd) {
|
|
301
310
|
// Create AGENTS.md (standard for Cursor, Windsurf, OpenCode, Cline, etc.)
|
|
302
311
|
const agentsPath = path.join(repoPath, 'AGENTS.md');
|
|
303
|
-
const agentsResult = await upsertGitNexusSection(agentsPath, content, projectName, stats);
|
|
312
|
+
const agentsResult = await upsertGitNexusSection(agentsPath, content, projectName, stats, options?.noStats);
|
|
304
313
|
createdFiles.push(`AGENTS.md (${agentsResult})`);
|
|
305
314
|
// Create CLAUDE.md (for Claude Code)
|
|
306
315
|
const claudePath = path.join(repoPath, 'CLAUDE.md');
|
|
307
|
-
const claudeResult = await upsertGitNexusSection(claudePath, content, projectName, stats);
|
|
316
|
+
const claudeResult = await upsertGitNexusSection(claudePath, content, projectName, stats, options?.noStats);
|
|
308
317
|
createdFiles.push(`CLAUDE.md (${claudeResult})`);
|
|
309
318
|
}
|
|
310
319
|
else {
|
|
@@ -23,6 +23,7 @@ export function emitKotlinScopeCaptures(sourceText, _filePath, cachedTree) {
|
|
|
23
23
|
const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
|
|
24
24
|
out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
|
|
25
25
|
out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes));
|
|
26
|
+
out.push(...synthesizeKotlinSmartCastBindings(tree.rootNode));
|
|
26
27
|
for (const match of getKotlinScopeQuery().matches(tree.rootNode)) {
|
|
27
28
|
const grouped = {};
|
|
28
29
|
for (const capture of match.captures) {
|
|
@@ -51,6 +52,27 @@ export function emitKotlinScopeCaptures(sourceText, _filePath, cachedTree) {
|
|
|
51
52
|
if (navNode === null || !shouldEmitReadMember(navNode))
|
|
52
53
|
continue;
|
|
53
54
|
}
|
|
55
|
+
// Virtual dispatch via constructor type (#1762). When a property
|
|
56
|
+
// declaration carries BOTH an explicit type annotation AND a
|
|
57
|
+
// constructor-style call value (e.g. `val animal: Animal = Dog()`),
|
|
58
|
+
// suppress the annotation capture so the constructor-inferred
|
|
59
|
+
// binding wins. This matches Kotlin's virtual dispatch semantics:
|
|
60
|
+
// `animal.speak()` should resolve to the overriding `Dog.speak`
|
|
61
|
+
// (the dynamic type), not `Animal.speak` (the static annotation).
|
|
62
|
+
//
|
|
63
|
+
// The annotation source has higher precedence than constructor-
|
|
64
|
+
// inferred in the generic scope-extractor (see
|
|
65
|
+
// `typeBindingStrength` in scope-extractor.ts), so the only way to
|
|
66
|
+
// make the constructor type prevail is to drop the annotation at
|
|
67
|
+
// emission time.
|
|
68
|
+
if (grouped['@type-binding.annotation'] !== undefined &&
|
|
69
|
+
grouped['@type-binding.name'] !== undefined &&
|
|
70
|
+
grouped['@type-binding.type'] !== undefined) {
|
|
71
|
+
const annotation = grouped['@type-binding.annotation'];
|
|
72
|
+
if (propertyDeclHasConstructorValue(tree.rootNode, annotation.range)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
54
76
|
if (grouped['@scope.function'] !== undefined) {
|
|
55
77
|
out.push(grouped);
|
|
56
78
|
const fnNode = findNodeAtRange(tree.rootNode, grouped['@scope.function'].range, 'function_declaration');
|
|
@@ -118,12 +140,107 @@ function synthesizeKotlinLoopBindings(rootNode, returnTypes) {
|
|
|
118
140
|
}
|
|
119
141
|
return out;
|
|
120
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Synthesize narrowed type-bindings for Kotlin smart-cast forms — issue #1758.
|
|
145
|
+
*
|
|
146
|
+
* For each `when (x) { is T -> body }` and `if (x is T) body`, emits a
|
|
147
|
+
* `@type-binding.annotation` capture binding `x → T` anchored on the body
|
|
148
|
+
* node. The capture lands in the matching `@scope.block` scope (see query.ts
|
|
149
|
+
* smart-cast scopes), shadowing the outer parameter binding for calls inside
|
|
150
|
+
* the body without leaking across sibling arms or to `else`.
|
|
151
|
+
*
|
|
152
|
+
* Only narrows when:
|
|
153
|
+
* - the `when` subject is a `simple_identifier` (not a call or field chain);
|
|
154
|
+
* - the `when_entry` condition is exactly one `type_test` (skips `!is`,
|
|
155
|
+
* compound conditions, range/`in`/value patterns);
|
|
156
|
+
* - the `if_expression` condition is a `check_expression` of the form
|
|
157
|
+
* `<simple_identifier> is <user_type>` and the then-branch is a
|
|
158
|
+
* `control_structure_body`.
|
|
159
|
+
*
|
|
160
|
+
* `else` arms and non-narrowing conditions emit nothing — the fall-through to
|
|
161
|
+
* the outer scope's declared type is the correct semantic.
|
|
162
|
+
*/
|
|
163
|
+
function synthesizeKotlinSmartCastBindings(rootNode) {
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const whenNode of descendantsOfType(rootNode, 'when_expression')) {
|
|
166
|
+
const subjectName = extractWhenSubjectIdentifier(whenNode);
|
|
167
|
+
if (subjectName === null)
|
|
168
|
+
continue;
|
|
169
|
+
for (const entry of whenNode.namedChildren) {
|
|
170
|
+
if (entry.type !== 'when_entry')
|
|
171
|
+
continue;
|
|
172
|
+
const narrowedType = extractIsTestTargetType(entry);
|
|
173
|
+
if (narrowedType === null)
|
|
174
|
+
continue;
|
|
175
|
+
const body = entry.namedChildren.find((child) => child.type === 'control_structure_body');
|
|
176
|
+
if (body === undefined)
|
|
177
|
+
continue;
|
|
178
|
+
out.push(buildNarrowedTypeBindingCapture(subjectName.node, body, narrowedType));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (const ifNode of descendantsOfType(rootNode, 'if_expression')) {
|
|
182
|
+
const check = ifNode.namedChildren.find((child) => child.type === 'check_expression');
|
|
183
|
+
if (check === undefined)
|
|
184
|
+
continue;
|
|
185
|
+
const subject = check.namedChildren.find((child) => child.type === 'simple_identifier');
|
|
186
|
+
const typeNode = check.namedChildren.find((child) => isKotlinTypeNode(child));
|
|
187
|
+
if (subject === undefined || typeNode === undefined)
|
|
188
|
+
continue;
|
|
189
|
+
// The first control_structure_body sibling is the then-branch; else
|
|
190
|
+
// branches (when present) appear as the second control_structure_body
|
|
191
|
+
// and are intentionally not narrowed.
|
|
192
|
+
const body = ifNode.namedChildren.find((child) => child.type === 'control_structure_body');
|
|
193
|
+
if (body === undefined)
|
|
194
|
+
continue;
|
|
195
|
+
out.push(buildNarrowedTypeBindingCapture(subject, body, typeNode));
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
function extractWhenSubjectIdentifier(whenNode) {
|
|
200
|
+
const subject = whenNode.namedChildren.find((child) => child.type === 'when_subject');
|
|
201
|
+
if (subject === undefined)
|
|
202
|
+
return null;
|
|
203
|
+
const ident = subject.namedChildren.find((child) => child.type === 'simple_identifier');
|
|
204
|
+
return ident === undefined ? null : { node: ident };
|
|
205
|
+
}
|
|
206
|
+
function extractIsTestTargetType(whenEntry) {
|
|
207
|
+
const condition = whenEntry.namedChildren.find((child) => child.type === 'when_condition');
|
|
208
|
+
if (condition === undefined)
|
|
209
|
+
return null;
|
|
210
|
+
// Exactly one when_condition child must be a positive type_test.
|
|
211
|
+
// Compound conditions (multiple `when_condition` siblings joined with
|
|
212
|
+
// commas in some grammars) or negated `!is` are not safe to narrow.
|
|
213
|
+
if (condition.namedChildCount !== 1)
|
|
214
|
+
return null;
|
|
215
|
+
const test = condition.namedChild(0);
|
|
216
|
+
if (test === null || test.type !== 'type_test')
|
|
217
|
+
return null;
|
|
218
|
+
// `!is` produces a different node (`negated_type_test` in some grammars,
|
|
219
|
+
// or an extra `!` child in others) — defend by checking text prefix.
|
|
220
|
+
if (test.text.trim().startsWith('!'))
|
|
221
|
+
return null;
|
|
222
|
+
return test.namedChildren.find((child) => isKotlinTypeNode(child)) ?? null;
|
|
223
|
+
}
|
|
224
|
+
function buildNarrowedTypeBindingCapture(subject, bodyAnchor, typeNode) {
|
|
225
|
+
return {
|
|
226
|
+
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', bodyAnchor),
|
|
227
|
+
'@type-binding.name': syntheticCapture('@type-binding.name', subject, subject.text),
|
|
228
|
+
'@type-binding.type': syntheticCapture('@type-binding.type', typeNode, normalizeKotlinType(typeNode.text)),
|
|
229
|
+
// Marker consumed by `kotlinBindingScopeFor` in simple-hooks.ts to
|
|
230
|
+
// override the scope-extractor's auto-hoist. Unbraced arm bodies
|
|
231
|
+
// (`is User -> obj.save()`) make the body anchor coincide with the
|
|
232
|
+
// Block scope's range; without this marker the binding would hoist
|
|
233
|
+
// to the enclosing function scope and lose its arm-local narrowing.
|
|
234
|
+
'@type-binding.narrowed': syntheticCapture('@type-binding.narrowed', bodyAnchor, '1'),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
121
237
|
function synthesizeKotlinLocalAssignmentBindings(rootNode, returnTypes) {
|
|
122
238
|
const out = [];
|
|
239
|
+
const classMembers = collectKotlinClassMembers(rootNode);
|
|
123
240
|
for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
|
|
124
241
|
const localTypes = new Map();
|
|
125
242
|
for (const prop of descendantsOfType(fnNode, 'property_declaration')) {
|
|
126
|
-
const inferred = inferKotlinPropertyType(prop, localTypes, returnTypes);
|
|
243
|
+
const inferred = inferKotlinPropertyType(prop, localTypes, returnTypes, classMembers);
|
|
127
244
|
if (inferred === null)
|
|
128
245
|
continue;
|
|
129
246
|
localTypes.set(inferred.name.text, inferred.rawType);
|
|
@@ -138,6 +255,69 @@ function synthesizeKotlinLocalAssignmentBindings(rootNode, returnTypes) {
|
|
|
138
255
|
}
|
|
139
256
|
return out;
|
|
140
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Per-file class-member index — primary-constructor `val`/`var` params,
|
|
260
|
+
* body property declarations, and method return types. Used by
|
|
261
|
+
* `inferKotlinPropertyType` to walk single-level field and method chains
|
|
262
|
+
* like `val addr = user.address` and `val city = addr.getCity()` (#1760).
|
|
263
|
+
*
|
|
264
|
+
* Indexes by simple class name only. Multi-class collisions inside a
|
|
265
|
+
* single file will pick whichever class was visited last for that name
|
|
266
|
+
* — acceptable because Kotlin forbids same-name top-level classes in
|
|
267
|
+
* one file and per-file resolution is the design boundary here.
|
|
268
|
+
*/
|
|
269
|
+
function collectKotlinClassMembers(rootNode) {
|
|
270
|
+
const fields = new Map();
|
|
271
|
+
const methods = new Map();
|
|
272
|
+
for (const cls of descendantsOfType(rootNode, 'class_declaration')) {
|
|
273
|
+
const className = cls.namedChildren.find((child) => child.type === 'type_identifier')?.text;
|
|
274
|
+
if (className === undefined)
|
|
275
|
+
continue;
|
|
276
|
+
const fmap = fields.get(className) ?? new Map();
|
|
277
|
+
const mmap = methods.get(className) ?? new Map();
|
|
278
|
+
const primary = cls.namedChildren.find((child) => child.type === 'primary_constructor');
|
|
279
|
+
if (primary !== undefined) {
|
|
280
|
+
for (const param of primary.namedChildren) {
|
|
281
|
+
if (param.type !== 'class_parameter')
|
|
282
|
+
continue;
|
|
283
|
+
// Constructor params are class fields ONLY when prefixed with
|
|
284
|
+
// `val`/`var` (binding_pattern_kind). Plain `fn(x: Int)`-style
|
|
285
|
+
// params remain locals to the constructor.
|
|
286
|
+
if (param.namedChildren.find((c) => c.type === 'binding_pattern_kind') === undefined) {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const fname = param.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
|
290
|
+
const ftype = param.namedChildren.find((c) => isKotlinTypeNode(c))?.text;
|
|
291
|
+
if (fname !== undefined && ftype !== undefined)
|
|
292
|
+
fmap.set(fname, ftype);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const body = cls.namedChildren.find((child) => child.type === 'class_body');
|
|
296
|
+
if (body !== undefined) {
|
|
297
|
+
for (const member of body.namedChildren) {
|
|
298
|
+
if (member.type === 'property_declaration') {
|
|
299
|
+
const v = member.namedChildren.find((c) => c.type === 'variable_declaration');
|
|
300
|
+
const fname = v?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
|
301
|
+
const ftype = v?.namedChildren.find((c) => isKotlinTypeNode(c))?.text;
|
|
302
|
+
if (fname !== undefined && ftype !== undefined)
|
|
303
|
+
fmap.set(fname, ftype);
|
|
304
|
+
}
|
|
305
|
+
else if (member.type === 'function_declaration') {
|
|
306
|
+
const mname = member.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
|
307
|
+
const paramsIdx = member.namedChildren.findIndex((c) => c.type === 'function_value_parameters');
|
|
308
|
+
const rtype = paramsIdx < 0
|
|
309
|
+
? undefined
|
|
310
|
+
: member.namedChildren.slice(paramsIdx + 1).find((c) => isKotlinTypeNode(c))?.text;
|
|
311
|
+
if (mname !== undefined && rtype !== undefined)
|
|
312
|
+
mmap.set(mname, rtype);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
fields.set(className, fmap);
|
|
317
|
+
methods.set(className, mmap);
|
|
318
|
+
}
|
|
319
|
+
return { fields, methods };
|
|
320
|
+
}
|
|
141
321
|
function collectKotlinLocalTypeTexts(fnNode, returnTypes) {
|
|
142
322
|
const out = new Map();
|
|
143
323
|
for (const node of descendants(fnNode)) {
|
|
@@ -169,7 +349,7 @@ function collectKotlinReturnTypeTexts(rootNode) {
|
|
|
169
349
|
}
|
|
170
350
|
return out;
|
|
171
351
|
}
|
|
172
|
-
function inferKotlinPropertyType(prop, localTypes, returnTypes) {
|
|
352
|
+
function inferKotlinPropertyType(prop, localTypes, returnTypes, classMembers) {
|
|
173
353
|
const variable = prop.namedChildren.find((child) => child.type === 'variable_declaration');
|
|
174
354
|
const name = variable?.namedChildren.find((child) => child.type === 'simple_identifier');
|
|
175
355
|
if (variable === undefined || name === undefined)
|
|
@@ -183,17 +363,70 @@ function inferKotlinPropertyType(prop, localTypes, returnTypes) {
|
|
|
183
363
|
const rawType = localTypes.get(value.text);
|
|
184
364
|
return rawType === undefined ? null : { name, rawType, source: value, synthetic: true };
|
|
185
365
|
}
|
|
366
|
+
if (value?.type === 'navigation_expression') {
|
|
367
|
+
// `val addr = user.address` — receiver type → field on that class (#1760).
|
|
368
|
+
const chained = inferKotlinNavigationFieldType(value, localTypes, classMembers);
|
|
369
|
+
if (chained === null)
|
|
370
|
+
return null;
|
|
371
|
+
return { name, rawType: chained, source: value, synthetic: true };
|
|
372
|
+
}
|
|
186
373
|
if (value?.type === 'call_expression') {
|
|
187
|
-
const callee = value.namedChildren.find((child) => child.type === 'simple_identifier');
|
|
374
|
+
const callee = value.namedChildren.find((child) => child.type === 'simple_identifier' || child.type === 'navigation_expression');
|
|
188
375
|
if (callee === undefined)
|
|
189
376
|
return null;
|
|
190
|
-
|
|
191
|
-
|
|
377
|
+
if (callee.type === 'simple_identifier') {
|
|
378
|
+
const rawType = returnTypes.get(callee.text) ?? (isUppercaseName(callee.text) ? callee.text : null);
|
|
379
|
+
if (rawType === null)
|
|
380
|
+
return null;
|
|
381
|
+
return { name, rawType, source: callee, synthetic: true };
|
|
382
|
+
}
|
|
383
|
+
// `val city = addr.getCity()` — receiver type → method return on that class (#1760).
|
|
384
|
+
const chained = inferKotlinNavigationCallReturnType(callee, localTypes, classMembers);
|
|
385
|
+
if (chained === null)
|
|
192
386
|
return null;
|
|
193
|
-
return { name, rawType, source: callee, synthetic: true };
|
|
387
|
+
return { name, rawType: chained, source: callee, synthetic: true };
|
|
194
388
|
}
|
|
195
389
|
return null;
|
|
196
390
|
}
|
|
391
|
+
/** Resolve `receiver.field` → field's declared type, where `receiver`
|
|
392
|
+
* is a simple identifier whose type is in `localTypes` and `field`
|
|
393
|
+
* is declared on that type in `classMembers.fields`. Returns null
|
|
394
|
+
* when any link in the chain is unknown — safe over-conservative. */
|
|
395
|
+
function inferKotlinNavigationFieldType(nav, localTypes, classMembers) {
|
|
396
|
+
if (classMembers === undefined)
|
|
397
|
+
return null;
|
|
398
|
+
const receiver = nav.namedChild(0);
|
|
399
|
+
if (receiver === null || receiver.type !== 'simple_identifier')
|
|
400
|
+
return null;
|
|
401
|
+
const member = nav.namedChildren
|
|
402
|
+
.find((c) => c.type === 'navigation_suffix')
|
|
403
|
+
?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
|
404
|
+
if (member === undefined)
|
|
405
|
+
return null;
|
|
406
|
+
const recvType = localTypes.get(receiver.text);
|
|
407
|
+
if (recvType === undefined)
|
|
408
|
+
return null;
|
|
409
|
+
return classMembers.fields.get(normalizeKotlinType(recvType))?.get(member) ?? null;
|
|
410
|
+
}
|
|
411
|
+
/** Resolve `receiver.method()` → method's declared return type, where
|
|
412
|
+
* `receiver` is a simple identifier whose type is in `localTypes` and
|
|
413
|
+
* `method` is declared on that type in `classMembers.methods`. */
|
|
414
|
+
function inferKotlinNavigationCallReturnType(navCallee, localTypes, classMembers) {
|
|
415
|
+
if (classMembers === undefined)
|
|
416
|
+
return null;
|
|
417
|
+
const receiver = navCallee.namedChild(0);
|
|
418
|
+
if (receiver === null || receiver.type !== 'simple_identifier')
|
|
419
|
+
return null;
|
|
420
|
+
const methodName = navCallee.namedChildren
|
|
421
|
+
.find((c) => c.type === 'navigation_suffix')
|
|
422
|
+
?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
|
423
|
+
if (methodName === undefined)
|
|
424
|
+
return null;
|
|
425
|
+
const recvType = localTypes.get(receiver.text);
|
|
426
|
+
if (recvType === undefined)
|
|
427
|
+
return null;
|
|
428
|
+
return classMembers.methods.get(normalizeKotlinType(recvType))?.get(methodName) ?? null;
|
|
429
|
+
}
|
|
197
430
|
function inferKotlinIterableElementType(iterable, localTypes, returnTypes) {
|
|
198
431
|
if (iterable.type === 'simple_identifier') {
|
|
199
432
|
const raw = localTypes.get(iterable.text);
|
|
@@ -214,7 +447,17 @@ function inferKotlinIterableElementType(iterable, localTypes, returnTypes) {
|
|
|
214
447
|
if (callee === undefined)
|
|
215
448
|
return null;
|
|
216
449
|
const raw = returnTypes.get(callee.text);
|
|
217
|
-
|
|
450
|
+
if (raw !== undefined)
|
|
451
|
+
return kotlinContainerElementType(raw, 'values');
|
|
452
|
+
// Cross-file fallback (#1759): the callee's return type is unknown
|
|
453
|
+
// locally because the function lives in another file. Emit the
|
|
454
|
+
// callee name itself as the binding's rawName; `propagateImported
|
|
455
|
+
// ReturnTypes` will chain-follow `loopvar → callee → <ElementType>`
|
|
456
|
+
// once the imported module's `callee → ElementType` mirror lands at
|
|
457
|
+
// module scope. If `callee` isn't actually an imported callable
|
|
458
|
+
// (e.g. a local lambda or unrelated symbol), chain-follow fails
|
|
459
|
+
// safely and no edge is emitted.
|
|
460
|
+
return callee.text;
|
|
218
461
|
}
|
|
219
462
|
return null;
|
|
220
463
|
}
|
|
@@ -306,6 +549,20 @@ function shouldEmitReadMember(navNode) {
|
|
|
306
549
|
return false;
|
|
307
550
|
return true;
|
|
308
551
|
}
|
|
552
|
+
/** True when the property_declaration anchored at `range` has a
|
|
553
|
+
* `call_expression` value sibling (i.e. `val x: T = Foo()`). Used to
|
|
554
|
+
* suppress the explicit-annotation type-binding capture so the
|
|
555
|
+
* constructor-inferred binding wins (#1762). */
|
|
556
|
+
function propertyDeclHasConstructorValue(rootNode, range) {
|
|
557
|
+
const propNode = findNodeAtRange(rootNode, range, 'property_declaration');
|
|
558
|
+
if (propNode === null)
|
|
559
|
+
return false;
|
|
560
|
+
const variable = propNode.namedChildren.find((c) => c.type === 'variable_declaration');
|
|
561
|
+
if (variable === undefined)
|
|
562
|
+
return false;
|
|
563
|
+
const value = propNode.namedChildren.find((c) => c.id !== variable.id && c.type !== 'binding_pattern_kind');
|
|
564
|
+
return value?.type === 'call_expression';
|
|
565
|
+
}
|
|
309
566
|
function callArguments(callNode) {
|
|
310
567
|
const suffix = callNode.namedChildren.find((child) => child.type === 'call_suffix');
|
|
311
568
|
if (suffix === undefined)
|
|
@@ -3,4 +3,4 @@ export interface KotlinResolveContext {
|
|
|
3
3
|
readonly fromFile: string;
|
|
4
4
|
readonly allFilePaths: ReadonlySet<string>;
|
|
5
5
|
}
|
|
6
|
-
export declare function resolveKotlinImportTarget(parsedImport: ParsedImport, workspaceIndex: WorkspaceIndex): string | null;
|
|
6
|
+
export declare function resolveKotlinImportTarget(parsedImport: ParsedImport, workspaceIndex: WorkspaceIndex): string | readonly string[] | null;
|
|
@@ -13,19 +13,41 @@ export function resolveKotlinImportTarget(parsedImport, workspaceIndex) {
|
|
|
13
13
|
? parsedImport.targetRaw.slice(0, -2)
|
|
14
14
|
: parsedImport.targetRaw;
|
|
15
15
|
const pathLike = target.replace(/\./g, '/');
|
|
16
|
+
// Resolution tiers, most-specific first:
|
|
17
|
+
// 1. The full `pathLike` matches a `.kt`/`.kts` file directly
|
|
18
|
+
// (`import util.User` → `util/User.kt`).
|
|
19
|
+
// 2. Stripped (last-segment removed) `pathLike` matches a file
|
|
20
|
+
// directly (`import util.OneArg.writeAudit` → `util/OneArg.kt`,
|
|
21
|
+
// a class-or-object holding `writeAudit`).
|
|
22
|
+
// 3. Stripped `pathLike` matches a *package directory* — fan out to
|
|
23
|
+
// every `.kt`/`.kts` file inside it (`import models.getRepo` →
|
|
24
|
+
// `[models/User.kt, models/Repo.kt]`). The finalize pass walks
|
|
25
|
+
// each candidate and picks the one whose `localDefs` actually
|
|
26
|
+
// export the imported name (#1759).
|
|
27
|
+
// 4. Progressive prefix strip for deeper namespace aliases that
|
|
28
|
+
// don't map 1:1 to directories.
|
|
29
|
+
const stripped = pathLike.split('/').slice(0, -1).join('/');
|
|
16
30
|
return (findKotlinFile(ctx.allFilePaths, pathLike) ??
|
|
17
|
-
|
|
31
|
+
findKotlinExactOrSuffix(ctx.allFilePaths, stripped) ??
|
|
32
|
+
findKotlinPackageFiles(ctx.allFilePaths, stripped) ??
|
|
18
33
|
findByProgressivePrefixStrip(ctx.allFilePaths, pathLike));
|
|
19
34
|
}
|
|
20
35
|
function findKotlinFile(allFilePaths, pathLike) {
|
|
36
|
+
return (findKotlinExactOrSuffix(allFilePaths, pathLike) ??
|
|
37
|
+
findKotlinDirectoryChild(allFilePaths, pathLike));
|
|
38
|
+
}
|
|
39
|
+
/** Exact (`file === pathLike+ext`) or suffix (`file ends with /pathLike+ext`)
|
|
40
|
+
* match — does NOT fall back to picking an arbitrary file inside a
|
|
41
|
+
* `pathLike/` directory. Used by the stripped-path tier in
|
|
42
|
+
* `resolveKotlinImportTarget` so a package import like `models.getRepo`
|
|
43
|
+
* delegates to `findKotlinPackageFiles` (multi-file fan-out) instead of
|
|
44
|
+
* silently committing to the first directory child. */
|
|
45
|
+
function findKotlinExactOrSuffix(allFilePaths, pathLike) {
|
|
21
46
|
if (pathLike === '')
|
|
22
47
|
return null;
|
|
23
48
|
const extensions = ['.kt', '.kts'];
|
|
24
49
|
const suffix = `/${pathLike}`;
|
|
25
|
-
const dirPrefix = `${pathLike}/`;
|
|
26
|
-
const suffixDirPrefix = `/${dirPrefix}`;
|
|
27
50
|
let suffixFile = null;
|
|
28
|
-
let directoryChild = null;
|
|
29
51
|
for (const raw of allFilePaths) {
|
|
30
52
|
const file = raw.replace(/\\/g, '/');
|
|
31
53
|
if (!extensions.some((ext) => file.endsWith(ext)))
|
|
@@ -36,18 +58,68 @@ function findKotlinFile(allFilePaths, pathLike) {
|
|
|
36
58
|
if (suffixFile === null && file.endsWith(`${suffix}${ext}`))
|
|
37
59
|
suffixFile = raw;
|
|
38
60
|
}
|
|
39
|
-
if (directoryChild === null) {
|
|
40
|
-
const atRoot = file.startsWith(dirPrefix);
|
|
41
|
-
const atNested = file.includes(suffixDirPrefix);
|
|
42
|
-
if (atRoot || atNested) {
|
|
43
|
-
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
|
|
44
|
-
const after = file.slice(idx + dirPrefix.length);
|
|
45
|
-
if (after.length > 0 && !after.includes('/'))
|
|
46
|
-
directoryChild = raw;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
61
|
}
|
|
50
|
-
return suffixFile
|
|
62
|
+
return suffixFile;
|
|
63
|
+
}
|
|
64
|
+
/** First directory child of `pathLike/` — preserves the legacy single-
|
|
65
|
+
* file fallback for cases where `pathLike` itself is an unqualified
|
|
66
|
+
* package reference (rare in real Kotlin code; some fixtures rely on
|
|
67
|
+
* it). Multi-file package fan-out goes through
|
|
68
|
+
* `findKotlinPackageFiles` instead. */
|
|
69
|
+
function findKotlinDirectoryChild(allFilePaths, pathLike) {
|
|
70
|
+
if (pathLike === '')
|
|
71
|
+
return null;
|
|
72
|
+
const extensions = ['.kt', '.kts'];
|
|
73
|
+
const dirPrefix = `${pathLike}/`;
|
|
74
|
+
const suffixDirPrefix = `/${dirPrefix}`;
|
|
75
|
+
for (const raw of allFilePaths) {
|
|
76
|
+
const file = raw.replace(/\\/g, '/');
|
|
77
|
+
if (!extensions.some((ext) => file.endsWith(ext)))
|
|
78
|
+
continue;
|
|
79
|
+
const atRoot = file.startsWith(dirPrefix);
|
|
80
|
+
const atNested = file.includes(suffixDirPrefix);
|
|
81
|
+
if (!atRoot && !atNested)
|
|
82
|
+
continue;
|
|
83
|
+
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
|
|
84
|
+
const after = file.slice(idx + dirPrefix.length);
|
|
85
|
+
if (after.length > 0 && !after.includes('/'))
|
|
86
|
+
return raw;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Return every `.kt`/`.kts` file inside the package directory `dirPath`
|
|
92
|
+
* (e.g. `models` → `['models/User.kt', 'models/Repo.kt']`). Used as a
|
|
93
|
+
* fallback when an import like `models.getRepo` does not resolve to a
|
|
94
|
+
* file named after the symbol — in Kotlin the symbol can live in any
|
|
95
|
+
* file inside the package directory. The finalize pass walks each
|
|
96
|
+
* candidate and picks the one whose `localDefs` actually export the
|
|
97
|
+
* imported name (#1759).
|
|
98
|
+
*/
|
|
99
|
+
function findKotlinPackageFiles(allFilePaths, dirPath) {
|
|
100
|
+
if (dirPath === '')
|
|
101
|
+
return null;
|
|
102
|
+
const extensions = ['.kt', '.kts'];
|
|
103
|
+
const dirPrefix = `${dirPath}/`;
|
|
104
|
+
const suffixDirPrefix = `/${dirPrefix}`;
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const raw of allFilePaths) {
|
|
107
|
+
const file = raw.replace(/\\/g, '/');
|
|
108
|
+
if (!extensions.some((ext) => file.endsWith(ext)))
|
|
109
|
+
continue;
|
|
110
|
+
const atRoot = file.startsWith(dirPrefix);
|
|
111
|
+
const atNested = file.includes(suffixDirPrefix);
|
|
112
|
+
if (!atRoot && !atNested)
|
|
113
|
+
continue;
|
|
114
|
+
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
|
|
115
|
+
const after = file.slice(idx + dirPrefix.length);
|
|
116
|
+
// Direct children only — `models/sub/Util.kt` is a different package
|
|
117
|
+
// (`models.sub`) and must not be merged with `models`.
|
|
118
|
+
if (after.length === 0 || after.includes('/'))
|
|
119
|
+
continue;
|
|
120
|
+
out.push(raw);
|
|
121
|
+
}
|
|
122
|
+
return out.length === 0 ? null : out;
|
|
51
123
|
}
|
|
52
124
|
function findByProgressivePrefixStrip(allFilePaths, pathLike) {
|
|
53
125
|
const segments = pathLike.split('/').filter(Boolean);
|
|
@@ -2,6 +2,30 @@ import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/s
|
|
|
2
2
|
export function populateKotlinOwners(parsed) {
|
|
3
3
|
populateClassOwnedMembers(parsed);
|
|
4
4
|
populateCompanionMembersOnEnclosingClass(parsed);
|
|
5
|
+
upgradeClassOwnedFunctionsToMethods(parsed);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Align scope-resolution `def.type` with the graph's node-label
|
|
9
|
+
* conventions: a `Function` def that lives inside a class body becomes
|
|
10
|
+
* a `Method`. The Kotlin extractor labels every `function_declaration`
|
|
11
|
+
* as `Function`, but the graph parsing-processor emits a `Method`
|
|
12
|
+
* graph-node label for class members. Without this realignment,
|
|
13
|
+
* `resolveDefGraphId`'s parameter-typed key lookup (gated on
|
|
14
|
+
* `def.type === 'Method'`) falls through to the simple-name fallback
|
|
15
|
+
* for class methods, collapsing same-name same-arity overloads onto
|
|
16
|
+
* the first-registered node (#1761).
|
|
17
|
+
*
|
|
18
|
+
* Only Method-bearing types are touched. Methods have a class owner
|
|
19
|
+
* (set by `populateClassOwnedMembers`) and a class-qualified name.
|
|
20
|
+
*/
|
|
21
|
+
function upgradeClassOwnedFunctionsToMethods(parsed) {
|
|
22
|
+
for (const def of parsed.localDefs) {
|
|
23
|
+
if (def.type !== 'Function')
|
|
24
|
+
continue;
|
|
25
|
+
if (def.ownerId === undefined)
|
|
26
|
+
continue;
|
|
27
|
+
def.type = 'Method';
|
|
28
|
+
}
|
|
5
29
|
}
|
|
6
30
|
function populateCompanionMembersOnEnclosingClass(parsed) {
|
|
7
31
|
const scopesById = new Map();
|
|
@@ -8,6 +8,19 @@ const KOTLIN_SCOPE_QUERY = `
|
|
|
8
8
|
(companion_object) @scope.class
|
|
9
9
|
(function_declaration) @scope.function
|
|
10
10
|
|
|
11
|
+
;; Smart-cast narrowing scopes (RFC #909 Ring 3, issue #1758).
|
|
12
|
+
;; Each is-test arm body and each if-then body becomes its own Block
|
|
13
|
+
;; scope so synthesized narrowed type-bindings (see captures.ts
|
|
14
|
+
;; synthesizeKotlinSmartCastBindings) shadow the outer parameter
|
|
15
|
+
;; binding for calls inside the body — without leaking across arms.
|
|
16
|
+
(when_entry
|
|
17
|
+
(when_condition (type_test))
|
|
18
|
+
(control_structure_body) @scope.block)
|
|
19
|
+
|
|
20
|
+
(if_expression
|
|
21
|
+
(check_expression)
|
|
22
|
+
(control_structure_body) @scope.block)
|
|
23
|
+
|
|
11
24
|
;; Declarations — types
|
|
12
25
|
(class_declaration
|
|
13
26
|
"interface"
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { SupportedLanguages } from '../../../../_shared/index.js';
|
|
2
2
|
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
|
|
3
|
+
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
|
|
4
|
+
import { isClassLike } from '../../scope-resolution/scope/walkers.js';
|
|
3
5
|
import { kotlinProvider } from '../kotlin.js';
|
|
4
6
|
import { kotlinArityCompatibility, kotlinMergeBindings, populateKotlinOwners, resolveKotlinImportTarget, } from './index.js';
|
|
5
7
|
/**
|
|
@@ -27,7 +29,7 @@ export const kotlinScopeResolver = {
|
|
|
27
29
|
},
|
|
28
30
|
mergeBindings: (existing, incoming) => [...kotlinMergeBindings([...existing, ...incoming])],
|
|
29
31
|
arityCompatibility: (callsite, def) => kotlinArityCompatibility(def, callsite),
|
|
30
|
-
buildMro: (graph, parsedFiles, nodeLookup) =>
|
|
32
|
+
buildMro: (graph, parsedFiles, nodeLookup) => buildKotlinMro(graph, parsedFiles, nodeLookup),
|
|
31
33
|
populateOwners: (parsed) => populateKotlinOwners(parsed),
|
|
32
34
|
isSuperReceiver: (text) => text.trim() === 'super',
|
|
33
35
|
fieldFallbackOnMethodLookup: false,
|
|
@@ -35,3 +37,88 @@ export const kotlinScopeResolver = {
|
|
|
35
37
|
collapseMemberCallsByCallerTarget: false,
|
|
36
38
|
hoistTypeBindingsToModule: true,
|
|
37
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* Kotlin MRO builder — extends `defaultLinearize` (EXTENDS-only) with
|
|
42
|
+
* interface ancestors discovered via `IMPLEMENTS` edges. Interface
|
|
43
|
+
* default methods (`interface Validator { fun validate(): Boolean = true }`)
|
|
44
|
+
* are inherited by implementing classes without an explicit override;
|
|
45
|
+
* the generic MRO would not surface them because the implementor has
|
|
46
|
+
* no `EXTENDS` link to the interface (#1763).
|
|
47
|
+
*
|
|
48
|
+
* Interfaces are appended after the EXTENDS chain (Kotlin resolves
|
|
49
|
+
* conflicts by requiring an explicit override, so first-seen-in-MRO
|
|
50
|
+
* ordering is a reasonable approximation for method lookup). Transitive
|
|
51
|
+
* interface inheritance (`interface A : B`) is closed via BFS.
|
|
52
|
+
*/
|
|
53
|
+
function buildKotlinMro(graph, parsedFiles, nodeLookup) {
|
|
54
|
+
const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
|
|
55
|
+
const defIdByGraphId = new Map();
|
|
56
|
+
for (const parsed of parsedFiles) {
|
|
57
|
+
for (const def of parsed.localDefs) {
|
|
58
|
+
if (!isClassLike(def.type))
|
|
59
|
+
continue;
|
|
60
|
+
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
|
|
61
|
+
if (graphId !== undefined)
|
|
62
|
+
defIdByGraphId.set(graphId, def.nodeId);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Direct IMPLEMENTS targets per class-like def.
|
|
66
|
+
const directImpls = new Map();
|
|
67
|
+
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
|
|
68
|
+
const source = defIdByGraphId.get(rel.sourceId);
|
|
69
|
+
const target = defIdByGraphId.get(rel.targetId);
|
|
70
|
+
if (source === undefined || target === undefined)
|
|
71
|
+
continue;
|
|
72
|
+
let list = directImpls.get(source);
|
|
73
|
+
if (list === undefined) {
|
|
74
|
+
list = [];
|
|
75
|
+
directImpls.set(source, list);
|
|
76
|
+
}
|
|
77
|
+
if (!list.includes(target))
|
|
78
|
+
list.push(target);
|
|
79
|
+
}
|
|
80
|
+
// For each class, append the transitive closure of interfaces reachable
|
|
81
|
+
// through its own + ancestor classes' IMPLEMENTS edges. Walking
|
|
82
|
+
// ancestors picks up interfaces inherited via the EXTENDS chain
|
|
83
|
+
// (e.g. `class C : B; class B : A; interface A` — C inherits A's
|
|
84
|
+
// interface methods through B).
|
|
85
|
+
for (const [classDefId, extendsMro] of mro) {
|
|
86
|
+
const ancestorChain = [classDefId, ...extendsMro];
|
|
87
|
+
const seeds = [];
|
|
88
|
+
for (const ancestorId of ancestorChain) {
|
|
89
|
+
for (const ifaceId of directImpls.get(ancestorId) ?? []) {
|
|
90
|
+
seeds.push(ifaceId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (seeds.length === 0)
|
|
94
|
+
continue;
|
|
95
|
+
const interfaces = closeInterfaces(seeds, directImpls);
|
|
96
|
+
mro.set(classDefId, [...extendsMro, ...interfaces.filter((i) => !extendsMro.includes(i))]);
|
|
97
|
+
}
|
|
98
|
+
// Classes with no EXTENDS still need an MRO entry when they implement
|
|
99
|
+
// interfaces (e.g. `class User : Validator` — no `mro` entry from the
|
|
100
|
+
// EXTENDS-only pass because no EXTENDS edges exist).
|
|
101
|
+
for (const [classDefId, ifaces] of directImpls) {
|
|
102
|
+
if (mro.has(classDefId))
|
|
103
|
+
continue;
|
|
104
|
+
mro.set(classDefId, closeInterfaces([...ifaces], directImpls));
|
|
105
|
+
}
|
|
106
|
+
return mro;
|
|
107
|
+
}
|
|
108
|
+
function closeInterfaces(seeds, directImpls) {
|
|
109
|
+
const out = [];
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
const queue = [...seeds];
|
|
112
|
+
while (queue.length > 0) {
|
|
113
|
+
const cur = queue.shift();
|
|
114
|
+
if (seen.has(cur))
|
|
115
|
+
continue;
|
|
116
|
+
seen.add(cur);
|
|
117
|
+
out.push(cur);
|
|
118
|
+
for (const next of directImpls.get(cur) ?? []) {
|
|
119
|
+
if (!seen.has(next))
|
|
120
|
+
queue.push(next);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
export function kotlinBindingScopeFor(decl, innermost, tree) {
|
|
2
|
+
// Smart-cast narrowed bindings (issue #1758) must stay at the innermost
|
|
3
|
+
// (Block) scope. Their anchor coincides with the Block's range for
|
|
4
|
+
// unbraced arm bodies (`is User -> obj.save()`), which would otherwise
|
|
5
|
+
// trigger scope-extractor auto-hoist into the enclosing function scope
|
|
6
|
+
// and erase the arm-local narrowing.
|
|
7
|
+
if (decl['@type-binding.narrowed'] !== undefined)
|
|
8
|
+
return innermost.id;
|
|
2
9
|
if (decl['@type-binding.return'] === undefined)
|
|
3
10
|
return null;
|
|
4
11
|
let current = innermost;
|
package/package.json
CHANGED