Files
codegraph/scripts/validation/drupal-corpus.cjs
T

141 lines
10 KiB
JavaScript

// Uses caller-owned corpus checkouts. Never run against a user's working repository.
// Build engine/extension first, then: node scripts/validation/drupal-corpus.cjs ../validation
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const cp = require('node:child_process');
const { DatabaseSync } = require('node:sqlite');
const { CodeGraph } = require('../../dist');
const { ToolHandler } = require('../../dist/mcp/tools');
const { ExtensionManager } = require('../../dist/plugins/manager');
process.env.CODEGRAPH_TELEMETRY = '0';
process.env.CODEGRAPH_PARSE_WORKERS = '2';
// Keep the large-repository run within a 4 GiB host. Compiled resolver workers
// are exercised separately by extensions-runtime.test.cjs.
process.env.CODEGRAPH_RESOLVE_WORKERS = '0';
const corpus = path.resolve(process.argv[2]);
const out = path.resolve(process.env.CORPUS_OUTPUT || '.qa/recovery/corpus'); fs.mkdirSync(out, { recursive: true });
const bytes = fs.readFileSync('dist/extensions/drupal.cgext');
const artifactSHA = crypto.createHash('sha256').update(bytes).digest('hex');
const definitions = {
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']],
},
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'],
],
},
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: [],
},
};
function snapshot(root) {
const db = new DatabaseSync(path.join(root, '.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();
const pluginEdges = edges.filter(e => JSON.parse(e.metadata || '{}').synthesizedBy === 'drupal');
return { hash: crypto.createHash('sha256').update(JSON.stringify({ nodes, edges })).digest('hex'),
files: db.prepare('SELECT count(*) AS n FROM files').get().n, nodes, edges, pluginEdges };
} finally { db.close(); }
}
async function timed(record, label, fn) {
const start = performance.now();
console.log(record.repo, label, 'started');
const value = await fn();
record.timings[label] = Math.round(performance.now() - start);
console.log(record.repo, label, record.timings[label] + 'ms');
if (value && Object.hasOwn(value, 'success')) assert.ok(value.success && value.filesErrored === 0, JSON.stringify(value.errors));
return value;
}
async function graphOperation(root, fn) {
const graph = CodeGraph.isInitialized(root) ? await CodeGraph.open(root) : await CodeGraph.init(root);
try { return await fn(graph); } finally { graph.close(); }
}
function edgeCheck(data, definition) {
const [from, sourceFile, to, targetFile, label] = definition;
const sources = new Set(data.nodes.filter(n => n.name === from && n.file_path === sourceFile).map(n => n.id));
const targets = new Set(data.nodes.filter(n => n.name === to && n.file_path === targetFile).map(n => n.id));
assert.ok(sources.size && targets.size, 'Expected symbols: ' + JSON.stringify(definition));
assert.ok(data.pluginEdges.some(e => sources.has(e.source) && targets.has(e.target) && JSON.parse(e.metadata).label === label), 'Missing edge: ' + JSON.stringify(definition));
}
(async () => {
const results = [];
for (const [repo, definition] of Object.entries(definitions)) {
const root = path.join(corpus, repo);
const record = { repo, root, revision: cp.execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), artifactSHA, timings: {}, checks: [], errors: [] };
results.push(record);
try {
// CLI-controlled corpus contains no pre-existing plugin config on its first run.
assert.ok(!new ExtensionManager(root).list().length, 'Start from a fresh corpus checkout');
await timed(record, 'builtin_fresh', () => graphOperation(root, g => g.indexAll()));
await timed(record, 'builtin_rebuild', () => graphOperation(root, g => g.refreshPluginIndex()));
let builtin = snapshot(root); record.builtin = { nodes: builtin.nodes.length, edges: builtin.edges.length, files: builtin.files };
builtin = null;
await timed(record, 'extension_install', () => new ExtensionManager(root).install({ bytes, replaces: ['drupal'] }));
let initial = snapshot(root);
record.extension = { files: initial.files, nodes: initial.nodes.length, edges: initial.edges.length, pluginEdges: initial.pluginEdges.length, hash: initial.hash };
for (const expected of [...definition.flows, ...definition.extra]) edgeCheck(initial, expected);
record.checks.push('explicit route/hook/event/DI/plugin edge assertions');
assert.ok(initial.nodes.filter(n => n.kind === 'route' && n.file_path.endsWith('.routing.yml')).every(n => n.id.startsWith('plugin:drupal:')));
const initialHash = initial.hash;
initial = null;
record.checks.push('built-in Drupal routing contributions replaced');
record.probes = [];
await graphOperation(root, async graph => {
for (let i = 0; i < definition.flows.length; i++) {
const flow = definition.flows[i], start = performance.now();
const response = await new ToolHandler(graph).execute('codegraph_explore', { query: flow[5] });
const text = response.content?.map(c => c.text || '').join('\n') || '';
fs.writeFileSync(path.join(out, repo + '-flow-' + (i + 1) + '.txt'), text);
const flowSection = text.split('**Exploration:')[0];
record.probes.push({ query: flow[5], ms: Math.round(performance.now() - start), chars: text.length,
hasFlow: /Flow|Dynamic-dispatch links/.test(flowSection), containsLabel: text.includes(flow[4]), isError: response.isError === true,
flowSection: flowSection.slice(0, 4000) });
assert.ok(!response.isError && text.includes(flow[4]), 'Explore did not surface expected flow: ' + flow[5]);
}
});
record.checks.push('three deterministic explore probes');
await timed(record, 'extension_rebuild', () => graphOperation(root, g => g.refreshPluginIndex()));
assert.equal(snapshot(root).hash, initialHash, 'Deterministic clean rebuild');
record.checks.push('full graph deterministic clean rebuild');
const probe = path.join(root, 'recovery_probe.routing.yml');
fs.writeFileSync(probe, "recovery.route:\n path: '/__recovery_probe'\n defaults:\n _controller: '\\Missing\\Unknown::missing'\n");
await timed(record, 'incremental_add', () => graphOperation(root, g => g.sync({ paths: ['recovery_probe.routing.yml'] })));
let incremental = snapshot(root);
const route = incremental.nodes.find(n => n.name === '/__recovery_probe');
assert.ok(route);
assert.ok(!incremental.pluginEdges.some(e => e.source === route.id), 'Unknown controller stays unresolved');
const incrementalHash = incremental.hash;
incremental = null;
await timed(record, 'incremental_clean_compare', () => graphOperation(root, g => g.refreshPluginIndex()));
assert.equal(snapshot(root).hash, incrementalHash, 'Incremental add converges to full rebuild');
fs.unlinkSync(probe);
await timed(record, 'incremental_remove', () => graphOperation(root, g => g.sync({ paths: ['recovery_probe.routing.yml'] })));
assert.equal(snapshot(root).hash, initialHash, 'Incremental removal restores original graph');
record.checks.push('incremental add/remove convergence and unknown-controller negative');
} catch (error) { record.errors.push(String(error.stack || error)); console.error(repo, error); }
fs.writeFileSync(path.join(out, repo + '.json'), JSON.stringify(record, null, 2));
}
fs.writeFileSync(path.join(out, 'summary.json'), JSON.stringify(results, null, 2));
console.log(JSON.stringify(results.map(({ repo, checks, errors, timings }) => ({ repo, checks, errors, timings })), null, 2));
process.exitCode = results.some(r => r.errors.length) ? 1 : 0;
})().catch(error => { console.error(error); process.exitCode = 1; });