diff --git a/__tests__/evaluation/results/precision-svelte-5895c63-dd02b58a.json b/__tests__/evaluation/results/precision-svelte-5895c63-dd02b58a.json
new file mode 100644
index 000000000..031c2a920
--- /dev/null
+++ b/__tests__/evaluation/results/precision-svelte-5895c63-dd02b58a.json
@@ -0,0 +1,35 @@
+{
+ "timestamp": "2026-10-28T16:17:05.201Z",
+ "corpus": "svelte",
+ "repo": "https://github.com/sveltejs/svelte.git",
+ "commit": "5895c63",
+ "codegraphSha": "dd02b58a",
+ "summary": {
+ "absentCases": 1,
+ "absentHeld": 1,
+ "presentCases": 0,
+ "presentHeld": 0,
+ "unjudgeable": 0
+ },
+ "resolvedBy": {
+ "(none)": 30920,
+ "import": 20057,
+ "exact-match": 10539,
+ "qualified-name": 1874,
+ "instance-method": 390,
+ "framework": 220,
+ "function-ref": 196,
+ "fuzzy": 73,
+ "file-path": 58
+ },
+ "edges": 64327,
+ "results": [
+ {
+ "caseId": "svelte-bare-import-bundle",
+ "expect": "absent",
+ "pass": true,
+ "found": [],
+ "missingEndpoints": []
+ }
+ ]
+}
\ No newline at end of file
diff --git a/__tests__/evaluation/results/precision-vite-8492422-dd02b58a.json b/__tests__/evaluation/results/precision-vite-8492422-dd02b58a.json
new file mode 100644
index 000000000..c03db209e
--- /dev/null
+++ b/__tests__/evaluation/results/precision-vite-8492422-dd02b58a.json
@@ -0,0 +1,95 @@
+{
+ "timestamp": "2026-10-28T16:16:50.912Z",
+ "corpus": "vite",
+ "repo": "https://github.com/vitejs/vite.git",
+ "commit": "8492422",
+ "codegraphSha": "dd02b58a",
+ "summary": {
+ "absentCases": 6,
+ "absentHeld": 6,
+ "presentCases": 2,
+ "presentHeld": 2,
+ "unjudgeable": 0
+ },
+ "resolvedBy": {
+ "(none)": 13392,
+ "import": 11109,
+ "exact-match": 3413,
+ "qualified-name": 1009,
+ "instance-method": 457,
+ "file-path": 197,
+ "function-ref": 165,
+ "framework": 2
+ },
+ "edges": 29744,
+ "results": [
+ {
+ "caseId": "vite-factory-receiver-control",
+ "expect": "present",
+ "pass": true,
+ "found": [
+ {
+ "from": "function:d321dd128d90578bc69867835ec73ef8",
+ "to": "method:db73e5f5d9c7af61d0f22c5b9971ced3",
+ "resolvedBy": "instance-method"
+ }
+ ],
+ "missingEndpoints": []
+ },
+ {
+ "caseId": "vite-external-receiver-not-project-method",
+ "expect": "absent",
+ "pass": true,
+ "found": [],
+ "missingEndpoints": []
+ },
+ {
+ "caseId": "vite-bare-import-self-edge",
+ "expect": "absent",
+ "pass": true,
+ "found": [],
+ "missingEndpoints": []
+ },
+ {
+ "caseId": "vite-bare-import-getEnv",
+ "expect": "absent",
+ "pass": true,
+ "found": [],
+ "missingEndpoints": []
+ },
+ {
+ "caseId": "vite-fuzzy-nested-resolveConfig-getEnv",
+ "expect": "absent",
+ "pass": true,
+ "found": [],
+ "missingEndpoints": []
+ },
+ {
+ "caseId": "vite-fuzzy-nested-build-scan",
+ "expect": "absent",
+ "pass": true,
+ "found": [],
+ "missingEndpoints": []
+ },
+ {
+ "caseId": "vite-sealed-module-defineConfig",
+ "expect": "absent",
+ "pass": true,
+ "found": [],
+ "missingEndpoints": []
+ },
+ {
+ "caseId": "vite-control-relative-import",
+ "expect": "present",
+ "pass": true,
+ "found": [
+ {
+ "from": "file:packages/vite/src/node/config.ts",
+ "to": "function:0c2f36c32c5742aee412978220fd5b23",
+ "resolvedBy": "import"
+ }
+ ],
+ "missingEndpoints": []
+ }
+ ]
+}
\ No newline at end of file
diff --git a/__tests__/sync-resynthesis.test.ts b/__tests__/sync-resynthesis.test.ts
index 4bc259c46..9f05190d1 100644
--- a/__tests__/sync-resynthesis.test.ts
+++ b/__tests__/sync-resynthesis.test.ts
@@ -176,6 +176,29 @@ describe('synthesized edges on incremental sync', () => {
await expect(cg.sync({ requireComplete: true })).rejects.toThrow('requires synthesized-edge refresh');
});
+ it('skips re-synthesis for an edit that touches no synthesized wiring or input pattern', async () => {
+ write('src/util.ts', 'export function add(a: number, b: number) {\n return a + b;\n}\n');
+ await cg.sync({ requireComplete: true });
+ const resolver = (cg as unknown as { resolver: { synthesisRuns: number } }).resolver;
+ const before = resolver.synthesisRuns;
+ write('src/util.ts', 'export function add(a: number, b: number) {\n // sum\n return a + b;\n}\n');
+ await cg.sync({ requireComplete: true });
+ expect(resolver.synthesisRuns).toBe(before);
+ // Edges the skipped run left alone are still served.
+ expect(callees('publish')).toContain('onSaved');
+ expect(callees('loadRepo')).toContain('GET https://api.github.com/repos/${…}');
+ });
+
+ it('re-synthesizes when an edited file matches an input pattern it had no edge for', async () => {
+ write('src/ticker.ts', "import { bus } from './bus';\n\nexport function tick() {\n return 1;\n}\n");
+ await cg.sync({ requireComplete: true });
+ const resolver = (cg as unknown as { resolver: { synthesisRuns: number } }).resolver;
+ const before = resolver.synthesisRuns;
+ write('src/ticker.ts', "import { bus } from './bus';\n\nexport function tick() {\n bus.emit('tick', 1);\n}\n");
+ await cg.sync({ requireComplete: true });
+ expect(resolver.synthesisRuns).toBeGreaterThan(before);
+ });
+
it('acknowledges current wiring across every refresh-ending sequence of up to three events', async () => {
process.env.CODEGRAPH_SYNTH_REFRESH_MS = '60000';
const attached = "import { bus } from './bus';\nimport { onSaved } from './handlers';\nexport function wire() { bus.on('saved', onSaved); }\n";
@@ -203,3 +226,42 @@ describe('synthesized edges on incremental sync', () => {
expect(failures).toEqual([]);
}, 20000);
});
+
+describe('synthesis gate on JSX definition deltas', () => {
+ let dir: string;
+ let cg: CodeGraph;
+ const write = (rel: string, body: string) => {
+ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
+ fs.writeFileSync(path.join(dir, rel), body);
+ };
+ const callees = (fn: string) => {
+ const node = cg.getNodesByName(fn).find((n) => n.kind === 'function')!;
+ return cg.getCallees(node.id).map((c) => c.node.name).sort();
+ };
+
+ beforeEach(async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-resynth-jsx-'));
+ write('src/App.tsx', "import { Team } from './components';\n\nexport function App() {\n return ;\n}\n");
+ write('src/components.ts', 'export {};\n');
+ cg = CodeGraph.initSync(dir, { config: { include: ['**/*.{ts,tsx}'], exclude: [] } });
+ await cg.indexAll();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ syncBuiltinESMExports();
+ try { cg.close(); } catch { /* ignore */ }
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ it('re-synthesizes when a changed file adds a component a tag elsewhere names', async () => {
+ const resolver = (cg as unknown as { resolver: { synthesisRuns: number } }).resolver;
+ const before = resolver.synthesisRuns;
+ // Non-pattern content (a .ts file, no markup, not a barrel): only the
+ // definition delta — `Team` is a JSX child candidate — can gate this in.
+ write('src/components.ts', "const label = 'team';\nexport function Team() {\n return label;\n}\n");
+ await cg.sync({ requireComplete: true });
+ expect(resolver.synthesisRuns).toBeGreaterThan(before);
+ expect(callees('App')).toContain('Team');
+ });
+});
diff --git a/src/codegraph.ts b/src/codegraph.ts
index 2d672db21..74e7e42db 100644
--- a/src/codegraph.ts
+++ b/src/codegraph.ts
@@ -1062,8 +1062,25 @@ export class CodeGraph {
const pythonFilesBefore = this.pythonFilePaths();
const watchRoutes = this.resolver.hasNavigationRouters();
let routesBefore: Node[] | null = null;
- const result = await this.orchestrator.sync(options.onProgress, options.paths, backpressure, () => {
+ // Files touching synthesized edges, snapshotted once on first use: a
+ // per-file lookup would rescan the edges table for every changed file.
+ let touchedBySynthesis: Set | null = null;
+ const touchesSynthesis = (p: string) =>
+ (touchedBySynthesis ??= this.queries.filesTouchingSynthesizedEdges()).has(p);
+ const result = await this.orchestrator.sync(options.onProgress, options.paths, backpressure, (filePath, content) => {
if (watchRoutes && routesBefore === null) routesBefore = this.queries.getNodesByKind('route');
+ // A changed file only dirties synthesis when it carried synthesized
+ // wiring (read BEFORE replacement cascades its edges), its last-seen
+ // content fed a pass (the input row is still here pre-delete; edges
+ // may already be gone, e.g. a channel dropped over the fan-out cap),
+ // or its new content still feeds a pass — the unconditional mark cost
+ // a full resynthesis on every save. Removed files get the same check.
+ if (process.env.CODEGRAPH_SYNC_RESYNTHESIS !== '0' &&
+ (touchesSynthesis(filePath) ||
+ this.queries.wasSynthesisInput(filePath) ||
+ (content !== undefined && resolution().mayFeedSynthesis(filePath, content)))) {
+ this.synthesisDirty.add(filePath);
+ }
});
// Fold the store phase's WAL BEFORE the post-store reads below
@@ -1458,8 +1475,24 @@ export class CodeGraph {
const pureRemoval = !filesChanged && result.filesRemoved > 0;
const resynthesis = (scopedChange || pureRemoval) && process.env.CODEGRAPH_SYNC_RESYNTHESIS !== '0';
if (resynthesis) {
- for (const p of result.changedFilePaths ?? []) this.synthesisDirty.add(p);
- for (const p of result.removedFilePaths ?? []) this.synthesisDirty.add(p);
+ // A removed file's endpoint nodes are gone by now, but registrations
+ // it held still name it in edge metadata until reconcile — that is
+ // #138/#197's case exactly, and hasSynthesizedEdgesTouchingFile's
+ // registeredAt leg still answers it.
+ for (const p of result.removedFilePaths ?? []) {
+ if (touchesSynthesis(p)) this.synthesisDirty.add(p);
+ }
+ // A component a tag in an unchanged file may render: `` links
+ // to the `Team` this sync adds. A component with no markup of its own
+ // (`return null`, a wrapper) matches none of the input patterns; the
+ // definition delta gates it instead. Removing one needs no check: a
+ // component a tag rendered had synthesized edges into its file.
+ if (result.definitionDelta && result.changedFilePaths) {
+ const delta = new Set(result.definitionDelta);
+ if (this.queries.getNodesByFiles(result.changedFilePaths).some((n) => delta.has(n.name) && resolution().isJsxChildCandidate(n))) {
+ for (const p of result.changedFilePaths) this.synthesisDirty.add(p);
+ }
+ }
}
// Refs the scoped path deferred to the passes below are still pending
@@ -1504,6 +1537,7 @@ export class CodeGraph {
result.pendingRefsUnresolved = recovery.stats.unresolved;
if (this.resolver.synthesisRuns > synthesisRuns) {
this.queries.reconcileSynthesizedEdges(this.resolver.lastSynthesizedEdges);
+ this.resolver.lastSynthesizedEdges = [];
this.synthesisDirty.clear();
} else if (this.synthesisDirty.size > 0) {
// Refresh attempted and failed: drop the stale synthesized edges,
@@ -1904,6 +1938,11 @@ export class CodeGraph {
const edges = await this.resolver.resynthesize((current, total) =>
onProgress?.({ phase: 'linking', current, total }));
const { deleted, updated } = this.queries.reconcileSynthesizedEdges(this.resolver.lastSynthesizedEdges);
+ // The emitted set and the warm caches a daemon's pass just filled are
+ // dead weight until the next rebuild — drop them rather than let the
+ // daemon's heap hold a whole-graph working set at idle.
+ this.resolver.lastSynthesizedEdges = [];
+ this.resolver.clearCaches();
this.lastSynthesisMs = Date.now() - t;
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[synth-timing] sync-resynthesis: ${Date.now() - t}ms (${files.length} files, ${deleted} pruned, ${updated} updated, ${edges} synthesized)`);
} catch (error) {
diff --git a/src/db/migrations.ts b/src/db/migrations.ts
index 1a37aa8fb..4b5f44e70 100644
--- a/src/db/migrations.ts
+++ b/src/db/migrations.ts
@@ -10,7 +10,7 @@ import { referenceNameTail, MEMBER_TAIL_SQL, MODULE_PARKED_SQL } from './referen
/**
* Current schema version
*/
-export const CURRENT_SCHEMA_VERSION = 22;
+export const CURRENT_SCHEMA_VERSION = 23;
/**
* Migration definition
@@ -432,6 +432,22 @@ CREATE TABLE synth_skips (
`);
},
},
+ {
+ version: 23,
+ description:
+ 'Add synthesis_inputs — files whose content fed the last synthesis run, so a later sync can tell an input was removed',
+ up: (db) => {
+ // DDL only. No backfill: the rows come from file CONTENT the migration
+ // cannot see, so the table stays empty until the next synthesis run and
+ // the gate falls back on the edge-touching check until then. Keep in
+ // lockstep with schema.sql.
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS synthesis_inputs (
+ file_path TEXT PRIMARY KEY REFERENCES files(path) ON DELETE CASCADE
+ );
+ `);
+ },
+ },
];
/**
diff --git a/src/db/queries.ts b/src/db/queries.ts
index 1fca97838..17e68fb2d 100644
--- a/src/db/queries.ts
+++ b/src/db/queries.ts
@@ -5733,6 +5733,56 @@ export class QueryBuilder {
return Number(r.changes ?? 0);
}
+ /** Must run before file replacement/deletion cascades the endpoint edges. */
+ hasSynthesizedEdgesTouchingFile(filePath: string): boolean {
+ const owned = "CASE WHEN json_valid(e.metadata) THEN json_extract(e.metadata, '$.synthesizedBy') END IS NOT NULL";
+ for (const endpoint of ['source', 'target']) {
+ if (this.db.prepare(`SELECT 1 FROM nodes n JOIN edges e ON e.${endpoint} = n.id
+ WHERE n.file_path = ? AND ${owned} LIMIT 1`).get(filePath)) return true;
+ }
+ // Wiring often lives in a third file, with neither endpoint in it.
+ return !!this.db.prepare(`SELECT 1 FROM edges e WHERE ${owned}
+ AND CASE WHEN json_valid(e.metadata) THEN json_extract(e.metadata, '$.registeredAt') END >= ?
+ AND CASE WHEN json_valid(e.metadata) THEN json_extract(e.metadata, '$.registeredAt') END < ? LIMIT 1`
+ ).get(`${filePath}:`, `${filePath};`);
+ }
+
+ /**
+ * Every file carrying or registering synthesized edges — the bulk form of
+ * hasSynthesizedEdgesTouchingFile for a sync that checks many files: one
+ * edges scan instead of one per file.
+ */
+ filesTouchingSynthesizedEdges(): Set {
+ const owned = "CASE WHEN json_valid(e.metadata) THEN json_extract(e.metadata, '$.synthesizedBy') END IS NOT NULL";
+ const registeredAt = "CASE WHEN json_valid(e.metadata) THEN json_extract(e.metadata, '$.registeredAt') END";
+ const rows = this.db.prepare(
+ `SELECT DISTINCT n.file_path AS f FROM nodes n JOIN edges e ON e.source = n.id OR e.target = n.id WHERE ${owned}
+ UNION SELECT DISTINCT rtrim(rtrim(rtrim(rtrim(${registeredAt}, '0123456789'), ':'), '0123456789'), ':') AS f
+ FROM edges e WHERE ${owned} AND ${registeredAt} IS NOT NULL`
+ ).all() as { f: string }[];
+ return new Set(rows.map((r) => r.f));
+ }
+
+ /**
+ * Whether this file's content fed the last completed synthesis run. Asked
+ * BEFORE the file row deletes: the input row cascades away with it.
+ */
+ wasSynthesisInput(filePath: string): boolean {
+ return !!this.db.prepare('SELECT 1 FROM synthesis_inputs WHERE file_path = ?').get(filePath);
+ }
+
+ /** Replace the synthesis-input set with the files a completed run read. */
+ replaceSynthesisInputs(files: readonly string[]): void {
+ const tx = this.db.transaction((list: readonly string[]) => {
+ this.db.exec('DELETE FROM synthesis_inputs');
+ const insert = this.db.prepare(
+ 'INSERT OR IGNORE INTO synthesis_inputs(file_path) SELECT path FROM files WHERE path = ?'
+ );
+ for (const f of list) insert.run(f);
+ });
+ tx(files);
+ }
+
/**
* Reconcile stored synthesized edges with the set a refresh just emitted,
* keyed on the idx_edges_identity columns. Kept edges stay put — unlike
diff --git a/src/db/schema.sql b/src/db/schema.sql
index 4c5303c89..8073789c7 100644
--- a/src/db/schema.sql
+++ b/src/db/schema.sql
@@ -303,3 +303,9 @@ CREATE TABLE IF NOT EXISTS synth_skips (
content_hash TEXT NOT NULL,
passes TEXT NOT NULL -- space-separated pass names
) WITHOUT ROWID;
+
+-- Files whose last-seen content fed a synthesis pass, so a later sync can tell
+-- an input was removed or dropped its pattern after its edges are already gone.
+CREATE TABLE IF NOT EXISTS synthesis_inputs (
+ file_path TEXT PRIMARY KEY REFERENCES files(path) ON DELETE CASCADE
+);
diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts
index bcfc44ab7..fb6c060ce 100644
--- a/src/resolution/callback-synthesizer.ts
+++ b/src/resolution/callback-synthesizer.ts
@@ -40,7 +40,7 @@ import { vueRouterLinkEdges } from './vue-router-synthesizer';
import { angularTemplateEdges } from './angular-template-synthesizer';
import { svelteKitLinkEdges, svelteKitPageComponentEdges } from './sveltekit-synthesizer';
import { createYielder, type MaybeYield } from './cooperative-yield';
-import { crossTierEdges, endpointNodesFor, testRequestEdges } from './tier-synthesizer';
+import { crossTierEdges, endpointNodesFor, hasCrossTierPattern, hasTestRequestPattern, testRequestEdges } from './tier-synthesizer';
import { enclosingFn, enclosingValue, makeLineAt, matchBalanced } from './synth-utils';
import { rnModuleMethods } from './frameworks/react-native';
import { resolveImportPath } from './import-resolver';
@@ -7119,6 +7119,83 @@ 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;
+ // Drupal hook wiring lives in .module/.install/.theme/.inc and *_alter / invokeAll sites.
+ if (/\.(?:module|install|theme|inc)$/.test(filePath) || /\b(?:invokeAll|module_implements|drupal_alter|module_invoke)\s*\(/.test(content)) return true;
+ // Every Go file: besides the declarations the interface passes read, a call
+ // through a func field, or a function put in one, can sit in any of them.
+ if (/\.go$/.test(filePath)) return true;
+ if (hasCrossTierPattern(content) || hasTestRequestPattern(filePath, content)) return true;
+ if (/\b(?:render|build|setState|defineStore|createStore|createApi|Store|href|sendEvent|sendEventWithName)\b|<\/|\/>/.test(content)) return true;
+ // A wrapper renders the component it wraps (jsxEdges), in a file with no markup of its own too.
+ if (WRAPPER_CALL_RE.test(content)) return true;
+ // windowMessageEdges (postMessage/addEventListener) and ngrxEdges (dispatch/ofType/@ngrx).
+ if (/\bpostMessage\s*\(|@ngrx|\bofType\s*\(|\bcreateEffect\s*\(/.test(content)) return true;
+ // rnDynamicModuleEdges / fabric / expo passes read the native-module names.
+ if (/\b(?:NativeModules|TurboModuleRegistry|requireNativeModule|NativeEventEmitter)\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,
+ SIDEKIQ_JOBS_ENQUEUE_RE, SIDEKIQ_JOBS_ENQUEUE_TIMED_RE, SIDEKIQ_PUSH_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)
+ );
+}
+
+const SYNTH_MODULE_FILE = /\.(?:[cm]?jsx|tsx|vue|svelte|astro)$/;
+// Re-exports only (`export {X} from`, `export *`): the barrel shapes that
+// rewire which module a name resolves through. `export type` carries no
+// synthesized-edge endpoint; a local `export const`/definition is covered by
+// the definition delta instead.
+const RE_EXPORT_LINE = /^\s*export\s+(?!type\b)(?:\*|\{[^}]*\}\s*from\b)/;
+
+/**
+ * Whether an edited file can feed a synthesis pass — the input gates above,
+ * plus the two shapes `wasSynthesisInput` tracked upstream that carry no
+ * pattern of their own: a markup-capable module (a tag or lazy() can name its
+ * component), and a barrel whose re-export lines rewire which module a
+ * tag resolves to. Files that only export plain values stay out: changing
+ * them can't move an edge's endpoint.
+ */
+export function mayFeedSynthesis(filePath: string, content: string): boolean {
+ if (hasSynthesisPattern(filePath, content)) return true;
+ if (SYNTH_MODULE_FILE.test(filePath)) return true;
+ const lines = content.split('\n').filter((l) => l.trim() !== '');
+ return lines.length > 0 && lines.filter((l) => RE_EXPORT_LINE.test(l)).length / lines.length > 0.6;
+}
+
+/**
+ * The files a completed synthesis run could have consumed — recorded into
+ * synthesis_inputs so a later sync knows a file was an input even after its
+ * edges dropped out (a channel crossing the fan-out cap) or the file is gone.
+ */
+export function collectSynthesisInputs(ctx: ResolutionContext): string[] {
+ const inputs: string[] = [];
+ for (const file of ctx.getAllFiles()) {
+ const content = ctx.readFile(file);
+ if (content && mayFeedSynthesis(file, content)) inputs.push(file);
+ }
+ return inputs;
+}
+
/**
* The independent passes, in MERGE ORDER — the first-seen dedup in
* synthesizeCallbackEdges follows this array, so reordering entries changes
diff --git a/src/resolution/index.ts b/src/resolution/index.ts
index c83aa8484..f556037d0 100644
--- a/src/resolution/index.ts
+++ b/src/resolution/index.ts
@@ -36,7 +36,7 @@ import { GO_TYPE_KINDS, isGoBareName, isGoUnknownQualified, goTypePositionTarget
import { ResolverPool, adaptiveEngageEnabled, minRefsForPool, shouldEngageAdaptively } from './resolver-pool';
import { resolveAliasBinding } from './alias-binding';
import { detectFrameworksWithSkips, getResolvingFrameworks } from './frameworks';
-import { synthesizeCallbackEdges } from './callback-synthesizer';
+import { collectSynthesisInputs, synthesizeCallbackEdges } from './callback-synthesizer';
import { createYielder, type MaybeYield } from './cooperative-yield';
import { MAX_SOURCE_FILE_SIZE_BYTES } from '../file-limits';
import { loadProjectAliases, loadScopedAliases, scopedAliasesForFile, type AliasMap, type AliasScope } from './path-aliases';
@@ -104,6 +104,7 @@ function cppConstructorForType(type: Node, queries: QueryBuilder): Node | null {
// Re-export types
export * from './types';
+export { hasSynthesisPattern, isJsxChildCandidate, mayFeedSynthesis } from './callback-synthesizer';
/**
* Reference Resolver
@@ -418,7 +419,9 @@ export class ReferenceResolver {
this.clearCaches();
const emitted: { edges?: Edge[] } = {};
const edges = await synthesizeCallbackEdges(this.queries, this.context, onProgress, undefined, undefined, emitted);
+ this.synthesisRuns++;
this.lastSynthesizedEdges = emitted.edges ?? [];
+ this.queries.replaceSynthesisInputs(collectSynthesisInputs(this.context));
this.queries.setSynthesisPending(false);
return edges;
}
@@ -2850,6 +2853,7 @@ export class ReferenceResolver {
);
this.synthesisRuns++;
this.lastSynthesizedEdges = emitted.edges ?? [];
+ this.queries.replaceSynthesisInputs(collectSynthesisInputs(this.context));
this.queries.setSynthesisPending(false);
} catch {
// synthesis is additive and optional; ignore failures
diff --git a/src/resolution/tier-synthesizer.ts b/src/resolution/tier-synthesizer.ts
index 80ecb920d..e702df964 100644
--- a/src/resolution/tier-synthesizer.ts
+++ b/src/resolution/tier-synthesizer.ts
@@ -1246,6 +1246,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);
+}
+
/** Marks an edge whose target is an external endpoint node the merge creates (see endpointNodesFor). */
export const HTTP_EXTERNAL = 'http-external';