diff --git a/CHANGELOG.md b/CHANGELOG.md index 273c2d195..8365a2807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/__tests__/extension-sync.test.ts b/__tests__/extension-sync.test.ts new file mode 100644 index 000000000..c50aa55cc --- /dev/null +++ b/__tests__/extension-sync.test.ts @@ -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); diff --git a/docs/extensions/authoring.md b/docs/extensions/authoring.md index e5de5f8f6..d35774688 100644 --- a/docs/extensions/authoring.md +++ b/docs/extensions/authoring.md @@ -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. diff --git a/scripts/validation/native-validation.cjs b/scripts/validation/native-validation.cjs index 0788c1aff..39cdd52f3 100644 --- a/scripts/validation/native-validation.cjs +++ b/scripts/validation/native-validation.cjs @@ -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') }); diff --git a/src/index.ts b/src/index.ts index 2d4832df5..f9b4465cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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;