fix(prompt-hook): close the segment-vocab integrity gaps (#1141, #1142, #1144, #1145, #1146) (#1150)

Five hardening fixes to the #1136 MEDIUM (graph-derived) tier:

- #1141: updateNode() now writes the segment vocabulary like insertNode()
  does — framework post-extract renames (NestJS route prefixing) left the
  new name permanently unsearchable (the old rows orphaned, the backfill
  gated on an EMPTY vocab, so even a full re-index re-created the drift).
- #1142: new CodeGraph.healSegmentVocabIfEmpty() — the hook opens the
  graph without sync, so a database migrated from pre-vocab schema kept
  the MEDIUM tier dormant until some unrelated sync ran. The hook heals
  on first use (one SELECT when populated; lock-aware, defers to a
  running sync) and records noop-vocab-empty when it can't.
- #1144: a name whose only nodes are file/import kind is skipped instead
  of falling back to surfacing an import statement as a matched symbol;
  import specifiers no longer enter the vocab at all (shared
  isSegmentableKind gate across insertNode/updateNode/rebuild page query)
  since they can never be surfaced and only inflate rarity statistics.
- #1145: plural variant folding is keyed on English plural spelling —
  bare-s plurals no longer mint a bogus -es sibling (services→servic),
  unambiguous sibilant-es plurals no longer mint a bogus -s sibling
  (classes→classe), trailing -ss singulars no longer strip (class→clas);
  genuinely ambiguous endings (caches/databases) still emit both keys.
- #1146: getSegmentCoOccurrence folds variants to their original word
  inside the SQL (CASE mapping + COUNT(DISTINCT word)) so a plural pair
  of ONE word can't tie with a genuine two-word match and crowd it past
  the pre-fold ORDER BY/LIMIT; the JS re-check stays as the honesty layer.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-02 17:23:54 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent be55b93d02
commit 35611b92bb
7 changed files with 233 additions and 20 deletions
+77
View File
@@ -141,4 +141,81 @@ export function writeConfig(): void {}
// UNTOUCHED file's names must come from the backfill.
expect(cg.getSegmentMatches(['checkout']).map((m) => m.name)).toContain('CheckoutService');
});
it('healSegmentVocabIfEmpty backfills WITHOUT a sync — the prompt-hook open path (#1142)', async () => {
// The hook opens the graph without syncing, and a database migrated from
// before the vocab table existed starts with it empty — sync's backfill
// never runs on that path, leaving the MEDIUM tier permanently dormant.
const queries = (cg as unknown as {
queries: { clearNameSegmentVocab(): void; isNameSegmentVocabEmpty(): boolean };
}).queries;
queries.clearNameSegmentVocab();
expect(queries.isNameSegmentVocabEmpty()).toBe(true);
await expect(cg.healSegmentVocabIfEmpty()).resolves.toBe(true);
expect(queries.isNameSegmentVocabEmpty()).toBe(false);
expect(cg.getSegmentMatches(['state', 'machine']).map((m) => m.name)).toContain('OrderStateMachine');
// Populated vocab: the fast path (one SELECT) still reports usable.
await expect(cg.healSegmentVocabIfEmpty()).resolves.toBe(true);
});
it('a rename through updateNode reaches the vocab — the framework post-extract path (#1141)', () => {
// Framework resolvers rewrite node names after extraction (NestJS route
// prefixing) via updateNode. The new name must become prose-searchable;
// the old name's rows become orphans the honesty gate drops.
const queries = (cg as unknown as {
queries: {
getNodesByName(name: string): Array<Record<string, unknown>>;
updateNode(node: Record<string, unknown>): void;
};
}).queries;
const node = queries.getNodesByName('OrderStateMachine')[0]!;
queries.updateNode({ ...node, name: 'RenamedWorkflowEngine', qualifiedName: 'RenamedWorkflowEngine' });
expect(cg.getSegmentMatches(['renamed', 'workflow']).map((m) => m.name)).toContain('RenamedWorkflowEngine');
expect(cg.getSegmentMatches(['state', 'machine'])).toEqual([]);
});
it('a name that exists only as an import statement is never surfaced (#1144)', async () => {
// Import nodes are named after module specifiers, not symbols. The write
// path no longer segments them; and even against legacy vocab rows (a DB
// populated before that exclusion), the representative picker must skip
// the name rather than surface the import line as a matched symbol.
fs.writeFileSync(
path.join(dir, 'src', 'consumer.ts'),
`import { Thing } from 'external-unindexed-pkg';\nexport function useIt(): void {}\n`,
);
await cg.sync();
expect(cg.getSegmentMatches(['external', 'unindexed'])).toEqual([]);
// Legacy rows: plant the vocab entries a pre-exclusion version wrote.
const queries = (cg as unknown as { queries: { insertNameSegmentsBatch(names: string[]): void } }).queries;
queries.insertNameSegmentsBatch(['external-unindexed-pkg']);
expect(cg.getSegmentMatches(['external', 'unindexed'])).toEqual([]);
});
it('co-occurrence counts distinct WORDS, not variants — plural pairs cannot pose as two words (#1146)', () => {
const queries = (cg as unknown as {
queries: {
insertNameSegmentsBatch(names: string[]): void;
getSegmentCoOccurrence(
variants: Array<{ segment: string; word: string }>,
minWords: number,
limit: number,
): Array<{ name: string; matches: number }>;
};
}).queries;
// BillingServicesService carries BOTH the `services` and `service`
// segments — two variants of ONE prompt word. It must not meet minWords=2.
queries.insertNameSegmentsBatch(['BillingServicesService']);
const hits = queries.getSegmentCoOccurrence(
[
{ segment: 'services', word: 'services' },
{ segment: 'service', word: 'services' },
{ segment: 'checkout', word: 'checkout' },
],
2,
24,
);
const names = hits.map((h) => h.name);
expect(names).toContain('CheckoutService'); // checkout + service(s) — two real words
expect(names).not.toContain('BillingServicesService'); // services + service — one word
});
});