gitnexus 1.6.10-rc.76 → 1.6.10-rc.78

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.
@@ -1,6 +1,22 @@
1
1
  const REF_PREFIX_RE = /^&\s*(mut\s+)?/;
2
2
  const PTR_PREFIX_RE = /^\*\s*(const|mut)?\s*/;
3
+ const DYN_PREFIX_RE = /^dyn\s+/;
3
4
  const ENUM_VARIANT_NAMES = new Set(['Some', 'None', 'Ok', 'Err']);
5
+ // `dyn Trait`, `&dyn Trait`, `Box<dyn Trait>` all name a trait object whose
6
+ // receiver-dispatch target is the trait itself (#2604) — strip the `dyn`
7
+ // keyword and any auto-trait/lifetime bound list (`dyn Trait + Send`) down to
8
+ // the principal trait name. Reference/pointer sigils are stripped by the
9
+ // caller first; wrapper unwrapping (Box<T> etc.) runs before this so the
10
+ // unwrapped inner text still gets the same treatment.
11
+ function stripDynBound(t) {
12
+ if (!DYN_PREFIX_RE.test(t))
13
+ return t;
14
+ t = t.replace(DYN_PREFIX_RE, '');
15
+ const plus = t.indexOf('+');
16
+ if (plus !== -1)
17
+ t = t.slice(0, plus);
18
+ return t.trim();
19
+ }
4
20
  // ─── interpretImport ──────────────────────────────────────────────────────
5
21
  export function interpretRustImport(captures) {
6
22
  const kind = captures['@import.kind']?.text;
@@ -104,6 +120,7 @@ export function normalizeRustTypeName(text) {
104
120
  if (inner !== null)
105
121
  t = inner;
106
122
  }
123
+ t = stripDynBound(t);
107
124
  const bracket = t.indexOf('<');
108
125
  if (bracket !== -1)
109
126
  t = t.slice(0, bracket);
@@ -168,6 +185,7 @@ function normalizeRustReturnType(text) {
168
185
  }
169
186
  }
170
187
  }
188
+ t = stripDynBound(t);
171
189
  const bracket = t.indexOf('<');
172
190
  if (bracket !== -1)
173
191
  t = t.slice(0, bracket);
@@ -9,6 +9,7 @@ const RUST_SCOPE_QUERY = `
9
9
  (enum_item) @scope.class
10
10
  (union_item) @scope.class
11
11
  (function_item) @scope.function
12
+ (function_signature_item) @scope.function
12
13
  (closure_expression) @scope.function
13
14
  (block) @scope.block
14
15
  (if_expression) @scope.block
@@ -54,6 +55,14 @@ const RUST_SCOPE_QUERY = `
54
55
  (function_item
55
56
  name: (identifier) @declaration.name) @declaration.function
56
57
 
