mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-22 01:07:18 +08:00
fix: reconcile global extension links atomically after source changes
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
|
||||
@@ -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') });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user