fix: reconcile global extension links atomically after source changes

This commit is contained in:
Forge
2026-09-22 13:37:42 +00:00
parent 2bc4029bf4
commit 8936a3dc43
5 changed files with 68 additions and 1 deletions
+2
View File
@@ -17,6 +17,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Extension preview
- Reconcile global semantic-extension links after sync or explicit file indexing with an atomic candidate rebuild. Failed passes preserve the working graph; changed-file sync currently pays full-rebuild cost for these extensions.
- Prepare immutable Drupal extension 0.1.1: ignore unrelated static receivers and commented/quoted declarations, and resolve both explicit Symfony event argument orders. Preserve the reviewed 0.1.0 artifact and policy.
- Keep installed and selected marketplace versions distinct while compatibility metadata refreshes; pending requests no longer display an incompatible catalog version as installed.
+43
View File
@@ -0,0 +1,43 @@
import { afterEach, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { CodeGraph, createExtensionProject, packExtension } from '../src';
import { ExtensionManager } from '../src/plugins/manager';
const roots: string[] = [];
afterEach(() => roots.splice(0).forEach(r => fs.rmSync(r, { recursive: true, force: true })));
it('semantic extension sync replaces cross-file links and preserves working graph after a real pass failure', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-extension-sync-')); roots.push(root);
const author = path.join(root, 'author'), project = path.join(root, 'project'); fs.mkdirSync(project);
createExtensionProject(author, 'python-events');
const packageFile = path.join(author, 'package.json'), manifest = JSON.parse(fs.readFileSync(packageFile, 'utf8'));
manifest.codegraph.capabilities = ['synthPasses']; fs.writeFileSync(packageFile, JSON.stringify(manifest));
fs.writeFileSync(path.join(author, 'index.cjs'), `module.exports = () => ({ synthPasses: [{name:'bindings',languages:['python'],run(ctx) {
const text=ctx.readFile('binding.py')||''; if(text.includes('FAIL')) throw Error('fixture candidate failed');
const name=/target: (\\w+)/.exec(text)?.[1]; if(!name)return [];
const from=ctx.getNodesByName('dispatch'),to=ctx.getNodesByName(name);if(from.length!==1||to.length!==1)return [];
return [{source:from[0].id,target:to[0].id,kind:'calls',metadata:{label:'Test binding'},line:1}];
}}] });`);
fs.writeFileSync(path.join(project, 'handlers.py'), 'def dispatch():\n pass\ndef send_receipt():\n pass\ndef cancel_order():\n pass\n');
await new ExtensionManager(project).install({ bytes: packExtension(author) });
const graph = await CodeGraph.open(project);
const links = () => {
const nodes = graph.getNodesByKind('function'); const names = new Map(nodes.map(n => [n.id, n.name]));
return graph.getOutgoingEdgesFrom(nodes.map(n => n.id)).filter(e => e.metadata?.synthesizedBy === 'python-events')
.map(e => [names.get(e.source), names.get(e.target)]);
};
try {
const file = path.join(project, 'binding.py'); fs.writeFileSync(file, '# target: send_receipt\n');
expect((await graph.sync({ paths: ['binding.py'] })).filesAdded).toBe(1);
expect(links()).toEqual([['dispatch', 'send_receipt']]);
fs.writeFileSync(file, '# target: cancel_order\n'); await graph.sync({ paths: ['binding.py'] });
expect(links()).toEqual([['dispatch', 'cancel_order']]);
fs.writeFileSync(file, '# FAIL\n'); await expect(graph.sync()).rejects.toThrow('fixture candidate failed');
expect(links()).toEqual([['dispatch', 'cancel_order']]);
fs.writeFileSync(file, '# target: send_receipt\n'); await graph.indexFiles(['binding.py']);
expect(links()).toEqual([['dispatch', 'send_receipt']]);
fs.unlinkSync(file); await graph.sync({ paths: ['binding.py'] }); expect(links()).toEqual([]);
const noChanges = await graph.sync();
expect(noChanges.filesAdded + noChanges.filesModified + noChanges.filesRemoved).toBe(0);
} finally { graph.close(); }
}, 90_000);
+4
View File
@@ -279,3 +279,7 @@ Windows/WSL project use, and mixed older writers bypassing coordination remain
unvalidated.
Direct edits to the graph database, deliberate deletion of the coordination
files, and concurrent project destruction are outside this recovery contract.
## Semantic-pass synchronization
API v1 semantic passes can inspect the whole project and do not declare a dependency map. When source files change in a project with a semantic pass, `sync` builds and atomically commits a full candidate graph. Explicit `indexFiles` also refreshes the whole project. Scoped path arguments do not restrict that candidate: cross-file registrations must agree with their endpoints. A failed pass preserves the previous graph and reports failure. An unchanged healthy project does not rebuild. This trades incremental latency for correctness; large-project edits can cost a full rebuild. A future dependency/invalidation API is required before making this path incremental safely.
+1 -1
View File
@@ -48,7 +48,7 @@ async function run(name, command, args, timeout = 360000, overrides = {}) {
await run('drupal-package', process.execPath, ['scripts/build-extensions.mjs']);
await run('drupal-accuracy', process.execPath, ['--test', 'scripts/validation/drupal-accuracy.test.cjs', 'scripts/validation/drupal-negative.test.cjs']);
await run('focused', process.execPath, ['node_modules/vitest/vitest.mjs', 'run',
'__tests__/marketplace-storage.test.ts', '__tests__/foundation.test.ts', '__tests__/extension-trust.test.ts', '__tests__/extension-releases.test.ts', '__tests__/extension-marketplace.test.ts', '__tests__/extension-author.test.ts', '__tests__/plugins.test.ts', '__tests__/extension-explore.test.ts',
'__tests__/marketplace-storage.test.ts', '__tests__/foundation.test.ts', '__tests__/extension-trust.test.ts', '__tests__/extension-releases.test.ts', '__tests__/extension-marketplace.test.ts', '__tests__/extension-author.test.ts', '__tests__/extension-sync.test.ts', '__tests__/plugins.test.ts', '__tests__/extension-explore.test.ts',
'__tests__/db-reopen-on-replace.test.ts', '__tests__/status-json.test.ts', '__tests__/sync.test.ts', '__tests__/concurrent-locking.test.ts',
'--maxWorkers=2', '--minWorkers=1', '--reporter=default', '--reporter=json', `--outputFile.json=${path.join(out, 'focused.json')}`]);
await run('registry-storage', process.execPath, ['scripts/validation/marketplace-storage.cjs'], 120000, { STORAGE_OUTPUT: path.join(out, 'registry-storage') });
+18
View File
@@ -913,6 +913,9 @@ export class CodeGraph {
const plugins = await loadPlugins(this.projectRoot);
if ((plugins.resolved.length || this.queries.getMetadata('indexed_with_plugins')) &&
this.queries.getMetadata('indexed_with_plugins') !== this.extensionStamp(plugins)) return this.indexAll();
// Semantic passes can connect unchanged files through changed metadata.
// A scoped write cannot invalidate those relationships safely.
if (plugins.synthPasses.length) return this.refreshPluginIndex();
return withPlugins(plugins, () => this.indexFilesWithPlugins(filePaths));
}
@@ -952,6 +955,21 @@ export class CodeGraph {
}
return withPlugins(plugins, async () => {
this.resolver.initialize();
if (plugins.synthPasses.length) {
const changed = this.orchestrator.getChangedFiles();
if (changed.added.length || changed.modified.length || changed.removed.length ||
this.queries.getUnresolvedReferencesCount() > 0 || this.getIndexState() === 'indexing') {
// API v1 semantic passes have no dependency/invalidation contract.
// Rebuild a candidate before touching the working graph, then commit
// it atomically. This also removes links whose registration changed
// in a different file from either endpoint. Ignore scoped paths here:
// a global contribution must observe one consistent project snapshot.
const result = await this.refreshPluginIndex({ ...options, paths: undefined });
return { filesChecked: result.filesIndexed, filesAdded: changed.added.length,
filesModified: changed.modified.length, filesRemoved: changed.removed.length,
nodesUpdated: result.nodesCreated, durationMs: result.durationMs };
}
}
const result = await this.syncWithPlugins(options);
this.queries.setMetadata('plugins_last_run', JSON.stringify(plugins.diagnostics));
return result;