fix(sync): refresh synthesized edges after incremental changes (#1988) (#2033)

* fix(sync): refresh synthesized edges after incremental changes (#1988)

Scoped sync skipped synthesis and retained obsolete dispatch edges.
Refresh all synthesis passes after base resolution when dependencies or source patterns change.
Stage and atomically replace owned edges, preserve ordinary edges, and retry interrupted refreshes.
Stabilize traversal order and migrate existing indexes for rebuild convergence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: preserve structural Go containment during synthesis refresh

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-27 13:41:49 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 9649ce1bd6
commit 2a2f71ec41
12 changed files with 570 additions and 38 deletions
+46 -5
View File
@@ -35,7 +35,7 @@ import { tanstackLinkEdges } from './tanstack-router-synthesizer';
import { vueRouterLinkEdges } from './vue-router-synthesizer';
import { svelteKitLinkEdges, svelteKitPageComponentEdges } from './sveltekit-synthesizer';
import { createYielder, type MaybeYield } from './cooperative-yield';
import { crossTierEdges } from './tier-synthesizer';
import { crossTierEdges, hasCrossTierPattern } from './tier-synthesizer';
import { enclosingFn, makeLineAt } from './synth-utils';
import { resolveImportPath } from './import-resolver';
import { crossesCodeBoundary } from './name-matcher';
@@ -889,9 +889,10 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr
*
* Go guarantees a method's receiver type is declared in the SAME PACKAGE as the
* method, and a Go package is a single directory — so this is a deterministic
* structural link, not a heuristic: find the same-named type in the method's own
* directory and add the missing `contains` edge (no `provenance: 'heuristic'`,
* matching the same-file edges extraction already emits). Skips methods that
* structural link, not a heuristic: find the same-named type in the method's
* own directory and add the missing `contains` edge with no provenance, matching
* same-file extraction. Tag synthesis ownership so an incremental
* refresh can replace it alongside implicit `implements`. Skips methods that
* already have a type parent (the same-file case). (#583, cross-file half)
*/
async function goCrossFileMethodContainsEdges(queries: QueryBuilder, onYield: MaybeYield): Promise<Edge[]> {
@@ -940,7 +941,8 @@ async function goCrossFileMethodContainsEdges(queries: QueryBuilder, onYield: Ma
const key = `${owner.id}>${method.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({ source: owner.id, target: method.id, kind: 'contains', line: method.startLine });
edges.push({ source: owner.id, target: method.id, kind: 'contains', line: method.startLine,
metadata: { synthesizedBy: 'go-method-contains' } });
}
return edges;
}
@@ -3608,6 +3610,36 @@ export interface SynthPassDef {
const ALWAYS = (): boolean => true;
/** Conservative input gates for SYNTH_PASSES; keep these in sync when adding a pass. */
export function hasSynthesisPattern(filePath: string, content: string): boolean {
// These passes consume declarations/layouts as well as dispatch sites. A
// header or markup edit can change a channel whose endpoints live elsewhere.
if (/\.(?:vue|svelte|dfm|fmx|nix|xml)$/.test(filePath)) return true;
if (/\.(?:c|h|cc|cpp|cxx|hpp|hh|hxx|cppm|ipp|inl|tcc|def|inc|tbl)$/i.test(filePath) &&
/\b(?:struct|union|typedef|virtual|override)\b|#\s*(?:include|define|if)|=|->|\[/.test(content)) return true;
if (/\b(?:class|interface|protocol|trait|impl|extends|implements|expect|actual)\b/.test(content)) return true;
if (/\.go$/.test(filePath) && /\b(?:struct|interface)\b|\bfunc\s*\(/.test(content)) return true;
if (hasCrossTierPattern(content)) return true;
if (/\b(?:render|build|setState|defineStore|createStore|createApi|Store|href|sendEvent|sendEventWithName)\b|<\/|\/>/.test(content)) return true;
if (/\.(?:forEach|append|add|push|insert|fire|dispatchEvent|addListener|Use|GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Any|Handle)\s*\(/.test(content)) return true;
if (/[\w$]\s*\[\s*[A-Za-z_$]/.test(content) || /\b(?:dispatch|commit)\s*\(/.test(content)) return true;
const patterns = [THUNK_DECL_RE, CELERY_TASK_DECORATOR_RE, CELERY_DISPATCH_RE,
SPRING_LISTENER_ANNO_RE, SPRING_APP_LISTENER_RE, SPRING_PUBLISH_RE,
MEDIATR_HANDLER_BASE_RE, MEDIATR_DISPATCH_RE, SIDEKIQ_WORKER_RE, SIDEKIQ_DISPATCH_RE,
ERLANG_CALLBACK_DECL_RE, ERLANG_DISPATCH_RE, LARAVEL_DISPATCH_RE, ARKUI_EMITTER_CALL_RE,
ARKUI_ROUTER_RE];
for (const re of patterns) {
re.lastIndex = 0;
const matches = re.test(content);
re.lastIndex = 0;
if (matches) return true;
}
// Field-backed observers use method-name gates rather than fixed call names.
return (content.match(/[A-Za-z_$][\w$]*/g) ?? []).some(
(name) => REGISTRAR_NAME.test(name) || DISPATCHER_NAME.test(name)
);
}
/**
* The independent passes, in MERGE ORDER — the first-seen dedup in
* synthesizeCallbackEdges follows this array, so reordering entries changes
@@ -3883,6 +3915,15 @@ export async function synthesizeCallbackEdges(
await yieldToLoop();
await foldIfOver();
}
// Remember source gates, including inputs that currently produce NO edges
// (e.g. an over-cap channel). Deleting one may make the full pass viable.
const inputs: string[] = [];
for (const file of ctx.getAllFiles()) {
const content = ctx.readFile(file);
if (content !== null && hasSynthesisPattern(file, content)) inputs.push(file);
await yieldToLoop();
}
queries.replaceSynthesisInputs(inputs);
__mark('insertMergedEdges');
return merged.length + goImpl.length + goMethodContains.length;
}
+23 -2
View File
@@ -8,6 +8,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { Language, Node, UnresolvedReference, Edge } from '../types';
import { QueryBuilder } from '../db/queries';
import { SynthesisStage } from '../db/synthesis-stage';
import {
UnresolvedRef,
ResolvedRef,
@@ -1683,7 +1684,8 @@ export class ReferenceResolver {
* each per-batch DELETE's B-tree work (DatabaseConnection.beginBulkRefLoad). */
refIndexLoad?: { begin: () => void; end: () => void | Promise<void> };
backpressure?: () => Promise<void> | null;
}
},
synthesize: boolean = true
): Promise<ResolutionResult> {
// Resolution runs on the indexer's MAIN thread, and the #850 liveness
// watchdog SIGKILLs a process whose event loop stalls past its window (60s
@@ -2105,7 +2107,7 @@ export class ReferenceResolver {
// loop. See docs/design/callback-edge-synthesis.md.
const tSynth = Date.now();
try {
aggregateStats.byMethod['callback-synthesis'] = await synthesizeCallbackEdges(
if (synthesize) aggregateStats.byMethod['callback-synthesis'] = await synthesizeCallbackEdges(
this.queries,
this.context,
onSynthesisProgress,
@@ -2133,6 +2135,25 @@ export class ReferenceResolver {
};
}
/** Replace synthesis only after every base-resolution pass has finished. */
async refreshSynthesis(
dbPath: string,
onProgress?: (done: number, total: number) => void,
backpressure?: () => Promise<void> | null
): Promise<number> {
this.clearCaches();
const stage = new SynthesisStage(dbPath);
try {
const fresh = new ReferenceResolver(this.projectRoot, stage.queries);
const count = await synthesizeCallbackEdges(stage.queries, fresh.context, onProgress, null, backpressure);
await stage.publish(backpressure);
return count;
} finally {
stage.close();
this.clearCaches();
}
}
/**
* Get detected frameworks
*/
+4
View File
@@ -880,6 +880,10 @@ const HTTP_GATE = /\b(?:fetch|\$fetch|ofetch|axios|ky|got|useFetch|useSWR)\b|\.\
const QUEUE_GATE = /\.\s*add\s*\(|@Processor\s*\(|\bnew\s+Worker\s*[<(]|\.\s*process\s*\(/;
const EVENT_GATE = /\.\s*(?:emit|emitAsync|on|once)\s*\(|@OnEvent\s*\(|@SubscribeMessage\s*\(/;
export function hasCrossTierPattern(content: string): boolean {
return HTTP_GATE.test(content) || QUEUE_GATE.test(content) || EVENT_GATE.test(content);
}
export async function crossTierEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
const routes = httpRoutes(ctx);
const httpSites: HttpSite[] = [];