gitnexus 1.6.10-rc.95 → 1.6.10-rc.96

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.
@@ -63,6 +63,13 @@ export interface GitNexusContentOptions {
63
63
  hasPdg?: boolean;
64
64
  }
65
65
  export declare function generateGitNexusContent(projectName: string, stats: RepoStats, opts?: GitNexusContentOptions): string;
66
+ /**
67
+ * Some agents read skills from a repo-local `.agents/skills/` directory and
68
+ * prefer it over the global `~/.agents/skills/` install. When the repo contains
69
+ * an `.agents/` directory, skills written to `.claude/skills/` are mirrored
70
+ * there too so those agents serve the up-to-date copies.
71
+ */
72
+ export declare function shouldMirrorSkillsToAgents(repoPath: string): Promise<boolean>;
66
73
  /**
67
74
  * Generate AI context files after indexing
68
75
  */
@@ -250,14 +250,31 @@ async function upsertGitNexusSection(filePath, content, projectName, stats, noSt
250
250
  await fs.writeFile(filePath, newContent, 'utf-8');
251
251
  return 'appended';
252
252
  }
253
+ /**
254
+ * Some agents read skills from a repo-local `.agents/skills/` directory and
255
+ * prefer it over the global `~/.agents/skills/` install. When the repo contains
256
+ * an `.agents/` directory, skills written to `.claude/skills/` are mirrored
257
+ * there too so those agents serve the up-to-date copies.
258
+ */
259
+ export async function shouldMirrorSkillsToAgents(repoPath) {
260
+ try {
261
+ const stat = await fs.stat(path.join(repoPath, '.agents'));
262
+ return stat.isDirectory();
263
+ }
264
+ catch {
265
+ return false;
266
+ }
267
+ }
253
268
  /**
254
269
  * Install GitNexus skills as direct children of .claude/skills/
255
- * Works natively with Claude Code, Cursor, and GitHub Copilot
270
+ * Works natively with Claude Code, Cursor, and GitHub Copilot.
271
+ * Mirrored to .agents/skills/ when .agents/ exists.
256
272
  */
