GitNexus/gitnexus/test/integration/lbug-core-adapter.test.ts
Gergő Magyar 21a52af1d4
fix(lbug): ship FTS per-platform and recover in-place native aborts (#3274)
* fix(lbug): pin Ladybug core so Dependabot cannot ship a skewed FTS artifact

The extension version is a separate upstream constant. Ignore daily core bumps and fail the pairing gate when the committed manifest does not name the installed core.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): make doctor and CI FTS gates resolve the packaged artifact

Doctor and the REQUIRE_FTS file gates still treated an empty ~/.lbdb as
unavailable, which would turn three CI jobs red once analyze stops
installing into that tree.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): name native-abort and tuple-missing so analyze cannot mis-advise

The CLI summary's trailing else treated every unknown skip reason as a
missing extension. New crash and platform causes must get their own
remedies, not a network-install hint.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): delete the dead read-path FTS index create

ensureFTSIndex had no production callers and swallowed read-only
CREATE_FTS_INDEX failures, which hid the only signal that a reader
tried to write.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): vendor per-platform FTS artifacts so analyze needs no host install

Keyword search depended on a CDN fetch into ~/.lbdb. Shipping the five
published tuples inside the package makes air-gapped and ignore-scripts
installs load the same artifact the publish gate checksums.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): load the packaged FTS artifact before any network install

Analyze still required a CDN fetch into ~/.lbdb even when the package
already shipped the file. FTS now path-loads the vendored tuple first
and records source labels so a later truncated home copy cannot steal
the diagnosis.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): diagnose a core/extension version skew instead of a missing runtime

A structurally valid FTS artifact whose path version disagrees with the
packaged pin must name both versions, not prescribe VC++ or OpenSSL.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): stamp an FTS phase so repair stays usable after an in-place abort

A native CREATE_FTS_INDEX abort leaves no skip reason; the next run infers
it from the dirty flag, and --repair-fts must not treat that phase as a
half-written graph.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): park an in-place FTS crash WAL without wiping the graph

An FTS abort after a successful checkpoint must reopen the live index on
macOS, Windows, and Linux. Staging never parks the live WAL; readers keep
today's large-WAL refusal.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): refuse read-only opens of an FTS-poisoned WAL

MCP and serve cannot repair a leftover in-place abort. Fail before the
native open and name --repair-fts, on macOS, Windows, and Linux.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): name a vendor-neutral Windows OpenSSL prerequisite

OQ1 is unanswered here so GitNexus does not ship OpenSSL DLLs. Windows
FTS now asks for a system OpenSSL 3 runtime instead of Git Bash PATH.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(lbug): inject the FTS vendor root and redact it on HTTP and MCP

Path-loaded artifacts no longer vary with HOME. Tests pass an injected
vendor tree and assert search warnings never leak a filesystem path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(lbug): document load-only as the global FTS install default

Analyze still overrides to auto. Packaged per-platform artifacts load
before any network install on macOS, Windows, and Linux.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(lbug): format the FTS install-policy README table

Prettier does not run on Markdown in pre-commit, so the U10 table wrap
needs its own formatting commit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): skip FTS CREATE after a persisted native abort

A recovered analyze run was retrying CREATE_FTS_INDEX from skipReason
alone. Keep that skip until --repair-fts, fail closed on unsupported
tuples, and honor the checkpoint warrant for park/repair.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): honor checkpoint flushed warrant and align FTS tests with packaged vendor

A no-op CHECKPOINT must not satisfy the FTS park warrant, and CI still asserted HOME-only FTS isolation after analyze started path-LOADing the packaged artifact.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(lbug): accept a nonempty incremental write set in the #2790 recovery check

FTS-phase recovery can incremental-add files (changed=0, added=1). That is not the #2790 empty-diff wipe skip.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): compare FTS home versions to the core pin and tighten the publish filename gate

Ladybug's ~/.lbdb/extension directory is the runtime/core version; treating it as the artifact version false-diagnosed skew. The publish guard now rejects a path-escaping filename the same way the fetch script does.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(lbug): seed FTS e2e fixtures from the packaged vendor artifact