58
+ ;; Declarations — trait method signature (required method, no body,
59
+ ;; e.g. fn foo(self) -> T; inside a trait body). Without this, an abstract
60
+ ;; trait method is invisible to scope resolution — never owned by its
61
+ ;; trait's Class scope, so a dyn Trait receiver can never dispatch to
62
+ ;; it (#2604).
63
+ (function_signature_item
64
+ name: (identifier) @declaration.name) @declaration.function
65
+
57
66
  ;; Declarations — struct fields
58
67
  (field_declaration
59
68
  name: (field_identifier) @declaration.name
@@ -3549,28 +3549,13 @@ export class LocalBackend {
3549
3549
  if (oldName === new_name) {
3550
3550
  return { error: 'New name is the same as the current name.' };
3551
3551
  }
3552
- // Step 2: Collect edits from graph (high confidence)
3553
- const changes = new Map();
3554
- const addEdit = (filePath, line, oldText, newText, confidence) => {
3555
- if (!changes.has(filePath)) {
3556
- changes.set(filePath, { file_path: filePath, edits: [] });
3557
- }
3558
- changes.get(filePath).edits.push({ line, old_text: oldText, new_text: newText, confidence });
3559
- };
3560
- // The definition itself
3561
- if (sym.filePath && sym.startLine) {
3562
- try {
3563
- const content = await fs.readFile(assertSafePath(sym.filePath), 'utf-8');
3564
- const lines = content.split('\n');
3565
- const lineIdx = sym.startLine - 1;
3566
- if (lineIdx >= 0 && lineIdx < lines.length && lines[lineIdx].includes(oldName)) {
3567
- const defRegex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
3568
- addEdit(sym.filePath, sym.startLine, lines[lineIdx].trim(), lines[lineIdx].replace(defRegex, new_name).trim(), 'graph');
3569
- }
3570
- }
3571
- catch (e) {
3572
- logQueryError('rename:read-definition', e);
3573
- }
3552
+ const escapedOldName = oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3553
+ // Classify each file to rewrite by how it was discovered. Definition and
3554
+ // graph-ref files carry graph confidence; files found only by text search
3555
+ // carry text_search confidence. A graph-classified file is never downgraded.
3556
+ const fileConfidence = new Map();
3557
+ if (sym.filePath) {
3558
+ fileConfidence.set(sym.filePath, 'graph');
3574
3559
  }
3575
3560
  // All incoming refs from graph (callers, importers, etc.)
3576
3561
  const allIncoming = [
@@ -3579,31 +3564,12 @@ export class LocalBackend {
3579
3564
  ...(lookupResult.incoming.extends || []),
3580
3565
  ...(lookupResult.incoming.implements || []),
3581
3566
  ];
3582
- let graphEdits = changes.size > 0 ? 1 : 0; // count definition edit
3583
3567
  for (const ref of allIncoming) {
3584
- if (!ref.filePath)
3585
- continue;
3586
- try {
3587
- const content = await fs.readFile(assertSafePath(ref.filePath), 'utf-8');
3588
- const lines = content.split('\n');
3589
- for (let i = 0; i < lines.length; i++) {
3590
- if (lines[i].includes(oldName)) {
3591
- addEdit(ref.filePath, i + 1, lines[i].trim(), lines[i]
3592
- .replace(new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'), new_name)
3593
- .trim(), 'graph');
3594
- graphEdits++;
3595
- break; // one edit per file from graph refs
3596
- }
3597
- }
3598
- }
3599
- catch (e) {
3600
- logQueryError('rename:read-ref', e);
3568
+ if (ref.filePath) {
3569
+ fileConfidence.set(ref.filePath, 'graph');
3601
3570
  }
3602
3571
  }
3603
- // Step 3: Text search for refs the graph might have missed
3604
- let astSearchEdits = 0;
3605
- const graphFiles = new Set([sym.filePath, ...allIncoming.map((r) => r.filePath)].filter(Boolean));
3606
- // Simple text search across the repo for the old name (in files not already covered by graph)
3572
+ // Text search for files the graph might have missed entirely.
3607
3573
  try {
3608
3574
  const { execFileSync } = await import('child_process');
3609
3575
  const rgArgs = [
@@ -3629,61 +3595,97 @@ export class LocalBackend {
3629
3595
  .filter((f) => f.length > 0);
3630
3596
  for (const file of files) {
3631
3597
  const normalizedFile = file.replace(/\\/g, '/').replace(/^\.\//, '');
3632
- if (graphFiles.has(normalizedFile))
3633
- continue; // already covered by graph
3634
- try {
3635
- const content = await fs.readFile(assertSafePath(normalizedFile), 'utf-8');
3636
- const lines = content.split('\n');
3637
- const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
3638
- for (let i = 0; i < lines.length; i++) {
3639
- regex.lastIndex = 0;
3640
- if (regex.test(lines[i])) {
3641
- regex.lastIndex = 0;
3642
- addEdit(normalizedFile, i + 1, lines[i].trim(), lines[i].replace(regex, new_name).trim(), 'text_search');
3643
- astSearchEdits++;
3644
- }
3645
- }
3646
- }
3647
- catch (e) {
3648
- logQueryError('rename:text-search-read', e);
3598
+ // Never downgrade a graph-classified file to text_search.
3599
+ if (!fileConfidence.has(normalizedFile)) {
3600
+ fileConfidence.set(normalizedFile, 'text_search');
3649
3601
  }
3650
3602
  }
3651
3603
  }
3652
3604
  catch (e) {
3653
3605
  logQueryError('rename:ripgrep', e);
3654
3606
  }
3655
- // Step 4: Apply or preview
3656
- const allChanges = Array.from(changes.values());
3657
- const totalEdits = allChanges.reduce((sum, c) => sum + c.edits.length, 0);
3607
+ // Enumerate every `\boldName\b` line in each file to rewrite, so the previewed
3608
+ // file set is exactly the set the apply loop below rewrites. A file with no
3609
+ // matching line is dropped (apply would write nothing to it). `wordTest`
3610
+ // (non-global) probes each line; `wordReplace` (global) rewrites it and is
3611
+ // reused by the apply loop — compiled once each rather than once per line,
3612
+ // and one escaping formula serves both passes.
3613
+ const wordTest = new RegExp(`\\b${escapedOldName}\\b`);
3614
+ const wordReplace = new RegExp(`\\b${escapedOldName}\\b`, 'g');
3615
+ const changes = new Map();
3616
+ for (const [filePath, confidence] of fileConfidence) {
3617
+ try {
3618
+ const content = await fs.readFile(assertSafePath(filePath), 'utf-8');
3619
+ const lines = content.split('\n');
3620
+ const edits = [];
3621
+ for (let i = 0; i < lines.length; i++) {
3622
+ if (!wordTest.test(lines[i])) {
3623
+ continue;
3624
+ }
3625
+ edits.push({
3626
+ line: i + 1,
3627
+ old_text: lines[i].trim(),
3628
+ new_text: lines[i].replace(wordReplace, new_name).trim(),
3629
+ confidence,
3630
+ });
3631
+ }
3632
+ if (edits.length > 0) {
3633
+ changes.set(filePath, { file_path: filePath, edits });
3634
+ }
3635
+ }
3636
+ catch (e) {
3637
+ logQueryError('rename:enumerate', e);
3638
+ }
3639
+ }
3640
+ // Step 4: Apply or preview.
3658
3641
  const failedFiles = [];
3659
3642
  if (!dry_run) {
3660
- // Apply edits to files
3661
- for (const change of allChanges) {
3643
+ for (const change of changes.values()) {
3662
3644
  try {
3663
3645
  const fullPath = assertSafePath(change.file_path);
3664
- let content = await fs.readFile(fullPath, 'utf-8');
3665
- const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
3666
- content = content.replace(regex, new_name);
3667
- await fs.writeFile(fullPath, content, 'utf-8');
3646
+ const content = await fs.readFile(fullPath, 'utf-8');
3647
+ await fs.writeFile(fullPath, content.replace(wordReplace, new_name), 'utf-8');
3668
3648
  }
3669
3649
  catch (e) {
3670
- // A swallowed write failure must not be reported as a full success
3671
- // (#2283): record the file so the result can degrade to 'partial'
3672
- // with the unwritten files listed, rather than masquerading as done.
3650
+ // A swallowed write failure must not be reported as success (#2283):
3651
+ // record the file so the result degrades to 'partial'.
3673
3652
  logQueryError('rename:apply-edit', e);
3674
3653
  failedFiles.push(change.file_path);
3675
3654
  }
3676
3655
  }
3656
+ // A file whose write threw did not land, so drop its edits from the
3657
+ // reported result — total_edits/changes must describe what actually
3658
+ // reached disk, not what was attempted (#2605: the report matches reality
3659
+ // even on partial failure). failed_files still names every dropped file.
3660
+ for (const f of failedFiles) {
3661
+ changes.delete(f);
3662
+ }
3663
+ }
3664
+ // Counts derive from the reported set (dry-run: every enumerated file;
3665
+ // apply: only files that landed), so the graph/text_search split always
3666
+ // sums to total_edits and never overstates a partial apply.
3667
+ const reported = Array.from(changes.values());
3668
+ let graphEdits = 0;
3669
+ let astSearchEdits = 0;
3670
+ for (const change of reported) {
3671
+ for (const edit of change.edits) {
3672
+ if (edit.confidence === 'graph') {
3673
+ graphEdits++;
3674
+ }
3675
+ else {
3676
+ astSearchEdits++;
3677
+ }
3678
+ }
3677
3679
  }
3678
3680
  return {
3679
3681
  status: failedFiles.length > 0 ? 'partial' : 'success',
3680
3682
  old_name: oldName,
3681
3683
  new_name,
3682
- files_affected: allChanges.length,
3683
- total_edits: totalEdits,
3684
+ files_affected: reported.length,
3685
+ total_edits: graphEdits + astSearchEdits,
3684
3686
  graph_edits: graphEdits,
3685
3687
  text_search_edits: astSearchEdits,
3686
- changes: allChanges,
3688
+ changes: reported,
3687
3689
  applied: !dry_run,
3688
3690
  ...(failedFiles.length > 0 && { failed_files: failedFiles }),
3689
3691
  };
@@ -411,8 +411,15 @@ export interface RepoMeta {
411
411
  * `Record` node and its `HAS_METHOD` edges for every unchanged record file
412
412
  * (same v7 contract: new nodes/edges the incremental path would otherwise
413
413
  * never backfill); force a full re-analyze instead.
414
- */
415
- export declare const INCREMENTAL_SCHEMA_VERSION = 10;
414
+ * v11: Rust abstract trait methods (`fn foo(&self) -> T;`, no body) now get a
415
+ * scope + declaration capture (#2604): RUST_SCOPE_QUERY had no
416
+ * `function_signature_item` pattern, so a `&dyn Trait` receiver could never
417
+ * dispatch a CALLS edge to the trait's own method. Same v7/v10 contract: the
418
+ * incremental write set only covers changed files, so a top-up against a
419
+ * pre-v11 index would keep silently missing these CALLS edges for every
420
+ * unchanged Rust trait file; force a full re-analyze instead.
421
+ */
422
+ export declare const INCREMENTAL_SCHEMA_VERSION = 11;
416
423
  export interface IndexedRepo {
417
424
  repoPath: string;
418
425
  storagePath: string;
@@ -132,8 +132,15 @@ export const cloneDirBelongsToEntry = (cloneDir, entryPath) => registryPathEqual
132
132
  * `Record` node and its `HAS_METHOD` edges for every unchanged record file
133
133
  * (same v7 contract: new nodes/edges the incremental path would otherwise
134
134
  * never backfill); force a full re-analyze instead.
135
+ * v11: Rust abstract trait methods (`fn foo(&self) -> T;`, no body) now get a
136
+ * scope + declaration capture (#2604): RUST_SCOPE_QUERY had no
137
+ * `function_signature_item` pattern, so a `&dyn Trait` receiver could never
138
+ * dispatch a CALLS edge to the trait's own method. Same v7/v10 contract: the
139
+ * incremental write set only covers changed files, so a top-up against a
140
+ * pre-v11 index would keep silently missing these CALLS edges for every
141
+ * unchanged Rust trait file; force a full re-analyze instead.
135
142
  */
136
- export const INCREMENTAL_SCHEMA_VERSION = 10;
143
+ export const INCREMENTAL_SCHEMA_VERSION = 11;
137
144
  const GITNEXUS_DIR = '.gitnexus';
138
145
  const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`;
139
146
  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.76",
3
+ "version": "1.6.10-rc.78",
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",