gitnexus 1.6.6-rc.32 → 1.6.6-rc.34
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.
|
@@ -5,7 +5,12 @@ import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-s
|
|
|
5
5
|
* - FastAPI `@app.get("/path")` provider decorators
|
|
6
6
|
* - `requests.get/post/...("url")` consumer calls
|
|
7
7
|
* - Generic `requests.request("METHOD", "url")` consumer calls
|
|
8
|
-
* - `httpx.AsyncClient` instances calling `.get/.post/...("url")
|
|
8
|
+
* - `httpx.AsyncClient` instances calling `.get/.post/...("url")`, including
|
|
9
|
+
* aliased imports such as `import httpx as hx`,
|
|
10
|
+
* `from httpx import AsyncClient`, and
|
|
11
|
+
* `from httpx import AsyncClient as HttpxAsyncClient`.
|
|
12
|
+
* Locally rebound names (e.g. `AsyncClient = mock_factory()` inside a
|
|
13
|
+
* function) are excluded to avoid false-positive consumer contracts.
|
|
9
14
|
*/
|
|
10
15
|
const FASTAPI_VERBS = {
|
|
11
16
|
get: 'GET',
|
|
@@ -67,12 +72,48 @@ const REQUESTS_GENERIC_PATTERNS = compilePatterns({
|
|
|
67
72
|
],
|
|
68
73
|
});
|
|
69
74
|
// ─── Consumer: httpx.AsyncClient assignments ────────────────────────
|
|
70
|
-
//
|
|
71
|
-
// construction. Direct imports (`from httpx import AsyncClient`) and module
|
|
72
|
-
// aliases (`import httpx as hx`) and annotated assignments (`client: httpx.AsyncClient = ...`)
|
|
73
|
-
// are intentionally left for a follow-up. Module-scope clients are only matched
|
|
75
|
+
// Module-scope clients are only matched
|
|
74
76
|
// at module scope; calls inside functions require a function/class-local tracked
|
|
75
77
|
// client to avoid false positives from same-name local variables.
|
|
78
|
+
const HTTPX_MODULE_IMPORT_PATTERNS = compilePatterns({
|
|
79
|
+
name: 'python-httpx-module-imports',
|
|
80
|
+
language: Python,
|
|
81
|
+
patterns: [
|
|
82
|
+
{
|
|
83
|
+
meta: {},
|
|
84
|
+
query: `
|
|
85
|
+
(import_statement
|
|
86
|
+
name: (aliased_import
|
|
87
|
+
name: (dotted_name (identifier) @module)
|
|
88
|
+
alias: (identifier) @alias))
|
|
89
|
+
`,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
const HTTPX_ASYNC_CLIENT_IMPORT_PATTERNS = compilePatterns({
|
|
94
|
+
name: 'python-httpx-async-client-imports',
|
|
95
|
+
language: Python,
|
|
96
|
+
patterns: [
|
|
97
|
+
{
|
|
98
|
+
meta: {},
|
|
99
|
+
query: `
|
|
100
|
+
(import_from_statement
|
|
101
|
+
module_name: (dotted_name (identifier) @module)
|
|
102
|
+
name: (dotted_name (identifier) @client_class))
|
|
103
|
+
`,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
meta: {},
|
|
107
|
+
query: `
|
|
108
|
+
(import_from_statement
|
|
109
|
+
module_name: (dotted_name (identifier) @module)
|
|
110
|
+
name: (aliased_import
|
|
111
|
+
name: (dotted_name (identifier) @client_class)
|
|
112
|
+
alias: (identifier) @alias))
|
|
113
|
+
`,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
});
|
|
76
117
|
const HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS = compilePatterns({
|
|
77
118
|
name: 'python-httpx-async-client-assign',
|
|
78
119
|
language: Python,
|
|
@@ -84,8 +125,23 @@ const HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS = compilePatterns({
|
|
|
84
125
|
left: (_) @client
|
|
85
126
|
right: (call
|
|
86
127
|
function: (attribute
|
|
87
|
-
object: (identifier) @module
|
|
88
|
-
attribute: (identifier) @client_class
|
|
128
|
+
object: (identifier) @module
|
|
129
|
+
attribute: (identifier) @client_class)))
|
|
130
|
+
`,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
const HTTPX_ASYNC_CLIENT_DIRECT_ASSIGN_PATTERNS = compilePatterns({
|
|
135
|
+
name: 'python-httpx-async-client-direct-assign',
|
|
136
|
+
language: Python,
|
|
137
|
+
patterns: [
|
|
138
|
+
{
|
|
139
|
+
meta: {},
|
|
140
|
+
query: `
|
|
141
|
+
(assignment
|
|
142
|
+
left: (_) @client
|
|
143
|
+
right: (call
|
|
144
|
+
function: (identifier) @client_class))
|
|
89
145
|
`,
|
|
90
146
|
},
|
|
91
147
|
],
|
|
@@ -101,8 +157,23 @@ const HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS = compilePatterns({
|
|
|
101
157
|
(as_pattern
|
|
102
158
|
(call
|
|
103
159
|
function: (attribute
|
|
104
|
-
object: (identifier) @module
|
|
105
|
-
attribute: (identifier) @client_class
|
|
160
|
+
object: (identifier) @module
|
|
161
|
+
attribute: (identifier) @client_class))
|
|
162
|
+
(as_pattern_target (identifier) @client))
|
|
163
|
+
`,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
const HTTPX_ASYNC_CLIENT_DIRECT_WITH_ALIAS_PATTERNS = compilePatterns({
|
|
168
|
+
name: 'python-httpx-async-client-direct-with-alias',
|
|
169
|
+
language: Python,
|
|
170
|
+
patterns: [
|
|
171
|
+
{
|
|
172
|
+
meta: {},
|
|
173
|
+
query: `
|
|
174
|
+
(as_pattern
|
|
175
|
+
(call
|
|
176
|
+
function: (identifier) @client_class)
|
|
106
177
|
(as_pattern_target (identifier) @client))
|
|
107
178
|
`,
|
|
108
179
|
},
|
|
@@ -131,14 +202,120 @@ function trackedClientScopeKey(clientNode) {
|
|
|
131
202
|
return getScopeKey(clientNode.parent, clientNode.text.includes('.'));
|
|
132
203
|
}
|
|
133
204
|
function callScopeKeys(clientNode) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
205
|
+
return [getScopeKey(clientNode.parent, clientNode.text.includes('.'))];
|
|
206
|
+
}
|
|
207
|
+
// Returns the scope key that a rebind of an imported alias would shadow under
|
|
208
|
+
// Python LEGB rules, or `null` when the rebind does not shadow anything that
|
|
209
|
+
// could produce a false-positive consumer detection.
|
|
210
|
+
// - Rebind inside a function/method → that function's scope.
|
|
211
|
+
// - Rebind at module top level → 'module' (shadows the whole file).
|
|
212
|
+
// - Rebind in a class body without an enclosing function → null. Python
|
|
213
|
+
// class attributes do not shadow bare-name lookups inside methods (methods
|
|
214
|
+
// see the module binding, not the class attribute), so we must not poison
|
|
215
|
+
// them.
|
|
216
|
+
function shadowScopeKey(node) {
|
|
217
|
+
let current = node;
|
|
218
|
+
let passedThroughClass = false;
|
|
219
|
+
while (current) {
|
|
220
|
+
if (current.type === 'function_definition') {
|
|
221
|
+
// Reuse getScopeKey's key format so the two helpers cannot drift apart.
|
|
222
|
+
return getScopeKey(current);
|
|
223
|
+
}
|
|
224
|
+
if (current.type === 'class_definition') {
|
|
225
|
+
passedThroughClass = true;
|
|
226
|
+
}
|
|
227
|
+
current = current.parent;
|
|
228
|
+
}
|
|
229
|
+
return passedThroughClass ? null : 'module';
|
|
230
|
+
}
|
|
231
|
+
function collectHttpxImportAliases(tree) {
|
|
232
|
+
const moduleAliases = new Set(['httpx']);
|
|
233
|
+
const asyncClientAliases = new Set();
|
|
234
|
+
// The @module capture is a single identifier inside a `dotted_name`, so for
|
|
235
|
+
// `import package.httpx as hx` the pattern would match the inner `httpx`
|
|
236
|
+
// segment. Check the full `dotted_name` text via `parent` to anchor the match.
|
|
237
|
+
for (const match of runCompiledPatterns(HTTPX_MODULE_IMPORT_PATTERNS, tree)) {
|
|
238
|
+
const moduleNode = match.captures.module;
|
|
239
|
+
const aliasNode = match.captures.alias;
|
|
240
|
+
if (moduleNode?.parent?.text === 'httpx' && aliasNode)
|
|
241
|
+
moduleAliases.add(aliasNode.text);
|
|
242
|
+
}
|
|
243
|
+
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_IMPORT_PATTERNS, tree)) {
|
|
244
|
+
const moduleNode = match.captures.module;
|
|
245
|
+
const classNode = match.captures.client_class;
|
|
246
|
+
if (moduleNode?.parent?.text !== 'httpx' || classNode?.text !== 'AsyncClient')
|
|
247
|
+
continue;
|
|
248
|
+
asyncClientAliases.add(match.captures.alias?.text ?? classNode.text);
|
|
249
|
+
}
|
|
250
|
+
return { moduleAliases, asyncClientAliases };
|
|
251
|
+
}
|
|
252
|
+
// Tracks local rebindings (`AsyncClient = ...`, `hx = ...`) that shadow an
|
|
253
|
+
// imported alias. We treat the whole enclosing scope (module, class, or
|
|
254
|
+
// function) as shadowed for that alias name, so subsequent constructions in
|
|
255
|
+
// that scope are not falsely detected as httpx consumers. Covers bare-identifier
|
|
256
|
+
// targets and the common tuple / list destructuring shapes.
|
|
257
|
+
const ALIAS_SHADOW_PATTERNS = compilePatterns({
|
|
258
|
+
name: 'python-httpx-alias-shadow',
|
|
259
|
+
language: Python,
|
|
260
|
+
patterns: [
|
|
261
|
+
{
|
|
262
|
+
meta: {},
|
|
263
|
+
query: `(assignment left: (identifier) @name)`,
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
meta: {},
|
|
267
|
+
query: `(assignment left: (pattern_list (identifier) @name))`,
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
meta: {},
|
|
271
|
+
query: `(assignment left: (tuple_pattern (identifier) @name))`,
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
meta: {},
|
|
275
|
+
query: `(assignment left: (list_pattern (identifier) @name))`,
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
});
|
|
279
|
+
function collectAliasShadowScopes(tree, aliases) {
|
|
280
|
+
const shadowed = new Map();
|
|
281
|
+
if (aliases.size === 0)
|
|
282
|
+
return shadowed;
|
|
283
|
+
for (const match of runCompiledPatterns(ALIAS_SHADOW_PATTERNS, tree)) {
|
|
284
|
+
const nameNode = match.captures.name;
|
|
285
|
+
if (!nameNode || !aliases.has(nameNode.text))
|
|
286
|
+
continue;
|
|
287
|
+
const scopeKey = shadowScopeKey(nameNode.parent);
|
|
288
|
+
if (scopeKey === null)
|
|
289
|
+
continue;
|
|
290
|
+
const set = shadowed.get(nameNode.text) ?? new Set();
|
|
291
|
+
set.add(scopeKey);
|
|
292
|
+
shadowed.set(nameNode.text, set);
|
|
293
|
+
}
|
|
294
|
+
return shadowed;
|
|
295
|
+
}
|
|
296
|
+
function isAliasShadowed(shadowed, aliasName, node) {
|
|
297
|
+
const scopes = shadowed.get(aliasName);
|
|
298
|
+
if (!scopes || scopes.size === 0)
|
|
299
|
+
return false;
|
|
300
|
+
let current = node.parent;
|
|
301
|
+
while (current) {
|
|
302
|
+
if (current.type === 'function_definition') {
|
|
303
|
+
// Reuse getScopeKey's key format so the two helpers cannot drift apart.
|
|
304
|
+
if (scopes.has(getScopeKey(current)))
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
current = current.parent;
|
|
308
|
+
}
|
|
309
|
+
// A module-level rebind shadows the alias for the entire file.
|
|
310
|
+
return scopes.has('module');
|
|
139
311
|
}
|
|
140
312
|
function collectHttpxAsyncClients(tree) {
|
|
141
313
|
const clients = new Map();
|
|
314
|
+
const { moduleAliases, asyncClientAliases } = collectHttpxImportAliases(tree);
|
|
315
|
+
// Module aliases (`hx`) and AsyncClient aliases (`AsyncClient`,
|
|
316
|
+
// `HttpxAsyncClient`) share disjoint name spaces, so one shadow map keyed by
|
|
317
|
+
// alias name serves both lookups and we only walk the tree for rebinds once.
|
|
318
|
+
const shadowed = collectAliasShadowScopes(tree, new Set([...moduleAliases, ...asyncClientAliases]));
|
|
142
319
|
const addClient = (clientNode) => {
|
|
143
320
|
if (!clientNode)
|
|
144
321
|
return;
|
|
@@ -149,9 +326,41 @@ function collectHttpxAsyncClients(tree) {
|
|
|
149
326
|
clients.set(clientText, scopes);
|
|
150
327
|
};
|
|
151
328
|
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS, tree)) {
|
|
329
|
+
const moduleNode = match.captures.module;
|
|
330
|
+
const classNode = match.captures.client_class;
|
|
331
|
+
if (!moduleNode || !classNode)
|
|
332
|
+
continue;
|
|
333
|
+
if (!moduleAliases.has(moduleNode.text) || classNode.text !== 'AsyncClient')
|
|
334
|
+
continue;
|
|
335
|
+
if (isAliasShadowed(shadowed, moduleNode.text, moduleNode))
|
|
336
|
+
continue;
|
|
337
|
+
addClient(match.captures.client);
|
|
338
|
+
}
|
|
339
|
+
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_DIRECT_ASSIGN_PATTERNS, tree)) {
|
|
340
|
+
const classNode = match.captures.client_class;
|
|
341
|
+
if (!classNode || !asyncClientAliases.has(classNode.text))
|
|
342
|
+
continue;
|
|
343
|
+
if (isAliasShadowed(shadowed, classNode.text, classNode))
|
|
344
|
+
continue;
|
|
152
345
|
addClient(match.captures.client);
|
|
153
346
|
}
|
|
154
347
|
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS, tree)) {
|
|
348
|
+
const moduleNode = match.captures.module;
|
|
349
|
+
const classNode = match.captures.client_class;
|
|
350
|
+
if (!moduleNode || !classNode)
|
|
351
|
+
continue;
|
|
352
|
+
if (!moduleAliases.has(moduleNode.text) || classNode.text !== 'AsyncClient')
|
|
353
|
+
continue;
|
|
354
|
+
if (isAliasShadowed(shadowed, moduleNode.text, moduleNode))
|
|
355
|
+
continue;
|
|
356
|
+
addClient(match.captures.client);
|
|
357
|
+
}
|
|
358
|
+
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_DIRECT_WITH_ALIAS_PATTERNS, tree)) {
|
|
359
|
+
const classNode = match.captures.client_class;
|
|
360
|
+
if (!classNode || !asyncClientAliases.has(classNode.text))
|
|
361
|
+
continue;
|
|
362
|
+
if (isAliasShadowed(shadowed, classNode.text, classNode))
|
|
363
|
+
continue;
|
|
155
364
|
addClient(match.captures.client);
|
|
156
365
|
}
|
|
157
366
|
return clients;
|
|
@@ -73,6 +73,12 @@ interface RepoHandle {
|
|
|
73
73
|
* and cannot be changed mid-process.
|
|
74
74
|
*/
|
|
75
75
|
export declare function resolveWorktreeCwd(repoPath: string, launchCwd: string): string;
|
|
76
|
+
/**
|
|
77
|
+
* Length of the base64url path hash appended to a colliding repo id.
|
|
78
|
+
* Exported so tests can pin the suffix shape without re-deriving the
|
|
79
|
+
* literal; see `repoId()` and the hashed-id resolution tier (#1658).
|
|
80
|
+
*/
|
|
81
|
+
export declare const REPO_ID_HASH_LENGTH = 6;
|
|
76
82
|
export declare class LocalBackend {
|
|
77
83
|
private repos;
|
|
78
84
|
private contextCache;
|
|
@@ -128,8 +134,20 @@ export declare class LocalBackend {
|
|
|
128
134
|
resolveRepo(repoParam?: string): Promise<RepoHandle>;
|
|
129
135
|
/**
|
|
130
136
|
* Try to resolve a repo from the in-memory cache. Returns null on miss.
|
|
137
|
+
* Throws {@link RegistryAmbiguousTargetError} when `repoParam` matches
|
|
138
|
+
* multiple handles by name and cwd cannot disambiguate (#1658).
|
|
131
139
|
*/
|
|
132
140
|
private resolveRepoFromCache;
|
|
141
|
+
/**
|
|
142
|
+
* Prefer the indexed repo whose path matches the git root of process.cwd().
|
|
143
|
+
*
|
|
144
|
+
* In MCP stdio server mode, `process.cwd()` is the server's launch directory,
|
|
145
|
+
* not the agent client's cwd. If the server was started from an unrelated
|
|
146
|
+
* directory, `getGitRoot` returns null and duplicate-name resolution throws
|
|
147
|
+
* {@link RegistryAmbiguousTargetError} — callers should pass an absolute path.
|
|
148
|
+
*/
|
|
149
|
+
private pickRepoHandleForCwd;
|
|
150
|
+
private handleToRegistryEntry;
|
|
133
151
|
private ensureInitialized;
|
|
134
152
|
/**
|
|
135
153
|
* Get context for a specific repo (or the single repo if only one).
|
|
@@ -16,7 +16,7 @@ import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/l
|
|
|
16
16
|
// import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
|
|
17
17
|
import { parseDiffHunks, getCanonicalRepoRoot, getGitRoot, } from '../../storage/git.js';
|
|
18
18
|
import { realpathSync } from 'fs';
|
|
19
|
-
import { listRegisteredRepos, cleanupOldKuzuFiles, } from '../../storage/repo-manager.js';
|
|
19
|
+
import { listRegisteredRepos, cleanupOldKuzuFiles, canonicalizePath, RegistryAmbiguousTargetError, } from '../../storage/repo-manager.js';
|
|
20
20
|
import { GroupService } from '../../core/group/service.js';
|
|
21
21
|
import { resolveAtGroupMemberRepoPath } from '../../core/group/resolve-at-member.js';
|
|
22
22
|
import { collectBestChunks } from '../../core/embeddings/types.js';
|
|
@@ -233,6 +233,12 @@ export function resolveWorktreeCwd(repoPath, launchCwd) {
|
|
|
233
233
|
}
|
|
234
234
|
return repoPath;
|
|
235
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Length of the base64url path hash appended to a colliding repo id.
|
|
238
|
+
* Exported so tests can pin the suffix shape without re-deriving the
|
|
239
|
+
* literal; see `repoId()` and the hashed-id resolution tier (#1658).
|
|
240
|
+
*/
|
|
241
|
+
export const REPO_ID_HASH_LENGTH = 6;
|
|
236
242
|
export class LocalBackend {
|
|
237
243
|
repos = new Map();
|
|
238
244
|
contextCache = new Map();
|
|
@@ -345,7 +351,13 @@ export class LocalBackend {
|
|
|
345
351
|
for (const [id, handle] of this.repos) {
|
|
346
352
|
if (id === base && handle.repoPath !== path.resolve(repoPath)) {
|
|
347
353
|
// Collision — use path hash
|
|
348
|
-
|
|
354
|
+
// Lowercase the hash so it survives the `paramLower` lookup in
|
|
355
|
+
// resolveRepoFromCache — base64url retains mixed case, but the id
|
|
356
|
+
// tier compares against `repoParam.toLowerCase()` (#1658 follow-up).
|
|
357
|
+
const hash = Buffer.from(repoPath)
|
|
358
|
+
.toString('base64url')
|
|
359
|
+
.slice(0, REPO_ID_HASH_LENGTH)
|
|
360
|
+
.toLowerCase();
|
|
349
361
|
return `${base}-${hash}`;
|
|
350
362
|
}
|
|
351
363
|
}
|
|
@@ -362,7 +374,20 @@ export class LocalBackend {
|
|
|
362
374
|
* while the MCP server was running.
|
|
363
375
|
*/
|
|
364
376
|
async resolveRepo(repoParam) {
|
|
365
|
-
|
|
377
|
+
let refreshedAfterAmbiguity = false;
|
|
378
|
+
let result;
|
|
379
|
+
try {
|
|
380
|
+
result = this.resolveRepoFromCache(repoParam);
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
if (!(err instanceof RegistryAmbiguousTargetError))
|
|
384
|
+
throw err;
|
|
385
|
+
// Stale in-memory duplicate siblings can linger after unregister; refresh
|
|
386
|
+
// once before re-throwing so a resolved registry can disambiguate (#1658).
|
|
387
|
+
await this.refreshRepos();
|
|
388
|
+
refreshedAfterAmbiguity = true;
|
|
389
|
+
result = this.resolveRepoFromCache(repoParam);
|
|
390
|
+
}
|
|
366
391
|
if (result) {
|
|
367
392
|
// Issue: silent graph drift across sibling clones.
|
|
368
393
|
// If the caller's cwd lives in a *different* on-disk clone of
|
|
@@ -375,8 +400,10 @@ export class LocalBackend {
|
|
|
375
400
|
});
|
|
376
401
|
return result;
|
|
377
402
|
}
|
|
378
|
-
// Miss — refresh registry and try once more
|
|
379
|
-
|
|
403
|
+
// Miss — refresh registry and try once more (skip if already refreshed above)
|
|
404
|
+
if (!refreshedAfterAmbiguity) {
|
|
405
|
+
await this.refreshRepos();
|
|
406
|
+
}
|
|
380
407
|
const retried = this.resolveRepoFromCache(repoParam);
|
|
381
408
|
if (retried) {
|
|
382
409
|
this.maybeWarnSiblingDrift(retried).catch(() => { });
|
|
@@ -403,31 +430,57 @@ export class LocalBackend {
|
|
|
403
430
|
}
|
|
404
431
|
/**
|
|
405
432
|
* Try to resolve a repo from the in-memory cache. Returns null on miss.
|
|
433
|
+
* Throws {@link RegistryAmbiguousTargetError} when `repoParam` matches
|
|
434
|
+
* multiple handles by name and cwd cannot disambiguate (#1658).
|
|
406
435
|
*/
|
|
407
436
|
resolveRepoFromCache(repoParam) {
|
|
408
437
|
if (this.repos.size === 0)
|
|
409
438
|
return null;
|
|
410
439
|
if (repoParam) {
|
|
411
440
|
const paramLower = repoParam.toLowerCase();
|
|
412
|
-
|
|
441
|
+
const looksLikePath = path.isAbsolute(repoParam) || repoParam.includes(path.sep) || repoParam.includes('/');
|
|
442
|
+
const resolvePathMatch = () => {
|
|
443
|
+
const canonicalTarget = canonicalizePath(repoParam);
|
|
444
|
+
return [...this.repos.values()].find((handle) => {
|
|
445
|
+
const stored = canonicalizePath(handle.repoPath);
|
|
446
|
+
return process.platform === 'win32'
|
|
447
|
+
? stored.toLowerCase() === canonicalTarget.toLowerCase()
|
|
448
|
+
: stored === canonicalTarget;
|
|
449
|
+
});
|
|
450
|
+
};
|
|
451
|
+
// Path-like params first (absolute or contains separators) — aligns with
|
|
452
|
+
// resolveRegistryEntry (#829). Bare aliases such as ".tmp-repro-mini" must
|
|
453
|
+
// not be resolved via path.resolve(cwd) before duplicate-name handling.
|
|
454
|
+
if (looksLikePath) {
|
|
455
|
+
const pathMatch = resolvePathMatch();
|
|
456
|
+
if (pathMatch)
|
|
457
|
+
return pathMatch;
|
|
458
|
+
}
|
|
459
|
+
// Exact name before id — the first duplicate sibling keeps id === name
|
|
460
|
+
// (e.g. id "shared"), so a name lookup must not be captured by the id tier.
|
|
461
|
+
const nameMatches = [...this.repos.values()].filter((handle) => handle.name.toLowerCase() === paramLower);
|
|
462
|
+
if (nameMatches.length === 1)
|
|
463
|
+
return nameMatches[0];
|
|
464
|
+
if (nameMatches.length > 1) {
|
|
465
|
+
const cwdPick = this.pickRepoHandleForCwd(nameMatches);
|
|
466
|
+
if (cwdPick)
|
|
467
|
+
return cwdPick;
|
|
468
|
+
throw new RegistryAmbiguousTargetError(repoParam, nameMatches.map((h) => this.handleToRegistryEntry(h)));
|
|
469
|
+
}
|
|
470
|
+
// Stable hashed id (e.g. "shared-abc123") from repoId() collision suffix
|
|
413
471
|
if (this.repos.has(paramLower))
|
|
414
472
|
return this.repos.get(paramLower);
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
// Match by partial name
|
|
427
|
-
for (const handle of this.repos.values()) {
|
|
428
|
-
if (handle.name.toLowerCase().includes(paramLower))
|
|
429
|
-
return handle;
|
|
430
|
-
}
|
|
473
|
+
// Bare name resolved as a cwd-relative path (e.g. "myrepo" against process.cwd()),
|
|
474
|
+
// after name/id tiers. Path-like strings with separators were handled at the top.
|
|
475
|
+
if (!looksLikePath) {
|
|
476
|
+
const pathMatch = resolvePathMatch();
|
|
477
|
+
if (pathMatch)
|
|
478
|
+
return pathMatch;
|
|
479
|
+
}
|
|
480
|
+
// Partial name — only when unambiguous
|
|
481
|
+
const partialMatches = [...this.repos.values()].filter((handle) => handle.name.toLowerCase().includes(paramLower));
|
|
482
|
+
if (partialMatches.length === 1)
|
|
483
|
+
return partialMatches[0];
|
|
431
484
|
return null;
|
|
432
485
|
}
|
|
433
486
|
if (this.repos.size === 1) {
|
|
@@ -435,6 +488,38 @@ export class LocalBackend {
|
|
|
435
488
|
}
|
|
436
489
|
return null; // Multiple repos, no param — ambiguous
|
|
437
490
|
}
|
|
491
|
+
/**
|
|
492
|
+
* Prefer the indexed repo whose path matches the git root of process.cwd().
|
|
493
|
+
*
|
|
494
|
+
* In MCP stdio server mode, `process.cwd()` is the server's launch directory,
|
|
495
|
+
* not the agent client's cwd. If the server was started from an unrelated
|
|
496
|
+
* directory, `getGitRoot` returns null and duplicate-name resolution throws
|
|
497
|
+
* {@link RegistryAmbiguousTargetError} — callers should pass an absolute path.
|
|
498
|
+
*/
|
|
499
|
+
pickRepoHandleForCwd(candidates) {
|
|
500
|
+
const cwdRoot = getGitRoot(process.cwd());
|
|
501
|
+
if (!cwdRoot)
|
|
502
|
+
return null;
|
|
503
|
+
const canonicalCwd = canonicalizePath(cwdRoot);
|
|
504
|
+
const cwdMatches = candidates.filter((handle) => {
|
|
505
|
+
const stored = canonicalizePath(handle.repoPath);
|
|
506
|
+
return process.platform === 'win32'
|
|
507
|
+
? stored.toLowerCase() === canonicalCwd.toLowerCase()
|
|
508
|
+
: stored === canonicalCwd;
|
|
509
|
+
});
|
|
510
|
+
return cwdMatches.length === 1 ? cwdMatches[0] : null;
|
|
511
|
+
}
|
|
512
|
+
handleToRegistryEntry(handle) {
|
|
513
|
+
return {
|
|
514
|
+
name: handle.name,
|
|
515
|
+
path: handle.repoPath,
|
|
516
|
+
storagePath: handle.storagePath,
|
|
517
|
+
indexedAt: handle.indexedAt,
|
|
518
|
+
lastCommit: handle.lastCommit,
|
|
519
|
+
stats: handle.stats,
|
|
520
|
+
remoteUrl: handle.remoteUrl,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
438
523
|
// ─── Lazy LadybugDB Init ────────────────────────────────────────────
|
|
439
524
|
async ensureInitialized(repoId) {
|
|
440
525
|
// If a reinit is already in progress for this repo, wait for it
|
package/package.json
CHANGED