257
273
  async function installSkills(repoPath) {
258
274
  const skillsDir = path.join(repoPath, '.claude', 'skills');
259
275
  const legacySkillsDir = path.join(skillsDir, 'gitnexus');
260
276
  const installedSkills = [];
277
+ const agentsMirror = await shouldMirrorSkillsToAgents(repoPath);
261
278
  for (const skill of STANDARD_SKILL_CATALOG.filter((entry) => entry.distributions.project && entry.distributions.npm)) {
262
279
  const skillDir = path.join(skillsDir, skill.name);
263
280
  const skillPath = path.join(skillDir, 'SKILL.md');
@@ -285,6 +302,17 @@ Use GitNexus tools to accomplish this task.
285
302
  `;
286
303
  }
287
304
  await fs.writeFile(skillPath, skillContent, 'utf-8');
305
+ // Mirror to .agents/skills/ for agents that read repo-local skills
306
+ if (agentsMirror) {
307
+ try {
308
+ const agentsSkillDir = path.join(repoPath, '.agents', 'skills', skill.name);
309
+ await fs.mkdir(agentsSkillDir, { recursive: true });
310
+ await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), skillContent, 'utf-8');
311
+ }
312
+ catch (err) {
313
+ logger.warn({ err }, `Warning: Could not mirror skill ${skill.name} to .agents/skills:`);
314
+ }
315
+ }
288
316
  installedSkills.push(skill.name);
289
317
  // Previous releases installed these known standard skills one level too
290
318
  // deep. Remove only the child owned by this installer; unknown siblings
@@ -301,7 +329,7 @@ Use GitNexus tools to accomplish this task.
301
329
  logger.warn({ err }, `Warning: Could not install skill ${skill.name}:`);
302
330
  }
303
331
  }
304
- return installedSkills;
332
+ return { skills: installedSkills, agentsMirror };
305
333
  }
306
334
  /**
307
335
  * Generate AI context files after indexing
@@ -349,9 +377,12 @@ export async function generateAIContextFiles(repoPath, storagePath, projectName,
349
377
  }
350
378
  // Install standard skills directly under .claude/skills/ (unless --skip-skills)
351
379
  if (!options?.skipSkills) {
352
- const installedSkills = await installSkills(repoPath);
380
+ const { skills: installedSkills, agentsMirror } = await installSkills(repoPath);
353
381
  if (installedSkills.length > 0) {
354
382
  createdFiles.push(`.claude/skills/gitnexus-*/ (${installedSkills.length} skills)`);
383
+ if (agentsMirror) {
384
+ createdFiles.push(`.agents/skills/gitnexus-*/ (${installedSkills.length} skills mirrored for .agents)`);
385
+ }
355
386
  }
356
387
  }
357
388
  else {
@@ -153,7 +153,7 @@ export declare const en: {
153
153
  readonly 'help.option.analyze.skills': "Generate repo-specific skill files from detected communities (no-op when --index-only is also set).";
154
154
  readonly 'help.option.analyze.skipAgentsMd': "Skip updating the gitnexus section in AGENTS.md and CLAUDE.md";
155
155
  readonly 'help.option.analyze.noStats': "Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md";
156
- readonly 'help.option.analyze.skipSkills': "Skip installing standard GitNexus skill files directly under .claude/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.";
156
+ readonly 'help.option.analyze.skipSkills': "Skip installing standard GitNexus skill files directly under .claude/skills/ and .agents/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.";
157
157
  readonly 'help.option.analyze.indexOnly': "Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)";
158
158
  readonly 'help.option.skipGit': "Treat the provided path/cwd as the index root and skip parent git-root discovery";
159
159
  readonly 'help.option.analyze.name': "Register this repo under a custom name in ~/.gitnexus/registry.json (disambiguates repos whose paths share a basename, e.g. two different .../app folders)";
@@ -153,7 +153,7 @@ export const en = {
153
153
  'help.option.analyze.skills': 'Generate repo-specific skill files from detected communities (no-op when --index-only is also set).',
154
154
  'help.option.analyze.skipAgentsMd': 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md',
155
155
  'help.option.analyze.noStats': 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md',
156
- 'help.option.analyze.skipSkills': 'Skip installing standard GitNexus skill files directly under .claude/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.',
156
+ 'help.option.analyze.skipSkills': 'Skip installing standard GitNexus skill files directly under .claude/skills/ and .agents/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.',
157
157
  'help.option.analyze.indexOnly': 'Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)',
158
158
  'help.option.skipGit': 'Treat the provided path/cwd as the index root and skip parent git-root discovery',
159
159
  'help.option.analyze.name': 'Register this repo under a custom name in ~/.gitnexus/registry.json (disambiguates repos whose paths share a basename, e.g. two different .../app folders)',
@@ -154,7 +154,7 @@ export declare const cliResources: {
154
154
  readonly 'help.option.analyze.skills': "Generate repo-specific skill files from detected communities (no-op when --index-only is also set).";
155
155
  readonly 'help.option.analyze.skipAgentsMd': "Skip updating the gitnexus section in AGENTS.md and CLAUDE.md";
156
156
  readonly 'help.option.analyze.noStats': "Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md";
157
- readonly 'help.option.analyze.skipSkills': "Skip installing standard GitNexus skill files directly under .claude/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.";
157
+ readonly 'help.option.analyze.skipSkills': "Skip installing standard GitNexus skill files directly under .claude/skills/ and .agents/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.";
158
158
  readonly 'help.option.analyze.indexOnly': "Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)";
159
159
  readonly 'help.option.skipGit': "Treat the provided path/cwd as the index root and skip parent git-root discovery";
160
160
  readonly 'help.option.analyze.name': "Register this repo under a custom name in ~/.gitnexus/registry.json (disambiguates repos whose paths share a basename, e.g. two different .../app folders)";
@@ -153,7 +153,7 @@ export const zhCN = {
153
153
  'help.option.analyze.skills': '根据检测到的社区生成仓库专属 skill 文件(同时设置 --index-only 时无效)。',
154
154
  'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块',
155
155
  'help.option.analyze.noStats': '从 AGENTS.md 和 CLAUDE.md 中省略易变的文件/符号计数',
156
- 'help.option.analyze.skipSkills': '跳过直接安装在 .claude/skills/ 下的标准 GitNexus skill 文件。不抑制 --skills 生成的社区 skill(位于 .claude/skills/gitnexus-area-*)。使用 --index-only 可跳过所有 AI 上下文文件注入。',
156
+ 'help.option.analyze.skipSkills': '跳过直接安装在 .claude/skills/ 和 .agents/skills/ 下的标准 GitNexus skill 文件。不抑制 --skills 生成的社区 skill(位于 .claude/skills/gitnexus-area-*)。使用 --index-only 可跳过所有 AI 上下文文件注入。',
157
157
  'help.option.analyze.indexOnly': '纯索引模式:跳过所有文件注入(AGENTS.md、CLAUDE.md、skills)',
158
158
  'help.option.skipGit': '将提供的路径/cwd 视为索引根目录,并跳过向上查找 git 根目录',
159
159
  'help.option.analyze.name': '在 ~/.gitnexus/registry.json 中使用自定义名称注册该仓库(用于区分路径 basename 相同的仓库,例如两个不同的 .../app 目录)',
package/dist/cli/index.js CHANGED
@@ -53,7 +53,7 @@ program
53
53
  'Without this flag, analyze always updates the workspace index, which follows the ' +
54
54
  'checked-out working tree. Distinct from --default-branch (cosmetic base_ref).')
55
55
  .option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
56
- .option('--skip-skills', 'Skip installing standard GitNexus skill files directly under .claude/skills/. ' +
56
+ .option('--skip-skills', 'Skip installing standard GitNexus skill files directly under .claude/skills/ and .agents/skills/. ' +
57
57
  'Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). ' +
58
58
  'Use --index-only to skip all AI-context file injection.')
59
59
  .option('--index-only', 'Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)')
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import fs from 'fs/promises';
10
10
  import path from 'path';
11
+ import { shouldMirrorSkillsToAgents } from './ai-context.js';
11
12
  const GENERATED_SKILL_PREFIX = 'gitnexus-area-';
12
13
  const MAX_SKILL_NAME_LENGTH = 64;
13
14
  const MAX_COMMUNITY_NAME_LENGTH = MAX_SKILL_NAME_LENGTH - GENERATED_SKILL_PREFIX.length;
@@ -25,6 +26,12 @@ export const generateSkillFiles = async (repoPath, projectName, pipelineResult)
25
26
  const { communityResult, processResult, graph } = pipelineResult;
26
27
  const outputDir = path.join(repoPath, '.claude', 'skills');
27
28
  const legacyOutputDir = path.join(outputDir, 'generated');
29
+ // Some agents prioritize repo-local .agents/skills over the global
30
+ // ~/.agents/skills install (see shouldMirrorSkillsToAgents). When .agents/
31
+ // exists, mirror the generated community skills there too so those agents
32
+ // serve the up-to-date copies.
33
+ const agentsOutputDir = path.join(repoPath, '.agents', 'skills');
34
+ let mirrorToAgents = await shouldMirrorSkillsToAgents(repoPath);
28
35
  // Community skills used to live under an undiscoverable `generated/`
29
36
  // grouping directory. Clear that GitNexus-owned legacy output and
30
37
  // stale direct outputs in the reserved namespace, while preserving every
@@ -44,6 +51,20 @@ export const generateSkillFiles = async (repoPath, projectName, pipelineResult)
44
51
  catch {
45
52
  /* legacy output may not exist */
46
53
  }
54
+ // Mirror cleanup: clear only stale GitNexus-generated community skills under
55
+ // .agents/skills/ (reserved gitnexus-area-* namespace), preserving mirrored
56
+ // standard skills and any user-authored skills. Never clear the whole root.
57
+ if (mirrorToAgents) {
58
+ try {
59
+ const entries = await fs.readdir(agentsOutputDir, { withFileTypes: true });
60
+ await Promise.all(entries
61
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith(GENERATED_SKILL_PREFIX))
62
+ .map((entry) => fs.rm(path.join(agentsOutputDir, entry.name), { recursive: true, force: true })));
63
+ }
64
+ catch {
65
+ /* mirror root may not exist yet */
66
+ }
67
+ }
47
68
  if (!communityResult || !communityResult.memberships.length) {
48
69
  console.log('\n Skills: no communities detected, skipping skill generation');
49
70
  return { skills: [], outputPath: outputDir };
@@ -73,6 +94,19 @@ export const generateSkillFiles = async (repoPath, projectName, pipelineResult)
73
94
  // Step 4: Ensure the shared project-skill root exists. Never clear it: it
74
95
  // also contains user-authored and standard GitNexus skills.
75
96
  await fs.mkdir(outputDir, { recursive: true });
97
+ // The .agents/ mirror is a side flow: keep it a weak dependency. If the
98
+ // mirror root cannot be created (e.g. `.agents/skills` exists as a file),
99
+ // warn and disable mirroring for this run instead of aborting canonical
100
+ // community-skill generation. Canonical writes below stay unaffected.
101
+ if (mirrorToAgents) {
102
+ try {
103
+ await fs.mkdir(agentsOutputDir, { recursive: true });
104
+ }
105
+ catch (err) {
106
+ console.log(`Warning: Could not create mirror root ${agentsOutputDir} — .agents/skills mirroring disabled for this run: ${err}`);
107
+ mirrorToAgents = false;
108
+ }
109
+ }
76
110
  // Step 5: Generate skill files
77
111
  const skills = [];
78
112
  const usedNames = new Set();
@@ -99,6 +133,19 @@ export const generateSkillFiles = async (repoPath, projectName, pipelineResult)
99
133
  const skillDir = path.join(outputDir, skillName);
100
134
  await fs.mkdir(skillDir, { recursive: true });
101
135
  await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8');
136
+ // Mirror to .agents/skills/ for agents that read repo-local skills
137
+ // (see mirrorToAgents above). Best-effort: a per-skill mirror failure
138
+ // must not abort canonical community-skill generation.
139
+ if (mirrorToAgents) {
140
+ try {
141
+ const agentsSkillDir = path.join(agentsOutputDir, skillName);
142
+ await fs.mkdir(agentsSkillDir, { recursive: true });
143
+ await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), content, 'utf-8');
144
+ }
145
+ catch (err) {
146
+ console.log(`Warning: Could not mirror skill ${skillName} to .agents/skills: ${err}`);
147
+ }
148
+ }
102
149
  const info = {
103
150
  name: skillName,
104
151
  label: community.label,
@@ -109,6 +156,9 @@ export const generateSkillFiles = async (repoPath, projectName, pipelineResult)
109
156
  console.log(` \u2713 ${community.label} (${community.symbolCount} symbols, ${files.length} files)`);
110
157
  }
111
158
  console.log(`\n ${skills.length} skills generated \u2192 .claude/skills/${GENERATED_SKILL_PREFIX}*/`);
159
+ if (mirrorToAgents) {
160
+ console.log(` ${skills.length} skills mirrored \u2192 .agents/skills/${GENERATED_SKILL_PREFIX}*/ (.agents)`);
161
+ }
112
162
  return { skills, outputPath: outputDir };
113
163
  };
114
164
  // ============================================================================
@@ -1,16 +1,6 @@
1
1
  import type { ParsedFile } from '../../../../_shared/index.js';
2
2
  import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
3
- /**
4
- * Populate type bindings for patterns and iterators that the tree-sitter
5
- * query can't handle (they need runtime type lookup, not just syntax).
6
- *
7
- * Covers: for-loop element types, if-let/while-let pattern bindings,
8
- * match arm patterns, and struct destructuring.
9
- *
10
- * Runs in Phase 2 (after propagateImportedReturnTypes) so all cross-file
11
- * type bindings are available for lookup.
12
- */
13
- export declare function populateRustRangeBindings(parsedFiles: readonly ParsedFile[], _indexes: ScopeResolutionIndexes, ctx: {
3
+ export declare function populateRustRangeBindings(parsedFiles: readonly ParsedFile[], indexes: ScopeResolutionIndexes, ctx: {
14
4
  readonly fileContents: ReadonlyMap<string, string>;
15
5
  readonly treeCache?: {
16
6
  get(filePath: string): unknown;
@@ -2,54 +2,84 @@ import { getRustParser } from './query.js';
2
2
  import { getTreeSitterBufferSize } from '../../constants.js';
3
3
  import { parseSourceSafe, ParseTimeoutError } from '../../../tree-sitter/safe-parse.js';
4
4
  import { logger } from '../../../logger.js';
5
+ import { lookupBindingsAt } from '../../scope-resolution/scope/walkers.js';
5
6
  /**
6
- * Populate type bindings for patterns and iterators that the tree-sitter
7
- * query can't handle (they need runtime type lookup, not just syntax).
8
- *
9
- * Covers: for-loop element types, if-let/while-let pattern bindings,
10
- * match arm patterns, and struct destructuring.
11
- *
12
- * Runs in Phase 2 (after propagateImportedReturnTypes) so all cross-file
13
- * type bindings are available for lookup.
7
+ * Hold parsed trees for reuse across both prepass loops only when the whole
8
+ * Rust source fits this budget. Trees are much larger than their source, so a
9
+ * modest source cap keeps peak held-tree memory bounded; larger repos fall
10
+ * back to re-parsing per loop (unchanged RSS).
14
11
  */
15
- export function populateRustRangeBindings(parsedFiles, _indexes, ctx) {
12
+ const TREE_REUSE_SOURCE_BUDGET_BYTES = 16 * 1024 * 1024;
13
+ /**
14
+ * Parse `filePath`'s source once, honoring the caller's `treeCache` and, when
15
+ * provided, an in-function `store` so the two prepass loops share a single
16
+ * parse instead of re-parsing every file. Returns null when the source is
17
+ * missing or parsing times out.
18
+ */
19
+ function getOrParseTree(parser, filePath, ctx, store) {
20
+ const cached = (ctx.treeCache?.get(filePath) ?? store?.get(filePath));
21
+ if (cached !== undefined)
22
+ return cached;
23
+ const sourceText = ctx.fileContents.get(filePath);
24
+ if (sourceText === undefined)
25
+ return null;
26
+ let tree;
27
+ try {
28
+ tree = parseSourceSafe(parser, sourceText, undefined, {
29
+ bufferSize: getTreeSitterBufferSize(sourceText),
30
+ });
31
+ }
32
+ catch (err) {
33
+ if (err instanceof ParseTimeoutError) {
34
+ logger.warn({ file: filePath }, 'rust range-binding: parse timed out, skipping file');
35
+ return null;
36
+ }
37
+ throw err;
38
+ }
39
+ store?.set(filePath, tree);
40
+ return tree;
41
+ }
42
+ export function populateRustRangeBindings(parsedFiles, indexes, ctx) {
16
43
  const parser = getRustParser();
17
44
  const allReturnTypes = new Map();
45
+ const ambiguousReturnTypes = new Set();
18
46
  const allFieldTypes = new Map();
47
+ const ambiguousFieldTypes = new Set();
48
+ // Per-defining-file, un-collapsed, FULL-generic return/field types. When a
49
+ // bare name is ambiguous (#2514) but the call site's `use` import pins a
50
+ // single definition, we resolve that definition's file here and read its
51
+ // untruncated type so a generic `Vec<Repo>` element type survives (#2514
52
+ // follow-up: import-disambiguated duplicates resolve like the compiler).
53
+ const returnTypeByFile = new Map();
54
+ const fieldTypeByFile = new Map();
55
+ // Parse each file once and reuse across both loops when the workspace fits
56
+ // the byte budget; otherwise re-parse per loop to bound RSS (see helper).
57
+ let totalSourceBytes = 0;
19
58
  for (const parsed of parsedFiles) {
20
- const sourceText = ctx.fileContents.get(parsed.filePath);
21
- if (sourceText === undefined)
59
+ totalSourceBytes += ctx.fileContents.get(parsed.filePath)?.length ?? 0;
60
+ }
61
+ const treeStore = totalSourceBytes <= TREE_REUSE_SOURCE_BUDGET_BYTES ? new Map() : undefined;
62
+ for (const parsed of parsedFiles) {
63
+ const tree = getOrParseTree(parser, parsed.filePath, ctx, treeStore);
64
+ if (tree === null)
22
65
  continue;
23
- const cachedTree = ctx.treeCache?.get(parsed.filePath);
24
- let tree;
25
- if (cachedTree !== undefined) {
26
- tree = cachedTree;
27
- }
28
- else {
29
- try {
30
- tree = parseSourceSafe(parser, sourceText, undefined, {
31
- bufferSize: getTreeSitterBufferSize(sourceText),
32
- });
33
- }
34
- catch (err) {
35
- if (err instanceof ParseTimeoutError) {
36
- logger.warn({ file: parsed.filePath }, 'rust range-binding: parse timed out, skipping file');
37
- continue;
38
- }
39
- throw err;
40
- }
41
- }
42
66
  for (const fn of tree.rootNode.descendantsOfType('function_item')) {
43
67
  const nameNode = fn.childForFieldName('name');
44
68
  const retType = fn.childForFieldName('return_type');
45
69
  if (nameNode !== null && retType !== null) {
46
70
  const name = nameNode.text;
71
+ // Ambiguity is a latch, not a toggle: once a name has two or more
72
+ // workspace definitions it stays ambiguous for the rest of the
73
+ // prepass, regardless of duplicate count or file order (#2514).
47
74
  if (allReturnTypes.has(name)) {
48
75
  allReturnTypes.delete(name);
76
+ ambiguousReturnTypes.add(name);
49
77
  }
50
- else {
78
+ else if (!ambiguousReturnTypes.has(name)) {
51
79
  allReturnTypes.set(name, retType.text);
52
80
  }
81
+ // Full-generic record per defining file for import-disambiguated lookup.
82
+ recordByFile(returnTypeByFile, parsed.filePath, name, retType.text);
53
83
  }
54
84
  }
55
85
  for (const structNode of tree.rootNode.descendantsOfType('struct_item')) {
@@ -67,12 +97,17 @@ export function populateRustRangeBindings(parsedFiles, _indexes, ctx) {
67
97
  }
68
98
  if (fields.size > 0) {
69
99
  const name = nameNode.text;
100
+ // Same ambiguity latch as return types (#2514): a third same-named
101
+ // struct must not restore a resolvable global field map.
70
102
  if (allFieldTypes.has(name)) {
71
103
  allFieldTypes.delete(name);
104
+ ambiguousFieldTypes.add(name);
72
105
  }
73
- else {
106
+ else if (!ambiguousFieldTypes.has(name)) {
74
107
  allFieldTypes.set(name, fields);
75
108
  }
109
+ // Full-generic record per defining file for import-disambiguated lookup.
110
+ recordByFile(fieldTypeByFile, parsed.filePath, name, fields);
76
111
  }
77
112
  }
78
113
  // Publish per-type member bindings for the whole workspace before resolving
@@ -83,35 +118,16 @@ export function populateRustRangeBindings(parsedFiles, _indexes, ctx) {
83
118
  processIdentityMethodBindings(parsed);
84
119
  }
85
120
  for (const parsed of parsedFiles) {
86
- const sourceText = ctx.fileContents.get(parsed.filePath);
87
- if (sourceText === undefined)
121
+ const tree = getOrParseTree(parser, parsed.filePath, ctx, treeStore);
122
+ if (tree === null)
88
123
  continue;
89
- const cachedTree = ctx.treeCache?.get(parsed.filePath);
90
- let tree;
91
- if (cachedTree !== undefined) {
92
- tree = cachedTree;
93
- }
94
- else {
95
- try {
96
- tree = parseSourceSafe(parser, sourceText, undefined, {
97
- bufferSize: getTreeSitterBufferSize(sourceText),
98
- });
99
- }
100
- catch (err) {
101
- if (err instanceof ParseTimeoutError) {
102
- logger.warn({ file: parsed.filePath }, 'rust range-binding: parse timed out, skipping file');
103
- continue;
104
- }
105
- throw err;
106
- }
107
- }
108
124
  const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s]));
109
125
  const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
110
126
  if (moduleScope === undefined)
111
127
  continue;
112
- processForLoops(tree.rootNode, parsed, scopeMap, moduleScope, allReturnTypes);
128
+ processForLoops(tree.rootNode, parsed, scopeMap, moduleScope, allReturnTypes, indexes, returnTypeByFile);
113
129
  processPatternBindings(tree.rootNode, parsed, scopeMap, moduleScope);
114
- processStructDestructuring(tree.rootNode, parsed, scopeMap, moduleScope, allFieldTypes);
130
+ processStructDestructuring(tree.rootNode, parsed, scopeMap, moduleScope, allFieldTypes, indexes, fieldTypeByFile);
115
131
  processPendingAssignments(tree.rootNode, parsed, parsedFiles, scopeMap, moduleScope, allReturnTypes);
116
132
  }
117
133
  }
@@ -158,7 +174,60 @@ function normalizeFieldType(text) {
158
174
  t = t.slice(lastColon + 2);
159
175
  return t.trim();
160
176
  }
161
- function processForLoops(root, parsed, scopeMap, moduleScope, allReturnTypes) {
177
+ /** Get-or-create the inner map for `file` and record `name -> value`. */
178
+ function recordByFile(byFile, file, name, value) {
179
+ let inner = byFile.get(file);
180
+ if (inner === undefined) {
181
+ inner = new Map();
182
+ byFile.set(file, inner);
183
+ }
184
+ inner.set(name, value);
185
+ }
186
+ /** Final segment of a dot-joined qualified name (`a.make` -> `make`), or the
187
+ * bare name when the def carries no qualifier. */
188
+ function simpleName(qualifiedName, bareName) {
189
+ if (qualifiedName === undefined)
190
+ return bareName;
191
+ const dot = qualifiedName.lastIndexOf('.');
192
+ return dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1);
193
+ }
194
+ /** Distinct `(file, name)` definitions, in first-seen order. */
195
+ function uniqueDefs(defs) {
196
+ const seen = new Set();
197
+ const out = [];
198
+ for (const d of defs) {
199
+ const key = `${d.file} ${d.name}`;
200
+ if (seen.has(key))
201
+ continue;
202
+ seen.add(key);
203
+ out.push(d);
204
+ }
205
+ return out;
206
+ }
207
+ /**
208
+ * Resolve `name` at `moduleScope` to the value recorded in `byFile` for the one
209
+ * definition visible here, or null when zero or several are visible (which
210
+ * keeps the #2514 ambiguity latch). Mirrors Rust name resolution: explicit
211
+ * `use`/re-export imports and local defs shadow `use x::*` globs, so a glob is
212
+ * consulted only when no explicit binding names `name`, and even then only when
213
+ * exactly one glob-target file actually defines it.
214
+ */
215
+ function resolveImportedDef(name, moduleScope, indexes, byFile) {
216
+ const explicit = uniqueDefs(lookupBindingsAt(moduleScope.id, name, indexes)
217
+ .filter((r) => r.origin === 'import' || r.origin === 'reexport' || r.origin === 'local')
218
+ .map((r) => ({ file: r.def.filePath, name: simpleName(r.def.qualifiedName, name) })));
219
+ const defs = explicit.length > 0
220
+ ? explicit
221
+ : uniqueDefs((indexes.imports.get(moduleScope.id) ?? [])
222
+ .filter((e) => e.kind === 'wildcard-expanded' &&
223
+ e.targetFile !== null &&
224
+ byFile.get(e.targetFile)?.has(name) === true)
225
+ .map((e) => ({ file: e.targetFile, name })));
226
+ if (defs.length !== 1)
227
+ return null;
228
+ return byFile.get(defs[0].file)?.get(defs[0].name) ?? null;
229
+ }
230
+ function processForLoops(root, parsed, scopeMap, moduleScope, allReturnTypes, indexes, returnTypeByFile) {
162
231
  for (const forNode of root.descendantsOfType('for_expression')) {
163
232
  const patternNode = forNode.childForFieldName('pattern');
164
233
  const valueNode = forNode.childForFieldName('value');
@@ -167,7 +236,7 @@ function processForLoops(root, parsed, scopeMap, moduleScope, allReturnTypes) {
167
236
  const varName = extractVarName(patternNode);
168
237
  if (varName === null)
169
238
  continue;
170
- const elementType = resolveIterableElementType(valueNode, parsed, scopeMap, moduleScope, allReturnTypes);
239
+ const elementType = resolveIterableElementType(valueNode, parsed, scopeMap, moduleScope, allReturnTypes, indexes, returnTypeByFile);
171
240
  if (elementType === null)
172
241
  continue;
173
242
  const targetScope = findEnclosingFunctionScope(forNode, scopeMap) ?? moduleScope;
@@ -251,7 +320,7 @@ function processTupleStructPattern(patternNode, contextNode, parsed, scopeMap, m
251
320
  const targetScope = findEnclosingFunctionScope(contextNode, scopeMap) ?? moduleScope;
252
321
  injectTypeBinding(targetScope, varName, resolvedType);
253
322
  }
254
- function processStructDestructuring(root, parsed, scopeMap, moduleScope, allFieldTypes) {
323
+ function processStructDestructuring(root, parsed, scopeMap, moduleScope, allFieldTypes, indexes, fieldTypeByFile) {
255
324
  for (const letNode of root.descendantsOfType('let_declaration')) {
256
325
  const patternNode = letNode.childForFieldName('pattern');
257
326
  if (patternNode === null || patternNode.type !== 'struct_pattern')
@@ -274,7 +343,13 @@ function processStructDestructuring(root, parsed, scopeMap, moduleScope, allFiel
274
343
  continue;
275
344
  let fieldType = lookupFieldType(typeName, fieldName, parsed, scopeMap, moduleScope);
276
345
  if (fieldType === null) {
277
- fieldType = allFieldTypes?.get(typeName)?.get(fieldName) ?? null;
346
+ fieldType = allFieldTypes.get(typeName)?.get(fieldName) ?? null;
347
+ }
348
+ if (fieldType === null) {
349
+ // Import-disambiguated duplicate struct (#2514 follow-up): the global
350
+ // field map is ambiguous, but a `use` import pins one definition.
351
+ const fields = resolveImportedDef(typeName, moduleScope, indexes, fieldTypeByFile);
352
+ fieldType = fields?.get(fieldName) ?? null;
278
353
  }
279
354
  if (fieldType !== null) {
280
355
  injectTypeBinding(targetScope, fieldName, fieldType);
@@ -367,7 +442,7 @@ function processPendingAssignments(root, parsed, allParsedFiles, scopeMap, modul
367
442
  }
368
443
  }
369
444
  }
370
- function resolveIterableElementType(valueNode, parsed, scopeMap, moduleScope, allReturnTypes) {
445
+ function resolveIterableElementType(valueNode, parsed, scopeMap, moduleScope, allReturnTypes, indexes, returnTypeByFile) {
371
446
  let iterableNode = valueNode;
372
447
  if (iterableNode.type === 'reference_expression') {
373
448
  iterableNode = iterableNode.firstNamedChild ?? iterableNode;
@@ -390,12 +465,19 @@ function resolveIterableElementType(valueNode, parsed, scopeMap, moduleScope, al
390
465
  }
391
466
  }
392
467
  if (func.type === 'identifier') {
393
- const crossFileReturn = allReturnTypes?.get(func.text);
468
+ const crossFileReturn = allReturnTypes.get(func.text);
394
469
  if (crossFileReturn !== undefined)
395
470
  return unwrapGeneric(crossFileReturn);
396
471
  const rawReturn = lookupRawFunctionReturnType(func.text, valueNode);
397
472
  if (rawReturn !== null)
398
473
  return unwrapGeneric(rawReturn);
474
+ // Import-disambiguated duplicate: the bare-name map is ambiguous (#2514)
475
+ // but a `use` import pins one definition. Read its FULL return type
476
+ // here, BEFORE the scope-binding lookup below, because that binding is
477
+ // generic-truncated (`Vec<Repo>` becomes `Vec`), losing the element.
478
+ const importedReturn = resolveImportedDef(func.text, moduleScope, indexes, returnTypeByFile);
479
+ if (importedReturn !== null)
480
+ return unwrapGeneric(importedReturn);
399
481
  const returnType = lookupReturnTypeInScopes(func.text, parsed, scopeMap, moduleScope);
400
482
  if (returnType !== null)
401
483
  return unwrapGeneric(returnType);
@@ -420,6 +420,7 @@ export function runScopeResolution(input, provider) {
420
420
  if (provider.propagatesReturnTypesAcrossImports !== false) {
421
421
  propagateImportedReturnTypes(parsedFiles, indexes, workspaceIndex);
422
422
  }
423
+ const tRangeBindStart = PROF ? process.hrtime.bigint() : 0n;
423
424
  if (provider.populateRangeBindings !== undefined) {
424
425
  provider.populateRangeBindings(parsedFiles, indexes, {
425
426
  fileContents: getFileContents(),
@@ -908,6 +909,7 @@ export function runScopeResolution(input, provider) {
908
909
  logger.warn(`[scope-resolution prof] extract=${ns(tStart, tExtract).toFixed(0)}ms` +
909
910
  ` finalize=${ns(tExtract, tFinalize).toFixed(0)}ms` +
910
911
  ` propagate=${ns(tFinalize, tPropagate).toFixed(0)}ms` +
912
+ ` rangeBind=${ns(tRangeBindStart, tPropagate).toFixed(1)}ms` +
911
913
  ` resolve=${ns(tPropagate, tResolve).toFixed(0)}ms` +
912
914
  ` emit=${ns(tResolve, tEnd).toFixed(0)}ms` +
913
915
  // pdg ⊆ emit: the M2 reaching-defs share of the emit bucket (#2082 U4).
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * True when the working tree has uncommitted changes that analyze would
3
3
  * re-index, even at a matching HEAD. Excludes the paths GitNexus writes during
4
- * analyze (.gitnexus/, .claude/, .cursor/, AGENTS.md, CLAUDE.md) so its own
5
- * output never counts as dirty (regression vs PR #1233 behavior). Conservative
6
- * on any git failure. Shared so `analyze`'s fast-path gate and `status`'s
7
- * freshness report agree on what "dirty" means.
4
+ * analyze (.gitnexus/, .claude/, .cursor/, AGENTS.md, CLAUDE.md, and the
5
+ * repo-local .agents/ mirror) so its own output never counts as dirty
6
+ * (regression vs PR #1233 behavior). The entire .agents/ tree is excluded,
7
+ * matching the .claude/ treatment, because the skill mirror writes across
8
+ * .agents/skills/ and deeper paths. Conservative on any git failure. Shared
9
+ * so `analyze`'s fast-path gate and `status`'s freshness report agree on what
10
+ * "dirty" means.
8
11
  */
9
12
  export declare const isWorkingTreeDirty: (repoPath: string) => boolean;
10
13
  export declare const isGitRepo: (repoPath: string) => boolean;
@@ -7,10 +7,13 @@ const chompGitOutput = (value) => value.toString().replace(/\r?\n$/, '');
7
7
  /**
8
8
  * True when the working tree has uncommitted changes that analyze would
9
9
  * re-index, even at a matching HEAD. Excludes the paths GitNexus writes during
10
- * analyze (.gitnexus/, .claude/, .cursor/, AGENTS.md, CLAUDE.md) so its own
11
- * output never counts as dirty (regression vs PR #1233 behavior). Conservative
12
- * on any git failure. Shared so `analyze`'s fast-path gate and `status`'s
13
- * freshness report agree on what "dirty" means.
10
+ * analyze (.gitnexus/, .claude/, .cursor/, AGENTS.md, CLAUDE.md, and the
11
+ * repo-local .agents/ mirror) so its own output never counts as dirty
12
+ * (regression vs PR #1233 behavior). The entire .agents/ tree is excluded,
13
+ * matching the .claude/ treatment, because the skill mirror writes across
14
+ * .agents/skills/ and deeper paths. Conservative on any git failure. Shared
15
+ * so `analyze`'s fast-path gate and `status`'s freshness report agree on what
16
+ * "dirty" means.
14
17
  */
15
18
  export const isWorkingTreeDirty = (repoPath) => {
16
19
  try {
@@ -27,6 +30,8 @@ export const isWorkingTreeDirty = (repoPath) => {
27
30
  ':(exclude).cursor/**',
28
31
  ':(exclude)AGENTS.md',
29
32
  ':(exclude)CLAUDE.md',
33
+ ':(exclude).agents',
34
+ ':(exclude).agents/**',
30
35
  ], {
31
36
  cwd: repoPath,
32
37
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -418,8 +418,21 @@ export interface RepoMeta {
418
418
  * incremental write set only covers changed files, so a top-up against a
419
419
  * pre-v11 index would keep silently missing these CALLS edges for every
420
420
  * unchanged Rust trait file; force a full re-analyze instead.
421
- */
422
- export declare const INCREMENTAL_SCHEMA_VERSION = 11;
421
+ * v12: Rust range-binding stopped restoring ambiguous duplicate type names
422
+ * (#2514): a function/struct name defined three or more times used to
423
+ * re-resolve to the last-scanned file (a presence toggle), so odd duplicate
424
+ * counts emitted a wrong cross-file CALLS edge. Same v7/v11 contract: the
425
+ * incremental write set only covers changed files, so a top-up against a
426
+ * pre-v12 index would keep these spurious CALLS edges on every unchanged Rust
427
+ * file. v12 also changes edges in the other direction: range-binding now
428
+ * RESOLVES import-disambiguated duplicate names (`for item in make()` /
429
+ * `let Struct { f } = ..` where a `use` or `use x::*` import pins one of several
430
+ * same-named definitions) to the imported definition's type. Both the removed
431
+ * spurious edges and these new resolved edges are cross-file, so a pre-v12
432
+ * top-up would leave unchanged Rust files stale either way; force a full
433
+ * re-analyze instead.
434
+ */
435
+ export declare const INCREMENTAL_SCHEMA_VERSION = 12;
423
436
  export interface IndexedRepo {
424
437
  repoPath: string;
425
438
  storagePath: string;
@@ -139,8 +139,21 @@ export const cloneDirBelongsToEntry = (cloneDir, entryPath) => registryPathEqual
139
139
  * incremental write set only covers changed files, so a top-up against a
140
140
  * pre-v11 index would keep silently missing these CALLS edges for every
141
141
  * unchanged Rust trait file; force a full re-analyze instead.
142
+ * v12: Rust range-binding stopped restoring ambiguous duplicate type names
143
+ * (#2514): a function/struct name defined three or more times used to
144
+ * re-resolve to the last-scanned file (a presence toggle), so odd duplicate
145
+ * counts emitted a wrong cross-file CALLS edge. Same v7/v11 contract: the
146
+ * incremental write set only covers changed files, so a top-up against a
147
+ * pre-v12 index would keep these spurious CALLS edges on every unchanged Rust
148
+ * file. v12 also changes edges in the other direction: range-binding now
149
+ * RESOLVES import-disambiguated duplicate names (`for item in make()` /
150
+ * `let Struct { f } = ..` where a `use` or `use x::*` import pins one of several
151
+ * same-named definitions) to the imported definition's type. Both the removed
152
+ * spurious edges and these new resolved edges are cross-file, so a pre-v12
153
+ * top-up would leave unchanged Rust files stale either way; force a full
154
+ * re-analyze instead.
142
155
  */
143
- export const INCREMENTAL_SCHEMA_VERSION = 11;
156
+ export const INCREMENTAL_SCHEMA_VERSION = 12;
144
157
  const GITNEXUS_DIR = '.gitnexus';
145
158
  const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`;
146
159
  export const INDEX_METADATA_FILE = 'gitnexus.json';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.10-rc.95",
3
+ "version": "1.6.10-rc.96",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",