mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-27 21:55:14 +08:00
* 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:
co-authored by
Claude Opus 5.5
parent
9649ce1bd6
commit
2a2f71ec41
@@ -361,6 +361,7 @@ export class DatabaseConnection {
|
||||
'idx_edges_source_kind',
|
||||
'idx_edges_target_kind',
|
||||
'idx_edges_provenance',
|
||||
'idx_edges_synthesis_site',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
+25
-1
@@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter';
|
||||
/**
|
||||
* Current schema version
|
||||
*/
|
||||
export const CURRENT_SCHEMA_VERSION = 9;
|
||||
export const CURRENT_SCHEMA_VERSION = 10;
|
||||
|
||||
/**
|
||||
* Migration definition
|
||||
@@ -177,6 +177,30 @@ const migrations: Migration[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 10,
|
||||
description: 'Track synthesis inputs and stabilize synthesis traversal for incremental refresh (#1988)',
|
||||
up: (db) => {
|
||||
db.exec(`
|
||||
DROP INDEX IF EXISTS idx_nodes_kind;
|
||||
CREATE INDEX idx_nodes_kind ON nodes(kind, file_path, start_line, id);
|
||||
CREATE TABLE IF NOT EXISTS synthesis_inputs (
|
||||
file_path TEXT PRIMARY KEY REFERENCES files(path) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_synthesis_site ON edges(json_extract(metadata, '$.registeredAt'))
|
||||
WHERE json_extract(metadata, '$.synthesizedBy') IS NOT NULL;
|
||||
UPDATE edges SET metadata = json_set(COALESCE(metadata, '{}'), '$.synthesizedBy', 'go-method-contains')
|
||||
WHERE kind = 'contains' AND provenance IS NULL AND EXISTS (
|
||||
SELECT 1 FROM nodes s JOIN nodes t ON t.id = edges.target
|
||||
WHERE s.id = edges.source AND s.language = 'go' AND t.language = 'go'
|
||||
AND s.kind IN ('struct', 'class', 'interface', 'enum', 'type_alias') AND t.kind = 'method'
|
||||
AND s.file_path != t.file_path
|
||||
);
|
||||
INSERT OR REPLACE INTO project_metadata(key, value, updated_at)
|
||||
VALUES ('synthesis_pending', '1', 0);
|
||||
`);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+39
-9
@@ -953,7 +953,7 @@ export class QueryBuilder {
|
||||
getNodesByFile(filePath: string): Node[] {
|
||||
if (!this.stmts.getNodesByFile) {
|
||||
this.stmts.getNodesByFile = this.db.prepare(
|
||||
'SELECT * FROM nodes WHERE file_path = ? ORDER BY start_line'
|
||||
'SELECT * FROM nodes WHERE file_path = ? ORDER BY start_line, id'
|
||||
);
|
||||
}
|
||||
const rows = this.stmts.getNodesByFile.all(filePath) as NodeRow[];
|
||||
@@ -1155,7 +1155,7 @@ export class QueryBuilder {
|
||||
*/
|
||||
getNodesByKind(kind: NodeKind): Node[] {
|
||||
if (!this.stmts.getNodesByKind) {
|
||||
this.stmts.getNodesByKind = this.db.prepare('SELECT * FROM nodes WHERE kind = ?');
|
||||
this.stmts.getNodesByKind = this.db.prepare('SELECT * FROM nodes WHERE kind = ? ORDER BY file_path, start_line, id');
|
||||
}
|
||||
const rows = this.stmts.getNodesByKind.all(kind) as NodeRow[];
|
||||
return rows.map(rowToNode);
|
||||
@@ -1171,7 +1171,10 @@ export class QueryBuilder {
|
||||
*iterateNodesByKind(kind: NodeKind): IterableIterator<Node> {
|
||||
// Fresh statement per call (not a cached one): an iterator holds an open
|
||||
// cursor, so a shared statement would conflict across overlapping scans.
|
||||
const stmt = this.db.prepare('SELECT * FROM nodes WHERE kind = ?');
|
||||
// Synthesis uses first/last-match precedence and caps: insertion order
|
||||
// changes on sync. idx_nodes_kind streams this canonical order without
|
||||
// materializing/sorting all of a large project's methods in memory.
|
||||
const stmt = this.db.prepare('SELECT * FROM nodes WHERE kind = ? ORDER BY file_path, start_line, id');
|
||||
for (const row of stmt.iterate(kind)) {
|
||||
yield rowToNode(row as NodeRow);
|
||||
}
|
||||
@@ -1198,7 +1201,7 @@ export class QueryBuilder {
|
||||
// Fresh statement per call — an iterator holds an open cursor (see
|
||||
// iterateNodesByKind).
|
||||
const stmt = this.db.prepare(
|
||||
"SELECT * FROM nodes WHERE language = ? AND decorators LIKE '%' || ? || '%'"
|
||||
"SELECT * FROM nodes WHERE language = ? AND decorators LIKE '%' || ? || '%' ORDER BY file_path, start_line, id"
|
||||
);
|
||||
for (const row of stmt.iterate(language, `"${decorator}"`)) {
|
||||
yield rowToNode(row as NodeRow);
|
||||
@@ -1237,7 +1240,7 @@ export class QueryBuilder {
|
||||
getNodesByName(name: string): Node[] {
|
||||
if (!this.stmts.getNodesByName) {
|
||||
this.stmts.getNodesByName = this.db.prepare(
|
||||
'SELECT * FROM nodes WHERE name = ? ORDER BY file_path, start_line'
|
||||
'SELECT * FROM nodes WHERE name = ? ORDER BY file_path, start_line, id'
|
||||
);
|
||||
}
|
||||
const rows = this.stmts.getNodesByName.all(name) as NodeRow[];
|
||||
@@ -1278,7 +1281,7 @@ export class QueryBuilder {
|
||||
getNodesByQualifiedNameExact(qualifiedName: string): Node[] {
|
||||
if (!this.stmts.getNodesByQualifiedNameExact) {
|
||||
this.stmts.getNodesByQualifiedNameExact = this.db.prepare(
|
||||
'SELECT * FROM nodes WHERE qualified_name = ?'
|
||||
'SELECT * FROM nodes WHERE qualified_name = ? ORDER BY file_path, start_line, id'
|
||||
);
|
||||
}
|
||||
const rows = this.stmts.getNodesByQualifiedNameExact.all(qualifiedName) as NodeRow[];
|
||||
@@ -1873,6 +1876,32 @@ export class QueryBuilder {
|
||||
// Edge Operations
|
||||
// ===========================================================================
|
||||
|
||||
/** Must run before file replacement/deletion cascades the endpoint edges. */
|
||||
hasSynthesizedEdgesTouchingFile(filePath: string): boolean {
|
||||
const owned = "json_extract(e.metadata, '$.synthesizedBy') 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 json_extract(e.metadata, '$.registeredAt') >= ?
|
||||
AND json_extract(e.metadata, '$.registeredAt') < ? LIMIT 1`
|
||||
).get(`${filePath}:`, `${filePath};`);
|
||||
}
|
||||
|
||||
wasSynthesisInput(filePath: string): boolean {
|
||||
return !!this.db.prepare('SELECT 1 FROM synthesis_inputs WHERE file_path = ?').get(filePath);
|
||||
}
|
||||
|
||||
replaceSynthesisInputs(files: string[]): void {
|
||||
this.db.transaction(() => {
|
||||
this.db.exec('DELETE FROM synthesis_inputs');
|
||||
const insert = this.db.prepare('INSERT INTO synthesis_inputs(file_path) VALUES (?)');
|
||||
for (const file of files) insert.run(file);
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new edge
|
||||
*/
|
||||
@@ -1961,12 +1990,13 @@ export class QueryBuilder {
|
||||
params.push(provenance);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY target, kind, line, col';
|
||||
const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
|
||||
return rows.map(rowToEdge);
|
||||
}
|
||||
|
||||
if (!this.stmts.getEdgesBySource) {
|
||||
this.stmts.getEdgesBySource = this.db.prepare('SELECT * FROM edges WHERE source = ?');
|
||||
this.stmts.getEdgesBySource = this.db.prepare('SELECT * FROM edges WHERE source = ? ORDER BY target, kind, line, col');
|
||||
}
|
||||
const rows = this.stmts.getEdgesBySource.all(sourceId) as EdgeRow[];
|
||||
return rows.map(rowToEdge);
|
||||
@@ -1977,13 +2007,13 @@ export class QueryBuilder {
|
||||
*/
|
||||
getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] {
|
||||
if (kinds && kinds.length > 0) {
|
||||
const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')})`;
|
||||
const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')}) ORDER BY source, kind, line, col`;
|
||||
const rows = this.db.prepare(sql).all(targetId, ...kinds) as EdgeRow[];
|
||||
return rows.map(rowToEdge);
|
||||
}
|
||||
|
||||
if (!this.stmts.getEdgesByTarget) {
|
||||
this.stmts.getEdgesByTarget = this.db.prepare('SELECT * FROM edges WHERE target = ?');
|
||||
this.stmts.getEdgesByTarget = this.db.prepare('SELECT * FROM edges WHERE target = ? ORDER BY source, kind, line, col');
|
||||
}
|
||||
const rows = this.stmts.getEdgesByTarget.all(targetId) as EdgeRow[];
|
||||
return rows.map(rowToEdge);
|
||||
|
||||
+9
-1
@@ -105,7 +105,7 @@ CREATE TABLE IF NOT EXISTS unresolved_refs (
|
||||
-- =============================================================================
|
||||
|
||||
-- Node indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind, file_path, start_line, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_qualified_name ON nodes(qualified_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_file_path ON nodes(file_path);
|
||||
@@ -198,6 +198,14 @@ CREATE INDEX IF NOT EXISTS idx_unresolved_from_name ON unresolved_refs(from_node
|
||||
CREATE INDEX IF NOT EXISTS idx_unresolved_status ON unresolved_refs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_unresolved_failed_tail ON unresolved_refs(name_tail) WHERE status = 'failed';
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_provenance ON edges(provenance);
|
||||
-- Sync's third-file wiring lookup must not scan every synthesized edge.
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_synthesis_site ON edges(json_extract(metadata, '$.registeredAt'))
|
||||
WHERE json_extract(metadata, '$.synthesizedBy') IS NOT NULL;
|
||||
|
||||
-- Retain the cheap source-gate verdict when a later sync deletes the source.
|
||||
CREATE TABLE IF NOT EXISTS synthesis_inputs (
|
||||
file_path TEXT PRIMARY KEY REFERENCES files(path) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Project metadata for version/provenance tracking
|
||||
CREATE TABLE IF NOT EXISTS project_metadata (
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createDatabase, type SqliteDatabase } from './sqlite-adapter';
|
||||
import { QueryBuilder } from './queries';
|
||||
import { createYielder } from '../resolution/cooperative-yield';
|
||||
|
||||
// Ownership is independent of provenance: Go method containment is structural.
|
||||
export const SYNTHESIZED_EDGE = "json_extract(metadata, '$.synthesizedBy') IS NOT NULL";
|
||||
|
||||
/** A private edge overlay: passes see base edges plus their new Go prerequisites. */
|
||||
export class SynthesisStage {
|
||||
readonly db: SqliteDatabase;
|
||||
readonly queries: QueryBuilder;
|
||||
|
||||
constructor(dbPath: string) {
|
||||
this.db = createDatabase(dbPath).db;
|
||||
try {
|
||||
this.db.pragma('busy_timeout = 5000');
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
this.db.pragma('synchronous = NORMAL');
|
||||
this.db.pragma('wal_autocheckpoint = 0');
|
||||
this.db.exec(`
|
||||
CREATE TEMP TABLE synthesis_inputs (file_path TEXT PRIMARY KEY);
|
||||
CREATE TEMP TABLE synthesis_edges (
|
||||
id INTEGER PRIMARY KEY, source TEXT, target TEXT, kind TEXT,
|
||||
metadata TEXT, line INTEGER, col INTEGER, provenance TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX temp.synthesis_identity ON synthesis_edges
|
||||
(source, target, kind, IFNULL(line, -1), IFNULL(col, -1));
|
||||
CREATE INDEX temp.synthesis_source ON synthesis_edges(source, kind);
|
||||
CREATE INDEX temp.synthesis_target ON synthesis_edges(target, kind);
|
||||
CREATE TEMP VIEW edges AS
|
||||
SELECT * FROM main.edges WHERE NOT COALESCE((${SYNTHESIZED_EDGE}), 0)
|
||||
UNION ALL SELECT * FROM synthesis_edges;
|
||||
CREATE TEMP TRIGGER synthesis_insert INSTEAD OF INSERT ON edges BEGIN
|
||||
INSERT OR IGNORE INTO synthesis_edges
|
||||
(source, target, kind, metadata, line, col, provenance)
|
||||
SELECT NEW.source, NEW.target, NEW.kind, NEW.metadata, NEW.line, NEW.col, NEW.provenance
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM main.edges WHERE source = NEW.source AND target = NEW.target
|
||||
AND kind = NEW.kind AND IFNULL(line, -1) = IFNULL(NEW.line, -1)
|
||||
AND IFNULL(col, -1) = IFNULL(NEW.col, -1)
|
||||
AND NOT COALESCE((${SYNTHESIZED_EDGE}), 0)
|
||||
);
|
||||
END;
|
||||
`);
|
||||
this.queries = new QueryBuilder(this.db);
|
||||
} catch (error) {
|
||||
this.db.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async publish(backpressure?: () => Promise<void> | null): Promise<void> {
|
||||
await backpressure?.();
|
||||
const yieldToLoop = createYielder();
|
||||
// Keep the replacement atomic for other connections. Yield between bounded
|
||||
// writes, but checkpoint only OUTSIDE this transaction (uncommitted frames
|
||||
// cannot be folded). Pass execution/staging above never writes the main WAL.
|
||||
this.db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const remove = this.db.prepare(`DELETE FROM main.edges WHERE id IN (
|
||||
SELECT id FROM main.edges WHERE ${SYNTHESIZED_EDGE} LIMIT 2000
|
||||
)`);
|
||||
while (remove.run().changes > 0) await yieldToLoop();
|
||||
const insert = this.db.prepare(`INSERT OR IGNORE INTO main.edges
|
||||
(source, target, kind, metadata, line, col, provenance)
|
||||
SELECT source, target, kind, metadata, line, col, provenance
|
||||
FROM synthesis_edges WHERE id > ? AND id <= ?`);
|
||||
const max = this.db.prepare('SELECT MAX(id) AS id FROM synthesis_edges').get()?.id ?? 0;
|
||||
for (let i = 0; i < max; i += 2000) {
|
||||
insert.run(i, i + 2000);
|
||||
await yieldToLoop();
|
||||
}
|
||||
this.db.exec(`DELETE FROM main.synthesis_inputs;
|
||||
INSERT INTO main.synthesis_inputs SELECT * FROM temp.synthesis_inputs`);
|
||||
this.db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await backpressure?.();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
@@ -3037,7 +3037,9 @@ export class ExtractionOrchestrator {
|
||||
* is stored, when no extraction transaction is open, so a checkpoint can
|
||||
* safely catch up before the next file grows the WAL further.
|
||||
*/
|
||||
backpressure?: () => Promise<void> | null
|
||||
backpressure?: () => Promise<void> | null,
|
||||
/** Inspect changed inputs before deletion/re-extraction cascades their edges. */
|
||||
onFileChange?: (filePath: string, content?: string) => void
|
||||
): Promise<SyncResult> {
|
||||
await initGrammars(); // Initialize WASM runtime (grammars loaded lazily below)
|
||||
const startTime = Date.now();
|
||||
@@ -3153,6 +3155,7 @@ export class ExtractionOrchestrator {
|
||||
this.queries.insertUnresolvedRefsBatch(resurrected);
|
||||
}
|
||||
}
|
||||
onFileChange?.(tracked.path);
|
||||
this.queries.deleteFile(tracked.path);
|
||||
filesRemoved++;
|
||||
}
|
||||
@@ -3202,10 +3205,12 @@ export class ExtractionOrchestrator {
|
||||
const contentHash = hashContent(content);
|
||||
|
||||
if (!tracked) {
|
||||
onFileChange?.(filePath, content);
|
||||
filesToIndex.push(filePath);
|
||||
changedFilePaths.push(filePath);
|
||||
filesAdded++;
|
||||
} else if (tracked.contentHash !== contentHash) {
|
||||
onFileChange?.(filePath, content);
|
||||
filesToIndex.push(filePath);
|
||||
changedFilePaths.push(filePath);
|
||||
filesModified++;
|
||||
|
||||
+30
-6
@@ -47,6 +47,7 @@ import {
|
||||
createResolver,
|
||||
ResolutionResult,
|
||||
} from './resolution';
|
||||
import { hasSynthesisPattern } from './resolution/callback-synthesizer';
|
||||
import { GraphTraverser, GraphQueryManager } from './graph';
|
||||
import { ContextBuilder, createContextBuilder } from './context';
|
||||
import { Mutex, FileLock } from './utils';
|
||||
@@ -839,7 +840,20 @@ export class CodeGraph {
|
||||
const fullReconcile = !options.paths || options.paths.length === 0;
|
||||
const gitState = this.orchestrator.beginGitIndexState(fullReconcile);
|
||||
|
||||
const result = await this.orchestrator.sync(options.onProgress, options.paths, backpressure);
|
||||
// An interrupted index may have absorbed its changed files before
|
||||
// resolution/synthesis. Detect those orphans BEFORE this sync adds refs.
|
||||
let refreshSynthesis = this.queries.getMetadata('synthesis_pending') === '1' ||
|
||||
this.queries.getUnresolvedReferencesCount() > 0;
|
||||
if (refreshSynthesis) this.queries.setMetadata('synthesis_pending', '1');
|
||||
const result = await this.orchestrator.sync(options.onProgress, options.paths, backpressure,
|
||||
(filePath, content) => {
|
||||
if (!refreshSynthesis && (this.queries.hasSynthesizedEdgesTouchingFile(filePath) ||
|
||||
this.queries.wasSynthesisInput(filePath) ||
|
||||
(content !== undefined && hasSynthesisPattern(filePath, content)))) {
|
||||
refreshSynthesis = true;
|
||||
this.queries.setMetadata('synthesis_pending', '1');
|
||||
}
|
||||
});
|
||||
|
||||
// Fold the store phase's WAL BEFORE the post-store reads below
|
||||
// (resolution reads on the main thread) — same rationale as
|
||||
@@ -938,7 +952,8 @@ export class CodeGraph {
|
||||
total: totalPasses,
|
||||
});
|
||||
},
|
||||
backpressure
|
||||
backpressure,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1008,7 +1023,8 @@ export class CodeGraph {
|
||||
total: totalPasses,
|
||||
});
|
||||
},
|
||||
backpressure
|
||||
backpressure,
|
||||
false
|
||||
);
|
||||
result.pendingRefsProcessed = recovery.stats.total;
|
||||
result.pendingRefsResolved = recovery.stats.resolved;
|
||||
@@ -1025,9 +1041,16 @@ export class CodeGraph {
|
||||
await this.resolver.resolveDeferredThisMemberRefs();
|
||||
}
|
||||
|
||||
if (refreshSynthesis) {
|
||||
await this.resolver.refreshSynthesis(this.db.getPath(), (done, total) => {
|
||||
options.onProgress?.({ phase: 'linking', current: done, total });
|
||||
}, backpressure);
|
||||
this.queries.setMetadata('synthesis_pending', '0');
|
||||
}
|
||||
|
||||
// Refresh planner stats + checkpoint the WAL after bulk writes.
|
||||
// Off-thread — see indexAll's call site.
|
||||
if (filesChanged || result.filesRemoved > 0 || orphanCount > 0) {
|
||||
if (filesChanged || result.filesRemoved > 0 || orphanCount > 0 || refreshSynthesis) {
|
||||
await this.db.runMaintenance();
|
||||
}
|
||||
|
||||
@@ -1295,7 +1318,8 @@ export class CodeGraph {
|
||||
// resolution is timer-driven passive checkpoints, which the pool's
|
||||
// continuous reads keep perpetually partial — the WAL then accretes the
|
||||
// whole phase's write volume (22GB on a 4.6GB DB at kernel scale).
|
||||
backpressure?: () => Promise<void> | null
|
||||
backpressure?: () => Promise<void> | null,
|
||||
synthesize: boolean = true
|
||||
): Promise<ResolutionResult> {
|
||||
return this.resolver.resolveAndPersistBatched(onProgress, undefined, onSynthesisProgress, {
|
||||
dbPath: this.db.getPath(),
|
||||
@@ -1313,7 +1337,7 @@ export class CodeGraph {
|
||||
end: () => this.db.endBulkRefLoad(),
|
||||
},
|
||||
backpressure,
|
||||
});
|
||||
}, synthesize);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user