GitNexus/gitnexus/test/integration/spring-destinations-incremental.test.ts

202 lines
7.6 KiB
TypeScript

/**
* The SECOND analyze.
*
* Every other test for this feature indexes from scratch exactly once, and
* that gap is precisely why both halves of the incremental defect survived
* review: a resolved `Destination` stores no `filePath`, which is what stops
* the per-file `DETACH DELETE ... WHERE n.filePath IN [...]` cutting a node
* shared across files — and which also made the writeback unable to ADD one or
* to REMOVE one.
*
* Additions were lost. `extractChangedSubgraph` includes a node only when
* its filePath is in the write set or it is graph-wide, and `Destination`
* was neither. Adding a file that published to a NEW topic reported
* `added=1`, exited 0, printed no warning, and put neither the destination
* nor the publisher's edge into the graph. After the first index every newly
* introduced topic was invisible until a full rebuild.
*
* Removals never happened. When the last file naming an address stopped
* naming it, the node survived as an edgeless orphan and accumulated on
* every run — still carrying `address`, the cross-repository join key, and
* still visible through the server API.
*
* This suite drives the real `runFullAnalysis` three times over a real git
* repository and asserts the DB after each, which is the only place either
* defect is observable.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFileSync } from 'node:child_process';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { runFullAnalysis } from '../../src/core/run-analyze.js';
import { executeQuery, closeLbug, initLbug } from '../../src/core/lbug/lbug-adapter.js';
import { getStoragePaths } from '../../src/storage/repo-manager.js';
import { createTempDir } from '../helpers/test-db.js';
const isWin = process.platform === 'win32';
const CONSUMER = `package com.example.messaging;
import org.springframework.kafka.annotation.KafkaListener;
public class OrderConsumer {
@KafkaListener(topics = "orders.v1")
public void consume(String payload) {}
}
`;
const PUBLISHER = `package com.example.messaging;
import org.springframework.kafka.core.KafkaTemplate;
public class OrderPublisher {
private final KafkaTemplate<String, String> kafkaTemplate;
public OrderPublisher(KafkaTemplate<String, String> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publish(String payload) {
kafkaTemplate.send("orders.v1", payload);
}
}
`;
/** The file added by the second analyze: a topic no other file names. */
const LATE_PUBLISHER = `package com.example.messaging;
import org.springframework.kafka.core.KafkaTemplate;
public class ShipmentPublisher {
private final KafkaTemplate<String, String> kafkaTemplate;
public ShipmentPublisher(KafkaTemplate<String, String> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publish(String payload) {
kafkaTemplate.send("shipments.v1", payload);
}
}
`;
describe.skipIf(isWin)('Destination across a second, incremental analyze', () => {
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
let repo: string;
let savedHome: string | undefined;
const srcDir = (): string =>
path.join(repo, 'src', 'main', 'java', 'com', 'example', 'messaging');
// Two argv-form calls rather than one shell string joined by `&&`: the commit
// message is a parameter, and interpolating it into a shell command would let
// a message containing a space split into extra arguments — the command would
// change meaning rather than fail visibly. No caller passes such a message
// today, which is exactly why the shell form would have stayed wrong quietly.
const commit = (message: string): void => {
execFileSync('git', ['add', '-A'], { cwd: repo, stdio: 'pipe' });
execFileSync(
'git',
['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', message],
{ cwd: repo, stdio: 'pipe' },
);
};
/** Run analyze and report whether the INCREMENTAL writeback branch ran. */
const analyze = async (): Promise<{ incremental: boolean }> => {
let incremental = false;
await runFullAnalysis(
repo,
{},
{
onProgress: (_phase: string, _pct: number, message?: string) => {
// Emitted only by the incremental writeback branch. Without this the
// whole suite could pass vacuously on a silent full rebuild.
if (message?.startsWith('Removing rows for changed files')) incremental = true;
},
},
);
return { incremental };
};
const openDb = async (): Promise<void> => {
const { lbugPath } = getStoragePaths(repo);
await initLbug(lbugPath);
};
const destinations = async (): Promise<
Array<{ name: string; address: string | null; edges: number }>
> => {
await openDb();
try {
const rows = await executeQuery(
'MATCH (d:Destination) OPTIONAL MATCH (m)-[r:CodeRelation]->(d) ' +
'RETURN d.name AS name, d.address AS address, count(r) AS edges ORDER BY name',
);
return rows.map((row) => ({
name: String(row.name),
address: (row.address ?? null) as string | null,
edges: Number(row.edges ?? 0),
}));
} finally {
await closeLbug();
}
};
beforeAll(async () => {
tmpHome = await createTempDir('gn-dest-incr-home-');
savedHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
tmpRepo = await createTempDir('gn-dest-incr-repo-');
repo = tmpRepo.dbPath;
execFileSync('git', ['init', '-q'], { cwd: repo, stdio: 'pipe' });
await fs.mkdir(srcDir(), { recursive: true });
await fs.writeFile(path.join(srcDir(), 'OrderConsumer.java'), CONSUMER);
await fs.writeFile(path.join(srcDir(), 'OrderPublisher.java'), PUBLISHER);
commit('init');
}, 60_000);
afterAll(async () => {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await tmpRepo?.cleanup();
await tmpHome?.cleanup();
});
it('indexes the shared destination on the first, from-scratch run', async () => {
await analyze();
const rows = await destinations();
expect(rows).toEqual([{ name: 'orders.v1', address: 'orders.v1', edges: 2 }]);
}, 180_000);
it('ADDS a destination introduced by a new file on the second, incremental run', async () => {
await fs.writeFile(path.join(srcDir(), 'ShipmentPublisher.java'), LATE_PUBLISHER);
commit('add-publisher');
const { incremental } = await analyze();
expect(incremental).toBe(true);
const rows = await destinations();
// The defect: `shipments.v1` was absent entirely and ShipmentPublisher had
// no PUBLISHES_TO edge, with exit 0 and no warning.
expect(rows).toEqual([
{ name: 'orders.v1', address: 'orders.v1', edges: 2 },
{ name: 'shipments.v1', address: 'shipments.v1', edges: 1 },
]);
}, 180_000);
it('REMOVES a destination whose last referrer is gone, leaving no orphan', async () => {
await fs.rm(path.join(srcDir(), 'ShipmentPublisher.java'));
commit('drop-publisher');
const { incremental } = await analyze();
expect(incremental).toBe(true);
const rows = await destinations();
// The mirror defect: `shipments.v1` survived as an edgeless orphan that
// still carried `address`, and accumulated on every subsequent run. The
// untouched half must be intact — deleting the layer without re-including
// it would show up here as orders.v1 losing its edges or vanishing.
expect(rows).toEqual([{ name: 'orders.v1', address: 'orders.v1', edges: 2 }]);
}, 180_000);
});