mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
* feat(review): add PR reviewer swarm agents
Seven read-only subagents coordinated by an orchestration skill for
structured, evidence-grounded production-readiness PR reviews.
Agents: facts-historian, branch-hygiene, risk-architect, test-ci-verifier,
security-boundary, docs-dod, synthesis-critic. All use Read/Grep/Glob/Bash
only — no edit tools.
Skill invoked as /gitnexus-pr-swarm-review <PR>.
* fix: patch vector extension and uncaughtException for review findings
- Add { policy: 'auto' } to both loadVectorExtension() calls in
embedding-pipeline.ts so analyze --embeddings auto-installs VECTOR
- Add void to uncaughtException shutdown(1) call for Node v20+ safety
- Re-add getExtensionInstallPolicy export + default change + 4 tests
* fix(mcp,lbug): graceful shutdown exit codes + complete offline-first VECTOR policy
Completes the two live issues PR #1161 only partially addressed.
#1132 — MCP shutdown crash: SIGINT/SIGTERM were registered with `shutdown`
directly, so Node passed the signal NAME string into process.exit(), crashing
with ERR_INVALID_ARG_TYPE ('SIGTERM'). Map signals to numeric exit codes
(SIGINT->130, SIGTERM->143) via a testable installSignalShutdown(); add an
unref'd force-exit watchdog so a hung disconnect()/close() cannot wedge
shutdown; and void the stdin/stdout handlers so event payloads never reach
process.exit() as a non-number.
#1153 — offline-first extension loading:
- semanticSearch (a query/read path) no longer forces policy:'auto'; queries
use load-only and never spawn a network INSTALL (extension.ladybugdb.com).
- the analyze embedding WRITE path resolves the policy from
GITNEXUS_LBUG_EXTENSION_INSTALL (honoring never/load-only/auto; default auto)
instead of hard-forcing 'auto', so an offline/locked-down operator's override
is respected (the regression that re-broke #1153 for the VECTOR path).
- surface the active install policy in `gitnexus doctor` (was claimed but never
delivered; also gives the previously-dead getExtensionInstallPolicy a caller).
- emit an actionable message when VECTOR is unavailable.
Tests: regression for the signal->numeric mapping (reproduces the signal-string
crash condition) and for embedding install-policy resolution. tsc/prettier clean,
eslint 0 errors, 55 unit tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(analyze): degrade gracefully when FTS extension is unavailable
The load-only default made `gitnexus analyze` throw when the FTS
extension was not pre-installed, breaking CI and offline use. Make the
analyze write path opt into the `auto` install policy (LOAD-first then
bounded INSTALL — symmetric with the VECTOR/embeddings path and the #726
contract) and degrade gracefully when the extension still cannot load:
skip search-index creation, log a warning, and complete with a fully
queryable graph (only full-text/BM25 search is disabled). `--repair-fts`
still fails loudly.
- Surface the degraded state instead of reporting healthy:
AnalyzeResult.ftsSkipped, a persistent CLI summary warning, and
meta.json capabilities.fts.status = "unavailable".
- Skip the FTS-primitive integration tests when the extension is
unavailable (shared skipUnlessFtsAvailable helper).
- Add a unit test for the degradation branch; fix the existing
full-analyze test mock that omitted loadFTSExtension.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(lbug): skip FTS-seeding suites when extension is unavailable
The withTestLbugDB helper seeds FTS indexes in beforeAll via createFTSIndex,
which throws when the optional FTS extension cannot load — failing the whole
suite on machines where it is neither pre-installed nor installable (the
macOS platform-sensitive CI runner). Probe the extension once (mirroring the
analyze write path's `auto` policy), bypass FTS seeding when it is
unavailable, and skip the suite's tests via beforeEach with a one-time
warning so the skip is visible rather than a setup crash.
Fixes the macOS failures in search-core, search-pool, local-backend-calltool,
and staleness-and-stability. Suites still run normally where FTS is available.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
231 lines
10 KiB
TypeScript
231 lines
10 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 { withTestLbugDB } from '../helpers/test-indexed-db.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).
|
|
*/
|
|
const FTS_UNAVAILABLE_NOTE =
|
|
'FTS extension unavailable (load-only policy; not pre-installed on this machine)';
|
|
|
|
/**
|
|
* Dynamically skip an FTS-primitive test when the extension cannot load.
|
|
* `ctx.skip()` aborts the test, so callers should `await` this first thing.
|
|
*/
|
|
const skipUnlessFtsAvailable = async (ctx: { skip: (note?: string) => void }): Promise<void> => {
|
|
const { loadFTSExtension } = await import('../../src/core/lbug/lbug-adapter.js');
|
|
if (!(await loadFTSExtension())) ctx.skip(FTS_UNAVAILABLE_NOTE);
|
|
};
|
|
|
|
// ─── 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);
|
|
});
|
|
|
|
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('ensureFTSIndex is idempotent and caches across writable calls (#1224)', async (ctx) => {
|
|
await skipUnlessFtsAvailable(ctx);
|
|
const { ensureFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');
|
|
|
|
// First call creates the index. Second call must short-circuit on the
|
|
// in-process cache — guarantees the read-only guard added in #1224
|
|
// still respects the success path.
|
|
await expect(
|
|
ensureFTSIndex('Function', 'function_fts_ensure', ['name', 'content']),
|
|
).resolves.toBeUndefined();
|
|
await expect(
|
|
ensureFTSIndex('Function', 'function_fts_ensure', ['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' })]),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
},
|
|
{
|
|
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);
|
|
},
|
|
},
|
|
);
|