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:
co-authored by
Claude Fable 5
parent
be55b93d02
commit
35611b92bb
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user