From d05c911a984feb5b1be4f0bdab351c96c895cbee Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:47:50 -0400 Subject: [PATCH] fix(index): rebuild word vocabulary for unchanged files Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- CHANGELOG.md | 1 + __tests__/segment-vocab.test.ts | 37 +++++++++++++++++++++++++++++++++ src/index.ts | 17 +++++++++++---- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eaa9f0bee..a636e08792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Objective-C, a message now reaches a method only when it could really be sent there. A message to `self` or `super` reaches only the sender's class and its superclasses, including categories on UIKit and AppKit views. A C-style call like `completionBlock()` is never taken for a method or property. A message to a local variable uses the variable's declared type (`FMResultSet *rs`). On SDWebImage, `[self class]`, `respondsToSelector:` and `isKindOfClass:` used to land on a proxy class's overrides over 150 times, and on fmdb `[rs close]` now reaches the result set. - In VB.NET, a call through a receiver like `Me.Panel.Controls.Add(…)` or `New System.Drawing.Size(…)` no longer lands on an unrelated class's member with the same name. It now resolves only through `Me`, `MyBase` or `MyClass`, through the member's own class or module (`Logger.Log(…)`), or through a service lookup's type (`GetService(Of Notifier).Notify()`). On SCrawler, the Windows Forms designer code used to link over 700 `Size` calls to a nested enum's case and over 500 `Controls.Add` calls to a collection class. - In CFML, a call no longer lands on an unrelated component's method just because the name matches. A call with no receiver inside a component now reaches only that component's methods and those of the components it extends, so built-ins like `now()` stay built-ins. A call on an untyped variable now needs the variable to be named after the component, so `server.keyExists(…)` is no longer taken for some component's `keyExists`. On coldbox, `now()` used to land on a date helper over 200 times, and TestBox's `fail(…)` in specs now reaches the spec base class. +- Repeating a full index through the library keeps unchanged symbols discoverable by the words in their names, and cancelling a re-index preserves those matches. - In Ruby, a call no longer lands on an unrelated class's method just because it's the only method with that name. A call on an untyped receiver now needs the receiver to be named after the method's class, like `web_push_request.legacy_encrypt`. A call with no receiver inside a class now reaches only that class's methods, its superclasses' methods, and those of the modules it includes, extends or prepends. On rubocop, `node.loc` on a syntax node used to land on the project's one `loc` method over 1,200 times, and Kernel's `format(…)` on the language server's `format`. On mastodon, `render` in controllers used to land on an inline renderer, and `authorize` now reaches the authorization concern. - In Kotlin, a call to a top-level or extension function now resolves only to one the file can see: one in its own package, one it imports by name, or one from a package it imports with `*`. On Exposed, tests that imported kotlin.test's or JUnit's `assertEquals`, or the JDBC suite's helper, used to land on the R2DBC suite's helper over 3,000 times. On okhttp, tests that import `mockwebserver3.MockResponse` used to construct the deprecated class of the same name. A function type with a qualified receiver, like `(DatabaseConfig.Builder.() -> Unit)?`, no longer breaks the parse of the class around it. Re-index after upgrading. - In Dart, a call written without a receiver now reaches only a method of the class it's written in, of a class it extends, mixes in or implements, or of an extension on one of those types, and picks the nearest one. On riverpod, package:test's `test(…)` in the test files used to land on a container class's `test` method over 1,100 times, shelf's and getx's `expect(…)` on unrelated classes' `expect`, and bloc's `emit(…)` in a Cubit on the wrong base class. Classes declared as `class A = B with C;` are now indexed under their name, with what they extend and mix in. Re-index after upgrading. diff --git a/__tests__/segment-vocab.test.ts b/__tests__/segment-vocab.test.ts index 50d688b53f..fcb2d33733 100644 --- a/__tests__/segment-vocab.test.ts +++ b/__tests__/segment-vocab.test.ts @@ -73,6 +73,43 @@ export function writeConfig(): void {} expect(hit.kind).not.toBe('file'); }); + it('keeps unchanged symbol words through repeated full indexing', async () => { + const before = cg.getSegmentMatches(['state', 'machine']); + expect(before.map((m) => m.name)).toContain('OrderStateMachine'); + + for (let run = 0; run < 2; run++) { + expect((await cg.indexAll()).success).toBe(true); + expect(cg.getNodesByName('OrderStateMachine')).toHaveLength(1); + expect(cg.getSegmentMatches(['state', 'machine'])).toEqual(before); + expect(cg.getSegmentMatches(['checkout', 'service']).map((m) => m.name)).toContain('CheckoutService'); + } + }); + + it('rebuilds words for both changed and unchanged files after a full index', async () => { + const touched = path.join(dir, 'src', 'state-machine.ts'); + fs.writeFileSync(touched, fs.readFileSync(touched, 'utf8').replace('OrderStateMachine', 'RenamedWorkflowEngine')); + + expect((await cg.indexAll()).success).toBe(true); + expect(cg.getSegmentMatches(['renamed', 'workflow']).map((m) => m.name)).toContain('RenamedWorkflowEngine'); + expect(cg.getSegmentMatches(['checkout', 'service']).map((m) => m.name)).toContain('CheckoutService'); + expect(cg.getSegmentMatches(['state', 'machine'])).toEqual([]); + + await cg.sync(); + expect(cg.getSegmentMatches(['checkout', 'service']).map((m) => m.name)).toContain('CheckoutService'); + }); + + it('preserves existing symbol words when full indexing is cancelled', async () => { + const before = cg.getSegmentMatches(['state', 'machine']); + const controller = new AbortController(); + controller.abort(); + + const result = await cg.indexAll({ signal: controller.signal }); + expect(result.success).toBe(false); + expect(result.errors.some((error) => error.message === 'Aborted')).toBe(true); + expect(cg.getNodesByName('OrderStateMachine')).toHaveLength(1); + expect(cg.getSegmentMatches(['state', 'machine'])).toEqual(before); + }); + it('single rare word qualifies; ubiquitous and singleton words do not', () => { // "checkout" clusters (Service + Controller) — a concept this repo is about. expect(cg.getSegmentMatches(['checkout']).map((m) => m.name)).toContain('CheckoutService'); diff --git a/src/index.ts b/src/index.ts index 99303b6fd6..b24946b6ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -564,10 +564,12 @@ export class CodeGraph { // marker behind, so `codegraph status` can tell a truncated index // from a completed one instead of silently serving partial results. try { this.queries.setMetadata('index_state', 'indexing'); } catch { /* metadata is advisory */ } - // Segment vocabulary starts empty and is repopulated by the node write - // path as every file (re-)indexes below — so a full index is also the - // orphan-cleanup pass for names deleted since the last one. - try { this.queries.clearNameSegmentVocab(); } catch { /* vocab is advisory — never fail an index over it */ } + // Fresh graphs populate the vocabulary through node writes. Existing + // graphs skip unchanged files at the store, so keep their vocabulary + // until this run succeeds and rebuild it from all surviving names below. + if (freshDb) { + try { this.queries.clearNameSegmentVocab(); } catch { /* vocab is advisory — never fail an index over it */ } + } // Bulk FTS mode for the mass-insert phase: drop the per-row FTS sync // triggers, rebuild nodes_fts once from the nodes table afterwards. // Crash inside the window is healed on the next DatabaseConnection.open. @@ -772,6 +774,13 @@ export class CodeGraph { } } catch { /* metadata is advisory — never fail an index over it */ } + if (result.success && !freshDb) { + try { + this.queries.clearNameSegmentVocab(); + await this.rebuildNameSegmentVocab(); + } catch { /* vocab is advisory — never fail an index over it */ } + } + return result; } finally { // Restore the auto-checkpoint interval AFTER the fold-up above so the