From 98142fa2a0e901e69002fabbabe03d5935d2631e Mon Sep 17 00:00:00 2001 From: bompus Date: Wed, 28 Oct 2026 10:25:48 -0600 Subject: [PATCH 1/3] sync: gate synthesized-edge refresh to edits that can affect it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An incremental sync marked every changed file synthesis-dirty, so a one-line comment edit re-ran whole-graph callback synthesis. On the discourse corpus that made `codegraph sync` 1.64 s / 654 MB peak where disabling the refresh entirely measured 0.93 s / 446 MB. A changed file now dirties synthesis only when it can actually feed or invalidate a pass: - it already carries synthesized wiring — endpoints in the file, or a `registeredAt` naming it (one memoized bulk query per sync, not one edges scan per file); - its last-seen content fed a pass — the new synthesis_inputs table, recorded after each completed run, is checked while the file row still exists so removed files and pattern-dropping edits on an already-collapsed channel still refresh; - its new content matches a pass's input pattern — the ported hasSynthesisPattern plus markup-capable extensions and barrel-shaped files, which is what lazy()/tag-module resolution can reach; - its definition delta adds a JSX-renderable name an unchanged file's tag may name. Two retention fixes land with it: `synthesisRuns` increments on the direct-resynthesize path too (it only counted batched runs before), and refreshSynthesis now drops the emitted edge array and the resolver's warm caches once rows are reconciled — the daemon's idle heap no longer holds a whole-graph working set after a watcher sync. Measured on discourse: a benign edit syncs in 0.75-1.26 s at ~480 MB peak (was 1.64 s / 654 MB), matching the disabled-refresh floor. Tests cover the skip path, pattern/removal/JSX-delta triggers, and the existing convergence suite passes including the fan-out-cap restore. --- .../precision-svelte-5895c63-dd02b58a.json | 35 +++++++ .../precision-vite-8492422-dd02b58a.json | 95 +++++++++++++++++++ __tests__/sync-resynthesis.test.ts | 62 ++++++++++++ src/codegraph.ts | 45 ++++++++- src/db/migrations.ts | 16 ++++ src/db/queries.ts | 50 ++++++++++ src/db/schema.sql | 6 ++ src/resolution/callback-synthesizer.ts | 74 ++++++++++++++- src/resolution/index.ts | 6 +- src/resolution/tier-synthesizer.ts | 4 + 10 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 __tests__/evaluation/results/precision-svelte-5895c63-dd02b58a.json create mode 100644 __tests__/evaluation/results/precision-vite-8492422-dd02b58a.json 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 0000000000..031c2a9206 --- /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 0000000000..c03db209e4 --- /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 4bc259c466..9f05190d1e 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 2d672db212..74e7e42dba 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 1a37aa8fb9..1e67055f95 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -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 1fca978388..17e68fb2de 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 4c5303c89c..8073789c72 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 bcfc44ab73..4523fd799e 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,78 @@ 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, + 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)$/; +const MODULE_LINE = /^\s*(?:import|export)\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 import/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) => MODULE_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 c83aa84848..f556037d06 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 80ecb920d5..e702df9649 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'; From 2b7637c2ee3eb605a995063066252747bc2b5c10 Mon Sep 17 00:00:00 2001 From: bompus Date: Wed, 28 Oct 2026 11:09:39 -0600 Subject: [PATCH 2/3] sync: bump schema version for synthesis_inputs; gate Sidekiq enqueue forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An existing v22 database never ran migration 23, leaving wasSynthesisInput to query a missing table — CodeRabbit #580. Also cover the Jobs.enqueue / enqueue_in / enqueue_at and Sidekiq::Client.push dispatch forms in the input gate. --- src/db/migrations.ts | 2 +- src/resolution/callback-synthesizer.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 1e67055f95..4b5f44e704 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 diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 4523fd799e..4de59aecdd 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -7145,6 +7145,7 @@ export function hasSynthesisPattern(filePath: string, content: string): boolean 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) { From cc77bfd9388cb68173d74926ee88f2dc3f9378a4 Mon Sep 17 00:00:00 2001 From: bompus Date: Wed, 28 Oct 2026 11:28:00 -0600 Subject: [PATCH 3/3] sync: narrow the barrel heuristic to value re-exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit import/export lines made every type-only barrel a synthesis input — n8n's `export type` index paid a full resynthesis upstream correctly skips. Only `export {X} from` / `export *` lines count now; type-only re-exports and local definitions drop out (the definition delta already covers those). --- src/resolution/callback-synthesizer.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 4de59aecdd..fb6c060ce1 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -7161,13 +7161,17 @@ export function hasSynthesisPattern(filePath: string, content: string): boolean } const SYNTH_MODULE_FILE = /\.(?:[cm]?jsx|tsx|vue|svelte|astro)$/; -const MODULE_LINE = /^\s*(?:import|export)\b/; +// 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 import/export lines rewire which module a + * 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. */ @@ -7175,7 +7179,7 @@ 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) => MODULE_LINE.test(l)).length / lines.length > 0.6; + return lines.length > 0 && lines.filter((l) => RE_EXPORT_LINE.test(l)).length / lines.length > 0.6; } /**