A machine with no ~/.lbdb copy should still run the vendor-survivorship cases; the seed no longer depends on HOME or a network install.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3274)

Keep in-place FTS abort evidence after persist so a second CREATE abort
cannot fail-open readers, and close the CLI, loader, embed, and e2e gaps
the review called out.

Note: full npm test hit Ladybug worker-pool startup failures under memory
pressure; tsc and 180 targeted unit tests passed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3274)

Run the vendored-path symlink guard on the OS matrix, put e2e HOME
fixtures on Ladybug's real extension layout, pin the embed crash-WAL
gate before the writable open, and let analyze writers park through
missing-shadow recovery.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): keep --repair-fts CI green after vendored-first FTS

Never-installed warning fixtures must not inspect a packaged vendor binary, and a failed dirty restamp must not abort an otherwise successful --repair-fts run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cli): give the #1169 analyze e2e the same 90s Windows budget as its sibling

The first #1169 persist-meta case was still on a 60s spawn/it budget and was killed banner-only on windows-latest after the FTS warning fixture no longer failed the shard first.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(ci): reweight Windows shards after the FTS e2e grew

Vendored-first HOME fixtures pushed fts-extension-e2e to ~6 minutes on windows-latest, so the old 146s weight packed it with skills-e2e and blew the 20-minute watchdog.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 08:52:24 +01:00

646 lines
31 KiB
TypeScript

