mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* fix(lbug): never report a drop that could not happen, and gate FTS-indexed DML `CALL DROP_FTS_INDEX` is itself an FTS-extension function, so with the extension unloaded it fails with `Catalog exception: function DROP_FTS_INDEX is not defined`. `isBenignDropFtsIndexError` classifies that as "nothing to drop" — correct when the index does not exist, wrong when it does: the drop silently no-ops and the next write to that table dies at bind time with an engine message that never mentions FTS (#2841). The classifier stays pure (a message cannot tell you whether an index is live). Instead `dropFTSIndex` settles liveness with a catalog read on the ERROR path only and raises an FTS-named, remedy-bearing error when the index is present but undroppable. Adds `ensureFtsRowDmlSafe`, the FTS twin of `ensureEmbeddingRowDmlSafe` (#2623): catalog first, load FTS with the analyze policy only when an index actually gates DML. LadybugDB refuses that DML at BIND time — a DETACH DELETE matching zero rows fails exactly as hard as one matching thousands — and the indexes cannot be cleared in place, so a verdict is the only useful answer. Both gates now share one `SHOW_INDEXES` read via `readIndexCatalogRows`, so adding the FTS check costs no extra catalog round-trip. Refs #2841 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CxokkpfssUCxvBwRZMtRCB * fix(analyze): escalate instead of crashing when FTS blocks incremental DML The incremental writeback decided its write plan without ever asking whether row-level DML was legal. On a DB carrying FTS indexes with an unloadable FTS extension, `deleteNodesForFiles` then died mid-writeback: Binder exception: Trying to delete from an index on table File but its extension is not loaded. with no mention of FTS anywhere in the run — the only install-capable load happened in Phase 3, long after the writes (#2841). The incremental branch now reads the index catalog once and derives both extension verdicts before any DML. When FTS (or VECTOR) blocks in-place writes, the run falls through to the existing wipe-and-bulk-COPY escalation — the same answer #2623 gave for VECTOR, and the only one available, since the indexes cannot be dropped without the extension. Every blocked extension is named in the reason log, not just the first one checked: a DB can carry both a vector index and FTS indexes, and reporting half the cause is how this failure stayed mis-diagnosed. Refs #2841 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CxokkpfssUCxvBwRZMtRCB * test(analyze): cover the FTS DML gate, both-blocked escalation, and the drop guard New `incremental-index-extension-dml-gate.test.ts` drives the real `runFullAnalysis` against a real mini-repo and a real LadybugDB: - a DB carrying FTS indexes with FTS made unloadable escalates to a full DB write, names FTS in the log, ends with zero FTS indexes, and still has the newly committed content in the graph (pre-fix: Binder exception, exit 1); - FTS available keeps the surgical plan and the indexes; - a DB that never carried FTS indexes is not escalated (the catalog-first check must not tax FTS-less machines); - FTS and VECTOR both blocked produce ONE escalation naming both. `drop-fts-index-error-classification.test.ts` gains the two `dropFTSIndex` cases the #2841 guard turns on: live index + unloaded extension rejects with an FTS-named error, absent index still resolves. The existing classifier assertions are unchanged — it stays pure. The CLI e2e reproduces the reporter's exact journey (analyze with the extension, remove it, touch a file, analyze again) and asserts exit 0 plus an FTS-named reason. It skips visibly when the seeded extension cannot load on the host, so it can never report a false red about the fix. Mutation-verified: reverting the run-analyze gate fails the first scenario; reverting the dropFTSIndex guard fails the live-index case. Refs #2841 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CxokkpfssUCxvBwRZMtRCB * fix(lbug): make every catalog-gated path fail closed, and classify the drop remedy Review findings on #2854 (two-engine, 17 lanes). H3 — `ftsIndexExistsInCatalog` returned `false` when the catalog could not be read, i.e. "index absent", so `dropFTSIndex` swallowed the error and the caller proceeded as if the index were gone. That is the #2841 symptom the guard exists to make loud, and it contradicted the contract `readIndexCatalogRows` states two functions above. It now fails closed. §6.A — `ensureFtsRowDmlSafe` keyed on `index_type === 'FTS'`, which answers `undefined === 'FTS'` → false → *no gate* for a row whose shape cannot be read: fail-open, in the gate whose only job is preventing an unsafe write, while the VECTOR twin fails closed on the same input. Now only a positively-identified non-FTS index is waved through. Deliberately NOT the twin's `!== 'HASH'`: that is safe there only because it is scoped to the embedding table first, and this gate is table-agnostic — `!== 'HASH'` would let the HNSW index gate FTS DML. §5.A — `undefined` was overloaded: "caller passed nothing" and "caller tried and could not prove anything" shared one value, so a failed shared read silently became three reads and the two gates could decide from different snapshots. The failed snapshot is now representable (`INDEX_CATALOG_UNREADABLE`), leaving one unambiguous `??` in `resolveGateRows`. §5.B — both gates regained the unconditional null-connection precondition the refactor moved into the reader. §5.G — the throw's remedy now routes through `diagnoseExtensionLoad`, like `--repair-fts` and `ftsDegradedWarning`, so a missing runtime dependency is not told to reinstall. The message stays path-free (#2374/#2375). The dead positional row fallbacks are kept and marked `LADYBUGDB-CONTRACT`: removing them would turn a proven-inert hedge into a fail-open gate if a future engine returns unnamed tuples. Refs #2841 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CxokkpfssUCxvBwRZMtRCB * fix(analyze): never undo an explicit wipe, stage extension-forced rebuilds, report honestly Review findings on #2854 (two-engine, 17 lanes). H1 (P1, both engines) — `analyze --drop-embeddings` was silently reverted. The `--drop-embeddings` → `force` conversion sits inside the `embeddingCheckpoint` branch, so without a checkpoint the run stays incremental and reaches the gate; the flag then *deliberately* leaves `cachedEmbeddings` empty, which is exactly the rescue's trigger, so every row the operator asked to destroy was read back and restored, exit 0. Widening the rescue from `!embeddingRowDmlSafe` to `extensionForcedRebuild` moved that latent bug onto the dominant path, because every analyzed DB carries FTS indexes. Guarded on the flag itself — NOT on `shouldLoadCache`, which is false in the meta-under-reports case the rescue exists for and would have deleted the safeguard while fixing the wipe. The `--drop-embeddings --embeddings` variant is covered by the same guard. H2 — an extension-forced escalation wiped the LIVE index in place: `buildPath` was frozen ~440 lines earlier while the run was still classified incremental, so an interrupt or ENOSPC left no complete index, where main failed at bind time with it intact. Extension-forced rebuilds now build into a staging file and publish via the existing atomic swap; size-forced ones stay in place, since that trigger is the repo's own churn rather than a machine condition. H5 — the escalation log asserted a vector index "exists" and that the store "carries FTS indexes" in exactly the case the catalog read proved nothing, while the only truthful signal went to stderr rather than the IPC log. It now emits a distinct unreadable-catalog cause, and "this index carries" (which pointed at the vector index just named) reads "the graph store carries". §5.D — the write-set cause was dropped whenever an extension cause co-occurred; causes are appended now, not selected between. §5.C — after an FTS-forced rebuild stamped lastCommit, a plain rerun on the same commit hit the alreadyUpToDate fast path before Phase 3, so the CLI's "install … then rerun" advice could never restore FTS. The fast path is now bypassed when meta records FTS unavailable and the extension can load again, keyed on the persisted capabilities stamp rather than new state. §5.F (skip the escalation for a zero-change commit) is deliberately NOT implemented: `deleteSpringAutoConfigurationSyntheticClasses` and `deleteSpringAopEvidenceNodes` run unconditionally on the surgical branch and bind against FTS-indexed `Class`/`CodeElement`, and a zero-row DETACH DELETE fails at bind time exactly as hard as a large one — so the skip would restore the original crash. Refs #2841 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CxokkpfssUCxvBwRZMtRCB * perf(search): read the index catalog once per drop sweep, and state the real contract Review findings on #2854. H4 — on a machine where FTS cannot load and the DB carries no FTS index, the gate correctly returned early without loading the extension, but the surgical path still ran the full 20-entry drop sweep: every `CALL DROP_FTS_INDEX` raised "function DROP_FTS_INDEX is not defined", and the new liveness guard then fired a fresh catalog read per table — 20 reads every run, forever, for exactly the offline/load-only population, contradicting the "healthy path costs nothing" claim shipped with the guard. The sweep now reads the catalog once and skips entirely when no FTS-typed index exists. An unreadable catalog runs the sweep, so an unprovable catalog never skips real work. H8 — the docstring still promised `dropFTSIndex` "tolerates" an unloadable extension. Post-#2854 a live index plus an unloadable extension throws, and safety rests on caller ordering discipline rather than the type system — which is what would have talked the next caller out of that ordering. GUARDRAILS — the "switching to a full DB write" sign described exactly one trigger (write set >~50%). Since #2623 and #2841 an unloadable extension escalates regardless of write-set size; documented with its recovery steps. Refs #2841 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CxokkpfssUCxvBwRZMtRCB * test(analyze): cover the wipe guard, the staged rebuild, and the fail-closed branches Review findings on #2854. H1/H2 mutation-verified: removing `!options.dropEmbeddings` fails the new drop-embeddings case ("expected true to be false"); disabling the staging upgrade fails the staging case ("expected 0 to be greater than 0"), so both assert behaviour rather than describe it. Gate suite (7 cases): `--drop-embeddings` under an FTS-forced escalation ends at zero embedding rows and logs no "Preserving"; the escalation is one-shot — a third run on a healthy host returns to surgery and rebuilds every FTS index; an extension-forced rebuild is observed building into `lbug.staging.*` and leaves none behind; the rescue complement still preserves un-stamped rows when no wipe was requested; the never-built case now asserts the commit reached the graph. H6 — the both-blocked case hard-asserted `createVectorIndex()` while the suite probed FTS only, so it went red on any FTS-yes/VECTOR-no host. VECTOR is probed now and gates only that case, with a GITNEXUS_REQUIRE_VECTOR hard-fail. H7 — the fail-closed branches had no coverage although the VECTOR twin's test and interception technique were ready to copy: `ensureFtsRowDmlSafe` under an unreadable catalog now proves it routes to the load, and `dropFTSIndex` proves it rejects rather than silently tolerating. Plus a redaction case that forces a real path-bearing load failure — under policy `never` the assertion would have been vacuous, since that reason carries no path. §5.E/§6.B — the suite is registered in the cross-platform matrix (its sibling was; it wasn't, and GITNEXUS_REQUIRE_VECTOR is set only on that job) and moved into the sequential lbug-db project per TESTING.md:68, verified not to drop it from the sharded ubuntu job. A Windows shard weight is added as a labelled estimate — the 8s floor would skew the split it exists to protect. Refs #2841 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CxokkpfssUCxvBwRZMtRCB * refactor(analyze): make the FTS gate's fast path cheap, its claims provable, and its remedies classified Cleanup review of the #2841 work (four parallel angles: reuse, simplification, efficiency, altitude). Behaviour-preserving except where the previous behaviour was wrong. Correctness the review caught: - The fast-path probe keyed on `capabilities.fts.status === 'unavailable'`, which collapses "extension unavailable" and "index build failed". A deterministic build failure (an un-tokenizable row, #2544) therefore bypassed `alreadyUpToDate` on EVERY subsequent run, re-analyzed the whole repo, failed the same way, and restamped — a permanent loop where the run used to be one `stat`. Phase 3 already computes the discriminator; it is now persisted as `fts.skipReason` and the probe only runs for `extension-unavailable`. Metas written before this carry no field and keep today's behaviour. - `dropSearchFTSIndexes` skipped its sweep when no row read `index_type === 'FTS'`, while `ensureFtsRowDmlSafe` treats an unreadable type as "might be FTS". Opposite polarity, under a comment claiming they matched: a row-shape change would let the gate wave the surgical plan through while the sweep dropped nothing, putting DELETEs back on tables carrying live FTS indexes — #2589 again. The sweep now decides per configured index on identity, which is also strictly more precise. Its old justification (leftover indexes under other names) was unreachable — the loop only ever drops configured entries. - `dropFTSIndex` threw "FTS index X on table Y exists" on the one path where the catalog could not be read — a fabricated claim, on a DB the same run had just shown carries no FTS index. Presence is now `present | absent | unverifiable` and the message says which. - The remedy was hand-written for three of the four load-failure classes, discarding `missingFileRemedy`/`corruptFileRemedy`, so a corrupt extension file was told to retry an install — the misdirection #2383 fixed. Both the drop error and the escalation log now use the classified remedy. Cost, measured on a 391 MB index (cold open ~1 s, SHOW_INDEXES ~4 ms): - The probe opened the live index WRITABLE on the millisecond fast path, dragging in schema DDL, the cross-process write lock, sidecar reclaim and a CHECKPOINT on close. It is read-only now. That also closes an install trap: `doInitLbug`'s pre-load resolves the env policy on the writable branch, so an operator following our own `GITNEXUS_LBUG_EXTENSION_INSTALL=auto` advice paid a forked 15 s installer on every up-to-date run (memoized per process; the CLI is a fresh process each time). The read-only branch pins `load-only`. - A failed staged rebuild orphaned a full index-sized copy until the next lock sweep; the failure path now reclaims it. - The sweep re-read a catalog the run already held, defeating the invariant the snapshot type exists to enforce. Structure: row-shape accessors have one home, so the LADYBUGDB-CONTRACT grep claim is true by construction; staging now applies to both escalation causes, since recoverability is a property of the wipe-then-COPY plan, not of the trigger; `getExtensionCapability`/`getFtsCapability` replace hand-spelled lookups where the seam allows. Two lookups in run-analyze.ts deliberately keep the exported `getExtensionCapabilities()` form: the #2383 tests stub that export, and an ESM module mock does not intercept a helper's internal call — routing through it silently degraded the classified remedy to generic text. Recorded in-comment. Not taken, deliberately: extracting the escalation message and replacing the snapshot protocol with a connection-scoped catalog memo (both sound, both restructure code this PR just stabilised — they belong in their own change); an extension registry (premature at two instances, and the FTS/VECTOR polarity difference is exactly what it would have to parameterize back out). Refs #2841 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CxokkpfssUCxvBwRZMtRCB * test(analyze): pin both sides of the degraded-FTS fast-path bypass `healDegradedFts` (§5.C) had zero coverage — three separate review angles flagged it, and the cleanup pass then found it sat one conjunct away from a permanent full-re-analyze loop. Both sides are pinned now: - it re-analyzes past `alreadyUpToDate` when the stored meta says FTS is degraded and the extension loads again: run 1 analyzes with loads blocked (asserting the precondition — `status: 'unavailable'`, `skipReason: 'extension-unavailable'` — rather than assuming it), then a same-commit clean-tree rerun rebuilds every FTS index without a file changing; - it stands down when the degradation was a BUILD failure: the stored `skipReason` is rewritten to 'build-failed' and the rerun must take the fast path, because that rebuild would fail identically on every run forever. The build-failed state is reached by rewriting the stamped discriminator, not by provoking a real tokenizer failure: a genuine one needs a stored row the native tokenizer rejects (#2544/#2546), which is neither portable across the CI matrix nor deterministic, and §5.C reads only that field. Also folds the first escalation case into the one-shot case. The claim that it was fully subsumed did not hold on audit: `logs` containing 'FTS' was unique as expected, but so was the duplicate-File-node row count — every other reader goes through a Map keyed by path, which collapses a stale twin an appending rebuild would leave. Both assertions moved rather than one being dropped. Net suite runtime goes UP (two cycles removed, four added), against the cross-platform-matrix argument that motivated the dedup — recorded here because the shard weight is an estimate pending a real Windows measurement. Refs #2841 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CxokkpfssUCxvBwRZMtRCB * test(search): keep the whole-module adapter mock in step with the row accessors The cleanup pass moved the LadybugDB row-shape reads behind named accessors so the column contract has one home. `fts-indexes.test.ts` mocks the entire adapter module with a hand-written factory, which still exposed only the three exports the file imported before — so `verifySearchFTSIndexes` failed with "No `indexRowName` export is defined on the mock" while production was fine. The added accessors mirror the real implementations rather than returning stubs. A stub would have read `undefined` out of every catalog row and let the suite pass for the wrong reason — the failure mode a whole-module mock invites whenever the module under test grows an import. Refs #2841 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CxokkpfssUCxvBwRZMtRCB * revert(analyze): drop the degraded-FTS auto-heal, fix the advice it existed to justify §5.C's complaint was that the CLI tells users to "install the extension … then rerun" when a rerun lands on the up-to-date fast path and rebuilds nothing. The answer shipped for it was a probe that bypasses that fast path. Four independent problems later, the sentence is cheaper to fix than to make true: - it could not tell "extension was missing" from "index build failed" without a stamped discriminator, so a deterministic build failure (#2544/#2546) re-analyzed the entire repo on every invocation, forever, where the run used to be one `stat`; - it opened the live index on the millisecond fast path — writable at first, dragging in DDL, the cross-process lock and a CHECKPOINT (~1 s on a 391 MB index), and even read-only it is a full open; - `doInitLbug`'s pre-load resolves the env policy, so an operator following our own `GITNEXUS_LBUG_EXTENSION_INSTALL=auto` advice paid a forked 15 s installer per up-to-date run; - and it turns the fast path into a full re-analysis whenever an index authored where FTS was unavailable is later read where it loads — a legitimate, common state, and the invariant `analyzer-identity-cli.test.ts` pins. So: no probe. The degraded-search warning now points at `gitnexus analyze --repair-fts`, which rebuilds the search indexes without re-parsing the repo, instead of "then rerun". One line, no new failure modes, and it is what the issue actually asked for. `capabilities.fts.skipReason` stays in the meta stamp: it costs three lines, makes the two degradation causes distinguishable for support, and is what any future correct answer here would key on. Also gates the H2 staging assertion on the production predicate. It asserted staging unconditionally while the upgrade requires `posixSwap || windowsSwapOk`, and `windowsSwapOk` is opt-in (#2614) — so it failed on the Windows matrix for a reason unrelated to #2841. Registering this suite cross-platform is what exposed it; the assertion now mirrors the condition it is testing. Refs #2841 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CxokkpfssUCxvBwRZMtRCB * fix(analyze): never stage around a damaged index — escalate in place when the catalog is unreadable CI caught this on ubuntu and macOS: `analyze-wal-checkpoint-failure` stopped failing, which is worse than it sounds. That test plants a directory at `.gitnexus/lbug.wal.checkpoint` so the auto-checkpoint's rename target is blocked, and asserts analyze exits non-zero with the `--wal-checkpoint-threshold` hint. But LadybugDB cannot open that path at all, so `CALL SHOW_INDEXES()` now fails with `IO exception: … Is a directory`. The catalog read returns UNREADABLE, both DML gates correctly fail closed, both extension loads fail with the same IO error, and the run escalates — and since the escalation stages, it built a fresh index at `lbug.staging.<uuid>`, swapped it in, and exited 0. The blocked path was never touched. The run "succeeded" while the damage sat untouched on disk, waiting to break the next in-place writeback. So the staging upgrade is now conditional on the catalog having been READ. Staging exists to protect a healthy live index from a machine-level cause (an extension that will not load); it must not be used to route around a damaged one. When we are escalating out of ignorance, build in place so the underlying IO fault lands on the failure path where the operator gets a diagnosis. Verified against the real CLI, not just the suite: with a directory planted at the checkpoint path, analyze now exits 1 and prints `gitnexus analyze --wal-checkpoint-threshold 67108864`. The healthy extension-forced case still stages (gate suite 6/6). Refs #2841 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CxokkpfssUCxvBwRZMtRCB --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
385 lines
17 KiB
TypeScript
385 lines
17 KiB
TypeScript
/**
|
|
* P1 Integration Tests: FTS extension lifecycle end-to-end (#2374)
|
|
*
|
|
* Everything real, nothing mocked: each test spawns the actual CLI entry as a
|
|
* child process, LadybugDB loads the actual extension shared library from
|
|
* disk, and the real out-of-process installer downloads the real extension in
|
|
* the network-gated cases.
|
|
*
|
|
* Isolation: LadybugDB resolves its extension directory from the process HOME
|
|
* (USERPROFILE on Windows), so every scenario owns a hermetic fake home with
|
|
* its own `.lbdb/extension/<version>/<platform>/fts/` state — the machine's
|
|
* real ~/.lbdb is never read or written. GITNEXUS_HOME additionally isolates
|
|
* the registry (#829), following cli-e2e.test.ts conventions.
|
|
*
|
|
* Scenario matrix (the #2374 report, codified):
|
|
* - happy: valid extension pre-installed, offline (load-only)
|
|
* - unhappy: extension file present but broken — the reporter's exact state
|
|
* - unhappy: extension file missing entirely (distinguishable reason)
|
|
* - heal: FORCE INSTALL replaces a broken file over the network (auto)
|
|
*/
|
|
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js';
|
|
import { spawnSync } from 'child_process';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
|
|
import { getExtensionInstallChildProcessArgs } from '../../src/core/lbug/extension-loader.js';
|
|
import { cleanupTempDirSync } from '../helpers/test-db.js';
|
|
import { findInstalledFtsExtension } from '../helpers/fts-availability.js';
|
|
|
|
/** `.lbdb/extension/<version>/<platform>/fts/libfts.lbug_extension`, discovered not hardcoded. */
|
|
let extensionRelPath: string;
|
|
/** Canonical valid extension bytes (path to a known-good file). */
|
|
let seedExtensionFile: string | null = null;
|
|
/** Real reachability of the extension repo — gates the auto-install cases. */
|
|
let networkAvailable = false;
|
|
|
|
const REQUIRE_FTS = process.env.GITNEXUS_REQUIRE_FTS === '1';
|
|
const tmpDirs: string[] = [];
|
|
|
|
const makeTmpDir = (label: string): string => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-fts-e2e-${label}-`));
|
|
tmpDirs.push(dir);
|
|
return dir;
|
|
};
|
|
|
|
/**
|
|
* Locate a known-good extension file for the running LadybugDB version.
|
|
* Prefers a copy already installed under the machine's real home (pure file
|
|
* read, offline); falls back to one real out-of-process install into a probe
|
|
* home — the production installer script, not a reimplementation.
|
|
*/
|
|
const resolveSeedExtension = (): void => {
|
|
const realExtensionRoot = path.join(os.homedir(), '.lbdb', 'extension');
|
|
const installed = findInstalledFtsExtension(realExtensionRoot);
|
|
if (installed) {
|
|
extensionRelPath = path.relative(os.homedir(), installed);
|
|
seedExtensionFile = installed;
|
|
return;
|
|
}
|
|
// No local copy — run the real installer against a hermetic probe home.
|
|
const probeHome = makeTmpDir('seed-home');
|
|
const install = spawnSync(process.execPath, getExtensionInstallChildProcessArgs('fts'), {
|
|
encoding: 'utf8',
|
|
timeout: 120_000,
|
|
env: { ...process.env, HOME: probeHome, USERPROFILE: probeHome },
|
|
});
|
|
const probeExtensionRoot = path.join(probeHome, '.lbdb', 'extension');
|
|
const probeInstalled = findInstalledFtsExtension(probeExtensionRoot);
|
|
if (install.status === 0 && probeInstalled) {
|
|
extensionRelPath = path.relative(probeHome, probeInstalled);
|
|
seedExtensionFile = probeInstalled;
|
|
networkAvailable = true;
|
|
return;
|
|
}
|
|
};
|
|
|
|
type ExtensionState = 'valid' | 'broken' | 'missing';
|
|
|
|
/** Create a hermetic fake home whose `.lbdb` holds the requested extension state. */
|
|
const makeHome = (state: ExtensionState): { home: string; extensionFile: string } => {
|
|
const home = makeTmpDir(`home-${state}`);
|
|
const extensionFile = path.join(home, extensionRelPath);
|
|
fs.mkdirSync(path.dirname(extensionFile), { recursive: true });
|
|
if (state === 'valid' && seedExtensionFile) fs.copyFileSync(seedExtensionFile, extensionFile);
|
|
if (state === 'broken') fs.writeFileSync(extensionFile, 'not a shared library');
|
|
return { home, extensionFile };
|
|
};
|
|
|
|
/** Fresh git-initialised throwaway repo with a uniquely named symbol to search for. */
|
|
const makeFixtureRepo = (label: string): string => {
|
|
const repo = path.join(makeTmpDir(`repo-${label}`), `fts-e2e-${label}`);
|
|
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(repo, 'src', 'greeter.ts'),
|
|
'export function greetE2eSymbol(name: string): string {\n' +
|
|
' return `Hello, ${name}`;\n' +
|
|
'}\n' +
|
|
"greetE2eSymbol('world');\n",
|
|
);
|
|
const gitEnv = {
|
|
...process.env,
|
|
GIT_AUTHOR_NAME: 'test',
|
|
GIT_AUTHOR_EMAIL: 'test@test',
|
|
GIT_COMMITTER_NAME: 'test',
|
|
GIT_COMMITTER_EMAIL: 'test@test',
|
|
};
|
|
spawnSync('git', ['init'], { cwd: repo, stdio: 'pipe' });
|
|
spawnSync('git', ['add', '-A'], { cwd: repo, stdio: 'pipe' });
|
|
spawnSync('git', ['commit', '-m', 'initial'], { cwd: repo, stdio: 'pipe', env: gitEnv });
|
|
return repo;
|
|
};
|
|
|
|
interface CliResult {
|
|
status: number | null;
|
|
/** stdout + stderr combined — warn lines and progress renderer interleave streams. */
|
|
output: string;
|
|
}
|
|
|
|
const runCli = (
|
|
args: string[],
|
|
cwd: string,
|
|
home: string,
|
|
policy: 'load-only' | 'auto',
|
|
timeoutMs = 180_000,
|
|
): CliResult => {
|
|
const result = spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args], {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
timeout: timeoutMs,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: {
|
|
...process.env,
|
|
HOME: home,
|
|
USERPROFILE: home,
|
|
GITNEXUS_HOME: path.join(home, '.gitnexus'),
|
|
GITNEXUS_LANG: 'en',
|
|
GITNEXUS_LBUG_EXTENSION_INSTALL: policy,
|
|
GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS: '60000',
|
|
// Skip analyzeCommand's ensureHeap re-exec, which would drop the tsx loader.
|
|
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
|
},
|
|
});
|
|
return { status: result.status, output: `${result.stdout ?? ''}\n${result.stderr ?? ''}` };
|
|
};
|
|
|
|
beforeAll(() => {
|
|
resolveSeedExtension();
|
|
if (!seedExtensionFile && REQUIRE_FTS) {
|
|
throw new Error(
|
|
'GITNEXUS_REQUIRE_FTS=1 but no FTS extension could be located or installed for the E2E suite.',
|
|
);
|
|
}
|
|
// The self-heal cases need the real extension repo; probe it cheaply when
|
|
// the seed came from a local copy (the installer fallback already proved it).
|
|
return (async () => {
|
|
if (seedExtensionFile && !networkAvailable) {
|
|
try {
|
|
const res = await fetch('https://extension.ladybugdb.com/', {
|
|
method: 'HEAD',
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
networkAvailable = res.ok;
|
|
} catch {
|
|
networkAvailable = false;
|
|
}
|
|
}
|
|
})();
|
|
}, 180_000);
|
|
|
|
afterAll(() => {
|
|
for (const dir of tmpDirs) cleanupTempDirSync(dir);
|
|
});
|
|
|
|
// Skip everything (visibly) when no valid extension exists and the machine is
|
|
// offline — mirrors the dynamic-skip convention in test/helpers/fts-availability.ts.
|
|
beforeEach((ctx) => {
|
|
if (!seedExtensionFile) ctx.skip();
|
|
});
|
|
|
|
describe('happy path — extension pre-installed, fully offline (load-only)', () => {
|
|
let home: string;
|
|
let repo: string;
|
|
|
|
beforeAll(() => {
|
|
// The file-level beforeEach skip fires only per-test; this hook runs first,
|
|
// so guard makeHome() (which needs extensionRelPath) when there is no seed.
|
|
if (!seedExtensionFile) return;
|
|
({ home } = makeHome('valid'));
|
|
repo = makeFixtureRepo('happy');
|
|
});
|
|
|
|
it('analyze builds the index with FTS and emits no degradation warning', () => {
|
|
const result = runCli(['analyze'], repo, home, 'load-only');
|
|
expect(result.status).toBe(0);
|
|
expect(result.output).toContain('indexed successfully');
|
|
expect(result.output).not.toContain('FTS extension unavailable');
|
|
expect(result.output).not.toContain('search is disabled');
|
|
}, 180_000);
|
|
|
|
it('query finds the symbol via BM25 with no degradation warning', () => {
|
|
const result = runCli(['query', 'greetE2eSymbol'], repo, home, 'load-only');
|
|
expect(result.status).toBe(0);
|
|
expect(result.output).toContain('greetE2eSymbol');
|
|
expect(result.output).not.toContain('keyword search degraded');
|
|
}, 60_000);
|
|
|
|
it('doctor reports a live-probed available FTS and a resolved LadybugDB version', () => {
|
|
const result = runCli(['doctor'], repo, home, 'load-only');
|
|
expect(result.status).toBe(0);
|
|
expect(result.output).toContain('Full-text search: available');
|
|
// #2374: version used to print as "unknown" on every platform.
|
|
expect(result.output).toMatch(/LadybugDB:\s*\d+\.\d+\.\d+/);
|
|
}, 60_000);
|
|
|
|
it('analyze --repair-fts rebuilds the search indexes offline', () => {
|
|
const result = runCli(['analyze', '--repair-fts'], repo, home, 'load-only');
|
|
expect(result.status).toBe(0);
|
|
expect(result.output).toContain('FTS indexes repaired successfully');
|
|
}, 180_000);
|
|
});
|
|
|
|
describe('unhappy path — extension file present but broken (the #2374 report)', () => {
|
|
let home: string;
|
|
let repo: string;
|
|
|
|
beforeAll(() => {
|
|
// See the happy-path note: skip setup when no seed extension is available
|
|
// so the per-test beforeEach skip is reached instead of throwing here.
|
|
if (!seedExtensionFile) return;
|
|
({ home } = makeHome('broken'));
|
|
repo = makeFixtureRepo('broken');
|
|
});
|
|
|
|
it('analyze degrades gracefully and names the real LOAD failure, not "not pre-installed"', () => {
|
|
const result = runCli(['analyze'], repo, home, 'load-only');
|
|
expect(result.status).toBe(0);
|
|
expect(result.output).toContain('indexed successfully');
|
|
expect(result.output).toContain('FTS extension unavailable');
|
|
// The load-side ground truth must survive to the user…
|
|
expect(result.output).toContain('LOAD fts failed');
|
|
expect(result.output).toContain('Failed to load library');
|
|
// …and the old misdiagnosis must not: the file IS pre-installed.
|
|
expect(result.output).not.toContain('not pre-installed');
|
|
}, 180_000);
|
|
|
|
it('analyze --repair-fts fails loudly with the live reason and an honest remedy', () => {
|
|
const result = runCli(['analyze', '--repair-fts'], repo, home, 'load-only');
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.output).toContain('Cannot repair FTS indexes');
|
|
expect(result.output).toContain('FTS extension failed to load');
|
|
expect(result.output).toContain('LOAD fts failed');
|
|
// Old message sent users to doctor "to install it"; doctor never installed.
|
|
expect(result.output).not.toContain('doctor` to install');
|
|
expect(result.output).toContain('gitnexus doctor');
|
|
// #2374 (U2): a corrupt file classifies as corrupt_file, so the Windows
|
|
// missing-dependency remedy must not misfire on the repair path either.
|
|
expect(result.output).not.toContain('Visual C++');
|
|
}, 180_000);
|
|
|
|
it('query warns with the extension-load failure, not the misleading indexes-missing message', () => {
|
|
const result = runCli(['query', 'greetE2eSymbol'], repo, home, 'load-only');
|
|
expect(result.status).toBe(0);
|
|
expect(result.output).toContain('FTS extension failed to load');
|
|
expect(result.output).toContain('Failed to load library');
|
|
expect(result.output).not.toContain('FTS indexes missing');
|
|
}, 60_000);
|
|
|
|
it('doctor live-probes FTS as unavailable, prints the real error and an actionable remedy', () => {
|
|
const result = runCli(['doctor'], repo, home, 'load-only');
|
|
expect(result.status).toBe(0);
|
|
expect(result.output).toContain('Full-text search: unavailable');
|
|
expect(result.output).toContain('Failed to load library');
|
|
// #2374 (U2): doctor routes the reason through the classifier and prints a
|
|
// remedy. A broken file is corrupt_file → re-download guidance; the Windows
|
|
// missing-dependency remedy (VC++/OpenSSL) must NOT misfire on a corrupt file
|
|
// — the catch-all guard, verified end-to-end through the real CLI.
|
|
expect(result.output).toContain('Re-download it with network access');
|
|
expect(result.output).not.toContain('Visual C++');
|
|
}, 60_000);
|
|
});
|
|
|
|
describe('unhappy path — extension missing entirely', () => {
|
|
it('analyze degrades with a reason that distinguishes missing from broken', () => {
|
|
const { home } = makeHome('missing');
|
|
const repo = makeFixtureRepo('missing');
|
|
const result = runCli(['analyze'], repo, home, 'load-only');
|
|
expect(result.status).toBe(0);
|
|
expect(result.output).toContain('FTS extension unavailable');
|
|
expect(result.output).toContain('has not been installed');
|
|
expect(result.output).not.toContain('Failed to load library');
|
|
}, 180_000);
|
|
});
|
|
|
|
describe('regression — the extension disappears between analyze runs (#2841)', () => {
|
|
it('the incremental run completes with a full DB write instead of an opaque Binder exception', (ctx) => {
|
|
const { home, extensionFile } = makeHome('valid');
|
|
const repo = makeFixtureRepo('vanishing-extension');
|
|
|
|
// 1. First analyze with the extension in place: the index ends up carrying
|
|
// an FTS index on every searchable table.
|
|
const first = runCli(['analyze'], repo, home, 'load-only');
|
|
expect(first.status).toBe(0);
|
|
// This case needs run 1 to actually BUILD the indexes — without them there
|
|
// is nothing for the gate to trip on and the assertions below would be
|
|
// vacuous. When the seeded extension cannot load on this host (the same
|
|
// environment gap the other cases in this file hit), skip VISIBLY rather
|
|
// than report a red that says nothing about the fix.
|
|
if (first.output.includes('FTS extension unavailable')) {
|
|
if (REQUIRE_FTS) {
|
|
throw new Error(
|
|
'GITNEXUS_REQUIRE_FTS=1 but the seeded FTS extension did not load — cannot verify the #2841 regression.',
|
|
);
|
|
}
|
|
ctx.skip();
|
|
}
|
|
|
|
// 2. The extension becomes unloadable — the reporter moved
|
|
// ~/.lbdb/extension away; a HOME change or a wiped cache does the same.
|
|
fs.rmSync(extensionFile);
|
|
|
|
// 3. A content change makes the next run incremental, so it must rewrite
|
|
// rows of tables that still carry the indexes from step 1.
|
|
fs.appendFileSync(path.join(repo, 'src', 'greeter.ts'), '\n// #2841 incremental touch\n');
|
|
const gitEnv = {
|
|
...process.env,
|
|
GIT_AUTHOR_NAME: 'test',
|
|
GIT_AUTHOR_EMAIL: 'test@test',
|
|
GIT_COMMITTER_NAME: 'test',
|
|
GIT_COMMITTER_EMAIL: 'test@test',
|
|
};
|
|
spawnSync('git', ['add', '-A'], { cwd: repo, stdio: 'pipe' });
|
|
spawnSync('git', ['commit', '-m', '#2841 touch'], { cwd: repo, stdio: 'pipe', env: gitEnv });
|
|
|
|
const second = runCli(['analyze'], repo, home, 'load-only');
|
|
// Pre-fix: exit 1 with "Binder exception: Trying to delete from an index on
|
|
// table File but its extension is not loaded" and no mention of FTS at all.
|
|
expect(second.status).toBe(0);
|
|
expect(second.output).not.toContain('its extension is not loaded');
|
|
expect(second.output).toContain('full DB write');
|
|
expect(second.output).toContain('FTS');
|
|
}, 400_000);
|
|
});
|
|
|
|
describe('self-heal over the network — FORCE INSTALL replaces a broken file (auto)', () => {
|
|
beforeEach((ctx) => {
|
|
// The platform matrix already exercises offline FTS load/diagnostic paths
|
|
// against real macOS/Windows binaries. Keep network redownload coverage on
|
|
// Ubuntu, where the full test job has the most stable extension fetch path.
|
|
if (process.platform !== 'linux') ctx.skip();
|
|
if (!networkAvailable) ctx.skip();
|
|
});
|
|
|
|
it('the reported journey heals: degraded analyze, then repair-fts with auto re-downloads and repairs', () => {
|
|
const { home, extensionFile } = makeHome('broken');
|
|
const repo = makeFixtureRepo('heal');
|
|
|
|
const degraded = runCli(['analyze'], repo, home, 'load-only');
|
|
expect(degraded.status).toBe(0);
|
|
expect(degraded.output).toContain('FTS extension unavailable');
|
|
|
|
// The reporter's exact failing command — plain INSTALL used to no-op
|
|
// over the broken file and this kept failing forever.
|
|
const repair = runCli(['analyze', '--repair-fts'], repo, home, 'auto');
|
|
expect(repair.status).toBe(0);
|
|
expect(repair.output).toContain('FTS indexes repaired successfully');
|
|
expect(fs.statSync(extensionFile).size).toBeGreaterThan(1024 * 1024);
|
|
|
|
const query = runCli(['query', 'greetE2eSymbol'], repo, home, 'load-only');
|
|
expect(query.status).toBe(0);
|
|
expect(query.output).toContain('greetE2eSymbol');
|
|
expect(query.output).not.toContain('keyword search degraded');
|
|
}, 600_000);
|
|
|
|
it('a fresh machine with no extension installs it during analyze and gets full FTS', () => {
|
|
const { home, extensionFile } = makeHome('missing');
|
|
const repo = makeFixtureRepo('fresh');
|
|
const result = runCli(['analyze'], repo, home, 'auto');
|
|
expect(result.status).toBe(0);
|
|
expect(result.output).toContain('indexed successfully');
|
|
expect(result.output).not.toContain('FTS extension unavailable');
|
|
expect(fs.existsSync(extensionFile)).toBe(true);
|
|
}, 600_000);
|
|
});
|