mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
141 lines
10 KiB
JavaScript
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; });
|