/**
* P0 Integration Tests: Core LadybugDB Adapter
*
* Tests: loadGraphToLbug CSV round-trip, createFTSIndex, getLbugStats.
*
* IMPORTANT: All core adapter tests share ONE coreHandle and ONE coreInitLbug
* call because the core adapter is a module-level singleton. Calling
* coreInitLbug with a different path closes the previous native DB handle
* and opens a new one — sharing a single handle avoids unnecessary churn.
*/
import { describe, it, expect } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import type { GraphRelationship } from 'gitnexus-shared';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { skipUnlessFtsAvailable } from '../helpers/fts-availability.js';
import {
SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION,
SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX,
} from '../../src/core/ingestion/frameworks/spring/auto-configuration.js';
/**
* LadybugDB 0.16.0 has a known Windows-only regression: `Database.close()`
* does not release the underlying file lock until the process exits, so any
* `closeLbug()` followed by `initLbug(samePath)` in the same process raises
* Win32 Error 33. Production paths are unaffected (single open per process).
*
* Tracking: kuzudb/kuzu#3872 / #3883 / #4730 (file-lock UX gaps on Windows).
*/
const itLbugReopen = process.platform === 'win32' ? it.skip : it;
// The FTS extension is optional and defaults to a `load-only` install policy
// (PR #1161 — offline-first), so on a machine where it was never pre-installed
// it cannot load. The tests below exercise the FTS *primitives* directly and
// have nothing to assert without the extension — skip them rather than fail.
// Graceful degradation when FTS is unavailable is covered at the analyze /
// query layer (see run-analyze.ts and the BM25 fallback tests).
// See test/helpers/fts-availability.ts for skipUnlessFtsAvailable's contract.
// ─── Core LadybugDB Adapter ─────────────────────────────────────────────
withTestLbugDB(
'core-adapter',
(handle) => {
describe('core adapter', () => {
it('loadGraphToLbug: loads a minimal graph and node counts match', async () => {
const { executeQuery: coreExecuteQuery } =
await import('../../src/core/lbug/lbug-adapter.js');
// createMinimalTestGraph has 2 File, 2 Function, 1 Class, 1 Folder = 6 nodes
const fileRows = await coreExecuteQuery('MATCH (n:File) RETURN n.id AS id');
expect(fileRows).toHaveLength(2);
const funcRows = await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id');
expect(funcRows).toHaveLength(2);
const classRows = await coreExecuteQuery('MATCH (n:Class) RETURN n.id AS id');
expect(classRows).toHaveLength(1);
const folderRows = await coreExecuteQuery('MATCH (n:Folder) RETURN n.id AS id');
expect(folderRows).toHaveLength(1);
});
it('createFTSIndex: creates FTS index on Function table without error', async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');
await expect(
createFTSIndex('Function', 'function_fts', ['name', 'content']),
).resolves.toBeUndefined();
});
it('loadFTSExtension(conn): loads on an explicit connection and returns true', async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const lbug = (await import('@ladybugdb/core')).default;
const { loadFTSExtension, getDatabase } =
await import('../../src/core/lbug/lbug-adapter.js');
const db = getDatabase();
expect(db).not.toBeNull();
// Fresh Connection on the same Database — simulates the pool adapter's
// path where loadFTSExtension is called with an explicit connection
// rather than the module-level singleton.
const freshConn = new lbug.Connection(db!);
try {
const loaded = await loadFTSExtension(freshConn);
expect(loaded).toBe(true);
// Idempotent on the same connection — calling again still returns true
// (exercises the "already loaded" catch branch in the fallback path).
const loadedAgain = await loadFTSExtension(freshConn);
expect(loadedAgain).toBe(true);
} finally {
await freshConn.close().catch(() => {});
}
});
it('getLbugStats: returns correct node and edge counts for seeded data', async () => {
const { getLbugStats } = await import('../../src/core/lbug/lbug-adapter.js');
const stats = await getLbugStats();
// createMinimalTestGraph: 6 nodes (2 File, 2 Function, 1 Class, 1 Folder)
expect(stats.nodes).toBe(6);
// 4 relationships (2 CALLS, 2 CONTAINS)
expect(stats.edges).toBe(4);
// STRUCTURAL count must be a real number, not `undefined`.
//
// This assertion exists because the failure mode is silent: the query is
// wrapped in a try/catch that yields `undefined` on error, and
// `undefined` makes the graph-write-collapse check decline to compare.
// A typo in the Cypher would therefore not throw, not fail any test, and
// simply switch the collapse guard off — the exact shape of
// confidently-doing-nothing this whole area exists to prevent.
//
// The seeded graph has no PDG layers, so structural == total here; the
// point is that the count was TAKEN.
expect(stats.structuralEdges).toBe(4);
// ...and that it was taken WITHOUT an error, which is the fact the
// collapse guard reads to tell "measured" from "could not measure".
expect(stats.structuralEdgesError).toBeUndefined();
});
it('getLbugStats: the structural count EXCLUDES PDG rows that `edges` counts', async () => {
// The assertion above cannot see the `WHERE NOT r.type IN [...]` filter
// at all — its own comment says "structural == total here" — so a broken
// or dropped predicate would pass it unchanged while silently switching
// the graph-write-collapse guard from a structural comparison back to
// the total one that let PDG volume mask structural loss.
//
// Seeds a real PDG-typed row (same CREATE pattern as the
// deleteAllInterprocTaintPaths test below) and asserts the two counts
// DIVERGE by exactly it. Removed again at the end: the count-based
// assertions in this file share one singleton DB and run in declaration
// order.
const { getLbugStats, executeQuery: coreExecuteQuery } =
await import('../../src/core/lbug/lbug-adapter.js');
const before = await getLbugStats();
expect(before.edges).toBe(before.structuralEdges);
const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as {
id: string;
}[];
expect(fns.length).toBe(2);
await coreExecuteQuery(
`MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
`CREATE (a)-[:CodeRelation {type: 'CFG', confidence: 1.0, reason: 'seq', step: 0}]->(b)`,
);
try {
const after = await getLbugStats();
// The total sees the new row...
expect(after.edges).toBe((before.edges ?? 0) + 1);
// ...and the structural count does NOT.
expect(after.structuralEdges).toBe(before.structuralEdges);
expect(after.structuralEdgesError).toBeUndefined();
} finally {
await coreExecuteQuery(`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'CFG' DELETE r`);
}
// Restored, so the later count-based assertions still see the seeded graph.
const restored = await getLbugStats();
expect(restored.edges).toBe(before.edges);
expect(restored.structuralEdges).toBe(before.structuralEdges);
});
it('deleteAllInterprocTaintPaths: removes TAINT_PATH edges and is benign when none exist (#2084 review P2-5)', async () => {
const { executeQuery: coreExecuteQuery, deleteAllInterprocTaintPaths } =
await import('../../src/core/lbug/lbug-adapter.js');
// Benign: no TAINT_PATH rows yet → returns 0, does NOT throw.
await expect(deleteAllInterprocTaintPaths()).resolves.toEqual({ edgesDeleted: 0 });
// Seed one TAINT_PATH edge between the two seeded Function nodes, then
// delete-all and confirm it is removed (the incremental-rebuild guard).
const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as {
id: string;
}[];
expect(fns.length).toBe(2);
await coreExecuteQuery(
`MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
`CREATE (a)-[:CodeRelation {type: 'TAINT_PATH', confidence: 0.6, reason: '1', step: 0}]->(b)`,
);
const r = await deleteAllInterprocTaintPaths();
expect(r.edgesDeleted).toBe(1);
const left = await coreExecuteQuery(
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'TAINT_PATH' RETURN count(r) AS cnt`,
);
expect(Number((left[0] as { cnt: number }).cnt)).toBe(0);
});
it('deleteAllInjects: removes only INJECTS edges and is benign when none exist (#2200)', async () => {
// Mirrors the deleteAllInterprocTaintPaths test above (same contract:
// COUNT-then-DELETE, missing-table carve-out, re-throw otherwise).
// The re-throw path is not simulated here — doing so would require
// breaking the shared singleton connection mid-suite. Its benign-vs-
// rethrow classification is pinned as a pure function instead:
// `classifyDeleteAllError` (lbug-config.ts), exhaustively covered in
// test/unit/lbug-delete-all-error.test.ts.
const { executeQuery: coreExecuteQuery, deleteAllInjects } =
await import('../../src/core/lbug/lbug-adapter.js');
// Benign: no INJECTS rows yet → returns 0, does NOT throw.
await expect(deleteAllInjects()).resolves.toEqual({ edgesDeleted: 0 });
// Seed one INJECTS edge plus one edge of ANOTHER type between the two
// seeded Function nodes, then delete-all and confirm exactly the
// INJECTS row is removed while the other-typed row survives.
const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as {
id: string;
}[];
expect(fns.length).toBe(2);
await coreExecuteQuery(
`MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
`CREATE (a)-[:CodeRelation {type: 'INJECTS', confidence: 0.8, reason: 'di', step: 0}]->(b)`,
);
await coreExecuteQuery(
`MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
`CREATE (a)-[:CodeRelation {type: 'QUERIES', confidence: 0.8, reason: 'orm', step: 0}]->(b)`,
);
const r2 = await deleteAllInjects();
expect(r2.edgesDeleted).toBe(1);
const injectsLeft = await coreExecuteQuery(
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'INJECTS' RETURN count(r) AS cnt`,
);
expect(Number((injectsLeft[0] as { cnt: number }).cnt)).toBe(0);
const queriesLeft = await coreExecuteQuery(
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'QUERIES' RETURN count(r) AS cnt`,
);
expect(Number((queriesLeft[0] as { cnt: number }).cnt)).toBe(1);
});
it('deleteAllAdvisedBy: removes only ADVISED_BY edges and is benign when none exist (#2416)', async () => {
const { executeQuery: coreExecuteQuery, deleteAllAdvisedBy } =
await import('../../src/core/lbug/lbug-adapter.js');
await expect(deleteAllAdvisedBy()).resolves.toEqual({ edgesDeleted: 0 });
const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as Array<{
id: string;
}>;
expect(fns.length).toBe(2);
await coreExecuteQuery(
`MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
`CREATE (a)-[:CodeRelation {type: 'ADVISED_BY', confidence: 0.95, reason: 'spring-aop:v1:{}', step: 0}]->(b)`,
);
await expect(deleteAllAdvisedBy()).resolves.toEqual({ edgesDeleted: 1 });
const advisedLeft = await coreExecuteQuery(
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'ADVISED_BY' RETURN count(r) AS cnt`,
);
expect(Number((advisedLeft[0] as { cnt: number }).cnt)).toBe(0);
});
it('deleteSpringAopEvidenceNodes: keys deletion to the owned ID namespace (#2416)', async () => {
const { executeQuery: coreExecuteQuery, deleteSpringAopEvidenceNodes } =
await import('../../src/core/lbug/lbug-adapter.js');
await expect(deleteSpringAopEvidenceNodes()).resolves.toEqual({ nodesDeleted: 0 });
await coreExecuteQuery(
`CREATE (:CodeElement {id: 'CodeElement:spring-aop:test-evidence', name: 'Aop', filePath: 'A.java', startLine: 1, endLine: 1, isExported: false, content: '', description: 'ordinary text'})`,
);
await coreExecuteQuery(
`CREATE (:CodeElement {id: 'CodeElement:ordinary-lookalike', name: 'Other', filePath: 'B.java', startLine: 1, endLine: 1, isExported: false, content: '', description: 'Spring AOP: lookalike'})`,
);
await expect(deleteSpringAopEvidenceNodes()).resolves.toEqual({ nodesDeleted: 1 });
const rows = await coreExecuteQuery(
`MATCH (n:CodeElement) WHERE n.id IN ['CodeElement:spring-aop:test-evidence', 'CodeElement:ordinary-lookalike'] RETURN n.id AS id`,
);
expect(rows).toEqual([{ id: 'CodeElement:ordinary-lookalike' }]);
await coreExecuteQuery(
`MATCH (n:CodeElement {id: 'CodeElement:ordinary-lookalike'}) DETACH DELETE n`,
);
});
it('persists the Interface Spring AOP evidence relation pair (#2416)', async () => {
const { executeQuery: coreExecuteQuery } =
await import('../../src/core/lbug/lbug-adapter.js');
await coreExecuteQuery(
`CREATE (:Interface {id: 'Interface:aop-test', name: 'AdvisedInterface', filePath: 'I.java', startLine: 1, endLine: 2, isExported: true, content: '', description: ''})`,
);
await coreExecuteQuery(
`CREATE (:CodeElement {id: 'CodeElement:aop-interface-evidence', name: 'Transactional', filePath: 'I.java', startLine: 1, endLine: 1, isExported: false, content: '', description: 'Spring AOP evidence'})`,
);
await coreExecuteQuery(
`MATCH (source:Interface {id: 'Interface:aop-test'}), (target:CodeElement {id: 'CodeElement:aop-interface-evidence'}) CREATE (source)-[:CodeRelation {type: 'ADVISED_BY', confidence: 1.0, reason: 'spring-aop:v1:{}', step: 0}]->(target)`,
);
const rows = await coreExecuteQuery(
`MATCH (source)-[r:CodeRelation]->() WHERE source.id = 'Interface:aop-test' AND r.type = 'ADVISED_BY' RETURN source.id AS id`,
);
expect(rows).toEqual([{ id: 'Interface:aop-test' }]);
await coreExecuteQuery(
`MATCH (n) WHERE n.id IN ['Interface:aop-test', 'CodeElement:aop-interface-evidence'] DETACH DELETE n`,
);
});
it('deleteSpringAutoConfigurationDeclarations: removes only Spring DECLARES edges (#2415)', async () => {
const { executeQuery: coreExecuteQuery, deleteSpringAutoConfigurationDeclarations } =
await import('../../src/core/lbug/lbug-adapter.js');
await expect(deleteSpringAutoConfigurationDeclarations()).resolves.toEqual({
edgesDeleted: 0,
});
const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as Array<{
id: string;
}>;
expect(fns.length).toBe(2);
await coreExecuteQuery(
`MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
`CREATE (a)-[:CodeRelation {type: 'DECLARES', confidence: 1.0, reason: 'spring-auto-configuration-import', step: 0}]->(b)`,
);
await coreExecuteQuery(
`MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
`CREATE (a)-[:CodeRelation {type: 'DECLARES', confidence: 1.0, reason: 'spring-auto-configuration-factory', step: 0}]->(b)`,
);
await coreExecuteQuery(
`MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
`CREATE (a)-[:CodeRelation {type: 'DECLARES', confidence: 1.0, reason: 'other-metadata-system', step: 0}]->(b)`,
);
await expect(deleteSpringAutoConfigurationDeclarations()).resolves.toEqual({
edgesDeleted: 2,
});
const left = await coreExecuteQuery(
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'DECLARES' RETURN count(r) AS cnt`,
);
expect(Number((left[0] as { cnt: number }).cnt)).toBe(1);
});
it('deleteSpringAutoConfigurationSyntheticClasses: removes only metadata placeholders (#2415)', async () => {
const { executeQuery: coreExecuteQuery, deleteSpringAutoConfigurationSyntheticClasses } =
await import('../../src/core/lbug/lbug-adapter.js');
await coreExecuteQuery(
`CREATE (:Class {id: '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX}com.example.ExternalAutoConfiguration', ` +
`name: 'ExternalAutoConfiguration', ` +
`filePath: 'META-INF/spring.factories', ` +
`description: '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION}'})`,
);
await coreExecuteQuery(
`CREATE (:Class {id: 'Class:src/Real.java:Real', name: 'Real', ` +
`filePath: 'src/Real.java', ` +
`description: '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION}'})`,
);
await expect(deleteSpringAutoConfigurationSyntheticClasses()).resolves.toEqual({
nodesDeleted: 1,
});
const syntheticLeft = await coreExecuteQuery(
`MATCH (n:Class) WHERE n.id STARTS WITH '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX}' ` +
'RETURN count(n) AS cnt',
);
expect(Number((syntheticLeft[0] as { cnt: number }).cnt)).toBe(0);
const realClasses = await coreExecuteQuery('MATCH (n:Class) RETURN count(n) AS cnt');
expect(Number((realClasses[0] as { cnt: number }).cnt)).toBe(2);
});
describe('unhappy path', () => {
it('throws on malformed Cypher query', async () => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
// Deliberately broken syntax: MATCH without a pattern clause
await expect(executeQuery('MATCH RETURN 1')).rejects.toThrow();
});
it('returns empty results for query matching no nodes', async () => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
// Valid Cypher, but the id will never exist in the seeded graph
const rows = await executeQuery(
"MATCH (n:Function) WHERE n.id = '__nonexistent_id__' RETURN n.id AS id",
);
expect(rows).toHaveLength(0);
});
it('handles query with non-existent table/node label', async () => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
// LadybugDB throws when the node table does not exist in the schema
await expect(executeQuery('MATCH (n:GhostTable) RETURN n')).rejects.toThrow();
});
});
describe('error handling', () => {
it('createFTSIndex handles already-existing index gracefully', async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');
// First call creates the index (may already exist from earlier test)
await createFTSIndex('Function', 'function_fts_dup', ['name', 'content']);
// Second call with same params should NOT throw — createFTSIndex catches "already exists"
await expect(
createFTSIndex('Function', 'function_fts_dup', ['name', 'content']),
).resolves.toBeUndefined();
});
it('getLbugStats returns valid counts', async () => {
const { getLbugStats } = await import('../../src/core/lbug/lbug-adapter.js');
// getLbugStats NEVER throws — it has silent catch blocks per table
const stats = await getLbugStats();
expect(typeof stats.nodes).toBe('number');
expect(typeof stats.edges).toBe('number');
expect(stats.nodes).toBeGreaterThanOrEqual(0);
expect(stats.edges).toBeGreaterThanOrEqual(0);
});
it('executeQuery with empty string rejects', async () => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
// LadybugDB throws on empty query string
await expect(executeQuery('')).rejects.toThrow();
});
it('deleteNodesForFile with non-existent path returns zero deleted', async () => {
const { deleteNodesForFile } = await import('../../src/core/lbug/lbug-adapter.js');
// deleteNodesForFile has per-query try/catch, returns {deletedNodes: 0} for missing paths
const result = await deleteNodesForFile('/absolutely/nonexistent/path/file.ts');
expect(result).toEqual({ deletedNodes: 0 });
});
});
itLbugReopen(
'initLbug loads FTS so reopened HTTP-style sessions can query existing indexes',
async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const indexName = 'function_fts_init_probe';
await adapter.createFTSIndex('Function', indexName, ['name', 'content']);
await adapter.closeLbug();
await adapter.initLbug(handle.dbPath);
await expect(adapter.queryFTS('Function', indexName, 'main', 5)).resolves.toEqual(
expect.arrayContaining([expect.objectContaining({ filePath: 'src/index.ts' })]),
);
},
);
// ── Cypher escaping sweep (#2409, tri-review 4669518496 P2-2) ─────
// Quoted-value round-trips through the three string-built statement
// builders that used SQL-style `''` doubling — which LadybugDB rejects
// as a parse error, so every quoted value silently failed wherever the
// call site swallowed per-row errors. Declared LAST on purpose: these
// tests APPEND rows to the shared singleton DB, and the count-based
// assertions above (getLbugStats, the loadGraphToLbug round-trip) run
// first in declaration order.
describe('string-built Cypher escaping (quoted values)', () => {
it('insertNodeToLbug: quoted filePath/name/content round-trip by exact match', async () => {
const { insertNodeToLbug, executeQuery } =
await import('../../src/core/lbug/lbug-adapter.js');
const { escapeCypherString } = await import('../../src/core/lbug/cypher-escape.js');
const filePath = "src/es'cape-probe.ts";
const inserted = await insertNodeToLbug('File', {
id: `File:${filePath}`,
name: "es'cape-probe.ts",
filePath,
content: "const s = 'quoted';",
});
expect(inserted).toBe(true);
const rows = await executeQuery(
`MATCH (n:File) WHERE n.filePath = '${escapeCypherString(filePath)}' ` +
`RETURN n.id AS id, n.name AS name, n.content AS content`,
);
expect(rows).toEqual([
{ id: `File:${filePath}`, name: "es'cape-probe.ts", content: "const s = 'quoted';" },
]);
});
it('fallbackRelationshipInserts: quoted endpoint ids create the edge; quoted reason round-trips', async () => {
const { fallbackRelationshipInserts, insertNodeToLbug, executeQuery } =
await import('../../src/core/lbug/lbug-adapter.js');
const { getNodeLabel } = await import('../../src/core/lbug/rel-pair-routing.js');
const { REL_CSV_HEADER, buildRelRow } =
await import('../../src/core/lbug/csv-generator.js');
const { NODE_TABLES, REL_TABLE_NAME } = await import('../../src/core/lbug/schema.js');
const { escapeCypherString } = await import('../../src/core/lbug/cypher-escape.js');
const quotedFile = "src/we'ird.ts";
const fnId = `Function:${quotedFile}:fn:1`;
const fileId = `File:${quotedFile}`;
expect(
await insertNodeToLbug('Function', {
id: fnId,
name: 'fn',
filePath: quotedFile,
startLine: 1,
endLine: 3,
isExported: true,
content: 'function fn() {}',
}),
).toBe(true);
expect(
await insertNodeToLbug('File', {
id: fileId,
name: "we'ird.ts",
filePath: quotedFile,
content: '',
}),
).toBe(true);
// Real buildRelRow bytes + the real rel-pair-routing getNodeLabel —
// exactly the shapes the production COPY-failure fallback receives.
// Direction is File→Function because that is a pair the CodeRelation
// rel table declares (schema.ts); Function→File is NOT declared, so
// the reverse edge would exercise schema validation, not escaping.
// NOTE (pre-existing narrowing, distinct from the `''` escaping bug
// and NOT fixed here): the fallback's row regex matches fields with
// `[^"]*`, so an id containing a double quote never matches and its
// edge is skipped — see the fallbackRelationshipInserts TSDoc.
const rel: GraphRelationship = {
id: 'rel-escaping-sweep-1',
sourceId: fileId,
targetId: fnId,
type: 'CALLS',
confidence: 1,
reason: "it's quoted",
step: 0,
};
await fallbackRelationshipInserts(
[REL_CSV_HEADER, buildRelRow(rel)],
new Set<string>(NODE_TABLES),
getNodeLabel,
);
const edges = await executeQuery(
`MATCH (a)-[r:${REL_TABLE_NAME}]->(b) ` +
`WHERE r.reason = '${escapeCypherString("it's quoted")}' ` +
`RETURN a.id AS fromId, b.id AS toId, r.type AS type, r.reason AS reason`,
);
expect(edges).toEqual([
{ fromId: fileId, toId: fnId, type: 'CALLS', reason: "it's quoted" },
]);
});
itLbugReopen(
'batchInsertNodesToLbug: quoted values MERGE cleanly over its own connection',
async () => {
// batchInsertNodesToLbug opens its OWN connection on dbPath, which
// cannot coexist with the singleton's exclusive file lock — close
// the singleton around the call and reopen after. win32-skipped
// for the same close→reopen native lock regression as the FTS
// reopen probe above. Labels are File + Class (NOT Function): the
// earlier tests in this suite put FTS indexes on Function, and a
// write to an FTS-indexed table fails on a connection that has not
// loaded the FTS extension (probed on 0.18.0) — an orthogonal
// engine behavior this escaping test must not trip over. Class
// exercises the same TABLES_WITH_EXPORTED + description branch.
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const { escapeCypherString } = await import('../../src/core/lbug/cypher-escape.js');
const filePath = "src/ba'tch.ts";
await adapter.closeLbug();
let result: { inserted: number; failed: number };
try {
result = await adapter.batchInsertNodesToLbug(
[
{
label: 'File',
properties: {
id: `File:${filePath}`,
name: "ba'tch.ts",
filePath,
content: "let q = 'x';",
},
},
{
label: 'Class',
properties: {
id: `Class:${filePath}:K:1`,
name: 'K',
filePath,
startLine: 1,
endLine: 2,
isExported: false,
content: '',
description: "batch'd",
},
},
],
handle.dbPath,
);
} finally {
await adapter.initLbug(handle.dbPath);
}
expect(result).toEqual({ inserted: 2, failed: 0 });
const rows = await adapter.executeQuery(
`MATCH (n:Class) WHERE n.filePath = '${escapeCypherString(filePath)}' ` +
`RETURN n.name AS name, n.description AS description`,
);
expect(rows).toEqual([{ name: 'K', description: "batch'd" }]);
},
);
it('backslash and raw-LF/CR values round-trip byte-identical', async () => {
// The old escapeValue closures rewrote literal \n / \r into
// two-character escape sequences; raw LF/CR are legal inside
// LadybugDB single-quoted literals (live-probed on 0.18.0), so the
// replaces are gone and content bytes must survive unchanged.
const { insertNodeToLbug, executeQuery } =
await import('../../src/core/lbug/lbug-adapter.js');
const { escapeCypherString } = await import('../../src/core/lbug/cypher-escape.js');
const id = 'File:src/bytes-probe.ts';
const content = "line1\nC:\\temp\\it's ok\r\nline3";
expect(
await insertNodeToLbug('File', {
id,
name: 'bytes-probe.ts',
filePath: 'src/bytes-probe.ts',
content,
}),
).toBe(true);
const rows = await executeQuery(
`MATCH (n:File) WHERE n.id = '${escapeCypherString(id)}' RETURN n.content AS content`,
);
expect(rows).toEqual([{ content }]);
});
});
});
},
{
afterSetup: async (handle) => {
// Load a minimal graph via CSV round-trip (core adapter is already initialized by wrapper)
const { loadGraphToLbug } = await import('../../src/core/lbug/lbug-adapter.js');
const { createMinimalTestGraph } = await import('../helpers/test-graph.js');
const graph = createMinimalTestGraph();
const storagePath = path.join(handle.tmpHandle.dbPath, 'storage');
await fs.mkdir(storagePath, { recursive: true });
await loadGraphToLbug(graph, '/test/repo', storagePath);
},
},
);