mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-02 02:11:29 +00:00
* 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>
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
/**
|
|
* #2589: `dropFTSIndex` must tolerate only benign "nothing to drop"
|
|
* `DROP_FTS_INDEX` failures and rethrow everything else — previously it
|
|
* swallowed every error unconditionally, which could mask a genuinely
|
|
* corrupted FTS index across analyze runs.
|
|
*
|
|
* `isBenignDropFtsIndexError` is pure string logic (no native connection
|
|
* needed), so the classification itself is unit-tested directly, including
|
|
* against the exact reported #2589 error text — a native repro of that
|
|
* specific engine failure was not achieved during investigation, but the
|
|
* classifier's behavior for it is still provable from the message alone.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { isBenignDropFtsIndexError, dropFTSIndex } from '../../src/core/lbug/lbug-adapter.js';
|
|
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
|
import { createTempDir } from '../helpers/test-db.js';
|
|
import {
|
|
resetExtensionState,
|
|
resolveAnalyzeInstallPolicy,
|
|
} from '../../src/core/lbug/extension-loader.js';
|
|
|
|
describe('isBenignDropFtsIndexError', () => {
|
|
it('is true for the FTS-extension/function-not-registered catalog error (probe-verified text)', () => {
|
|
expect(
|
|
isBenignDropFtsIndexError(
|
|
"Catalog exception: function DROP_FTS_INDEX is not defined. This function exists in the FTS extension. You can install and load the extension by running 'INSTALL FTS; LOAD EXTENSION FTS;'.",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('is true for the index-never-created binder error (probe-verified against the real dropFTSIndex path)', () => {
|
|
expect(
|
|
isBenignDropFtsIndexError(
|
|
"Binder exception: Table File doesn't have an index with name file_fts.",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('is false for the #2589 runtime inconsistency error (must surface, not be swallowed)', () => {
|
|
expect(
|
|
isBenignDropFtsIndexError(
|
|
"Runtime exception: FTS index 'file_fts' is inconsistent: term 'wiki' is missing during delete.",
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('is false for an unrelated failure', () => {
|
|
expect(isBenignDropFtsIndexError('Connection Exception: database is closed')).toBe(false);
|
|
});
|
|
|
|
it('is false for a genuine failure that merely mentions "Binder exception" mid-message (anchored, not a bare substring match)', () => {
|
|
expect(
|
|
isBenignDropFtsIndexError(
|
|
'Runtime exception: internal state corrupted while processing Binder exception: recovery failed.',
|
|
),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
withTestLbugDB('drop-fts-index-benign-cases', (handle) => {
|
|
describe('dropFTSIndex end-to-end benign cases (#2589)', () => {
|
|
it('resolves cleanly when the named index was never created', async () => {
|
|
void handle;
|
|
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
|
|
await executeQuery(
|
|
`CREATE NODE TABLE IF NOT EXISTS DropProbe (id STRING PRIMARY KEY, content STRING)`,
|
|
);
|
|
await expect(dropFTSIndex('DropProbe', 'drop_probe_never_created')).resolves.toBeUndefined();
|
|
}, 120_000);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* #2841: "function DROP_FTS_INDEX is not defined" is benign only when there is
|
|
* nothing to drop. When the index is LIVE, that same message means the drop did
|
|
* not happen and cannot happen — every later insert/delete against the table
|
|
* dies at bind time with an engine error that never mentions FTS. The message
|
|
* classifier stays pure (it cannot know whether an index exists); the liveness
|
|
* question is settled inside `dropFTSIndex`, on the error path only.
|
|
*/
|
|
describe('dropFTSIndex with the FTS extension unloaded (#2841)', () => {
|
|
const TABLE = 'DropProbe2841';
|
|
const LIVE_INDEX = 'drop_probe_2841_live';
|
|
let ftsAvailable = true;
|
|
/**
|
|
* Mutable holder rather than `probe: … | undefined` + `probe!.dbPath`: the
|
|
* non-null assertion was a lint warning, and the alternative (a runtime guard
|
|
* in every consumer) would put branching into the test path. `beforeAll`
|
|
* overwrites both fields; if it never ran, `initLbug('')` fails loudly, which
|
|
* is the same outcome the assertion had.
|
|
*/
|
|
const probe: { dbPath: string; cleanup: () => Promise<void> } = {
|
|
dbPath: '',
|
|
cleanup: async () => {},
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const tmp = await createTempDir('gitnexus-2841-drop-probe-');
|
|
probe.dbPath = tmp.dbPath;
|
|
probe.cleanup = tmp.cleanup;
|
|
await adapter.initLbug(probe.dbPath);
|
|
try {
|
|
ftsAvailable = await adapter.loadFTSExtension(undefined, {
|
|
policy: resolveAnalyzeInstallPolicy(),
|
|
});
|
|
if (ftsAvailable) {
|
|
await adapter.executeQuery(
|
|
`CREATE NODE TABLE IF NOT EXISTS ${TABLE} (id STRING PRIMARY KEY, name STRING, content STRING)`,
|
|
);
|
|
// A real, live FTS index — the state that makes the catalog error fatal.
|
|
await adapter.createFTSIndex(TABLE, LIVE_INDEX, ['name', 'content']);
|
|
}
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await probe.cleanup();
|
|
});
|
|
|
|
beforeEach((ctx) => {
|
|
if (!ftsAvailable) {
|
|
if (process.env.GITNEXUS_REQUIRE_FTS === '1') {
|
|
throw new Error(
|
|
'GITNEXUS_REQUIRE_FTS=1 but the FTS extension is unavailable — cannot verify the #2841 drop guard.',
|
|
);
|
|
}
|
|
console.warn(
|
|
'[drop-fts-index-error-classification] Skipping the #2841 cases — FTS extension unavailable.',
|
|
);
|
|
ctx.skip();
|
|
}
|
|
});
|
|
|
|
/** Reopen the seeded DB with the extension forced unloadable for this connection. */
|
|
const withUnloadedFts = async (run: () => Promise<void>): Promise<void> => {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const previousPolicy = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
|
|
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'never';
|
|
resetExtensionState();
|
|
try {
|
|
await adapter.initLbug(probe.dbPath);
|
|
await run();
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
if (previousPolicy === undefined) delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
|
|
else process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = previousPolicy;
|
|
resetExtensionState();
|
|
}
|
|
};
|
|
|
|
it('rejects, naming FTS and both remedies, when the index is live and the extension is not loaded', async () => {
|
|
await withUnloadedFts(async () => {
|
|
await expect(dropFTSIndex(TABLE, LIVE_INDEX)).rejects.toThrow(
|
|
/FTS index '.*' on table .* exists but the LadybugDB FTS extension is not loaded/,
|
|
);
|
|
// Both remedies, asserted as stable SUBSTRINGS — the load-side half is
|
|
// generated by `diagnoseExtensionLoad` now, so pinning a whole sentence
|
|
// would break on any classifier wording change. `never` is not a load
|
|
// failure the classifier recognizes, so the diagnosis here is `unknown`
|
|
// and the doctor pointer is the load-side remedy the user gets.
|
|
await expect(dropFTSIndex(TABLE, LIVE_INDEX)).rejects.toThrow(/gitnexus doctor/);
|
|
await expect(dropFTSIndex(TABLE, LIVE_INDEX)).rejects.toThrow(/analyze --force/);
|
|
});
|
|
}, 120_000);
|
|
|
|
it('still resolves when the extension is not loaded and the index does not exist', async () => {
|
|
await withUnloadedFts(async () => {
|
|
await expect(dropFTSIndex(TABLE, 'drop_probe_2841_absent')).resolves.toBeUndefined();
|
|
});
|
|
}, 120_000);
|
|
|
|
/**
|
|
* #2374/#2375 redaction contract. LadybugDB's own load error names the
|
|
* extension FILE, and that `reason` is what the classifier is fed — so the
|
|
* one thing this surface must never do is pass it through into the message a
|
|
* user sees. Until now that held only by code inspection.
|
|
*
|
|
* A bare "policy is never" run cannot prove it: that reason carries no path,
|
|
* so the assertion would be vacuous. So the LOAD is forced to fail with a
|
|
* real, path-bearing LadybugDB error instead, which drives the classifier
|
|
* down its `missing_dependency` branch — the branch whose remedy is derived
|
|
* from the very text that contains the path.
|
|
*/
|
|
const FORCED_EXTENSION_PATH = '/nonexistent-gitnexus-2841/fts.lbug_extension';
|
|
const FORCED_LOAD_FAILURE =
|
|
`Failed to load library: ${FORCED_EXTENSION_PATH} which is needed by extension: fts; ` +
|
|
'libcrypto.so.3: cannot open shared object file: No such file or directory';
|
|
|
|
it('routes the load-side remedy through the classifier without leaking the path LadybugDB named', async () => {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { default: lbug } = await import('@ladybugdb/core');
|
|
const previousPolicy = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
|
|
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'load-only';
|
|
resetExtensionState();
|
|
|
|
const originalQuery = lbug.Connection.prototype.query;
|
|
// Installed BEFORE initLbug so FTS can never load on this connection —
|
|
// otherwise the DROP would succeed and there would be no message to inspect.
|
|
const spy = vi.spyOn(lbug.Connection.prototype, 'query').mockImplementation(function (
|
|
this: unknown,
|
|
sql: string,
|
|
...rest: unknown[]
|
|
) {
|
|
if (
|
|
/^\s*LOAD\s+EXTENSION\b/i.test(sql) &&
|
|
(/\bfts\b/i.test(sql) || /libfts\.lbug_extension/i.test(sql))
|
|
) {
|
|
return Promise.reject(new Error(FORCED_LOAD_FAILURE));
|
|
}
|
|
return originalQuery.call(this, sql, ...rest);
|
|
});
|
|
|
|
try {
|
|
await adapter.initLbug(probe.dbPath);
|
|
// Record the capability from the forced failure, so `dropFTSIndex` reads
|
|
// a real cached diagnosis rather than re-deriving one from nothing.
|
|
await expect(adapter.loadFTSExtension(undefined, { policy: 'load-only' })).resolves.toBe(
|
|
false,
|
|
);
|
|
|
|
const rejection: unknown = await dropFTSIndex(TABLE, LIVE_INDEX).catch((e: unknown) => e);
|
|
expect(rejection).toBeInstanceOf(Error);
|
|
const message = String(rejection);
|
|
|
|
// The classifier really fired on this reason (POSIX missing-dependency),
|
|
// so the redaction assertion below is not vacuous…
|
|
expect(message).toContain('Reinstalling the extension will NOT help');
|
|
// …and neither the path nor any other filesystem path reached the user.
|
|
expect(message).not.toContain(FORCED_EXTENSION_PATH);
|
|
expect(message).not.toMatch(/(?:[A-Za-z]:\\|\/)[^\s'"]+/);
|
|
} finally {
|
|
spy.mockRestore();
|
|
await adapter.closeLbug();
|
|
if (previousPolicy === undefined) delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
|
|
else process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = previousPolicy;
|
|
resetExtensionState();
|
|
}
|
|
}, 120_000);
|
|
});
|
|
|
|
describe('dropFTSIndex fallback diagnosis wiring', () => {
|
|
it('extracts the inspect path before resolveFtsVersionPair', () => {
|
|
const source = readFileSync(
|
|
path.join(__dirname, '..', '..', 'src', 'core', 'lbug', 'lbug-adapter.ts'),
|
|
'utf8',
|
|
);
|
|
const drop = source.slice(source.indexOf('export const dropFTSIndex'));
|
|
const inspectAt = drop.indexOf('extractExtensionPath(ftsCapability?.reason)');
|
|
const diagnoseAt = drop.indexOf('diagnoseExtensionLoad(');
|
|
const pairAt = drop.indexOf('resolveFtsVersionPair(inspectPath)');
|
|
expect(inspectAt).toBeGreaterThan(-1);
|
|
expect(diagnoseAt).toBeGreaterThan(inspectAt);
|
|
expect(pairAt).toBeGreaterThan(diagnoseAt);
|
|
expect(drop).not.toContain('resolveFtsVersionPair(undefined)');
|
|
});
|
|
});
|