From e72c0645466db080078e80e77f66ac093d0f0eba Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 22 Sep 2026 13:15:58 +0000 Subject: [PATCH] test: expand Drupal corpus proofs and native package regression --- .github/workflows/extensions-native.yml | 2 + scripts/validation/drupal-review-cases.json | 255 ++++++++++++++++++ .../validation/drupal-review-incremental.cjs | 36 +++ scripts/validation/drupal-review-probes.cjs | 28 ++ scripts/validation/native-validation.cjs | 4 +- 5 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 scripts/validation/drupal-review-cases.json create mode 100644 scripts/validation/drupal-review-incremental.cjs create mode 100644 scripts/validation/drupal-review-probes.cjs diff --git a/.github/workflows/extensions-native.yml b/.github/workflows/extensions-native.yml index d2805cb09..a9548c01a 100644 --- a/.github/workflows/extensions-native.yml +++ b/.github/workflows/extensions-native.yml @@ -7,6 +7,8 @@ on: branches: [feature/extensions-author-kit-20260922] paths: - 'src/**' + - 'extensions/drupal/**' + - 'scripts/build-extensions.mjs' - '__tests__/**' - 'scripts/validation/**' - 'package*.json' diff --git a/scripts/validation/drupal-review-cases.json b/scripts/validation/drupal-review-cases.json new file mode 100644 index 000000000..1227f3b5c --- /dev/null +++ b/scripts/validation/drupal-review-cases.json @@ -0,0 +1,255 @@ +{ + "pathauto": { + "flows": [ + [ + "/admin/config/search/path/settings", + "pathauto.routing.yml", + "buildForm", + "src/Form/PathautoSettingsForm.php", + "Drupal route handler", + "/admin/config/search/path/settings PathautoSettingsForm.buildForm" + ], + [ + "/admin/config/search/path/update_bulk", + "pathauto.routing.yml", + "buildForm", + "src/Form/PathautoBulkUpdateForm.php", + "Drupal route handler", + "/admin/config/search/path/update_bulk PathautoBulkUpdateForm.buildForm" + ], + [ + "getPunctuationCharacters", + "src/AliasCleaner.php", + "pathautoPunctuationCharsAlter", + "tests/modules/pathauto_custom_punctuation_test/src/Hook/PathautoCustomPunctuationTestHooks.php", + "Drupal hook pathauto_punctuation_chars_alter", + "AliasCleaner.getPunctuationCharacters PathautoCustomPunctuationTestHooks.pathautoPunctuationCharsAlter" + ] + ], + "extra": [ + [ + "PathautoGenerator", + "src/PathautoGenerator.php", + "AliasCleaner", + "src/AliasCleaner.php", + "Drupal service injection" + ], + [ + "pathauto_update_8100", + "pathauto.install", + "AliasTypeManager", + "src/AliasTypeManager.php", + "Drupal service lookup" + ], + [ + "service:pathauto.alias_cleaner", + "pathauto.services.yml", + "AliasCleaner", + "src/AliasCleaner.php", + "Drupal service implementation" + ], + [ + "PathautoGenerator", + "src/PathautoGenerator.php", + "AliasUniquifier", + "src/AliasUniquifier.php", + "Drupal service injection" + ], + [ + "Action:pathauto_update_alias", + "src/Plugin/Action/UpdateAction.php", + "UpdateAction", + "src/Plugin/Action/UpdateAction.php", + "Drupal plugin implementation" + ], + [ + "getPunctuationCharacters", + "src/AliasCleaner.php", + "pathauto_custom_punctuation_test_pathauto_punctuation_chars_alter", + "tests/modules/pathauto_custom_punctuation_test/pathauto_custom_punctuation_test.module", + "Drupal hook pathauto_punctuation_chars_alter" + ] + ], + "negative": [ + [ + "/admin/config/search/path/patterns/add", + "pathauto.routing.yml", + "Drupal route handler", + "_entity_form" + ], + [ + "buildForm", + "src/Form/PathautoBulkUpdateForm.php", + "Drupal plugin construction", + "createInstance($id)" + ] + ] + }, + "commerce": { + "flows": [ + [ + "/cart", + "modules/cart/commerce_cart.routing.yml", + "cartPage", + "modules/cart/src/Controller/CartController.php", + "Drupal route handler", + "/cart CartController.cartPage" + ], + [ + "/checkout/{commerce_order}/{step}", + "modules/checkout/commerce_checkout.routing.yml", + "formPage", + "modules/checkout/src/Controller/CheckoutController.php", + "Drupal route handler", + "/checkout/{commerce_order}/{step} CheckoutController.formPage" + ], + [ + "/admin/commerce/orders/add", + "modules/order/commerce_order.routing.yml", + "buildForm", + "modules/order/src/Form/OrderAddForm.php", + "Drupal route handler", + "/admin/commerce/orders/add OrderAddForm.buildForm" + ] + ], + "extra": [ + [ + "CurrentCountry", + "src/CurrentCountry.php", + "ChainCountryResolver", + "src/Resolver/ChainCountryResolver.php", + "Drupal service injection" + ], + [ + "Block:commerce_cart", + "modules/cart/src/Plugin/Block/CartBlock.php", + "CartBlock", + "modules/cart/src/Plugin/Block/CartBlock.php", + "Drupal plugin implementation" + ], + [ + "commerce_cart_cron", + "modules/cart/commerce_cart.module", + "Cron", + "modules/cart/src/Cron.php", + "Drupal service lookup" + ], + [ + "CurrentLocale", + "src/CurrentLocale.php", + "ChainLocaleResolver", + "src/Resolver/ChainLocaleResolver.php", + "Drupal service injection" + ], + [ + "QueueWorker:commerce_cart_expiration", + "modules/cart/src/Plugin/QueueWorker/CartExpiration.php", + "CartExpiration", + "modules/cart/src/Plugin/QueueWorker/CartExpiration.php", + "Drupal plugin implementation" + ], + [ + "addOrderItem", + "modules/cart/src/CartManager.php", + "displayAddToCartMessage", + "modules/cart/src/EventSubscriber/CartEventSubscriber.php", + "Drupal event commerce_cart.entity.add" + ] + ], + "negative": [ + [ + "/admin/commerce/config/products", + "modules/product/commerce_product.routing.yml", + "Drupal route handler", + "SystemController::systemAdminMenuBlockPage" + ] + ] + }, + "drupal": { + "flows": [ + [ + "setAccount", + "core/lib/Drupal/Core/Session/AccountProxy.php", + "setDefaultTimeZone", + "core/modules/system/src/TimeZoneResolver.php", + "Drupal event account.set", + "AccountProxy.setAccount TimeZoneResolver.setDefaultTimeZone" + ], + [ + "filterResponse", + "core/modules/big_pipe/src/Render/BigPipe.php", + "onResponse", + "core/lib/Drupal/Core/EventSubscriber/ActiveLinkResponseFilter.php", + "Drupal event Symfony\\Component\\HttpKernel\\KernelEvents::RESPONSE", + "BigPipe.filterResponse ActiveLinkResponseFilter.onResponse" + ], + [ + "finish", + "core/lib/Drupal/Core/Config/ConfigImporter.php", + "onConfigImporterImport", + "core/lib/Drupal/Core/EventSubscriber/ConfigSnapshotSubscriber.php", + "Drupal event config.importer.import", + "ConfigImporter.finish ConfigSnapshotSubscriber.onConfigImporterImport" + ] + ], + "extra": [ + [ + "/admin/modules", + "core/modules/system/system.routing.yml", + "buildForm", + "core/modules/system/src/Form/ModulesListForm.php", + "Drupal route handler" + ], + [ + "service:renderer", + "core/core.services.yml", + "Renderer", + "core/lib/Drupal/Core/Render/Renderer.php", + "Drupal service implementation" + ], + [ + "ConfigActionManager", + "core/lib/Drupal/Core/Config/Action/ConfigActionManager.php", + "ConfigManager", + "core/lib/Drupal/Core/Config/ConfigManager.php", + "Drupal service injection" + ], + [ + "_batch_page", + "core/includes/batch.inc", + "BatchStorage", + "core/lib/Drupal/Core/Batch/BatchStorage.php", + "Drupal service lookup" + ], + [ + "_filter_url", + "core/modules/filter/filter.module", + "FilterUrl", + "core/modules/filter/src/Plugin/Filter/FilterUrl.php", + "Drupal plugin construction" + ], + [ + "Filter:filter_url", + "core/modules/filter/src/Plugin/Filter/FilterUrl.php", + "FilterUrl", + "core/modules/filter/src/Plugin/Filter/FilterUrl.php", + "Drupal plugin implementation" + ], + [ + "drupal_flush_all_caches", + "core/includes/common.inc", + "cacheFlush", + "core/modules/locale/src/Hook/LocaleHooks.php", + "Drupal hook cache_flush" + ] + ], + "negative": [ + [ + "invokeAllDeprecated", + "core/lib/Drupal/Core/Extension/ModuleHandler.php", + "Drupal hook ", + "invokeAll($hook" + ] + ] + } +} diff --git a/scripts/validation/drupal-review-incremental.cjs b/scripts/validation/drupal-review-incremental.cjs new file mode 100644 index 000000000..6c8b32524 --- /dev/null +++ b/scripts/validation/drupal-review-incremental.cjs @@ -0,0 +1,36 @@ +// Three separately receipted phases so a task interruption cannot erase progress. +const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'),crypto=require('node:crypto'); +const {DatabaseSync}=require('node:sqlite'); const {CodeGraph}=require('../../dist'); +const [project,phase,output]=process.argv.slice(2);fs.mkdirSync(path.dirname(output),{recursive:true}); +const files=['review_convergence.php','review_convergence.routing.yml']; +function snapshot(){const db=new DatabaseSync(path.join(project,'.codegraph/codegraph.db'),{readOnly:true});try{ + const nodes=db.prepare('SELECT * FROM nodes ORDER BY id').all().map(({updated_at,...n})=>n); + const edges=db.prepare('SELECT source,target,kind,metadata,line,col,provenance FROM edges ORDER BY source,target,kind,line,col,metadata').all(); + return {hash:crypto.createHash('sha256').update(JSON.stringify({nodes,edges})).digest('hex'),nodes,edges}; +}finally{db.close();}} +(async()=>{const record=fs.existsSync(output)?JSON.parse(fs.readFileSync(output)):{project,phases:[]};let g; + try{ + if(phase==='add'){ + assert.equal(record.phases.length,0);record.original=snapshot().hash; + for(const file of files)assert.ok(!fs.existsSync(path.join(project,file))); + fs.writeFileSync(path.join(project,files[0]),` 'onReview']; } public function onReview($event) {} } +class ReviewDispatch { public function send($dispatcher,$event) { $dispatcher->dispatch('review.convergence', $event); } } +`); + fs.writeFileSync(path.join(project,files[1]),"review.route:\n path: '/__review_unknown'\n defaults:\n _controller: '\\Missing\\Unknown::missing'\n"); + }else assert.ok(['rebuild','remove'].includes(phase)&&record.incremental); + if(phase==='remove'){for(const file of files)fs.unlinkSync(path.join(project,file));} + const start=performance.now();g=await CodeGraph.open(project); + const result=phase==='rebuild'?await g.refreshPluginIndex():await g.sync({paths:files}); + assert.ok(result?.success!==false&&!result?.filesErrored);g.close();g=null; + const state=snapshot(); + if(phase==='add'){ + const route=state.nodes.find(n=>n.name==='/__review_unknown');assert.ok(route);assert.ok(!state.edges.some(e=>e.source===route.id)); + const send=state.nodes.find(n=>n.name==='send'&&n.file_path===files[0]),receive=state.nodes.find(n=>n.name==='onReview'&&n.file_path===files[0]);assert.ok(send&&receive); + assert.equal(state.edges.filter(e=>e.source===send.id&&e.target===receive.id&&JSON.parse(e.metadata||'{}').label==='Drupal event review.convergence').length,1); + record.incremental=state.hash; + }else assert.equal(state.hash,phase==='rebuild'?record.incremental:record.original,'Incremental/full convergence'); + record.phases.push({phase,hash:state.hash,wallMs:performance.now()-start});record.success=record.phases.length===3; + }catch(e){record.failure=String(e.stack);throw e;}finally{g?.close();fs.writeFileSync(output,JSON.stringify(record,null,2));} +})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/scripts/validation/drupal-review-probes.cjs b/scripts/validation/drupal-review-probes.cjs new file mode 100644 index 000000000..43bc5af46 --- /dev/null +++ b/scripts/validation/drupal-review-probes.cjs @@ -0,0 +1,28 @@ +// Read-only source-grounded probes over an already indexed disposable corpus. +const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'),crypto=require('node:crypto'); +const {DatabaseSync}=require('node:sqlite'); +const {CodeGraph}=require('../../dist');const {ToolHandler}=require('../../dist/mcp/tools'); +const [name,project,output]=process.argv.slice(2),cases=require('./drupal-review-cases.json')[name]; +const db=new DatabaseSync(path.join(project,'.codegraph/codegraph.db'),{readOnly:true}); +const hash=b=>crypto.createHash('sha256').update(b).digest('hex'); +const record={name,project,positive:[],negative:[],probes:[],success:false}; +function evidence(file,line){const bytes=fs.readFileSync(path.join(project,file));const lines=bytes.toString().split('\n');assert.ok(line>=1&&line<=lines.length);return {file,line,sha256:hash(bytes),excerpt:lines.slice(Math.max(0,line-3),line+3).join('\n')};} +(async()=>{try{ + const edges=db.prepare("SELECT s.name source,s.file_path sourceFile,s.start_line sourceLine,t.name target,t.file_path targetFile,t.start_line targetLine,e.source sourceId,e.target targetId,e.kind,e.line,e.metadata FROM edges e JOIN nodes s ON e.source=s.id JOIN nodes t ON e.target=t.id WHERE json_extract(e.metadata,'$.synthesizedBy')='drupal'").all(); + const seen=new Set();for(const e of edges){const key=[e.sourceId,e.targetId,e.kind,e.line].join('|');assert.ok(!seen.has(key),'Duplicate extension edge '+key);seen.add(key);} + for(const [source,sf,target,tf,label]of [...cases.flows,...cases.extra]){ + const matches=edges.filter(e=>e.source===source&&e.sourceFile===sf&&e.target===target&&e.targetFile===tf&&JSON.parse(e.metadata).label===label);assert.equal(matches.length,1,'Expected unique grounded edge '+JSON.stringify([source,sf,target,tf,label])); + const e=matches[0],m=JSON.parse(e.metadata),at=/^(.*):(\d+)$/.exec(m.registeredAt);assert.ok(at); + record.positive.push({source,target,label,kind:e.kind,from:evidence(sf,e.sourceLine),to:evidence(tf,e.targetLine),wiring:evidence(at[1],Number(at[2]))}); + } + for(const [source,file,labelPrefix,text]of cases.negative){ + assert.ok(fs.readFileSync(path.join(project,file),'utf8').includes(text),'Negative has source evidence'); + assert.ok(db.prepare('SELECT 1 FROM nodes WHERE name=? AND file_path=?').get(source,file),'Negative source node exists'); + assert.equal(edges.filter(e=>e.source===source&&e.sourceFile===file&&JSON.parse(e.metadata).label.startsWith(labelPrefix)).length,0,'Unsupported/dynamic target must stay unresolved'); + record.negative.push({source,file,labelPrefix,text,sha256:hash(fs.readFileSync(path.join(project,file)))}); + } + const graph=await CodeGraph.open(project);try{ + for(const c of cases.flows){const response=await new ToolHandler(graph).execute('codegraph_explore',{query:c[5]});const text=response.content.map(v=>v.text||'').join('\n');assert.ok(!response.isError&&text.includes(c[4]),'Expected labelled flow');record.probes.push({query:c[5],text});} + }finally{graph.close();} + record.edgeCount=edges.length;record.noDuplicateKeys=true;record.success=true; +}finally{db.close();fs.mkdirSync(path.dirname(output),{recursive:true});fs.writeFileSync(output,JSON.stringify(record,null,2));}})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/scripts/validation/native-validation.cjs b/scripts/validation/native-validation.cjs index a58e43e69..0788c1aff 100644 --- a/scripts/validation/native-validation.cjs +++ b/scripts/validation/native-validation.cjs @@ -21,7 +21,7 @@ const playwright = process.env.PLAYWRIGHT_MODULE || path.join(root, '.qa/browser env.PLAYWRIGHT_MODULE = playwright; env.CHROME_PATH = process.env.CHROME_PATH || require(playwright).chromium.executablePath(); async function run(name, command, args, timeout = 360000, overrides = {}) { - if (metadata.scope === 'marketplace' && !['build','marketplace-build','focused','registry-storage','external-author','compatibility','browser'].includes(name)) { + if (metadata.scope === 'marketplace' && !['build','marketplace-build','drupal-package','drupal-accuracy','focused','registry-storage','external-author','compatibility','browser'].includes(name)) { metadata.notRun.push({ name, reason: 'Unchanged core lifecycle; retain prior native evidence instead of rerunning' }); save(); return true; } const step = { name, command: [command, ...args], cwd: root, revision: version, overrides, started: new Date().toISOString(), status: 'running' }; @@ -45,6 +45,8 @@ async function run(name, command, args, timeout = 360000, overrides = {}) { const built = await run('build', ...npmCommand(['run', 'build'])); if (built) { await run('marketplace-build', ...npmCommand(['run', 'build', '--prefix', 'marketplace'])); + 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__/db-reopen-on-replace.test.ts', '__tests__/status-json.test.ts', '__tests__/sync.test.ts', '__tests__/concurrent-locking.test.ts',