mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
Merge branch 'main' into fix/ts-object-method-exports-luyua9
This commit is contained in:
commit
bafeaa6384
21 changed files with 904 additions and 60 deletions
|
|
@ -197,7 +197,8 @@ args = ["-y", "gitnexus@latest", "mcp"]
|
|||
```bash
|
||||
gitnexus setup # Configure MCP for your editors (one-time)
|
||||
gitnexus analyze [path] # Index a repository (or update stale index)
|
||||
gitnexus analyze --force # Force full re-index
|
||||
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
|
||||
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
|
||||
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
|
||||
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@ environment:
|
|||
|
||||
Defaults are `port: 4848` and `host: 127.0.0.1` (loopback only). Use `0.0.0.0` only when the agent container needs to reach the eval-server from a separate network namespace. The health probe and tool scripts connect via the configured bind host (defaulting to `127.0.0.1`), which is reachable for both loopback and all-interface binds.
|
||||
|
||||
`"localhost"` is also a valid `eval_server_host` value. The OS resolves it at bind time — typically `127.0.0.1` on dual-stack or IPv4-only systems, and `::1` on IPv6-only systems. The exact result depends on your `/etc/hosts` and `gai.conf`. The READY signal will reflect the actual bound address (e.g. `GITNEXUS_EVAL_SERVER_READY:127.0.0.1:4848` or `GITNEXUS_EVAL_SERVER_READY:[::1]:4848`), not the literal string `localhost`. Use this when you want the server to bind to whichever loopback address the OS prefers rather than forcing IPv4.
|
||||
|
||||
**Running eval-server directly in Docker / Docker Compose:**
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -151,7 +151,8 @@ Your AI agent gets these tools automatically:
|
|||
```bash
|
||||
gitnexus setup # Configure MCP for your editors (one-time)
|
||||
gitnexus analyze [path] # Index a repository (or update stale index)
|
||||
gitnexus analyze --force # Force full re-index
|
||||
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
|
||||
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
|
||||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ function ensureHeap(): boolean {
|
|||
|
||||
export interface AnalyzeOptions {
|
||||
force?: boolean;
|
||||
repairFts?: boolean;
|
||||
/**
|
||||
* Embedding generation toggle. Commander parses `--embeddings [limit]` as:
|
||||
* - `undefined` when the flag is omitted
|
||||
|
|
@ -343,6 +344,15 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
process.env.GITNEXUS_EMBEDDING_DEVICE = options.embeddingDevice;
|
||||
}
|
||||
|
||||
if (options?.repairFts && options?.force) {
|
||||
cliError(
|
||||
' Cannot combine `--repair-fts` with `--force`. ' +
|
||||
'Use `--repair-fts` for fast FTS-only repair, or `--force` for a full rebuild.\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n GitNexus Analyzer\n');
|
||||
|
||||
// `--index-only` is the stronger contract — it suppresses every form of file
|
||||
|
|
@ -521,9 +531,11 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
// needs a fresh pipelineResult. Has no bearing on the registry
|
||||
// collision guard (see allowDuplicateName below).
|
||||
force: options?.force || options?.skills,
|
||||
repairFts: options?.repairFts,
|
||||
embeddings: embeddingsEnabled,
|
||||
embeddingsNodeLimit,
|
||||
dropEmbeddings: options?.dropEmbeddings,
|
||||
verbose: options?.verbose,
|
||||
skipGit: options?.skipGit,
|
||||
skipAgentsMd,
|
||||
skipSkills,
|
||||
|
|
@ -568,6 +580,19 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
return;
|
||||
}
|
||||
|
||||
if (result.ftsRepairedOnly) {
|
||||
clearInterval(elapsedTimer);
|
||||
process.removeListener('SIGINT', sigintHandler);
|
||||
console.log = origLog;
|
||||
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
||||
console.warn = origWarn;
|
||||
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
||||
console.error = origError;
|
||||
bar.stop();
|
||||
console.log(' FTS indexes repaired successfully\n');
|
||||
return;
|
||||
}
|
||||
|
||||
// Post-finalize invariant (#1169): runFullAnalysis nominally writes
|
||||
// meta.json and registers the repo, but on Windows it has been
|
||||
// observed to return successfully with neither artifact present
|
||||
|
|
|
|||
|
|
@ -44,10 +44,12 @@ export interface EvalServerOptions {
|
|||
|
||||
/**
|
||||
* Validate the --host value. Accepts IPv4, IPv6, or "localhost".
|
||||
* Returns the normalised host string, or null if invalid.
|
||||
* Returns the host string unchanged, or null if invalid.
|
||||
* "localhost" is passed through so the OS resolves it to the correct loopback
|
||||
* address (127.0.0.1 or ::1) at bind time rather than forcing IPv4.
|
||||
*/
|
||||
export function validateHost(raw: string): string | null {
|
||||
if (raw === 'localhost') return '127.0.0.1';
|
||||
if (raw === 'localhost') return raw;
|
||||
if (isIPv4(raw) || isIPv6(raw)) return raw;
|
||||
return null;
|
||||
}
|
||||
|
|
@ -470,12 +472,14 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
|
|||
{ code: err.code, port, host },
|
||||
);
|
||||
} else if (err.code === 'EADDRNOTAVAIL') {
|
||||
const isIPv6Host = isIPv6(host);
|
||||
// "localhost" may resolve to ::1 on IPv6-only systems; treat it as
|
||||
// potentially IPv6 so the user gets the right diagnostic hint.
|
||||
const isIPv6Host = isIPv6(host) || host === 'localhost';
|
||||
cliError(
|
||||
`\nGitNexus eval-server failed to start:\n` +
|
||||
` Address ${host} is not available on this machine.\n\n` +
|
||||
(isIPv6Host
|
||||
? ` IPv6 address ${host} is not reachable — IPv6 may be disabled on this system or container.\n` +
|
||||
? ` Address ${host} resolved but is not reachable — IPv6 may be disabled, or the loopback interface may be unavailable.\n` +
|
||||
` Docker containers and many CI environments disable IPv6 by default.\n\n`
|
||||
: ` The --host value must be an IP assigned to a local network interface.\n` +
|
||||
` Run \`ip addr\` (Linux) or \`ipconfig\` (Windows) to list available addresses.\n\n`) +
|
||||
|
|
@ -506,10 +510,21 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
|
|||
// Plain-text banner for the human watching stderr; structured record
|
||||
// for log aggregation (split into two so the user sees a real banner
|
||||
// not `{"level":30,"msg":"...","port":4747,"endpoints":[...]}`).
|
||||
// Use server.address().port so --port 0 (OS-assigned) emits the real port.
|
||||
// Use server.address() so the banner and READY signal reflect what the OS
|
||||
// actually bound to, not the input host string. This matters when "localhost"
|
||||
// is passed: the OS may resolve it to ::1 on some systems.
|
||||
const addr = server.address();
|
||||
const boundPort = typeof addr === 'object' && addr !== null ? addr.port : port;
|
||||
const displayHost = host.includes(':') ? `[${host}]` : host;
|
||||
// server.listen callback only fires after a successful TCP bind, so
|
||||
// server.address() is guaranteed to return an AddressInfo object here.
|
||||
if (typeof addr !== 'object' || addr === null) {
|
||||
cliError(
|
||||
`\nGitNexus eval-server: unexpected server.address() value after bind: ${JSON.stringify(addr)}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const boundPort = addr.port;
|
||||
const boundAddress = addr.address;
|
||||
const displayHost = boundAddress.includes(':') ? `[${boundAddress}]` : boundAddress;
|
||||
const bannerLines = [
|
||||
`GitNexus eval-server: listening on http://${displayHost}:${boundPort}`,
|
||||
` POST /tool/query — search execution flows`,
|
||||
|
|
@ -537,8 +552,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
|
|||
});
|
||||
try {
|
||||
// Use fd 1 directly — LadybugDB captures process.stdout (#324)
|
||||
const readyHost = host.includes(':') ? `[${host}]` : host;
|
||||
writeSync(1, `GITNEXUS_EVAL_SERVER_READY:${readyHost}:${boundPort}\n`);
|
||||
writeSync(1, `GITNEXUS_EVAL_SERVER_READY:${displayHost}:${boundPort}\n`);
|
||||
} catch {
|
||||
// stdout may not be available (e.g., broken pipe)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ program
|
|||
.command('analyze [path]')
|
||||
.description('Index a repository (full analysis)')
|
||||
.option('-f, --force', 'Force full re-index even if up to date')
|
||||
.option('--repair-fts', 'Repair/rebuild search FTS indexes without full re-analysis')
|
||||
.option(
|
||||
'--embeddings [limit]',
|
||||
'Enable embedding generation for semantic search (off by default). ' +
|
||||
|
|
|
|||
|
|
@ -74,12 +74,31 @@ export const walkRepositoryPaths = async (
|
|||
|
||||
if (skippedLarge > 0) {
|
||||
const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES;
|
||||
const isOverrideUnset = !process.env.GITNEXUS_MAX_FILE_SIZE;
|
||||
const suffix = isDefault ? ', likely generated/vendored' : '';
|
||||
logger.warn(` Skipped ${skippedLarge} large files (>${maxFileSizeBytes / 1024}KB${suffix})`);
|
||||
if (isVerboseIngestionEnabled()) {
|
||||
for (const p of skippedLargePaths) {
|
||||
logger.warn(` - ${p}`);
|
||||
}
|
||||
|
||||
// Always show at least the first few paths so users can diagnose why
|
||||
// edges are missing from a specific file (issue #1659). The full list is
|
||||
// gated behind GITNEXUS_VERBOSE=1 to avoid flooding output on repos with
|
||||
// many generated/vendored blobs. Sort before slicing so the preview is
|
||||
// stable across runs (fs.stat callbacks race within each batch).
|
||||
skippedLargePaths.sort();
|
||||
const SKIPPED_PREVIEW_CAP = 5;
|
||||
const showAll = isVerboseIngestionEnabled() || skippedLargePaths.length <= SKIPPED_PREVIEW_CAP;
|
||||
const preview = showAll ? skippedLargePaths : skippedLargePaths.slice(0, SKIPPED_PREVIEW_CAP);
|
||||
for (const p of preview) {
|
||||
logger.warn(` - ${p}`);
|
||||
}
|
||||
if (!showAll) {
|
||||
const remaining = skippedLargePaths.length - SKIPPED_PREVIEW_CAP;
|
||||
logger.warn(` ...and ${remaining} more (set GITNEXUS_VERBOSE=1 to list them all)`);
|
||||
}
|
||||
// Only hint about the env var when the user has not set it at all. An
|
||||
// explicit GITNEXUS_MAX_FILE_SIZE=512 happens to resolve to the same
|
||||
// bytes as the default but the operator clearly already knows the knob.
|
||||
if (isDefault && isOverrideUnset) {
|
||||
logger.warn(` Set GITNEXUS_MAX_FILE_SIZE=<KB> to include files above the default cap.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1651,7 +1651,10 @@ export const createFTSIndex = async (
|
|||
if (ensuredFTSIndexes.has(key)) return;
|
||||
|
||||
if (!(await loadFTSExtension())) {
|
||||
return;
|
||||
throw new Error(
|
||||
`FTS extension unavailable - cannot create FTS index ${tableName}.${indexName}. ` +
|
||||
'Run `gitnexus doctor` and ensure the LadybugDB FTS extension is installed and loadable on this machine.',
|
||||
);
|
||||
}
|
||||
|
||||
const propList = properties.map((p) => `'${p}'`).join(', ');
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import {
|
|||
deleteAllCommunitiesAndProcesses,
|
||||
queryImporters,
|
||||
} from './lbug/lbug-adapter.js';
|
||||
import { createSearchFTSIndexes } from './search/fts-indexes.js';
|
||||
import { createSearchFTSIndexes, verifySearchFTSIndexes } from './search/fts-indexes.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
saveMeta,
|
||||
|
|
@ -71,6 +71,10 @@ export interface AnalyzeOptions {
|
|||
* bypass. See `allowDuplicateName` below.
|
||||
*/
|
||||
force?: boolean;
|
||||
/** Repair only search indexes without re-running full parsing/indexing. */
|
||||
repairFts?: boolean;
|
||||
/** Emit per-index FTS create logs. */
|
||||
verbose?: boolean;
|
||||
embeddings?: boolean;
|
||||
/**
|
||||
* Override the auto-skip node-count cap for embedding generation.
|
||||
|
|
@ -126,6 +130,8 @@ export interface AnalyzeResult {
|
|||
alreadyUpToDate?: boolean;
|
||||
/** The raw pipeline result — only populated when needed by callers (e.g. skill generation). */
|
||||
pipelineResult?: any;
|
||||
/** True when analyze only repaired FTS indexes and skipped pipeline re-analysis. */
|
||||
ftsRepairedOnly?: boolean;
|
||||
}
|
||||
|
||||
// Re-export the pure flag-derivation helper so external callers (and tests)
|
||||
|
|
@ -190,6 +196,78 @@ export async function runFullAnalysis(
|
|||
const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : '';
|
||||
const existingMeta = await loadMeta(storagePath);
|
||||
|
||||
// ── FTS-only repair path ────────────────────────────────────────────
|
||||
if (options.repairFts) {
|
||||
if (!existingMeta) {
|
||||
throw new Error(
|
||||
'Cannot repair FTS indexes because this repository has not been analyzed yet. ' +
|
||||
'Run `gitnexus analyze` first to create the initial index, then retry `--repair-fts`.',
|
||||
);
|
||||
}
|
||||
let lbugStat;
|
||||
try {
|
||||
lbugStat = await fs.lstat(lbugPath);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Cannot repair FTS indexes: graph store at ${lbugPath} is missing. ` +
|
||||
'Run `gitnexus analyze` (full) to rebuild from scratch.',
|
||||
);
|
||||
}
|
||||
if (!lbugStat.isFile()) {
|
||||
const foundType = lbugStat.isDirectory()
|
||||
? 'a directory'
|
||||
: lbugStat.isSymbolicLink()
|
||||
? 'a symbolic link'
|
||||
: lbugStat.isSocket()
|
||||
? 'a socket'
|
||||
: lbugStat.isBlockDevice()
|
||||
? 'a block device'
|
||||
: lbugStat.isCharacterDevice()
|
||||
? 'a character device'
|
||||
: lbugStat.isFIFO()
|
||||
? 'a FIFO'
|
||||
: 'not a regular file';
|
||||
throw new Error(
|
||||
`Cannot repair FTS indexes: graph store at ${lbugPath} is ${foundType} (expected a file). ` +
|
||||
'Run `gitnexus analyze` (full) to rebuild from scratch.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
await initLbug(lbugPath);
|
||||
progress('fts', 85, 'Repairing search indexes...');
|
||||
await createSearchFTSIndexes({
|
||||
onIndexStart: options.verbose
|
||||
? (table, indexName) => log(`FTS: creating ${table}.${indexName}`)
|
||||
: undefined,
|
||||
onIndexReady: options.verbose
|
||||
? (table, indexName) => log(`FTS: ready ${table}.${indexName}`)
|
||||
: undefined,
|
||||
});
|
||||
const missing = await verifySearchFTSIndexes(executeQuery);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`FTS repair failed - missing indexes after rebuild: ${missing.join(', ')}. ` +
|
||||
'Run `gitnexus analyze --force` to perform a full graph+FTS rebuild; ' +
|
||||
'if that also fails, verify FTS extension availability via `gitnexus doctor`.',
|
||||
);
|
||||
}
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
progress('fts', 90, 'Search indexes ready');
|
||||
progress('done', 100, 'Done');
|
||||
return {
|
||||
repoName:
|
||||
options.registryName ??
|
||||
getInferredRepoName(repoPath) ??
|
||||
path.basename(resolveRepoIdentityRoot(repoPath)),
|
||||
repoPath,
|
||||
stats: existingMeta.stats ?? {},
|
||||
ftsRepairedOnly: true,
|
||||
};
|
||||
} finally {
|
||||
await closeLbug().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Crash recovery: dirty flag forces full rebuild ────────────────
|
||||
// If the previous incremental run set incrementalInProgress and didn't
|
||||
// clear it, the on-disk index may be in a half-state. Cheapest path
|
||||
|
|
@ -583,7 +661,21 @@ export async function runFullAnalysis(
|
|||
|
||||
// ── Phase 3: FTS (85–90%) ─────────────────────────────────────────
|
||||
progress('fts', 85, 'Creating search indexes...');
|
||||
await createSearchFTSIndexes();
|
||||
await createSearchFTSIndexes({
|
||||
onIndexStart: options.verbose
|
||||
? (table, indexName) => log(`FTS: creating ${table}.${indexName}`)
|
||||
: undefined,
|
||||
onIndexReady: options.verbose
|
||||
? (table, indexName) => log(`FTS: ready ${table}.${indexName}`)
|
||||
: undefined,
|
||||
});
|
||||
const missingIndexNames = await verifySearchFTSIndexes(executeQuery);
|
||||
if (missingIndexNames.length > 0) {
|
||||
throw new Error(
|
||||
`FTS verification failed - missing indexes after analyze: ${missingIndexNames.join(', ')}. ` +
|
||||
'Check FTS extension availability, then retry `gitnexus analyze --force` for a full rebuild.',
|
||||
);
|
||||
}
|
||||
progress('fts', 90, 'Search indexes ready');
|
||||
|
||||
// ── Phase 3.5: Re-insert cached embeddings ────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,8 +1,45 @@
|
|||
import { createFTSIndex } from '../lbug/lbug-adapter.js';
|
||||
import { FTS_INDEXES } from './fts-schema.js';
|
||||
|
||||
export async function createSearchFTSIndexes(): Promise<void> {
|
||||
export interface CreateSearchFTSIndexesOptions {
|
||||
onIndexStart?: (table: string, indexName: string) => void;
|
||||
onIndexReady?: (table: string, indexName: string) => void;
|
||||
}
|
||||
|
||||
export async function createSearchFTSIndexes(
|
||||
options?: CreateSearchFTSIndexesOptions,
|
||||
): Promise<void> {
|
||||
for (const { table, indexName, properties } of FTS_INDEXES) {
|
||||
options?.onIndexStart?.(table, indexName);
|
||||
await createFTSIndex(table, indexName, [...properties]);
|
||||
options?.onIndexReady?.(table, indexName);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifySearchFTSIndexes(
|
||||
executeQuery: (cypher: string) => Promise<unknown[]>,
|
||||
): Promise<string[]> {
|
||||
const safeIdentifier = (value: string): string => {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
||||
throw new Error(`Invalid FTS identifier: ${value}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const missing: string[] = [];
|
||||
for (const { table, indexName } of FTS_INDEXES) {
|
||||
const safeTable = safeIdentifier(table);
|
||||
const safeIndex = safeIdentifier(indexName);
|
||||
const probe = `
|
||||
CALL QUERY_FTS_INDEX('${safeTable}', '${safeIndex}', '__gitnexus_fts_probe__', conjunctive := false)
|
||||
RETURN score
|
||||
LIMIT 1
|
||||
`;
|
||||
try {
|
||||
await executeQuery(probe);
|
||||
} catch {
|
||||
missing.push(`${table}.${indexName}`);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1066,7 +1066,7 @@ export class LocalBackend {
|
|||
timing,
|
||||
...(!ftsUsed && {
|
||||
warning:
|
||||
'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.',
|
||||
'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1233,7 +1233,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
const response: any = { results: results.searchResults ?? results };
|
||||
if (results.ftsAvailable === false) {
|
||||
response.warning =
|
||||
'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.';
|
||||
'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.';
|
||||
}
|
||||
res.json(response);
|
||||
} catch (err: any) {
|
||||
|
|
|
|||
|
|
@ -1408,5 +1408,102 @@ describe('CLI end-to-end', () => {
|
|||
}, 30000);
|
||||
});
|
||||
}, 35000);
|
||||
|
||||
it('emits READY signal with bound IP (not literal "localhost") when --host localhost is used', () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'--import',
|
||||
tsxImportUrl,
|
||||
cliEntry,
|
||||
'eval-server',
|
||||
'--port',
|
||||
'0',
|
||||
'--host',
|
||||
'localhost',
|
||||
'--idle-timeout',
|
||||
'3',
|
||||
],
|
||||
{
|
||||
cwd: MINI_REPO,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: cliEnv(),
|
||||
},
|
||||
);
|
||||
|
||||
let stdoutBuffer = '';
|
||||
let settled = false;
|
||||
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.kill('SIGTERM');
|
||||
fn();
|
||||
};
|
||||
|
||||
child.stdout.on('data', async (chunk: Buffer) => {
|
||||
stdoutBuffer += chunk.toString();
|
||||
const readyLine = stdoutBuffer
|
||||
.split('\n')
|
||||
.find((l) => l.startsWith('GITNEXUS_EVAL_SERVER_READY:'));
|
||||
if (!readyLine || settled) return;
|
||||
|
||||
// The signal must contain a real bound IP, not the literal input string
|
||||
if (readyLine.includes(':localhost:')) {
|
||||
settle(() =>
|
||||
reject(
|
||||
new Error(
|
||||
`READY signal contained literal "localhost" instead of a bound IP:\n${readyLine}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse host and port: everything after the prefix up to the last colon
|
||||
const withoutPrefix = readyLine.slice('GITNEXUS_EVAL_SERVER_READY:'.length);
|
||||
const lastColon = withoutPrefix.lastIndexOf(':');
|
||||
const signalHost = withoutPrefix.slice(0, lastColon); // "127.0.0.1" or "[::1]"
|
||||
const boundPort = withoutPrefix.slice(lastColon + 1).trim();
|
||||
if (!boundPort || isNaN(Number(boundPort))) {
|
||||
settle(() => reject(new Error(`Could not parse port from READY signal: ${readyLine}`)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe /health at the bound address to confirm the server is reachable
|
||||
try {
|
||||
const res = await fetch(`http://${signalHost}:${boundPort}/health`);
|
||||
if (res.status === 200) {
|
||||
settle(resolve);
|
||||
} else {
|
||||
settle(() => reject(new Error(`/health returned ${res.status}, expected 200`)));
|
||||
}
|
||||
} catch (err) {
|
||||
settle(() =>
|
||||
reject(
|
||||
new Error(
|
||||
`eval-server bound to localhost but /health unreachable at ${signalHost}:${boundPort}: ${err}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
if (text.includes('unknown option') || text.includes('error: unknown')) {
|
||||
settle(() => reject(new Error(`eval-server rejected --host flag:\n${text}`)));
|
||||
}
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
settle(() =>
|
||||
reject(new Error('eval-server --host localhost did not emit READY signal within 30s')),
|
||||
);
|
||||
}, 30000);
|
||||
});
|
||||
}, 35000);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -398,5 +398,172 @@ describe('filesystem-walker', () => {
|
|||
expect(skipWarnings.length).toBeGreaterThan(0);
|
||||
expect(String(skipWarnings[0].msg ?? '')).toContain('generated/vendored');
|
||||
});
|
||||
|
||||
// Regression: issue #1659. The skipped-paths list and the
|
||||
// GITNEXUS_MAX_FILE_SIZE hint must appear by default, otherwise users
|
||||
// see "Skipped N large files" with no actionable detail and misdiagnose
|
||||
// missing IMPORTS/CALLS edges as a resolver bug.
|
||||
it('lists the skipped path by default (not gated behind GITNEXUS_VERBOSE)', async () => {
|
||||
await walkRepositoryPaths(sizeDir);
|
||||
const pathWarnings = cap.records().filter((r) => String(r.msg ?? '').includes(BIG_FILE));
|
||||
expect(pathWarnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('emits a GITNEXUS_MAX_FILE_SIZE hint when running with the default cap', async () => {
|
||||
await walkRepositoryPaths(sizeDir);
|
||||
const hint = cap
|
||||
.records()
|
||||
.filter((r) => String(r.msg ?? '').includes('GITNEXUS_MAX_FILE_SIZE=<KB>'));
|
||||
expect(hint.length).toBe(1);
|
||||
});
|
||||
|
||||
it('omits the GITNEXUS_MAX_FILE_SIZE hint when an override is active', async () => {
|
||||
process.env.GITNEXUS_MAX_FILE_SIZE = '1';
|
||||
await walkRepositoryPaths(sizeDir);
|
||||
const hint = cap
|
||||
.records()
|
||||
.filter((r) => String(r.msg ?? '').includes('GITNEXUS_MAX_FILE_SIZE=<KB>'));
|
||||
expect(hint.length).toBe(0);
|
||||
});
|
||||
|
||||
// Edge case from the #1661 adversarial review: setting GITNEXUS_MAX_FILE_SIZE
|
||||
// to the same value as the default (512KB) used to still print the hint
|
||||
// because the byte comparison resolved to equal. The hint should care
|
||||
// about whether the operator set the env var, not what value they chose.
|
||||
it('omits the GITNEXUS_MAX_FILE_SIZE hint when the override equals the default value', async () => {
|
||||
process.env.GITNEXUS_MAX_FILE_SIZE = '512';
|
||||
await walkRepositoryPaths(sizeDir);
|
||||
const hint = cap
|
||||
.records()
|
||||
.filter((r) => String(r.msg ?? '').includes('GITNEXUS_MAX_FILE_SIZE=<KB>'));
|
||||
expect(hint.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('large file skip preview cap (#1659)', () => {
|
||||
let manyDir: string;
|
||||
const ORIGINAL_ENV = process.env.GITNEXUS_MAX_FILE_SIZE;
|
||||
const ORIGINAL_VERBOSE = process.env.GITNEXUS_VERBOSE;
|
||||
let cap: ReturnType<typeof _captureLogger>;
|
||||
|
||||
beforeAll(async () => {
|
||||
manyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-size-many-'));
|
||||
await fs.mkdir(path.join(manyDir, 'src'), { recursive: true });
|
||||
// 8 files >512KB so the preview-cap path (5) is exercised.
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await fs.writeFile(path.join(manyDir, 'src', `big${i}.ts`), 'x'.repeat(600 * 1024));
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(manyDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.GITNEXUS_MAX_FILE_SIZE;
|
||||
delete process.env.GITNEXUS_VERBOSE;
|
||||
_resetMaxFileSizeWarnings();
|
||||
cap = _captureLogger();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_ENV === undefined) {
|
||||
delete process.env.GITNEXUS_MAX_FILE_SIZE;
|
||||
} else {
|
||||
process.env.GITNEXUS_MAX_FILE_SIZE = ORIGINAL_ENV;
|
||||
}
|
||||
if (ORIGINAL_VERBOSE === undefined) {
|
||||
delete process.env.GITNEXUS_VERBOSE;
|
||||
} else {
|
||||
process.env.GITNEXUS_VERBOSE = ORIGINAL_VERBOSE;
|
||||
}
|
||||
cap.restore();
|
||||
});
|
||||
|
||||
it('truncates the path list to 5 and mentions GITNEXUS_VERBOSE when over the cap', async () => {
|
||||
await walkRepositoryPaths(manyDir);
|
||||
const pathLines = cap.records().filter((r) => /^\s*-\s/.test(String(r.msg ?? '')));
|
||||
expect(pathLines.length).toBe(5);
|
||||
const more = cap
|
||||
.records()
|
||||
.filter((r) => String(r.msg ?? '').includes('and 3 more (set GITNEXUS_VERBOSE=1'));
|
||||
expect(more.length).toBe(1);
|
||||
});
|
||||
|
||||
// Boundary check from the #1661 adversarial review: the SKIPPED_PREVIEW_CAP
|
||||
// comparison is `<=`, so 5 paths should list all five without a truncation
|
||||
// line and 6 paths should list exactly five plus "...and 1 more". Tested
|
||||
// explicitly so a future off-by-one refactor (`<=` → `<`) fails fast.
|
||||
it('lists all paths and omits the truncation line at exactly 5 skipped files', async () => {
|
||||
const fiveDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-size-five-'));
|
||||
try {
|
||||
await fs.mkdir(path.join(fiveDir, 'src'), { recursive: true });
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await fs.writeFile(path.join(fiveDir, 'src', `big${i}.ts`), 'x'.repeat(600 * 1024));
|
||||
}
|
||||
await walkRepositoryPaths(fiveDir);
|
||||
const pathLines = cap.records().filter((r) => /^\s*-\s/.test(String(r.msg ?? '')));
|
||||
expect(pathLines.length).toBe(5);
|
||||
const more = cap.records().filter((r) => String(r.msg ?? '').includes('...and '));
|
||||
expect(more.length).toBe(0);
|
||||
} finally {
|
||||
await fs.rm(fiveDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('lists exactly 5 paths plus "...and 1 more" at exactly 6 skipped files', async () => {
|
||||
const sixDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-size-six-'));
|
||||
try {
|
||||
await fs.mkdir(path.join(sixDir, 'src'), { recursive: true });
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await fs.writeFile(path.join(sixDir, 'src', `big${i}.ts`), 'x'.repeat(600 * 1024));
|
||||
}
|
||||
await walkRepositoryPaths(sixDir);
|
||||
const pathLines = cap.records().filter((r) => /^\s*-\s/.test(String(r.msg ?? '')));
|
||||
expect(pathLines.length).toBe(5);
|
||||
const more = cap
|
||||
.records()
|
||||
.filter((r) => String(r.msg ?? '').includes('and 1 more (set GITNEXUS_VERBOSE=1'));
|
||||
expect(more.length).toBe(1);
|
||||
} finally {
|
||||
await fs.rm(sixDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('lists every skipped path when GITNEXUS_VERBOSE=1', async () => {
|
||||
process.env.GITNEXUS_VERBOSE = '1';
|
||||
await walkRepositoryPaths(manyDir);
|
||||
const pathLines = cap.records().filter((r) => /^\s*-\s/.test(String(r.msg ?? '')));
|
||||
expect(pathLines.length).toBe(8);
|
||||
const more = cap.records().filter((r) => String(r.msg ?? '').includes('and '));
|
||||
expect(more.length).toBe(0);
|
||||
});
|
||||
|
||||
// Issue #1659 follow-up (PR #1661 review): paths were pushed in fs.stat
|
||||
// completion order, so the default preview could vary between runs on
|
||||
// the same repo. The implementation sorts skippedLargePaths before
|
||||
// slicing, so the listed paths come out in sorted order, which is the
|
||||
// stable contract operators can rely on.
|
||||
it('lists skipped paths in sorted order (deterministic preview)', async () => {
|
||||
process.env.GITNEXUS_VERBOSE = '1';
|
||||
await walkRepositoryPaths(manyDir);
|
||||
const pathLines = cap
|
||||
.records()
|
||||
.map((r) => String(r.msg ?? ''))
|
||||
.filter((m) => /^\s*-\s/.test(m))
|
||||
.map((m) => m.replace(/^\s*-\s*/, ''));
|
||||
expect(pathLines).toEqual([...pathLines].sort());
|
||||
// sanity-check we actually saw all 8 of the manyDir fixture
|
||||
expect(pathLines).toEqual([
|
||||
'src/big0.ts',
|
||||
'src/big1.ts',
|
||||
'src/big2.ts',
|
||||
'src/big3.ts',
|
||||
'src/big4.ts',
|
||||
'src/big5.ts',
|
||||
'src/big6.ts',
|
||||
'src/big7.ts',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { runFullAnalysisMock, generateAIContextFilesMock, generateSkillFilesMock } = vi.hoisted(
|
||||
() => {
|
||||
const { runFullAnalysisMock, generateAIContextFilesMock, generateSkillFilesMock, cliErrorMock } =
|
||||
vi.hoisted(() => {
|
||||
const runFullAnalysisMock = vi.fn();
|
||||
const generateAIContextFilesMock = vi.fn(async () => ({ files: [] as string[] }));
|
||||
const generateSkillFilesMock = vi.fn(async () => ({
|
||||
skills: [{ name: 'c', label: 'Community', symbolCount: 1, fileCount: 1 }],
|
||||
outputPath: '/repo/.claude/skills/generated',
|
||||
}));
|
||||
return { runFullAnalysisMock, generateAIContextFilesMock, generateSkillFilesMock };
|
||||
},
|
||||
);
|
||||
const cliErrorMock = vi.fn();
|
||||
return {
|
||||
runFullAnalysisMock,
|
||||
generateAIContextFilesMock,
|
||||
generateSkillFilesMock,
|
||||
cliErrorMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/core/run-analyze.js', () => ({
|
||||
runFullAnalysis: runFullAnalysisMock,
|
||||
|
|
@ -24,6 +29,10 @@ vi.mock('../../src/cli/skill-gen.js', () => ({
|
|||
generateSkillFiles: generateSkillFilesMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/cli/cli-message.js', () => ({
|
||||
cliError: cliErrorMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
|
@ -62,6 +71,7 @@ describe('analyzeCommand commander → runFullAnalysis noStats bridge (#1477)',
|
|||
skills: [{ name: 'c', label: 'Community', symbolCount: 1, fileCount: 1 }],
|
||||
outputPath: '/repo/.claude/skills/generated',
|
||||
});
|
||||
cliErrorMock.mockReset();
|
||||
process.exitCode = undefined;
|
||||
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
|
||||
});
|
||||
|
|
@ -104,6 +114,27 @@ describe('analyzeCommand commander → runFullAnalysis noStats bridge (#1477)',
|
|||
expect(opts.skipAgentsMd).toBe(true);
|
||||
});
|
||||
|
||||
it('passes --repair-fts through to runFullAnalysis', async () => {
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, { repairFts: true });
|
||||
|
||||
const opts = runFullAnalysisMock.mock.calls[0][1];
|
||||
expect(opts.repairFts).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects combining --repair-fts with --force', async () => {
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, { repairFts: true, force: true });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(cliErrorMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/cannot combine `--repair-fts` with `--force`/i),
|
||||
);
|
||||
expect(runFullAnalysisMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes stats:false as noStats to generateAIContextFiles on the --skills regeneration path (#1477)', async () => {
|
||||
runFullAnalysisMock.mockResolvedValueOnce({
|
||||
repoName: 'repo',
|
||||
|
|
|
|||
|
|
@ -40,6 +40,31 @@ describe('BM25 search', () => {
|
|||
['Interface', 'interface_fts', ['name', 'content']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('verifies all configured FTS indexes are queryable', async () => {
|
||||
const executeQuery = vi.fn().mockResolvedValue([]);
|
||||
const { verifySearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js');
|
||||
|
||||
const missing = await verifySearchFTSIndexes(executeQuery);
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
expect(executeQuery).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('reports missing indexes when an FTS probe fails', async () => {
|
||||
const executeQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockRejectedValueOnce(new Error('index does not exist'))
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
const { verifySearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js');
|
||||
|
||||
const missing = await verifySearchFTSIndexes(executeQuery);
|
||||
|
||||
expect(missing).toEqual(['Function.function_fts']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchFTSFromLbug', () => {
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ describe('LocalBackend.callTool', () => {
|
|||
const result = await backend.callTool('query', { query: 'ProcessActivity' });
|
||||
|
||||
expect(result).toHaveProperty('warning');
|
||||
expect((result as any).warning).toMatch(/gitnexus analyze --force/);
|
||||
expect((result as any).warning).toMatch(/gitnexus analyze --repair-fts/);
|
||||
});
|
||||
|
||||
it('does not include warning when ftsAvailable is true with zero results', async () => {
|
||||
|
|
|
|||
|
|
@ -76,4 +76,11 @@ describe('CLI help surface', () => {
|
|||
expect(result.stdout).toContain('understand-quickly');
|
||||
expect(result.stdout).toContain('UNDERSTAND_QUICKLY_TOKEN');
|
||||
});
|
||||
|
||||
it('analyze help includes the FTS repair option', () => {
|
||||
const result = runHelp('analyze');
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('--repair-fts');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ import {
|
|||
// ─── validateHost ────────────────────────────────────────────────────
|
||||
|
||||
describe('validateHost', () => {
|
||||
it('normalizes "localhost" to "127.0.0.1"', () => {
|
||||
expect(validateHost('localhost')).toBe('127.0.0.1');
|
||||
it('passes "localhost" through unchanged', () => {
|
||||
expect(validateHost('localhost')).toBe('localhost');
|
||||
});
|
||||
|
||||
it('accepts valid IPv4 addresses', () => {
|
||||
|
|
|
|||
243
gitnexus/test/unit/run-analyze-fts-repair.test.ts
Normal file
243
gitnexus/test/unit/run-analyze-fts-repair.test.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import fs from 'fs/promises';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getStoragePaths, saveMeta } from '../../src/storage/repo-manager.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
const SIMULATED_MISSING_FTS_INDEX_NAME = 'File.file_fts';
|
||||
const PLACEHOLDER_GRAPH_STORE_CONTENT = 'fixture';
|
||||
|
||||
const createPlaceholderGraphStore = async (lbugPath: string): Promise<void> => {
|
||||
// Repair mode gates on existence before `initLbug` takes over open/validate.
|
||||
// A placeholder file is enough to exercise this preflight branch.
|
||||
await fs.writeFile(lbugPath, PLACEHOLDER_GRAPH_STORE_CONTENT);
|
||||
};
|
||||
|
||||
const escapeForRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
describe('runFullAnalysis FTS repair and verification failure paths', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('../../src/core/lbug/lbug-adapter.js');
|
||||
vi.doUnmock('../../src/core/search/fts-indexes.js');
|
||||
vi.doUnmock('../../src/core/ingestion/pipeline.js');
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('fails repair mode when no base meta exists', async () => {
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-no-meta-');
|
||||
try {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
|
||||
await expect(
|
||||
runFullAnalysis(
|
||||
tmpRepo.dbPath,
|
||||
{ repairFts: true },
|
||||
{
|
||||
onProgress: () => {},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/has not been analyzed yet/i);
|
||||
} finally {
|
||||
await tmpRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails repair mode when graph store is missing', async () => {
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-missing-store-');
|
||||
try {
|
||||
const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath);
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
await saveMeta(storagePath, {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: '',
|
||||
indexedAt: new Date().toISOString(),
|
||||
stats: {},
|
||||
});
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
|
||||
await expect(
|
||||
runFullAnalysis(
|
||||
tmpRepo.dbPath,
|
||||
{ repairFts: true },
|
||||
{
|
||||
onProgress: () => {},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(new RegExp(`graph store at ${escapeForRegex(lbugPath)} is missing`));
|
||||
} finally {
|
||||
await tmpRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails repair mode when graph store path is not a file', async () => {
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-store-not-file-');
|
||||
try {
|
||||
const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath);
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
await saveMeta(storagePath, {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: '',
|
||||
indexedAt: new Date().toISOString(),
|
||||
stats: {},
|
||||
});
|
||||
await fs.mkdir(lbugPath, { recursive: true });
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
|
||||
await expect(
|
||||
runFullAnalysis(
|
||||
tmpRepo.dbPath,
|
||||
{ repairFts: true },
|
||||
{
|
||||
onProgress: () => {},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new RegExp(
|
||||
`graph store at ${escapeForRegex(lbugPath)} is a directory \\(expected a file\\)`,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await tmpRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails repair mode when FTS verify still reports missing indexes', async () => {
|
||||
const closeLbugMock = vi.fn(async () => undefined);
|
||||
vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
initLbug: vi.fn(async () => undefined),
|
||||
loadGraphToLbug: vi.fn(async () => undefined),
|
||||
getLbugStats: vi.fn(async () => ({})),
|
||||
executeQuery: vi.fn(async () => []),
|
||||
executeWithReusedStatement: vi.fn(async () => []),
|
||||
closeLbug: closeLbugMock,
|
||||
loadCachedEmbeddings: vi.fn(async () => ({ embeddingNodeIds: new Set(), embeddings: [] })),
|
||||
deleteNodesForFile: vi.fn(async () => undefined),
|
||||
deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined),
|
||||
queryImporters: vi.fn(async () => []),
|
||||
}));
|
||||
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
|
||||
createSearchFTSIndexes: vi.fn(async () => undefined),
|
||||
verifySearchFTSIndexes: vi.fn(async () => [SIMULATED_MISSING_FTS_INDEX_NAME]),
|
||||
}));
|
||||
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-verify-fail-');
|
||||
try {
|
||||
const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath);
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
await saveMeta(storagePath, {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: '',
|
||||
indexedAt: new Date().toISOString(),
|
||||
stats: {},
|
||||
});
|
||||
await createPlaceholderGraphStore(lbugPath);
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
|
||||
await expect(
|
||||
runFullAnalysis(
|
||||
tmpRepo.dbPath,
|
||||
{ repairFts: true },
|
||||
{
|
||||
onProgress: () => {},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/FTS repair failed - missing indexes after rebuild/i);
|
||||
expect(closeLbugMock).toHaveBeenCalled();
|
||||
} finally {
|
||||
await tmpRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces extension-unavailable errors from FTS index creation in repair mode', async () => {
|
||||
vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
initLbug: vi.fn(async () => undefined),
|
||||
loadGraphToLbug: vi.fn(async () => undefined),
|
||||
getLbugStats: vi.fn(async () => ({})),
|
||||
executeQuery: vi.fn(async () => []),
|
||||
executeWithReusedStatement: vi.fn(async () => []),
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
loadCachedEmbeddings: vi.fn(async () => ({ embeddingNodeIds: new Set(), embeddings: [] })),
|
||||
deleteNodesForFile: vi.fn(async () => undefined),
|
||||
deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined),
|
||||
queryImporters: vi.fn(async () => []),
|
||||
}));
|
||||
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
|
||||
createSearchFTSIndexes: vi.fn(async () => {
|
||||
throw new Error('FTS extension unavailable');
|
||||
}),
|
||||
verifySearchFTSIndexes: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-extension-fail-');
|
||||
try {
|
||||
const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath);
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
await saveMeta(storagePath, {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: '',
|
||||
indexedAt: new Date().toISOString(),
|
||||
stats: {},
|
||||
});
|
||||
await createPlaceholderGraphStore(lbugPath);
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
|
||||
await expect(
|
||||
runFullAnalysis(
|
||||
tmpRepo.dbPath,
|
||||
{ repairFts: true },
|
||||
{
|
||||
onProgress: () => {},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/FTS extension unavailable/i);
|
||||
} finally {
|
||||
await tmpRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails full analyze when FTS verification reports missing indexes after creation', async () => {
|
||||
vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
initLbug: vi.fn(async () => undefined),
|
||||
loadGraphToLbug: vi.fn(async () => undefined),
|
||||
getLbugStats: vi.fn(async () => ({ nodes: 0, edges: 0, communities: 0, processes: 0 })),
|
||||
executeQuery: vi.fn(async () => []),
|
||||
executeWithReusedStatement: vi.fn(async () => []),
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
loadCachedEmbeddings: vi.fn(async () => ({ embeddingNodeIds: new Set(), embeddings: [] })),
|
||||
deleteNodesForFile: vi.fn(async () => undefined),
|
||||
deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined),
|
||||
queryImporters: vi.fn(async () => []),
|
||||
}));
|
||||
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
|
||||
createSearchFTSIndexes: vi.fn(async () => undefined),
|
||||
verifySearchFTSIndexes: vi.fn(async () => ['Function.function_fts']),
|
||||
}));
|
||||
vi.doMock('../../src/core/ingestion/pipeline.js', () => ({
|
||||
runPipelineFromRepo: vi.fn(async (repoPath: string) => ({
|
||||
repoPath,
|
||||
// Full-analyze path only needs `forEachNode` before the FTS verify guard.
|
||||
graph: { forEachNode: () => undefined },
|
||||
})),
|
||||
}));
|
||||
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-full-verify-fail-');
|
||||
try {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await expect(
|
||||
runFullAnalysis(
|
||||
tmpRepo.dbPath,
|
||||
{ force: true },
|
||||
{
|
||||
onProgress: () => {},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/FTS verification failed - missing indexes after analyze/i);
|
||||
} finally {
|
||||
await tmpRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,32 @@ import fs from 'fs';
|
|||
|
||||
describe('--skip-git CLI flag', () => {
|
||||
const cliPath = path.resolve(__dirname, '../../dist/cli/index.js');
|
||||
const ftsUnavailableMessage = 'FTS extension unavailable - cannot create FTS index';
|
||||
|
||||
interface ExecSyncLikeError {
|
||||
message?: string;
|
||||
stdout?: string | Buffer;
|
||||
stderr?: string | Buffer;
|
||||
}
|
||||
|
||||
const isFtsUnavailableError = (err: unknown): boolean => {
|
||||
if (!err || typeof err !== 'object') return false;
|
||||
const e = err as ExecSyncLikeError;
|
||||
return (
|
||||
e.message?.includes(ftsUnavailableMessage) ||
|
||||
e.stdout?.toString().includes(ftsUnavailableMessage) ||
|
||||
e.stderr?.toString().includes(ftsUnavailableMessage)
|
||||
);
|
||||
};
|
||||
|
||||
const shouldSkipForFtsUnavailable = (err: unknown, testName: string): boolean => {
|
||||
if (!isFtsUnavailableError(err)) return false;
|
||||
|
||||
console.warn(
|
||||
`[skip-git-cli.test] Skipping "${testName}" because FTS extension is unavailable.`,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
it('Commander maps --skip-git to options.skipGit (not --no-git inversion)', () => {
|
||||
// Verify the CLI defines --skip-git and --skip-agents-md in analyze help.
|
||||
|
|
@ -37,14 +63,26 @@ describe('--skip-git CLI flag', () => {
|
|||
...process.env,
|
||||
HOME: gitnexusHome,
|
||||
GITNEXUS_HOME: gitnexusHome,
|
||||
GITNEXUS_LBUG_EXTENSION_INSTALL: 'never',
|
||||
};
|
||||
|
||||
try {
|
||||
const output = execSync(
|
||||
`node "${cliPath}" analyze "${tmpDir}" --index-only --skills --skip-agents-md`,
|
||||
{ encoding: 'utf8', timeout: 60000, env },
|
||||
);
|
||||
let output: string;
|
||||
try {
|
||||
output = execSync(
|
||||
`node "${cliPath}" analyze "${tmpDir}" --index-only --skills --skip-agents-md`,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
env,
|
||||
},
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
shouldSkipForFtsUnavailable(err, 'warns when --index-only overrides --skills (PR 1485)')
|
||||
)
|
||||
return;
|
||||
throw err;
|
||||
}
|
||||
expect(output).toContain('--index-only overrides --skills');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
|
|
@ -87,15 +125,25 @@ describe('--skip-git CLI flag', () => {
|
|||
...process.env,
|
||||
HOME: gitnexusHome,
|
||||
GITNEXUS_HOME: gitnexusHome,
|
||||
GITNEXUS_LBUG_EXTENSION_INSTALL: 'never',
|
||||
};
|
||||
|
||||
try {
|
||||
execSync(`node "${cliPath}" analyze "${tmpDir}" --skip-git --skip-agents-md`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
env,
|
||||
});
|
||||
try {
|
||||
execSync(`node "${cliPath}" analyze "${tmpDir}" --skip-git --skip-agents-md`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
env,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
shouldSkipForFtsUnavailable(
|
||||
err,
|
||||
'still respects .gitnexusignore when run with --skip-git',
|
||||
)
|
||||
)
|
||||
return;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const keepContext = execSync(
|
||||
`node "${cliPath}" context keep --repo "${path.basename(tmpDir)}"`,
|
||||
|
|
@ -130,9 +178,8 @@ describe('--skip-git CLI flag', () => {
|
|||
function testEnv() {
|
||||
return {
|
||||
...process.env,
|
||||
HOME: parentDir,
|
||||
HOME: gitnexusHome,
|
||||
GITNEXUS_HOME: gitnexusHome,
|
||||
GITNEXUS_LBUG_EXTENSION_INSTALL: 'never',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -221,12 +268,24 @@ describe('--skip-git CLI flag', () => {
|
|||
createTestStructure();
|
||||
try {
|
||||
// Run analyze from COOLIO with --skip-git
|
||||
const output = execSync(`node "${cliPath}" analyze --skip-git --skip-agents-md`, {
|
||||
cwd: path.join(parentDir, 'COOLIO'),
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
env: testEnv(),
|
||||
});
|
||||
let output: string;
|
||||
try {
|
||||
output = execSync(`node "${cliPath}" analyze --skip-git --skip-agents-md`, {
|
||||
cwd: path.join(parentDir, 'COOLIO'),
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
env: testEnv(),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
shouldSkipForFtsUnavailable(
|
||||
err,
|
||||
'from subdir inside parent git repo, indexes subdir not parent',
|
||||
)
|
||||
)
|
||||
return;
|
||||
throw err;
|
||||
}
|
||||
// Should mention COOLIO not the parent dir name
|
||||
expect(output).toContain('COOLIO');
|
||||
|
||||
|
|
@ -255,12 +314,23 @@ describe('--skip-git CLI flag', () => {
|
|||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
execSync(`node "${cliPath}" analyze --skip-git --skip-agents-md`, {
|
||||
cwd: path.join(parentDir, 'COOLIO'),
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
env: testEnv(),
|
||||
});
|
||||
try {
|
||||
execSync(`node "${cliPath}" analyze --skip-git --skip-agents-md`, {
|
||||
cwd: path.join(parentDir, 'COOLIO'),
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
env: testEnv(),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
shouldSkipForFtsUnavailable(
|
||||
err,
|
||||
'keeps parent git status clean for --skip-git subdir analyze (#1233)',
|
||||
)
|
||||
)
|
||||
return;
|
||||
throw err;
|
||||
}
|
||||
|
||||
expect(
|
||||
fs.readFileSync(path.join(parentDir, 'COOLIO', '.gitnexus', '.gitignore'), 'utf8'),
|
||||
|
|
@ -278,12 +348,21 @@ describe('--skip-git CLI flag', () => {
|
|||
it('explicit input path with --skip-git indexes subdir', () => {
|
||||
createTestStructure();
|
||||
try {
|
||||
const output = execSync(`node "${cliPath}" analyze ./COOLIO --skip-git --skip-agents-md`, {
|
||||
cwd: parentDir,
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
env: testEnv(),
|
||||
});
|
||||
let output: string;
|
||||
try {
|
||||
output = execSync(`node "${cliPath}" analyze ./COOLIO --skip-git --skip-agents-md`, {
|
||||
cwd: parentDir,
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
env: testEnv(),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
shouldSkipForFtsUnavailable(err, 'explicit input path with --skip-git indexes subdir')
|
||||
)
|
||||
return;
|
||||
throw err;
|
||||
}
|
||||
expect(output).toContain('COOLIO');
|
||||
|
||||
expectCoolioRegistryEntry();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue