From 16d7e9477bf2dee41fee7515c6998b489974336e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sun, 4 Oct 2026 09:26:02 +0100 Subject: [PATCH 01/13] fix(embeddings): reuse completed vectors after interrupted analyze (#3463) --- docker-server.test.mjs | 45 +- gitnexus/src/cli/embeddings-sync.ts | 18 +- .../staged-embedding-recovery-child.ts | 181 +++++ .../embeddings/staged-embedding-recovery.ts | 244 +++++++ gitnexus/src/core/run-analyze.ts | 162 ++++- gitnexus/src/server/api.ts | 13 +- gitnexus/src/storage/embedding-recovery.ts | 167 +++++ gitnexus/src/storage/index-lock.ts | 9 +- gitnexus/src/storage/repo-meta.ts | 14 + .../staged-embedding-recovery/seed.mjs | 52 ++ .../analyze-staged-embedding-recovery.test.ts | 449 ++++++++++++ .../staged-embedding-recovery.test.ts | 208 ++++++ gitnexus/test/unit/api-fts-mode.test.ts | 172 ++++- .../test/unit/embedding-recovery-race.test.ts | 199 ++++++ gitnexus/test/unit/embedding-recovery.test.ts | 175 +++++ .../test/unit/embeddings-sync-command.test.ts | 116 ++- gitnexus/test/unit/index-lock.test.ts | 104 +++ .../test/unit/run-analyze-fts-repair.test.ts | 659 +++++++++++++++++- .../staged-embedding-recovery-child.test.ts | 300 ++++++++ .../unit/staged-embedding-recovery.test.ts | 95 +++ 20 files changed, 3339 insertions(+), 43 deletions(-) create mode 100644 gitnexus/src/core/embeddings/staged-embedding-recovery-child.ts create mode 100644 gitnexus/src/core/embeddings/staged-embedding-recovery.ts create mode 100644 gitnexus/src/storage/embedding-recovery.ts create mode 100644 gitnexus/test/fixtures/staged-embedding-recovery/seed.mjs create mode 100644 gitnexus/test/integration/analyze-staged-embedding-recovery.test.ts create mode 100644 gitnexus/test/integration/staged-embedding-recovery.test.ts create mode 100644 gitnexus/test/unit/embedding-recovery-race.test.ts create mode 100644 gitnexus/test/unit/embedding-recovery.test.ts create mode 100644 gitnexus/test/unit/staged-embedding-recovery-child.test.ts create mode 100644 gitnexus/test/unit/staged-embedding-recovery.test.ts diff --git a/docker-server.test.mjs b/docker-server.test.mjs index 6d2a9f6c2..309edf3b5 100644 --- a/docker-server.test.mjs +++ b/docker-server.test.mjs @@ -331,8 +331,8 @@ const respondOk = (_req, res) => { // // upstream request handler, replaceable mid-test via `ctx.handler`; // null points the proxy at a port nothing ever listens on -// listenAfterMs bind the upstream this late, so the first attempt(s) hit -// ECONNREFUSED (a single-instance restart window) +// listenAfterMs bind the upstream this late after the first refused attempt +// (a single-instance restart window) // schemeless drop http:// from GITNEXUS_UPSTREAM_URL, the way Render's // `fromService: { property: hostport }` yields it // env extra environment for docker-server.mjs @@ -366,18 +366,15 @@ async function withProxy( }) : null; - // A late (or never) bind needs its port reserved up front; otherwise let the - // OS assign one at listen time. - const upstreamPort = - server && listenAfterMs === 0 - ? await new Promise((r) => server.listen(0, '127.0.0.1', () => r(server.address().port))) - : await getFreePort(); - const bindTimer = - server && listenAfterMs > 0 - ? setTimeout(() => server.listen(upstreamPort, '127.0.0.1'), listenAfterMs) - : null; - + // Keep the upstream port bound until the proxy port is chosen. Releasing it + // sooner lets the OS assign both services the same port and proxy to itself. + const reservation = server ?? createServer(); + const upstreamPort = await new Promise((r) => + reservation.listen(0, '127.0.0.1', () => r(reservation.address().port)), + ); const port = await getFreePort(); + let bindTimer = null; + const target = `127.0.0.1:${upstreamPort}`; const proc = spawnServerWithEnv(dir, port, { GITNEXUS_UPSTREAM_URL: schemeless ? target : `http://${target}`, @@ -390,18 +387,25 @@ async function withProxy( ...env, }); proc.stderr.setEncoding('utf8'); - proc.stderr.on('data', (chunk) => { + const collectStderr = (chunk) => { ctx.stderr += chunk; - }); + // Process startup must not consume the restart window or skip the retry. + if (server && listenAfterMs > 0 && !bindTimer && ctx.stderr.includes('ECONNREFUSED; retry')) { + bindTimer = setTimeout(() => server.listen(upstreamPort, '127.0.0.1'), listenAfterMs); + } + }; + proc.stderr.on('data', collectStderr); try { await waitForServer(port); + if (!server || listenAfterMs > 0) await new Promise((r) => reservation.close(r)); await fn(port, ctx); } finally { + proc.stderr.off('data', collectStderr); if (bindTimer) clearTimeout(bindTimer); await killAndWait(proc); - if (server?.listening) { - server.closeAllConnections?.(); - await new Promise((r) => server.close(r)); + if (reservation.listening) { + reservation.closeAllConnections?.(); + await new Promise((r) => reservation.close(r)); } await rm(dir, { recursive: true, force: true }); } @@ -661,8 +665,8 @@ it('returns 502 when the upstream is unreachable', async () => { // -- Connection-retry across an upstream restart window --------------------- // -// `listenAfterMs: 400` binds the upstream late, so the first attempt hits -// ECONNREFUSED and must be retried — a single-instance restart. The default 3 +// `listenAfterMs: 400` binds the upstream 400ms after the first ECONNREFUSED, +// so the request must be retried — a single-instance restart. The default 3 // attempts (backoff 250ms, 500ms) span ~750ms, so a retry lands after the bind. it('retries a connection-refused POST and succeeds once the upstream is up', async () => { @@ -676,6 +680,7 @@ it('retries a connection-refused POST and succeeds once the upstream is up', asy assert.match(res.body, /"ok":true/); assert.equal(ctx.calls, 1, 'upstream must run the job exactly once (no double-execute)'); assert.equal(ctx.body, '{"repo":"x"}', 'buffered body replayed intact'); + assert.match(ctx.stderr, /ECONNREFUSED; retry/, 'the restart gap must exercise a retry'); }); }); diff --git a/gitnexus/src/cli/embeddings-sync.ts b/gitnexus/src/cli/embeddings-sync.ts index 9848ef7c4..59cf9270a 100644 --- a/gitnexus/src/cli/embeddings-sync.ts +++ b/gitnexus/src/cli/embeddings-sync.ts @@ -4,7 +4,11 @@ import { LBUG_DIRECTORY } from '../storage/storage-constants.js'; import path from 'node:path'; import { cliInfo } from './cli-message.js'; import { getGitRoot } from '../storage/git.js'; -import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js'; +import { + acquireIndexLock, + requireExclusiveIndexLock, + sweepStagingArtifacts, +} from '../storage/index-lock.js'; import { getStoragePaths, loadMeta, saveMeta } from '../storage/repo-manager.js'; import { closeLbug, @@ -50,12 +54,22 @@ export const embeddingsSyncCommand = async (inputPath?: string): Promise = // Writes go to the slot's own graph. A shared-store checkout that reads an // immutable commit graph (#3352) takes a private copy first. const lbugPath = path.join(metaDir, LBUG_DIRECTORY); - const lock = await acquireIndexLock(metaDir); + const lock = await acquireIndexLock(metaDir, { sweep: false }); try { requireExclusiveIndexLock( lock, `Cannot acquire the index lock at ${metaDir}; refusing an unlocked embeddings sync.`, ); + // Sync writes the published graph and cannot recover a staged generation. + // Reject even malformed receipts before sweeping staging files or writing. + const recoveryCheckpoint = (await loadMeta(metaDir))?.embeddingCheckpoint; + if (recoveryCheckpoint && Object.hasOwn(recoveryCheckpoint, 'recovery')) { + throw new Error( + 'Cannot sync embeddings: the index checkpoint references staged embeddings. ' + + 'Run `gitnexus analyze` to recover them first.', + ); + } + sweepStagingArtifacts(metaDir); if (!(await ensurePrivateSharedGraph(metaDir, (m) => console.log(` ${m}`)))) { throw new Error('The shared graph this checkout reads is gone. Run gitnexus analyze first.'); } diff --git a/gitnexus/src/core/embeddings/staged-embedding-recovery-child.ts b/gitnexus/src/core/embeddings/staged-embedding-recovery-child.ts new file mode 100644 index 000000000..b1fb2cb73 --- /dev/null +++ b/gitnexus/src/core/embeddings/staged-embedding-recovery-child.ts @@ -0,0 +1,181 @@ +/** Isolated strict native reader. Never import this entrypoint into analyze. */ +import fs from 'node:fs'; +import path from 'node:path'; +import lbug from '@ladybugdb/core'; +import { createLbugDatabase, toNativeSafePath } from '../lbug/lbug-config.js'; +import { FAMILY_SUFFIXES } from '../../storage/embedding-recovery.js'; +import { + abortCachedEmbeddingsBuilder, + createCachedEmbeddingsBuilder, + finalizeCachedEmbeddingsSnapshot, + ingestCachedEmbeddingRow, +} from './embedding-restore-spill.js'; +import type { StagedEmbeddingExport } from './staged-embedding-recovery.js'; + +/** Native replay and close may checkpoint; only give them disposable copies. */ +function copyRecoveryFamily(dbPath: string, exportDir: string): string { + const replayDir = fs.mkdtempSync(path.join(exportDir, 'replay-')); + const replayPath = path.join(replayDir, path.basename(dbPath)); + const noFollow = fs.constants.O_NOFOLLOW ?? 0; + const flags = fs.constants.O_RDONLY | noFollow | (fs.constants.O_NONBLOCK ?? 0); + const buffer = Buffer.allocUnsafe(1024 * 1024); + for (const suffix of FAMILY_SUFFIXES) { + const sourcePath = dbPath + suffix; + let entry: fs.BigIntStats; + try { + entry = fs.lstatSync(sourcePath, { bigint: true }); + } catch (error) { + if (suffix && (error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + if (!entry.isFile()) throw new Error('staged embedding family is not a regular file'); + const source = fs.openSync(sourcePath, flags); + let destination: number | undefined; + try { + const opened = fs.fstatSync(source, { bigint: true }); + const current = fs.lstatSync(sourcePath, { bigint: true }); + if ( + !opened.isFile() || + !current.isFile() || + (noFollow === 0 && opened.ino === 0n) || + opened.dev !== entry.dev || + opened.ino !== entry.ino || + opened.dev !== current.dev || + opened.ino !== current.ino + ) { + throw new Error('staged embedding family changed while opening'); + } + destination = fs.openSync(replayPath + suffix, 'wx', 0o600); + let copied = 0n; + for (;;) { + const bytesRead = fs.readSync(source, buffer, 0, buffer.length, null); + if (bytesRead === 0) break; + fs.writeFileSync(destination, buffer.subarray(0, bytesRead)); + copied += BigInt(bytesRead); + } + const after = fs.fstatSync(source, { bigint: true }); + if ( + copied !== opened.size || + after.size !== opened.size || + after.mtimeNs !== opened.mtimeNs || + after.ctimeNs !== opened.ctimeNs + ) { + throw new Error('staged embedding family changed while copying'); + } + } finally { + try { + if (destination !== undefined) fs.closeSync(destination); + } finally { + fs.closeSync(source); + } + } + } + return replayPath; +} + +async function extract(): Promise { + const [dbPath, exportDir, dimensionsArg] = process.argv.slice(2); + const dimensions = Number(dimensionsArg); + if (!dbPath || !exportDir || !Number.isInteger(dimensions) || dimensions <= 0) { + throw new Error('invalid staged embedding extraction arguments'); + } + // The parent owns exportDir and reclaims it even after killing this child. + const replayPath = copyRecoveryFamily(dbPath, exportDir); + const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0, spillDir: exportDir }); + const rejectedNodeIds = new Set(); + let db: lbug.Database | undefined; + let conn: lbug.Connection | undefined; + try { + // Avoid openLbugConnection's test-fixture lock sweep: a recovery source must + // never have its WAL removed, even when an external slot resembles a fixture. + db = createLbugDatabase(lbug, toNativeSafePath(replayPath), { throwOnWalReplayFailure: true }); + conn = new lbug.Connection(db); + const queried = await conn.query( + 'MATCH (e:CodeEmbedding) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding, e.contentHash AS contentHash', + ); + const results = Array.isArray(queried) ? queried : [queried]; + try { + if (results.length !== 1) throw new Error('unexpected staged embedding query result'); + const result = results[0]; + while (await result.hasNext()) { + const raw = await result.getNext(); + const rec = raw as Record & unknown[]; + const nodeId = rec.nodeId ?? rec[0]; + if (typeof nodeId !== 'string' || !nodeId) + throw new Error('invalid staged embedding node id'); + const chunkIndex = rec.chunkIndex ?? rec[1]; + const startLine = rec.startLine ?? rec[2]; + const endLine = rec.endLine ?? rec[3]; + const embedding = rec.embedding ?? rec[4]; + const contentHash = rec.contentHash ?? rec[5]; + const vector = + Array.isArray(embedding) || + (ArrayBuffer.isView(embedding) && !(embedding instanceof DataView)) + ? Array.from(embedding as ArrayLike) + : undefined; + if ( + !Number.isInteger(chunkIndex) || + Number(chunkIndex) < 0 || + !Number.isInteger(startLine) || + Number(startLine) < 0 || + !Number.isInteger(endLine) || + Number(endLine) < Number(startLine) || + typeof contentHash !== 'string' || + !contentHash || + !vector || + vector.length !== dimensions || + vector.some( + (value) => + typeof value !== 'number' || + !Number.isFinite(value) || + !Number.isFinite(Math.fround(value)), + ) + ) { + rejectedNodeIds.add(nodeId); + continue; + } + ingestCachedEmbeddingRow( + builder, + { nodeId, chunkIndex, startLine, endLine, embedding: vector, contentHash }, + true, + ); + } + } finally { + for (const result of results) await result.close(); + } + // Both closes must succeed. Suppressed native teardown errors are unsafe. + await conn.close(); + await db.close(); + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + if (snapshot.spill) fs.renameSync(snapshot.spill.path, path.join(exportDir, 'vectors.bin')); + const manifest: StagedEmbeddingExport = { + version: 1, + dimensions, + rows: snapshot.rows, + rejectedNodeIds: [...rejectedNodeIds], + }; + fs.writeFileSync(path.join(exportDir, 'manifest.json'), JSON.stringify(manifest), { + flag: 'wx', + mode: 0o600, + }); + } catch (err) { + abortCachedEmbeddingsBuilder(builder); + // Cleanup is best effort on a rejected source, never used to approve output. + try { + await conn?.close(); + } catch { + /* rejected */ + } + try { + await db?.close(); + } catch { + /* rejected */ + } + throw err; + } +} + +extract().catch((err: unknown) => { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = 1; +}); diff --git a/gitnexus/src/core/embeddings/staged-embedding-recovery.ts b/gitnexus/src/core/embeddings/staged-embedding-recovery.ts new file mode 100644 index 000000000..b295f4beb --- /dev/null +++ b/gitnexus/src/core/embeddings/staged-embedding-recovery.ts @@ -0,0 +1,244 @@ +/** Recover paid embedding rows without opening an interrupted native DB in analyze. */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + abortCachedEmbeddingsBuilder, + createCachedEmbeddingsBuilder, + EmbeddingSpillReader, + emptyCachedEmbeddingsSnapshot, + finalizeCachedEmbeddingsSnapshot, + ingestCachedEmbeddingRow, + materializeCachedEmbeddings, + type CachedEmbeddingMeta, + type CachedEmbeddingsSnapshot, +} from './embedding-restore-spill.js'; + +export interface StagedEmbeddingRecoveryOptions { + dimensions: number; + /** Active checkpoint window and any incomplete inherited restore groups. */ + excludedNodeIds?: Iterable; + timeoutMs?: number; +} + +export interface StagedEmbeddingExport { + version: 1; + dimensions: number; + rows: CachedEmbeddingMeta[]; + rejectedNodeIds: string[]; +} + +const RESTORE_BATCH_SIZE = 200; +const DEFAULT_EXTRACTION_TIMEOUT_MS = 120_000; + +/** A contiguous prefix is safe only when its node is outside every unsafe window. */ +export function validateRecoveredNodeGroups( + rows: readonly CachedEmbeddingMeta[], + excludedNodeIds: ReadonlySet = new Set(), +): Set { + const groups = new Map; invalid: boolean }>(); + for (const row of rows) { + let group = groups.get(row.nodeId); + if (!group) { + group = { hash: row.contentHash, ordinals: new Set(), invalid: false }; + groups.set(row.nodeId, group); + } + if ( + typeof row.nodeId !== 'string' || + !row.nodeId || + typeof row.contentHash !== 'string' || + !row.contentHash || + row.contentHash !== group.hash || + !Number.isInteger(row.chunkIndex) || + row.chunkIndex < 0 || + group.ordinals.has(row.chunkIndex) || + !Number.isInteger(row.startLine) || + row.startLine < 0 || + !Number.isInteger(row.endLine) || + row.endLine < row.startLine + ) + group.invalid = true; + group.ordinals.add(row.chunkIndex); + } + const accepted = new Set(); + for (const [nodeId, group] of groups) { + if (group.invalid || excludedNodeIds.has(nodeId)) continue; + // Unique ordinals with max n-1 and zero present have no holes. + if (!group.ordinals.has(0)) continue; + if ([...group.ordinals].some((ordinal) => ordinal >= group.ordinals.size)) continue; + accepted.add(nodeId); + } + return accepted; +} + +/** Whole recovered nodes replace whole published groups; vectors stay in bounded batches. */ +export function mergeRecoveredEmbeddings( + live: CachedEmbeddingsSnapshot, + recovered: CachedEmbeddingsSnapshot, +): CachedEmbeddingsSnapshot { + const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0 }); + try { + appendSnapshotRows( + live, + live.rows.filter((row) => !recovered.embeddingNodeIds.has(row.nodeId)), + builder, + ); + appendSnapshotRows(recovered, recovered.rows, builder); + return finalizeCachedEmbeddingsSnapshot(builder); + } catch (err) { + abortCachedEmbeddingsBuilder(builder); + throw err; + } +} + +function appendSnapshotRows( + snapshot: CachedEmbeddingsSnapshot, + rows: readonly CachedEmbeddingMeta[], + builder: ReturnType, +): void { + const reader = snapshot.spill ? new EmbeddingSpillReader(snapshot.spill) : undefined; + try { + for (let i = 0; i < rows.length; i += RESTORE_BATCH_SIZE) { + const batch = materializeCachedEmbeddings( + snapshot, + rows.slice(i, i + RESTORE_BATCH_SIZE), + reader, + ); + for (const row of batch) + ingestCachedEmbeddingRow(builder, row as unknown as Record, true); + } + } finally { + reader?.close(); + } +} + +/** + * Caller must validate checkpoint identity, schema, exact generation, and hold the + * index lock. A subprocess contains native WAL replay/query/destructor failures. + * A failed strict open is never retried with WAL removed or validation disabled. + */ +export async function recoverStagedEmbeddings( + dbPath: string, + options: StagedEmbeddingRecoveryOptions, +): Promise { + if (!Number.isInteger(options.dimensions) || options.dimensions <= 0) { + throw new Error('invalid staged embedding dimensions'); + } + const dbStat = fs.lstatSync(dbPath); + if (!dbStat.isFile() || dbStat.isSymbolicLink()) + throw new Error('staged embedding DB is not a regular file'); + const exportDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-stage-export-')); + try { + await runExtractionChild(dbPath, exportDir, options); + const manifestPath = path.join(exportDir, 'manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as StagedEmbeddingExport; + if ( + manifest.version !== 1 || + manifest.dimensions !== options.dimensions || + !Array.isArray(manifest.rows) || + !Array.isArray(manifest.rejectedNodeIds) || + manifest.rejectedNodeIds.some((id) => typeof id !== 'string') || + manifest.rows.some((row, i) => !row || row.vectorIndex !== i) + ) + throw new Error('invalid staged embedding export manifest'); + if (manifest.rows.length === 0) return emptyCachedEmbeddingsSnapshot(); + const spill = { + path: path.join(exportDir, 'vectors.bin'), + dims: options.dimensions, + rowCount: manifest.rows.length, + }; + const vectorStat = fs.lstatSync(spill.path); + if ( + !vectorStat.isFile() || + vectorStat.isSymbolicLink() || + vectorStat.size !== 12 + spill.rowCount * spill.dims * 4 + ) { + throw new Error('invalid staged embedding export size'); + } + const excluded = new Set(options.excludedNodeIds ?? []); + for (const nodeId of manifest.rejectedNodeIds) excluded.add(nodeId); + const accepted = validateRecoveredNodeGroups(manifest.rows, excluded); + const exported: CachedEmbeddingsSnapshot = { + rows: manifest.rows, + embeddings: [], + embeddingNodeIds: accepted, + spill, + }; + const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0 }); + const reader = new EmbeddingSpillReader(spill); + try { + const rows = manifest.rows.filter((row) => accepted.has(row.nodeId)); + for (let i = 0; i < rows.length; i += RESTORE_BATCH_SIZE) { + for (const row of materializeCachedEmbeddings( + exported, + rows.slice(i, i + RESTORE_BATCH_SIZE), + reader, + )) { + if ( + row.embedding.length !== options.dimensions || + row.embedding.some((value) => !Number.isFinite(value)) + ) { + throw new Error('invalid staged embedding vector'); + } + ingestCachedEmbeddingRow(builder, row as unknown as Record, true); + } + } + return finalizeCachedEmbeddingsSnapshot(builder); + } catch (err) { + abortCachedEmbeddingsBuilder(builder); + throw err; + } finally { + reader.close(); + } + } finally { + fs.rmSync(exportDir, { recursive: true, force: true }); + } +} + +function runExtractionChild( + dbPath: string, + exportDir: string, + options: StagedEmbeddingRecoveryOptions, +): Promise { + const compiledPath = fileURLToPath( + new URL('./staged-embedding-recovery-child.js', import.meta.url), + ); + const sourcePath = compiledPath.replace(/\.js$/, '.ts'); + const childPath = fs.existsSync(compiledPath) ? compiledPath : sourcePath; + const args = childPath.endsWith('.ts') + ? ['--import', import.meta.resolve('tsx'), childPath] + : [childPath]; + args.push(dbPath, exportDir, String(options.dimensions)); + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true, + }); + let stderr = ''; + let timedOut = false; + child.stderr.on('data', (chunk: Buffer) => { + if (stderr.length < 4096) stderr += chunk.toString().slice(0, 4096 - stderr.length); + }); + const timer = setTimeout(() => { + timedOut = true; + // A process boundary is safe to kill even while native code is executing. + child.kill('SIGKILL'); + }, options.timeoutMs ?? DEFAULT_EXTRACTION_TIMEOUT_MS); + child.once('error', (err) => { + clearTimeout(timer); + reject(err); + }); + child.once('close', (code, signal) => { + clearTimeout(timer); + if (code === 0 && !signal && !timedOut) resolve(); + else + reject( + new Error( + `staged embedding extraction failed${timedOut ? ' (timeout)' : ` (${signal ?? code})`}${stderr ? `: ${stderr.trim()}` : ''}`, + ), + ); + }); + }); +} diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index af51bb6ea..5f43df3e2 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -36,7 +36,12 @@ import fs from 'fs/promises'; import { constants as fsConstants, existsSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { retryRename } from '../storage/fs-atomic.js'; -import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js'; +import { + acquireIndexLock, + requireExclusiveIndexLock, + sweepStagingArtifacts, +} from '../storage/index-lock.js'; +import { resolveEmbeddingRecovery } from '../storage/embedding-recovery.js'; import { invalidateNodeWorkspacePackages } from './ingestion/import-resolvers/node-workspace-packages.js'; import { logNameFallbackSummary, @@ -1287,6 +1292,8 @@ export async function runFullAnalysis( const log = (msg: string) => callbacks.onLog?.(stripControlCharacters(msg)); const acquireOpts = { log, + // Resolve and validate the canonical slot under the lock before cleanup. + sweep: false, onWaitStart: () => callbacks.onProgress('lock', 0, 'Waiting for another analyze to finish on this index…'), }; @@ -1345,6 +1352,7 @@ export async function runFullAnalysis( } const flatShared = writeTarget.placement.branch ? undefined : writeTarget.sharedStore; if (flatShared) await seedSharedSlot(flatShared, repoPath, log); + sweepStagingArtifacts(writeTarget.metaDir, log); const slotToLeave = options.noShare ? await optedInSlotToLeave(repoPath) : undefined; const result = await runFullAnalysisInner( repoPath, @@ -1842,7 +1850,13 @@ async function runFullAnalysisInner( decision = decideEmbeddingResume(checkpoint, embeddingIdentityForRun, resumeOptions); } if (decision.action === 'abort') throw new Error(decision.error); - log(decision.log); + log( + decision.action === 'resume' && checkpoint.recovery + ? `Previous analyze recorded an embedding checkpoint (${checkpoint.nodesProcessed}/` + + `${checkpoint.totalNodes} nodes); validating staged vectors before retrying ` + + `${decision.pendingNodeIds.size} pending node(s).` + : decision.log, + ); if (options.dropEmbeddings) { // --drop-embeddings has always implied a rebuild here; the decision only // covers the marker. @@ -2671,6 +2685,74 @@ async function runFullAnalysisInner( } } + // A checkpoint's pending decision and its paid, complete vectors are + // independent: --force discards the former but can still reuse the latter. + // Select only the explicitly referenced generation, never an orphan by age. + const stagedRecovery = resolveEmbeddingRecovery(metaDir, existingMeta?.embeddingCheckpoint); + const stagedCheckpoint = existingMeta?.embeddingCheckpoint; + const inheritedUnsafeNodeIds = new Set([ + ...pendingEmbeddingNodeIds, + ...(stagedRecovery?.unsafeNodeIds ?? []), + ]); + if (shouldLoadCache && !options.dropEmbeddings && stagedRecovery && stagedCheckpoint) { + if (!embeddingIdentityForRun) { + const { resolveEmbeddingIdentity } = await import('./embeddings/embedding-identity.js'); + embeddingIdentityForRun = resolveEmbeddingIdentity(); + } + const marker = stagedCheckpoint; + const matchesIdentity = + marker.model === embeddingIdentityForRun.model && + marker.dimensions === embeddingIdentityForRun.dimensions && + marker.provider === embeddingIdentityForRun.provider; + if (matchesIdentity && stagedRecovery.schemaFingerprint === SCHEMA_FINGERPRINT) { + // A force-discarded pending decision must not turn a known incomplete + // inherited group into a reusable cache merely because its hash matches. + if (inheritedUnsafeNodeIds.size > 0) { + const rows = cachedSnapshot.rows.filter((row) => !inheritedUnsafeNodeIds.has(row.nodeId)); + cachedSnapshot = { + ...cachedSnapshot, + rows, + embeddingNodeIds: new Set(rows.map((row) => row.nodeId)), + }; + } + let recovered: CachedEmbeddingsSnapshot | undefined; + try { + const { recoverStagedEmbeddings, mergeRecoveredEmbeddings } = + await import('./embeddings/staged-embedding-recovery.js'); + recovered = await recoverStagedEmbeddings(stagedRecovery.dbPath, { + dimensions: embeddingIdentityForRun.dimensions, + excludedNodeIds: stagedRecovery.unsafeNodeIds, + }); + const liveCacheDims = snapshotEmbeddingDims(cachedSnapshot); + if (liveCacheDims !== undefined && liveCacheDims !== embeddingIdentityForRun.dimensions) { + log( + `Embedding dimensions changed (${liveCacheDims}d -> ` + + `${embeddingIdentityForRun.dimensions}d), discarding published cache`, + ); + discardCachedEmbeddings(); + } + if (recovered.rows.length > 0) { + const merged = mergeRecoveredEmbeddings(cachedSnapshot, recovered); + disposeEmbeddingSpill(cachedSnapshot.spill); + adoptCachedEmbeddings(merged); + } + log( + `Recovered ${recovered.rows.length} complete staged embedding chunk(s) ` + + `for ${recovered.embeddingNodeIds.size} node(s); unchanged content can reuse them.`, + ); + } catch (err) { + log( + `Warning: could not recover staged embeddings (${(err as Error).message}); ` + + 'the retry will regenerate missing chunks.', + ); + } finally { + disposeEmbeddingSpill(recovered?.spill); + } + } else { + log('Staged embedding identity or schema changed; its vectors will not be reused.'); + } + } + // ── Load incremental parse cache ────────────────────────────────── // Content-addressed: `--force` reuses parser shards; `useParseCache: false` // stages a new generation under a run-unique parse-rebuild.* dir and publishes @@ -4487,6 +4569,16 @@ async function runFullAnalysisInner( embeddingIdentityForRun = resolveEmbeddingIdentity(); } const embeddingIdentity = embeddingIdentityForRun; + const stagedRecoveryEnabled = useAtomicSwap && isManualCheckpointEnabled(); + if (useAtomicSwap && !stagedRecoveryEnabled) { + log( + 'Manual WAL checkpoints are disabled; new staged work cannot be recovered after interruption. ' + + 'Any previous durable recovery source is retained until publication.', + ); + } + const unsafeRecoveryNodeIds = new Set([...inheritedUnsafeNodeIds, ...restoreFailedNodeIds]); + let activeWindowNodeIds: string[] = []; + let recoveryGenerationDurable = false; // Build a Map from cached embeddings for incremental mode let existingEmbeddings: Map | undefined; if (cachedSnapshot.embeddingNodeIds.size > 0) { @@ -4540,6 +4632,14 @@ async function runFullAnalysisInner( stagedCheckpointEmbeddingCount = embeddings; } const latestMeta = (await loadMeta(metaDir)) ?? existingMeta; + // An in-place write or manual-checkpoint opt-out cannot create a new + // recoverable staged generation. Keep the complete previous receipt: + // updated progress or unsafe nodes would describe different source bytes. + const preservedRecoveryCheckpoint = + !stagedRecoveryEnabled && + resolveEmbeddingRecovery(metaDir, latestMeta?.embeddingCheckpoint) + ? latestMeta?.embeddingCheckpoint + : undefined; // First-ever analyze of this repo: no meta exists on disk yet (the // pre-wipe dirty stamp only fires when one does). Mint the minimum // RepoMeta requires, with `lastCommit: ''` — never `currentCommit` — @@ -4550,6 +4650,11 @@ async function runFullAnalysisInner( lastCommit: '', indexedAt: new Date().toISOString(), }; + const interrupted = mintInterruptedCheckpoint( + embeddingIdentity, + checkpoint, + pendingNodeIds, + ); await saveMeta(metaDir, { ...base, ...(embeddings === undefined || buildPath !== lbugPath @@ -4557,11 +4662,18 @@ async function runFullAnalysisInner( : { stats: { ...base.stats, embeddings } }), // Written by a run that is still IN FLIGHT — see the `kind` doc in // repo-manager.ts. - embeddingCheckpoint: mintInterruptedCheckpoint( - embeddingIdentity, - checkpoint, - pendingNodeIds, - ), + embeddingCheckpoint: preservedRecoveryCheckpoint ?? { + ...interrupted, + ...(stagedRecoveryEnabled + ? { + recovery: { + stagingFile: path.basename(buildPath), + schemaFingerprint: SCHEMA_FINGERPRINT, + unsafeNodeIds: [...unsafeRecoveryNodeIds], + }, + } + : {}), + }, }); }; @@ -4584,7 +4696,21 @@ async function runFullAnalysisInner( { forceReembedNodeIds: pendingEmbeddingNodeIds, onCheckpointWindowStart: async ({ nodeIds, ...checkpoint }) => { + const handoff = stagedRecoveryEnabled && !recoveryGenerationDurable; + if (handoff) { + if (!(await checkpointOnce())) { + throw new Error( + 'Could not checkpoint restored embeddings before recovery handoff.', + ); + } + recoveryGenerationDurable = true; + } + activeWindowNodeIds = nodeIds; + for (const id of nodeIds) unsafeRecoveryNodeIds.add(id); await saveEmbeddingCheckpoint(checkpoint, nodeIds); + // Reclaim the old source only after the new durable generation's + // reference is saved. Later windows retain this same generation. + if (handoff) sweepStagingArtifacts(metaDir, log); }, // ── The mid-run count is a DIAGNOSTIC, not a gate (#2790) ────── // This used to run the count query bare. THIS callback's rejection @@ -4598,7 +4724,12 @@ async function runFullAnalysisInner( // touch stats.embeddings" signal — so the checkpoint still lands, // with whatever count is already on disk left alone. onCheckpoint: async (checkpoint) => { - await checkpointOnce(); + const durable = await checkpointOnce(); + if (stagedRecoveryEnabled && !durable) { + throw new Error('Could not checkpoint the completed embedding window for recovery.'); + } + for (const id of activeWindowNodeIds) unsafeRecoveryNodeIds.delete(id); + activeWindowNodeIds = []; const measured = await measurePersistedEmbeddingCount(executeQuery); if (measured.kind === 'unknown') { log( @@ -5085,6 +5216,7 @@ async function runFullAnalysisInner( // is a crash-safety improvement: a failed swap leaves the previous index // live and the next run recovers via the full-rebuild path. await saveMeta(metaDir, meta); + sweepStagingArtifacts(metaDir, log); // Registry freshness is published only after the graph and its metadata. // A failed close, swap, or metadata save must leave the previous registry @@ -5294,7 +5426,13 @@ async function runFullAnalysisInner( // rethrow below is the surface, and the lock's sweep remains the backstop. if (useAtomicSwap && buildPath !== lbugPath) { try { - await wipeLbugDbFiles(buildPath); + const recovery = resolveEmbeddingRecovery( + metaDir, + (await loadMeta(metaDir))?.embeddingCheckpoint, + ); + // Both paths belong to this locked slot. The validated generation + // basename identifies the same file even through a directory alias. + if (recovery?.stagingFile !== path.basename(buildPath)) await wipeLbugDbFiles(buildPath); } catch { /* swallow — orphan reclamation must never mask the real failure */ } @@ -5306,6 +5444,12 @@ async function runFullAnalysisInner( // IndexLockTimeoutError and other domain failures with `instanceof`. recordLiveIndexMutationRisk(err); } + if (/max(?:imum)?(?: database| db)? size|database size limit|maxDBSize/i.test(String(err))) { + log( + 'The database size limit was reached. Set GITNEXUS_LBUG_MAX_DB_SIZE to a larger ' + + 'byte limit before retrying analyze; retained complete embeddings can be reused.', + ); + } throw err; } } diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 86cafb0c1..f4cb39c66 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -12,6 +12,7 @@ import { acquireIndexLock, IndexLockTimeoutError, requireExclusiveIndexLock, + sweepStagingArtifacts, type IndexLockHandle, } from '../storage/index-lock.js'; import { ensurePrivateSharedGraph } from '../core/shared-store-analyze.js'; @@ -2152,11 +2153,21 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // for the whole embedding write, released in the finally below. let slotLock: IndexLockHandle | undefined; try { - slotLock = await acquireIndexLock(storagePath); + slotLock = await acquireIndexLock(storagePath, { sweep: false }); requireExclusiveIndexLock( slotLock, `Cannot acquire the index lock at ${storagePath}; refusing an unlocked embedding run.`, ); + // This writer cannot recover staged generations. Preserve their + // receipts, including malformed ones, before sweeping or writing. + const recoveryCheckpoint = (await loadMeta(storagePath))?.embeddingCheckpoint; + if (recoveryCheckpoint && Object.hasOwn(recoveryCheckpoint, 'recovery')) { + throw new Error( + 'Cannot generate embeddings: the index checkpoint references staged embeddings. ' + + 'Run `gitnexus analyze` to recover them first.', + ); + } + sweepStagingArtifacts(storagePath); // Writes go to the slot's own graph; a shared-store checkout // reading an immutable commit graph (#3352) takes a private copy. if (!(await ensurePrivateSharedGraph(storagePath, () => {}))) { diff --git a/gitnexus/src/storage/embedding-recovery.ts b/gitnexus/src/storage/embedding-recovery.ts new file mode 100644 index 000000000..54821bcc7 --- /dev/null +++ b/gitnexus/src/storage/embedding-recovery.ts @@ -0,0 +1,167 @@ +/** + * Filesystem-only staged embedding provenance. Keep this independent of native + * and model imports: every index-lock caller needs the retention decision. + */ +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readFileSync, + realpathSync, +} from 'node:fs'; +import path from 'node:path'; +import type { EmbeddingRecoveryReference } from './repo-meta.js'; +import { INDEX_METADATA_FILE, LEGACY_METADATA_FILE } from './storage-constants.js'; + +const STAGING_FILENAME = + /^lbug\.staging\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +export const FAMILY_SUFFIXES = [ + '', + '.wal', + '.shadow', + '.wal.checkpoint', + '.lock', + '.checkpoint.intent.lock', + '.checkpoint.apply.lock', +] as const; + +export interface ResolvedEmbeddingRecovery extends EmbeddingRecoveryReference { + dbPath: string; + /** Exact basenames, never a prefix match that can preserve another generation. */ + familyFiles: string[]; +} + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); +const isNonemptyString = (value: unknown): value is string => + typeof value === 'string' && value.length > 0; +const isNodeIds = (value: unknown): value is string[] => + Array.isArray(value) && value.every(isNonemptyString); +const isCount = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; + +/** + * Resolve only an explicit interrupted-generation receipt in this canonical + * slot. This validates provenance, not native contents or current identity; + * those checks must pass separately before any rows can be reused. + */ +export const resolveEmbeddingRecovery = ( + lockDir: string, + checkpoint: unknown, +): ResolvedEmbeddingRecovery | undefined => { + if (!isRecord(checkpoint) || !isRecord(checkpoint.recovery)) return undefined; + if (checkpoint.kind !== undefined && checkpoint.kind !== 'interrupted') return undefined; + if ( + !isNonemptyString(checkpoint.at) || + !Number.isFinite(Date.parse(checkpoint.at)) || + !isCount(checkpoint.nodesProcessed) || + !isCount(checkpoint.totalNodes) || + checkpoint.nodesProcessed > checkpoint.totalNodes || + !isCount(checkpoint.chunksProcessed) || + !isNonemptyString(checkpoint.model) || + !isCount(checkpoint.dimensions) || + checkpoint.dimensions === 0 || + !isNonemptyString(checkpoint.provider) || + (checkpoint.pendingNodeIds !== undefined && !isNodeIds(checkpoint.pendingNodeIds)) + ) { + return undefined; + } + const recovery = checkpoint.recovery; + if ( + !isNonemptyString(recovery.stagingFile) || + !STAGING_FILENAME.test(recovery.stagingFile) || + !isNonemptyString(recovery.schemaFingerprint) || + !isNodeIds(recovery.unsafeNodeIds) + ) { + return undefined; + } + const unsafeNodeIds = new Set(recovery.unsafeNodeIds); + if ( + isNodeIds(checkpoint.pendingNodeIds) && + checkpoint.pendingNodeIds.some((nodeId) => !unsafeNodeIds.has(nodeId)) + ) { + return undefined; + } + + try { + const canonicalDir = realpathSync(lockDir); + if (!lstatSync(canonicalDir).isDirectory()) return undefined; + const familyFiles = FAMILY_SUFFIXES.map((suffix) => recovery.stagingFile + suffix); + for (const [index, filename] of familyFiles.entries()) { + try { + // lstat refuses both live and dangling symlinks, without following one + // to a database outside the slot. The base file must exist. + if (!lstatSync(path.join(canonicalDir, filename)).isFile()) return undefined; + } catch (error) { + if (index > 0 && (error as NodeJS.ErrnoException).code === 'ENOENT') continue; + return undefined; + } + } + return { + dbPath: path.join(canonicalDir, recovery.stagingFile), + stagingFile: recovery.stagingFile, + schemaFingerprint: recovery.schemaFingerprint, + unsafeNodeIds: [...unsafeNodeIds], + familyFiles, + }; + } catch { + return undefined; + } +}; + +/** Synchronous mirror of loadMeta's primary-first, absent-only fallback rule. */ +export const readEmbeddingRecovery = (lockDir: string): ResolvedEmbeddingRecovery | undefined => { + let metadataPath = path.join(lockDir, INDEX_METADATA_FILE); + let descriptor: number | undefined; + const noFollow = constants.O_NOFOLLOW ?? 0; + const flags = constants.O_RDONLY | noFollow | (constants.O_NONBLOCK ?? 0); + try { + try { + descriptor = openSync(metadataPath, flags); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') return undefined; + // Windows cannot open with O_NOFOLLOW: an open of a dangling symlink + // reports ENOENT, but that existing primary entry must prevent fallback. + try { + lstatSync(metadataPath); + return undefined; + } catch (statError) { + const statCode = (statError as NodeJS.ErrnoException).code; + if (statCode !== 'ENOENT' && statCode !== 'ENOTDIR') return undefined; + } + metadataPath = path.join(lockDir, LEGACY_METADATA_FILE); + descriptor = openSync(metadataPath, flags); + } + const opened = fstatSync(descriptor, { bigint: true }); + const entry = lstatSync(metadataPath, { bigint: true }); + // Check the opened file itself and match the current non-symlink entry. + // This also refuses replacement on platforms without O_NOFOLLOW. + if ( + !opened.isFile() || + !entry.isFile() || + (noFollow === 0 && opened.ino === 0n) || + opened.dev !== entry.dev || + opened.ino !== entry.ino + ) { + return undefined; + } + const meta: unknown = JSON.parse(readFileSync(descriptor, 'utf8')); + if (!isRecord(meta)) return undefined; + // storagePath describes the flat/cache root, including in branch-slot + // metadata. The current locked directory is the generation boundary. + return resolveEmbeddingRecovery(lockDir, meta.embeddingCheckpoint); + } catch { + return undefined; + } finally { + if (descriptor !== undefined) { + try { + closeSync(descriptor); + } catch { + /* best-effort */ + } + } + } +}; diff --git a/gitnexus/src/storage/index-lock.ts b/gitnexus/src/storage/index-lock.ts index 5588127e6..5d3b6cc47 100644 --- a/gitnexus/src/storage/index-lock.ts +++ b/gitnexus/src/storage/index-lock.ts @@ -62,6 +62,7 @@ import path from 'node:path'; import os from 'node:os'; import { randomBytes, randomUUID, createHash } from 'node:crypto'; import { isProcessAlive } from '../utils/process-identity.js'; +import { readEmbeddingRecovery } from './embedding-recovery.js'; const LOCK_FILENAME = 'analyze.lock'; const LOCK_RECORD_VERSION = 1 as const; @@ -433,8 +434,9 @@ const deniedCreateHandle = ( /** * Delete orphaned build/staging artifacts left in the lock directory by a * crashed prior writer. Safe precisely because we hold the exclusive lock: no - * other writer can be creating these here right now, so anything present is a - * crash orphan. Matches this slot's staging files ONLY — never `lbug` itself, + * other writer can be creating these here right now. A checkpoint-referenced + * generation is retained for embedding recovery; all other stages are orphans. + * Matches this slot's staging files ONLY — never `lbug` itself, * never `lbug.wal`/`lbug.shadow` (the LIVE index's own sidecars), and never a * `branches//` sub-slot (which owns its own lock + sweep). Non-recursive. */ @@ -442,6 +444,7 @@ export const sweepStagingArtifacts = (lockDir: string, log?: (msg: string) => vo // Matches `lbug.new`, `lbug.new.wal`, `lbug.staging.`, `lbug.staging..wal`, … // Does NOT match `lbug`, `lbug.wal`, `lbug.shadow`. const stagingRe = /^lbug\.(staging\..+|new(\..+)?)$/; + const retained = new Set(readEmbeddingRecovery(lockDir)?.familyFiles ?? []); let removed = 0; let entries: string[]; try { @@ -450,7 +453,7 @@ export const sweepStagingArtifacts = (lockDir: string, log?: (msg: string) => vo return; } for (const name of entries) { - if (!stagingRe.test(name)) continue; + if (!stagingRe.test(name) || retained.has(name)) continue; try { unlinkSync(path.join(lockDir, name)); removed++; diff --git a/gitnexus/src/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts index 0c412dd7c..1562ee8c7 100644 --- a/gitnexus/src/storage/repo-meta.ts +++ b/gitnexus/src/storage/repo-meta.ts @@ -43,6 +43,15 @@ export type ContentRetention = 'full' | 'symbol' | 'none'; export type FtsProfile = 'full' | 'symbol-no-file-content' | 'name-only'; export const CONTENT_RETENTION_SCHEMA_VERSION = 1; +/** Exact staged generation whose completed embedding groups can survive a retry. */ +export interface EmbeddingRecoveryReference { + /** A run-minted basename within this metadata file's index slot. */ + stagingFile: string; + schemaFingerprint: string; + /** Active-window and inherited incomplete groups: never reusable until completed. */ + unsafeNodeIds: string[]; +} + /** * Versioned receipt for the analyzer process that produced an index. * @@ -521,6 +530,11 @@ export interface RepoMeta { * subset of their chunks; for `'partial'` they hold none. */ pendingNodeIds?: string[]; + /** + * Interrupted atomic builds only. This does not publish the staged graph + * or advance live embedding statistics; it identifies a recovery source. + */ + recovery?: EmbeddingRecoveryReference; }; /** * Name of the git branch this index represents (#2106). Absent for the diff --git a/gitnexus/test/fixtures/staged-embedding-recovery/seed.mjs b/gitnexus/test/fixtures/staged-embedding-recovery/seed.mjs new file mode 100644 index 000000000..68401a4b8 --- /dev/null +++ b/gitnexus/test/fixtures/staged-embedding-recovery/seed.mjs @@ -0,0 +1,52 @@ +import lbug from '@ladybugdb/core'; +import fs from 'node:fs'; +import { createLbugDatabase } from '../../../src/core/lbug/lbug-config.ts'; + +const [dbPath, mode] = process.argv.slice(2); +const db = createLbugDatabase(lbug, dbPath); +const conn = new lbug.Connection(db); +async function query(cypher) { + const queried = await conn.query(cypher); + for (const result of Array.isArray(queried) ? queried : [queried]) { + await result.getAll(); + await result.close(); + } +} +await query('CREATE NODE TABLE CodeEmbedding (id STRING, nodeId STRING, chunkIndex INT32, startLine INT64, endLine INT64, embedding FLOAT[2], contentHash STRING, PRIMARY KEY(id))'); +async function row(id, nodeId, chunkIndex, hash = 'same', startLine = 1, endLine = 3) { + await query(`CREATE (:CodeEmbedding {id: '${id}', nodeId: '${nodeId}', chunkIndex: ${chunkIndex}, startLine: ${startLine}, endLine: ${endLine}, embedding: [1.0, 2.0], contentHash: ${hash === null ? 'NULL' : `'${hash}'`}})`); +} +await row('complete-0', 'complete', 0); +await row('complete-1', 'complete', 1); +await row('other', 'other', 0); +await query('CHECKPOINT'); +if (mode === 'interrupted-checkpoint') { + await row('checkpoint-only', 'checkpoint-only', 0); + const main = fs.readFileSync(dbPath); + const wal = fs.readFileSync(`${dbPath}.wal`); + await conn.close(); + await db.close(); + // Restore the pre-close bytes: writable close has already checkpointed them. + fs.writeFileSync(dbPath, main); + fs.writeFileSync(`${dbPath}.wal.checkpoint`, wal); + fs.writeFileSync(`${dbPath}.wal`, ''); + fs.writeFileSync(`${dbPath}.shadow`, ''); + fs.writeFileSync(`${dbPath}.checkpoint.intent.lock`, ''); + fs.writeFileSync(`${dbPath}.checkpoint.apply.lock`, ''); +} else if (mode === 'hard-kill') { + await row('unsafe', 'unsafe-prefix', 0); + process.kill(process.pid, 'SIGKILL'); +} else { + await row('gap', 'gap', 1); + await row('duplicate-0', 'duplicate', 0); + await row('duplicate-1', 'duplicate', 0); + await row('mixed-0', 'mixed', 0, 'old'); + await row('mixed-1', 'mixed', 1, 'new'); + await row('missing-hash-0', 'missing-hash', 0); + await row('missing-hash-1', 'missing-hash', 1, null); + await row('bad-line', 'bad-line', 0, 'same', -1, 3); + await row('nan-0', 'nan', 0); + await query("CREATE (:CodeEmbedding {id: 'nan-1', nodeId: 'nan', chunkIndex: 1, startLine: 1, endLine: 3, embedding: [CAST('NaN', 'FLOAT'), 2.0], contentHash: 'same'})"); + await conn.close(); + await db.close(); +} diff --git a/gitnexus/test/integration/analyze-staged-embedding-recovery.test.ts b/gitnexus/test/integration/analyze-staged-embedding-recovery.test.ts new file mode 100644 index 000000000..dbb5b893e --- /dev/null +++ b/gitnexus/test/integration/analyze-staged-embedding-recovery.test.ts @@ -0,0 +1,449 @@ +/** + * Exercise the CLI -> native staged DB -> durable checkpoint -> SIGKILL -> + * isolated recovery -> graph rebuild -> publication chain. The endpoint is + * local and deterministic; request text is the billing/reuse oracle. + */ +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { CLI_SPAWN_PREFIX, tsxLoaderUrl } from '../helpers/cli-entry.js'; + +const DIMS = 8; +const NODE_COUNT = 5_128; +const DEADLINE = process.env.CI ? 180_000 : 120_000; +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const adapterUrl = pathToFileURL(path.join(packageRoot, 'src/core/lbug/lbug-adapter.ts')).href; + +interface CheckpointMeta { + repoPath: string; + stats?: { embeddings?: number }; + embeddingCheckpoint?: { + nodesProcessed: number; + chunksProcessed: number; + pendingNodeIds?: string[]; + recovery?: { stagingFile: string; schemaFingerprint: string; unsafeNodeIds: string[] }; + }; +} + +interface Run { + child: ChildProcess; + output: () => string; + done: Promise<{ code: number | null; signal: NodeJS.Signals | null; output: string }>; +} + +let root: string; +let stoppedRepo: string; +let server: http.Server; +let endpoint: string; +let submitted: string[] = []; +let completed: string[] = []; +let durableTexts: string[] = []; +const activeWindowCompleted: string[] = []; +let held: string[] = []; +let stopAfterCheckpoint = false; +let currentRepo: string; +let gateResolve: (() => void) | undefined; +const running = new Set(); + +function readMeta(repo: string): CheckpointMeta { + return JSON.parse(fs.readFileSync(path.join(repo, '.gitnexus', 'gitnexus.json'), 'utf8')); +} + +function cliEnv(repo: string): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.startsWith('GITNEXUS_EMBEDDING_') || key.startsWith('GITNEXUS_STORAGE_')) + delete env[key]; + } + return { + ...env, + GITNEXUS_HOME: path.join(root, `home-${path.basename(repo)}`), + GITNEXUS_SHARED_STORE: 'off', + GITNEXUS_LBUG_EXTENSION_INSTALL: 'never', + GITNEXUS_LBUG_BUFFER_POOL_SIZE: String(256 * 1024 * 1024), + GITNEXUS_EMBEDDING_URL: endpoint, + GITNEXUS_EMBEDDING_MODEL: 'staged-recovery-fixture', + GITNEXUS_EMBEDDING_DIMS: String(DIMS), + GITNEXUS_EMBEDDING_BATCH_SIZE: '5000', + GITNEXUS_EMBEDDING_SUB_BATCH_SIZE: '64', + GITNEXUS_EMBEDDING_MAX_ATTEMPTS: '1', + GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT: '0', + GITNEXUS_MEMORY: 'off', + // SIGKILL skips process exit hooks; keep orphaned cache/export spills in + // this suite's owned directory so afterAll can remove them as well. + TMPDIR: path.join(root, 'tmp'), + NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=2048`.trim(), + CI: '1', + }; +} + +function runAnalyze(repo: string, flags: string[] = [], onOutput?: (output: string) => void): Run { + let output = ''; + const child = spawn( + process.execPath, + [ + ...CLI_SPAWN_PREFIX, + 'analyze', + repo, + '--no-share', + '--skip-skills', + '--skip-fts', + '--workers', + '1', + ...flags, + ], + { cwd: repo, env: cliEnv(repo), stdio: ['ignore', 'pipe', 'pipe'] }, + ); + running.add(child); + const timeout = setTimeout(() => child.kill('SIGKILL'), DEADLINE); + const collect = (chunk: Buffer) => { + output += chunk.toString(); + onOutput?.(output); + }; + child.stdout?.on('data', collect); + child.stderr?.on('data', collect); + const done = new Promise<{ code: number | null; signal: NodeJS.Signals | null; output: string }>( + (resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => { + clearTimeout(timeout); + running.delete(child); + resolve({ code, signal, output }); + }); + }, + ); + return { child, done, output: () => output }; +} + +async function successfulAnalyze(repo: string, flags: string[] = []): Promise { + submitted = []; + stopAfterCheckpoint = false; + currentRepo = repo; + const result = await runAnalyze(repo, flags).done; + expect(result.output, `CLI exited ${result.code}, signal ${result.signal}`).not.toContain( + 'SIGABRT', + ); + expect(result.code, result.output).toBe(0); + return [...submitted]; +} + +function cloneStoppedRepo(name: string): string { + const repo = path.join(root, name); + fs.cpSync(stoppedRepo, repo, { recursive: true }); + for (const filename of ['gitnexus.json', 'meta.json']) { + const target = path.join(repo, '.gitnexus', filename); + const meta = JSON.parse(fs.readFileSync(target, 'utf8')); + meta.repoPath = repo; + meta.storagePath = path.join(repo, '.gitnexus'); + fs.writeFileSync(target, JSON.stringify(meta)); + } + return repo; +} + +function readPublishedRows( + repo: string, +): Array<{ nodeId: string; chunkIndex: number; embedding: number[] }> { + // Keep native handles out of the vitest fork, and wait for clean teardown + // before accepting the receipt. Every read opens only a published DB. + const receiptPath = path.join(root, `rows-${path.basename(repo)}.json`); + const script = ` + const adapter = await import(${JSON.stringify(adapterUrl)}); + const fs = await import('node:fs'); + await adapter.initLbug(${JSON.stringify(path.join(repo, '.gitnexus', 'lbug'))}); + try { + const rows = await adapter.executeQuery('MATCH (e:CodeEmbedding) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.embedding AS embedding'); + fs.writeFileSync(${JSON.stringify(receiptPath)}, JSON.stringify(rows)); + console.log('ROWS_RECEIPT:' + rows.length); + } finally { await adapter.closeLbug(); } + `; + const result = spawnSync( + process.execPath, + ['--import', tsxLoaderUrl(), '--input-type=module', '-e', script], + { + cwd: packageRoot, + env: cliEnv(repo), + encoding: 'utf8', + timeout: 30_000, + }, + ); + try { + const diagnostic = + `${result.error ?? ''} ${result.signal ?? ''}\n${result.stderr}\n${result.stdout}`.slice( + 0, + 4096, + ); + expect(result.status, diagnostic).toBe(0); + expect(result.stdout).toMatch(/ROWS_RECEIPT:\d+/); + return JSON.parse(fs.readFileSync(receiptPath, 'utf8')); + } finally { + fs.rmSync(receiptPath, { force: true }); + } +} + +function expectPublishedComplete(repo: string, expectedNodes: number): void { + const meta = readMeta(repo); + expect(meta.embeddingCheckpoint).toBeUndefined(); + const rows = readPublishedRows(repo); + expect(new Set(rows.map((row) => row.nodeId)).size).toBe(expectedNodes); + expect(meta.stats?.embeddings).toBe(rows.length); + const byNode = new Map(); + for (const row of rows) { + expect(row.embedding).toHaveLength(DIMS); + expect(row.embedding.every(Number.isFinite)).toBe(true); + const indices = byNode.get(row.nodeId) ?? []; + indices.push(row.chunkIndex); + byNode.set(row.nodeId, indices); + } + for (const indices of byNode.values()) { + expect(indices.sort((a, b) => a - b)).toEqual( + Array.from({ length: indices.length }, (_, index) => index), + ); + } + expect( + fs.readdirSync(path.join(repo, '.gitnexus')).filter((name) => name.startsWith('lbug.staging.')), + ).toEqual([]); +} + +beforeAll(async () => { + if (process.platform === 'win32') return; + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-staged-recovery-e2e-')); + fs.mkdirSync(path.join(root, 'tmp')); + stoppedRepo = path.join(root, 'interrupted'); + currentRepo = stoppedRepo; + fs.mkdirSync(stoppedRepo); + const shortFunctions = Array.from( + { length: NODE_COUNT - 1 }, + (_, index) => `export function recoverable${index}() { return ${index}; }`, + ); + // Multi-chunk nodes must be reused as a complete group, including their tail. + const longFunction = `export function longRecoverable() {\n${Array.from({ length: 80 }, (_, index) => ` // retained chunk marker ${index} ${'x'.repeat(80)}`).join('\n')}\n return 42;\n}`; + fs.writeFileSync( + path.join(stoppedRepo, 'functions.ts'), + `${longFunction}\n${shortFunctions.join('\n')}\n`, + ); + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: 'test', + GIT_AUTHOR_EMAIL: 'test@test', + GIT_COMMITTER_NAME: 'test', + GIT_COMMITTER_EMAIL: 'test@test', + }; + for (const args of [['init'], ['add', 'functions.ts'], ['commit', '-m', 'recovery fixture']]) { + const result = spawnSync('git', args, { cwd: stoppedRepo, env: gitEnv, encoding: 'utf8' }); + expect(result.status, result.stderr).toBe(0); + } + server = http.createServer((request, response) => { + let body = ''; + request.on('data', (chunk) => { + body += chunk; + }); + request.on('end', () => { + const { input } = JSON.parse(body) as { input: string[] }; + submitted.push(...input); + if ( + stopAfterCheckpoint && + (readMeta(currentRepo).embeddingCheckpoint?.nodesProcessed ?? 0) >= 5000 + ) { + if (activeWindowCompleted.length > 0) { + held = [...input]; + gateResolve?.(); + // One sub-batch has already inserted rows inside the unsafe window. + // Awaiting the next real request gates a crash before it completes. + return; + } + durableTexts = [...completed]; + activeWindowCompleted.push(...input); + } + completed.push(...input); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + data: input.map((text, index) => ({ + index, + embedding: Array.from( + { length: DIMS }, + (_, dimension) => ((text.length + dimension * 17) % 101) / 101, + ), + })), + }), + ); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address() as { port: number }; + endpoint = `http://127.0.0.1:${address.port}/v1`; + await successfulAnalyze(stoppedRepo, ['--index-only']); + expect(readMeta(stoppedRepo).stats?.embeddings ?? 0).toBe(0); + completed = []; + held = []; + stopAfterCheckpoint = true; + const gate = new Promise((resolve) => { + gateResolve = resolve; + }); + const first = runAnalyze(stoppedRepo, ['--force', '--embeddings']); + await Promise.race([ + gate, + first.done.then((result) => { + throw new Error(`CLI exited before recovery gate: ${result.output}`); + }), + ]); + first.child.kill('SIGKILL'); + expect((await first.done).signal).toBe('SIGKILL'); + const interrupted = readMeta(stoppedRepo); + expect(interrupted.embeddingCheckpoint?.nodesProcessed).toBe(5000); + expect(interrupted.embeddingCheckpoint?.recovery?.stagingFile).toMatch(/^lbug\.staging\./); + expect(interrupted.stats?.embeddings ?? 0).toBe(0); + expect(completed.length).toBeGreaterThanOrEqual(5000); + expect(completed.filter((text) => text.includes('longRecoverable')).length).toBeGreaterThan(1); + expect(activeWindowCompleted).toHaveLength(64); + expect(held.length).toBeGreaterThan(0); + gateResolve = undefined; + stopAfterCheckpoint = false; +}, DEADLINE * 2); + +afterAll(async () => { + for (const child of running) child.kill('SIGKILL'); + await Promise.all( + [...running].map( + (child) => new Promise((resolve) => child.once('close', () => resolve())), + ), + ); + if (server) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +// Atomic publication is POSIX-specific; Windows uses the in-place path. +describe + .skipIf(process.platform === 'win32') + .sequential('interrupted staged embedding recovery (real CLI and native DB)', () => { + it.each([ + ['plain', []], + ['forced', ['--force', '--embeddings']], + ] as const)( + '%s retry bills only unfinished groups and publishes an honest count', + async (name, flags) => { + const repo = cloneStoppedRepo(name); + const texts = await successfulAnalyze(repo, [...flags]); + const durable = new Set(durableTexts); + expect(texts.filter((text) => durable.has(text))).toEqual([]); + for (const text of held) expect(texts).toContain(text); + for (const text of activeWindowCompleted) expect(texts).toContain(text); + expect(texts.length).toBe(128); + expectPublishedComplete(repo, NODE_COUNT); + }, + DEADLINE, + ); + + it( + 'manual checkpoint opt-out completes a staged retry without resubmitting durable chunks', + async () => { + const repo = cloneStoppedRepo('manual-checkpoint-opt-out'); + const previous = process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT; + process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = '0'; + try { + const texts = await successfulAnalyze(repo, ['--force', '--embeddings']); + expect(texts.filter((text) => new Set(durableTexts).has(text))).toEqual([]); + expect(texts.length).toBe(128); + expectPublishedComplete(repo, NODE_COUNT); + } finally { + if (previous === undefined) delete process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT; + else process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = previous; + } + }, + DEADLINE, + ); + + it( + 'survives another crash after harvesting but before the replacement is durable', + async () => { + const repo = cloneStoppedRepo('crash-again'); + const source = readMeta(repo).embeddingCheckpoint?.recovery?.stagingFile; + expect(source).toBeDefined(); + if (!source) throw new Error('fixture has no retained generation'); + submitted = []; + currentRepo = repo; + let killed = false; + const retry = runAnalyze(repo, [], (output) => { + if (!killed && /Recovered \d+ complete staged embedding chunk/.test(output)) { + killed = true; + retry.child.kill('SIGKILL'); + } + }); + const result = await retry.done; + expect(killed, result.output).toBe(true); + expect(result.signal).toBe('SIGKILL'); + expect(submitted).toEqual([]); + expect(readMeta(repo).embeddingCheckpoint?.recovery?.stagingFile).toBe(source); + expect(fs.existsSync(path.join(repo, '.gitnexus', source))).toBe(true); + const finalTexts = await successfulAnalyze(repo); + expect(finalTexts.filter((text) => new Set(durableTexts).has(text))).toEqual([]); + expect(finalTexts.length).toBe(128); + expectPublishedComplete(repo, NODE_COUNT); + }, + DEADLINE * 2, + ); + + it( + 'regenerates changed content and removes deleted nodes while reusing the other complete groups', + async () => { + const repo = cloneStoppedRepo('changed'); + const source = path.join(repo, 'functions.ts'); + const content = fs + .readFileSync(source, 'utf8') + .replace( + 'export function recoverable5() { return 5; }', + 'export function recoverable5() { return 999999; }', + ) + .replace( + 'export function recoverable6() { return 6; }', + '// deleted function retains line offsets', + ); + fs.writeFileSync(source, content); + const texts = await successfulAnalyze(repo); + expect(texts.some((text) => text.includes('recoverable5') && text.includes('999999'))).toBe( + true, + ); + expect(texts.some((text) => text.includes('recoverable6'))).toBe(false); + expect(texts.filter((text) => new Set(durableTexts).has(text))).toEqual([]); + expect(texts.length).toBe(129); + expectPublishedComplete(repo, NODE_COUNT - 1); + }, + DEADLINE, + ); + + it( + 'a forced retry with a different model does not import the staged cache', + async () => { + const repo = cloneStoppedRepo('different-model'); + const texts = await successfulAnalyze(repo, [ + '--force', + '--embeddings', + '--embedding-model', + 'different-model', + ]); + for (const text of durableTexts) expect(texts).toContain(text); + expect(texts.length).toBeGreaterThanOrEqual(NODE_COUNT); + expectPublishedComplete(repo, NODE_COUNT); + }, + DEADLINE, + ); + + it( + 'explicit drop abandons staged vectors without contacting the provider', + async () => { + const repo = cloneStoppedRepo('drop'); + expect(await successfulAnalyze(repo, ['--force', '--drop-embeddings'])).toEqual([]); + expect(readMeta(repo).embeddingCheckpoint).toBeUndefined(); + expect(readMeta(repo).stats?.embeddings ?? 0).toBe(0); + expect(readPublishedRows(repo)).toEqual([]); + }, + DEADLINE, + ); + }); diff --git a/gitnexus/test/integration/staged-embedding-recovery.test.ts b/gitnexus/test/integration/staged-embedding-recovery.test.ts new file mode 100644 index 000000000..0a52c67ab --- /dev/null +++ b/gitnexus/test/integration/staged-embedding-recovery.test.ts @@ -0,0 +1,208 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + disposeEmbeddingSpill, + materializeCachedEmbeddings, + type CachedEmbeddingsSnapshot, +} from '../../src/core/embeddings/embedding-restore-spill.js'; +import { recoverStagedEmbeddings } from '../../src/core/embeddings/staged-embedding-recovery.js'; + +describe('isolated native staged embedding recovery', () => { + let tmp: string | undefined; + let recovered: CachedEmbeddingsSnapshot | undefined; + afterEach(() => { + disposeEmbeddingSpill(recovered?.spill); + recovered = undefined; + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); + tmp = undefined; + }); + function stagePath() { + tmp ??= fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-stage-native-')); + return path.join(tmp, `lbug.staging.${randomUUID()}`); + } + function seed(dbPath: string, mode = 'clean') { + const result = spawnSync( + process.execPath, + [ + '--import', + 'tsx', + fileURLToPath(new URL('../fixtures/staged-embedding-recovery/seed.mjs', import.meta.url)), + dbPath, + mode, + ], + { + encoding: 'utf8', + timeout: 20_000, + env: { ...process.env, GITNEXUS_LBUG_BUFFER_POOL_SIZE: String(128 * 1024 * 1024) }, + }, + ); + expect(result.error, result.stderr).toBeUndefined(); + if (mode === 'hard-kill') expect(result.signal, result.stderr).toBe('SIGKILL'); + else expect(result.status, result.stderr).toBe(0); + } + + function snapshotSourceFamily(dbPath: string) { + const basename = path.basename(dbPath); + return Object.fromEntries( + fs + .readdirSync(path.dirname(dbPath)) + .filter((name) => name === basename || name.startsWith(`${basename}.`)) + .sort() + .map((name) => [name, fs.readFileSync(path.join(path.dirname(dbPath), name))]), + ); + } + + it('streams complete same-hash groups and rejects malformed whole nodes', async () => { + const dbPath = stagePath(); + seed(dbPath); + recovered = await recoverStagedEmbeddings(dbPath, { dimensions: 2 }); + expect([...recovered.embeddingNodeIds].sort()).toEqual(['complete', 'other']); + expect(recovered.rows).toHaveLength(3); + expect(recovered.embeddings).toEqual([]); + expect(materializeCachedEmbeddings(recovered, recovered.rows)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + nodeId: 'complete', + chunkIndex: 0, + embedding: [1, 2], + contentHash: 'same', + }), + expect.objectContaining({ + nodeId: 'complete', + chunkIndex: 1, + embedding: [1, 2], + contentHash: 'same', + }), + ]), + ); + expect(fs.existsSync(dbPath)).toBe(true); + }, 30_000); + + it('replays a hard-killed native writer strictly and excludes the incomplete active window', async () => { + const dbPath = stagePath(); + seed(dbPath, 'hard-kill'); + const sourceBefore = snapshotSourceFamily(dbPath); + recovered = await recoverStagedEmbeddings(dbPath, { + dimensions: 2, + excludedNodeIds: ['unsafe-prefix', 'other'], + }); + expect([...recovered.embeddingNodeIds]).toEqual(['complete']); + expect(recovered.rows).toHaveLength(2); + expect( + materializeCachedEmbeddings(recovered, recovered.rows) + .map((row) => row.chunkIndex) + .sort(), + ).toEqual([0, 1]); + expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore); + }, 30_000); + + it('rejects vectors from a different dimension instead of coercing them', async () => { + const dbPath = stagePath(); + seed(dbPath); + recovered = await recoverStagedEmbeddings(dbPath, { dimensions: 3 }); + expect(recovered.rows).toEqual([]); + }, 30_000); + + it('strictly replays an interrupted checkpoint copy with both checkpoint locks retained', async (ctx) => { + const version = JSON.parse( + fs.readFileSync( + new URL('../../node_modules/@ladybugdb/core/package.json', import.meta.url), + 'utf8', + ), + ).version as string; + const [major, minor] = version.split('.').map(Number); + // Older pins do not support the deterministic interrupted-checkpoint plant. + if (Number.isFinite(major) && Number.isFinite(minor) && major === 0 && minor < 19) ctx.skip(); + const dbPath = stagePath(); + seed(dbPath, 'interrupted-checkpoint'); + const sourceBefore = snapshotSourceFamily(dbPath); + expect(fs.statSync(`${dbPath}.wal.checkpoint`).size).toBeGreaterThan(0); + expect(fs.existsSync(`${dbPath}.checkpoint.intent.lock`)).toBe(true); + expect(fs.existsSync(`${dbPath}.checkpoint.apply.lock`)).toBe(true); + + recovered = await recoverStagedEmbeddings(dbPath, { dimensions: 2 }); + + expect([...recovered.embeddingNodeIds].sort()).toEqual([ + 'checkpoint-only', + 'complete', + 'other', + ]); + expect(recovered.rows).toHaveLength(4); + expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore); + }, 30_000); + + it('contains a malformed native source in a subprocess and preserves it', async () => { + const dbPath = stagePath(); + fs.writeFileSync(dbPath, 'not a ladybug database'); + await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow( + /extraction failed/, + ); + expect(fs.readFileSync(dbPath, 'utf8')).toBe('not a ladybug database'); + }, 30_000); + + it('rejects a malformed WAL without deleting or quarantining it to reopen the source', async () => { + const dbPath = stagePath(); + seed(dbPath, 'hard-kill'); + const walPath = `${dbPath}.wal`; + fs.writeFileSync(walPath, Buffer.alloc(128, 0xff)); + const sourceBefore = snapshotSourceFamily(dbPath); + await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow( + /extraction failed/, + ); + expect(fs.existsSync(walPath)).toBe(true); + expect(fs.readdirSync(path.dirname(dbPath)).some((name) => /bad|quarantine/i.test(name))).toBe( + false, + ); + expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore); + }, 30_000); + + it('does not create a missing source and refuses a symlink', async () => { + const dbPath = stagePath(); + await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow(/ENOENT/); + expect(fs.existsSync(dbPath)).toBe(false); + const realPath = path.join(path.dirname(dbPath), 'real'); + fs.writeFileSync(realPath, 'fixture'); + fs.symlinkSync(realPath, dbPath); + await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow( + /regular file/, + ); + }); + + it('can kill a timed out native subprocess without aborting analyze', async () => { + const dbPath = stagePath(); + seed(dbPath); + const sourceBefore = snapshotSourceFamily(dbPath); + await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2, timeoutMs: 1 })).rejects.toThrow( + /timeout/, + ); + expect(fs.existsSync(dbPath)).toBe(true); + expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore); + }, 30_000); + + it('loads the source child when analyze runs in another repository directory', () => { + const dbPath = stagePath(); + seed(dbPath); + const result = spawnSync( + process.execPath, + [ + '--import', + import.meta.resolve('tsx'), + '--input-type=module', + '-e', + 'const { recoverStagedEmbeddings } = await import(process.argv[1]); const { disposeEmbeddingSpill } = await import(process.argv[2]); const recovered = await recoverStagedEmbeddings(process.argv[3], {dimensions: 2}); process.stdout.write(String(recovered.rows.length)); disposeEmbeddingSpill(recovered.spill);', + new URL('../../src/core/embeddings/staged-embedding-recovery.ts', import.meta.url).href, + new URL('../../src/core/embeddings/embedding-restore-spill.ts', import.meta.url).href, + dbPath, + ], + { cwd: path.dirname(dbPath), encoding: 'utf8', timeout: 20_000 }, + ); + expect(result.error, result.stderr).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe('3'); + }, 30_000); +}); diff --git a/gitnexus/test/unit/api-fts-mode.test.ts b/gitnexus/test/unit/api-fts-mode.test.ts index 632bd37d3..ab8eaf401 100644 --- a/gitnexus/test/unit/api-fts-mode.test.ts +++ b/gitnexus/test/unit/api-fts-mode.test.ts @@ -7,7 +7,12 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } const mocks = vi.hoisted(() => ({ loadMeta: vi.fn(), + saveMeta: vi.fn(), listRegisteredRepos: vi.fn(), + acquireIndexLock: vi.fn(), + releaseIndexLock: vi.fn(), + ensurePrivateSharedGraph: vi.fn(), + runEmbeddingPipeline: vi.fn(), withLbugDb: vi.fn(), search: vi.fn(), updateJob: vi.fn(), @@ -16,8 +21,19 @@ const mocks = vi.hoisted(() => ({ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({ ...(await importOriginal()), loadMeta: mocks.loadMeta, + saveMeta: mocks.saveMeta, listRegisteredRepos: mocks.listRegisteredRepos, })); +vi.mock('../../src/storage/index-lock.js', async (importOriginal) => ({ + ...(await importOriginal()), + acquireIndexLock: mocks.acquireIndexLock, +})); +vi.mock('../../src/core/shared-store-analyze.js', () => ({ + ensurePrivateSharedGraph: mocks.ensurePrivateSharedGraph, +})); +vi.mock('../../src/core/embeddings/embedding-pipeline.js', () => ({ + runEmbeddingPipeline: mocks.runEmbeddingPipeline, +})); vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => ({ ...(await importOriginal()), requireRegisteredStoragePath: vi.fn(async (entry: { storagePath: string }) => entry.storagePath), @@ -116,6 +132,19 @@ afterAll(() => { beforeEach(() => { vi.clearAllMocks(); + mocks.acquireIndexLock.mockResolvedValue({ + release: mocks.releaseIndexLock, + record: { + v: 1, + pid: process.pid, + hostname: 'test-host', + startTime: null, + token: 'test-lock', + invocationId: 'test-run', + acquiredAt: '', + }, + }); + mocks.ensurePrivateSharedGraph.mockResolvedValue(true); mocks.listRegisteredRepos.mockResolvedValue([entry]); mocks.withLbugDb.mockImplementation(async (_path, callback) => callback()); mocks.search.mockImplementation(async (_query, _limit, _exec, reason) => ({ @@ -124,6 +153,145 @@ beforeEach(() => { })); }); +describe('POST /api/embed staged recovery preflight', () => { + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(entry.storagePath, { recursive: true, force: true }); + fs.mkdirSync(entry.storagePath, { recursive: true }); + }); + + async function useRealIndexLock() { + vi.stubEnv('GITNEXUS_INDEX_LOCK_BACKEND', 'file'); + const actual = await vi.importActual( + '../../src/storage/index-lock.js', + ); + mocks.acquireIndexLock.mockImplementation( + async (...args: Parameters) => { + const lock = await actual.acquireIndexLock(...args); + return { + ...lock, + release: () => { + lock.release(); + mocks.releaseIndexLock(); + }, + }; + }, + ); + } + + it.each([ + { + name: 'valid staged receipt', + recovery: { + stagingFile: 'lbug.staging.12345678-1234-4123-8123-123456789abc', + schemaFingerprint: 'test-schema', + unsafeNodeIds: ['n2', 'inherited-window-node'], + }, + }, + { name: 'null receipt', recovery: null }, + { name: 'malformed receipt', recovery: { stagingFile: 'invalid' } }, + { name: 'false receipt', recovery: false }, + ])('preserves a $name and releases the job locks', async ({ recovery }) => { + await useRealIndexLock(); + const lockPath = path.join(entry.storagePath, 'analyze.lock'); + const lbugPath = path.join(entry.storagePath, 'lbug'); + const metaPath = path.join(entry.storagePath, 'gitnexus.json'); + const sourcePath = path.join( + entry.storagePath, + 'lbug.staging.12345678-1234-4123-8123-123456789abc', + ); + fs.writeFileSync(lbugPath, 'published graph'); + fs.writeFileSync(sourcePath, 'completed paid vectors'); + fs.writeFileSync(`${sourcePath}.wal`, 'unfinished window'); + const metadataBytes = JSON.stringify({ + repoPath: entry.path, + lastCommit: 'abc123', + indexedAt: '2026-01-01T00:00:00.000Z', + stats: { embeddings: 7 }, + embeddingCheckpoint: { + at: '2026-01-01T00:00:00.000Z', + nodesProcessed: 1, + totalNodes: 2, + chunksProcessed: 1, + model: 'test-model', + dimensions: 768, + provider: 'local', + kind: 'interrupted', + pendingNodeIds: ['n2'], + recovery, + }, + }); + fs.writeFileSync(metaPath, metadataBytes); + mocks.loadMeta.mockImplementation(async () => { + expect(fs.existsSync(lockPath)).toBe(true); + return JSON.parse(fs.readFileSync(metaPath, 'utf8')); + }); + mocks.withLbugDb.mockResolvedValue(undefined); + + await invoke('/api/embed'); + await vi.waitFor(() => expect(mocks.releaseIndexLock).toHaveBeenCalledTimes(1)); + + expect(mocks.updateJob).toHaveBeenCalledWith( + 'embed-job', + expect.objectContaining({ + status: 'failed', + error: expect.stringMatching( + /staged embeddings.*Run `gitnexus analyze` to recover them first/, + ), + }), + ); + expect(mocks.acquireIndexLock).toHaveBeenCalledWith(entry.storagePath, { sweep: false }); + expect(mocks.loadMeta.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.acquireIndexLock.mock.invocationCallOrder[0]!, + ); + expect(mocks.ensurePrivateSharedGraph).not.toHaveBeenCalled(); + expect(mocks.withLbugDb).not.toHaveBeenCalled(); + expect(mocks.runEmbeddingPipeline).not.toHaveBeenCalled(); + expect(mocks.saveMeta).not.toHaveBeenCalled(); + expect(fs.existsSync(lockPath)).toBe(false); + expect(fs.readFileSync(metaPath, 'utf8')).toBe(metadataBytes); + expect(fs.readFileSync(lbugPath, 'utf8')).toBe('published graph'); + expect(fs.readFileSync(sourcePath, 'utf8')).toBe('completed paid vectors'); + expect(fs.readFileSync(`${sourcePath}.wal`, 'utf8')).toBe('unfinished window'); + + // A second accepted job proves the in-memory repo lock was also released. + await invoke('/api/embed'); + await vi.waitFor(() => expect(mocks.releaseIndexLock).toHaveBeenCalledTimes(2)); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('sweeps orphaned staging files before a writable job without a recovery receipt', async () => { + await useRealIndexLock(); + const metaPath = path.join(entry.storagePath, 'gitnexus.json'); + fs.writeFileSync(metaPath, JSON.stringify({ repoPath: entry.path })); + const sourcePath = path.join( + entry.storagePath, + 'lbug.staging.12345678-1234-4123-8123-123456789abc', + ); + fs.writeFileSync(sourcePath, 'orphaned database'); + fs.writeFileSync(`${sourcePath}.wal`, 'orphaned WAL'); + mocks.loadMeta.mockImplementation(async () => JSON.parse(fs.readFileSync(metaPath, 'utf8'))); + mocks.ensurePrivateSharedGraph.mockImplementation(async () => { + expect(fs.existsSync(path.join(entry.storagePath, 'analyze.lock'))).toBe(true); + expect(fs.existsSync(sourcePath)).toBe(false); + expect(fs.existsSync(`${sourcePath}.wal`)).toBe(false); + return true; + }); + mocks.withLbugDb.mockResolvedValue(undefined); + + await invoke('/api/embed'); + await vi.waitFor(() => expect(mocks.releaseIndexLock).toHaveBeenCalledTimes(1)); + + expect(mocks.updateJob).toHaveBeenCalledWith( + 'embed-job', + expect.objectContaining({ status: 'complete' }), + ); + expect(mocks.ensurePrivateSharedGraph).toHaveBeenCalledTimes(1); + expect(mocks.withLbugDb).toHaveBeenCalledTimes(1); + expect(fs.existsSync(path.join(entry.storagePath, 'analyze.lock'))).toBe(false); + }); +}); + async function invoke(route: string, query: Record = {}) { const layer = app.router.stack.find((item: any) => item.route?.path === route); expect(layer, route).toBeDefined(); @@ -257,7 +425,9 @@ describe('serve uses one metadata-derived FTS mode on every DB-open path', () => expect.any(Function), skip ? { skipFts: true } : {}, ); - expect(mocks.loadMeta).toHaveBeenCalledExactlyOnceWith(entry.storagePath); + expect(mocks.loadMeta).toHaveBeenCalledTimes(2); + expect(mocks.loadMeta).toHaveBeenNthCalledWith(1, entry.storagePath); + expect(mocks.loadMeta).toHaveBeenNthCalledWith(2, entry.storagePath); }, ); }); diff --git a/gitnexus/test/unit/embedding-recovery-race.test.ts b/gitnexus/test/unit/embedding-recovery-race.test.ts new file mode 100644 index 000000000..145f860ec --- /dev/null +++ b/gitnexus/test/unit/embedding-recovery-race.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { readEmbeddingRecovery } from '../../src/storage/embedding-recovery.js'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + constants: { ...actual.constants }, + lstatSync: vi.fn(actual.lstatSync), + openSync: vi.fn(actual.openSync), + fstatSync: vi.fn(actual.fstatSync), + readFileSync: vi.fn(actual.readFileSync), + closeSync: vi.fn(actual.closeSync), + }; +}); + +const actual = await vi.importActual('node:fs'); +const stagingFile = 'lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46'; +const receipt = { + embeddingCheckpoint: { + kind: 'interrupted', + at: '2026-10-03T12:00:00.000Z', + nodesProcessed: 1, + totalNodes: 2, + chunksProcessed: 1, + model: 'test-model', + dimensions: 2, + provider: 'local', + recovery: { stagingFile, schemaFingerprint: 'test-schema', unsafeNodeIds: [] }, + }, +}; + +let dir: string; +let metadataPath: string; +beforeEach(() => { + vi.mocked(fs.lstatSync).mockImplementation(actual.lstatSync); + vi.mocked(fs.openSync).mockImplementation(actual.openSync); + vi.mocked(fs.fstatSync).mockImplementation(actual.fstatSync); + vi.mocked(fs.readFileSync).mockImplementation(actual.readFileSync); + vi.mocked(fs.closeSync).mockImplementation(actual.closeSync); + Object.assign(fs.constants, actual.constants); + vi.clearAllMocks(); + dir = actual.mkdtempSync(path.join(os.tmpdir(), 'gnx-recovery-metadata-race-')); + metadataPath = path.join(dir, 'gitnexus.json'); + actual.writeFileSync(path.join(dir, stagingFile), 'stage'); +}); +afterEach(() => actual.rmSync(dir, { recursive: true, force: true })); + +describe('readEmbeddingRecovery metadata races', () => { + it('does not read replacement metadata after checking the original file', () => { + actual.writeFileSync(metadataPath, JSON.stringify({ embeddingCheckpoint: null })); + let replaced = false; + vi.mocked(fs.lstatSync).mockImplementation((...args) => { + const stat = actual.lstatSync(...args); + if (args[0] === metadataPath && !replaced) { + replaced = true; + actual.renameSync(metadataPath, path.join(dir, 'original-metadata.json')); + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + } + return stat; + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(replaced).toBe(true); + const descriptor = vi.mocked(fs.openSync).mock.results[0]?.value; + expect(typeof descriptor).toBe('number'); + expect(fs.readFileSync).toHaveBeenCalledWith(descriptor, 'utf8'); + expect(fs.closeSync).toHaveBeenCalledWith(descriptor); + }); + + it('rejects a file replaced between opening and checking its identity', () => { + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + let replaced = false; + vi.mocked(fs.openSync).mockImplementation((...args) => { + const descriptor = actual.openSync(...args); + if (args[0] === metadataPath && !replaced) { + replaced = true; + actual.renameSync(metadataPath, path.join(dir, 'original-metadata.json')); + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + } + return descriptor; + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(replaced).toBe(true); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('reads regular metadata when no-follow opens are unavailable', () => { + Object.assign(fs.constants, { O_NOFOLLOW: 0, O_NONBLOCK: 0 }); + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + + expect(readEmbeddingRecovery(dir)?.stagingFile).toBe(stagingFile); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('refuses unverifiable file identity when no-follow opens are unavailable', () => { + Object.assign(fs.constants, { O_NOFOLLOW: 0, O_NONBLOCK: 0 }); + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + vi.mocked(fs.fstatSync).mockImplementation((...args) => { + const stat = actual.fstatSync(...args); + Object.defineProperty(stat, 'ino', { value: 0n }); + return stat; + }); + vi.mocked(fs.lstatSync).mockImplementation((...args) => { + const stat = actual.lstatSync(...args); + Object.defineProperty(stat, 'ino', { value: 0n }); + return stat; + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('rejects a symlink introduced before opening without no-follow support', () => { + Object.assign(fs.constants, { O_NOFOLLOW: 0, O_NONBLOCK: 0 }); + actual.writeFileSync(metadataPath, JSON.stringify({ embeddingCheckpoint: null })); + const target = path.join(dir, 'foreign-metadata.json'); + actual.writeFileSync(target, JSON.stringify(receipt)); + let replaced = false; + vi.mocked(fs.openSync).mockImplementation((...args) => { + if (args[0] === metadataPath && !replaced) { + replaced = true; + actual.rmSync(metadataPath); + actual.symlinkSync(target, metadataPath); + } + return actual.openSync(...args); + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(replaced).toBe(true); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('never falls back from a dangling primary symlink without no-follow support', () => { + Object.assign(fs.constants, { O_NOFOLLOW: 0, O_NONBLOCK: 0 }); + actual.symlinkSync(path.join(dir, 'missing-metadata.json'), metadataPath); + actual.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(receipt)); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + }); + + it('rejects a symlinked legacy receipt when the primary is absent', () => { + Object.assign(fs.constants, { O_NOFOLLOW: 0, O_NONBLOCK: 0 }); + const target = path.join(dir, 'foreign-metadata.json'); + actual.writeFileSync(target, JSON.stringify(receipt)); + actual.symlinkSync(target, path.join(dir, 'meta.json')); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('closes a descriptor when fstat fails without falling back to legacy metadata', () => { + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + actual.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(receipt)); + vi.mocked(fs.fstatSync).mockImplementationOnce(() => { + throw Object.assign(new Error('stat failed'), { code: 'EIO' }); + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('closes a descriptor when JSON parsing fails without falling back', () => { + actual.writeFileSync(metadataPath, '{'); + actual.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(receipt)); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it.skipIf(process.platform === 'win32')('rejects a substituted FIFO without blocking', () => { + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + let replaced = false; + vi.mocked(fs.openSync).mockImplementation((...args) => { + if (args[0] === metadataPath && !replaced) { + replaced = true; + actual.rmSync(metadataPath); + execFileSync('mkfifo', [metadataPath]); + } + return actual.openSync(...args); + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(replaced).toBe(true); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); +}); diff --git a/gitnexus/test/unit/embedding-recovery.test.ts b/gitnexus/test/unit/embedding-recovery.test.ts new file mode 100644 index 000000000..a8f1064a0 --- /dev/null +++ b/gitnexus/test/unit/embedding-recovery.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + readEmbeddingRecovery, + resolveEmbeddingRecovery, +} from '../../src/storage/embedding-recovery.js'; + +const stagingFile = 'lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46'; +const familySuffixes = [ + '', + '.wal', + '.shadow', + '.wal.checkpoint', + '.lock', + '.checkpoint.intent.lock', + '.checkpoint.apply.lock', +]; +const checkpoint = () => ({ + kind: 'interrupted', + at: '2026-10-03T12:00:00.000Z', + nodesProcessed: 1, + totalNodes: 3, + chunksProcessed: 2, + model: 'test-model', + dimensions: 2, + provider: 'local', + pendingNodeIds: ['active'], + recovery: { + stagingFile, + schemaFingerprint: 'test-schema', + unsafeNodeIds: ['active', 'incomplete-restore'], + }, +}); + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), 'gnx-embedding-recovery-')); + writeFileSync(path.join(dir, stagingFile), 'stage'); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +describe('resolveEmbeddingRecovery', () => { + it('resolves the exact staged generation and carries all unsafe node IDs', () => { + expect(resolveEmbeddingRecovery(dir, checkpoint())).toEqual({ + ...checkpoint().recovery, + dbPath: path.join(dir, stagingFile), + familyFiles: familySuffixes.map((suffix) => stagingFile + suffix), + }); + }); + + it.each([ + '../lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46', + '/tmp/lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46', + 'branches/foreign/lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46', + '..\\lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46', + 'lbug', + 'lbug.new', + 'lbug.staging.orphan', + `${stagingFile}.wal`, + 'lbug.staging.00000000-0000-4000-8000-000000000000', + ])('rejects a foreign, unrecognized or missing generation: %s', (filename) => { + const marker = checkpoint(); + marker.recovery.stagingFile = filename; + expect(resolveEmbeddingRecovery(dir, marker)).toBeUndefined(); + }); + + it.each([ + null, + [], + { recovery: checkpoint().recovery }, + { ...checkpoint(), kind: 'partial' }, + { ...checkpoint(), kind: 'unverified-count' }, + { ...checkpoint(), kind: 'unknown' }, + { ...checkpoint(), at: 'invalid-time' }, + { ...checkpoint(), nodesProcessed: -1 }, + { ...checkpoint(), nodesProcessed: 4 }, + { ...checkpoint(), totalNodes: 1.5 }, + { ...checkpoint(), chunksProcessed: Number.NaN }, + { ...checkpoint(), model: '' }, + { ...checkpoint(), provider: null }, + { ...checkpoint(), dimensions: 0 }, + { ...checkpoint(), pendingNodeIds: [42] }, + { ...checkpoint(), pendingNodeIds: ['unexcluded'] }, + { ...checkpoint(), recovery: { ...checkpoint().recovery, schemaFingerprint: '' } }, + { ...checkpoint(), recovery: { ...checkpoint().recovery, unsafeNodeIds: undefined } }, + { ...checkpoint(), recovery: { ...checkpoint().recovery, unsafeNodeIds: [''] } }, + ])('rejects malformed or incompatible checkpoint shape %#', (marker) => { + expect(resolveEmbeddingRecovery(dir, marker)).toBeUndefined(); + }); + + it('accepts a durable restored stage even before a new embedding window completes', () => { + expect( + resolveEmbeddingRecovery(dir, { + ...checkpoint(), + nodesProcessed: 0, + chunksProcessed: 0, + pendingNodeIds: [], + }), + ).toBeDefined(); + }); + + it('rejects a directory in place of the staged database', () => { + rmSync(path.join(dir, stagingFile)); + mkdirSync(path.join(dir, stagingFile)); + expect(resolveEmbeddingRecovery(dir, checkpoint())).toBeUndefined(); + }); + + it.each(familySuffixes)('rejects a symlink in the staged family: %s', (suffix) => { + const target = path.join(dir, 'external-file'); + writeFileSync(target, 'external'); + const candidate = path.join(dir, stagingFile + suffix); + rmSync(candidate, { force: true }); + symlinkSync(target, candidate); + expect(resolveEmbeddingRecovery(dir, checkpoint())).toBeUndefined(); + }); + + it.each(familySuffixes.slice(1))('rejects a dangling staged sidecar symlink: %s', (suffix) => { + symlinkSync(path.join(dir, 'missing'), path.join(dir, stagingFile + suffix)); + expect(resolveEmbeddingRecovery(dir, checkpoint())).toBeUndefined(); + }); + + it.each(familySuffixes.slice(1))('rejects a non-file staged sidecar: %s', (suffix) => { + mkdirSync(path.join(dir, stagingFile + suffix)); + expect(resolveEmbeddingRecovery(dir, checkpoint())).toBeUndefined(); + }); +}); + +describe('readEmbeddingRecovery', () => { + const writeMeta = (filename: string, marker: unknown = checkpoint()): void => { + writeFileSync(path.join(dir, filename), JSON.stringify({ embeddingCheckpoint: marker })); + }; + + it('prefers the primary metadata file over a stale legacy reference', () => { + writeMeta('gitnexus.json'); + writeMeta('meta.json', null); + expect(readEmbeddingRecovery(dir)?.stagingFile).toBe(stagingFile); + }); + + it('loads the legacy mirror only when the primary metadata file is absent', () => { + writeMeta('meta.json'); + expect(readEmbeddingRecovery(dir)?.stagingFile).toBe(stagingFile); + }); + + it('does not resurrect a legacy reference when the primary file is malformed', () => { + writeFileSync(path.join(dir, 'gitnexus.json'), '{'); + writeMeta('meta.json'); + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + }); + + it('does not fall back from valid primary metadata with no recovery reference', () => { + writeMeta('gitnexus.json', null); + writeMeta('meta.json'); + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + }); + + it('rejects symlinked primary metadata rather than reading a foreign receipt', () => { + writeMeta('meta.json'); + symlinkSync(path.join(dir, 'meta.json'), path.join(dir, 'gitnexus.json')); + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + }); + + it('resolves branch-slot provenance within that slot despite a flat storagePath', () => { + const branchSlot = path.join(dir, 'branches', 'feature'); + mkdirSync(branchSlot, { recursive: true }); + writeFileSync(path.join(branchSlot, stagingFile), 'branch-stage'); + writeFileSync( + path.join(branchSlot, 'gitnexus.json'), + JSON.stringify({ storagePath: dir, embeddingCheckpoint: checkpoint() }), + ); + expect(readEmbeddingRecovery(branchSlot)?.dbPath).toBe(path.join(branchSlot, stagingFile)); + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/embeddings-sync-command.test.ts b/gitnexus/test/unit/embeddings-sync-command.test.ts index 01afa40c3..ec33fee6d 100644 --- a/gitnexus/test/unit/embeddings-sync-command.test.ts +++ b/gitnexus/test/unit/embeddings-sync-command.test.ts @@ -3,13 +3,15 @@ * index lock, missing-DB preflight, identity fail-closed, tri-state count, * closeLbug masking, and hash-only cache load. */ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { acquireIndexLockMock, + ensurePrivateSharedGraphMock, releaseMock, getStoragePathsMock, loadMetaMock, @@ -27,6 +29,7 @@ const { reapEmbeddingSidecarMock, } = vi.hoisted(() => ({ acquireIndexLockMock: vi.fn(), + ensurePrivateSharedGraphMock: vi.fn(), releaseMock: vi.fn(), getStoragePathsMock: vi.fn(), loadMetaMock: vi.fn(), @@ -48,6 +51,10 @@ vi.mock('../../src/storage/git.js', () => ({ getGitRoot: () => '/tmp/emb-sync-repo', })); +vi.mock('../../src/core/shared-store-analyze.js', () => ({ + ensurePrivateSharedGraph: (...args: unknown[]) => ensurePrivateSharedGraphMock(...args), +})); + vi.mock('../../src/storage/index-lock.js', async (importOriginal) => ({ ...(await importOriginal()), acquireIndexLock: (...args: unknown[]) => acquireIndexLockMock(...args), @@ -113,6 +120,25 @@ async function run(inputPath = '/tmp/emb-sync-repo') { await embeddingsSyncCommand(inputPath); } +async function useRealIndexLock() { + vi.stubEnv('GITNEXUS_INDEX_LOCK_BACKEND', 'file'); + const actual = await vi.importActual( + '../../src/storage/index-lock.js', + ); + acquireIndexLockMock.mockImplementation( + async (...args: Parameters) => { + const lock = await actual.acquireIndexLock(...args); + return { + ...lock, + release: () => { + lock.release(); + releaseMock(); + }, + }; + }, + ); +} + describe('embeddingsSyncCommand writer safety (#3065)', () => { const tmpDirs: string[] = []; const originalEmbeddingUrl = process.env.GITNEXUS_EMBEDDING_URL; @@ -132,6 +158,7 @@ describe('embeddingsSyncCommand writer safety (#3065)', () => { beforeEach(() => { vi.resetModules(); acquireIndexLockMock.mockReset().mockResolvedValue(lockHandle()); + ensurePrivateSharedGraphMock.mockReset().mockResolvedValue(true); releaseMock.mockReset(); getStoragePathsMock.mockReset(); loadMetaMock.mockReset().mockResolvedValue({ ...BASE_META }); @@ -156,6 +183,7 @@ describe('embeddingsSyncCommand writer safety (#3065)', () => { }); afterEach(async () => { + vi.unstubAllEnvs(); if (originalEmbeddingUrl === undefined) delete process.env.GITNEXUS_EMBEDDING_URL; else process.env.GITNEXUS_EMBEDDING_URL = originalEmbeddingUrl; if (originalEmbeddingModel === undefined) delete process.env.GITNEXUS_EMBEDDING_MODEL; @@ -183,7 +211,7 @@ describe('embeddingsSyncCommand writer safety (#3065)', () => { await run(); - expect(acquireIndexLockMock).toHaveBeenCalledWith(dir); + expect(acquireIndexLockMock).toHaveBeenCalledWith(dir, { sweep: false }); expect(order[0]).toBe('lock'); expect(order.indexOf('loadMeta')).toBeGreaterThan(order.indexOf('lock')); expect(order.indexOf('init')).toBeGreaterThan(order.indexOf('loadMeta')); @@ -299,6 +327,90 @@ describe('embeddingsSyncCommand writer safety (#3065)', () => { expect(releaseMock).toHaveBeenCalled(); }); + it.each([ + { + name: 'valid staged receipt', + recovery: { + stagingFile: 'lbug.staging.12345678-1234-4123-8123-123456789abc', + schemaFingerprint: 'test-schema', + unsafeNodeIds: ['n2', 'inherited-window-node'], + }, + }, + { name: 'null receipt', recovery: null }, + { name: 'malformed receipt', recovery: { stagingFile: 'invalid' } }, + { name: 'false receipt', recovery: false }, + ])('preserves a $name before any writable sync work', async ({ recovery }) => { + const { dir, lbugPath, metaPath } = await store(); + await useRealIndexLock(); + const lockPath = path.join(dir, 'analyze.lock'); + const sourcePath = path.join(dir, 'lbug.staging.12345678-1234-4123-8123-123456789abc'); + await writeFile(sourcePath, 'completed paid vectors'); + await writeFile(`${sourcePath}.wal`, 'unfinished window'); + const metadataBytes = JSON.stringify({ + ...BASE_META, + embeddingCheckpoint: { + ...IDENTITY, + at: '2026-01-01T00:00:00.000Z', + nodesProcessed: 1, + totalNodes: 2, + chunksProcessed: 1, + kind: 'interrupted', + pendingNodeIds: ['n2'], + recovery, + }, + }); + await writeFile(metaPath, metadataBytes); + loadMetaMock.mockImplementation(async () => { + expect(existsSync(lockPath)).toBe(true); + return JSON.parse(await readFile(metaPath, 'utf8')); + }); + resolveEmbeddingRuntimeMock.mockReturnValue(null); + + await expect(run()).rejects.toThrow( + /staged embeddings.*Run `gitnexus analyze` to recover them first/, + ); + + expect(acquireIndexLockMock).toHaveBeenCalledWith(dir, { sweep: false }); + expect(loadMetaMock.mock.invocationCallOrder[0]).toBeGreaterThan( + acquireIndexLockMock.mock.invocationCallOrder[0]!, + ); + expect(ensurePrivateSharedGraphMock).not.toHaveBeenCalled(); + expect(resolveEmbeddingIdentityMock).not.toHaveBeenCalled(); + expect(installEmbeddingRuntimeMock).not.toHaveBeenCalled(); + expect(initLbugMock).not.toHaveBeenCalled(); + expect(runEmbeddingPipelineMock).not.toHaveBeenCalled(); + expect(saveMetaMock).not.toHaveBeenCalled(); + expect(releaseMock).toHaveBeenCalledTimes(1); + expect(existsSync(lockPath)).toBe(false); + expect(await readFile(metaPath, 'utf8')).toBe(metadataBytes); + expect(await readFile(lbugPath, 'utf8')).toBe('db'); + expect(await readFile(sourcePath, 'utf8')).toBe('completed paid vectors'); + expect(await readFile(`${sourcePath}.wal`, 'utf8')).toBe('unfinished window'); + }); + + it('sweeps orphaned staging files before writable sync work without a recovery receipt', async () => { + const { dir, metaPath } = await store(); + await useRealIndexLock(); + await writeFile(metaPath, JSON.stringify(BASE_META)); + const sourcePath = path.join(dir, 'lbug.staging.12345678-1234-4123-8123-123456789abc'); + await writeFile(sourcePath, 'orphaned database'); + await writeFile(`${sourcePath}.wal`, 'orphaned WAL'); + loadMetaMock.mockImplementation(async () => JSON.parse(await readFile(metaPath, 'utf8'))); + ensurePrivateSharedGraphMock.mockImplementation(async () => { + expect(existsSync(path.join(dir, 'analyze.lock'))).toBe(true); + expect(existsSync(sourcePath)).toBe(false); + expect(existsSync(`${sourcePath}.wal`)).toBe(false); + return true; + }); + + await run(); + + expect(ensurePrivateSharedGraphMock).toHaveBeenCalledTimes(1); + expect(runEmbeddingPipelineMock).toHaveBeenCalledTimes(1); + expect(releaseMock).toHaveBeenCalledTimes(1); + expect(existsSync(path.join(dir, 'analyze.lock'))).toBe(false); + }); + it('persists an interrupted checkpoint from the pipeline checkpoint callbacks', async () => { // The resume contract lives in these callbacks; a mock that never invokes // them leaves the whole save path unexecuted. diff --git a/gitnexus/test/unit/index-lock.test.ts b/gitnexus/test/unit/index-lock.test.ts index 1a6846cc0..120e09727 100644 --- a/gitnexus/test/unit/index-lock.test.ts +++ b/gitnexus/test/unit/index-lock.test.ts @@ -17,6 +17,7 @@ import { existsSync, chmodSync, symlinkSync, + mkdirSync, } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -178,6 +179,109 @@ describe('release', () => { }); describe('sweepStagingArtifacts', () => { + const recoveryStage = 'lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46'; + const seedRecovery = (stagingFile = recoveryStage): void => { + writeFileSync( + path.join(dir, 'gitnexus.json'), + JSON.stringify({ + embeddingCheckpoint: { + kind: 'interrupted', + at: '2026-10-03T12:00:00.000Z', + nodesProcessed: 1, + totalNodes: 2, + chunksProcessed: 1, + model: 'test-model', + dimensions: 2, + provider: 'local', + pendingNodeIds: ['node-2'], + recovery: { + stagingFile, + schemaFingerprint: 'schema-v1', + unsafeNodeIds: ['node-2'], + }, + }, + }), + ); + }; + + it('retains the checkpoint-referenced family while reclaiming unrelated orphans', () => { + const family = [ + '', + '.wal', + '.shadow', + '.wal.checkpoint', + '.lock', + '.checkpoint.intent.lock', + '.checkpoint.apply.lock', + ].map((suffix) => recoveryStage + suffix); + for (const name of [...family, `${recoveryStage}.unexpected`, 'lbug.staging.orphan']) { + writeFileSync(path.join(dir, name), 'x'); + } + seedRecovery(); + + sweepStagingArtifacts(dir); + + for (const name of family) expect(existsSync(path.join(dir, name))).toBe(true); + expect(existsSync(path.join(dir, `${recoveryStage}.unexpected`))).toBe(false); + expect(existsSync(path.join(dir, 'lbug.staging.orphan'))).toBe(false); + }); + + it('preserves a referenced generation when a shared caller acquires the lock', async () => { + writeFileSync(path.join(dir, recoveryStage), 'x'); + seedRecovery(); + const lock = await acquireIndexLock(dir); + try { + expect(existsSync(path.join(dir, recoveryStage))).toBe(true); + } finally { + lock.release(); + } + }); + + it('retains only the referenced family in a branch sub-slot', async () => { + const branchDir = path.join(dir, 'branches', 'feature'); + mkdirSync(branchDir, { recursive: true }); + seedRecovery(); + const metadata = JSON.parse(readFileSync(path.join(dir, 'gitnexus.json'), 'utf8')); + writeFileSync( + path.join(branchDir, 'gitnexus.json'), + JSON.stringify({ ...metadata, storagePath: dir }), + ); + writeFileSync(path.join(branchDir, recoveryStage), 'stage'); + writeFileSync(path.join(branchDir, 'lbug.staging.orphan'), 'orphan'); + writeFileSync(path.join(dir, 'lbug.staging.orphan'), 'other-slot'); + + const lock = await acquireIndexLock(branchDir); + try { + expect(existsSync(path.join(branchDir, recoveryStage))).toBe(true); + expect(existsSync(path.join(branchDir, 'lbug.staging.orphan'))).toBe(false); + expect(existsSync(path.join(dir, 'lbug.staging.orphan'))).toBe(true); + } finally { + lock.release(); + } + }); + + it('rejects a symlinked reference and never follows it while reclaiming the stage', () => { + const external = path.join(dir, 'foreign-database'); + writeFileSync(external, 'external'); + symlinkSync(external, path.join(dir, recoveryStage)); + seedRecovery(); + + sweepStagingArtifacts(dir); + + expect(existsSync(path.join(dir, recoveryStage))).toBe(false); + expect(readFileSync(external, 'utf8')).toBe('external'); + }); + + it('does not sweep any generation when acquisition explicitly defers cleanup', async () => { + writeFileSync(path.join(dir, 'lbug.staging.orphan'), 'x'); + const lock = await acquireIndexLock(dir, { sweep: false }); + try { + expect(existsSync(path.join(dir, 'lbug.staging.orphan'))).toBe(true); + } finally { + lock.release(); + } + }); + it('removes only staging files, never the live index or its sidecars', () => { const files = [ 'lbug', diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts index b6de84c6b..b60cd60f1 100644 --- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts @@ -1,13 +1,18 @@ import { execSync } from 'child_process'; import fs from 'fs/promises'; -import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { basename } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { getStoragePaths, loadMeta, saveMeta, type RepoMeta, } from '../../src/storage/repo-manager.js'; -import { EMBEDDING_DIMS, STALE_HASH_SENTINEL } from '../../src/core/lbug/schema.js'; +import { + EMBEDDING_DIMS, + STALE_HASH_SENTINEL, + SCHEMA_FINGERPRINT, +} from '../../src/core/lbug/schema.js'; import { getIndexIncompleteReasons } from '../../src/core/index-freshness.js'; import type { EmbeddingPipelineOptions, @@ -2382,12 +2387,20 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => { incrementalInProgress: { phase: 'full-rebuild' }, stats: { embeddings: 7 }, }); + expect(snapshots.postWindow?.embeddingCheckpoint?.recovery).toMatchObject({ + stagingFile: expect.stringMatching(/^lbug\.staging\.[a-f0-9-]+$/), + schemaFingerprint: SCHEMA_FINGERPRINT, + unsafeNodeIds: [], + }); // ── Window 2: the published count remains unchanged ──────────────── expect(snapshots.secondWindow).toMatchObject({ lastCommit: STALE_COMMIT, stats: { embeddings: 7 }, - embeddingCheckpoint: { pendingNodeIds: ['node-3', 'node-4'] }, + embeddingCheckpoint: { + pendingNodeIds: ['node-3', 'node-4'], + recovery: { unsafeNodeIds: ['node-3', 'node-4'] }, + }, }); // Only the finalize write — after the index is published — advances @@ -2427,6 +2440,14 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => { * NEXT run does with it). */ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => { + const actualPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + + beforeEach(() => { + // These mocked checkpoint tests exercise the atomic rebuild used on POSIX. + // Select that same path on Windows; the in-place case below opts out. + vi.stubEnv('GITNEXUS_ATOMIC_WINDOWS_SWAP', '1'); + }); + const RESILIENCE_NODE_ID = 'Function:src/app.ts:handler:1'; const stubNode = { id: RESILIENCE_NODE_ID, @@ -2461,7 +2482,7 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => const mockResilienceHarness = ( controls: ResilienceControls, - ): { runEmbeddingPipeline: Mock; loadCachedEmbeddings: Mock } => { + ): { runEmbeddingPipeline: Mock; loadCachedEmbeddings: Mock; batchInsertEmbeddings: Mock } => { const loadCachedEmbeddings = vi.fn(async () => ({ embeddingNodeIds: new Set(), embeddings: [], @@ -2530,11 +2551,13 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => pipelineOptions: EmbeddingPipelineOptions, ): Promise => controls.pipeline(pipelineOptions), ); + const batchInsertEmbeddings = vi.fn(async () => undefined); vi.doMock('../../src/core/embeddings/embedding-pipeline.js', () => ({ runEmbeddingPipeline, + batchInsertEmbeddings, buildVectorIndex: vi.fn(async () => false), })); - return { runEmbeddingPipeline, loadCachedEmbeddings }; + return { runEmbeddingPipeline, loadCachedEmbeddings, batchInsertEmbeddings }; }; /** A checkpoint shaped exactly as `RepoMeta` declares it. */ @@ -2583,12 +2606,17 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => }; afterEach(() => { + if (actualPlatformDescriptor) { + Object.defineProperty(process, 'platform', actualPlatformDescriptor); + } vi.doUnmock('../../src/core/lbug/lbug-adapter.js'); vi.doUnmock('../../src/core/search/fts-indexes.js'); vi.doUnmock('../../src/core/ingestion/pipeline.js'); vi.doUnmock('../../src/storage/repo-manager.js'); vi.doUnmock('../../src/core/embeddings/embedding-identity.js'); vi.doUnmock('../../src/core/embeddings/embedding-pipeline.js'); + vi.doUnmock('../../src/core/embeddings/staged-embedding-recovery.js'); + vi.restoreAllMocks(); vi.resetModules(); vi.clearAllMocks(); vi.unstubAllEnvs(); @@ -3040,4 +3068,625 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => await tmpRepo.cleanup(); } }); + + const mockStagedFiles = async (): Promise => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.initLbug).mockImplementation(async (dbPath) => { + if (dbPath.includes('.staging.')) await fs.writeFile(dbPath, 'staged fixture'); + }); + vi.mocked(adapter.wipeLbugDbFiles).mockImplementation(async (dbPath) => { + await fs.rm(dbPath, { force: true }); + }); + }; + + it.each([ + { mode: 'staged', atomicSwap: '1', checkpointCount: 7 }, + { mode: 'in-place', atomicSwap: '0', checkpointCount: 42 }, + ])( + 'keeps Windows $mode checkpoint counts consistent with the live index', + async ({ mode, atomicSwap, checkpointCount }) => { + const tmpRepo = await createTempDir('gitnexus-2790-checkpoint-meta-'); + try { + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { nodes: 2, embeddings: 7 } }); + await fs.writeFile(lbugPath, 'published fixture'); + const snapshots: Array = []; + mockResilienceHarness({ + count: [{ cnt: 42 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: [RESILIENCE_NODE_ID], + }); + snapshots.push(await loadMeta(storagePath)); + await options.onCheckpoint?.({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 3 }); + snapshots.push(await loadMeta(storagePath)); + return cleanResult(); + }, + }); + await mockStagedFiles(); + // Load modules on the actual host before changing only the platform + // branch exercised by runFullAnalysis; all native DB work is mocked. + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + vi.stubEnv('GITNEXUS_ATOMIC_WINDOWS_SWAP', atomicSwap); + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + await runFullAnalysis( + tmpRepo.dbPath, + { force: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {}, onLog: () => {} }, + ); + + expect(snapshots[0]?.stats?.embeddings).toBe(7); + expect(snapshots[1]?.stats?.embeddings).toBe(checkpointCount); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const buildPath = vi.mocked(adapter.initLbug).mock.calls.at(-1)?.[0]; + if (mode === 'staged') { + expect(buildPath).toMatch(/\.staging\.[a-f0-9-]+$/); + expect(snapshots[1]?.embeddingCheckpoint?.recovery).toMatchObject({ + stagingFile: expect.stringMatching(/^lbug\.staging\.[a-f0-9-]+$/), + unsafeNodeIds: [], + }); + expect(await fs.readFile(lbugPath, 'utf8')).toBe('staged fixture'); + } else { + expect(buildPath).toBe(lbugPath); + expect(snapshots[0]?.embeddingCheckpoint?.recovery).toBeUndefined(); + expect(snapshots[1]?.embeddingCheckpoint?.recovery).toBeUndefined(); + } + const finalMeta = await loadMeta(storagePath); + expect(finalMeta?.stats?.embeddings).toBe(42); + expect(finalMeta?.embeddingCheckpoint).toBeUndefined(); + } finally { + if (actualPlatformDescriptor) { + Object.defineProperty(process, 'platform', actualPlatformDescriptor); + } + await tmpRepo.cleanup(); + } + }, + ); + + it.each(['close', 'rename'] as const)( + 'keeps the published count and database when %s fails before the staged publish', + async (failure) => { + const tmpRepo = await createTempDir('gitnexus-2790-checkpoint-meta-'); + try { + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { nodes: 2, embeddings: 7 } }); + await fs.writeFile(lbugPath, 'published fixture'); + const rename = fs.rename.bind(fs); + let checkpointMeta: RepoMeta | null = null; + let failedPublishRename: Mock | undefined; + mockResilienceHarness({ + count: [{ cnt: 42 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: [RESILIENCE_NODE_ID], + }); + await options.onCheckpoint?.({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 3 }); + checkpointMeta = await loadMeta(storagePath); + if (failure === 'close') { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.closeLbug).mockRejectedValueOnce( + new Error('pre-publish close failed'), + ); + } else { + failedPublishRename = vi.fn(async () => { + throw Object.assign(new Error('staged publish rename failed'), { code: 'EIO' }); + }); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).startsWith(`${lbugPath}.staging.`) && + String(destination) === lbugPath + ) { + return failedPublishRename?.(); + } + return rename(source, destination); + }); + } + return cleanResult(); + }, + }); + await mockStagedFiles(); + expect( + await runAnalyze( + tmpRepo.dbPath, + { force: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, + [], + ), + ).toMatchObject({ + message: + failure === 'close' ? 'pre-publish close failed' : 'staged publish rename failed', + }); + expect(checkpointMeta?.stats?.embeddings).toBe(7); + expect((await loadMeta(storagePath))?.stats?.embeddings).toBe(7); + expect(await fs.readFile(lbugPath, 'utf8')).toBe('published fixture'); + const recovery = (await loadMeta(storagePath))?.embeddingCheckpoint?.recovery; + if (!recovery) throw new Error('expected durable stage after failed publish'); + expect(await fs.readFile(`${storagePath}/${recovery.stagingFile}`, 'utf8')).toBe( + 'staged fixture', + ); + if (failure === 'rename') expect(failedPublishRename).toHaveBeenCalledTimes(1); + } finally { + vi.restoreAllMocks(); + await tmpRepo.cleanup(); + } + }, + ); + + const seedRecovery = async (storagePath: string, repoPath: string) => { + const stagingFile = 'lbug.staging.11111111-1111-4111-8111-111111111111'; + const checkpoint = checkpointFixture({ + kind: 'interrupted', + pendingNodeIds: [], + recovery: { stagingFile, schemaFingerprint: SCHEMA_FINGERPRINT, unsafeNodeIds: [] }, + }); + await seedMeta(storagePath, repoPath, { + stats: { nodes: 2, embeddings: 7 }, + embeddingCheckpoint: checkpoint, + }); + await fs.writeFile(`${storagePath}/${stagingFile}`, 'previous durable source'); + vi.doMock('../../src/core/embeddings/staged-embedding-recovery.js', () => ({ + recoverStagedEmbeddings: vi.fn(async () => ({ rows: [], embeddingNodeIds: new Set() })), + })); + return { checkpoint, stagingFile }; + }; + + it.each( + ['1', '0'].flatMap((manualCheckpoint) => + ( + ['window-start crash', 'post-window crash', 'final-metadata failure', 'success'] as const + ).map((outcome) => ({ manualCheckpoint, outcome })), + ), + )( + 'preserves the in-place recovery receipt with manual checkpoints=$manualCheckpoint through $outcome', + async ({ manualCheckpoint, outcome }) => { + vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', manualCheckpoint); + vi.stubEnv('GITNEXUS_INDEX_LOCK_BACKEND', 'file'); + const tmpRepo = await createTempDir('gitnexus-3456-opt-out-source-'); + try { + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + const { checkpoint, stagingFile } = await seedRecovery(storagePath, tmpRepo.dbPath); + const original = { + ...checkpoint, + pendingNodeIds: ['original-pending'], + recovery: { + stagingFile, + schemaFingerprint: SCHEMA_FINGERPRINT, + unsafeNodeIds: ['original-pending', 'original-unsafe'], + }, + } satisfies NonNullable; + await seedMeta(storagePath, tmpRepo.dbPath, { + stats: { nodes: 2, embeddings: 7 }, + embeddingCheckpoint: original, + }); + const sourceFiles = [ + { filename: stagingFile, contents: 'previous durable source' }, + { filename: `${stagingFile}.wal`, contents: 'previous durable WAL' }, + { filename: `${stagingFile}.shadow`, contents: 'previous durable shadow' }, + ]; + for (const source of sourceFiles) { + await fs.writeFile(`${storagePath}/${source.filename}`, source.contents); + } + await fs.writeFile(lbugPath, 'previous published index'); + const { normalizeCachedEmbeddings } = + await import('../../src/core/embeddings/embedding-restore-spill.js'); + vi.doMock( + '../../src/core/embeddings/staged-embedding-recovery.js', + async (importActual) => ({ + ...(await importActual< + typeof import('../../src/core/embeddings/staged-embedding-recovery.js') + >()), + recoverStagedEmbeddings: vi.fn(async () => + normalizeCachedEmbeddings({ + embeddings: [ + { + nodeId: RESILIENCE_NODE_ID, + chunkIndex: 0, + startLine: 1, + endLine: 2, + contentHash: 'current-hash', + embedding: new Array(EMBEDDING_DIMS).fill(0), + }, + ], + }), + ), + }), + ); + const snapshots: Array = []; + const rename = fs.rename.bind(fs); + const { batchInsertEmbeddings } = mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['current-window'], + }); + snapshots.push(await loadMeta(storagePath)); + if (outcome === 'window-start crash') throw new Error(outcome); + await options.onCheckpoint?.({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 9 }); + snapshots.push(await loadMeta(storagePath)); + if (outcome === 'post-window crash') throw new Error(outcome); + if (outcome === 'final-metadata failure') { + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if (basename(String(destination)) === 'gitnexus.json') throw new Error(outcome); + return rename(source, destination); + }); + } + return cleanResult(); + }, + }); + await mockStagedFiles(); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.loadGraphToLbug).mockImplementation(async () => { + await fs.writeFile(lbugPath, 'in-place replacement'); + }); + // Import on the host first, then choose the Windows default in-place + // branch. The native adapter is mocked; source files and lock cleanup are real. + await import('../../src/core/run-analyze.js'); + vi.stubEnv('GITNEXUS_ATOMIC_WINDOWS_SWAP', '0'); + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + const error = await runAnalyze( + tmpRepo.dbPath, + { force: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, + [], + ); + if (actualPlatformDescriptor) { + Object.defineProperty(process, 'platform', actualPlatformDescriptor); + } + expect(batchInsertEmbeddings).toHaveBeenCalled(); + expect(vi.mocked(adapter.initLbug).mock.calls.at(-1)?.[0]).toBe(lbugPath); + const finalMeta = await loadMeta(storagePath); + // Reacquiring the real lock performs the next retry's orphan sweep. + // A lost receipt would delete every byte of the proven old generation here. + const { acquireIndexLock } = await import('../../src/storage/index-lock.js'); + const lock = await acquireIndexLock(storagePath, { timeoutMs: 1000 }); + try { + if (outcome === 'success') { + expect(error).toBeNull(); + expect(finalMeta?.embeddingCheckpoint).toBeUndefined(); + expect(finalMeta?.stats?.embeddings).toBe(9); + expect(await fs.readFile(lbugPath, 'utf8')).toBe('in-place replacement'); + for (const source of sourceFiles) { + await expect(fs.stat(`${storagePath}/${source.filename}`)).rejects.toMatchObject({ + code: 'ENOENT', + }); + } + } else { + expect(error).toMatchObject({ message: outcome }); + for (const source of sourceFiles) { + expect(await fs.readFile(`${storagePath}/${source.filename}`, 'utf8')).toBe( + source.contents, + ); + } + expect(finalMeta?.embeddingCheckpoint).toEqual(original); + expect(finalMeta?.stats?.embeddings).toBe(outcome === 'window-start crash' ? 7 : 9); + } + expect(snapshots[0]?.embeddingCheckpoint).toEqual(original); + expect(snapshots[0]?.stats?.embeddings).toBe(7); + if (outcome !== 'window-start crash') { + expect(snapshots[1]?.embeddingCheckpoint).toEqual(original); + expect(snapshots[1]?.stats?.embeddings).toBe(9); + } + } finally { + lock.release(); + } + } finally { + if (actualPlatformDescriptor) { + Object.defineProperty(process, 'platform', actualPlatformDescriptor); + } + vi.restoreAllMocks(); + await tmpRepo.cleanup(); + } + }, + ); + + it('finishes a staged embedding run with manual checkpoints disabled', async () => { + vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '0'); + const tmpRepo = await createTempDir('gitnexus-3456-checkpoint-opt-out-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { embeddings: 7 } }); + mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['active-node'], + }); + expect((await loadMeta(storagePath))?.embeddingCheckpoint?.recovery).toBeUndefined(); + await options.onCheckpoint?.({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 9 }); + const midRun = await loadMeta(storagePath); + expect(midRun?.embeddingCheckpoint?.recovery).toBeUndefined(); + expect(midRun?.stats?.embeddings).toBe(7); + return cleanResult(); + }, + }); + await mockStagedFiles(); + expect(await runAnalyze(tmpRepo.dbPath, { force: true, embeddings: true }, [])).toBeNull(); + expect((await loadMeta(storagePath))?.embeddingCheckpoint).toBeUndefined(); + expect((await loadMeta(storagePath))?.stats?.embeddings).toBe(9); + expect(await fs.readFile(getStoragePaths(tmpRepo.dbPath).lbugPath, 'utf8')).toBe( + 'staged fixture', + ); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('keeps the previous durable source when manual checkpoints are disabled', async () => { + vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '0'); + const tmpRepo = await createTempDir('gitnexus-3456-opt-out-source-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + const { checkpoint: original, stagingFile } = await seedRecovery(storagePath, tmpRepo.dbPath); + mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['active-node'], + }); + await options.onCheckpoint?.({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 9 }); + expect((await loadMeta(storagePath))?.embeddingCheckpoint).toEqual(original); + throw new Error('endpoint failure with manual checkpoints disabled'); + }, + }); + await mockStagedFiles(); + expect(await runAnalyze(tmpRepo.dbPath, { force: true, embeddings: true }, [])).toMatchObject( + { message: 'endpoint failure with manual checkpoints disabled' }, + ); + expect((await loadMeta(storagePath))?.embeddingCheckpoint).toEqual(original); + expect(await fs.readFile(`${storagePath}/${stagingFile}`, 'utf8')).toBe( + 'previous durable source', + ); + expect( + (await fs.readdir(storagePath)).filter((name) => name.startsWith('lbug.staging.')), + ).toEqual([stagingFile]); + } finally { + await tmpRepo.cleanup(); + } + }); + + it.skipIf(process.platform === 'win32')( + 'retains the referenced stage through a symlinked storage directory', + async () => { + const tmpRepo = await createTempDir('gitnexus-3456-storage-alias-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + const actualStorage = `${tmpRepo.dbPath}/actual-index`; + await fs.mkdir(actualStorage); + await fs.symlink(actualStorage, storagePath, 'dir'); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { embeddings: 7 } }); + mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['active-node'], + }); + await options.onCheckpoint?.({ nodesProcessed: 1, totalNodes: 3, chunksProcessed: 9 }); + throw new Error('endpoint failed after durable window'); + }, + }); + await mockStagedFiles(); + expect( + await runAnalyze(tmpRepo.dbPath, { force: true, embeddings: true }, []), + ).toMatchObject({ message: 'endpoint failed after durable window' }); + const recovery = (await loadMeta(storagePath))?.embeddingCheckpoint?.recovery; + if (!recovery) throw new Error('expected retained recovery generation'); + expect(await fs.readFile(`${actualStorage}/${recovery.stagingFile}`, 'utf8')).toBe( + 'staged fixture', + ); + expect((await loadMeta(storagePath))?.stats?.embeddings).toBe(7); + } finally { + await tmpRepo.cleanup(); + } + }, + ); + + it.each(['checkpoint', 'metadata'] as const)( + 'keeps the previous source when %s fails before recovery handoff', + async (failure) => { + const tmpRepo = await createTempDir('gitnexus-3456-handoff-failure-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + const { checkpoint: original, stagingFile } = await seedRecovery( + storagePath, + tmpRepo.dbPath, + ); + const rename = fs.rename.bind(fs); + mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + if (failure === 'metadata') { + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if (basename(String(destination)) === 'gitnexus.json') + throw new Error('metadata write failed'); + return rename(source, destination); + }); + } else { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.tryFlushWAL).mockResolvedValue(false); + } + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['active-node'], + }); + return cleanResult(); + }, + }); + await mockStagedFiles(); + const error = await runAnalyze(tmpRepo.dbPath, { force: true, embeddings: true }, []); + expect(error).toMatchObject({ + message: + failure === 'metadata' + ? 'metadata write failed' + : 'Could not checkpoint restored embeddings before recovery handoff.', + }); + expect((await loadMeta(storagePath))?.embeddingCheckpoint).toEqual(original); + expect((await loadMeta(storagePath))?.stats?.embeddings).toBe(7); + expect(await fs.readFile(`${storagePath}/${stagingFile}`, 'utf8')).toBe( + 'previous durable source', + ); + expect( + (await fs.readdir(storagePath)).filter((name) => name.startsWith('lbug.staging.')), + ).toEqual([stagingFile]); + } finally { + vi.restoreAllMocks(); + await tmpRepo.cleanup(); + } + }, + ); + + it('keeps an active window unsafe when its completion checkpoint fails', async () => { + const tmpRepo = await createTempDir('gitnexus-3456-completion-failure-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { embeddings: 7 } }); + mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['active-node'], + }); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.tryFlushWAL).mockResolvedValue(false); + await options.onCheckpoint?.({ nodesProcessed: 1, totalNodes: 3, chunksProcessed: 9 }); + return cleanResult(); + }, + }); + await mockStagedFiles(); + expect(await runAnalyze(tmpRepo.dbPath, { force: true, embeddings: true }, [])).toMatchObject( + { message: 'Could not checkpoint the completed embedding window for recovery.' }, + ); + const meta = await loadMeta(storagePath); + expect(meta?.stats?.embeddings).toBe(7); + expect(meta?.embeddingCheckpoint?.pendingNodeIds).toEqual(['active-node']); + expect(meta?.embeddingCheckpoint?.recovery?.unsafeNodeIds).toEqual(['active-node']); + if (!meta?.embeddingCheckpoint?.recovery) throw new Error('expected retained active window'); + expect( + await fs.readFile( + `${storagePath}/${meta.embeddingCheckpoint.recovery.stagingFile}`, + 'utf8', + ), + ).toBe('staged fixture'); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('retains a failed stage and keeps future incomplete restore groups unsafe', async () => { + const tmpRepo = await createTempDir('gitnexus-3456-unsafe-restore-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { nodes: 2, embeddings: 1 } }); + let completedWindow: RepoMeta | null = null; + const { loadCachedEmbeddings, batchInsertEmbeddings } = mockResilienceHarness({ + count: [{ cnt: 5 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 2, + chunksProcessed: 0, + nodeIds: ['earlier-window-node'], + }); + await options.onCheckpoint?.({ nodesProcessed: 1, totalNodes: 2, chunksProcessed: 1 }); + completedWindow = await loadMeta(storagePath); + throw new Error('Maximum database size exceeded'); + }, + }); + loadCachedEmbeddings.mockResolvedValue({ + embeddingNodeIds: new Set([RESILIENCE_NODE_ID]), + embeddings: [ + { + nodeId: RESILIENCE_NODE_ID, + chunkIndex: 0, + startLine: 1, + endLine: 2, + contentHash: 'current-hash', + embedding: new Array(EMBEDDING_DIMS).fill(0), + }, + ], + }); + batchInsertEmbeddings.mockRejectedValue(new Error('restore batch partially inserted')); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.initLbug).mockImplementation(async (dbPath) => { + if (dbPath.includes('.staging.')) await fs.writeFile(dbPath, 'staged fixture'); + }); + vi.mocked(adapter.wipeLbugDbFiles).mockImplementation(async (dbPath) => { + await fs.rm(dbPath, { force: true }); + }); + const logs: string[] = []; + expect( + await runAnalyze( + tmpRepo.dbPath, + { embeddings: true, force: true, skipAgentsMd: true, skipSkills: true }, + logs, + ), + ).toMatchObject({ message: 'Maximum database size exceeded' }); + expect(completedWindow?.embeddingCheckpoint?.recovery?.unsafeNodeIds).toEqual([ + RESILIENCE_NODE_ID, + ]); + expect(completedWindow?.stats?.embeddings).toBe(1); + const recovery = (await loadMeta(storagePath))?.embeddingCheckpoint?.recovery; + if (!recovery) throw new Error('expected retained recovery generation'); + expect(await fs.readFile(`${storagePath}/${recovery.stagingFile}`, 'utf8')).toBe( + 'staged fixture', + ); + expect(logs).toContainEqual(expect.stringContaining('GITNEXUS_LBUG_MAX_DB_SIZE')); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('reclaims a failed current stage before any recovery checkpoint exists', async () => { + const tmpRepo = await createTempDir('gitnexus-3456-no-checkpoint-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, {}); + mockResilienceHarness({ + count: [{ cnt: 0 }], + pipeline: async () => { + throw new Error('failed before first window'); + }, + }); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.initLbug).mockImplementation(async (dbPath) => { + if (dbPath.includes('.staging.')) await fs.writeFile(dbPath, 'staged fixture'); + }); + vi.mocked(adapter.wipeLbugDbFiles).mockImplementation(async (dbPath) => { + await fs.rm(dbPath, { force: true }); + }); + expect( + await runAnalyze( + tmpRepo.dbPath, + { embeddings: true, force: true, skipAgentsMd: true, skipSkills: true }, + [], + ), + ).toMatchObject({ message: 'failed before first window' }); + expect( + (await fs.readdir(storagePath)).filter((name) => name.startsWith('lbug.staging.')), + ).toEqual([]); + expect((await loadMeta(storagePath))?.embeddingCheckpoint).toBeUndefined(); + } finally { + await tmpRepo.cleanup(); + } + }); }); diff --git a/gitnexus/test/unit/staged-embedding-recovery-child.test.ts b/gitnexus/test/unit/staged-embedding-recovery-child.test.ts new file mode 100644 index 000000000..58711493b --- /dev/null +++ b/gitnexus/test/unit/staged-embedding-recovery-child.test.ts @@ -0,0 +1,300 @@ +import fs from 'node:fs'; +import { EventEmitter } from 'node:events'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ + dbCtor: vi.fn(), + connCtor: vi.fn(), + dbClose: vi.fn<() => Promise>(), + connClose: vi.fn<() => Promise>(), + query: vi.fn(), + abortBuilder: vi.fn(), + spawn: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ spawn: h.spawn })); + +vi.mock('@ladybugdb/core', () => { + class Database { + constructor(...args: unknown[]) { + h.dbCtor(...args); + } + close = h.dbClose; + } + class Connection { + constructor(db: unknown) { + h.connCtor(db); + } + query = h.query; + close = h.connClose; + } + return { default: { Database, Connection } }; +}); + +vi.mock('../../src/core/embeddings/embedding-restore-spill.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + abortCachedEmbeddingsBuilder: ( + ...args: Parameters + ) => { + h.abortBuilder(...args); + return actual.abortCachedEmbeddingsBuilder(...args); + }, + }; +}); + +describe('staged embedding recovery child native lifecycle', () => { + const suffixes = [ + '', + '.wal', + '.shadow', + '.wal.checkpoint', + '.lock', + '.checkpoint.intent.lock', + '.checkpoint.apply.lock', + ]; + let tmp: string; + let dbPath: string; + let exportDir: string; + let originalArgv: string[]; + let originalExitCode: typeof process.exitCode; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + h.dbCtor.mockReset(); + h.connCtor.mockReset(); + h.dbClose.mockReset().mockResolvedValue(undefined); + h.connClose.mockReset().mockResolvedValue(undefined); + h.query.mockReset().mockResolvedValue({ + hasNext: vi.fn().mockResolvedValue(false), + getNext: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + }); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-recovery-child-')); + dbPath = path.join(tmp, 'lbug.stage-test'); + exportDir = path.join(tmp, 'export'); + fs.writeFileSync(dbPath, 'mock native database'); + fs.writeFileSync(`${dbPath}.wal`, 'retained WAL'); + fs.mkdirSync(exportDir); + originalArgv = process.argv; + originalExitCode = process.exitCode; + process.argv = [process.execPath, 'staged-embedding-recovery-child', dbPath, exportDir, '2']; + process.exitCode = undefined; + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + vi.useRealTimers(); + process.argv = originalArgv; + process.exitCode = originalExitCode; + vi.restoreAllMocks(); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + function sourceFamily() { + return Object.fromEntries( + suffixes.map((suffix) => [suffix, fs.readFileSync(dbPath + suffix, 'utf8')]), + ); + } + + function seedCompleteFamily() { + for (const suffix of suffixes) fs.writeFileSync(dbPath + suffix, `retained ${suffix}`); + return sourceFamily(); + } + + async function runRejectedChild(message: string): Promise { + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => { + expect(process.exitCode).toBe(1); + expect(process.stderr.write).toHaveBeenCalledWith(`${message}\n`); + }); + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + expect(fs.readFileSync(`${dbPath}.wal`, 'utf8')).toBe('retained WAL'); + } + + it('closes the opened database and aborts the builder when Connection construction fails', async () => { + h.connCtor.mockImplementation(() => { + throw new Error('connection constructor failed'); + }); + + await runRejectedChild('connection constructor failed'); + + expect(h.dbClose).toHaveBeenCalledOnce(); + expect(h.connClose).not.toHaveBeenCalled(); + expect(h.abortBuilder).toHaveBeenCalledOnce(); + expect(h.query).not.toHaveBeenCalled(); + expect(h.dbCtor.mock.calls[0][7]).toBe(true); + }); + + it('aborts the builder when Database construction fails', async () => { + h.dbCtor.mockImplementation(() => { + throw new Error('database constructor failed'); + }); + + await runRejectedChild('database constructor failed'); + + expect(h.abortBuilder).toHaveBeenCalledOnce(); + expect(h.dbClose).not.toHaveBeenCalled(); + expect(h.connCtor).not.toHaveBeenCalled(); + }); + + it('rejects output and closes the database when Connection close fails', async () => { + h.connClose.mockRejectedValue(new Error('connection close failed')); + + await runRejectedChild('connection close failed'); + + expect(h.dbClose).toHaveBeenCalled(); + expect(h.abortBuilder).toHaveBeenCalledOnce(); + }); + + it('rejects output when Database close fails', async () => { + h.dbClose.mockRejectedValue(new Error('database close failed')); + + await runRejectedChild('database close failed'); + + expect(h.connClose).toHaveBeenCalled(); + expect(h.abortBuilder).toHaveBeenCalledOnce(); + }); + + it('confines writable replay and failed checkpoint close to a separate copied family', async () => { + const sourceBefore = seedCompleteFamily(); + h.dbCtor.mockImplementation((openedPath: string) => { + for (const suffix of suffixes) { + expect(fs.readFileSync(openedPath + suffix, 'utf8')).toBe(sourceBefore[suffix]); + fs.writeFileSync(openedPath + suffix, `replayed ${suffix}`); + } + }); + h.dbClose.mockImplementation(async () => { + const openedPath = h.dbCtor.mock.calls[0][0] as string; + fs.writeFileSync(openedPath, 'partial checkpoint'); + throw new Error('checkpoint close failed'); + }); + + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => expect(process.exitCode).toBe(1)); + + expect(h.dbCtor.mock.calls[0][0]).not.toBe(dbPath); + expect(path.relative(exportDir, h.dbCtor.mock.calls[0][0] as string)).not.toMatch(/^\.\./); + expect(sourceFamily()).toEqual(sourceBefore); + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + expect(process.stderr.write).toHaveBeenCalledWith('checkpoint close failed\n'); + }); + + it('reclaims a timed-out writer copy without changing the retained source', async () => { + const sourceBefore = seedCompleteFamily(); + h.query.mockReturnValue(new Promise(() => {})); + h.dbCtor.mockImplementation((openedPath: string) => { + for (const suffix of suffixes) fs.writeFileSync(openedPath + suffix, 'writer opened'); + }); + let childImport: Promise | undefined; + const child = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + kill: vi.fn(() => { + queueMicrotask(() => child.emit('close', null, 'SIGKILL')); + return true; + }), + }); + h.spawn.mockImplementation((_command: string, args: string[]) => { + process.argv = [process.execPath, 'staged-embedding-recovery-child', ...args.slice(-3)]; + childImport = import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + return child; + }); + const { recoverStagedEmbeddings } = + await import('../../src/core/embeddings/staged-embedding-recovery.js'); + vi.useFakeTimers(); + const recovering = expect( + recoverStagedEmbeddings(dbPath, { dimensions: 2, timeoutMs: 500 }), + ).rejects.toThrow(/timeout/); + await childImport; + expect(h.dbCtor).toHaveBeenCalledOnce(); + const openedPath = h.dbCtor.mock.calls[0][0] as string; + + await vi.advanceTimersByTimeAsync(500); + await recovering; + + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + expect(openedPath).not.toBe(dbPath); + expect(fs.existsSync(path.dirname(openedPath))).toBe(false); + expect(sourceFamily()).toEqual(sourceBefore); + expect(h.dbClose).not.toHaveBeenCalled(); + }); + + it.each(['.wal', '.checkpoint.intent.lock', '.checkpoint.apply.lock'])( + 'refuses a dangling family symlink before native open: %s', + async (suffix) => { + fs.rmSync(dbPath + suffix, { force: true }); + fs.symlinkSync(path.join(tmp, 'missing-sidecar'), dbPath + suffix); + + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => expect(process.exitCode).toBe(1)); + + expect(h.dbCtor).not.toHaveBeenCalled(); + expect(process.stderr.write).toHaveBeenCalledWith( + 'staged embedding family is not a regular file\n', + ); + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + }, + ); + + it('refuses a family entry replaced with a symlink between lstat and open', async () => { + const foreignPath = path.join(tmp, 'foreign-file'); + fs.writeFileSync(foreignPath, 'foreign'); + const open = fs.openSync; + const read = vi.spyOn(fs, 'readSync'); + vi.spyOn(fs, 'openSync').mockImplementation((...args) => { + if (args[0] === dbPath) { + fs.rmSync(dbPath); + fs.symlinkSync(foreignPath, dbPath); + } + return open(...args); + }); + + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => expect(process.exitCode).toBe(1)); + + expect(read).not.toHaveBeenCalled(); + expect(h.dbCtor).not.toHaveBeenCalled(); + expect(fs.readFileSync(foreignPath, 'utf8')).toBe('foreign'); + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + }); + + it('fails closed when copying the complete family runs out of space', async () => { + const sourceBefore = seedCompleteFamily(); + vi.spyOn(fs, 'writeFileSync').mockImplementation(() => { + throw Object.assign(new Error('copy ran out of space'), { code: 'ENOSPC' }); + }); + + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => expect(process.exitCode).toBe(1)); + + expect(h.dbCtor).not.toHaveBeenCalled(); + expect(sourceFamily()).toEqual(sourceBefore); + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + expect(process.stderr.write).toHaveBeenCalledWith('copy ran out of space\n'); + }); + + it('writes the manifest only after both native closes succeed', async () => { + const closed: string[] = []; + h.connClose.mockImplementation(async () => { + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + closed.push('connection'); + }); + h.dbClose.mockImplementation(async () => { + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + closed.push('database'); + }); + + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(true)); + + expect(closed).toEqual(['connection', 'database']); + expect(h.abortBuilder).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + expect(fs.readFileSync(`${dbPath}.wal`, 'utf8')).toBe('retained WAL'); + }); +}); diff --git a/gitnexus/test/unit/staged-embedding-recovery.test.ts b/gitnexus/test/unit/staged-embedding-recovery.test.ts new file mode 100644 index 000000000..552e48dc5 --- /dev/null +++ b/gitnexus/test/unit/staged-embedding-recovery.test.ts @@ -0,0 +1,95 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + createCachedEmbeddingsBuilder, + disposeEmbeddingSpill, + finalizeCachedEmbeddingsSnapshot, + ingestCachedEmbeddingRow, + materializeCachedEmbeddings, + type CachedEmbeddingsSnapshot, +} from '../../src/core/embeddings/embedding-restore-spill.js'; +import { + mergeRecoveredEmbeddings, + validateRecoveredNodeGroups, +} from '../../src/core/embeddings/staged-embedding-recovery.js'; + +describe('staged embedding recovery', () => { + const snapshots: CachedEmbeddingsSnapshot[] = []; + let tmp: string | undefined; + afterEach(() => { + for (const snapshot of snapshots) disposeEmbeddingSpill(snapshot.spill); + snapshots.length = 0; + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); + tmp = undefined; + }); + + function snapshot( + rows: { nodeId: string; chunkIndex: number; contentHash?: string; embedding?: number[] }[], + ) { + tmp ??= fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-stage-test-')); + const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0, spillDir: tmp }); + for (const row of rows) { + ingestCachedEmbeddingRow( + builder, + { startLine: 1, endLine: 2, embedding: [1, 2], ...row }, + true, + ); + } + const result = finalizeCachedEmbeddingsSnapshot(builder); + snapshots.push(result); + return result; + } + + it('accepts only complete groups with one content hash and unique contiguous chunk ordinals', () => { + const cached = snapshot([ + { nodeId: 'complete', chunkIndex: 1, contentHash: 'same' }, + { nodeId: 'complete', chunkIndex: 0, contentHash: 'same' }, + { nodeId: 'gap', chunkIndex: 1, contentHash: 'same' }, + { nodeId: 'duplicate', chunkIndex: 0, contentHash: 'same' }, + { nodeId: 'duplicate', chunkIndex: 0, contentHash: 'same' }, + { nodeId: 'mixed', chunkIndex: 0, contentHash: 'old' }, + { nodeId: 'mixed', chunkIndex: 1, contentHash: 'new' }, + { nodeId: 'no-hash', chunkIndex: 0 }, + { nodeId: 'unsafe', chunkIndex: 0, contentHash: 'same' }, + ]); + expect([...validateRecoveredNodeGroups(cached.rows, new Set(['unsafe']))]).toEqual([ + 'complete', + ]); + }); + + it('replaces an entire published node group and keeps unrelated rows without retaining vector arrays', () => { + const live = snapshot([ + { nodeId: 'changed', chunkIndex: 0, contentHash: 'old' }, + { nodeId: 'changed', chunkIndex: 1, contentHash: 'old' }, + { nodeId: 'other', chunkIndex: 0, contentHash: 'other' }, + ]); + const recovered = snapshot([ + { nodeId: 'changed', chunkIndex: 0, contentHash: 'new', embedding: [7, 8] }, + ]); + const merged = mergeRecoveredEmbeddings(live, recovered); + snapshots.push(merged); + expect(merged.embeddings).toEqual([]); + expect(merged.rows).toHaveLength(2); + expect(materializeCachedEmbeddings(merged, merged.rows)).toEqual([ + expect.objectContaining({ nodeId: 'other', contentHash: 'other' }), + expect.objectContaining({ + nodeId: 'changed', + chunkIndex: 0, + contentHash: 'new', + embedding: [7, 8], + }), + ]); + expect(fs.existsSync(live.spill.path)).toBe(true); + expect(fs.existsSync(recovered.spill.path)).toBe(true); + }); + + it('does not accept missing vector bytes during a merge', () => { + const cached = snapshot([{ nodeId: 'complete', chunkIndex: 0, contentHash: 'same' }]); + fs.truncateSync(cached.spill.path, 12); + expect(() => mergeRecoveredEmbeddings(snapshot([]), cached)).toThrow( + /short embedding spill read/, + ); + }); +}); From 1a5d88391cf459911c2607523ce9786642053748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sun, 4 Oct 2026 10:52:01 +0100 Subject: [PATCH 02/13] fix(mcp): reject corrupt impact and context identities (#3466) --- gitnexus/src/mcp/local/aop-metadata.ts | 27 +- gitnexus/src/mcp/local/local-backend.ts | 222 ++++- gitnexus/src/mcp/local/pdg-impact.ts | 27 +- .../src/mcp/local/query-result-integrity.ts | 57 ++ .../impact-context-integrity.test.ts | 758 ++++++++++++++++ .../unit/calltool-dispatch-id-bridge.test.ts | 17 +- gitnexus/test/unit/calltool-dispatch.test.ts | 104 +-- .../unit/impact-batching-grouping.test.ts | 21 + .../unit/impact-context-integrity.test.ts | 847 ++++++++++++++++++ gitnexus/test/unit/impact-pagination.test.ts | 2 + .../test/unit/impact-route-enrichment.test.ts | 5 +- gitnexus/test/unit/pdg-impact-engine.test.ts | 83 ++ gitnexus/vitest.config.ts | 2 + 13 files changed, 2080 insertions(+), 92 deletions(-) create mode 100644 gitnexus/src/mcp/local/query-result-integrity.ts create mode 100644 gitnexus/test/integration/impact-context-integrity.test.ts create mode 100644 gitnexus/test/unit/impact-context-integrity.test.ts diff --git a/gitnexus/src/mcp/local/aop-metadata.ts b/gitnexus/src/mcp/local/aop-metadata.ts index a3506d9c5..d5d41ab02 100644 --- a/gitnexus/src/mcp/local/aop-metadata.ts +++ b/gitnexus/src/mcp/local/aop-metadata.ts @@ -1,4 +1,9 @@ import { executeParameterized } from '../../core/lbug/pool-adapter.js'; +import { + assertSymbolIdentity, + assertIdentityFields, + rethrowSymbolIdentityError, +} from './query-result-integrity.js'; import { decodeSpringAopReason, type SpringAopReason, @@ -151,6 +156,7 @@ const DETERMINISTIC_RELATIONSHIP_ORDER = 'ORDER BY sourceId, targetId, reason, s * shared decoder. Other DECLARES edges (for example Spring Bean factories) * and malformed/forward-version evidence are ignored. Query failures are * fail-soft because older or partially upgraded indexes must remain readable. + * Corrupt identities propagate to the context/impact integrity error boundary. */ export async function querySpringAopMetadata( lbugPath: string, @@ -204,6 +210,24 @@ export async function querySpringAopMetadata( ), ]); + for (const rows of [ + outgoingAdviceRows, + incomingAdviceRows, + outgoingPointcutRows, + incomingPointcutRows, + ]) { + for (const row of rows) { + assertSymbolIdentity(readRowValue(row, 'sourceId', 0)); + assertSymbolIdentity(readRowValue(row, 'targetId', 3)); + assertIdentityFields( + readRowValue(row, 'sourceName', 1), + readRowValue(row, 'sourceFilePath', 2), + readRowValue(row, 'targetName', 4), + readRowValue(row, 'targetFilePath', 5), + ); + } + } + const behaviors: SpringAopBehaviorMetadata[] = []; const advices: SpringAopAdviceMetadata[] = []; const resolvedPointcuts: SpringAopResolvedPointcutMetadata[] = []; @@ -346,7 +370,8 @@ export async function querySpringAopMetadata( resolvedPointcuts: dedupedResolvedPointcuts, unresolvedPointcuts: dedupedPointcuts, }; - } catch { + } catch (error) { + rethrowSymbolIdentityError(error); return undefined; } } diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index cb8546543..e5dc607f1 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -28,6 +28,15 @@ import { } from '../../core/lbug/pool-adapter.js'; import { queryClassBeanMetadata } from './bean-metadata.js'; import { querySpringAopMetadata } from './aop-metadata.js'; +import { + SYMBOL_IDENTITY_RECOVERY_SUGGESTION, + SymbolIdentityError, + assertSymbolIdentity, + queryRowValue, + assertIdentityFields, + assertQueryIdentity, + rethrowSymbolIdentityError, +} from './query-result-integrity.js'; import { queryConvexDispatchMetadata } from './convex-metadata.js'; import { isValidQueryParams } from '../../core/lbug/query-params.js'; import { toDisplayLine } from './line-display.js'; @@ -324,25 +333,67 @@ function nonBlankUid(value: unknown): string | undefined { return typeof value === 'string' ? value.trim() || undefined : undefined; } -const SYMBOL_IDENTITY_RECOVERY_SUGGESTION = - 'Run gitnexus analyze --force from the affected repository root to rebuild the index.'; +function assertSymbolRowIdentity(row: unknown): void { + assertQueryIdentity(row, 'id', 0, [ + ['name', 1], + ['type', 2], + ['filePath', 3], + ]); +} -class SymbolIdentityError extends Error { - constructor() { - super('The index returned an invalid symbol identity. ' + SYMBOL_IDENTITY_RECOVERY_SUGGESTION); - this.name = 'SymbolIdentityError'; +function assertContextRefs(rows: unknown[]): void { + for (const row of rows) { + assertQueryIdentity(row, 'uid', 1, [ + ['name', 2], + ['filePath', 3], + ['kind', 4], + ]); + assertSymbolIdentity(queryRowValue(row, 'relType', 0)); } } -/** Validate database identities before using them as graph traversal anchors. */ -function assertSymbolIdentity(id: unknown, expectedUid?: string): asserts id is string { - if ( - typeof id !== 'string' || - !id.trim() || - id.includes('\0') || - (expectedUid !== undefined && id !== expectedUid) - ) { - throw new SymbolIdentityError(); +const RESPONSE_IDENTITY_FIELDS = new Set([ + 'id', + 'uid', + 'name', + 'filePath', + 'label', + 'kind', + 'type', + 'relationType', + 'processType', + 'url', + 'method', + 'sourceId', + 'targetId', + 'symbolId', + 'symbolName', + 'symbolFilePath', + 'adviceId', + 'adviceName', + 'adviceFilePath', + 'advisedId', + 'advisedName', + 'advisedFilePath', + 'evidenceId', +]); + +/** Cover nested additive identity fields while leaving source/metadata text alone. */ +function assertResponseIdentities(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) assertResponseIdentities(item); + } else if (value !== null && typeof value === 'object') { + for (const [key, field] of Object.entries(value)) { + if (key === 'seedBlocks' || key === 'reachableBlocks' || key === 'intraReachableBlocks') { + if (!Array.isArray(field)) throw new SymbolIdentityError(); + for (const id of field) assertSymbolIdentity(id); + continue; + } + if (RESPONSE_IDENTITY_FIELDS.has(key)) assertIdentityFields(field); + if (key !== 'content' && key !== 'methodMetadata' && key !== 'bean') { + assertResponseIdentities(field); + } + } } } @@ -4393,9 +4444,10 @@ export class LocalBackend { * "unknown kind" and, worse, makes the `kind` disambiguation hint unable to * filter it out (#2687). * - * Failures are swallowed: label enrichment is an optimisation for + * Ordinary query failures are swallowed: label enrichment is an optimisation for * downstream scoring and #480 Class/Interface BFS seeding; if it fails * the symbol still resolves, just without the kind-priority bonus. + * Corrupt identities propagate to the context/impact error envelope. */ private async enrichCandidateLabels( repo: RepoHandle, @@ -4429,6 +4481,7 @@ export class LocalBackend { ); const labelById = new Map(); for (const r of rows as any[]) { + assertQueryIdentity(r, 'id', 0, [['label', 1]]); const id = (r.id ?? r[0]) as string; const label = (r.label ?? r[1]) as string; if (id && label && !labelById.has(id)) labelById.set(id, label); @@ -4436,7 +4489,8 @@ export class LocalBackend { for (const c of candidates) { if (c.type === '' && labelById.has(c.id)) c.type = labelById.get(c.id) as string; } - } catch { + } catch (error) { + rethrowSymbolIdentityError(error); /* best-effort — downstream resolvers still work without the label */ } } @@ -4561,6 +4615,7 @@ export class LocalBackend { { uid }, ); if (rows.length === 0) return { kind: 'not_found' }; + assertSymbolRowIdentity(rows[0]); const r = rows[0] as any; const symbol = { id: (r.id ?? r[0]) as string, @@ -4695,6 +4750,10 @@ export class LocalBackend { if (rows.length === 0) return { kind: 'not_found' }; + // Reject every raw candidate before narrowing/scoring can hide a corrupt row. + for (const row of rows) { + assertSymbolRowIdentity(row); + } // Normalise row shape across object / tuple returns from LadybugDB. let normalized = rows.map((r: any) => ({ id: (r.id ?? r[0]) as string, @@ -4705,10 +4764,6 @@ export class LocalBackend { endLine: (r.endLine ?? r[5]) as number, ...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}), })); - // Reject the whole result before narrowing or scoring: dropping a corrupt - // candidate could make an unrelated surviving symbol look unambiguous. - for (const candidate of normalized) assertSymbolIdentity(candidate.id); - // An exact File path wins over anchored suffix candidates. Without this, // `lib/a.ts` and `src/lib/a.ts` both score as File candidates and turn an // otherwise unambiguous exact target into `ambiguous` (#3084 review P2). @@ -4858,7 +4913,9 @@ export class LocalBackend { }, ): Promise { try { - return await this._contextImpl(repo, params); + const result = await this._contextImpl(repo, params); + if (!result.error) assertResponseIdentities(result); + return result; } catch (err: any) { const msg = (err instanceof Error ? err.message : String(err)) || 'Context query failed'; if (err instanceof SymbolIdentityError) { @@ -4978,6 +5035,8 @@ export class LocalBackend { { symId }, ), ]); + assertContextRefs(incomingRows); + assertContextRefs(incomingAdvisedRows); incomingRows.push(...incomingAdvisedRows); let typedPropertyRows: any[] = []; @@ -5082,6 +5141,16 @@ export class LocalBackend { }, ), ]); + assertContextRefs(ctorIncoming); + assertContextRefs(fileIncoming); + assertContextRefs(typedPropertyIncoming); + for (const row of typedProperties) { + assertQueryIdentity(row, 'uid', 0, [ + ['name', 1], + ['filePath', 2], + ['kind', 3], + ]); + } typedPropertyRows = typedProperties; // Deduplicate by (relType, uid) — a caller can have multiple relation @@ -5098,6 +5167,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('context:class-incoming-expansion', e); } } @@ -5128,6 +5198,8 @@ export class LocalBackend { { symId }, ), ]); + assertContextRefs(outgoingRows); + assertContextRefs(outgoingAdvisedRows); outgoingRows.push(...outgoingAdvisedRows); // Process participation. @@ -5151,7 +5223,14 @@ export class LocalBackend { `, { symId }, ); + for (const row of processRows) { + assertQueryIdentity(row, 'pid', 0, [ + ['label', 1], + ['entryPointId', 4], + ]); + } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('context:process-participation', e); } @@ -5188,6 +5267,7 @@ export class LocalBackend { // (GET/POST pair). URL-only dedup would drop the second endpoint. const seenRoutes = new Set(); for (const r of routeRows) { + assertIdentityFields(queryRowValue(r, 'url', 0), queryRowValue(r, 'method', 1)); const url = r.url ?? r[0]; const method = r.method ?? r[1]; const dedupKey = routeEnrichmentKey(method ? String(method) : undefined, url); @@ -5197,7 +5277,8 @@ export class LocalBackend { } } } catch (e) { - // Best-effort enrichment — never fail the context call. + rethrowSymbolIdentityError(e); + // Ordinary query failures leave this best-effort enrichment unavailable. logQueryError('context:route-lookup', e); } @@ -5247,6 +5328,7 @@ export class LocalBackend { ); const beanMetadataPromise = queryClassBeanMetadata(repo.lbugPath, symId, epistemicSymType); const aopMetadataPromise = querySpringAopMetadata(repo.lbugPath, symId, epistemicSymType); + void aopMetadataPromise.catch(() => undefined); // R3-1. A `Property` whose name the analyzer declined to link — because // every definition of it lives in another language — otherwise returns an @@ -5341,6 +5423,7 @@ export class LocalBackend { try { chain = await this._computeContextChain(repo, symId, requestedDepth); } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('context:chain-bfs', e); } } @@ -5381,8 +5464,8 @@ export class LocalBackend { processes: processRows.map((r: any) => ({ id: r.pid || r[0], name: r.label || r[1], - step_index: r.step || r[2], - step_count: r.stepCount || r[3], + step_index: r.step ?? r[2], + step_count: r.stepCount ?? r[3], })), }; } @@ -5463,6 +5546,13 @@ export class LocalBackend { visited: Array.from(visited), }); if (rows.length === 0) return { nextFrontier: [] }; + for (const row of rows) { + assertQueryIdentity(row, 'uid', 0, [ + ['name', 1], + ['filePath', 2], + ['kind', 3], + ]); + } // MATCH is one row per CALLS edge. Cypher `WITH DISTINCT` applies // LIMIT 50 to unique neighbors; this second pass still collapses // twins if a driver/engine ever returns duplicate rows. @@ -5482,6 +5572,7 @@ export class LocalBackend { for (const r of fresh) visited.add(r.uid); return { nodes, nextFrontier: fresh.map((r: any) => r.uid) }; } catch (e) { + rethrowSymbolIdentityError(e); logQueryError(logLabel, e); return { nextFrontier: [] }; } @@ -7163,7 +7254,9 @@ export class LocalBackend { private async impact(repo: RepoHandle, params: ImpactParams): Promise { try { - return await this._impactImpl(repo, params); + const result = await this._impactImpl(repo, params); + if (!result.error) assertResponseIdentities(result); + return result; } catch (err: any) { // Return structured error instead of crashing (#321) const message = @@ -7481,6 +7574,7 @@ export class LocalBackend { } catch (e) { probeFailed = true; candidateProbeFailed = true; + rethrowSymbolIdentityError(e); logQueryError('impact:ambiguous-candidate', e); } return { @@ -7738,6 +7832,7 @@ export class LocalBackend { }); return composeUnifiedPdgImpactResult(pdgResult, interproceduralResult); } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:pdg-interprocedural-reach', e); return composeUnifiedPdgImpactResult(pdgResult, null, e); } @@ -7775,10 +7870,12 @@ export class LocalBackend { { ids: blockIds }, ); for (const r of rows as any[]) { + assertIdentityFields(r.callees ?? r[0]); const raw = String(r.callees ?? r[0] ?? ''); for (const n of raw.split(' ')) if (n) names.add(n); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:pdg-slice-callees', e); } return names; @@ -7814,6 +7911,7 @@ export class LocalBackend { for (const id of splitCalleeIds(r.calleeIds ?? r[0])) ids.add(id); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:pdg-slice-callee-ids', e); } return ids; @@ -7967,7 +8065,10 @@ export class LocalBackend { ORDER BY id LIMIT 25`, { symId, heritage: HERITAGE_TYPES }, - ).catch(() => []); + ).catch((error) => { + rethrowSymbolIdentityError(error); + return []; + }); const undecidedSummary = meta?.undecidedInterfaceSatisfaction; const undecidedDrops = undecidedSummary === undefined @@ -8006,6 +8107,10 @@ export class LocalBackend { } const ifaceRows = await interfaceRowsPromise; for (const r of ifaceRows) { + assertQueryIdentity(r, 'id', 0, [ + ['name', 1], + ['label', 2], + ]); const id = (r.id ?? r[0]) as string; if (id && !boundary.has(id)) { boundary.set(id, { @@ -8031,7 +8136,10 @@ export class LocalBackend { WHERE iface.id = $ifaceId AND r.type IN $types RETURN COUNT(DISTINCT other.id) AS cnt`, { ifaceId, types }, - ).catch(() => []); + ).catch((error) => { + rethrowSymbolIdentityError(error); + return []; + }); const cnt = rows.length > 0 ? Number((rows[0] as any).cnt ?? (rows[0] as any)[0] ?? 0) : 0; m.set(ifaceId, cnt); @@ -8090,7 +8198,8 @@ export class LocalBackend { callableValueReferences: droppedBoundaries.callableValueReferences, }, }; - } catch { + } catch (error) { + rethrowSymbolIdentityError(error); // Never let the heritage probe's failure suppress a drop we already know // about — the whole point is that silence must not read as certainty. return epistemicFrom(droppedBoundaries); @@ -8182,6 +8291,7 @@ export class LocalBackend { `Impact target '${sym.name || sym[1] || '?'}' resolved without a node id; refusing to report a blast radius`, ); } + assertSymbolRowIdentity(sym); // #1858 — kick off the epistemic boundary probe concurrently with the BFS. // It depends only on symId/symType/symName (all known now) and touches no @@ -8217,6 +8327,7 @@ export class LocalBackend { opts.skipEpistemic || summaryOnly ? Promise.resolve(undefined) : querySpringAopMetadata(repo.lbugPath, symId, symType); + void aopMetadataPromise.catch(() => undefined); const impacted: any[] = []; const visited = new Set([symId]); const pdgBridgeEvidenceById = new Map(); @@ -8261,6 +8372,7 @@ export class LocalBackend { ]); for (const r of ctorRows) { + assertSymbolRowIdentity(r); const rid = r.id || r[0]; if (rid && !visited.has(rid)) { visited.add(rid); @@ -8268,6 +8380,7 @@ export class LocalBackend { } } for (const r of fileRows) { + assertSymbolRowIdentity(r); const rid = r.id || r[0]; if (rid && !visited.has(rid)) { visited.add(rid); @@ -8292,6 +8405,7 @@ export class LocalBackend { ); for (const r of typedPropertyRows) { + assertSymbolRowIdentity(r); const rid = r.id || r[0]; if (rid && !visited.has(rid)) { visited.add(rid); @@ -8299,6 +8413,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:class-node-expansion', e); traversalComplete = false; } @@ -8336,6 +8451,9 @@ export class LocalBackend { `, { symId }, ); + for (const row of memberRows) { + assertSymbolRowIdentity(row); + } memberRows.sort((a, b) => compareCodeUnits(String(a.id ?? a[0]), String(b.id ?? b[0]))); if (memberRows.length > OBJECT_CALLABLE_MEMBER_CAP) traversalComplete = false; for (const row of memberRows.slice(0, OBJECT_CALLABLE_MEMBER_CAP)) { @@ -8355,6 +8473,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:object-callable-expansion', e); traversalComplete = false; } @@ -8411,6 +8530,17 @@ export class LocalBackend { relTypes: relationTypes, ...(safeMinConfidence > 0 ? { minConfidence: safeMinConfidence } : {}), }); + // Validate before filtering/deduplication; a discarded corrupt edge is + // still evidence that this result's counts cannot be trusted. + for (const row of related) { + assertQueryIdentity(row, 'id', 1, [ + ['sourceId', 0], + ['name', 2], + ['type', 3], + ['filePath', 4], + ]); + assertSymbolIdentity(queryRowValue(row, 'relType', 5)); + } const edges: ImpactFrontierEdge[] = related.map((rel) => ({ id: rel.id || rel[1], @@ -8510,6 +8640,7 @@ export class LocalBackend { }); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:depth-traversal', e); // Break out of depth loop on query failure but return partial results // collected so far, rather than silently swallowing the error (#321) @@ -8635,6 +8766,7 @@ export class LocalBackend { `, { ids }, ).catch((err) => { + rethrowSymbolIdentityError(err); processQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:process-chunk', err); @@ -8642,6 +8774,14 @@ export class LocalBackend { }); for (const row of rows) { + assertQueryIdentity(row, 'pId', 0, [ + ['name', 1], + ['processType', 2], + ['entryPointId', 3], + ['epName', 7], + ['epType', 8], + ['epFilePath', 9], + ]); const pId = row.pId ?? row[0]; const epId = row.entryPointId ?? row[3] ?? row.pId ?? row[0]; // Track mapping from process -> entryPoint so we can backfill missing minStep @@ -8690,6 +8830,7 @@ export class LocalBackend { ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep ?? Infinity); } } catch (e) { + rethrowSymbolIdentityError(e); processQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:process-chunk', e); @@ -8712,12 +8853,14 @@ export class LocalBackend { `, { pIds, ids: allImpactedIds }, ).catch((err) => { + rethrowSymbolIdentityError(err); enrichmentDegraded = true; logQueryError('impact:process-chunk-backfill', err); return []; }); for (const mr of missingRows) { + assertQueryIdentity(mr, 'pid', 0, []); const pid = mr.pid ?? mr[0]; const minStep = mr.minStep ?? mr[1]; const epId = processToEntryPoint.get(String(pid)); @@ -8729,6 +8872,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); enrichmentDegraded = true; logQueryError('impact:process-chunk-backfill', e); } @@ -8791,6 +8935,7 @@ export class LocalBackend { `, { ids: idsChunk }, ).catch((err) => { + rethrowSymbolIdentityError(err); moduleQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:module-chunk', err); @@ -8798,12 +8943,14 @@ export class LocalBackend { }); for (const r of rows) { + assertIdentityFields(queryRowValue(r, 'name', 0)); const name = r.name ?? r[0] ?? null; const hits = (r.hits ?? r[1]) || 0; if (!name) continue; moduleHitsMap.set(name, (moduleHitsMap.get(name) || 0) + hits); } } catch (e) { + rethrowSymbolIdentityError(e); moduleQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:module-chunk', e); @@ -8832,16 +8979,19 @@ export class LocalBackend { `, { ids: idsChunk }, ).catch((err) => { + rethrowSymbolIdentityError(err); enrichmentDegraded = true; moduleClassificationFailed = true; logQueryError('impact:direct-module-chunk', err); return []; }); for (const r of rows) { + assertIdentityFields(queryRowValue(r, 'name', 0)); const name = r.name ?? r[0] ?? null; if (name) directModuleSet.add(name); } } catch (e) { + rethrowSymbolIdentityError(e); enrichmentDegraded = true; moduleClassificationFailed = true; logQueryError('impact:direct-module-chunk', e); @@ -8903,11 +9053,14 @@ export class LocalBackend { `, { ids: chunkIds }, ).catch((err) => { + rethrowSymbolIdentityError(err); enrichmentDegraded = true; logQueryError('impact:route-chunk', err); return []; }); for (const row of rows) { + assertSymbolIdentity(queryRowValue(row, 'hid', 0)); + assertIdentityFields(queryRowValue(row, 'url', 1), queryRowValue(row, 'method', 2)); const hid = String(row.hid ?? row[0] ?? ''); const url = row.url ?? row[1]; if (!hid || typeof url !== 'string') continue; @@ -9064,8 +9217,16 @@ export class LocalBackend { p.processType AS pType, MIN(r.step) AS step `, { ids: chunkIds }, - ).catch(() => []); + ).catch((err) => { + rethrowSymbolIdentityError(err); + return []; + }); for (const row of rows) { + assertQueryIdentity(row, 'sid', 0, []); + assertQueryIdentity(row, 'pid', 1, [ + ['pName', 2], + ['pType', 3], + ]); const sid = row.sid ?? row[0]; if (!sid) continue; const procEntry = { @@ -9079,6 +9240,7 @@ export class LocalBackend { else perSymbolProcesses.set(String(sid), [procEntry]); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:per-symbol-process-chunk', e); } } diff --git a/gitnexus/src/mcp/local/pdg-impact.ts b/gitnexus/src/mcp/local/pdg-impact.ts index ae4e9836f..4b7321925 100644 --- a/gitnexus/src/mcp/local/pdg-impact.ts +++ b/gitnexus/src/mcp/local/pdg-impact.ts @@ -27,6 +27,11 @@ import { toDisplayLine } from './line-display.js'; import { toOneBasedLine } from '../../core/ingestion/utils/line-base.js'; import { decodeCallSummary } from '../../core/ingestion/taint/call-summary-codec.js'; import { decodeReachingDefReason } from '../../core/ingestion/cfg/reaching-def-reason-codec.js'; +import { + assertSymbolIdentity, + assertIdentityFields, + assertQueryIdentity, +} from './query-result-integrity.js'; /** * Parse the `` segment out of a `BasicBlock` id (1-based function start @@ -90,14 +95,19 @@ const INTERPROC_NODE_BUDGET = 5000; * `classifyPdgBridgeEvidence`); this is the same fact, read at the descent side. */ function parseCalleeIdsCell(raw: unknown): { ids: string[]; truncated: boolean } { + assertIdentityFields(raw); const ids: string[] = []; let truncated = false; + if (!String(raw ?? '').trim()) return { ids, truncated }; // Split on the SHARED CALLEE_ID_SEP (tab) — ids embed file paths / multi-word // C++ type tokens that can contain a space, so a space split would fragment // them. Producer (calleeIdsOfBlock) joins with the same constant. for (const id of String(raw ?? '').split(CALLEE_ID_SEP)) { if (id === CALLEES_TRUNCATED_SENTINEL) truncated = true; - else if (id) ids.push(id); + else { + assertSymbolIdentity(id); + ids.push(id); + } } return { ids, truncated }; } @@ -218,6 +228,7 @@ async function selfReachingDefEdgesByBlock( { ids: blockIds }, ); for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0); const id = String(r['id'] ?? ''); if (!id) continue; const decoded = decodeReachingDefReason(r['reason']); @@ -297,6 +308,7 @@ async function pdgStatementsForBlocks( // Narrow the awaited rows ONCE at the boundary to a typed record shape; read // the aliased cells via bracket access with String()/Number() coercion. for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0); const id = String(r['id'] ?? ''); const line = Number(r['line'] ?? 0); if (!id || !Number.isFinite(line) || line <= 0) continue; @@ -501,6 +513,10 @@ async function projectBlocksToSymbols(deps: { // non-aliased row shape) — no per-field `as any`, matching the typed-row // pattern used elsewhere in this file (e.g. lines ~264, ~1309, ~1386). for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0, [ + ['name', 1], + ['label', 2], + ]); resolved.push({ id: String(r['id'] ?? r['0'] ?? ''), name: String(r['name'] ?? r['1'] ?? ''), @@ -1718,6 +1734,7 @@ async function bfsReachableBlocks(input: { // Narrow the awaited rows ONCE at the boundary (executeParameterized returns // any[]) to a typed record shape, then read the aliased `id` via bracket // access — no `as any` sprayed per field. + for (const row of rawRows) assertQueryIdentity(row, 'id', 0); const rows = rawRows.slice(0, stepLimit) as Array>; depthReached = depth + 1; if (rawRows.length > stepLimit) truncatedByLimit = true; @@ -1796,6 +1813,10 @@ async function calleeIdsByBlock( // Narrow the awaited rows ONCE at the boundary to a typed record shape; read // the aliased cells via bracket access — no per-field `as any`. for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0, [ + ['calleeIds', 1], + ['callees', 2], + ]); const blockId = String(r['id'] ?? ''); if (!blockId) continue; // ONE pass over the cell classifies BOTH facts — a second full split just to @@ -1877,6 +1898,7 @@ async function calleesWithReturnFlow( { ids: calleeIds }, ); for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0); const id = String(r['id'] ?? ''); if (!id) continue; const decoded = decodeCallSummary(r['reason']); @@ -1931,6 +1953,7 @@ async function resolveCalleeSpans( // the aliased columns via bracket access with Number()/String() coercion — // no per-field `as any` (the same boundary-narrowing the typed helpers use). for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0, [['filePath', 1]]); const id = String(r['id'] ?? ''); const filePath = String(r['filePath'] ?? ''); const startLine = Number(r['startLine']); @@ -2168,6 +2191,7 @@ async function interproceduralDescent(input: { seedBlockQuery(anchorClause, probeLimit), queryParams, ); + for (const row of rawSeedRows) assertQueryIdentity(row, 'id', 0); const exceeded = rawSeedRows.length > stepLimit; const seeds = rawSeedRows .slice(0, stepLimit) @@ -2353,6 +2377,7 @@ export async function runImpactPDG(deps: RunPdgImpactDeps): Promise>; let seedBlocks: string[] = seedRows .map((r) => String(r['id'] ?? '')) diff --git a/gitnexus/src/mcp/local/query-result-integrity.ts b/gitnexus/src/mcp/local/query-result-integrity.ts new file mode 100644 index 000000000..da92cca55 --- /dev/null +++ b/gitnexus/src/mcp/local/query-result-integrity.ts @@ -0,0 +1,57 @@ +/** Shared integrity boundary for identities returned by impact/context queries. */ +export const SYMBOL_IDENTITY_RECOVERY_SUGGESTION = + 'Run gitnexus analyze --force from the affected repository root to rebuild the index.'; + +export class SymbolIdentityError extends Error { + constructor() { + super('The index returned an invalid symbol identity. ' + SYMBOL_IDENTITY_RECOVERY_SUGGESTION); + this.name = 'SymbolIdentityError'; + } +} + +/** Validate database identities before using them as graph traversal anchors. */ +export function assertSymbolIdentity(id: unknown, expectedUid?: string): asserts id is string { + if ( + typeof id !== 'string' || + !id.trim() || + id.includes('\0') || + (expectedUid !== undefined && id !== expectedUid) + ) { + throw new SymbolIdentityError(); + } +} + +/** Read either native row shape without turning an absent row into a TypeError. */ +export function queryRowValue(row: unknown, key: string, index: number): unknown { + if (typeof row !== 'object' || row === null) return undefined; + const value = row as Record; + return value[key] ?? value[index]; +} + +/** Optional labels/paths may be empty or NULL; NUL is never a usable identity. */ +export function assertIdentityFields(...values: unknown[]): void { + for (const value of values) { + if ( + value !== null && + value !== undefined && + (typeof value !== 'string' || value.includes('\0')) + ) { + throw new SymbolIdentityError(); + } + } +} + +export function assertQueryIdentity( + row: unknown, + idKey: string, + idIndex: number, + fields: ReadonlyArray = [], +): void { + assertSymbolIdentity(queryRowValue(row, idKey, idIndex)); + for (const [key, index] of fields) assertIdentityFields(queryRowValue(row, key, index)); +} + +/** Ordinary query failures may degrade; corrupt identities must reach the outer error envelope. */ +export function rethrowSymbolIdentityError(error: unknown): void { + if (error instanceof SymbolIdentityError) throw error; +} diff --git a/gitnexus/test/integration/impact-context-integrity.test.ts b/gitnexus/test/integration/impact-context-integrity.test.ts new file mode 100644 index 000000000..0e42de484 --- /dev/null +++ b/gitnexus/test/integration/impact-context-integrity.test.ts @@ -0,0 +1,758 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import lbug from '@ladybugdb/core'; +import * as adapter from '../../src/core/lbug/lbug-adapter.js'; +import { executeParameterized } from '../../src/core/lbug/pool-adapter.js'; +import { closeQueryResults } from '../../src/core/lbug/query-result-utils.js'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { retryRename } from '../../src/storage/fs-atomic.js'; +import { getStoragePaths, registerRepo, saveMeta } from '../../src/storage/repo-manager.js'; +import { createTempDir } from '../helpers/test-db.js'; + +const REPO = 'query-integrity'; +const nodes = { + alpha: { id: 'Function:src/alpha.ts:runSweep', name: 'runSweep', filePath: 'src/alpha.ts' }, + alphaCaller: { + id: 'Function:src/α/caller.ts:appelÉ', + name: 'appelÉ', + filePath: 'src/α/caller.ts', + }, + alphaOtherCaller: { + id: 'Function:src/other.ts:runOther', + name: 'runOther', + filePath: 'src/other.ts', + }, + alphaRoot: { id: 'Function:src/root.ts:startSweep', name: 'startSweep', filePath: 'src/root.ts' }, + alphaReader: { + id: 'Function:src/reader.ts:readSweep', + name: 'readSweep', + filePath: 'src/reader.ts', + }, + beta: { + id: 'Function:src/beta.ts:extractLeadingNumber', + name: 'extractLeadingNumber', + filePath: 'src/beta.ts', + }, + betaCaller: { + id: 'Function:src/number.ts:parseNumber', + name: 'parseNumber', + filePath: 'src/number.ts', + }, + betaReader: { + id: 'Function:src/number-view.ts:readNumber', + name: 'readNumber', + filePath: 'src/number-view.ts', + }, +} as const; + +const processes = { + alpha: { + id: 'process:alpha', + label: 'Sweep flow', + entry: nodes.alphaRoot, + terminal: nodes.alpha, + stepCount: 3, + }, + alphaOther: { + id: 'process:alpha-other', + label: 'Other sweep flow', + entry: nodes.alphaOtherCaller, + terminal: nodes.alpha, + stepCount: 2, + }, + beta: { + id: 'process:beta', + label: 'Number flow', + entry: nodes.betaCaller, + terminal: nodes.beta, + stepCount: 2, + }, +} as const; + +type NodeIdentity = { id: string; name: string; filePath: string }; +type Membership = { id: string; label: string; processType: string; step: number }; +type ImpactRow = NodeIdentity & { + relationType: string; + confidence: number; + processes: Membership[]; +}; +type ContextRef = { uid: string; name: string; filePath: string }; +type Target = 'alpha' | 'beta'; + +const membership = ( + process: (typeof processes)[keyof typeof processes], + step: number, +): Membership => ({ + id: process.id, + label: process.label, + processType: 'intra_community', + step, +}); + +const affectedProcess = (process: (typeof processes)[keyof typeof processes], hits: number) => ({ + name: process.entry.name, + type: 'Function', + filePath: process.entry.filePath, + affected_process_count: 1, + total_hits: hits, + earliest_broken_step: 0, +}); + +const oracle = { + alpha: { + count: 3, + direct: 2, + byDepth: { + 1: [ + { + ...nodes.alphaOtherCaller, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.alphaOther, 0)], + }, + { + ...nodes.alphaCaller, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.alpha, 1)], + }, + ], + 2: [ + { + ...nodes.alphaRoot, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.alpha, 0)], + }, + ], + }, + callers: [nodes.alphaOtherCaller, nodes.alphaCaller], + accesses: [nodes.alphaReader], + processes: [ + { id: processes.alpha.id, name: processes.alpha.label, step_index: 2, step_count: 3 }, + { + id: processes.alphaOther.id, + name: processes.alphaOther.label, + step_index: 1, + step_count: 2, + }, + ], + affectedProcesses: [ + affectedProcess(processes.alpha, 2), + affectedProcess(processes.alphaOther, 1), + ], + }, + beta: { + count: 1, + direct: 1, + byDepth: { + 1: [ + { + ...nodes.betaCaller, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.beta, 0)], + }, + ], + }, + callers: [nodes.betaCaller], + accesses: [nodes.betaReader], + processes: [ + { id: processes.beta.id, name: processes.beta.label, step_index: 1, step_count: 2 }, + ], + affectedProcesses: [affectedProcess(processes.beta, 1)], + }, +} as const; + +// Compare the values returned by the native engine with a hand-written graph +// oracle, rather than accepting a repeated (and potentially wrong) first result. +function expectImpact( + result: Awaited>, + target: Target, + summaryOnly: boolean, +): void { + const expected = oracle[target]; + expect(result).not.toHaveProperty('error'); + expect(result).not.toHaveProperty('partial'); + expect(result.target).toMatchObject(nodes[target]); + expect(result.direction).toBe('upstream'); + expect(result.impactedCount).toBe(expected.count); + expect(result.risk).toBe('LOW'); + expect(result.epistemic).toBe('exact'); + expect(result.summary).toEqual({ + direct: expected.direct, + processes_affected: expected.affectedProcesses.length, + modules_affected: 0, + }); + expect(result.byDepthCounts).toEqual(target === 'alpha' ? { 1: 2, 2: 1 } : { 1: 1 }); + expect(result.affected_processes).toEqual(expected.affectedProcesses); + expect(result.affected_modules).toEqual([]); + expect(result.affected_routes).toEqual([]); + if (summaryOnly) { + expect(result).not.toHaveProperty('byDepth'); + } else { + const byDepth = Object.fromEntries( + Object.entries(result.byDepth).map(([depth, rows]) => [ + depth, + (rows as ImpactRow[]).map( + ({ id, name, filePath, relationType, confidence, processes: memberships }) => ({ + id, + name, + filePath, + relationType, + confidence, + processes: memberships, + }), + ), + ]), + ); + expect(byDepth).toEqual(expected.byDepth); + } +} + +function expectContext( + result: Awaited>, + target: Target, +): void { + const expected = oracle[target]; + expect(result).not.toHaveProperty('error'); + expect(result.status).toBe('found'); + expect(result.symbol).toMatchObject({ + uid: nodes[target].id, + name: nodes[target].name, + filePath: nodes[target].filePath, + }); + expect(result.epistemic).toBe('exact'); + const incoming = Object.fromEntries( + Object.entries(result.incoming).map(([type, refs]) => [ + type, + (refs as ContextRef[]).map(({ uid, name, filePath }) => ({ id: uid, name, filePath })), + ]), + ); + expect(incoming).toEqual({ calls: expected.callers, accesses: expected.accesses }); + expect(result.outgoing).toEqual({}); + expect(result.processes).toEqual(expected.processes); +} + +function edge(source: NodeIdentity, target: NodeIdentity, type: 'CALLS' | 'ACCESSES'): string { + return `MATCH (a:Function {id: '${source.id}'}), (b:Function {id: '${target.id}'}) CREATE (a)-[:CodeRelation {type: '${type}', confidence: 1.0, reason: 'direct', step: 0}]->(b)`; +} + +function processStep( + node: NodeIdentity, + process: (typeof processes)[keyof typeof processes], + step: number, +): string { + return `MATCH (n:Function {id: '${node.id}'}), (p:Process {id: '${process.id}'}) CREATE (n)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: 'trace-detection', step: ${step}}]->(p)`; +} + +describe('native impact/context result integrity (#3354)', () => { + let temp: Awaited>; + let backend: LocalBackend; + let lbugPath: string; + + beforeAll(async () => { + temp = await createTempDir(); + vi.stubEnv('GITNEXUS_HOME', path.join(temp.dbPath, 'home')); + vi.stubEnv('GITNEXUS_STORAGE_PATH', path.join(temp.dbPath, 'index')); + vi.stubEnv('GITNEXUS_SHARED_STORE', 'off'); + const paths = getStoragePaths(temp.dbPath); + lbugPath = paths.lbugPath; + + // Close the writer before LocalBackend opens its ordinary read pool. No + // mocked registry or injected writable Database bypasses the read path. + await adapter.initLbug(lbugPath); + try { + const seed = [ + ...Object.values(nodes).map( + (node) => + `CREATE (:Function {id: '${node.id}', name: '${node.name}', filePath: '${node.filePath}', startLine: 1, endLine: 3})`, + ), + ...Object.values(processes).map( + (process) => + `CREATE (:Process {id: '${process.id}', label: '${process.label}', heuristicLabel: '${process.label}', processType: 'intra_community', stepCount: ${process.stepCount}, communities: [], entryPointId: '${process.entry.id}', terminalId: '${process.terminal.id}'})`, + ), + edge(nodes.alphaCaller, nodes.alpha, 'CALLS'), + edge(nodes.alphaOtherCaller, nodes.alpha, 'CALLS'), + edge(nodes.alphaRoot, nodes.alphaCaller, 'CALLS'), + edge(nodes.alphaReader, nodes.alpha, 'ACCESSES'), + edge(nodes.betaCaller, nodes.beta, 'CALLS'), + edge(nodes.betaReader, nodes.beta, 'ACCESSES'), + processStep(nodes.alphaRoot, processes.alpha, 0), + processStep(nodes.alphaCaller, processes.alpha, 1), + processStep(nodes.alpha, processes.alpha, 2), + processStep(nodes.alphaOtherCaller, processes.alphaOther, 0), + processStep(nodes.alpha, processes.alphaOther, 1), + processStep(nodes.betaCaller, processes.beta, 0), + processStep(nodes.beta, processes.beta, 1), + ]; + for (const query of seed) await adapter.executeQuery(query); + await adapter.flushWAL(); + } finally { + await adapter.closeLbug(); + } + const meta = { + repoPath: temp.dbPath, + storagePath: paths.storagePath, + lastCommit: 'integrity-fixture', + indexedAt: new Date().toISOString(), + scopeExtractionReceipt: 1 as const, + stats: { files: 8, nodes: 11, processes: 3, communities: 0 }, + }; + await saveMeta(paths.storagePath, meta); + await registerRepo(temp.dbPath, meta, { name: REPO }); + backend = new LocalBackend(); + expect(await backend.init()).toBe(true); + }); + + afterAll(async () => { + try { + await backend?.dispose(); + } finally { + await adapter.closeLbug(); + vi.unstubAllEnvs(); + await temp?.cleanup(); + } + }); + + const impact = (target: Target, summaryOnly = false) => + backend.callTool('impact', { + repo: REPO, + target: nodes[target].name, + direction: 'upstream', + summaryOnly, + }); + const context = (target: Target) => + backend.callTool('context', { repo: REPO, uid: nodes[target].id }); + + it('returns exact values across identical sequential requests', async () => { + for (let repeat = 0; repeat < 6; repeat++) { + expectImpact(await impact('alpha', true), 'alpha', true); + expectContext(await context('alpha'), 'alpha'); + expectImpact(await impact('alpha'), 'alpha', false); + } + }); + + it('keeps unrelated targets isolated across mixed requests', async () => { + for (const target of ['alpha', 'beta', 'beta', 'alpha'] as const) { + expectImpact(await impact(target, true), target, true); + expectContext(await context(target), target); + expectImpact(await impact(target), target, false); + } + }); + + it('keeps mixed concurrent prepared reads symbol-specific', async () => { + for (let repeat = 0; repeat < 3; repeat++) { + const results = await Promise.all([ + impact('alpha', true), + context('beta'), + impact('beta'), + impact('beta', true), + context('alpha'), + impact('alpha'), + ]); + expectImpact(results[0], 'alpha', true); + expectContext(results[1], 'beta'); + expectImpact(results[2], 'beta', false); + expectImpact(results[3], 'beta', true); + expectContext(results[4], 'alpha'); + expectImpact(results[5], 'alpha', false); + } + }); + + it('returns exact relation rows directly from the pooled prepared adapter', async () => { + // Warm the pool through the same backend, then check the native row + // boundary independently of the tool's normalization and aggregation. + expectContext(await context('alpha'), 'alpha'); + const read = (target: Target) => + executeParameterized( + lbugPath, + ` + MATCH (caller:Function)-[r:CodeRelation]->(target:Function {id: $id}) + WHERE r.type IN ['CALLS', 'ACCESSES'] + RETURN caller.id AS id, caller.name AS name, caller.filePath AS filePath, r.type AS relationType + ORDER BY id + `, + { id: nodes[target].id }, + ); + const expectedRows = (target: Target) => + [ + ...oracle[target].callers.map((caller) => ({ ...caller, relationType: 'CALLS' })), + ...oracle[target].accesses.map((reader) => ({ ...reader, relationType: 'ACCESSES' })), + ].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + for (let repeat = 0; repeat < 4; repeat++) { + expect(await read('alpha')).toEqual(expectedRows('alpha')); + const [beta, alpha] = await Promise.all([read('beta'), read('alpha')]); + expect(beta).toEqual(expectedRows('beta')); + expect(alpha).toEqual(expectedRows('alpha')); + } + }); +}); + +describe('native string projections after checkpointed deletion (#3354)', () => { + it('keeps long symbol identities associated with their source rows across segments', async () => { + const temp = await createTempDir(); + const db = new lbug.Database(path.join(temp.dbPath, 'scan.lbug'), 128 * 1024 * 1024); + const conn = new lbug.Connection(db, 4); + const source = Array.from({ length: 10_000 }, (_, startLine) => ({ + id: `Function:src/generated/rené-${String(startLine).padStart(5, '0')}.ts:fn${startLine}`, + name: `generated_function_${startLine}_é`, + filePath: `src/generated/rené-${String(startLine).padStart(5, '0')}.ts`, + startLine, + })); + const projection = + 'RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine'; + const read = async (query: string) => { + const result = await conn.query(query); + try { + const cursor = Array.isArray(result) ? result[0] : result; + return await cursor.getAll(); + } finally { + await closeQueryResults(result); + } + }; + + try { + await read( + 'CREATE NODE TABLE Function(id STRING, name STRING, filePath STRING, startLine INT64, PRIMARY KEY(id))', + ); + // Separate checkpoints create segment boundaries inside scan vectors. + // LadybugDB 0.18.3's filtered STRING scan could retain another row's + // printable identities here (LadybugDB/ladybug#678, fixed by #737). + for (let batch = 0; batch < 4; batch++) { + const csvPath = path.join(temp.dbPath, `rows-${batch}.csv`); + const csv = source + .slice(batch * 2500, (batch + 1) * 2500) + .map((row) => + Object.values(row) + .map((value) => JSON.stringify(value)) + .join(','), + ) + .join('\n'); + await fs.writeFile(csvPath, `${csv}\n`); + await read( + `COPY Function FROM ${JSON.stringify(csvPath.replaceAll('\\', '/'))} (HEADER=false)`, + ); + await read('CHECKPOINT'); + } + expect(await read(`MATCH (n:Function) ${projection} ORDER BY n.startLine`)).toEqual(source); + + await read( + 'MATCH (n:Function) WHERE n.startLine >= 3000 AND n.startLine < 3400 DETACH DELETE n', + ); + await read('CHECKPOINT'); + const surviving = source.filter((row) => row.startLine < 3000 || row.startLine >= 3400); + for (let repeat = 0; repeat < 3; repeat++) { + for (const order of ['', ' ORDER BY n.startLine']) { + const rows = await read(`MATCH (n:Function) ${projection}${order}`); + expect(new Set(rows.map((row) => row.id)).size).toBe(surviving.length); + expect(rows.sort((a, b) => a.startLine - b.startLine)).toEqual(surviving); + } + } + // Point lookups independently verify values in the affected segments; + // a repeatably wrong scan must never become the test's reference answer. + for (const startLine of [1600, 7486]) { + expect( + await read(`MATCH (n:Function {id: '${source[startLine].id}'}) ${projection}`), + ).toEqual([source[startLine]]); + } + expect(await read(`MATCH (n:Function {id: '${source[3000].id}'}) ${projection}`)).toEqual([]); + } finally { + try { + await conn.close(); + } finally { + try { + await db.close(); + } finally { + await temp.cleanup(); + } + } + } + }); +}); + +// Windows graph replacement is opt-in in production. The repeated-read +// characterization above remains enabled there; only this POSIX swap is skipped. +describe.skipIf(process.platform === 'win32')('warm backend index replacement (#3354)', () => { + it('reads changed callers, processes and a new symbol through the real freshness window', async () => { + const temp = await createTempDir(); + let backend: LocalBackend | undefined; + vi.stubEnv('GITNEXUS_HOME', path.join(temp.dbPath, 'home')); + vi.stubEnv('GITNEXUS_STORAGE_PATH', path.join(temp.dbPath, 'index')); + vi.stubEnv('GITNEXUS_SHARED_STORE', 'off'); + const paths = getStoragePaths(temp.dbPath); + const stagedPath = `${paths.lbugPath}.replacement`; + const replacement = { + entry: { + id: 'Function:src/replacement-entry.ts:startReplacement', + name: 'startReplacement', + filePath: 'src/replacement-entry.ts', + }, + caller: { + id: 'Function:src/replacement-caller.ts:callReplacement', + name: 'callReplacement', + filePath: 'src/replacement-caller.ts', + }, + reader: { + id: 'Function:src/replacement-reader.ts:readReplacement', + name: 'readReplacement', + filePath: 'src/replacement-reader.ts', + }, + }; + const nextProcess = { id: 'process:replacement', label: 'Replacement flow' }; + const oldProcess = processes.alphaOther; + + const seed = async (dbPath: string, next: boolean) => { + await adapter.initLbug(dbPath); + try { + const caller = next ? replacement.caller : nodes.alphaOtherCaller; + const reader = next ? replacement.reader : nodes.alphaReader; + const process = next ? nextProcess : oldProcess; + const entry = next ? replacement.entry : caller; + for (const node of [nodes.alpha, caller, reader, ...(next ? [entry] : [])]) { + await adapter.executeQuery( + `CREATE (:Function {id: '${node.id}', name: '${node.name}', filePath: '${node.filePath}', startLine: 1, endLine: 3})`, + ); + } + await adapter.executeQuery( + `CREATE (:Process {id: '${process.id}', label: '${process.label}', heuristicLabel: '${process.label}', processType: 'intra_community', stepCount: ${next ? 3 : 2}, communities: [], entryPointId: '${entry.id}', terminalId: '${nodes.alpha.id}'})`, + ); + await adapter.executeQuery(edge(caller, nodes.alpha, 'CALLS')); + await adapter.executeQuery(edge(reader, nodes.alpha, 'ACCESSES')); + if (next) await adapter.executeQuery(edge(entry, caller, 'CALLS')); + const steps = next ? [entry, caller, nodes.alpha] : [caller, nodes.alpha]; + for (const [step, node] of steps.entries()) { + await adapter.executeQuery( + `MATCH (n:Function {id: '${node.id}'}), (p:Process {id: '${process.id}'}) CREATE (n)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: 'trace-detection', step: ${step}}]->(p)`, + ); + } + await adapter.flushWAL(); + } finally { + await adapter.closeLbug(); + } + }; + const processMembership = (next: boolean, step: number) => ({ + ...(next ? nextProcess : { id: oldProcess.id, label: oldProcess.label }), + processType: 'intra_community', + step, + }); + const expectedCaller = (node: NodeIdentity, next: boolean, step: number) => ({ + ...node, + relationType: 'CALLS', + confidence: 1, + processes: [processMembership(next, step)], + }); + const expectGeneration = ( + impact: Awaited>, + context: Awaited>, + next: boolean, + ) => { + const caller = next ? replacement.caller : nodes.alphaOtherCaller; + const reader = next ? replacement.reader : nodes.alphaReader; + const entry = next ? replacement.entry : caller; + const process = next ? nextProcess : oldProcess; + expect(impact).not.toHaveProperty('error'); + expect(impact).not.toHaveProperty('partial'); + expect(impact.target).toMatchObject(nodes.alpha); + expect(impact.risk).toBe('LOW'); + expect(impact.epistemic).toBe('exact'); + expect(impact.impactedCount).toBe(next ? 2 : 1); + expect(impact.summary).toEqual({ direct: 1, processes_affected: 1, modules_affected: 0 }); + expect(impact.byDepthCounts).toEqual(next ? { 1: 1, 2: 1 } : { 1: 1 }); + const byDepth = Object.fromEntries( + Object.entries(impact.byDepth).map(([depth, rows]) => [ + depth, + (rows as ImpactRow[]).map( + ({ id, name, filePath, relationType, confidence, processes: memberships }) => ({ + id, + name, + filePath, + relationType, + confidence, + processes: memberships, + }), + ), + ]), + ); + expect(byDepth).toEqual({ + 1: [expectedCaller(caller, next, next ? 1 : 0)], + ...(next ? { 2: [expectedCaller(entry, true, 0)] } : {}), + }); + expect(impact.affected_processes).toEqual([ + { + name: entry.name, + type: 'Function', + filePath: entry.filePath, + affected_process_count: 1, + total_hits: next ? 2 : 1, + earliest_broken_step: 0, + }, + ]); + expect(impact.affected_modules).toEqual([]); + expect(impact.affected_routes).toEqual([]); + expect(context).not.toHaveProperty('error'); + expect(context.status).toBe('found'); + expect(context.epistemic).toBe('exact'); + expect(context.symbol).toMatchObject({ + uid: nodes.alpha.id, + name: nodes.alpha.name, + filePath: nodes.alpha.filePath, + }); + expect( + Object.fromEntries( + Object.entries(context.incoming).map(([type, refs]) => [ + type, + (refs as ContextRef[]).map(({ uid, name, filePath }) => ({ id: uid, name, filePath })), + ]), + ), + ).toEqual({ calls: [caller], accesses: [reader] }); + expect(context.outgoing).toEqual({}); + expect(context.processes).toEqual([ + { + id: process.id, + name: process.label, + step_index: next ? 2 : 1, + step_count: next ? 3 : 2, + }, + ]); + }; + + try { + await seed(paths.lbugPath, false); + const meta = { + repoPath: temp.dbPath, + storagePath: paths.storagePath, + lastCommit: 'graph-a', + indexedAt: new Date().toISOString(), + scopeExtractionReceipt: 1 as const, + stats: { files: 3, nodes: 4, processes: 1, communities: 0 }, + }; + await saveMeta(paths.storagePath, meta); + await registerRepo(temp.dbPath, meta, { name: REPO }); + const heldBackend = new LocalBackend(); + backend = heldBackend; + expect(await heldBackend.init()).toBe(true); + const impact = () => + heldBackend.callTool('impact', { + repo: REPO, + target: nodes.alpha.name, + direction: 'upstream', + }); + const context = () => heldBackend.callTool('context', { repo: REPO, uid: nodes.alpha.id }); + expectGeneration(await impact(), await context(), false); + expect( + await heldBackend.callTool('context', { repo: REPO, uid: replacement.entry.id }), + ).toHaveProperty('error'); + + // Keep this backend and its read pool alive. Publish only after the + // separate staged writer has closed, exactly as run-analyze does. + await seed(stagedPath, true); + for (const suffix of ['.wal', '.shadow', '.wal.checkpoint']) { + await expect(fs.stat(`${stagedPath}${suffix}`)).rejects.toMatchObject({ code: 'ENOENT' }); + } + await retryRename(stagedPath, paths.lbugPath); + const nextMeta = { + ...meta, + lastCommit: 'graph-b', + indexedAt: new Date(Date.now() + 1).toISOString(), + stats: { files: 4, nodes: 5, processes: 1, communities: 0 }, + }; + await saveMeta(paths.storagePath, nextMeta); + await registerRepo(temp.dbPath, nextMeta, { name: REPO }); + + // An independent native read-only Database opens the published path. + // It does not share LocalBackend's pool or trigger its reinitialization. + const freshDb = new lbug.Database(paths.lbugPath, 128 * 1024 * 1024, true, true); + const freshConn = new lbug.Connection(freshDb); + try { + const read = async (query: string) => { + const result = await freshConn.query(query); + try { + const cursor = Array.isArray(result) ? result[0] : result; + return await cursor.getAll(); + } finally { + await closeQueryResults(result); + } + }; + expect( + await read(` + MATCH (n:Function) + RETURN n.id AS id, n.name AS name, n.filePath AS filePath + ORDER BY id + `), + ).toEqual( + [nodes.alpha, ...Object.values(replacement)].sort((a, b) => + a.id < b.id ? -1 : a.id > b.id ? 1 : 0, + ), + ); + expect( + await read(` + MATCH (n:Function)-[r:CodeRelation]->(target:Function {id: '${nodes.alpha.id}'}) + WHERE r.type IN ['CALLS', 'ACCESSES'] + RETURN n.id AS id, n.name AS name, n.filePath AS filePath, r.type AS relationType + ORDER BY id + `), + ).toEqual([ + { ...replacement.caller, relationType: 'CALLS' }, + { ...replacement.reader, relationType: 'ACCESSES' }, + ]); + expect( + await read(` + MATCH (n:Function)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) + RETURN n.id AS id, p.id AS processId, p.heuristicLabel AS label, + r.step AS step, p.stepCount AS stepCount, p.entryPointId AS entryPointId + ORDER BY step + `), + ).toEqual( + [replacement.entry, replacement.caller, nodes.alpha].map((node, step) => ({ + id: node.id, + processId: nextProcess.id, + label: nextProcess.label, + step, + stepCount: 3, + entryPointId: replacement.entry.id, + })), + ); + } finally { + await freshConn.close(); + await freshDb.close(); + } + + // Poll the SAME backend through its unchanged five-second throttle. + // No private watermark override, poolInit, reset or restart is used. + const deadline = Date.now() + 15_000; + let refreshed = await context(); + while (refreshed.processes?.[0]?.id !== nextProcess.id && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 300)); + refreshed = await context(); + } + expectGeneration(await impact(), refreshed, true); + for (let repeat = 0; repeat < 3; repeat++) { + expectGeneration(await impact(), await context(), true); + const introduced = await heldBackend.callTool('context', { + repo: REPO, + name: replacement.entry.name, + }); + expect(introduced).not.toHaveProperty('error'); + expect(introduced.status).toBe('found'); + expect(introduced.symbol).toMatchObject({ + uid: replacement.entry.id, + name: replacement.entry.name, + filePath: replacement.entry.filePath, + }); + expect(introduced.processes).toEqual([ + { id: nextProcess.id, name: nextProcess.label, step_index: 0, step_count: 3 }, + ]); + } + } finally { + try { + await backend?.dispose(); + } finally { + await adapter.closeLbug(); + vi.unstubAllEnvs(); + await temp.cleanup(); + } + } + }); +}); diff --git a/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts b/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts index 0390caab6..be6d32215 100644 --- a/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts +++ b/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts @@ -159,9 +159,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - // Target resolution (WHERE n.name = $symName) and any other read. - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { @@ -195,8 +194,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { @@ -230,8 +229,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee'), frontierRow('func:callee-B', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { @@ -266,8 +265,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee'), frontierRow('*', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index aeefc94a4..7eaf2e1ff 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -183,6 +183,16 @@ function setupNoRepos() { (listRegisteredRepos as any).mockResolvedValue([]); } +/** Seed resolver rows without inventing relationship/process rows for other projections. */ +function mockSymbolRows(rows: Record[]) { + (executeParameterized as any).mockImplementation( + async (_repo: string, query: string, params: Record) => { + if (query.includes('COUNT(*) AS total')) return [{ total: rows.length }]; + return params?.symName || params?.uid ? rows : []; + }, + ); +} + const duplicateFixtureDirs: string[] = []; function makeDuplicateNameFixture() { @@ -1321,7 +1331,7 @@ describe('LocalBackend.callTool', () => { }); it('dispatches context tool', async () => { - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:main', name: 'main', @@ -1663,27 +1673,22 @@ describe('LocalBackend.callTool', () => { }); it('exact File path wins over suffixed matches during qualified resolution (#3084 review P2)', async () => { - (executeParameterized as any).mockImplementation(async (_repo: string, query: string) => { - if (query.startsWith('MATCH (n)')) { - return [ - { - id: 'File:src/lib/a.ts', - name: 'a.ts', - filePath: 'src/lib/a.ts', - kind: 'File', - total_hits: 1, - }, - { - id: 'File:lib/a.ts', - name: 'a.ts', - filePath: 'lib/a.ts', - kind: 'File', - total_hits: 1, - }, - ]; - } - return [{ total: 2 }]; - }); + mockSymbolRows([ + { + id: 'File:src/lib/a.ts', + name: 'a.ts', + filePath: 'src/lib/a.ts', + kind: 'File', + total_hits: 1, + }, + { + id: 'File:lib/a.ts', + name: 'a.ts', + filePath: 'lib/a.ts', + kind: 'File', + total_hits: 1, + }, + ]); const result = await backend.callTool('context', { name: 'lib/a.ts' }); expect(result).toMatchObject({ @@ -2005,7 +2010,7 @@ describe('LocalBackend.callTool', () => { }); it('context tool ranks file_path match higher than non-match (#470)', async () => { - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:handleConnect:1', name: 'handleConnect', @@ -2044,7 +2049,7 @@ describe('LocalBackend.callTool', () => { // review): both candidates satisfy the file_path hint (so DB // pre-filter would return both in production), and promotion is // determined purely by the combined file_path + kind score. - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'fn:App:1', name: 'render', @@ -2135,7 +2140,7 @@ describe('LocalBackend.callTool', () => { it('impact tool returns ambiguous shape with ranked candidates when target has multiple matches (#470)', async () => { // resolveSymbolCandidates issues a single name query; mock it to return // two Function rows in different files with no hints. - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:login:1', name: 'login', @@ -2222,7 +2227,7 @@ describe('LocalBackend.callTool', () => { // Resolver returns target; BFS returns one frontier caller; no STEP_IN_PROCESS rows. (executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => { // BFS frontier query is now parameterized (#1907 U3). - if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) { + if (cypher.includes('$frontierIds')) { return Promise.resolve([ { id: 'func:caller', @@ -2234,10 +2239,12 @@ describe('LocalBackend.callTool', () => { }, ]); } - // Symbol resolution. - return Promise.resolve([ - { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, - ]); + // Symbol resolution; unseeded enrichment queries return no rows. + return Promise.resolve( + cypher.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : [], + ); }); (executeQuery as any).mockResolvedValue([]); @@ -2974,7 +2981,7 @@ describe('LocalBackend.callTool', () => { }); it('dispatches "explore" as alias for context', async () => { - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:main', name: 'main', @@ -3007,9 +3014,7 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { // dispatch (callgraph BFS or the PDG traversal). The callgraph BFS then issues // executeQuery for its frontier; the PDG path delegates to runImpactPDG. function resolveSingleTarget() { - (executeParameterized as any).mockResolvedValue([ - { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, - ]); + mockSymbolRows([{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }]); (executeQuery as any).mockResolvedValue([]); } @@ -3266,18 +3271,13 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { it("mode:'pdg' + downstream line:8 routes to the PDG traversal and seeds bridge evidence", async () => { resolveSingleTarget(); - // The target-resolution row doubles as the calleesOfBlocks row: `callees` - // ('callee') is the leaf name persisted on the slice's BasicBlock, the - // statement-precise substrate the bridge keys on. - (executeParameterized as any).mockResolvedValue([ - { - id: 'func:main', - name: 'main', - type: 'Function', - filePath: 'src/index.ts', - callees: 'callee', - }, - ]); + // BasicBlock callees and symbol lookup use distinct native projections. + vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => { + if (query.includes('RETURN b.callees')) return [{ callees: 'callee' }]; + return query.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : []; + }); // A line-seeded downstream slice with one reachable block → the dispatch // queries that block's callees and seeds the bridge with them. const pdgSpy = vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({ @@ -3402,11 +3402,13 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { // is not built and the inter-procedural reach falls back to callgraph-equal — // never surfacing the error or producing a partial proven/unproven labeling. resolveSingleTarget(); - // The slice-callees query (RETURN b.callees) throws; every other query (target - // resolution) returns the resolved symbol row. + // The slice-callees query throws; lookup returns the target and unseeded + // relationship/process projections return no rows. vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => { if (query.includes('RETURN b.callees')) throw new Error('slice-callees query failed'); - return [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }]; + return query.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : []; }); // A line-seeded downstream slice so calleesOfBlocks is attempted. vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({ @@ -3461,7 +3463,9 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { resolveSingleTarget(); vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => { if (query.includes('RETURN b.callees')) throw new Error('Table BasicBlock does not exist'); - return [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }]; + return query.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : []; }); vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({ mode: 'pdg', diff --git a/gitnexus/test/unit/impact-batching-grouping.test.ts b/gitnexus/test/unit/impact-batching-grouping.test.ts index e068870f9..7f4d18bd8 100644 --- a/gitnexus/test/unit/impact-batching-grouping.test.ts +++ b/gitnexus/test/unit/impact-batching-grouping.test.ts @@ -79,6 +79,8 @@ describe('impact: batching and grouping', () => { // Handle parameterized calls (including chunked STEP_IN_PROCESS queries) executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; const params = args[2] || {}; // Match only the aggregation chunk (which uses COUNT(DISTINCT s.id)), // not the per-symbol enrichment pass added by impact byDepth processes @@ -91,6 +93,7 @@ describe('impact: batching and grouping', () => { const idx = chunkCallIndex++; return [ { + pId: 'proc-' + idx, entryPointId: `ep-${Math.floor(idx)}`, epName: `epName-${idx}`, epType: 'Function', @@ -148,6 +151,8 @@ describe('impact: batching and grouping', () => { executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; // BFS frontier query (parameterized #1907 U3): return 6 impacted nodes. if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { const res: any[] = []; @@ -166,6 +171,7 @@ describe('impact: batching and grouping', () => { // For STEP_IN_PROCESS in this test, return grouping rows return [ { + pId: 'proc-1a', entryPointId: 'ep-1', epName: 'EP1', epType: 'Function', @@ -174,6 +180,7 @@ describe('impact: batching and grouping', () => { minStep: 1, }, { + pId: 'proc-2', entryPointId: 'ep-2', epName: 'EP2', epType: 'Function', @@ -182,6 +189,7 @@ describe('impact: batching and grouping', () => { minStep: 2, }, { + pId: 'proc-1b', entryPointId: 'ep-1', epName: 'EP1', epType: 'Function', @@ -190,6 +198,7 @@ describe('impact: batching and grouping', () => { minStep: 3, }, { + pId: 'proc-3', entryPointId: 'ep-3', epName: 'EP3', epType: 'Function', @@ -245,6 +254,8 @@ describe('impact: batching and grouping', () => { executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; const params = args[2] || {}; // Match only the aggregation chunk (which uses COUNT(DISTINCT s.id)), // not the per-symbol enrichment pass added by impact byDepth processes @@ -254,6 +265,7 @@ describe('impact: batching and grouping', () => { chunkSizes.push(ids.length); return [ { + pId: 'proc-x-' + chunkSizes.length, entryPointId: 'ep-x', epName: 'EPX', epType: 'Function', @@ -351,6 +363,7 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { return [ { @@ -394,6 +407,7 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; if (query.includes('STEP_IN_PROCESS')) { throw new Error('process chunk failed'); } @@ -443,6 +457,8 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; if (query.includes('MEMBER_OF')) throw new Error('module chunk failed'); if (query.includes('STEP_IN_PROCESS') && query.includes('COUNT(DISTINCT s.id)')) { return [ @@ -500,6 +516,8 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; if (query.includes('MIN(r.step) AS minStep') && !query.includes('COUNT(DISTINCT s.id)')) { throw new Error('minStep backfill failed'); } @@ -559,6 +577,8 @@ describe('impact: batching and grouping', () => { let processChunk = 0; executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; if (query.includes('STEP_IN_PROCESS') && query.includes('COUNT(DISTINCT s.id)')) { processChunk += 1; if (processChunk === 2) throw new Error('later process chunk failed'); @@ -615,6 +635,7 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; if (query.includes('MEMBER_OF') && query.includes('RETURN DISTINCT c.heuristicLabel')) { throw new Error('module classification failed'); } diff --git a/gitnexus/test/unit/impact-context-integrity.test.ts b/gitnexus/test/unit/impact-context-integrity.test.ts new file mode 100644 index 000000000..5ad29172b --- /dev/null +++ b/gitnexus/test/unit/impact-context-integrity.test.ts @@ -0,0 +1,847 @@ +/** Corrupt detail rows must not become usable context/impact answers (#3354). */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { db, aop } = vi.hoisted(() => ({ + db: { + initLbug: vi.fn().mockResolvedValue(undefined), + executeQuery: vi.fn().mockResolvedValue([]), + executeParameterized: vi.fn().mockResolvedValue([]), + closeLbug: vi.fn().mockResolvedValue(undefined), + isLbugReady: vi.fn().mockReturnValue(true), + }, + aop: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...db, +})); +vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...db, +})); +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({ + ...(await importOriginal()), + listRegisteredRepos: vi.fn().mockResolvedValue([ + { + name: 'integrity-fixture', + path: '/tmp/integrity-fixture', + storagePath: '/tmp/integrity-fixture/.gitnexus', + indexedAt: '2026-10-03T12:00:00Z', + lastCommit: 'fixture', + stats: { files: 2, nodes: 2, edges: 1, communities: 0, processes: 1 }, + }, + ]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), + loadMeta: vi.fn().mockResolvedValue({ + pdg: { maxCdgEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 }, + }), +})); +vi.mock('../../src/core/git-staleness.js', () => ({ + checkStalenessAsync: vi.fn().mockResolvedValue({ isStale: false, commitsBehind: 0 }), + checkStaleness: vi.fn().mockReturnValue({ isStale: false, commitsBehind: 0 }), + checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }), +})); +vi.mock('../../src/storage/git.js', async (importOriginal) => ({ + ...(await importOriginal()), + getGitRoot: vi.fn().mockReturnValue(null), +})); +vi.mock('../../src/mcp/local/aop-metadata.js', () => ({ querySpringAopMetadata: aop })); + +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { SymbolIdentityError } from '../../src/mcp/local/query-result-integrity.js'; + +const TARGET = { + id: 'func:target', + name: 'target', + type: 'Function', + filePath: 'src/target.ts', + startLine: 1, + endLine: 4, +}; +const REF = { + relType: 'CALLS', + uid: 'func:caller', + name: 'caller', + filePath: 'src/caller.ts', + kind: 'Function', +}; +const EDGE = { + sourceId: TARGET.id, + id: REF.uid, + name: REF.name, + type: REF.kind, + filePath: REF.filePath, + relType: 'CALLS', + confidence: 1, +}; +const PROCESS = { + pId: 'proc:caller', + name: 'Caller flow', + processType: 'intra_community', + entryPointId: REF.uid, + hits: 1, + minStep: 0, + stepCount: 2, + epName: REF.name, + epType: REF.kind, + epFilePath: REF.filePath, +}; +const BAD = 'corrupt\0persisted-value'; + +type Seam = + | 'target' + | 'targetLabels' + | 'aopRows' + | 'pdgSeed' + | 'pdgNeighbor' + | 'pdgOwner' + | 'pdgStatement' + | 'pdgSelf' + | 'pdgCalleeBlocks' + | 'pdgSummary' + | 'pdgSpans' + | 'incoming' + | 'classIncoming' + | 'outgoing' + | 'typedProperties' + | 'contextProcess' + | 'contextRoute' + | 'interfaceBoundary' + | 'interfaceCount' + | 'chain' + | 'seeds' + | 'members' + | 'frontier' + | 'process' + | 'backfill' + | 'membership' + | 'modules' + | 'impactRoute' + | 'metadata'; +let backend: LocalBackend; +let rows: Partial>; +let failedSeam: Seam | undefined; + +function fixture(seam: Seam): unknown[] { + if (failedSeam === seam) throw new Error('ordinary unavailable query'); + return rows[seam] ?? []; +} + +function querySeam(query: string, params: Record | undefined): Seam | undefined { + if (params?.symName || params?.uid) return 'target'; + if (query.includes("RETURN n.id AS id, 'Class' AS label")) return 'targetLabels'; + if (query.includes("r.reason STARTS WITH 'spring-aop:v1:'")) return 'aopRows'; + if (query.includes('RETURN s.id AS id, s.name AS name')) return 'pdgOwner'; + if (query.includes('RETURN s.id AS id, s.filePath AS filePath')) return 'pdgSpans'; + if (query.includes('RETURN c.id AS id, r.reason AS reason')) return 'pdgSummary'; + if (query.includes('RETURN a.id AS id, r.reason AS reason')) return 'pdgSelf'; + if (query.includes('RETURN a.id AS id ORDER BY a.startLine')) return 'pdgSeed'; + if (query.includes('RETURN b.id AS id')) { + if (query.includes('b.calleeIds AS calleeIds')) return 'pdgCalleeBlocks'; + if (query.includes('b.text AS text')) return 'pdgStatement'; + return 'pdgSeed'; + } + if (query.includes('BasicBlock') && query.includes('RETURN DISTINCT')) return 'pdgNeighbor'; + if (query.includes('WITH DISTINCT caller') || query.includes('WITH DISTINCT target')) + return 'chain'; + if (query.includes('caller.id AS uid')) + return query.includes('(ctor:Constructor)') ? 'classIncoming' : 'incoming'; + if (query.includes('target.id AS uid')) return 'outgoing'; + if (query.includes('RETURN p.id AS uid')) return 'typedProperties'; + if (query.includes('RETURN p.id AS pid, p.heuristicLabel AS label')) return 'contextProcess'; + if (query.includes('RETURN route.name AS url')) return 'contextRoute'; + if (query.includes('RETURN DISTINCT iface.id AS id')) return 'interfaceBoundary'; + if (query.includes('RETURN COUNT(DISTINCT other.id) AS cnt')) return 'interfaceCount'; + if ( + query.includes('RETURN c.id AS id') || + query.includes('RETURN f.id AS id') || + query.includes('RETURN p.id AS id') + ) + return 'seeds'; + if (query.includes('RETURN DISTINCT member.id AS id')) return 'members'; + if (query.includes('AS sourceId')) return 'frontier'; + if (query.includes('RETURN p.id AS pId')) return 'process'; + if (query.includes('RETURN p.id AS pid, MIN(r.step) AS minStep')) return 'backfill'; + if (query.includes('RETURN s.id AS sid')) return 'membership'; + if (query.includes('c.heuristicLabel AS name')) return 'modules'; + if (query.includes('RETURN h.id AS hid')) return 'impactRoute'; + if (query.includes('n.visibility AS visibility')) return 'metadata'; + return undefined; +} + +async function context(extra = {}) { + return backend.callTool('context', { name: TARGET.name, ...extra }); +} +async function impact(extra = {}) { + return backend.callTool('impact', { + target: TARGET.name, + direction: 'upstream', + maxDepth: 1, + ...extra, + }); +} +function expectIntegrityError(result: any, isImpact = false) { + expect(result.error).toMatch(/invalid symbol identity/i); + expect(result.recoverySuggestion).toMatch(/analyze.*--force/); + expect(JSON.stringify(result)).not.toContain('persisted-value'); + expect(result).not.toHaveProperty('symbol'); + expect(result).not.toHaveProperty('incoming'); + if (isImpact) { + expect(result.risk).toBe('UNKNOWN'); + expect(result.impactedCount).toBeNull(); + expect(result.epistemic).not.toBe('exact'); + } +} +function tuple(value: Record, keys: string[]): unknown[] { + return keys.map((key) => value[key]); +} + +beforeEach(async () => { + vi.clearAllMocks(); + aop.mockResolvedValue(undefined); + failedSeam = undefined; + rows = { target: [{ ...TARGET }], frontier: [{ ...EDGE }] }; + db.executeParameterized.mockImplementation(async (_db, query, params) => { + const seam = querySeam(query, params); + return seam ? fixture(seam) : []; + }); + backend = new LocalBackend(); + await backend.init(); + vi.spyOn(backend as any, 'ensureInitialized').mockResolvedValue(undefined); + vi.spyOn(backend as any, 'computeEpistemicBoundary').mockResolvedValue({ epistemic: 'exact' }); +}); + +describe('identity corruption before target selection', () => { + for (const corrupt of [true, false]) { + it('distinguishes corrupt and ordinary ambiguous candidate failures: ' + corrupt, async () => { + rows.target = [ + { ...TARGET, id: 'func:one', filePath: 'src/one.ts' }, + { ...TARGET, id: 'func:two', filePath: 'src/two.ts' }, + ]; + vi.spyOn(backend as any, '_runImpactBFS').mockRejectedValue( + corrupt ? new SymbolIdentityError() : new Error('ordinary candidate failure'), + ); + const result = await impact(); + if (corrupt) { + expectIntegrityError(result, true); + } else { + expect(result.error).toBeUndefined(); + expect(result.status).toBe('ambiguous'); + expect(result.partialProbe).toBe(true); + } + }); + } + for (const tool of ['context', 'impact']) { + for (const badRow of [ + { id: BAD, label: 'Class' }, + { id: '', label: 'Class' }, + { id: 42, label: 'Class' }, + { id: TARGET.id, label: BAD }, + ]) { + it('rejects corrupt label enrichment for ' + tool + JSON.stringify(badRow), async () => { + rows.target = [{ ...TARGET, type: '' }]; + rows.targetLabels = [badRow, { id: TARGET.id, label: 'Class' }]; + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }); + } + } + for (const shape of ['object', 'tuple']) { + for (const field of ['name', 'filePath']) { + for (const tool of ['context', 'impact', 'pdg impact']) { + it('rejects NUL in target ' + field + ' from ' + shape + ' rows for ' + tool, async () => { + const target = { ...TARGET, [field]: BAD }; + rows.target = [ + shape === 'tuple' + ? tuple(target, ['id', 'name', 'type', 'filePath', 'startLine', 'endLine']) + : target, + ]; + expectIntegrityError( + tool === 'context' + ? await context() + : await impact(tool === 'pdg impact' ? { mode: 'pdg' } : {}), + tool !== 'context', + ); + }); + } + } + } + it('rejects corrupt exact-UID metadata before expansion', async () => { + rows.target = [{ ...TARGET, filePath: BAD }]; + expectIntegrityError(await context({ uid: TARGET.id })); + expectIntegrityError(await impact({ target_uid: TARGET.id }), true); + }); + for (const field of ['name', 'filePath']) { + it('rejects non-string target ' + field, async () => { + rows.target = [{ ...TARGET, [field]: 42 }]; + expectIntegrityError(await context()); + }); + } + it('validates every candidate before exact File narrowing', async () => { + rows.target = [ + { ...TARGET, id: 'File:src/target.ts', name: 'target.ts', type: 'File' }, + { ...TARGET, id: 'func:other', filePath: BAD }, + ]; + expectIntegrityError(await context({ name: TARGET.filePath })); + }); +}); + +describe('context detail row integrity', () => { + for (const seam of ['incoming', 'outgoing'] as const) { + for (const shape of ['object', 'tuple']) { + for (const field of ['uid', 'name', 'filePath']) { + it('rejects NUL in ' + seam + ' ' + field + ' from ' + shape + ' rows', async () => { + const ref = { ...REF, [field]: BAD }; + rows[seam] = [ + shape === 'tuple' ? tuple(ref, ['relType', 'uid', 'name', 'filePath', 'kind']) : ref, + ]; + expectIntegrityError(await context()); + }); + } + for (const badRow of [null, {}, [], { ...REF, uid: '' }, { ...REF, relType: '' }]) { + it( + 'rejects incomplete ' + + seam + + ' row ' + + JSON.stringify(badRow) + + ' in ' + + shape + + ' response', + async () => { + rows[seam] = [ + shape === 'tuple' && badRow !== null + ? tuple(badRow, ['relType', 'uid', 'name', 'filePath', 'kind']) + : badRow, + ]; + expectIntegrityError(await context()); + }, + ); + } + } + } + for (const badRow of [ + null, + {}, + [''], + { pid: '' }, + { pid: 'proc:target', label: BAD }, + ['proc:target', BAD, 0, 0], + ]) { + it('rejects corrupt context process ' + JSON.stringify(badRow), async () => { + rows.contextProcess = [badRow]; + expectIntegrityError(await context()); + }); + } + it('does not swallow corrupt class expansion or typed property rows', async () => { + rows.target = [{ ...TARGET, type: 'Class' }]; + rows.typedProperties = [{ uid: 'prop:target', name: 'prop', filePath: BAD, kind: 'Property' }]; + expectIntegrityError(await context()); + }); + it('rejects corrupt class refs before deduplication can discard them', async () => { + rows.target = [{ ...TARGET, type: 'Class' }]; + rows.incoming = [REF]; + rows.classIncoming = [{ ...REF, filePath: BAD }]; + expectIntegrityError(await context()); + }); + for (const badRow of [{}, { ...REF, filePath: BAD }, { ...REF, uid: '' }]) { + it('does not swallow corrupt chain rows ' + JSON.stringify(badRow), async () => { + rows.chain = [badRow]; + expectIntegrityError(await context({ chain_depth: 1 })); + }); + } + it('rejects NUL in route names', async () => { + rows.contextRoute = [{ url: BAD, method: 'GET' }]; + expectIntegrityError(await context()); + }); + it('rejects nested AOP identity fields while keeping the shared error envelope', async () => { + aop.mockResolvedValue({ + framework: 'spring', + advices: [{ adviceId: 'advice:1', adviceName: BAD }], + }); + expectIntegrityError(await context()); + }); +}); + +describe('epistemic boundary row integrity', () => { + const iface = { id: 'iface:target', name: 'Target contract', label: 'Interface' }; + + function prepareBoundary() { + vi.mocked((backend as any).computeEpistemicBoundary).mockRestore(); + rows.interfaceBoundary = [iface]; + rows.interfaceCount = [{ cnt: 2 }]; + } + + for (const tool of ['context', 'impact']) { + for (const shape of ['object', 'tuple']) { + for (const badRow of [ + { ...iface, id: undefined }, + { ...iface, id: '' }, + { ...iface, id: BAD }, + { ...iface, id: 42 }, + { ...iface, name: BAD }, + { ...iface, name: 42 }, + { ...iface, label: BAD }, + { ...iface, label: 42 }, + ]) { + it( + 'rejects corrupt ' + + tool + + ' boundary before deduplication: ' + + shape + + JSON.stringify(badRow), + async () => { + prepareBoundary(); + rows.interfaceBoundary = [iface, badRow].map((row) => + shape === 'tuple' ? tuple(row, ['id', 'name', 'label']) : row, + ); + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }, + ); + } + } + for (const seam of ['interfaceBoundary', 'interfaceCount'] as const) { + for (const corrupt of [true, false]) { + it( + 'distinguishes ' + tool + ' boundary query failure: ' + seam + ' ' + corrupt, + async () => { + prepareBoundary(); + db.executeParameterized.mockImplementation(async (_db, query, params) => { + const currentSeam = querySeam(query, params); + if (currentSeam === seam) { + throw corrupt ? new SymbolIdentityError() : new Error('ordinary boundary failure'); + } + return currentSeam ? fixture(currentSeam) : []; + }); + const result = tool === 'context' ? await context() : await impact(); + if (corrupt) { + expectIntegrityError(result, tool === 'impact'); + } else { + expect(result.error).toBeUndefined(); + expect(result.recoverySuggestion).toBeUndefined(); + expect(result.epistemic).toBe('lower-bound'); + if (tool === 'impact') expect(result.impactedCount).toBe(1); + } + }, + ); + } + } + it('preserves healthy ' + tool + ' boundary descriptions', async () => { + prepareBoundary(); + const result = tool === 'context' ? await context() : await impact(); + expect(result.error).toBeUndefined(); + expect(result.epistemic).toBe('lower-bound'); + expect(result.boundaries).toContainEqual( + expect.stringContaining('Target contract is an interface'), + ); + expect(result.causes.dispatchBoundary).toBe(4); + }); + } +}); + +describe('impact detail row integrity', () => { + for (const seam of ['frontier', 'process'] as const) { + it( + 'PDG detail integrity rejects corrupt ' + seam + ' rows through the outer envelope', + async () => { + rows[seam] = [ + seam === 'frontier' ? { ...EDGE, filePath: BAD } : { ...PROCESS, epName: BAD }, + ]; + expectIntegrityError(await impact({ mode: 'pdg' }), true); + }, + ); + } + for (const shape of ['object', 'tuple']) { + for (const field of ['id', 'name', 'filePath', 'sourceId']) { + it('rejects NUL in frontier ' + field + ' from ' + shape + ' rows', async () => { + const edge = { ...EDGE, [field]: BAD }; + rows.frontier = [ + shape === 'tuple' + ? tuple(edge, [ + 'sourceId', + 'id', + 'name', + 'type', + 'filePath', + 'relType', + 'confidence', + 'staticGated', + ]) + : edge, + ]; + expectIntegrityError(await impact(), true); + }); + } + } + for (const badRow of [null, {}, [], { ...EDGE, id: '' }]) { + it('rejects incomplete frontier rows ' + JSON.stringify(badRow), async () => { + rows.frontier = [badRow]; + expectIntegrityError(await impact(), true); + }); + } + it('validates corrupt rows before test filtering', async () => { + rows.frontier = [{ ...EDGE, filePath: 'test/corrupt\0.test.ts' }]; + expectIntegrityError(await impact({ includeTests: false }), true); + }); + for (const shape of ['object', 'tuple']) { + for (const field of ['pId', 'name', 'entryPointId', 'epName', 'epFilePath']) { + it('rejects NUL in process ' + field + ' from ' + shape + ' rows', async () => { + const process = { ...PROCESS, [field]: BAD }; + rows.process = [ + shape === 'tuple' + ? tuple(process, [ + 'pId', + 'name', + 'processType', + 'entryPointId', + 'hits', + 'minStep', + 'stepCount', + 'epName', + 'epType', + 'epFilePath', + ]) + : process, + ]; + expectIntegrityError(await impact({ summaryOnly: true }), true); + }); + } + } + for (const badRow of [null, {}, [], { ...PROCESS, pId: '' }]) { + it('does not aggregate incomplete processes ' + JSON.stringify(badRow), async () => { + rows.process = [badRow]; + expectIntegrityError(await impact(), true); + }); + } + for (const badRow of [{}, { pid: BAD, minStep: 1 }]) { + it( + 'does not swallow corrupt backfill process identities ' + JSON.stringify(badRow), + async () => { + rows.process = [{ ...PROCESS, minStep: null }]; + rows.backfill = [badRow]; + expectIntegrityError(await impact(), true); + }, + ); + } + for (const badRow of [ + {}, + { sid: REF.uid, pid: '' }, + { sid: REF.uid, pid: 'proc:caller', pName: BAD }, + ]) { + it( + 'does not swallow corrupt per-symbol process identities ' + JSON.stringify(badRow), + async () => { + rows.process = [PROCESS]; + rows.membership = [badRow]; + expectIntegrityError(await impact(), true); + }, + ); + } + for (const type of ['Class', 'Const']) { + for (const badRow of [{}, { ...TARGET, id: 'seed:1', filePath: BAD }]) { + it('rejects corrupt ' + type + ' seeds ' + JSON.stringify(badRow), async () => { + rows.target = [{ ...TARGET, type }]; + rows[type === 'Class' ? 'seeds' : 'members'] = [badRow]; + expectIntegrityError(await impact({ direction: 'downstream' }), true); + }); + } + } + it('rejects NUL in module names before aggregation', async () => { + rows.modules = [{ name: BAD, hits: 1 }]; + expectIntegrityError(await impact(), true); + }); + it('rejects NUL in route names', async () => { + rows.impactRoute = [{ hid: REF.uid, url: BAD }]; + expectIntegrityError(await impact(), true); + }); + for (const shape of ['object', 'tuple']) { + for (const hid of [undefined, null, '', ' ', BAD, 42, {}]) { + it( + 'rejects corrupt route handler IDs from ' + shape + ' rows: ' + JSON.stringify(hid), + async () => { + const route = { hid, url: '/target', method: 'GET' }; + rows.impactRoute = [shape === 'tuple' ? tuple(route, ['hid', 'url', 'method']) : route]; + expectIntegrityError(await impact(), true); + }, + ); + } + } + it('rejects nested AOP paths', async () => { + aop.mockResolvedValue({ + framework: 'spring', + advices: [{ adviceId: 'advice:1', adviceFilePath: BAD }], + }); + expectIntegrityError(await impact(), true); + }); +}); + +describe('healthy and ordinary-failure compatibility', () => { + it('preserves Unicode, opaque IDs, optional NULL metadata and source NUL', async () => { + const content = 'const value = "actual\0source";'; + rows.target = [{ ...TARGET, name: 'café�', filePath: '源/café�.ts', content }]; + rows.incoming = [{ ...REF, name: '呼び出し�', filePath: null, kind: '' }]; + rows.contextProcess = [ + { pid: 'legacy:proc ', label: '', step: 0, stepCount: 0, entryPointId: null }, + ]; + rows.metadata = [{ annotations: ['@Text("source\0value")'], parameterTypes: null }]; + const result = await context({ include_content: true }); + expect(result.error).toBeUndefined(); + expect(result.symbol).toMatchObject({ + uid: TARGET.id, + name: 'café�', + filePath: '源/café�.ts', + content, + }); + expect(result.symbol.methodMetadata).toEqual({ annotations: ['@Text("source\0value")'] }); + expect(result.incoming.calls[0]).toMatchObject({ uid: REF.uid, name: '呼び出し�' }); + expect(result.processes).toEqual([ + { id: 'legacy:proc ', name: undefined, step_index: 0, step_count: 0 }, + ]); + }); + it('preserves tuple zero steps and empty process labels', async () => { + rows.contextProcess = [['proc:0', '', 0, 0, null]]; + expect((await context()).processes).toEqual([ + { id: 'proc:0', name: '', step_index: 0, step_count: 0 }, + ]); + }); + it('allows absent OPTIONAL MATCH entry-point fields and missing legacy sourceId', async () => { + rows.frontier = [{ ...EDGE, sourceId: undefined }]; + rows.process = [ + { ...PROCESS, name: '', entryPointId: null, epName: null, epType: null, epFilePath: null }, + ]; + const result = await impact(); + expect(result.error).toBeUndefined(); + expect(result.impactedCount).toBe(1); + expect(result.affected_processes).toEqual([ + { + name: 'unknown', + type: 'Function', + filePath: '', + affected_process_count: 1, + total_hits: 1, + earliest_broken_step: 0, + }, + ]); + }); + it('keeps missing optional route fields as ordinary skipped enrichment', async () => { + rows.contextRoute = [{}]; + rows.impactRoute = [{ hid: REF.uid }]; + expect((await context()).error).toBeUndefined(); + expect((await impact()).error).toBeUndefined(); + }); + it('does not misdiagnose an unmatched user-supplied NUL as index corruption', async () => { + rows.target = []; + const contextResult = await context({ name: 'client\0input' }); + const impactResult = await impact({ target: 'client\0input' }); + expect(contextResult.error).toContain('not found'); + expect(impactResult.error).toContain('not found'); + expect(contextResult.recoverySuggestion).toBeUndefined(); + expect(impactResult.recoverySuggestion).toBeUndefined(); + }); + it('keeps ordinary process query failures degraded rather than integrity errors', async () => { + failedSeam = 'process'; + const result = await impact(); + expect(result.error).toBeUndefined(); + expect(result.partial).toBe(true); + expect(result.impactedCount).toBe(1); + expect(result.affected_processes).toEqual([]); + }); + it('keeps ordinary PDG interprocedural failures as degraded results', async () => { + vi.spyOn(backend as any, '_runImpactBFS').mockRejectedValue( + new Error('ordinary bridge failure'), + ); + const result = await impact({ mode: 'pdg' }); + expect(result.error).toBeUndefined(); + expect(result.partial).toBe(true); + expect(result.interproceduralError).toBe('ordinary bridge failure'); + expect(result.recoverySuggestion).toBeUndefined(); + }); + it('keeps ordinary context process query failures as unavailable enrichment', async () => { + failedSeam = 'contextProcess'; + const result = await context(); + expect(result.error).toBeUndefined(); + expect(result.processes).toEqual([]); + }); +}); + +describe('raw PDG identities before coercion, caps, and projection', () => { + const seed = 'BasicBlock:src/target.ts:2:0:0'; + const reached = 'BasicBlock:src/caller.ts:1:0:0'; + + function preparePdg() { + rows.pdgSeed = [{ id: seed }]; + rows.pdgNeighbor = [{ id: reached }]; + rows.pdgOwner = [{ id: REF.uid, name: REF.name, label: 'Function', startLine: 0 }]; + rows.pdgStatement = [{ id: reached, line: 1, endLine: 1, text: 'value = 1;' }]; + } + + for (const seam of ['pdgSeed', 'pdgNeighbor', 'pdgOwner', 'pdgStatement'] as const) { + for (const bad of [BAD, '', null, 42]) { + it('rejects raw ' + seam + ' identity ' + JSON.stringify(bad), async () => { + preparePdg(); + rows[seam] = [{ id: bad, name: 'caller', label: 'Function', line: 1, startLine: 0 }]; + expectIntegrityError( + await impact({ mode: 'pdg', ...(seam === 'pdgStatement' ? { line: 2 } : {}) }), + true, + ); + }); + } + } + for (const seam of ['pdgSeed', 'pdgNeighbor'] as const) { + it('validates ' + seam + ' cap probe rows', async () => { + preparePdg(); + rows[seam] = [{ id: seam === 'pdgSeed' ? seed : reached }, { id: BAD }]; + expectIntegrityError(await impact({ mode: 'pdg', limit: 1 }), true); + }); + } + for (const field of ['name', 'label']) { + it('rejects raw owner ' + field + ' before String coercion', async () => { + preparePdg(); + rows.pdgOwner = [{ id: REF.uid, name: 'caller', label: 'Function', [field]: BAD }]; + expectIntegrityError(await impact({ mode: 'pdg' }), true); + }); + } + for (const field of ['seedBlocks', 'reachableBlocks', 'intraReachableBlocks']) { + for (const bad of [BAD, '', null, 42]) { + it('rejects malformed final ' + field + ' members ' + JSON.stringify(bad), async () => { + const healthy = await impact({ mode: 'pdg' }); + vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValue({ + ...healthy, + [field]: [bad], + }); + expectIntegrityError(await impact({ mode: 'pdg' }), true); + }); + } + } + it('allows a generated unresolved owner marker', async () => { + preparePdg(); + rows.pdgOwner = []; + const result = await impact({ mode: 'pdg' }); + expect(result.error).toBeUndefined(); + expect(result.unresolvedBlockCount).toBe(1); + }); + it('preserves Unicode owner tuples and source NUL', async () => { + preparePdg(); + rows.pdgOwner = [[REF.uid, '呼び出し�', 'Function', 0]]; + expect((await impact({ mode: 'pdg' })).error).toBeUndefined(); + rows.pdgStatement = [{ id: reached, line: 1, endLine: 1, text: 'value = "source\0text";' }]; + const result = await impact({ mode: 'pdg', line: 2 }); + expect(result.error).toBeUndefined(); + expect(result.affectedStatements.some((s: any) => s.text.includes('\0'))).toBe(true); + }); + for (const field of ['callees', 'calleeIds']) { + it('does not swallow a corrupt statement bridge ' + field, async () => { + preparePdg(); + const original = db.executeParameterized.getMockImplementation()!; + db.executeParameterized.mockImplementation(async (...args) => { + if (args[1].includes('RETURN b.' + field + ' AS ' + field)) { + return [{ [field]: BAD }]; + } + return original(...args); + }); + expectIntegrityError(await impact({ mode: 'pdg', direction: 'downstream' }), true); + }); + } +}); + +describe('real AOP helper identities before deduplication', () => { + const reason = + 'spring-aop:v1:' + + JSON.stringify({ + kind: 'advice', + annotation: 'org.aspectj.lang.annotation.Around', + advice: 'around', + pointcut: 'execution(*)', + match: 'static', + activation: 'unknown', + proxy: 'possible', + }); + const advice = { + sourceId: TARGET.id, + sourceName: 'target', + sourceFilePath: TARGET.filePath, + targetId: 'advice:1', + targetName: 'audit', + targetFilePath: 'src/audit.ts', + reason, + }; + async function useRealAop() { + const actual = await vi.importActual( + '../../src/mcp/local/aop-metadata.js', + ); + aop.mockImplementation(actual.querySpringAopMetadata); + rows.target = [{ ...TARGET, type: 'Method' }]; + } + for (const tool of ['context', 'impact']) { + for (const field of ['sourceId', 'targetId']) { + for (const bad of [BAD, '', null, 42]) { + it('rejects raw AOP ' + field + ' for ' + tool + JSON.stringify(bad), async () => { + await useRealAop(); + rows.aopRows = [{ ...advice, [field]: bad }, advice]; + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }); + } + } + for (const field of ['sourceName', 'sourceFilePath', 'targetName', 'targetFilePath']) { + it('rejects corrupt duplicate AOP ' + field + ' for ' + tool, async () => { + await useRealAop(); + rows.aopRows = [{ ...advice, [field]: BAD }, advice]; + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }); + } + } + it('validates the AOP cap-probe row', async () => { + await useRealAop(); + rows.aopRows = [...Array.from({ length: 1000 }, () => advice), { ...advice, targetId: BAD }]; + expectIntegrityError(await context()); + }); + it('preserves Unicode and optional NULL metadata', async () => { + await useRealAop(); + rows.aopRows = [ + { ...advice, sourceName: '源�', sourceFilePath: null, targetName: '', targetFilePath: null }, + ]; + const result = await context(); + expect(result.error).toBeUndefined(); + expect(result.symbol.aop.advices[0]).toMatchObject({ + adviceId: advice.targetId, + advisedId: advice.sourceId, + advisedName: '源�', + }); + }); + it('keeps ordinary AOP query failures fail-soft', async () => { + await useRealAop(); + failedSeam = 'aopRows'; + expect((await context()).error).toBeUndefined(); + expect((await impact()).error).toBeUndefined(); + }); + it('handles early AOP rejection while the frontier is pending', async () => { + const unhandled = vi.fn(); + process.on('unhandledRejection', unhandled); + aop.mockRejectedValue(new SymbolIdentityError()); + const original = db.executeParameterized.getMockImplementation()!; + db.executeParameterized.mockImplementation(async (...args) => { + if (querySeam(args[1], args[2]) === 'frontier') { + await new Promise((resolve) => setTimeout(resolve, 30)); + } + return original(...args); + }); + try { + expectIntegrityError(await impact(), true); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off('unhandledRejection', unhandled); + } + }); +}); diff --git a/gitnexus/test/unit/impact-pagination.test.ts b/gitnexus/test/unit/impact-pagination.test.ts index 9b8aa8e21..0a710a71e 100644 --- a/gitnexus/test/unit/impact-pagination.test.ts +++ b/gitnexus/test/unit/impact-pagination.test.ts @@ -56,6 +56,7 @@ function setupMultiDepthHub(d1Count: number, d2Count: number) { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); if (query.includes('STEP_IN_PROCESS')) return []; if (query.includes('MEMBER_OF')) return []; + if (query.includes('RETURN h.id AS hid')) return []; // The #1858 epistemic-boundary probe (computeEpistemicBoundary) runs // concurrently with the BFS and also matches `r.type IN`, but targets the // `iface` alias. Return empty so it stays `epistemic: 'exact'` and does not @@ -99,6 +100,7 @@ function setupHubSymbol(count: number) { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); if (query.includes('STEP_IN_PROCESS')) return []; if (query.includes('MEMBER_OF')) return []; + if (query.includes('RETURN h.id AS hid')) return []; // See setupMultiDepthHub — keep the #1858 epistemic probe from matching the // `r.type IN` caller branch below. if (query.includes('iface')) return []; diff --git a/gitnexus/test/unit/impact-route-enrichment.test.ts b/gitnexus/test/unit/impact-route-enrichment.test.ts index 9e9cc8791..d2b53031c 100644 --- a/gitnexus/test/unit/impact-route-enrichment.test.ts +++ b/gitnexus/test/unit/impact-route-enrichment.test.ts @@ -80,7 +80,10 @@ async function runImpact(routeRows: readonly RouteRow[], routeQueryFails = false : []; } if (query.includes('STEP_IN_PROCESS') || query.includes('MEMBER_OF')) return []; - return [{ id: 'svc', name: 'UnfinalizeRound', filePath: 'svc.go', type: 'Method' }]; + if (query.includes('n.id AS id')) { + return [{ id: 'svc', name: 'UnfinalizeRound', filePath: 'svc.go', type: 'Method' }]; + } + return []; }); const backend = new LocalBackend(); diff --git a/gitnexus/test/unit/pdg-impact-engine.test.ts b/gitnexus/test/unit/pdg-impact-engine.test.ts index e163df4be..3ca021e0b 100644 --- a/gitnexus/test/unit/pdg-impact-engine.test.ts +++ b/gitnexus/test/unit/pdg-impact-engine.test.ts @@ -1,12 +1,95 @@ import { describe, expect, it } from 'vitest'; import { IMPACT_MAX_DEPTH } from '../../src/mcp/tools.js'; +import { CALLEES_TRUNCATED_SENTINEL } from '../../src/core/ingestion/cfg/callee-cell-format.js'; import { pdgLayerStatus, runImpactPDG, + splitCalleeIds, type RunPdgImpactDeps, } from '../../src/mcp/local/pdg-impact.js'; +import { SymbolIdentityError } from '../../src/mcp/local/query-result-integrity.js'; + +describe('splitCalleeIds', () => { + const firstId = 'Function:src/my dir/rené.cpp:unsigned char'; + const secondId = 'Function:src/my dir/rené.cpp:long double'; + + it.each([undefined, null, '', ' ', '\t', '\t \t'])( + 'preserves an entirely empty optional cell (%j)', + (cell) => { + expect(splitCalleeIds(cell)).toEqual([]); + }, + ); + + it('preserves spaces and Unicode within healthy callee identities', () => { + expect(splitCalleeIds(`${firstId}\t${secondId}`)).toEqual([firstId, secondId]); + }); + + it('drops the generated truncation sentinel without changing resolved identities', () => { + expect(splitCalleeIds(CALLEES_TRUNCATED_SENTINEL)).toEqual([]); + expect(splitCalleeIds(`${firstId}\t${CALLEES_TRUNCATED_SENTINEL}\t${secondId}`)).toEqual([ + firstId, + secondId, + ]); + }); + + it.each([ + ['leading', `\t${firstId}`], + ['interior', `${firstId}\t\t${secondId}`], + ['trailing', `${firstId}\t`], + ['whitespace-only token', `${firstId}\t \t${secondId}`], + ['before a sentinel', `\t${CALLEES_TRUNCATED_SENTINEL}`], + ['after a sentinel', `${CALLEES_TRUNCATED_SENTINEL}\t`], + ['mixed with a sentinel', `${firstId}\t\t${CALLEES_TRUNCATED_SENTINEL}\t${secondId}`], + ])('rejects a populated cell with an empty identity (%s)', (_case, cell) => { + expect(() => splitCalleeIds(cell)).toThrow(SymbolIdentityError); + }); +}); describe('runImpactPDG', () => { + it.each([ + ['leading', '\tFunction:src/hot.ts:callee'], + ['interior', 'Function:src/hot.ts:a\t\tFunction:src/hot.ts:b'], + ['trailing', 'Function:src/hot.ts:callee\t'], + ['sentinel-adjacent', `Function:src/hot.ts:callee\t${CALLEES_TRUNCATED_SENTINEL}\t`], + ])( + 'rejects an empty callee identity before interprocedural descent (%s)', + async (_case, cell) => { + const seed = 'BasicBlock:src/hot.ts:1:0:0'; + const queries: string[] = []; + const exec: RunPdgImpactDeps['executeParameterized'] = async (_repo, query) => { + queries.push(query); + if (query.includes('MATCH (a:BasicBlock) WHERE')) return [{ id: seed }]; + if (query.includes('RETURN b.id AS id, b.calleeIds AS calleeIds')) { + return [{ id: seed, calleeIds: cell, callees: 'callee' }]; + } + return []; + }; + + await expect( + runImpactPDG({ + repo: { lbugPath: 'repo' }, + sym: { + id: 'Function:src/hot.ts:hot', + name: 'hot', + filePath: 'src/hot.ts', + startLine: 0, + endLine: 3, + }, + symType: 'Function', + direction: 'downstream', + maxDepth: 2, + limit: 50, + line: 1, + executeParameterized: exec, + }), + ).rejects.toBeInstanceOf(SymbolIdentityError); + expect( + queries.some((query) => query.includes('RETURN b.id AS id, b.calleeIds AS calleeIds')), + ).toBe(true); + expect(queries.some((query) => query.includes("r.type = 'CALL_SUMMARY'"))).toBe(false); + }, + ); + it('clamps huge maxDepth values to the documented impact traversal cap', async () => { let bfsQueries = 0; const exec = async (_repo: string, query: string) => { diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 8bb6ff502..37c5889f3 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -68,6 +68,7 @@ export default defineConfig({ include: [ 'test/integration/skip-fts.test.ts', 'test/integration/impact-callable-value-references.test.ts', + 'test/integration/impact-context-integrity.test.ts', 'test/integration/impact-epistemic-lower-bound.test.ts', 'test/integration/impact-scope-omission-persistence.test.ts', 'test/integration/lbug-core-adapter.test.ts', @@ -162,6 +163,7 @@ export default defineConfig({ exclude: [ 'test/integration/skip-fts.test.ts', 'test/integration/impact-callable-value-references.test.ts', + 'test/integration/impact-context-integrity.test.ts', 'test/integration/impact-epistemic-lower-bound.test.ts', 'test/integration/impact-scope-omission-persistence.test.ts', 'test/integration/lbug-core-adapter.test.ts', From 504bff7102e39f45649917396f44ee84b5efb8cc Mon Sep 17 00:00:00 2001 From: rgb-vgx Date: Sun, 4 Oct 2026 18:55:41 +0700 Subject: [PATCH 03/13] fix(group): join gin/echo route-group prefixes and accept method-value handlers in Go HTTP providers (#3458) --- .../core/group/extractors/http-patterns/go.ts | 532 +++++++++- .../group/extractors/http-patterns/types.ts | 8 + .../group/extractors/http-route-extractor.ts | 16 +- .../ingestion/route-extractors/go-gin-echo.ts | 54 +- .../ingestion/route-extractors/go-shared.ts | 75 ++ .../unit/group/go-gin-route-groups.test.ts | 969 ++++++++++++++++++ 6 files changed, 1573 insertions(+), 81 deletions(-) create mode 100644 gitnexus/src/core/ingestion/route-extractors/go-shared.ts create mode 100644 gitnexus/test/unit/group/go-gin-route-groups.test.ts diff --git a/gitnexus/src/core/group/extractors/http-patterns/go.ts b/gitnexus/src/core/group/extractors/http-patterns/go.ts index ebf87f0e1..6d29de5fe 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/go.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/go.ts @@ -1,27 +1,38 @@ +import type Parser from 'tree-sitter'; import Go from 'tree-sitter-go'; +import { goImportPackageName } from '../../../ingestion/languages/go/import-package-name.js'; +import { stringLiteral } from '../../../ingestion/route-extractors/go-shared.js'; import { compilePatterns, runCompiledPatterns, - unquoteLiteral, type LanguagePatterns, } from '../tree-sitter-scanner.js'; import type { HttpDetection, HttpLanguagePlugin } from './types.js'; /** * Go HTTP plugin. Handles: - * - gin / echo / chi framework routing — `r.GET("/path", handler)` + * - gin / echo framework routing — `r.GET("/path", handler)`, including + * prefixes from route groups bound in the same function (`r.Group("/api")`) * - net/http stdlib — `http.HandleFunc("/path", handler)` * - net/http consumer — `http.Get(...)`, `http.NewRequest("METHOD", ...)` * - resty consumer — `client.R().Delete("/path")` */ // ─── Provider: framework routing ────────────────────────────────────── -// Matches `\w+\.GET(...)` etc. (gin, echo, chi all share this shape). -// Captures the HTTP method (field name), path literal, and the handler — -// anchored to the LAST argument (`@handler .`) so a variadic middleware -// chain (`r.GET("/x", mw, handler)`, gin/echo/chi style) binds the real -// handler, not a middleware identifier (which would otherwise over-match -// and attach the route to the wrong symbol — see #2276 review). +// Matches `\w+\.GET(...)` etc. (gin and echo share this shape). +// Captures the receiver, the HTTP method (field name), and the path literal +// (either Go string form; stringLiteral decodes both, as ingestion does). The +// query does not anchor the path with `.`: tree-sitter counts comments as +// named children, so `GET(/* c */ "/p", h)` would fail the anchor. scan instead +// requires the path to be the first argument in code (comments skipped) and +// picks the handler out of the remaining code arguments. Which argument that is depends on the framework: +// gin is `GET(path, middleware..., handler)` (last), echo is +// `GET(path, handler, middleware...)` (first) — see readFrameworkImports and +// the per-call choice in scan below. +// The handler must be an identifier, an inline func literal, or a method +// value / package-qualified function (`h.ListUsers`, `handlers.ListUsers`); +// anything else there means the call cannot be attributed to a symbol, so it +// is dropped rather than guessed (variadic-middleware over-match, #2276). const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({ name: 'go-framework-route', language: Go, @@ -31,16 +42,431 @@ const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({ query: ` (call_expression function: (selector_expression + operand: (_) @receiver field: (field_identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE|PATCH)$")) arguments: (argument_list - (interpreted_string_literal) @path - [(identifier) (func_literal)] @handler - .)) + [(interpreted_string_literal) (raw_string_literal)] @path)) `, }, ], } satisfies LanguagePatterns>); +/** Named children that are code, not comments (tree-sitter names comments). */ +function codeChildren(node: Parser.SyntaxNode | null | undefined): Parser.SyntaxNode[] { + return node ? node.namedChildren.filter((c) => c.type !== 'comment') : []; +} + +/** Argument forms a route handler may take. */ +const HANDLER_ARG_TYPES: ReadonlySet = new Set([ + 'identifier', + 'func_literal', + 'selector_expression', +]); + +/** + * The file's framework import aliases: which local qualifiers resolve to + * echo and to gin. Matched on the import path rather than the local name, so + * an aliased import still counts; an unaliased import is keyed by its + * conventional package name (`goImportPackageName`). `_` and `.` imports bind + * no qualifier this file can route through. An empty set means the file + * proves nothing about that framework. Echo's verb calls take the handler as + * the FIRST argument after the path (`GET(path, handler, middleware...)`), + * gin's as the LAST (`GET(path, middleware..., handler)`) — `scan` picks the + * rule per call from these sets. + */ +function readFrameworkImports(root: Parser.SyntaxNode): { + echo: Set; + gin: Set; +} { + // Imports sit only at file scope; skip bodies. + const specs = root.namedChildren + .filter((node) => node.type === 'import_declaration') + .flatMap((decl) => decl.descendantsOfType('import_spec')); + const echo = new Set(); + const gin = new Set(); + for (const spec of specs) { + const importPath = stringLiteral(spec.childForFieldName('path')); + if (importPath === null) continue; + const local = spec.childForFieldName('name')?.text ?? goImportPackageName(importPath); + if (local === '_' || local === '.') continue; + if (importPath.includes('labstack/echo')) echo.add(local); + else if (importPath.includes('gin-gonic/gin')) gin.add(local); + } + return { echo, gin }; +} + +// ─── Route groups: `v1 := r.Group("/api/v1")` ───────────────────────── +// gin (`*gin.RouterGroup`) and echo (`*echo.Group`) routes registered on a +// group inherit every enclosing `Group(prefix)`. The prefix is recovered by +// walking the route's receiver back through its bindings, lexically, inside +// the enclosing function declaration only: a group handed to another +// function (`registerAdmin(v1)`) arrives as a parameter and contributes no +// prefix there, and a receiver bound to anything but a literal-prefix +// `Group(...)` call contributes none either — the route keeps its literal path. +// Statement-scoped bindings count too: an `if`/`switch` initializer, a `for` +// clause (including `range`), a type-switch guard, and declarations inside a +// switch case all scope over their statement the same way Go scopes them, so +// they shadow an outer group of the same name instead of being skipped. A +// select case's receive binding (`case g := <-ch:`) stops the walk entirely: +// what arrives from the channel is statically unknown, so the route keeps its +// literal path rather than inheriting an outer group. + +const MAX_GROUP_DEPTH = 32; + +function joinRoutePath(prefix: string, relative: string): string { + let joined = relative; + if (prefix && relative) { + joined = `${prefix.replace(/\/+$/, '')}/${relative.replace(/^\/+/, '')}`; + } else if (prefix) { + joined = prefix; + } + // Collapse duplicate slashes on the FINAL result — every return branch, not + // just the join — because ingestion's normalizeExtractedRoutePath collapses + // all "//" while the downstream contract-id normalizer does not: a path + // that keeps "//" would split into two contract ids across the strategies. + const collapsed = joined.replace(/\/+/g, '/'); + // Force a leading "/" for the same reason: ingestion's + // normalizeExtractedRoutePath always adds one, while normalizeHttpPath (the + // shared contract-id normalizer) does not — a literal "x" or a slashless + // Group("api") prefix would emit `...::x` here and `...::/x` there. Gin + // also refuses a registration path that does not start with "/". + return collapsed.startsWith('/') ? collapsed : `/${collapsed}`; +} + +/** `parent.Group("/p", mw...)` → its receiver and literal prefix; null otherwise. */ +function asGroupCall( + node: Parser.SyntaxNode, +): { parent: Parser.SyntaxNode; prefix: string } | null { + if (node.type !== 'call_expression') return null; + const fn = node.childForFieldName('function'); + if (fn?.type !== 'selector_expression' || fn.childForFieldName('field')?.text !== 'Group') { + return null; + } + const parent = fn.childForFieldName('operand'); + const first = codeChildren(node.childForFieldName('arguments'))[0]; + // Only a string literal carries a prefix, and it must decode to the text + // the runtime registers: stringLiteral applies Go unescaping (both `"…"` + // with escapes and raw `` `…` `` strings). A non-literal argument (a + // variable, concatenation) or an undecodable string contributes no prefix. + const prefix = first ? stringLiteral(first) : null; + if (!parent || prefix === null) return null; + return { parent, prefix }; +} + +/** The expression `name` is assigned by `stmt` (`:=`, `=`, or `var`), if any. */ +function boundValue(stmt: Parser.SyntaxNode, name: string): Parser.SyntaxNode | null | undefined { + const pick = ( + names: Parser.SyntaxNode[], + values: Parser.SyntaxNode | null, + ): Parser.SyntaxNode | null | undefined => { + const i = names.findIndex((n) => n.type === 'identifier' && n.text === name); + if (i < 0) return undefined; + return codeChildren(values)[i] ?? null; + }; + switch (stmt.type) { + case 'short_var_declaration': + case 'assignment_statement': + return pick(codeChildren(stmt.childForFieldName('left')), stmt.childForFieldName('right')); + case 'var_spec': + return pick(stmt.childrenForFieldName('name'), stmt.childForFieldName('value')); + case 'var_declaration': { + const specs = stmt.namedChildren.flatMap((c) => + c.type === 'var_spec_list' ? c.namedChildren : [c], + ); + for (const spec of specs) { + if (spec.type !== 'var_spec') continue; + const value = pick(spec.childrenForFieldName('name'), spec.childForFieldName('value')); + if (value !== undefined) return value; + } + return undefined; + } + default: + return undefined; + } +} + +/** + * A binding whose value cannot be established statically: an earlier + * statement writes the name inside a nested scope (`{ g = r.Group("/new") }`, + * a branch or loop body), so which value reaches the use depends on control + * flow. Callers decline the route instead of picking the older binding. + */ +const CONFLICT = Symbol('conflicting-binding'); +/** The name is a parameter (or method receiver): its declaration carries a static type. */ +interface ParamBinding { + param: Parser.SyntaxNode; +} +type Binding = Parser.SyntaxNode | null | undefined | typeof CONFLICT | ParamBinding; + +function isParamBinding(b: Binding): b is ParamBinding { + return typeof b === 'object' && b !== null && 'param' in b; +} + +/** The parameter_declaration of `fn` (parameters or method receiver) declaring `name`. */ +function paramDeclaring(fn: Parser.SyntaxNode, name: string): Parser.SyntaxNode | null { + for (const list of [fn.childForFieldName('parameters'), fn.childForFieldName('receiver')]) { + for (const decl of codeChildren(list)) { + if (decl.type !== 'parameter_declaration' && decl.type !== 'variadic_parameter_declaration') { + continue; + } + if (decl.childrenForFieldName('name').some((n) => n.text === name)) return decl; + } + } + return null; +} + +/** Whether `inner` lies within `outer`'s source span. */ +function within(outer: Parser.SyntaxNode | null, inner: Parser.SyntaxNode): boolean { + return !!outer && outer.startIndex <= inner.startIndex && inner.endIndex <= outer.endIndex; +} + +/** + * Whether `stmt` writes `name` with a plain assignment somewhere inside it + * (`g = …` in a nested block, branch, loop, or closure body). A nested `:=` + * declares a new variable and is not a write to the outer one. + */ +function writesNameInside(stmt: Parser.SyntaxNode, name: string): boolean { + return [stmt, ...stmt.descendantsOfType('assignment_statement')].some( + (a) => a.type === 'assignment_statement' && declaresName(a.childForFieldName('left'), name), + ); +} + +/** Whether an identifier or expression_list (e.g. a range left side) declares `name`. */ +function declaresName(node: Parser.SyntaxNode | null, name: string): boolean { + if (!node) return false; + if (node.type === 'identifier') return node.text === name; + return node.namedChildren.some((n) => n.type === 'identifier' && n.text === name); +} + +/** + * The value last bound to identifier `ident` before its use: the nearest + * binding site in the enclosing scopes, walking outward — preceding statements + * in blocks and switch/select cases (`expression_case`/`type_case`/ + * `communication_case`/`default_case` act as statement containers), then + * statement-scoped bindings (`if`/`switch` initializers, `for` clauses + * including `range`, type-switch guards), up to the enclosing function + * declaration. Returns null when the name is a parameter, is bound without a + * value, is received from a channel by a select case head (the received value + * is statically unknown, so the walk stops instead of escaping to an outer + * group), or is not bound in scope. + */ +function findBinding(ident: Parser.SyntaxNode): Parser.SyntaxNode | null | typeof CONFLICT { + const binding = lookupBinding(ident); + return isParamBinding(binding) ? null : (binding ?? null); +} + +/** + * findBinding's walk, keeping "declared without a traceable value" (null: a + * `func` literal parameter, `var x T`, a select receive) apart from "not + * declared before reaching the enclosing function declaration" (undefined). + * CONFLICT means a preceding statement writes the name in a nested scope, so + * the value at the use is control-flow dependent. A parameter or method + * receiver of the enclosing function returns its declaration (ParamBinding): + * no value, but a static type. + */ +function lookupBinding(ident: Parser.SyntaxNode): Binding { + const name = ident.text; + let child: Parser.SyntaxNode = ident; + for (let node = ident.parent; node; child = node, node = node.parent) { + if ( + node.type === 'function_declaration' || + node.type === 'method_declaration' || + node.type === 'func_literal' + ) { + const param = paramDeclaring(node, name); + if (param) return { param }; + if (node.type === 'func_literal') continue; + return undefined; + } + // Inside a grouped `var ( a = …; b = a.Group(…) )`, the specs before the + // one holding the use are already in scope; the current and later specs + // are not (`var g = g.Group(…)` reads the outer g). + if (node.type === 'var_spec_list') { + const specs = codeChildren(node); + const useIndex = specs.findIndex((s) => s.id === child.id); + for (const spec of specs.slice(0, useIndex).reverse()) { + const value = boundValue(spec, name); + if (value !== undefined) return value; + } + continue; + } + if ( + node.type === 'block' || + node.type === 'expression_case' || + node.type === 'type_case' || + node.type === 'communication_case' || + node.type === 'default_case' + ) { + // A select case head can rebind the name (`case g := <-ch:` or + // `case g = <-ch:`); the received value is statically unknown, so the + // walk must STOP with no traceable value — the same decline the + // ingestion-side route bindings record (value null) — instead of + // escaping to an outer group of the same name. + if (node.type === 'communication_case') { + for (const head of node.children) { + if ( + head.type === 'receive_statement' && + declaresName(head.childForFieldName('left'), name) + ) { + return null; + } + } + } + const stmts = codeChildren(node); + const useIndex = stmts.findIndex((s) => s.id === child.id); + for (const stmt of stmts.slice(0, useIndex).reverse()) { + const value = boundValue(stmt, name); + if (value !== undefined) return value; + // A write nested in an earlier statement (block, branch, loop) may or + // may not run before the use: the reaching value is unprovable. + if (writesNameInside(stmt, name)) return CONFLICT; + } + continue; + } + // Statement-scoped bindings enclose the use the same way Go scopes them. + if (node.type === 'if_statement' || node.type === 'expression_switch_statement') { + // A use inside the initializer itself (`if g := g.Group(…); …`) reads + // the OUTER binding: the new one only scopes over what follows it. + const init = node.childForFieldName('initializer'); + if (init && !within(init, ident)) { + const value = boundValue(init, name); + if (value !== undefined) return value; + } + continue; + } + if (node.type === 'for_statement') { + const clause = codeChildren(node)[0]; + // A write in the loop's post statement, condition, or body runs between + // iterations, so from the second pass on the body sees that value + // instead of the one it entered with (`for ; c; g = r.Group("/post")`): + // the prefix is control-flow dependent, so decline it. + if (within(node.childForFieldName('body'), ident)) { + const loopParts = [ + node.childForFieldName('body'), + clause?.type === 'for_clause' ? clause.childForFieldName('update') : null, + clause?.type === 'for_clause' ? clause.childForFieldName('condition') : null, + ]; + if (loopParts.some((part) => part && writesNameInside(part, name))) return CONFLICT; + } + if (clause?.type === 'for_clause') { + // Only the initializer binds before the body; an absent initializer + // (`for ; c; i++`) binds nothing. + const init = clause.childForFieldName('initializer'); + if (init && !within(init, ident)) { + const value = boundValue(init, name); + if (value !== undefined) return value; + } + } else if (clause?.type === 'range_clause') { + if ( + declaresName(clause.childForFieldName('left'), name) && + !within(clause.childForFieldName('right'), ident) + ) { + return clause.childForFieldName('right') ?? null; + } + } + continue; + } + if (node.type === 'type_switch_statement') { + // `switch g := x.(type)` — the guard list is the `alias` field, which + // only the `:=` form has (bare `switch x.(type)` parses with none), + // matching how the ingestion-side route-bindings read it. The switched + // value is the operand right after the guard list. + const guard = node.childForFieldName('alias'); + const switched = codeChildren(node)[1] ?? null; + if ( + guard?.type === 'expression_list' && + declaresName(guard, name) && + !within(switched, ident) + ) { + return switched; + } + continue; + } + } + return undefined; +} + +/** + * Whether a mixed-import file's route receiver traces back to echo's + * constructor — `e := echo.New()` / `echo.Default()` with `echo` resolving to + * one of the file's verified echo import aliases — either directly or through + * enclosing `Group(...)` calls (`users := api.Group(…)` ← `api := e.Group(…)` + * ← `echo.New()`), the normal shape of grouped routes (review #7, #10). + * A parameter or method receiver counts by its declared type instead: + * `e *echo.Echo` / `g *echo.Group` (with `echo` one of those aliases) proves + * echo; any other type — gin's, or one the file cannot tie to echo — does not. + * Unrelated packages' `New()`, a local that shadows the echo import name, and + * anything else return false so the caller keeps the conservative + * last-argument fallback instead of guessing. Returns null when the Group chain exceeds MAX_GROUP_DEPTH or a + * binding on it is control-flow dependent (CONFLICT): the framework is then + * unprovable either way, so the caller declines the route. + */ +function receiverBindsToEchoConstructor( + receiver: Parser.SyntaxNode, + echoAliases: ReadonlySet, + depth = 0, +): boolean | null { + if (depth > MAX_GROUP_DEPTH) return null; + // An identifier resolves through its binding; a chained `X.Group(…).Group(…)` + // operand is already a call and is inspected as-is. + const value = receiver.type === 'identifier' ? lookupBinding(receiver) : receiver; + if (value === CONFLICT) return null; + if (isParamBinding(value)) + return isEchoRouterType(value.param.childForFieldName('type'), echoAliases); + if (value?.type !== 'call_expression') return false; + const fn = value.childForFieldName('function'); + if (fn?.type !== 'selector_expression') return false; + const field = fn.childForFieldName('field')?.text; + const operand = fn.childForFieldName('operand'); + if (!operand) return false; + if (field === 'New' || field === 'Default') { + return operand.type === 'identifier' && echoAliases.has(operand.text) && !isLocalName(operand); + } + if (field === 'Group') { + return receiverBindsToEchoConstructor(operand, echoAliases, depth + 1); + } + return false; +} + +/** `*echo.Echo` / `echo.Echo` / `*echo.Group` with `echo` a verified echo import alias. */ +function isEchoRouterType( + type: Parser.SyntaxNode | null, + echoAliases: ReadonlySet, +): boolean { + const named = type?.type === 'pointer_type' ? codeChildren(type)[0] : type; + if (named?.type !== 'qualified_type') return false; + const pkg = named.childForFieldName('package')?.text; + const typeName = named.childForFieldName('name')?.text; + return !!pkg && echoAliases.has(pkg) && (typeName === 'Echo' || typeName === 'Group'); +} + +/** + * Whether `ident` names a local value rather than an imported package: a + * declaration in scope (with or without a value — `var echo Factory` shadows + * too) or a parameter/receiver of an enclosing function. Go lets either + * shadow a package qualifier (`func f(echo *Factory) { echo.New() }`). + */ +function isLocalName(ident: Parser.SyntaxNode): boolean { + // lookupBinding covers parameters and receivers too (ParamBinding). + return lookupBinding(ident) !== undefined; +} + +/** + * Joined `Group(...)` prefix of a route receiver; '' when it cannot be traced. + * Returns null when the chain exceeds MAX_GROUP_DEPTH, or when a binding on it + * is control-flow dependent (CONFLICT): a partial or stale prefix would emit + * a wrong path, so the caller declines the route instead. + */ +function groupPrefix(receiver: Parser.SyntaxNode, depth = 0): string | null { + if (depth > MAX_GROUP_DEPTH) return null; + const value = receiver.type === 'identifier' ? findBinding(receiver) : receiver; + if (value === CONFLICT) return null; + const group = value ? asGroupCall(value) : null; + if (!group) return ''; + const outer = groupPrefix(group.parent, depth + 1); + return outer === null ? null : joinRoutePath(outer, group.prefix); +} + // ─── Provider: net/http `http.HandleFunc("/p", handler)` ───────────── const HANDLE_FUNC_PATTERNS = compilePatterns({ name: 'go-handle-func', @@ -135,27 +561,87 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { scan(tree) { const out: HttpDetection[] = []; - // Framework providers: r.GET/POST/... with handler identifier + // Framework providers: r.GET/POST/... on an engine or (nested) route group + const imports = readFrameworkImports(tree.rootNode); + const echoOnly = imports.echo.size > 0 && imports.gin.size === 0; + const mixed = imports.echo.size > 0 && imports.gin.size > 0; + // Handler names declared more than once in this file (`(h *A) List` and + // `(o *B) List`): the emitted name is field-only, so these must resolve + // only when unique in the file instead of taking the first same-named row. + const declaredNames = new Map(); + for (const decl of tree.rootNode.descendantsOfType([ + 'function_declaration', + 'method_declaration', + ])) { + const declName = decl.childForFieldName('name')?.text; + if (declName) declaredNames.set(declName, (declaredNames.get(declName) ?? 0) + 1); + } for (const match of runCompiledPatterns(FRAMEWORK_ROUTE_PATTERNS, tree)) { const methodNode = match.captures.http_method; const pathNode = match.captures.path; - const handlerNode = match.captures.handler; + const receiverNode = match.captures.receiver; if (!methodNode || !pathNode) continue; - const path = unquoteLiteral(pathNode.text); - if (path === null) continue; + const literalPath = stringLiteral(pathNode); + if (literalPath === null) continue; + const argList = pathNode.parent; + if (argList?.type !== 'argument_list') continue; + const args = codeChildren(argList); + if (args[0]?.id !== pathNode.id) continue; + // The path is the first code argument, so everything after it is a handler or + // middleware candidate: echo's verb calls take the FIRST of those, gin's + // the LAST (see FRAMEWORK_ROUTE_PATTERNS / readFrameworkImports). The + // rule is chosen per call: an echo-only file is unambiguous; a + // mixed-import file takes the first argument only when the receiver + // provably traces to echo's constructor (directly or through enclosing + // Group() calls) — everything else keeps the last-argument anchor, + // gin's order and the safer default when the file proves nothing. + const rest = args.slice(1); + if (rest.length === 0) continue; + const echoOrder = mixed + ? receiverNode + ? receiverBindsToEchoConstructor(receiverNode, imports.echo) + : false + : echoOnly; + // A Group chain deeper than MAX_GROUP_DEPTH proves neither the full + // prefix nor the framework order: decline rather than emit a guess. + if (echoOrder === null) continue; + const prefix = receiverNode ? groupPrefix(receiverNode) : ''; + if (prefix === null) continue; + const handlerNode = echoOrder ? rest[0] : rest[rest.length - 1]; + if (!HANDLER_ARG_TYPES.has(handlerNode.type)) continue; + const path = receiverNode ? joinRoutePath(prefix, literalPath) : literalPath; // An inline `func(){…}` handler has no name → emit `name: null` and a // `line` so it resolves to its containing/closure symbol by line-span - // containment (like a consumer). A named identifier handler keeps its - // name and resolves by name; `line` is harmless there. + // containment (like a consumer). A named handler keeps its name and + // resolves by name; `line` is harmless there. For a method value or a + // package-qualified function (`h.List`, `pkg.List`) that name is the + // field, and the operand (usually a local variable, not the receiver + // type) does not prove where `List` is declared — so the detection is + // marked qualifiedHandler: resolve only to a repo-wide unique `List`, + // never to a same-named local method that merely shares the name. const isInlineHandler = handlerNode?.type === 'func_literal'; + const isQualified = handlerNode?.type === 'selector_expression'; + const handlerName = isQualified + ? (handlerNode.childForFieldName('field')?.text ?? null) + : (handlerNode?.text ?? null); out.push({ role: 'provider', framework: 'go-framework', method: methodNode.text.toUpperCase(), path, - name: isInlineHandler ? null : (handlerNode?.text ?? null), + name: isInlineHandler ? null : handlerName, line: (handlerNode ?? pathNode).startPosition.row + 1, confidence: 0.8, + ...(isQualified ? { qualifiedHandler: true } : {}), + // A bare name declared more than once in this file (a function and a + // method of the same name) resolves only when the file holds exactly + // one match, rather than binding to whichever row the graph lists first. + ...(!isQualified && + !isInlineHandler && + handlerName && + (declaredNames.get(handlerName) ?? 0) > 1 + ? { strictHandlerResolution: true } + : {}), }); } @@ -164,7 +650,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { const pathNode = match.captures.path; const handlerNode = match.captures.handler; if (!pathNode) continue; - const path = unquoteLiteral(pathNode.text); + const path = stringLiteral(pathNode); if (path === null) continue; // Inline `func(){…}` handler → resolve by containment (see go-framework // note above); a named handler resolves by name. @@ -187,7 +673,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { if (!fnNode || !pathNode) continue; const httpMethod = HTTP_CLIENT_METHOD_TO_HTTP[fnNode.text]; if (!httpMethod) continue; - const path = unquoteLiteral(pathNode.text); + const path = stringLiteral(pathNode); if (path === null) continue; out.push({ role: 'consumer', @@ -205,8 +691,8 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { const methodNode = match.captures.http_method; const pathNode = match.captures.path; if (!methodNode || !pathNode) continue; - const method = unquoteLiteral(methodNode.text); - const path = unquoteLiteral(pathNode.text); + const method = stringLiteral(methodNode); + const path = stringLiteral(pathNode); if (method === null || path === null) continue; out.push({ role: 'consumer', @@ -224,7 +710,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { const methodNode = match.captures.http_method; const pathNode = match.captures.path; if (!methodNode || !pathNode) continue; - const path = unquoteLiteral(pathNode.text); + const path = stringLiteral(pathNode); if (path === null) continue; out.push({ role: 'consumer', diff --git a/gitnexus/src/core/group/extractors/http-patterns/types.ts b/gitnexus/src/core/group/extractors/http-patterns/types.ts index 38c197704..bbd3fe1a2 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/types.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/types.ts @@ -58,6 +58,14 @@ export interface HttpDetection { handlerImport?: { name: string; module: string }; /** Resolve only from the registration file or exact import target; never guess repo-wide. */ strictHandlerResolution?: boolean; + /** + * The handler was designated through a qualifier the plugin cannot tie to a + * declaration (`recv.name`, `pkg.name`): `name` alone does not prove the + * handler lives in the registration file. Skip the file-scoped name lookup + * — a same-named but unrelated local symbol would win it — and accept only + * a repo-wide unique match, else keep the file-level fallback. + */ + qualifiedHandler?: boolean; /** * The plugin saw a provider handler designator but could not prove its owner. * Prevents the orchestrator from treating it as an anonymous inline handler diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index d116bc046..707ccdff7 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -629,11 +629,17 @@ export class HttpRouteExtractor implements ContractExtractor { if (d.strictHandlerResolution) return null; return resolveSymbolByNameUnique(d.handlerImport.name); } - const byName = d.strictHandlerResolution - ? resolveFileSymbolByNameUnique(syms, d.name) - : resolveSymbolByName(syms, d.name); - if (byName) return byName; - if (d.strictHandlerResolution) return null; + // A qualified designator (`recv.name`) does not prove the handler is + // declared in this file, so the file-first rung could bind an + // unrelated same-named local symbol: go straight to the unique + // repo-wide match. + if (!d.qualifiedHandler) { + const byName = d.strictHandlerResolution + ? resolveFileSymbolByNameUnique(syms, d.name) + : resolveSymbolByName(syms, d.name); + if (byName) return byName; + if (d.strictHandlerResolution) return null; + } const byGlobal = await resolveSymbolByNameUnique(d.name); if (byGlobal) return byGlobal; // A NAMED handler we could not resolve by name (neither file-scoped nor diff --git a/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts index f7cdd1c64..e77b1b82b 100644 --- a/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts +++ b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts @@ -26,6 +26,7 @@ import type Parser from 'tree-sitter'; import { goImportPackageName } from '../languages/go/import-package-name.js'; import { GoRouteBindings, type GoRouteBinding } from '../languages/go/route-bindings.js'; +import { stringLiteral } from './go-shared.js'; import { normalizeExtractedRoutePath } from './route-path.js'; import type { SyntaxNode } from 'tree-sitter'; import type { ExtractedDecoratorRoute, RouteHandlerReceiver } from '../workers/parse-worker.js'; @@ -58,59 +59,6 @@ interface Framework { const FUNCTION_TYPE_LIST = ['function_declaration', 'method_declaration', 'func_literal']; const FUNCTION_TYPES: ReadonlySet = new Set(FUNCTION_TYPE_LIST); -function stringLiteral(node: SyntaxNode | null | undefined): string | null { - if (!node || node.hasError) return null; - const body = node.text.slice(1, -1); - // Go discards carriage returns in raw strings, including CRLF source files. - if (node.type === 'raw_string_literal') return body.replace(/\r/g, ''); - if (node.type !== 'interpreted_string_literal') return null; - if (!body.includes('\\')) return body; - - const simple: Readonly> = { - a: '\x07', - b: '\b', - f: '\f', - n: '\n', - r: '\r', - t: '\t', - v: '\v', - '\\': '\\', - '"': '"', - }; - const chunks: Buffer[] = []; - const tokens = - /\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g; - let consumed = 0; - for (const match of body.matchAll(tokens)) { - if (match.index !== consumed) return null; - const token = match[0]; - consumed += token.length; - if (!token.startsWith('\\')) { - chunks.push(Buffer.from(token)); - } else if (simple[token[1]] !== undefined) { - chunks.push(Buffer.from(simple[token[1]])); - } else { - const octal = /[0-7]/.test(token[1]); - const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16); - if (octal || token[1] === 'x') { - // Octal and hex escapes encode bytes, not Unicode code points. - if (value > 255) return null; - chunks.push(Buffer.from([value])); - } else { - if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return null; - chunks.push(Buffer.from(String.fromCodePoint(value))); - } - } - } - if (consumed !== body.length) return null; - try { - // Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL. - return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)); - } catch { - return null; - } -} - /** The framework this file routes with, when exactly one is imported. */ function readImports(root: SyntaxNode): { readonly framework: Framework | null; diff --git a/gitnexus/src/core/ingestion/route-extractors/go-shared.ts b/gitnexus/src/core/ingestion/route-extractors/go-shared.ts new file mode 100644 index 000000000..a2d13faef --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/go-shared.ts @@ -0,0 +1,75 @@ +import type { SyntaxNode } from 'tree-sitter'; + +/** + * Go string-literal decoding shared by the Go route extractors on both + * layers: the ingestion extractor (`go-gin-echo.ts`, Strategy A) and the + * group-mode HTTP plugin (`group/extractors/http-patterns/go.ts`, + * Strategy B). Both sides must produce the SAME text for a route literal, + * or the same route lands under two different contract ids that never + * collide in the merge — a wrong-path duplicate that survives alongside + * the graph's correct entry. + * + * Go's semantics, per `strconv.Unquote`: + * - interpreted (`"…"`) strings decode escapes (`\n`, `\x2f`, `ሴ`, + * `\101`, …); hex and octal escapes encode BYTES, not code points; + * - raw (`` `…` ``) strings have no escapes (a backslash is literal), + * and Go discards carriage returns in them, including CRLF source files; + * - a string that cannot be decoded to valid UTF-8 text (invalid escape, + * non-UTF-8 bytes) returns null — callers decline instead of guessing, + * since a URL cannot carry those bytes losslessly. + * + * Nodes that are not string literals (an identifier prefix, a rune + * literal, an errored subtree) also return null. + */ +export function stringLiteral(node: SyntaxNode | null | undefined): string | null { + if (!node || node.hasError) return null; + const body = node.text.slice(1, -1); + // Go discards carriage returns in raw strings, including CRLF source files. + if (node.type === 'raw_string_literal') return body.replace(/\r/g, ''); + if (node.type !== 'interpreted_string_literal') return null; + if (!body.includes('\\')) return body; + + const simple: Readonly> = { + a: '\x07', + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + '\\': '\\', + '"': '"', + }; + const chunks: Buffer[] = []; + const tokens = + /\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g; + let consumed = 0; + for (const match of body.matchAll(tokens)) { + if (match.index !== consumed) return null; + const token = match[0]; + consumed += token.length; + if (!token.startsWith('\\')) { + chunks.push(Buffer.from(token)); + } else if (simple[token[1]] !== undefined) { + chunks.push(Buffer.from(simple[token[1]])); + } else { + const octal = /[0-7]/.test(token[1]); + const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16); + if (octal || token[1] === 'x') { + // Octal and hex escapes encode bytes, not Unicode code points. + if (value > 255) return null; + chunks.push(Buffer.from([value])); + } else { + if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return null; + chunks.push(Buffer.from(String.fromCodePoint(value))); + } + } + } + if (consumed !== body.length) return null; + try { + // Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL. + return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)); + } catch { + return null; + } +} diff --git a/gitnexus/test/unit/group/go-gin-route-groups.test.ts b/gitnexus/test/unit/group/go-gin-route-groups.test.ts new file mode 100644 index 000000000..bdb5bde56 --- /dev/null +++ b/gitnexus/test/unit/group/go-gin-route-groups.test.ts @@ -0,0 +1,969 @@ +/** + * Group HTTP-contract layer: Go gin/echo framework routes registered through + * route groups and method-value handlers. Exercises `GO_HTTP_PLUGIN.scan` + * directly with a real tree-sitter parser, asserting the FULL registered path + * (every enclosing `x := y.Group("/p")` prefix joined in) and the handler name + * the group layer resolves by. The last block runs the real extractor on a Go + * provider repo and a fetch() consumer repo and pairs them with `runExactMatch`. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import Parser from 'tree-sitter'; +import Go from 'tree-sitter-go'; +import { GO_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/go.js'; +import { + HttpRouteExtractor, + RESOLVE_BY_NAME_QUERY, +} from '../../../src/core/group/extractors/http-route-extractor.js'; +import { runExactMatch } from '../../../src/core/group/matching.js'; +import type { RepoHandle, StoredContract } from '../../../src/core/group/types.js'; + +const parser = new Parser(); + +interface Provider { + method: string; + path: string; + name: string | null; +} + +function providers(src: string): Provider[] { + parser.setLanguage(Go); + return GO_HTTP_PLUGIN.scan(parser.parse(src)) + .filter((d) => d.role === 'provider') + .map(({ method, path: p, name }) => ({ method, path: p, name })); +} + +// Mirrors the shapes of a real gin `RegisterRoutes`: an engine-level group, +// `{ }` blocks, nested groups three levels deep, empty-prefix groups used only +// to attach middleware, method-value / package-func / identifier / inline +// handlers, and variadic middleware before the handler. +const GIN_ROUTES = `package handlers + +import "github.com/gin-gonic/gin" + +func RegisterRoutes(r *gin.Engine, svc *service.Service) { + playerHandler := NewPlayerHandler(svc.Player) + matchHandler := NewMatchHandler(svc.Match) + r.GET("/ping", pingHandle) + v1 := r.Group("/api/v1") + { + v1.GET("/health", func(c *gin.Context) { c.Status(200) }) + v1.GET("/players", playerHandler.GetPlayersHandle) + v1.POST("/upload/avatar", UploadAvatarHandle) + v1.GET("/exports", exports.ListExportsHandle) + v1.PATCH("/players/:playerId", middleware.AuthRequired(svc.Auth), playerHandler.UpdatePlayerHandle) + admin := v1.Group("/admin") + admin.Use(middleware.AdminRequired(svc.AdminControl)) + { + admin.POST("/seasons/:seasonId/rounds/:roundId/unfinalize", matchHandler.UnfinalizeRoundHandle) + newsAdmin := admin.Group("/news") + { + newsAdmin.DELETE("/:id", newsHandler.DeleteNewsHandle) + } + knockoutAdmin := admin.Group("", middleware.KnockoutGuard()) + knockoutAdmin.POST("/seasons/:seasonId/knockout", knockoutHandler.CreateKnockoutHandle) + adminOnly := admin.Group("") + adminOnly.PUT("/seasons/:seasonId/streak-config", streakHandler.SaveStreakConfigHandle) + } + } +} +`; + +describe('GO_HTTP_PLUGIN — gin route groups', () => { + const got = providers(GIN_ROUTES); + const find = (method: string, p: string) => got.find((d) => d.method === method && d.path === p); + + it('emits exactly one provider per registered route (Use() and Group() are not routes)', () => { + expect(got).toHaveLength(10); + }); + + it('keeps a route on the engine root, outside any group, at its literal path', () => { + expect(find('GET', '/ping')).toEqual({ method: 'GET', path: '/ping', name: 'pingHandle' }); + }); + + it('prefixes an inline func_literal handler and leaves it unnamed', () => { + expect(find('GET', '/api/v1/health')).toEqual({ + method: 'GET', + path: '/api/v1/health', + name: null, + }); + }); + + it('accepts a method-value handler and names it by its field', () => { + expect(find('GET', '/api/v1/players')?.name).toBe('GetPlayersHandle'); + }); + + it('accepts a package-qualified function handler and names it by its field', () => { + expect(find('GET', '/api/v1/exports')?.name).toBe('ListExportsHandle'); + }); + + it('keeps an identifier handler', () => { + expect(find('POST', '/api/v1/upload/avatar')?.name).toBe('UploadAvatarHandle'); + }); + + it('binds the last argument, not the variadic middleware before it', () => { + expect(find('PATCH', '/api/v1/players/:playerId')?.name).toBe('UpdatePlayerHandle'); + }); + + it('joins a nested group inside a { } block', () => { + expect(find('POST', '/api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize')?.name).toBe( + 'UnfinalizeRoundHandle', + ); + }); + + it('joins groups nested three levels deep', () => { + expect(find('DELETE', '/api/v1/admin/news/:id')?.name).toBe('DeleteNewsHandle'); + }); + + it('treats an empty-prefix group (with or without middleware) as its parent prefix', () => { + expect(find('POST', '/api/v1/admin/seasons/:seasonId/knockout')?.name).toBe( + 'CreateKnockoutHandle', + ); + expect(find('PUT', '/api/v1/admin/seasons/:seasonId/streak-config')?.name).toBe( + 'SaveStreakConfigHandle', + ); + }); +}); + +describe('GO_HTTP_PLUGIN — group binding edge cases', () => { + it('follows plain `=` assignment and `var x = …` declarations', () => { + const got = providers(`package main +func routes(r *gin.Engine) { + var api *gin.RouterGroup + api = r.Group("/v2") + api.GET("/a", h.A) + var ops = api.Group("/ops") + ops.GET("/b", h.B) +} +`); + expect(got).toEqual([ + { method: 'GET', path: '/v2/a', name: 'A' }, + { method: 'GET', path: '/v2/ops/b', name: 'B' }, + ]); + }); + + it('joins a Group() call chained directly onto the route call', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + r.Group("/inline").GET("/y", h.Y) +} +`), + ).toEqual([{ method: 'GET', path: '/inline/y', name: 'Y' }]); + }); + + it('uses the binding visible at the call site when a name is reused in sibling blocks', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + { + g := r.Group("/a") + g.GET("/x", h.AX) + } + { + g := r.Group("/b") + g.GET("/x", h.BX) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/a/x', name: 'AX' }, + { method: 'GET', path: '/b/x', name: 'BX' }, + ]); + }); + + it('uses the latest assignment that precedes the route, not one after it', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/first") + g.GET("/x", h.X) + g = r.Group("/second") + g.GET("/y", h.Y) +} +`), + ).toEqual([ + { method: 'GET', path: '/first/x', name: 'X' }, + { method: 'GET', path: '/second/y', name: 'Y' }, + ]); + }); + + it('resolves a group captured by a closure registered inside the function', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + v1 := r.Group("/api/v1") + register := func() { + v1.GET("/inner", h.Inner) + } + register() +} +`), + ).toEqual([{ method: 'GET', path: '/api/v1/inner', name: 'Inner' }]); + }); + + it('keeps the literal path when the receiver is a parameter (group passed from another function)', () => { + expect( + providers(`package main +func registerAdmin(g *gin.RouterGroup) { + g.GET("/extra", extraHandle) +} +`), + ).toEqual([{ method: 'GET', path: '/extra', name: 'extraHandle' }]); + }); + + it('keeps the literal path when the receiver is bound to something other than Group()', () => { + expect( + providers(`package main +func routes() { + g := newRouter("/ignored") + g.GET("/x", h.X) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'X' }]); + }); + + it('does not resolve a group bound in a different function', () => { + expect( + providers(`package main +func a(r *gin.Engine) { + g := r.Group("/a") + g.GET("/in-a", h.A) +} +func b(g *gin.RouterGroup) { + g.GET("/in-b", h.B) +} +`), + ).toEqual([ + { method: 'GET', path: '/a/in-a', name: 'A' }, + { method: 'GET', path: '/in-b', name: 'B' }, + ]); + }); + + it('ignores a Group() whose prefix is not a string literal and keeps the route literal', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group(prefix) + g.GET("/x", h.X) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'X' }]); + }); + + it('uses the group bound by an if initializer in the body and in the else branch', () => { + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/outer") + if g := r.Group("/inner"); cond { + g.GET("/x", h.X) + } else { + g.GET("/y", h.Y) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/inner/x', name: 'X' }, + { method: 'GET', path: '/inner/y', name: 'Y' }, + ]); + }); + + it('keeps the outer group when an if initializer binds a different name', () => { + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/outer") + if x := prepare(); cond { + g.GET("/x", h.X) + } +} +`), + ).toEqual([{ method: 'GET', path: '/outer/x', name: 'X' }]); + }); + + it('stops at an if initializer bound to something other than Group()', () => { + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/outer") + if g := build(); cond { + g.GET("/x", h.X) + } +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'X' }]); + }); + + it('uses a switch initializer group and a group declared inside a case clause', () => { + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/outer") + switch g := r.Group("/s"); g != nil { + case cond: + g.GET("/x", h.X) + } + switch { + case cond: + g := r.Group("/case") + g.GET("/y", h.Y) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/s/x', name: 'X' }, + { method: 'GET', path: '/case/y', name: 'Y' }, + ]); + }); + + it('stops at a type-switch guard binding and resolves groups declared in a type case', () => { + expect( + providers(`package main +func routes(r *gin.Engine, anyVal any) { + g := r.Group("/outer") + switch g := anyVal.(type) { + case *Router: + g.GET("/x", h.X) + } + switch anyVal.(type) { + case interface{}: + g := r.Group("/t") + g.GET("/y", h.Y) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/x', name: 'X' }, + { method: 'GET', path: '/t/y', name: 'Y' }, + ]); + }); + + it('stops at a select receive binding instead of inheriting an outer group', () => { + // The value received from a channel is statically unknown, so a case that + // rebinds `g` must shadow the outer group with "nothing traceable" (the + // route keeps its literal path) — not leak `/outer` into the id. An + // unrebound case still inherits, and a default clause declaring its own + // group shadows like any other statement list. + expect( + providers(`package main +func routes(r *gin.Engine, ch chan *gin.RouterGroup) { + g := r.Group("/outer") + select { + case g := <-ch: + g.GET("/x", h.X) + } + select { + case <-ch: + g.GET("/y", h.Y) + } + select { + default: + g := r.Group("/d") + g.GET("/z", h.Z) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/x', name: 'X' }, + { method: 'GET', path: '/outer/y', name: 'Y' }, + { method: 'GET', path: '/d/z', name: 'Z' }, + ]); + }); + + it('keeps the outer group through a plain for init and stops at a range shadow binding', () => { + expect( + providers(`package main +func routes(r *gin.Engine, n int, subs []*gin.RouterGroup) { + g := r.Group("/outer") + for i := 0; i < n; i++ { + g.GET("/i", h.I) + } + for _, g := range subs { + g.GET("/r", h.R) + } + for g := r.Group("/loop"); ; { + g.GET("/l", h.L) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/outer/i', name: 'I' }, + { method: 'GET', path: '/r', name: 'R' }, + { method: 'GET', path: '/loop/l', name: 'L' }, + ]); + }); + + it('declines a route whose group a loop reassigns between iterations', () => { + // The post statement (or the body) runs between iterations, so from the + // second pass on the body sees the reassigned group rather than the one + // it entered with: the prefix is control-flow dependent, so the route is + // declined instead of emitting either value. A loop that never writes the + // name keeps its initializer binding. + expect( + providers(`package main +func routes(r *gin.Engine, cond bool, n int) { + g := r.Group("/old") + for ; cond; g = r.Group("/post") { + g.GET("/a", h.A) + } + for g := r.Group("/init"); ; g = r.Group("/post3") { + g.GET("/c", h.C) + } + for k := r.Group("/k"); cond; { + k.GET("/d", h.D) + } +} +`), + ).toEqual([{ method: 'GET', path: '/k/d', name: 'D' }]); + }); + + it('accepts a raw-string (backtick) group prefix', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group(\`/api\`) + g.GET("/x", h.X) +} +`), + ).toEqual([{ method: 'GET', path: '/api/x', name: 'X' }]); + }); + + it('accepts a raw-string (backtick) route path, as ingestion does', () => { + // Ingestion (Strategy A) decodes both Go string forms for the route path; + // the group layer must emit the same contract for a backtick path, both + // on a bound group and on a chained `Group(...).GET(...)` receiver. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/api") + g.GET(\`/health\`, h.Health) + r.Group("/v1").POST(\`/raw\\x2fy\`, h.Raw) +} +`), + ).toEqual([ + { method: 'GET', path: '/api/health', name: 'Health' }, + { method: 'POST', path: '/v1/raw\\x2fy', name: 'Raw' }, + ]); + }); + + it('decodes Go string escapes in group prefixes and route paths', () => { + // "/api\x2fv1" and "/health\x2fcheck" are `/api/v1` and `/health/check` + // once Go processes the escapes — the id must match the registered URL. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/api\\x2fv1") + g.GET("/health\\x2fcheck", h.H) +} +`), + ).toEqual([{ method: 'GET', path: '/api/v1/health/check', name: 'H' }]); + }); + + it('leaves escapes literal inside a raw-string (backtick) prefix', () => { + // Go raw strings process no escapes: the prefix is `/raw\x2fy`, backslash included. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group(\`/raw\\x2fy\`) + g.GET("/z", h.Z) +} +`), + ).toEqual([{ method: 'GET', path: '/raw\\x2fy/z', name: 'Z' }]); + }); + + it('collapses duplicate slashes so the path matches ingestion normalization', () => { + // normalizeExtractedRoutePath collapses every "//" run; the downstream + // contract-id normalizer does not — a path keeping "//" would get a + // different id than the graph's route node for the same route. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/api//v1") + g.GET("/a//b", h.A) + r.GET("//root", h.R) + r.GET("/ok", h.Ok) +} +`), + ).toEqual([ + { method: 'GET', path: '/api/v1/a/b', name: 'A' }, + { method: 'GET', path: '/root', name: 'R' }, + { method: 'GET', path: '/ok', name: 'Ok' }, + ]); + }); + + it('forces a leading slash on slashless literals and group prefixes', () => { + // Ingestion's normalizeExtractedRoutePath always adds a leading "/" but + // normalizeHttpPath (the shared contract-id normalizer) does not — a path + // like "x" would become `http::GET::x` here and `http::GET::/x` there, + // splitting the id across the two strategies. Gin likewise panics when a + // route is registered without one. + expect( + providers(`package main +func routes(r *gin.Engine) { + r.GET("x", h.X) + g := r.Group("api") + g.GET("y", h.Y) + r.GET("", h.Root) +} +`), + ).toEqual([ + { method: 'GET', path: '/x', name: 'X' }, + { method: 'GET', path: '/api/y', name: 'Y' }, + { method: 'GET', path: '/', name: 'Root' }, + ]); + }); + + it("binds echo's handler (first argument) when the file imports echo only", () => { + // echo is `GET(path, handler, middleware...)` — the handler is second, + // not last, so a middleware selector must not become the route's name. + expect( + providers(`package main +import "github.com/labstack/echo/v4" + +func routes(e *echo.Echo) { + e.GET("/x", h.Handler, auth.Middleware) + e.POST("/y", handlerID) + e.PUT("/z", func(c echo.Context) error { return nil }) +} +`), + ).toEqual([ + { method: 'GET', path: '/x', name: 'Handler' }, + { method: 'POST', path: '/y', name: 'handlerID' }, + { method: 'PUT', path: '/z', name: null }, + ]); + }); + + it('picks the handler order from a typed receiver parameter in a mixed-import file', () => { + // A parameter's static type proves its framework: `*echo.Echo` / + // `*echo.Group` take echo's order (handler FIRST), gin's types and types + // the file cannot tie to echo keep the last-argument fallback. Method + // receivers and func-literal parameters count the same way. + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes(e *echo.Echo, g *echo.Group, r *gin.RouterGroup, x *Router) { + e.GET("/e", h.Handler, auth.Middleware) + g.GET("/g", h.Handler, auth.Middleware) + r.GET("/r", auth.Middleware, h.Handler) + x.GET("/x", h.Handler, auth.Middleware) + register := func(sub *echo.Group) { + sub.GET("/f", h.Handler, auth.Middleware) + } + _ = register +} + +type Server struct{} + +func (s *Server) routes(api *echo.Group) { + api.GET("/m", h.Handler, auth.Middleware) +} +`), + ).toEqual([ + { method: 'GET', path: '/e', name: 'Handler' }, + { method: 'GET', path: '/g', name: 'Handler' }, + { method: 'GET', path: '/r', name: 'Handler' }, + { method: 'GET', path: '/x', name: 'Middleware' }, + { method: 'GET', path: '/f', name: 'Handler' }, + { method: 'GET', path: '/m', name: 'Handler' }, + ]); + }); + + it('picks the handler order per receiver constructor in a mixed-import file', () => { + // Mixed imports are ambiguous at file scope, but `e := echo.New()` proves + // this call follows echo's order (handler FIRST after the path) and + // `r := gin.Default()` proves gin's (handler LAST). + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes() { + e := echo.New() + r := gin.Default() + e.GET("/x", h.Handler, auth.Middleware) + r.POST("/y", auth.Middleware, h.Post) +} +`), + ).toEqual([ + { method: 'GET', path: '/x', name: 'Handler' }, + { method: 'POST', path: '/y', name: 'Post' }, + ]); + }); + + it('does not treat an unrelated New() as a framework constructor', () => { + // `wrapper.New()` is neither echo's nor gin's constructor: no proof → + // conservative last-argument fallback, not a guessed echo order. + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes() { + e := wrapper.New() + e.GET("/x", h.Handler, auth.Middleware) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'Middleware' }]); + }); + + it('does not mistake a local shadowing the echo import for its constructor', () => { + // A parameter named `echo` shadows the package qualifier: `echo.New()` + // is then a method on that value, not echo's constructor, so the mixed + // file keeps the conservative last-argument fallback. + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes(echo *Factory) { + e := echo.New() + e.GET("/x", h.Handler, auth.Middleware) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'Middleware' }]); + }); + + it('does not mistake a value-less local declaration of echo for the import', () => { + // `var echo Factory` declares a local without an initializer; it still + // shadows the package qualifier, so `echo.New()` proves nothing. + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes() { + var echo Factory + e := echo.New() + e.GET("/x", h.Handler, auth.Middleware) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'Middleware' }]); + }); + + it('declines a route whose Group chain exceeds the depth cap', () => { + // Past MAX_GROUP_DEPTH (32) the full prefix — and, in a mixed file, the + // framework order — is unprovable: emitting the outer prefixes alone (or + // gin's handler order for an echo chain) would be a silent guess. + const chain = (ctor: string, imports: string) => { + const lines = [`g0 := ${ctor}`]; + for (let i = 1; i <= 34; i++) lines.push(`g${i} := g${i - 1}.Group("/p${i}")`); + return `package main +${imports} + +func routes() { + ${lines.join('\n\t')} + g34.GET("/x", h.Handler, auth.Middleware) + g2.GET("/y", h.Handler, auth.Middleware) +} +`; + }; + expect(providers(chain('gin.Default()', 'import "github.com/gin-gonic/gin"'))).toEqual([ + { method: 'GET', path: '/p1/p2/y', name: 'Middleware' }, + ]); + const mixed = `import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +)`; + expect(providers(chain('echo.New()', mixed))).toEqual([ + { method: 'GET', path: '/p1/p2/y', name: 'Handler' }, + ]); + }); + + it('traces a grouped receiver back to its framework constructor in a mixed file', () => { + // The normal grouped shape: `users := api.Group(…)` ← `api := e.Group(…)` + // ← `echo.New()` proves echo order through the Group chain, while the + // gin chain resolves to gin.Default() and keeps the last-argument rule. + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes() { + e := echo.New() + api := e.Group("/api") + users := api.Group("/users") + users.GET("/:id", h.Handler, auth.Middleware) + r := gin.Default() + v1 := r.Group("/v1") + v1.POST("/x", auth.Middleware, h.Post) +} +`), + ).toEqual([ + { method: 'GET', path: '/api/users/:id', name: 'Handler' }, + { method: 'POST', path: '/v1/x', name: 'Post' }, + ]); + }); + + it('marks handler resolution by how the handler is designated', () => { + // `h.List` / `o.List` emit the field name `List`, but the operand does not + // prove where `List` is declared: they are qualifiedHandler (repo-wide + // unique match only, never a same-named local method). A bare name + // declared more than once in the file (`Show` as function and method) + // resolves only when unique in the file; a bare unique name keeps the + // default resolution. + parser.setLanguage(Go); + const flags = GO_HTTP_PLUGIN.scan( + parser.parse(`package main +import "github.com/gin-gonic/gin" + +type A struct{} + +func (a *A) List(c *gin.Context) {} +func (a *A) Show(c *gin.Context) {} +func Show(c *gin.Context) {} +func Ping(c *gin.Context) {} + +func routes(r *gin.Engine, h *A, o *B) { + r.GET("/a", h.List) + r.GET("/b", o.List) + r.GET("/s", Show) + r.GET("/p", Ping) +} +`), + ) + .filter((d) => d.role === 'provider') + .map((d) => [ + d.path, + d.name, + d.qualifiedHandler ?? false, + d.strictHandlerResolution ?? false, + ]); + expect(flags).toEqual([ + ['/a', 'List', true, false], + ['/b', 'List', true, false], + ['/s', 'Show', false, true], + ['/p', 'Ping', false, false], + ]); + }); + + it('resolves grouped var specs that precede the use', () => { + // Earlier specs in a grouped `var (…)` are in scope for later ones; the + // ingestion side emits /api/admin/x for this, so both strategies agree. + expect( + providers(`package main +func routes(r *gin.Engine) { + var ( + api = r.Group("/api") + admin = api.Group("/admin") + ) + admin.GET("/x", handler) +} +`), + ).toEqual([{ method: 'GET', path: '/api/admin/x', name: 'handler' }]); + }); + + it('declines a route whose group is reassigned in an earlier nested scope', () => { + // `{ g = r.Group("/new") }` (or a branch) writes the outer g: which value + // reaches the use depends on control flow, and ingestion declines it too, + // so emitting the older `/old/x` would invent a route. A nested `:=` + // declares a new variable and leaves the outer binding intact. + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/old") + { g = r.Group("/new") } + g.GET("/x", handler) + k := r.Group("/k") + if cond { k = r.Group("/other") } + k.GET("/y", handler) + m := r.Group("/m") + { m := r.Group("/inner"); m.GET("/i", handler) } + m.GET("/z", handler) +} +`), + ).toEqual([ + { method: 'GET', path: '/inner/i', name: 'handler' }, + { method: 'GET', path: '/m/z', name: 'handler' }, + ]); + }); + + it('resolves a name used in its own statement initializer to the outer binding', () => { + // `if g := g.Group("/inner"); …` — the right-hand g is the OUTER group; + // the new g only scopes over what follows. Same for a for initializer. + expect( + providers(`package main +func routes(r *gin.Engine, enabled bool) { + g := r.Group("/api") + if g := g.Group("/inner"); enabled { + g.GET("/x", handler) + } + for g := g.Group("/loop"); enabled; { + g.GET("/y", handler) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/api/inner/x', name: 'handler' }, + { method: 'GET', path: '/api/loop/y', name: 'handler' }, + ]); + }); + + it('skips comments when locating the path and the handler', () => { + // tree-sitter names comments, so they must not count as arguments: a + // leading comment must not hide the path, and a comment must not be + // picked as the echo (first) or gin (last) handler. + expect( + providers(`package main +import "github.com/labstack/echo/v4" +func routes(e *echo.Echo) { + e.GET("/users", /* description */ users) + e.POST(/* description */ "/posts", posts /* trailing */) +} +`), + ).toEqual([ + { method: 'GET', path: '/users', name: 'users' }, + { method: 'POST', path: '/posts', name: 'posts' }, + ]); + expect( + providers(`package main +import "github.com/gin-gonic/gin" +func routes(r *gin.Engine) { + r.GET(/* description */ "/users", users /* trailing */) + g := r.Group(/* c */ "/api") + g.GET("/x", h.X) +} +`), + ).toEqual([ + { method: 'GET', path: '/users', name: 'users' }, + { method: 'GET', path: '/api/x', name: 'X' }, + ]); + }); + + it('applies the same group logic to echo', () => { + expect( + providers(`package main +func main() { + e := echo.New() + api := e.Group("/api") + users := api.Group("/users") + users.GET("/:id", userHandler.Get) +} +`), + ).toEqual([{ method: 'GET', path: '/api/users/:id', name: 'Get' }]); + }); +}); + +describe('Go gin provider ↔ fetch() consumer pairing', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-go-gin-groups-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const repoHandle = (repoPath: string, id: string): RepoHandle => ({ + id, + path: id, + repoPath, + storagePath: path.join(repoPath, '.gitnexus'), + }); + + it('does not bind a qualified handler to an unrelated same-named local method', async () => { + // routes.go declares A.List; the route's handler is b.List, with B.List in + // b.go. The file-first name lookup would pick A.List, so a qualified + // handler skips it: a repo-wide unique List resolves, an ambiguous one + // keeps the file-level fallback (empty symbolUid) instead of A.List. + const repo = path.join(tmpDir, 'repo'); + fs.mkdirSync(repo, { recursive: true }); + fs.writeFileSync( + path.join(repo, 'routes.go'), + `package main + +import "github.com/gin-gonic/gin" + +type A struct{} + +func (a *A) List(c *gin.Context) {} + +func routes(r *gin.Engine, b *B) { + r.GET("/bs", b.List) +} +`, + ); + const aList = { + uid: 'Method:routes.go:A.List', + name: 'List', + filePath: 'routes.go', + startLine: 6, + endLine: 6, + labels: ['Method'], + }; + const bList = { uid: 'Method:b.go:B.List', name: 'List', filePath: 'b.go' }; + const run = async (repoWide: Record[]) => { + const db = async (query: string, params?: Record) => { + if (query === RESOLVE_BY_NAME_QUERY) return params?.name === 'List' ? repoWide : []; + if (query.includes('UNION ALL') && params?.filePath === 'routes.go') return [aList]; + return []; + }; + const out = await new HttpRouteExtractor().extract(db, repo, repoHandle(repo, 'repo')); + return out.find((c) => c.contractId === 'http::GET::/bs')?.symbolUid; + }; + expect(await run([aList, bList])).toBe(''); + expect(await run([bList])).toBe(bList.uid); + }); + + it('cross-links a grouped method-value route to a ${API_BASE}-prefixed fetch', async () => { + const backend = path.join(tmpDir, 'backend'); + const web = path.join(tmpDir, 'web'); + fs.mkdirSync(path.join(backend, 'internal/handlers'), { recursive: true }); + fs.mkdirSync(path.join(web, 'src/pages'), { recursive: true }); + fs.writeFileSync(path.join(backend, 'internal/handlers/routes.go'), GIN_ROUTES); + fs.writeFileSync( + path.join(web, 'src/pages/AdminMatchesPage.tsx'), + `const API_BASE = import.meta.env.VITE_API_BASE; + +export async function handleUnfinalize(seasonId: string, roundId: string) { + await fetch(\`\${API_BASE}/api/v1/admin/seasons/\${seasonId}/rounds/\${roundId}/unfinalize\`, { + method: 'POST', + }); +} +`, + ); + + const extractor = new HttpRouteExtractor(); + const contracts: StoredContract[] = [ + ...(await extractor.extract(null, backend, repoHandle(backend, 'backend'))).map((c) => ({ + ...c, + repo: 'backend', + })), + ...(await extractor.extract(null, web, repoHandle(web, 'web'))).map((c) => ({ + ...c, + repo: 'web', + })), + ]; + + const { matched } = runExactMatch(contracts); + const link = matched.find( + (l) => l.contractId === 'http::POST::/api/v1/admin/seasons/{param}/rounds/{param}/unfinalize', + ); + expect(link).toMatchObject({ + from: { repo: 'web', symbolRef: { filePath: 'src/pages/AdminMatchesPage.tsx' } }, + to: { + repo: 'backend', + symbolRef: { filePath: 'internal/handlers/routes.go', name: 'UnfinalizeRoundHandle' }, + }, + matchType: 'exact', + }); + }); +}); From c474a811ba4e401e2968dd9cd80629b27412adb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 5 Oct 2026 04:47:37 +0100 Subject: [PATCH 04/13] fix(test): align fixtures and mocks with current types (#3471) --- gitnexus/test/helpers/cfg-harness.ts | 2 +- .../test/helpers/parse-source-safe-mock.ts | 4 +- .../test/helpers/parse-typescript-source.ts | 4 +- gitnexus/test/helpers/perf-sequencer.ts | 14 +- gitnexus/test/helpers/test-graph.ts | 3 +- gitnexus/test/integration/api-query.test.ts | 13 +- .../test/integration/cfg/cfg-emit.test.ts | 1 + .../clean-stale-branch-slots.test.ts | 2 +- .../data-route-table-pipeline.test.ts | 2 +- .../dispatch-guard-route-pipeline.test.ts | 2 +- .../django-route-extraction-e2e.test.ts | 2 +- .../doc-comment-description-e2e.test.ts | 2 - gitnexus/test/integration/expo-routes.test.ts | 2 +- .../fastapi-composed-route-constants.test.ts | 3 +- .../fastapi-prefix-pipeline.test.ts | 2 +- .../fts-repair-warm-session.test.ts | 2 +- .../go-multi-name-worker-metadata.test.ts | 1 - .../integration/go-pipeline-benchmark.test.ts | 11 +- .../impact-context-integrity.test.ts | 9 +- .../impact-epistemic-lower-bound.test.ts | 15 +- .../test/integration/impact-pdg-shape.test.ts | 1 + .../kotlin-spring-route-pipeline.test.ts | 2 +- .../local-backend-calltool.test.ts | 4 +- .../integration/mcp/update-notice.test.ts | 2 +- .../multi-verb-route-identity.test.ts | 2 +- gitnexus/test/integration/pdg-query.test.ts | 2 +- .../resolvers/callable-value-flow.test.ts | 12 +- .../test/integration/resolvers/cpp.test.ts | 66 +- .../javascript-object-properties.test.ts | 1 + .../test/integration/resolvers/python.test.ts | 43 +- .../resolvers/value-ref-locality.test.ts | 2 +- .../integration/shared-store-analyze.test.ts | 6 +- .../integration/shared-store-seed.test.ts | 6 +- gitnexus/test/integration/skip-fts.test.ts | 10 +- .../integration/spring-bean-pipeline.test.ts | 2 +- .../spring-config-pipeline.test.ts | 2 +- ...ing-interface-inheritance-pipeline.test.ts | 2 +- .../integration/spring-route-pipeline.test.ts | 2 +- .../test/integration/taint-explain.test.ts | 2 +- .../test/integration/watch-filesystem.test.ts | 16 +- gitnexus/test/integration/worker-pool.test.ts | 2 +- gitnexus/test/unit/ai-context.test.ts | 2 +- gitnexus/test/unit/analyze-delete-api.test.ts | 6 +- gitnexus/test/unit/analyze-gitnexusrc.test.ts | 8 +- .../unit/analyze-http-endpoint-error.test.ts | 7 +- .../analyze-local-embedding-error.test.ts | 7 +- .../test/unit/analyze-no-stats-bridge.test.ts | 4 +- .../test/unit/analyze-worker-core.test.ts | 1 + gitnexus/test/unit/analyze-worker-ipc.test.ts | 11 +- gitnexus/test/unit/api-analyze-upload.test.ts | 48 +- gitnexus/test/unit/api-fts-mode.test.ts | 2 +- .../test/unit/api-graph-streaming.test.ts | 2 +- gitnexus/test/unit/auto-sync-runner.test.ts | 190 ++--- gitnexus/test/unit/auto-sync.test.ts | 8 +- gitnexus/test/unit/call-routing/ruby.test.ts | 85 +- .../unit/calltool-dispatch-id-bridge.test.ts | 4 +- gitnexus/test/unit/calltool-dispatch.test.ts | 105 ++- gitnexus/test/unit/cfg/c-cpp-visitor.test.ts | 1 + gitnexus/test/unit/cfg/cfg-builder.test.ts | 2 +- .../test/unit/cfg/control-dependence.test.ts | 1 + .../test/unit/cfg/post-dominators.test.ts | 1 + .../test/unit/cfg/synthetic-escape.test.ts | 1 + gitnexus/test/unit/cli-commands.test.ts | 44 +- .../test/unit/cohesion-consistency.test.ts | 2 +- .../test/unit/community-processor.test.ts | 2 +- gitnexus/test/unit/convex-metadata.test.ts | 9 +- .../test/unit/cpp-ue-preprocessor.test.ts | 4 +- .../test/unit/embedding-restore-spill.test.ts | 6 +- .../unit/embedding-sidecar-client.test.ts | 6 +- .../unit/embeddings-install-command.test.ts | 6 +- ...nsure-initialized-reinit-watermark.test.ts | 2 +- .../test/unit/entry-point-scoring.test.ts | 123 ++- .../test/unit/fetch-reason-parsing.test.ts | 3 +- gitnexus/test/unit/field-extraction.test.ts | 9 +- .../test/unit/framework-detection.test.ts | 41 +- gitnexus/test/unit/git.test.ts | 3 + .../test/unit/graph-collapse-wiring.test.ts | 2 +- gitnexus/test/unit/graph.test.ts | 5 +- gitnexus/test/unit/group/cross-impact.test.ts | 4 +- .../group/fastapi-composed-provider.test.ts | 1 + .../test/unit/group/grpc-extractor.test.ts | 1 + .../group/java-const-route-parity.test.ts | 4 +- .../group/kotlin-const-route-fold.test.ts | 2 +- gitnexus/test/unit/group/service.test.ts | 12 +- .../group/sync-partial-extraction.test.ts | 6 +- .../unit/group/sync-registry-identity.test.ts | 6 +- gitnexus/test/unit/group/sync.test.ts | 1 + gitnexus/test/unit/has-method.test.ts | 21 +- gitnexus/test/unit/hook-db-lock-probe.test.ts | 2 +- .../unit/impact-batching-grouping.test.ts | 6 +- gitnexus/test/unit/impact-pagination.test.ts | 8 +- .../unit/impact-pdg-id-bridge-gate.test.ts | 1 - .../impact-pdg-id-vs-name-metrics.test.ts | 1 - .../test/unit/impact-pdg-metric-math.test.ts | 1 - .../test/unit/impact-route-enrichment.test.ts | 6 +- .../test/unit/incremental-parse-cache.test.ts | 43 +- .../unit/incremental-write-integrity.test.ts | 8 +- gitnexus/test/unit/index-repo-command.test.ts | 2 +- .../test/unit/ipynb-python-captures.test.ts | 4 +- .../unit/java-runtime-symbol-strategy.test.ts | 2 +- .../test/unit/jvm-package-siblings.test.ts | 3 +- .../unit/kotlin-route-const-resolver.test.ts | 2 +- .../kotlin-spring-route-ingestion.test.ts | 2 +- gitnexus/test/unit/lazy-action.test.ts | 4 +- .../unit/lbug-pool-evict-reopen-race.test.ts | 4 + gitnexus/test/unit/lbug-pool-pinning.test.ts | 6 + .../test/unit/local-cli-subprocess.test.ts | 29 +- .../test/unit/mcp-context-route-chain.test.ts | 4 +- gitnexus/test/unit/mcp-read-file-grep.test.ts | 4 +- gitnexus/test/unit/mcp-wal-feedback.test.ts | 4 +- gitnexus/test/unit/method-extraction.test.ts | 12 +- .../unit/model/owned-members-lookup.test.ts | 6 +- ...-guessed-edges-excluded-from-flows.test.ts | 4 +- .../test/unit/objective-c-provider.test.ts | 72 +- gitnexus/test/unit/ops-snapshot.test.ts | 6 +- .../unit/parse-impl-worker-lazy-cache.test.ts | 6 +- gitnexus/test/unit/parsedfile-store.test.ts | 72 +- .../test/unit/pdg-callee-id-capture.test.ts | 1 + gitnexus/test/unit/pdg-impact-engine.test.ts | 2 + gitnexus/test/unit/publish.test.ts | 4 +- .../test/unit/query-degraded-signal.test.ts | 6 +- .../unit/range-binding-parse-timeout.test.ts | 3 +- gitnexus/test/unit/rate-limit.test.ts | 2 +- ...epo-manager-ensure-ignore-readonly.test.ts | 2 +- .../test/unit/repo-manager-reconcile.test.ts | 5 +- .../test/unit/repo-manager-rm-failure.test.ts | 2 +- .../repro-3291-staleness-indexed-ref.test.ts | 21 +- .../test/unit/resolve-enclosing-owner.test.ts | 2 +- .../resolve-route-handler-symbols.test.ts | 6 +- .../unit/route-constant-baseline-gate.test.ts | 1 - .../test/unit/route-process-linking.test.ts | 6 +- .../unit/run-analyze-adopt-failure.test.ts | 22 +- .../unit/run-analyze-fts-crash-marker.test.ts | 4 +- .../test/unit/run-analyze-fts-repair.test.ts | 24 +- .../scope-resolution/c/c-workspace.test.ts | 2 +- .../callable-value-flow-worklist.test.ts | 5 +- .../construction-syntax-wiring.test.ts | 9 +- .../cpp/cpp-constraint.test.ts | 2 +- .../cpp/cpp-qualified-ns-index.test.ts | 8 +- .../scope-resolution/emit-references.test.ts | 7 + .../esm-export-marker.test.ts | 6 +- .../unit/scope-resolution/go/go-hooks.test.ts | 106 +-- .../scope-resolution/go/go-imports.test.ts | 4 +- .../go/go-range-binding-null-guard.test.ts | 12 +- .../import-target-index-parity.test.ts | 2 + .../node-lookup-determinism.test.ts | 37 +- .../php-import-target-parity.test.ts | 6 +- .../property-name-index.test.ts | 10 +- .../python/python-hooks.test.ts | 9 +- .../reconcile-ownership.test.ts | 2 +- .../references-to-edges-call-form.test.ts | 1 + .../resolve-references.test.ts | 6 + .../resolved-callee-names.test.ts | 8 +- .../rust/rust-mod-scope-qualifier.test.ts | 2 +- .../rust/rust-range-binding-order.test.ts | 13 +- .../typescript/typescript-imports.test.ts | 20 +- .../unresolved-receivers.test.ts | 38 +- .../walkers-augmentations.test.ts | 10 +- .../test/unit/semantic-search-ready.test.ts | 2 +- .../unit/server-api-repo-resolution.test.ts | 2 +- gitnexus/test/unit/server.test.ts | 3 +- gitnexus/test/unit/setup-codex.test.ts | 2 +- gitnexus/test/unit/setup-jsonc.test.ts | 2 +- gitnexus/test/unit/setup.test.ts | 2 +- gitnexus/test/unit/shard-balance.test.ts | 4 +- gitnexus/test/unit/skill-gen.test.ts | 22 +- .../unit/spring-destinations-phase.test.ts | 69 +- .../unit/stream-graph-emit-config.test.ts | 7 +- .../unit/symbol-identity-validation.test.ts | 4 +- gitnexus/test/unit/taint/path-codec.test.ts | 4 +- gitnexus/test/unit/taint/propagate.test.ts | 19 +- gitnexus/test/unit/taint/taint-emit.test.ts | 2 +- .../test/unit/tool-process-linking.test.ts | 2 +- gitnexus/test/unit/trace-bfs.test.ts | 4 +- .../unit/trpc-entry-point-scoring.test.ts | 20 +- gitnexus/test/unit/type-env.test.ts | 759 +++++++++--------- gitnexus/test/unit/upload-ingest.test.ts | 1 - .../test/unit/variable-extraction.test.ts | 2 +- gitnexus/test/unit/watch-queue.test.ts | 28 +- gitnexus/test/unit/wiki-db-pin.test.ts | 2 + gitnexus/test/unit/wiki-flags.test.ts | 6 +- .../worker-pool-ready-timeout-env.test.ts | 15 +- .../test/unit/worker-pool-resilience.test.ts | 6 +- .../unit/worker-pool-startup-stderr.test.ts | 31 +- .../unit/worker-pool-stdout-forward.test.ts | 1 + .../test/unit/write-lock-required.test.ts | 2 +- gitnexus/tsconfig.test.json | 2 +- 187 files changed, 1705 insertions(+), 1186 deletions(-) diff --git a/gitnexus/test/helpers/cfg-harness.ts b/gitnexus/test/helpers/cfg-harness.ts index e5d787836..eb7b5c04c 100644 --- a/gitnexus/test/helpers/cfg-harness.ts +++ b/gitnexus/test/helpers/cfg-harness.ts @@ -29,7 +29,7 @@ export interface CfgHarness { * CFG shape). The parser is created once and reused across parses. */ export function makeCfgHarness( - grammar: Parser.Language, + grammar: Parameters[0], visitor: CfgVisitor, filePath = 'fixture', ): CfgHarness { diff --git a/gitnexus/test/helpers/parse-source-safe-mock.ts b/gitnexus/test/helpers/parse-source-safe-mock.ts index 8cc0646a4..78a6df6d2 100644 --- a/gitnexus/test/helpers/parse-source-safe-mock.ts +++ b/gitnexus/test/helpers/parse-source-safe-mock.ts @@ -1,4 +1,4 @@ -import { vi } from 'vitest'; +import { vi, type Mock } from 'vitest'; import type * as SafeParseModule from '../../src/core/tree-sitter/safe-parse.js'; /** @@ -43,7 +43,7 @@ import type * as SafeParseModule from '../../src/core/tree-sitter/safe-parse.js' * }); */ export async function buildSafeParseMock( - spy: ReturnType, + spy: Mock, ): Promise { const actual = await vi.importActual( '../../src/core/tree-sitter/safe-parse.js', diff --git a/gitnexus/test/helpers/parse-typescript-source.ts b/gitnexus/test/helpers/parse-typescript-source.ts index d324d84d8..0935a240d 100644 --- a/gitnexus/test/helpers/parse-typescript-source.ts +++ b/gitnexus/test/helpers/parse-typescript-source.ts @@ -30,10 +30,8 @@ export interface ParsedSource { const PARSE_PLUGINS: NonNullable[1]>['plugins'] = [ 'typescript', - 'explicitResourceManagement', - 'importAttributes', 'decoratorAutoAccessors', - ['decorators', { decoratorsBeforeExport: true }], + 'decorators', ]; export function forEachChild(node: Node, visit: (child: AstNode) => void): void { diff --git a/gitnexus/test/helpers/perf-sequencer.ts b/gitnexus/test/helpers/perf-sequencer.ts index d5f8604a3..9dd6d1fb3 100644 --- a/gitnexus/test/helpers/perf-sequencer.ts +++ b/gitnexus/test/helpers/perf-sequencer.ts @@ -17,7 +17,19 @@ export default class PerfSequencer extends BaseSequencer { override async shard(specs: TestSpecification[]): Promise { const shard = this.ctx.config.shard; if (!shard) return specs; - const groups = assignShards(specs, shard.count, specWeight, (spec) => spec.moduleId); + const groups = assignShards( + specs, + shard.count, + (spec) => { + const config = spec.project.config; + const fileParallelism = + 'fileParallelism' in config && typeof config.fileParallelism === 'boolean' + ? config.fileParallelism + : undefined; + return specWeight({ moduleId: spec.moduleId, project: { config: { fileParallelism } } }); + }, + (spec) => spec.moduleId, + ); return groups[shard.index - 1] ?? []; } } diff --git a/gitnexus/test/helpers/test-graph.ts b/gitnexus/test/helpers/test-graph.ts index 834d0df9b..a0a589e97 100644 --- a/gitnexus/test/helpers/test-graph.ts +++ b/gitnexus/test/helpers/test-graph.ts @@ -5,7 +5,8 @@ * without touching the filesystem or LadybugDB. */ import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import type { KnowledgeGraph, NodeLabel, RelationshipType } from '../../src/core/graph/types.js'; +import type { KnowledgeGraph } from '../../src/core/graph/types.js'; +import type { NodeLabel, RelationshipType } from 'gitnexus-shared'; export interface TestNodeInput { id: string; diff --git a/gitnexus/test/integration/api-query.test.ts b/gitnexus/test/integration/api-query.test.ts index 70cc81287..caac05dcd 100644 --- a/gitnexus/test/integration/api-query.test.ts +++ b/gitnexus/test/integration/api-query.test.ts @@ -51,8 +51,8 @@ withTestLbugDB( }); expect(response.status).toBe(200); const body = await response.json(); - expect(Array.isArray(body.result)).toBe(true); - expect(body.result[0].one).toBe(1); + expect(body).toHaveProperty('result', expect.any(Array)); + expect(body).toHaveProperty('result.0.one', 1); }); it('returns 403 for a write query on read-only HTTP path', async () => { @@ -65,7 +65,10 @@ withTestLbugDB( }); expect(response.status).toBe(403); const body = await response.json(); - expect(body.error).toContain('Write queries are not allowed'); + expect(body).toHaveProperty( + 'error', + expect.stringContaining('Write queries are not allowed'), + ); }); it('returns 400 for invalid params payload', async () => { @@ -76,7 +79,7 @@ withTestLbugDB( }); expect(response.status).toBe(400); const body = await response.json(); - expect(body.error).toContain('"params"'); + expect(body).toHaveProperty('error', expect.stringContaining('"params"')); }); it('returns 400 when cypher is missing', async () => { @@ -87,7 +90,7 @@ withTestLbugDB( }); expect(response.status).toBe(400); const body = await response.json(); - expect(body.error).toContain('Missing "cypher"'); + expect(body).toHaveProperty('error', expect.stringContaining('Missing "cypher"')); }); }); }, diff --git a/gitnexus/test/integration/cfg/cfg-emit.test.ts b/gitnexus/test/integration/cfg/cfg-emit.test.ts index 66f9a35fc..32ed86652 100644 --- a/gitnexus/test/integration/cfg/cfg-emit.test.ts +++ b/gitnexus/test/integration/cfg/cfg-emit.test.ts @@ -490,6 +490,7 @@ describe('U4 (#2085 M5) — emitFileCdg', () => { const unsound: FunctionCfg = { filePath: 'spin.ts', functionStartLine: 1, + functionEndLine: 4, functionStartColumn: 0, entryIndex: 0, exitIndex: 3, diff --git a/gitnexus/test/integration/clean-stale-branch-slots.test.ts b/gitnexus/test/integration/clean-stale-branch-slots.test.ts index b287da7e6..4cdf4605b 100644 --- a/gitnexus/test/integration/clean-stale-branch-slots.test.ts +++ b/gitnexus/test/integration/clean-stale-branch-slots.test.ts @@ -68,7 +68,7 @@ describe('clean --stale leftover branch slots (#3331)', () => { } vi.spyOn(process, 'cwd').mockReturnValue(repo); vi.spyOn(console, 'log').mockImplementation(() => {}); - return { dir, storagePath }; + return { repo, dir, storagePath }; } it('reclaims a slot after the git branch is deleted', async () => { diff --git a/gitnexus/test/integration/data-route-table-pipeline.test.ts b/gitnexus/test/integration/data-route-table-pipeline.test.ts index 0965a80b3..7bcaa18ec 100644 --- a/gitnexus/test/integration/data-route-table-pipeline.test.ts +++ b/gitnexus/test/integration/data-route-table-pipeline.test.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; import { DATA_ROUTE_TABLE_SOURCE } from '../../src/core/ingestion/route-extractors/data-route-table.js'; import { loadParseCache, diff --git a/gitnexus/test/integration/dispatch-guard-route-pipeline.test.ts b/gitnexus/test/integration/dispatch-guard-route-pipeline.test.ts index a8fb9153a..1d693b33b 100644 --- a/gitnexus/test/integration/dispatch-guard-route-pipeline.test.ts +++ b/gitnexus/test/integration/dispatch-guard-route-pipeline.test.ts @@ -20,7 +20,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; import { DISPATCH_GUARD_SOURCE } from '../../src/core/ingestion/route-extractors/dispatch-guard.js'; const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'dispatch-guard-app'); diff --git a/gitnexus/test/integration/django-route-extraction-e2e.test.ts b/gitnexus/test/integration/django-route-extraction-e2e.test.ts index 3b11cc1bf..0ef6a13e6 100644 --- a/gitnexus/test/integration/django-route-extraction-e2e.test.ts +++ b/gitnexus/test/integration/django-route-extraction-e2e.test.ts @@ -13,7 +13,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'django-subdir-app'); diff --git a/gitnexus/test/integration/doc-comment-description-e2e.test.ts b/gitnexus/test/integration/doc-comment-description-e2e.test.ts index 9424ae32e..dcdb5057e 100644 --- a/gitnexus/test/integration/doc-comment-description-e2e.test.ts +++ b/gitnexus/test/integration/doc-comment-description-e2e.test.ts @@ -59,7 +59,6 @@ describe('doc-comment description end-to-end (issue #2270)', () => { it('surfaces an exported function JSDoc as its node description through the pipeline', async () => { const result: PipelineResult = await runPipelineFromRepo(createTsRepo(), () => {}, { skipGraphPhases: true, - workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, workerPoolSize: 2, }); @@ -79,7 +78,6 @@ describe('doc-comment description end-to-end (issue #2270)', () => { async () => { const result: PipelineResult = await runPipelineFromRepo(createSwiftRepo(), () => {}, { skipGraphPhases: true, - workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, workerPoolSize: 2, }); diff --git a/gitnexus/test/integration/expo-routes.test.ts b/gitnexus/test/integration/expo-routes.test.ts index 74c63dc2a..066c4bcf9 100644 --- a/gitnexus/test/integration/expo-routes.test.ts +++ b/gitnexus/test/integration/expo-routes.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; const EXPO_APP = path.resolve(__dirname, '..', 'fixtures', 'expo-app'); diff --git a/gitnexus/test/integration/fastapi-composed-route-constants.test.ts b/gitnexus/test/integration/fastapi-composed-route-constants.test.ts index 89e6bcb45..1c066154e 100644 --- a/gitnexus/test/integration/fastapi-composed-route-constants.test.ts +++ b/gitnexus/test/integration/fastapi-composed-route-constants.test.ts @@ -21,7 +21,7 @@ import * as os from 'node:os'; import Parser from 'tree-sitter'; import Python from 'tree-sitter-python'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; import { PYTHON_HTTP_PLUGIN } from '../../src/core/group/extractors/http-patterns/python.js'; import { loadParseCache, @@ -141,6 +141,7 @@ describe('FastAPI composed route constants — ingestion↔group parity (#2391 R return p.parse(src); }; const ctx = PYTHON_HTTP_PLUGIN.prepareRepo?.({ + repoPath: '/test-repo', files: Object.keys(files), parser, readFile: (r) => files[r] ?? null, diff --git a/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts b/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts index 0450f123b..07b269847 100644 --- a/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts +++ b/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts @@ -46,7 +46,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'fastapi-prefix-app'); diff --git a/gitnexus/test/integration/fts-repair-warm-session.test.ts b/gitnexus/test/integration/fts-repair-warm-session.test.ts index ac9ce2713..d6e5a3ed6 100644 --- a/gitnexus/test/integration/fts-repair-warm-session.test.ts +++ b/gitnexus/test/integration/fts-repair-warm-session.test.ts @@ -92,7 +92,7 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', ( await tmpHandle.cleanup(); }); - it.each(['all', 'Function'] as const)( + it.for(['all', 'Function'] as const)( 'a warm session repairs missing %s indexes without restarting', { timeout: 60_000 }, async (missing, ctx) => { diff --git a/gitnexus/test/integration/go-multi-name-worker-metadata.test.ts b/gitnexus/test/integration/go-multi-name-worker-metadata.test.ts index fbf97646f..4065772d4 100644 --- a/gitnexus/test/integration/go-multi-name-worker-metadata.test.ts +++ b/gitnexus/test/integration/go-multi-name-worker-metadata.test.ts @@ -32,7 +32,6 @@ function createGoRepo(): string { async function runWorker(): Promise { return runPipelineFromRepo(createGoRepo(), () => {}, { skipGraphPhases: true, - workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, workerPoolSize: 2, }); } diff --git a/gitnexus/test/integration/go-pipeline-benchmark.test.ts b/gitnexus/test/integration/go-pipeline-benchmark.test.ts index ac3c0c4cf..095c1f330 100644 --- a/gitnexus/test/integration/go-pipeline-benchmark.test.ts +++ b/gitnexus/test/integration/go-pipeline-benchmark.test.ts @@ -32,7 +32,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { makeScopeId, type ParsedFile, type SymbolDefinition } from 'gitnexus-shared'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; import { emitGoScopeCaptures, @@ -587,14 +587,17 @@ function generateSyntheticInterfaceData(interfaceCount: number, structCount: num return [ { filePath: 'repo.go', - language: 'go', + moduleScope: makeScopeId({ + filePath: 'repo.go', + range: { startLine: 0, startCol: 0, endLine: 0, endCol: 0 }, + kind: 'Module', + }), scopes: [], - imports: [], parsedImports: [], localDefs: defs, referenceSites: [], }, - ] as ParsedFile[]; + ]; } /** diff --git a/gitnexus/test/integration/impact-context-integrity.test.ts b/gitnexus/test/integration/impact-context-integrity.test.ts index 0e42de484..f26151faf 100644 --- a/gitnexus/test/integration/impact-context-integrity.test.ts +++ b/gitnexus/test/integration/impact-context-integrity.test.ts @@ -447,7 +447,14 @@ describe('native string projections after checkpointed deletion (#3354)', () => for (const order of ['', ' ORDER BY n.startLine']) { const rows = await read(`MATCH (n:Function) ${projection}${order}`); expect(new Set(rows.map((row) => row.id)).size).toBe(surviving.length); - expect(rows.sort((a, b) => a.startLine - b.startLine)).toEqual(surviving); + expect( + rows.sort((a, b) => { + if (typeof a.startLine !== 'number' || typeof b.startLine !== 'number') { + throw new Error('Expected numeric startLine values from the database'); + } + return a.startLine - b.startLine; + }), + ).toEqual(surviving); } } // Point lookups independently verify values in the affected segments; diff --git a/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts b/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts index 2ef3e6c78..458a19155 100644 --- a/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts +++ b/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts @@ -124,12 +124,15 @@ withTestLbugDB( it('marks impact as a lower bound when scope extraction omitted files', async () => { vi.mocked(loadMeta).mockResolvedValueOnce({ + repoPath: '/test/repo', + lastCommit: 'abc123', + indexedAt: new Date().toISOString(), scopeExtractionReceipt: 1, scopeExtractionFailures: { total: 2, paths: ['src/broken-a.ts', 'src/broken-b.ts'], }, - } as Awaited>); + }); const result = await backend.callTool('impact', { target: 'formatDate', @@ -143,12 +146,15 @@ withTestLbugDB( it('never renders repository-controlled failure paths in boundary prose', async () => { vi.mocked(loadMeta).mockResolvedValueOnce({ + repoPath: '/test/repo', + lastCommit: 'abc123', + indexedAt: new Date().toISOString(), scopeExtractionReceipt: 1, scopeExtractionFailures: { total: 1, paths: ['src/`break`\n\u001b[31m\u202e\u200binject.ts'], }, - } as Awaited>); + }); const result = await backend.callTool('impact', { target: 'formatDate', @@ -239,12 +245,15 @@ withTestLbugDB( it('context() reports persisted scope extraction omissions as a lower bound', async () => { vi.mocked(loadMeta).mockResolvedValueOnce({ + repoPath: '/test/repo', + lastCommit: 'abc123', + indexedAt: new Date().toISOString(), scopeExtractionReceipt: 1, scopeExtractionFailures: { total: 2, paths: ['src/broken-a.ts', 'src/broken-b.ts'], }, - } as Awaited>); + }); const result = await backend.callTool('context', { name: 'formatDate', diff --git a/gitnexus/test/integration/impact-pdg-shape.test.ts b/gitnexus/test/integration/impact-pdg-shape.test.ts index 0458ab03d..3d83a6fbf 100644 --- a/gitnexus/test/integration/impact-pdg-shape.test.ts +++ b/gitnexus/test/integration/impact-pdg-shape.test.ts @@ -279,6 +279,7 @@ withTestLbugDB( const result = await downstream(); const localRisk = String(result.risk); expect(localRisk).toBe('UNKNOWN'); + if (localRisk !== 'UNKNOWN') throw new Error(`Expected UNKNOWN risk, got ${localRisk}`); // No cross-repo hits → mergeRisk returns localRisk verbatim: 'UNKNOWN', // NEVER coerced to a confident 'LOW' (the false-safe this guards). expect(mergeRisk(localRisk, [])).toBe('UNKNOWN'); diff --git a/gitnexus/test/integration/kotlin-spring-route-pipeline.test.ts b/gitnexus/test/integration/kotlin-spring-route-pipeline.test.ts index c7f29f912..6a931cd4e 100644 --- a/gitnexus/test/integration/kotlin-spring-route-pipeline.test.ts +++ b/gitnexus/test/integration/kotlin-spring-route-pipeline.test.ts @@ -10,7 +10,7 @@ import os from 'node:os'; import path from 'node:path'; import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.js'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; import { loadParseCache, PARSE_CACHE_VERSION, diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts index 35eabbdd4..6477a5fce 100644 --- a/gitnexus/test/integration/local-backend-calltool.test.ts +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -356,7 +356,9 @@ withTestLbugDB( const result = await backend.callTool('tool_map', {}); expect(result).not.toHaveProperty('error'); - const tools = new Map(result.tools.map((tool: any) => [tool.name, tool])); + const toolEntries: Array<{ name: string; description: string; flows: string[] }> = + result.tools; + const tools = new Map(toolEntries.map((tool) => [tool.name, tool])); expect(tools.get('alpha')?.description).toBe('Calls chain A.'); expect(tools.get('beta')?.description).toBe('Calls chain B.'); expect(tools.get('alpha')?.flows).toEqual(['AlphaFlow']); diff --git a/gitnexus/test/integration/mcp/update-notice.test.ts b/gitnexus/test/integration/mcp/update-notice.test.ts index a1380af39..654d97524 100644 --- a/gitnexus/test/integration/mcp/update-notice.test.ts +++ b/gitnexus/test/integration/mcp/update-notice.test.ts @@ -18,7 +18,7 @@ interface FakeChecker { } interface FakeLogger { - info: ReturnType; + info: ReturnType, message: string) => unknown>>; } function checker(initial: UpdateState | null): { diff --git a/gitnexus/test/integration/multi-verb-route-identity.test.ts b/gitnexus/test/integration/multi-verb-route-identity.test.ts index d488c2522..f0d270358 100644 --- a/gitnexus/test/integration/multi-verb-route-identity.test.ts +++ b/gitnexus/test/integration/multi-verb-route-identity.test.ts @@ -21,7 +21,7 @@ import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; import { generateId } from '../../src/lib/utils.js'; import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'multi-verb-route-app'); diff --git a/gitnexus/test/integration/pdg-query.test.ts b/gitnexus/test/integration/pdg-query.test.ts index de5477d8a..a138d6766 100644 --- a/gitnexus/test/integration/pdg-query.test.ts +++ b/gitnexus/test/integration/pdg-query.test.ts @@ -134,7 +134,7 @@ withTestLbugDB( // An MCP client may send {"name":"pdg_query"} with no `arguments` field; // the dispatch then hands `params: undefined` to the impl. It must // default to {} and surface the mode-validation error, not a TypeError. - const result = await backend.callTool('pdg_query'); + const result = await backend.callTool('pdg_query', undefined); expect(result).toHaveProperty('error'); expect(result.error).toMatch(/mode/i); }); diff --git a/gitnexus/test/integration/resolvers/callable-value-flow.test.ts b/gitnexus/test/integration/resolvers/callable-value-flow.test.ts index e1502ab0b..6e2942945 100644 --- a/gitnexus/test/integration/resolvers/callable-value-flow.test.ts +++ b/gitnexus/test/integration/resolvers/callable-value-flow.test.ts @@ -323,7 +323,12 @@ function callableTargetQualifiedNames( ): string[] { return getRelationships(result, 'CALLS') .filter((edge) => edge.source === source && edge.rel.reason === 'callable-value-flow') - .map((edge) => result.graph.getNode(edge.rel.targetId)?.properties.qualifiedName ?? edge.target) + .map((edge) => { + const qualifiedName = result.graph.getNode(edge.rel.targetId)?.properties.qualifiedName; + if (qualifiedName == null) return edge.target; + if (typeof qualifiedName !== 'string') throw new Error('Expected a string qualified name'); + return qualifiedName; + }) .sort(); } @@ -347,7 +352,10 @@ describe('callable value flow', () => { it('does not overstate Objective-C callable-value-flow coverage', () => { expect(CALLABLE_FLOW_PROVIDER_COVERAGE[SupportedLanguages.ObjectiveC]).toBe('not-applicable'); expect( - PROVIDER_FLOW_CASES.some(({ language }) => language === SupportedLanguages.ObjectiveC), + PROVIDER_FLOW_CASES.some( + ({ language }: { language: SupportedLanguages }) => + language === SupportedLanguages.ObjectiveC, + ), ).toBe(false); }); diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 4bcb4e0bd..47899b348 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1757,7 +1757,11 @@ describe('C++ template specialization disambiguation across files', () => { expect(targetOwnerEdge).toBeDefined(); expect(sourceOwnerEdge!.rel.sourceId).toBe(targetOwnerEdge!.rel.sourceId); const ownerNode = result.graph.getNode(sourceOwnerEdge!.rel.sourceId); - const fp = ownerNode?.properties.templateArguments?.join(','); + const templateArguments = ownerNode?.properties.templateArguments; + if (templateArguments != null && !Array.isArray(templateArguments)) { + throw new Error('Expected template arguments to be an array'); + } + const fp = Array.isArray(templateArguments) ? templateArguments.join(',') : undefined; if (fp) ownerFingerprints.add(fp); } expect(ownerFingerprints).toEqual(new Set(['User', 'Order'])); @@ -2125,14 +2129,15 @@ describe('C++ ambiguous integer-width overloads', () => { }); it('records a structured suppression reason for normalization ambiguity', () => { - const outcomes = getResolutionOutcomes(result).filter( - (o) => - o.kind === 'suppressed' && - o.name === 'process' && - o.phase === 'receiver-bound-calls' && - o.filePath.endsWith('caller.cpp') && - o.reason === 'overload-ambiguous-normalization', - ); + const outcomes = getResolutionOutcomes(result) + .filter((o) => o.kind === 'suppressed') + .filter( + (o) => + o.name === 'process' && + o.phase === 'receiver-bound-calls' && + o.filePath.endsWith('caller.cpp') && + o.reason === 'overload-ambiguous-normalization', + ); expect(outcomes.length).toBeGreaterThan(0); expect(outcomes[0]?.candidateIds.length).toBe(2); @@ -2214,13 +2219,12 @@ describe('C++ overload resolution — conversion-rank disambiguation (#1578)', ( }); it('records a structured suppression reason for conversion-rank ties', () => { - const outcomes = getResolutionOutcomes(result).filter( - (o) => - o.kind === 'suppressed' && - o.name === 'h' && - o.phase === 'free-call-fallback' && - o.reason === 'conversion-rank-tied', - ); + const outcomes = getResolutionOutcomes(result) + .filter((o) => o.kind === 'suppressed') + .filter( + (o) => + o.name === 'h' && o.phase === 'free-call-fallback' && o.reason === 'conversion-rank-tied', + ); expect(outcomes.length).toBeGreaterThan(0); expect(outcomes[0]?.candidateIds.length).toBe(2); @@ -3256,13 +3260,14 @@ describe('C++ ADL — non-function ordinary lookup suppresses ADL', () => { }); it('records a structured suppression reason for ADL blocker lookup', () => { - const outcomes = getResolutionOutcomes(result).filter( - (o) => - o.kind === 'suppressed' && - o.name === 'record' && - o.phase === 'free-call-fallback' && - o.reason === 'adl-ordinary-lookup-blocked', - ); + const outcomes = getResolutionOutcomes(result) + .filter((o) => o.kind === 'suppressed') + .filter( + (o) => + o.name === 'record' && + o.phase === 'free-call-fallback' && + o.reason === 'adl-ordinary-lookup-blocked', + ); expect(outcomes.length).toBeGreaterThan(0); expect(outcomes[0]?.candidateIds.length).toBe(0); @@ -3552,13 +3557,14 @@ describe('C++ inline namespace — ambiguous same-name across inline children (# }); it('records a structured suppression reason for inline namespace ambiguity', () => { - const outcomes = getResolutionOutcomes(result).filter( - (o) => - o.kind === 'suppressed' && - o.name === 'foo' && - o.phase === 'receiver-bound-calls' && - o.reason === 'inline-ns-ambiguous', - ); + const outcomes = getResolutionOutcomes(result) + .filter((o) => o.kind === 'suppressed') + .filter( + (o) => + o.name === 'foo' && + o.phase === 'receiver-bound-calls' && + o.reason === 'inline-ns-ambiguous', + ); expect(outcomes.length).toBeGreaterThan(0); expect(outcomes[0]?.candidateIds.length).toBe(0); diff --git a/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts b/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts index 159c0d694..6df379155 100644 --- a/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts +++ b/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts @@ -413,6 +413,7 @@ describe('JavaScript plain-object property access (A1/A5)', () => { }); interface PropNode { + readonly id: string; readonly label: string; readonly properties: Record; } diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 646fef3b0..57dc088d0 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -1146,6 +1146,7 @@ describe('Python mixin self-dispatch', () => { expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.filePath === 'mixins.py' && outcome.name === 'class_only' && outcome.reason === 'receiver-unresolved', @@ -1202,11 +1203,9 @@ describe('Python mixin self-dispatch', () => { it('records only the expected mixin dispatch gaps and partial coverage', () => { const unresolvedSites = getResolutionOutcomes(result) + .filter((outcome) => outcome.kind === 'suppressed') .filter( - (outcome) => - outcome.kind === 'suppressed' && - outcome.reason === 'receiver-unresolved' && - outcome.filePath === 'mixins.py', + (outcome) => outcome.reason === 'receiver-unresolved' && outcome.filePath === 'mixins.py', ) .map((outcome) => `${outcome.range.startLine}:${outcome.name}`) .sort(); @@ -1325,6 +1324,7 @@ describe('Python mixin self-dispatch', () => { expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.filePath === 'mixins.py' && outcome.name === 'duplicate_hook' && outcome.reason === 'member-lookup-ambiguous', @@ -1378,12 +1378,9 @@ describe('Python unproven subtype methods', () => { expect(calls.map((call) => call.rel.targetId)).toEqual([ expect.stringContaining('Concrete.hook'), ]); - const unresolved = getResolutionOutcomes(result).filter( - (outcome) => - outcome.kind === 'suppressed' && - outcome.name === 'hook' && - outcome.reason === 'receiver-unresolved', - ); + const unresolved = getResolutionOutcomes(result) + .filter((outcome) => outcome.kind === 'suppressed') + .filter((outcome) => outcome.name === 'hook' && outcome.reason === 'receiver-unresolved'); expect(unresolved.flatMap((outcome) => outcome.candidateIds).sort()).toEqual([ // AbstractWorker.hook (line 10) and Receiverless.hook (line 13). 'def:worker.py#10:4:Method:hook', @@ -1419,6 +1416,7 @@ describe('Python unproven subtype methods', () => { ]); expect( getResolutionOutcomes(result) + .filter((outcome) => outcome.kind === 'suppressed') .filter((outcome) => outcome.name === 'hook' && outcome.reason === 'receiver-unresolved') .flatMap((outcome) => outcome.candidateIds), ).toEqual([expect.stringMatching(/:Class:Base$/)]); @@ -1459,6 +1457,7 @@ class MissingHook(Mixin): expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.name === 'hook' && outcome.reason === 'receiver-unresolved' && outcome.candidateIds.some((id) => id.endsWith(':Class:MissingHook')), @@ -1547,7 +1546,10 @@ class Mixin: ); expect( getResolutionOutcomes(result).some( - (outcome) => outcome.name === target && outcome.reason === 'receiver-unresolved', + (outcome) => + outcome.kind === 'suppressed' && + outcome.name === target && + outcome.reason === 'receiver-unresolved', ), ).toBe(true); } @@ -1647,7 +1649,10 @@ class ReboundProperty: ); expect( getResolutionOutcomes(result).some( - (outcome) => outcome.name === target && outcome.reason === 'receiver-unresolved', + (outcome) => + outcome.kind === 'suppressed' && + outcome.name === target && + outcome.reason === 'receiver-unresolved', ), ).toBe(true); } @@ -1660,13 +1665,17 @@ class ReboundProperty: expect( getResolutionOutcomes(result).some( (outcome) => - outcome.name === 'wrapped_helper' && outcome.reason === 'receiver-unresolved', + outcome.kind === 'suppressed' && + outcome.name === 'wrapped_helper' && + outcome.reason === 'receiver-unresolved', ), ).toBe(true); expect( getResolutionOutcomes(result).some( (outcome) => - outcome.name === 'rebound_helper' && outcome.reason === 'receiver-unresolved', + outcome.kind === 'suppressed' && + outcome.name === 'rebound_helper' && + outcome.reason === 'receiver-unresolved', ), ).toBe(true); } finally { @@ -1752,6 +1761,7 @@ class Worker(Mixin): expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.filePath === 'case.py' && outcome.name === 'hook' && outcome.reason === 'receiver-unresolved', @@ -1760,6 +1770,7 @@ class Worker(Mixin): expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.filePath === 'case.py' && outcome.name === 'own_hook' && outcome.reason === 'receiver-unresolved', @@ -1768,6 +1779,7 @@ class Worker(Mixin): expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.filePath === 'case.py' && outcome.name === 'unicode_helper' && outcome.reason === 'receiver-unresolved', @@ -1776,6 +1788,7 @@ class Worker(Mixin): expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.filePath === 'case.py' && outcome.name === 'nested_hook' && outcome.reason === 'receiver-unresolved', @@ -1855,6 +1868,7 @@ class DirectWorker(HookMixin, external.Parent, First, Second): expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.name === 'hook' && outcome.reason === 'receiver-unresolved' && outcome.candidateIds.some((id) => id.endsWith(':Class:Worker')), @@ -1896,6 +1910,7 @@ class DirectWorker(HookMixin): expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.name === 'hook' && outcome.reason === 'receiver-unresolved' && outcome.candidateIds.some((id) => id.endsWith(':Class:Worker')), diff --git a/gitnexus/test/integration/resolvers/value-ref-locality.test.ts b/gitnexus/test/integration/resolvers/value-ref-locality.test.ts index 9a1d6ee45..cfa1c931a 100644 --- a/gitnexus/test/integration/resolvers/value-ref-locality.test.ts +++ b/gitnexus/test/integration/resolvers/value-ref-locality.test.ts @@ -47,7 +47,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'node:path'; import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../../types/pipeline.js'; +import type { PipelineResult } from '../../../src/types/pipeline.js'; const LANG_FIXTURES = path.resolve(__dirname, '..', '..', 'fixtures', 'lang-resolution'); diff --git a/gitnexus/test/integration/shared-store-analyze.test.ts b/gitnexus/test/integration/shared-store-analyze.test.ts index 2c08b47a3..55062266a 100644 --- a/gitnexus/test/integration/shared-store-analyze.test.ts +++ b/gitnexus/test/integration/shared-store-analyze.test.ts @@ -186,9 +186,9 @@ describe('shared sibling store analyze (#3352)', () => { const conn = new lbug.Connection(db); let rows: { p: string }[]; try { - rows = (await ( - await conn.query('MATCH (f:File) RETURN f.filePath AS p ORDER BY p') - ).getAll()) as { p: string }[]; + const result = await conn.query('MATCH (f:File) RETURN f.filePath AS p ORDER BY p'); + if (Array.isArray(result)) throw new Error('Expected a single query result'); + rows = (await result.getAll()) as { p: string }[]; } finally { await conn.close(); await db.close(); diff --git a/gitnexus/test/integration/shared-store-seed.test.ts b/gitnexus/test/integration/shared-store-seed.test.ts index 2b50704b1..951a9f9c8 100644 --- a/gitnexus/test/integration/shared-store-seed.test.ts +++ b/gitnexus/test/integration/shared-store-seed.test.ts @@ -41,9 +41,9 @@ const queryNames = async (graph: string): Promise => { const db = new lbug.Database(graph, 0, true, true); const conn = new lbug.Connection(db); try { - const rows = (await ( - await conn.query('MATCH (f:Function) RETURN f.name AS n ORDER BY n') - ).getAll()) as { n: string }[]; + const result = await conn.query('MATCH (f:Function) RETURN f.name AS n ORDER BY n'); + if (Array.isArray(result)) throw new Error('Expected a single query result'); + const rows = (await result.getAll()) as { n: string }[]; return rows.map((r) => r.n); } finally { await conn.close(); diff --git a/gitnexus/test/integration/skip-fts.test.ts b/gitnexus/test/integration/skip-fts.test.ts index 896287ee9..4192316bb 100644 --- a/gitnexus/test/integration/skip-fts.test.ts +++ b/gitnexus/test/integration/skip-fts.test.ts @@ -367,8 +367,14 @@ describe('FTS opt-out analysis lifecycle (#3091)', () => { }); expect(response.status).toBe(200); const body = await response.json(); - expect(body.warning).toContain('FTS disabled for this index'); - expect(body.warning).not.toMatch(/failed to load|indexes missing|repair-fts/); + expect(body).toHaveProperty( + 'warning', + expect.stringContaining('FTS disabled for this index'), + ); + expect(body).toHaveProperty( + 'warning', + expect.not.stringMatching(/failed to load|indexes missing|repair-fts/), + ); } } finally { if (server.exitCode === null) server.kill('SIGTERM'); diff --git a/gitnexus/test/integration/spring-bean-pipeline.test.ts b/gitnexus/test/integration/spring-bean-pipeline.test.ts index c01cd9915..d010663bb 100644 --- a/gitnexus/test/integration/spring-bean-pipeline.test.ts +++ b/gitnexus/test/integration/spring-bean-pipeline.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-bean-app'); diff --git a/gitnexus/test/integration/spring-config-pipeline.test.ts b/gitnexus/test/integration/spring-config-pipeline.test.ts index 65adf802a..111f5f0e0 100644 --- a/gitnexus/test/integration/spring-config-pipeline.test.ts +++ b/gitnexus/test/integration/spring-config-pipeline.test.ts @@ -4,7 +4,7 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'; import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; import { SPRING_CONFIG_DESCRIPTION } from '../../src/core/ingestion/frameworks/spring/config-bindings.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; import { createTempDir } from '../helpers/test-db.js'; const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-config-app'); diff --git a/gitnexus/test/integration/spring-interface-inheritance-pipeline.test.ts b/gitnexus/test/integration/spring-interface-inheritance-pipeline.test.ts index d759e8348..dd0368ebf 100644 --- a/gitnexus/test/integration/spring-interface-inheritance-pipeline.test.ts +++ b/gitnexus/test/integration/spring-interface-inheritance-pipeline.test.ts @@ -10,7 +10,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; const ORDER_API = `package com.example; import org.springframework.web.bind.annotation.*; diff --git a/gitnexus/test/integration/spring-route-pipeline.test.ts b/gitnexus/test/integration/spring-route-pipeline.test.ts index e6fd739af..1683cd027 100644 --- a/gitnexus/test/integration/spring-route-pipeline.test.ts +++ b/gitnexus/test/integration/spring-route-pipeline.test.ts @@ -13,7 +13,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-route-app'); diff --git a/gitnexus/test/integration/taint-explain.test.ts b/gitnexus/test/integration/taint-explain.test.ts index 28478051e..09867bb6d 100644 --- a/gitnexus/test/integration/taint-explain.test.ts +++ b/gitnexus/test/integration/taint-explain.test.ts @@ -76,7 +76,7 @@ export function handle(req: any, db: any, map: Map, task: any, r .map((rel) => { const sink = result.graph.getNode(rel.targetId); const decoded = decodeTaintPath(rel.reason); - if (!decoded.ok) { + if (decoded.ok === false) { throw new Error(`invalid TAINTED reason for ${rel.id}: ${decoded.error}`); } return { diff --git a/gitnexus/test/integration/watch-filesystem.test.ts b/gitnexus/test/integration/watch-filesystem.test.ts index f1e9aae8b..646d95e2a 100644 --- a/gitnexus/test/integration/watch-filesystem.test.ts +++ b/gitnexus/test/integration/watch-filesystem.test.ts @@ -81,7 +81,9 @@ describe('watch filesystem integration', () => { const loop = await startWatchFileLoop( repo, 30, - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, (error) => { throw error; }, @@ -166,7 +168,9 @@ describe('watch filesystem integration', () => { const loop = await startWatchFileLoop( repo, 25, - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, (error) => { throw error; }, @@ -224,7 +228,9 @@ describe('watch filesystem integration', () => { const loop = await startWatchFileLoop( repo, 25, - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, (_error, paths) => errors.push([...paths]), ); loops.push(loop); @@ -252,7 +258,9 @@ describe('watch filesystem integration', () => { const loop = await startWatchFileLoop( repo, 25, - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, (error) => { throw error; }, diff --git a/gitnexus/test/integration/worker-pool.test.ts b/gitnexus/test/integration/worker-pool.test.ts index ac6fe0076..b995873a5 100644 --- a/gitnexus/test/integration/worker-pool.test.ts +++ b/gitnexus/test/integration/worker-pool.test.ts @@ -1367,7 +1367,7 @@ describe('worker pool integration', () => { ]); // Deterministically: a.ts crashes round 1, b.ts crashes round 2. - const quarantine = (pool.getQuarantinedPaths?.() ?? []).sort(); + const quarantine = [...(pool.getQuarantinedPaths?.() ?? [])].sort(); expect(quarantine).toEqual(['a.ts', 'b.ts']); // All non-quarantined files eventually parsed by the survivor slot. const allPaths = results.flatMap((r) => r.paths).sort(); diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index a7174e674..c411d7ec5 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -1667,7 +1667,7 @@ describe('count-only reindex does not churn the committed block (#2907)', () => describe('--no-stats drops the per-cluster symbol counts too (#2907)', () => { const stats = { nodes: 10, edges: 20, processes: 3 }; - const skills = [{ label: 'ingestion', name: 'p-ingestion', symbolCount: 120 }]; + const skills = [{ label: 'ingestion', name: 'p-ingestion', symbolCount: 120, fileCount: 10 }]; it('omits the count under --no-stats and keeps it otherwise', () => { const lean = generateGitNexusContent('P', stats, { generatedSkills: skills, noStats: true }); diff --git a/gitnexus/test/unit/analyze-delete-api.test.ts b/gitnexus/test/unit/analyze-delete-api.test.ts index e40ff53cc..74b01e733 100644 --- a/gitnexus/test/unit/analyze-delete-api.test.ts +++ b/gitnexus/test/unit/analyze-delete-api.test.ts @@ -152,7 +152,11 @@ const invokeDelete = (route: string, jobId: string): { statusCode: number; body: return this; }, }; - handler({ params: { jobId } }, res); + handler( + { params: { jobId } } as unknown as express.Request, + res as unknown as express.Response, + vi.fn(), + ); return res; }; diff --git a/gitnexus/test/unit/analyze-gitnexusrc.test.ts b/gitnexus/test/unit/analyze-gitnexusrc.test.ts index 081b3664c..e7fe6224c 100644 --- a/gitnexus/test/unit/analyze-gitnexusrc.test.ts +++ b/gitnexus/test/unit/analyze-gitnexusrc.test.ts @@ -22,8 +22,12 @@ const { getDefaultBranchMock, } = vi.hoisted(() => ({ runFullAnalysisMock: vi.fn(), - generateAIContextFilesMock: vi.fn(async () => ({ files: [] as string[] })), - refreshBaseRefLineMock: vi.fn(async () => ({ files: [] as string[] })), + generateAIContextFilesMock: vi.fn< + typeof import('../../src/cli/ai-context.js').generateAIContextFiles + >(async () => ({ files: [] as string[] })), + refreshBaseRefLineMock: vi.fn( + async () => ({ files: [] as string[] }), + ), generateSkillFilesMock: vi.fn(async () => ({ skills: [{ name: 'c', label: 'Community', symbolCount: 1, fileCount: 1 }], outputPath: '/repo/.claude/skills', diff --git a/gitnexus/test/unit/analyze-http-endpoint-error.test.ts b/gitnexus/test/unit/analyze-http-endpoint-error.test.ts index ffb7d2b91..718a9fa16 100644 --- a/gitnexus/test/unit/analyze-http-endpoint-error.test.ts +++ b/gitnexus/test/unit/analyze-http-endpoint-error.test.ts @@ -30,12 +30,15 @@ const resolveEmbeddingRuntimeMock = vi.fn<() => { source: string } | null>(() => source: 'package', })); const isPrefixRuntimeLoadableMock = vi.fn(() => true); -const installEmbeddingRuntimeMock = vi.fn(async () => undefined); +const installEmbeddingRuntimeMock = vi.fn< + typeof import('../../src/core/embeddings/runtime-install.js').installEmbeddingRuntime +>(async () => undefined); vi.mock('../../src/core/embeddings/runtime-install.js', async (importOriginal) => ({ ...(await importOriginal()), resolveEmbeddingRuntime: () => resolveEmbeddingRuntimeMock(), isPrefixRuntimeLoadable: () => isPrefixRuntimeLoadableMock(), - installEmbeddingRuntime: (...args: unknown[]) => installEmbeddingRuntimeMock(...args), + installEmbeddingRuntime: (...args: Parameters) => + installEmbeddingRuntimeMock(...args), getEmbeddingRuntimeDir: () => '/fake/embedding-runtime', })); diff --git a/gitnexus/test/unit/analyze-local-embedding-error.test.ts b/gitnexus/test/unit/analyze-local-embedding-error.test.ts index e67f43fe5..161f3b77b 100644 --- a/gitnexus/test/unit/analyze-local-embedding-error.test.ts +++ b/gitnexus/test/unit/analyze-local-embedding-error.test.ts @@ -38,12 +38,15 @@ const resolveEmbeddingRuntimeMock = vi.fn<() => { source: string } | null>(() => source: 'package', })); const isPrefixRuntimeLoadableMock = vi.fn(() => true); -const installEmbeddingRuntimeMock = vi.fn(async () => undefined); +const installEmbeddingRuntimeMock = vi.fn< + typeof import('../../src/core/embeddings/runtime-install.js').installEmbeddingRuntime +>(async () => undefined); vi.mock('../../src/core/embeddings/runtime-install.js', async (importOriginal) => ({ ...(await importOriginal()), resolveEmbeddingRuntime: () => resolveEmbeddingRuntimeMock(), isPrefixRuntimeLoadable: () => isPrefixRuntimeLoadableMock(), - installEmbeddingRuntime: (...args: unknown[]) => installEmbeddingRuntimeMock(...args), + installEmbeddingRuntime: (...args: Parameters) => + installEmbeddingRuntimeMock(...args), getEmbeddingRuntimeDir: () => '/fake/embedding-runtime', })); diff --git a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts index a63a6fa5a..63a417b2e 100644 --- a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts +++ b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts @@ -3,7 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { runFullAnalysisMock, generateAIContextFilesMock, generateSkillFilesMock, cliErrorMock } = vi.hoisted(() => { const runFullAnalysisMock = vi.fn(); - const generateAIContextFilesMock = vi.fn(async () => ({ files: [] as string[] })); + const generateAIContextFilesMock = vi.fn< + typeof import('../../src/cli/ai-context.js').generateAIContextFiles + >(async () => ({ files: [] as string[] })); const generateSkillFilesMock = vi.fn(async () => ({ skills: [{ name: 'c', label: 'Community', symbolCount: 1, fileCount: 1 }], outputPath: '/repo/.claude/skills', diff --git a/gitnexus/test/unit/analyze-worker-core.test.ts b/gitnexus/test/unit/analyze-worker-core.test.ts index 2b09108b5..2c0323220 100644 --- a/gitnexus/test/unit/analyze-worker-core.test.ts +++ b/gitnexus/test/unit/analyze-worker-core.test.ts @@ -23,6 +23,7 @@ import type { AnalyzerRunnerIdentity } from '../../src/storage/repo-manager.js'; import { IndexLockTimeoutError, type LockRecord } from '../../src/storage/index-lock.js'; const baseResult: AnalyzeResult = { + rebuildReasons: [], repoName: 'repo', repoPath: '/repo', storagePath: '/repo/.gitnexus', diff --git a/gitnexus/test/unit/analyze-worker-ipc.test.ts b/gitnexus/test/unit/analyze-worker-ipc.test.ts index d6dde3fcc..e05d8c180 100644 --- a/gitnexus/test/unit/analyze-worker-ipc.test.ts +++ b/gitnexus/test/unit/analyze-worker-ipc.test.ts @@ -29,6 +29,7 @@ function hostileResult(): AnalyzeResult { }; graph.self = graph; // circular — JSON.stringify throws return { + rebuildReasons: [], repoName: 'demo', repoPath: '/repos/demo', storagePath: '/repos/demo/.gitnexus', @@ -83,8 +84,16 @@ describe('#2112: analyze-worker IPC projection', () => { properties: { name: `n${i}`, filePath: 'x.ts' }, }); } - graph.addRelationship({ id: 'n0->n1', source: 'n0', target: 'n1', type: 'CALLS' }); + graph.addRelationship({ + id: 'n0->n1', + sourceId: 'n0', + targetId: 'n1', + type: 'CALLS', + confidence: 1, + reason: '', + }); const result: AnalyzeResult = { + rebuildReasons: [], repoName: 'demo', repoPath: '/r', storagePath: '/r/.gitnexus', diff --git a/gitnexus/test/unit/api-analyze-upload.test.ts b/gitnexus/test/unit/api-analyze-upload.test.ts index 8843231ff..bc8d7370e 100644 --- a/gitnexus/test/unit/api-analyze-upload.test.ts +++ b/gitnexus/test/unit/api-analyze-upload.test.ts @@ -1,3 +1,4 @@ +import type { AnalyzeUploadDeps } from '../../src/server/analyze-upload.js'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import path from 'node:path'; import fs from 'node:fs/promises'; @@ -88,8 +89,13 @@ function uniqueTop(): string { describe('createAnalyzeUploadHandler', () => { it('ingests, promotes the inner folder, and launches analysis (202)', async () => { const top = uniqueTop(); - const createJob = vi.fn(() => ({ id: 'job-1', status: 'queued' })); - const launch = vi.fn((_j, dir: string) => promoted.push(dir)); + const createJob = vi.fn(() => ({ + id: 'job-1', + status: 'queued', + })); + const launch = vi.fn((_j, dir) => { + promoted.push(dir); + }); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); @@ -118,10 +124,12 @@ describe('createAnalyzeUploadHandler', () => { it('maps a busy job (createJob throws "already in progress") to 409 and promotes nothing', async () => { const top = uniqueTop(); - const createJob = vi.fn(() => { + const createJob = vi.fn(() => { throw new Error('Analysis already in progress for another repository'); }); - const launch = vi.fn((_j, dir: string) => promoted.push(dir)); + const launch = vi.fn((_j, dir) => { + promoted.push(dir); + }); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); @@ -142,8 +150,8 @@ describe('createAnalyzeUploadHandler', () => { }); it('rejects a traversal path in the manifest (400) without launching', async () => { - const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); - const launch = vi.fn(); + const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); + const launch = vi.fn(); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); @@ -162,8 +170,8 @@ describe('createAnalyzeUploadHandler', () => { }); it('rejects an un-nameable top folder (Windows-reserved → 400)', async () => { - const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); - const launch = vi.fn(); + const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); + const launch = vi.fn(); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); @@ -182,8 +190,13 @@ describe('createAnalyzeUploadHandler', () => { it('strips a crafted .gitnexus index from the promoted upload', async () => { const top = uniqueTop(); - const createJob = vi.fn(() => ({ id: 'job-x', status: 'queued' })); - const launch = vi.fn((_j, dir: string) => promoted.push(dir)); + const createJob = vi.fn(() => ({ + id: 'job-x', + status: 'queued', + })); + const launch = vi.fn((_j, dir) => { + promoted.push(dir); + }); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); @@ -204,8 +217,8 @@ describe('createAnalyzeUploadHandler', () => { }); it('rejects a single-segment manifest before creating a job (no slot taken)', async () => { - const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); - const launch = vi.fn(); + const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); + const launch = vi.fn(); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); @@ -224,8 +237,8 @@ describe('createAnalyzeUploadHandler', () => { }); it('rejects a multi-top-folder manifest (would silently drop folders)', async () => { - const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); - const launch = vi.fn(); + const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); + const launch = vi.fn(); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); @@ -245,9 +258,12 @@ describe('createAnalyzeUploadHandler', () => { it('releases the single slot (failJob) when a step fails after createJob', async () => { const top = uniqueTop(); - const createJob = vi.fn(() => ({ id: 'job-fail', status: 'queued' })); + const createJob = vi.fn(() => ({ + id: 'job-fail', + status: 'queued', + })); // launch throws AFTER createJob + promote — the slot must be released. - const launch = vi.fn((_j, dir: string) => { + const launch = vi.fn((_j, dir: string) => { promoted.push(dir); throw new Error('worker fork blew up'); }); diff --git a/gitnexus/test/unit/api-fts-mode.test.ts b/gitnexus/test/unit/api-fts-mode.test.ts index ab8eaf401..a78a2525a 100644 --- a/gitnexus/test/unit/api-fts-mode.test.ts +++ b/gitnexus/test/unit/api-fts-mode.test.ts @@ -328,7 +328,7 @@ async function invoke(route: string, query: Record = {}) { this.emit('finish'); }, }); - await handler(req, res); + await handler(req as unknown as express.Request, res as unknown as express.Response, vi.fn()); expect(res.statusCode, JSON.stringify(res.body)).toBe(route === '/api/embed' ? 202 : 200); return res; } diff --git a/gitnexus/test/unit/api-graph-streaming.test.ts b/gitnexus/test/unit/api-graph-streaming.test.ts index f79a1ea5a..4aa6ccdb8 100644 --- a/gitnexus/test/unit/api-graph-streaming.test.ts +++ b/gitnexus/test/unit/api-graph-streaming.test.ts @@ -10,7 +10,7 @@ const { lbugMocks } = vi.hoisted(() => ({ })); vi.mock('../../src/core/lbug/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); diff --git a/gitnexus/test/unit/auto-sync-runner.test.ts b/gitnexus/test/unit/auto-sync-runner.test.ts index a16f0fdac..e7c8761e3 100644 --- a/gitnexus/test/unit/auto-sync-runner.test.ts +++ b/gitnexus/test/unit/auto-sync-runner.test.ts @@ -98,7 +98,7 @@ async function runWithOnDiskPdgState(options: { getCurrentCommit: vi.fn(async () => 'commit-1'), runAnalysis, registerRepo: vi.fn(async () => 'repo'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -145,11 +145,11 @@ describe('auto-sync runner', () => { it('runs clone, analyzes changed commits, registers the repo, and syncs changed groups', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', @@ -229,11 +229,11 @@ describe('auto-sync runner', () => { await fs.writeFile(path.join(targetDir, '.gitnexusrc'), '{"embeddings": true}'); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => targetDir), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -268,12 +268,12 @@ describe('auto-sync runner', () => { const stateKey = '/tmp/repos/gitee.com/qts_server/qts_account|master'; const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-1'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-1'), getExistingPdgMode: vi.fn(async () => false), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ [stateKey]: { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', @@ -318,12 +318,12 @@ describe('auto-sync runner', () => { }; const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), getExistingPdgMode: vi.fn(async () => true), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -411,12 +411,12 @@ describe('auto-sync runner', () => { const warn = vi.fn(); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-1'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-1'), getExistingPdgMode: vi.fn(async () => true), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -452,7 +452,7 @@ describe('auto-sync runner', () => { runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), resolveBranchPlacement: vi.fn(async () => ({ branch: 'master' })), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => true), @@ -480,11 +480,11 @@ describe('auto-sync runner', () => { it('syncs a group when a repo is newly added to the group', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => true), @@ -508,11 +508,11 @@ describe('auto-sync runner', () => { it('syncs a group after successful re-analysis even when membership already exists', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-3'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-3'), runAnalysis: vi.fn(async () => ({ stats: { files: 2 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-2', analyzedCommitId: 'commit-2', @@ -547,12 +547,14 @@ describe('auto-sync runner', () => { projects: [ { localPath: '/tmp/repos-a', + overwriteLocalChanges: false, groupName: 'back_end', branches: ['main'], remoteUrls: ['git@github.com:team-a/service.git'], }, { localPath: '/tmp/repos-b', + overwriteLocalChanges: false, groupName: 'back_end', branches: ['main'], remoteUrls: ['git@gitlab.com:team-b/service.git'], @@ -565,11 +567,11 @@ describe('auto-sync runner', () => { root: localPath, })), cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), - getCurrentBranch: vi.fn(() => 'main'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'main'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'service'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => true), @@ -618,11 +620,11 @@ describe('auto-sync runner', () => { it('skips analysis when commit id has not changed', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-1'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-1'), runAnalysis: vi.fn(), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', @@ -664,11 +666,11 @@ describe('auto-sync runner', () => { .mockResolvedValueOnce(undefined); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-1'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-1'), runAnalysis: vi.fn(), registerRepo: vi.fn(), - loadState: vi.fn(async () => structuredClone(persistedState)), + loadState: vi.fn(async () => structuredClone(persistedState)), saveState: vi.fn(async (state) => { persistedState = structuredClone(state); }), @@ -705,11 +707,11 @@ describe('auto-sync runner', () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -763,11 +765,11 @@ describe('auto-sync runner', () => { const cloneOrPull = vi.fn(async () => '/tmp/repos/gitlab.mycompany.com/group/repo'); const deps: Partial = withCloneRoot({ cloneOrPull, - getCurrentBranch: vi.fn(() => 'main'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'main'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'repo'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -826,11 +828,11 @@ describe('auto-sync runner', () => { const runAnalysis = vi.fn(async () => ({ stats: { files: 1 } }) as any); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis, registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -873,11 +875,11 @@ describe('auto-sync runner', () => { if (options?.branch === 'missing') throw new Error('remote branch not found'); return '/tmp/repos/gitee.com/qts_server/qts_account'; }), - getCurrentBranch: vi.fn(() => 'develop'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'develop'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -940,7 +942,7 @@ describe('auto-sync runner', () => { getCurrentCommit: vi.fn(), runAnalysis: vi.fn(), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -980,11 +982,11 @@ describe('auto-sync runner', () => { const warnLogger = vi.fn(); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'develop'), + getCurrentBranch: vi.fn(async () => 'develop'), getCurrentCommit: vi.fn(), runAnalysis: vi.fn(), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => true), @@ -1010,11 +1012,11 @@ describe('auto-sync runner', () => { const warnLogger = vi.fn(); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => undefined), + getCurrentBranch: vi.fn(async () => undefined), getCurrentCommit: vi.fn(), runAnalysis: vi.fn(), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => true), @@ -1055,13 +1057,13 @@ describe('auto-sync runner', () => { if (remoteUrl.includes('failing_sync')) throw new Error('sync failed'); return '/tmp/repos/gitee.com/qts_server/qts_account'; }), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => { throw new Error('analysis failed'); }), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', @@ -1113,11 +1115,11 @@ describe('auto-sync runner', () => { it('records the resolved target directory when a post-sync operation fails', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => { + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => { throw new Error('git log failed'); }), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), getAvailableMemoryGB: vi.fn(() => 8), @@ -1153,11 +1155,11 @@ describe('auto-sync runner', () => { return cloneRoot; }), cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'repo'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1180,13 +1182,13 @@ describe('auto-sync runner', () => { const errorLogger = vi.fn(); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => { throw new Error('registry busy'); }), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1220,11 +1222,11 @@ describe('auto-sync runner', () => { const errorLogger = vi.fn(); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1279,13 +1281,13 @@ describe('auto-sync runner', () => { events.push(`clone-end:${remoteUrl}`); return remoteUrl.includes('/one.git') ? '/tmp/repos/one' : '/tmp/repos/two'; }), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn((repoPath) => + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async (repoPath: string) => repoPath.endsWith('/one') ? 'one-commit' : 'two-commit', ), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'repo'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1323,11 +1325,11 @@ describe('auto-sync runner', () => { }; const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), - getCurrentBranch: vi.fn(() => 'main'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'main'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async (_path, _meta, options) => options?.name ?? 'repo'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1367,14 +1369,14 @@ describe('auto-sync runner', () => { }; const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/github.com/owner/repo'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), syncGroupByName: vi.fn(async () => {}), getAvailableMemoryGB: vi.fn(() => 8), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'abc'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'abc'), runAnalysis: vi.fn(async () => ({ stats: {} })), registerRepo: vi.fn(async () => 'repo'), }); @@ -1399,13 +1401,13 @@ describe('auto-sync runner', () => { const errorLogger = vi.fn(); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => { throw new Error('parser crashed\nwith stack'); }), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', @@ -1460,13 +1462,13 @@ describe('auto-sync runner', () => { ); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-1'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-1'), runAnalysis: vi.fn(async () => { throw leftover; }), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', @@ -1513,8 +1515,8 @@ describe('auto-sync runner', () => { it('does not count a retryable live-holder index-lock timeout toward the analyze failure threshold', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-1'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-1'), runAnalysis: vi.fn(async () => { throw new AutoSyncAnalysisError('waited for the index lock', { code: 'index-lock-timeout', @@ -1522,7 +1524,7 @@ describe('auto-sync runner', () => { }); }), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1550,8 +1552,8 @@ describe('auto-sync runner', () => { it('still counts a non-retryable index-lock guard timeout toward the analyze failure threshold', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-1'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-1'), runAnalysis: vi.fn(async () => { throw new AutoSyncAnalysisError('index lock guard timeout', { code: 'index-lock-timeout', @@ -1559,7 +1561,7 @@ describe('auto-sync runner', () => { }); }), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1586,13 +1588,13 @@ describe('auto-sync runner', () => { it('records a null analysis failure without masking it with a TypeError', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => { throw null; }), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1621,11 +1623,11 @@ describe('auto-sync runner', () => { const errorLogger = vi.fn(); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', @@ -1673,11 +1675,11 @@ describe('auto-sync runner', () => { it('clears prior analyze failure count after a successful analyze', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', diff --git a/gitnexus/test/unit/auto-sync.test.ts b/gitnexus/test/unit/auto-sync.test.ts index ac974817a..ebf3ba866 100644 --- a/gitnexus/test/unit/auto-sync.test.ts +++ b/gitnexus/test/unit/auto-sync.test.ts @@ -287,7 +287,7 @@ describe('auto-sync', () => { const loaded = await loadAutoSyncConfig(); expect(loaded.ok).toBe(false); - if (loaded.ok) throw new Error('expected invalid config'); + if (loaded.ok !== false) throw new Error('expected invalid config'); expect(loaded.message).toContain('analyze_failure_threshold must be an integer >= 2'); }); @@ -307,7 +307,7 @@ describe('auto-sync', () => { const loaded = await loadAutoSyncConfig(); expect(loaded.ok).toBe(false); - if (loaded.ok) throw new Error('expected invalid config'); + if (loaded.ok !== false) throw new Error('expected invalid config'); expect(loaded.reason).toBe('invalid'); expect(loaded.message).toContain('[auto-sync] Invalid watch_config.yml:'); expect(loaded.message).toContain('sync_interval_minutes must be a positive integer'); @@ -330,7 +330,7 @@ describe('auto-sync', () => { const loaded = await loadAutoSyncConfig(); expect(loaded.ok).toBe(false); - if (loaded.ok) throw new Error('expected invalid config'); + if (loaded.ok !== false) throw new Error('expected invalid config'); expect(loaded.message).toContain('local_path must be an absolute path'); }); @@ -740,7 +740,7 @@ describe('auto-sync', () => { const loaded = await loadAutoSyncConfig(); expect(loaded.ok).toBe(false); - if (loaded.ok) throw new Error('expected invalid config'); + if (loaded.ok !== false) throw new Error('expected invalid config'); expect(loaded.message).toContain('must not set both branch and branches'); }); diff --git a/gitnexus/test/unit/call-routing/ruby.test.ts b/gitnexus/test/unit/call-routing/ruby.test.ts index 69c6405c3..a5ea5a6f7 100644 --- a/gitnexus/test/unit/call-routing/ruby.test.ts +++ b/gitnexus/test/unit/call-routing/ruby.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import type { SyntaxNode } from 'tree-sitter'; import { routeRubyCall } from '../../../src/core/ingestion/call-routing.js'; // ── Mock AST node helpers ──────────────────────────────────────────────────── @@ -164,67 +165,73 @@ function makeNamedSibling(type = 'expression_statement'): MockNode { return { type, text: '', isNamed: true }; } +// These routing fixtures intentionally provide only the traversed AST fields. +// Keep the native SyntaxNode assertion at the test boundary. +function syntaxNode(node: MockNode): SyntaxNode { + return node as unknown as SyntaxNode; +} + // ── require / require_relative ─────────────────────────────────────────────── describe('routeRubyCall — require / require_relative', () => { it('require with a valid string path returns import with isRelative=false', () => { const node = makeRequireCallNode('net/http'); - const result = routeRubyCall('require', node); + const result = routeRubyCall('require', syntaxNode(node)); expect(result).toEqual({ kind: 'import', importPath: 'net/http', isRelative: false }); }); it('require_relative without leading dot prepends "./"', () => { const node = makeRequireCallNode('models/user'); - const result = routeRubyCall('require_relative', node); + const result = routeRubyCall('require_relative', syntaxNode(node)); expect(result).toEqual({ kind: 'import', importPath: './models/user', isRelative: true }); }); it('require_relative with path already starting with "." does not double-prepend', () => { const node = makeRequireCallNode('./helpers/formatter'); - const result = routeRubyCall('require_relative', node); + const result = routeRubyCall('require_relative', syntaxNode(node)); expect(result).toEqual({ kind: 'import', importPath: './helpers/formatter', isRelative: true }); }); it('require_relative with "../" prefix is left unchanged', () => { const node = makeRequireCallNode('../shared/utils'); - const result = routeRubyCall('require_relative', node); + const result = routeRubyCall('require_relative', syntaxNode(node)); expect(result).toEqual({ kind: 'import', importPath: '../shared/utils', isRelative: true }); }); it('returns skip when there is no string_content node (non-literal argument)', () => { const node = makeRequireCallNodeNoContent(); - expect(routeRubyCall('require', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('require', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('returns skip when import path is an empty string', () => { const node = makeRequireCallNode(''); - expect(routeRubyCall('require', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('require', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('returns skip when import path contains a control character (\\x00)', () => { const node = makeRequireCallNode('some\x00path'); - expect(routeRubyCall('require', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('require', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('returns skip when import path contains a newline control character (\\n)', () => { const node = makeRequireCallNode('path\ninjection'); - expect(routeRubyCall('require', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('require', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('returns skip when import path exceeds 1024 characters', () => { const longPath = 'a'.repeat(1025); const node = makeRequireCallNode(longPath); - expect(routeRubyCall('require', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('require', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('accepts import path of exactly 1024 characters', () => { const maxPath = 'a'.repeat(1024); const node = makeRequireCallNode(maxPath); - const result = routeRubyCall('require', node); + const result = routeRubyCall('require', syntaxNode(node)); expect(result).toEqual({ kind: 'import', importPath: maxPath, isRelative: false }); }); @@ -240,13 +247,13 @@ describe('routeRubyCall — require / require_relative', () => { text: '', childForFieldName: (name: string) => (name === 'arguments' ? argList : undefined), }; - expect(routeRubyCall('require', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('require', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('returns skip when childForFieldName is absent (undefined callNode fields)', () => { // callNode has no childForFieldName method at all const node: MockNode = { type: 'call', text: '' }; - expect(routeRubyCall('require', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('require', syntaxNode(node))).toEqual({ kind: 'skip' }); }); }); @@ -259,7 +266,7 @@ describe('routeRubyCall — require / require_relative', () => { describe('routeRubyCall — include / extend / prepend (heritage owned by scope-resolution)', () => { it('include returns skip (heritage emitted by scope-resolution, not a call edge)', () => { const node = makeHeritageCallNode([makeConstantArg('Serializable')], 'class', 'User'); - expect(routeRubyCall('include', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('include', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('extend returns skip (heritage emitted by scope-resolution, not a call edge)', () => { @@ -268,12 +275,12 @@ describe('routeRubyCall — include / extend / prepend (heritage owned by scope- 'class', 'Post', ); - expect(routeRubyCall('extend', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('extend', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('prepend returns skip (heritage emitted by scope-resolution, not a call edge)', () => { const node = makeHeritageCallNode([makeConstantArg('Instrumented')], 'class', 'Service'); - expect(routeRubyCall('prepend', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('prepend', syntaxNode(node))).toEqual({ kind: 'skip' }); }); }); @@ -282,7 +289,7 @@ describe('routeRubyCall — include / extend / prepend (heritage owned by scope- describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { it('attr_accessor with a single symbol returns a property item', () => { const node = makeAccessorCallNode([makeSimpleSymbol('name', 5)]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toEqual({ kind: 'properties', @@ -292,7 +299,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { it('attr_reader sets accessorType to "attr_reader"', () => { const node = makeAccessorCallNode([makeSimpleSymbol('age', 3)]); - const result = routeRubyCall('attr_reader', node); + const result = routeRubyCall('attr_reader', syntaxNode(node)); expect(result).toEqual({ kind: 'properties', @@ -302,7 +309,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { it('attr_writer sets accessorType to "attr_writer"', () => { const node = makeAccessorCallNode([makeSimpleSymbol('email', 7)]); - const result = routeRubyCall('attr_writer', node); + const result = routeRubyCall('attr_writer', syntaxNode(node)); expect(result).toEqual({ kind: 'properties', @@ -319,7 +326,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { endPosition: { row: 2 }, }; const node = makeAccessorCallNode([symNode]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', items: [{ propName: 'title' }] }); }); @@ -333,7 +340,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { endPosition: { row: 1 }, }; const node = makeAccessorCallNode([symNode]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', items: [{ propName: 'status' }] }); }); @@ -345,7 +352,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { makeSimpleSymbol('dob', 10), ]; const node = makeAccessorCallNode(args); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toEqual({ kind: 'properties', @@ -360,7 +367,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { it('extracts simple YARD @return [Type] from preceding comment', () => { const comment = makeCommentNode('# @return [Address]'); const node = makeAccessorCallNode([makeSimpleSymbol('address', 20)], [comment]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', @@ -372,7 +379,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { // The regex captures "Array"; the simple match grabs the first uppercase word "Array" const comment = makeCommentNode('# @return [Array]'); const node = makeAccessorCallNode([makeSimpleSymbol('users', 15)], [comment]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', @@ -383,7 +390,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { it('extracts type from YARD comment with extra whitespace inside brackets', () => { const comment = makeCommentNode('# @return [ Integer ]'); const node = makeAccessorCallNode([makeSimpleSymbol('count', 8)], [comment]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', @@ -393,7 +400,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { it('does not set declaredType when no YARD comment precedes the call', () => { const node = makeAccessorCallNode([makeSimpleSymbol('score', 12)]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', items: [{ propName: 'score' }] }); const item = (result as any).items[0]; @@ -403,7 +410,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { it('does not set declaredType when comment has no @return annotation', () => { const comment = makeCommentNode('# This accessor stores the user name'); const node = makeAccessorCallNode([makeSimpleSymbol('user_name', 9)], [comment]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); const item = (result as any).items[0]; expect(item.declaredType).toBeUndefined(); @@ -413,7 +420,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { // e.g. "@return [string]" — lowercase first char fails the simple = raw.match(/^([A-Z]\w*)/) const comment = makeCommentNode('# @return [string]'); const node = makeAccessorCallNode([makeSimpleSymbol('label', 4)], [comment]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); const item = (result as any).items[0]; expect(item.declaredType).toBeUndefined(); @@ -426,7 +433,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { const named = makeNamedSibling(); const node = makeAccessorCallNode([makeSimpleSymbol('owner', 6)], [yardComment, named]); // named is last in the array → becomes direct previousSibling - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); const item = (result as any).items[0]; expect(item.declaredType).toBeUndefined(); @@ -438,7 +445,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { const comment = makeCommentNode('# @return [Order]'); // siblings in order oldest→newest; the last becomes the direct previousSibling const node = makeAccessorCallNode([makeSimpleSymbol('order', 30)], [comment, unnamedNode]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', @@ -450,12 +457,12 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { // Only an identifier node — not a symbol const identArg: MockNode = { type: 'identifier', text: 'some_var' }; const node = makeAccessorCallNode([identArg]); - expect(routeRubyCall('attr_accessor', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('attr_accessor', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('returns skip when arg list is empty', () => { const node = makeAccessorCallNode([]); - expect(routeRubyCall('attr_accessor', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('attr_accessor', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('records correct startLine and endLine from symbol node positions', () => { @@ -466,7 +473,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { endPosition: { row: 42 }, }; const node = makeAccessorCallNode([sym]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', @@ -480,19 +487,19 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { describe('routeRubyCall — default (unknown method name)', () => { it('returns {kind: "call"} for an arbitrary method name', () => { const node: MockNode = { type: 'call', text: '' }; - expect(routeRubyCall('some_method', node)).toEqual({ kind: 'call' }); + expect(routeRubyCall('some_method', syntaxNode(node))).toEqual({ kind: 'call' }); }); it('returns {kind: "call"} for an empty method name string', () => { const node: MockNode = { type: 'call', text: '' }; - expect(routeRubyCall('', node)).toEqual({ kind: 'call' }); + expect(routeRubyCall('', syntaxNode(node))).toEqual({ kind: 'call' }); }); it('returns {kind: "call"} for a realistic method name (save, render, etc.)', () => { const node: MockNode = { type: 'call', text: '' }; - expect(routeRubyCall('render', node)).toEqual({ kind: 'call' }); - expect(routeRubyCall('save', node)).toEqual({ kind: 'call' }); - expect(routeRubyCall('destroy', node)).toEqual({ kind: 'call' }); + expect(routeRubyCall('render', syntaxNode(node))).toEqual({ kind: 'call' }); + expect(routeRubyCall('save', syntaxNode(node))).toEqual({ kind: 'call' }); + expect(routeRubyCall('destroy', syntaxNode(node))).toEqual({ kind: 'call' }); }); }); @@ -501,13 +508,13 @@ describe('routeRubyCall — default (unknown method name)', () => { describe('routeRubyCall passthrough', () => { it('routeRubyCall delegates correctly for require', () => { const node = makeRequireCallNode('json'); - const result = routeRubyCall('require', node); + const result = routeRubyCall('require', syntaxNode(node)); expect(result).toEqual({ kind: 'import', importPath: 'json', isRelative: false }); }); it('routeRubyCall returns {kind: "call"} for an unknown method name', () => { const node: MockNode = { type: 'call', text: '' }; - const result = routeRubyCall('render', node); + const result = routeRubyCall('render', syntaxNode(node)); expect(result).toEqual({ kind: 'call' }); }); }); diff --git a/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts b/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts index be6d32215..ac832db95 100644 --- a/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts +++ b/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts @@ -29,12 +29,12 @@ const { lbugMocks } = vi.hoisted(() => ({ })); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 7eaf2e1ff..df5895120 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -30,13 +30,13 @@ const { lbugMocks } = vi.hoisted(() => ({ })); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); // Re-export shim must resolve to the same mocks vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); @@ -2560,9 +2560,7 @@ describe('LocalBackend.callTool', () => { // `oldName` must sit on the file's 0-based line 1 for the definition edit to // fire. (#2380: the mock previously put it on line 0, which stopped matching // once context() went 1-based.) - const readSpy = vi - .spyOn(fsPromises, 'readFile') - .mockResolvedValue('\nfunction oldName() {}\n' as unknown as Buffer); + const readSpy = vi.spyOn(fsPromises, 'readFile').mockResolvedValue('\nfunction oldName() {}\n'); const writeSpy = vi .spyOn(fsPromises, 'writeFile') .mockRejectedValue(new Error('EACCES: permission denied')); @@ -3034,7 +3032,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { it('mode absent → callgraph result (target populated, no mode-error, BFS runs)', async () => { resolveSingleTarget(); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream' }); // A clean callgraph result carries no mode error and runs the BFS. expect(result.error ?? '').not.toMatch(/Invalid "mode"/); @@ -3062,7 +3063,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { it("mode:'pdg' routes to the PDG traversal and attaches interprocedural symbol reach", async () => { resolveSingleTarget(); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream', @@ -3079,7 +3083,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { it("mode:'pdg' labels interprocedural symbols as a callgraph bridge", async () => { resolveSingleTarget(); - vi.spyOn(backend as any, '_runImpactBFS').mockResolvedValueOnce({ + vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ).mockResolvedValueOnce({ target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, direction: 'downstream', impactedCount: 1, @@ -3118,7 +3125,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { it("mode:'pdg' preserves unproven bridge evidence when call-site proof is unavailable", async () => { resolveSingleTarget(); - vi.spyOn(backend as any, '_runImpactBFS').mockResolvedValueOnce({ + vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ).mockResolvedValueOnce({ target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, direction: 'downstream', impactedCount: 1, @@ -3159,7 +3169,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { 'invalid mode %j → structured {error}, never a callgraph result (KTD5 anti-silent-fallback)', async (bad) => { resolveSingleTarget(); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream', @@ -3176,7 +3189,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { 'line param with mode:%j → structured {error} (line is PDG-only), never a callgraph result', async (mode) => { resolveSingleTarget(); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream', @@ -3199,7 +3215,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { 'mode:%j + adapter-materialized line:0 is treated as omitted and runs the BFS (#2279)', async (mode) => { resolveSingleTarget(); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream', @@ -3296,7 +3315,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { affectedStatementCount: 1, criterionLine: 8, }); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'main', direction: 'downstream', @@ -3349,7 +3371,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { affectedStatementCount: 0, criterionLine: 8, }); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); await backend.callTool('impact', { target: 'main', direction: 'downstream', @@ -3426,7 +3451,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { affectedStatementCount: 1, criterionLine: 8, }); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const cap = _captureLogger(); try { const result = await backend.callTool('impact', { @@ -3482,7 +3510,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { affectedStatementCount: 1, criterionLine: 8, }); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const cap = _captureLogger('debug'); try { const result = await backend.callTool('impact', { @@ -3529,7 +3560,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { affectedStatementCount: 1, criterionLine: 8, }); - vi.spyOn(backend as any, '_runImpactBFS'); + vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const cap = _captureLogger(); try { await backend.callTool('impact', { @@ -3548,7 +3582,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { it("mode:'pdg' + crossDepth → hard {error} (single-repo PDG impact)", async () => { resolveSingleTarget(); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream', @@ -3565,17 +3602,22 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { ['minConfidence', { minConfidence: 0.5 }, (opts: any) => opts.minConfidence], ])("mode:'pdg' + %s feeds the interprocedural symbol reach", async (_label, extra, readOpt) => { resolveSingleTarget(); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS').mockResolvedValueOnce({ - target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, - direction: 'upstream', - impactedCount: 0, - risk: 'LOW', - summary: { direct: 0, processes_affected: 0, modules_affected: 0 }, - byDepthCounts: {}, - affected_processes: [], - affected_modules: [], - byDepth: {}, - }); + const bfsSpy = vi + .spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ) + .mockResolvedValueOnce({ + target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, + direction: 'upstream', + impactedCount: 0, + risk: 'LOW', + summary: { direct: 0, processes_affected: 0, modules_affected: 0 }, + byDepthCounts: {}, + affected_processes: [], + affected_modules: [], + byDepth: {}, + }); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream', @@ -3606,7 +3648,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { startLine: 8, }, ]); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'login', direction: 'upstream', diff --git a/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts b/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts index a9263e37f..99f8a0043 100644 --- a/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts +++ b/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts @@ -1,3 +1,4 @@ +import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js'; import { describe, it, expect, vi } from 'vitest'; import { createRequire } from 'node:module'; import { requireVendoredGrammar } from '../../../src/core/tree-sitter/vendored-grammars.js'; diff --git a/gitnexus/test/unit/cfg/cfg-builder.test.ts b/gitnexus/test/unit/cfg/cfg-builder.test.ts index 1bef8cc41..6effd0dc4 100644 --- a/gitnexus/test/unit/cfg/cfg-builder.test.ts +++ b/gitnexus/test/unit/cfg/cfg-builder.test.ts @@ -116,7 +116,7 @@ describe('ControlFlowContext', () => { it('labeled break/continue resolve to the labeled loop, not the nearest', () => { const ctx = new ControlFlowContext(); - ctx.pushLoop(/*outer*/ 100, 200, 'outer'); + ctx.pushLoop(/*outer*/ 100, 200, ['outer']); ctx.pushLoop(/*inner*/ 110, 210); expect(ctx.breakTarget('outer')).toBe(200); expect(ctx.continueTarget('outer')).toBe(100); diff --git a/gitnexus/test/unit/cfg/control-dependence.test.ts b/gitnexus/test/unit/cfg/control-dependence.test.ts index e131a946e..04f96fc18 100644 --- a/gitnexus/test/unit/cfg/control-dependence.test.ts +++ b/gitnexus/test/unit/cfg/control-dependence.test.ts @@ -49,6 +49,7 @@ function mkCfg( return { filePath: 't.ts', functionStartLine: 1, + functionEndLine: blockCount, functionStartColumn: 0, entryIndex: entry, exitIndex: exit, diff --git a/gitnexus/test/unit/cfg/post-dominators.test.ts b/gitnexus/test/unit/cfg/post-dominators.test.ts index abc257b41..4f665b7b4 100644 --- a/gitnexus/test/unit/cfg/post-dominators.test.ts +++ b/gitnexus/test/unit/cfg/post-dominators.test.ts @@ -36,6 +36,7 @@ function mkCfg( return { filePath: 't.ts', functionStartLine: 1, + functionEndLine: blockCount, functionStartColumn: 0, entryIndex: entry, exitIndex: exit, diff --git a/gitnexus/test/unit/cfg/synthetic-escape.test.ts b/gitnexus/test/unit/cfg/synthetic-escape.test.ts index ee02e01c0..5f62a7227 100644 --- a/gitnexus/test/unit/cfg/synthetic-escape.test.ts +++ b/gitnexus/test/unit/cfg/synthetic-escape.test.ts @@ -51,6 +51,7 @@ function mkCfg( return { filePath: 't.ts', functionStartLine: 1, + functionEndLine: blockCount, functionStartColumn: 0, entryIndex: entry, exitIndex: exit, diff --git a/gitnexus/test/unit/cli-commands.test.ts b/gitnexus/test/unit/cli-commands.test.ts index ade958fe2..ed8031aef 100644 --- a/gitnexus/test/unit/cli-commands.test.ts +++ b/gitnexus/test/unit/cli-commands.test.ts @@ -96,7 +96,9 @@ describe('CLI commands', () => { describe('optional parser dependencies', () => { it('loads vendored grammars from vendor/ — never file: optionalDependencies (#1728) nor a node_modules copy (#2111)', async () => { const pkg = await import('../../package.json', { with: { type: 'json' } }); - const optional = pkg.default.optionalDependencies ?? {}; + const optional = + (pkg.default as { optionalDependencies?: Record }).optionalDependencies ?? + {}; expect(optional['tree-sitter-dart']).toBeUndefined(); expect(optional['tree-sitter-proto']).toBeUndefined(); expect(optional['tree-sitter-swift']).toBeUndefined(); @@ -142,8 +144,12 @@ describe('CLI commands', () => { expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs'); expect(swiftPkg.default.version).toBe('0.7.1'); // No scripts.install / dependencies inside vendor/ (#836 / #1728 hygiene). - expect(swiftPkg.default.scripts?.install).toBeUndefined(); - expect(swiftPkg.default.dependencies).toBeUndefined(); + expect( + (swiftPkg.default as { scripts?: Record }).scripts?.install, + ).toBeUndefined(); + expect( + (swiftPkg.default as { dependencies?: Record }).dependencies, + ).toBeUndefined(); expect(swiftPkg.default.peerDependencies['tree-sitter']).toContain('^0.21.1'); // Swift is now unified with Dart/Proto/Kotlin/C: the grammar SOURCE is // vendored so build-tree-sitter-grammars.cjs can source-build the binding @@ -164,7 +170,9 @@ describe('CLI commands', () => { const kotlinPkg = await import('../../vendor/tree-sitter-kotlin/package.json', { with: { type: 'json' }, }); - const optional = pkg.default.optionalDependencies ?? {}; + const optional = + (pkg.default as { optionalDependencies?: Record }).optionalDependencies ?? + {}; // Kotlin is now VENDORED (like Swift/Dart/Proto), not a third-party npm // optionalDependency. Its prebuilds are GitNexus-cross-built (upstream // ships source only) and loaded from vendor/ by absolute path (#2111). @@ -172,8 +180,12 @@ describe('CLI commands', () => { expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs'); expect(kotlinPkg.default.version).toBe('0.4.0'); // No scripts.install / dependencies inside vendor/ (#836 / #1728 hygiene). - expect(kotlinPkg.default.scripts?.install).toBeUndefined(); - expect(kotlinPkg.default.dependencies).toBeUndefined(); + expect( + (kotlinPkg.default as { scripts?: Record }).scripts?.install, + ).toBeUndefined(); + expect( + (kotlinPkg.default as { dependencies?: Record }).dependencies, + ).toBeUndefined(); expect(kotlinPkg.default.peerDependencies['tree-sitter']).toContain('^0.21'); }); @@ -182,15 +194,21 @@ describe('CLI commands', () => { const zigPkg = await import('../../vendor/tree-sitter-zig/package.json', { with: { type: 'json' }, }); - const optional = pkg.default.optionalDependencies ?? {}; + const optional = + (pkg.default as { optionalDependencies?: Record }).optionalDependencies ?? + {}; expect(optional['@tree-sitter-grammars/tree-sitter-zig']).toBeUndefined(); expect(Object.keys(pkg.default.overrides ?? {})).not.toContain( '@tree-sitter-grammars/tree-sitter-zig', ); expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs'); expect(zigPkg.default.version).toBe('1.1.2'); - expect(zigPkg.default.scripts?.install).toBeUndefined(); - expect(zigPkg.default.dependencies).toBeUndefined(); + expect( + (zigPkg.default as { scripts?: Record }).scripts?.install, + ).toBeUndefined(); + expect( + (zigPkg.default as { dependencies?: Record }).dependencies, + ).toBeUndefined(); expect(zigPkg.default.peerDependencies['tree-sitter']).toContain('^0.21'); }); @@ -205,8 +223,12 @@ describe('CLI commands', () => { expect(pkg.default.dependencies['tree-sitter-c']).toBeUndefined(); expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs'); expect(cPkg.default.version).toBe('0.21.4'); - expect(cPkg.default.scripts?.install).toBeUndefined(); - expect(cPkg.default.dependencies).toBeUndefined(); + expect( + (cPkg.default as { scripts?: Record }).scripts?.install, + ).toBeUndefined(); + expect( + (cPkg.default as { dependencies?: Record }).dependencies, + ).toBeUndefined(); }); }); diff --git a/gitnexus/test/unit/cohesion-consistency.test.ts b/gitnexus/test/unit/cohesion-consistency.test.ts index 327f0cd65..0301264e7 100644 --- a/gitnexus/test/unit/cohesion-consistency.test.ts +++ b/gitnexus/test/unit/cohesion-consistency.test.ts @@ -10,7 +10,7 @@ */ import { describe, it, expect } from 'vitest'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import type { GraphNode, GraphRelationship } from '../../src/core/graph/types.js'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; import { processCommunities } from '../../src/core/ingestion/community-processor.js'; // ============================================================================ diff --git a/gitnexus/test/unit/community-processor.test.ts b/gitnexus/test/unit/community-processor.test.ts index e9b3d07aa..a2bb8ce6d 100644 --- a/gitnexus/test/unit/community-processor.test.ts +++ b/gitnexus/test/unit/community-processor.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path'; import { Worker } from 'node:worker_threads'; import { describe, it, expect, vi } from 'vitest'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import type { GraphNode, GraphRelationship } from '../../src/core/graph/types.js'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; import { getCommunityColor, COMMUNITY_COLORS, diff --git a/gitnexus/test/unit/convex-metadata.test.ts b/gitnexus/test/unit/convex-metadata.test.ts index e486f6bc2..45bb603b3 100644 --- a/gitnexus/test/unit/convex-metadata.test.ts +++ b/gitnexus/test/unit/convex-metadata.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import Parser from 'tree-sitter'; import TypeScript from 'tree-sitter-typescript'; -import type { ParsedImport } from 'gitnexus-shared'; +import type { NodeLabel, ParsedImport } from 'gitnexus-shared'; import { extractConvexEndpointProperties } from '../../src/core/ingestion/languages/typescript/convex-endpoint-metadata.js'; import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; @@ -27,18 +27,19 @@ const namedImport = ( importedName: string, localName = importedName, ): ParsedImport => ({ - kind: localName === importedName ? 'named' : 'alias', + ...(localName === importedName + ? { kind: 'named' as const } + : { kind: 'alias' as const, alias: localName }), targetRaw, importedName, localName, - ...(localName === importedName ? {} : { alias: localName }), }); function extract( source: string, imports: readonly ParsedImport[], isExported = true, - nodeLabel = 'Const', + nodeLabel: NodeLabel = 'Const', definitionType = 'export_statement', ) { return extractConvexEndpointProperties({ diff --git a/gitnexus/test/unit/cpp-ue-preprocessor.test.ts b/gitnexus/test/unit/cpp-ue-preprocessor.test.ts index 087c5ba2a..dbbc0dcfb 100644 --- a/gitnexus/test/unit/cpp-ue-preprocessor.test.ts +++ b/gitnexus/test/unit/cpp-ue-preprocessor.test.ts @@ -211,7 +211,7 @@ describe('stripUeMacros — tree-sitter extraction (end-to-end)', () => { */ function extractClassNames(source: string): string[] { const parser = new Parser(); - parser.setLanguage(CPP as unknown as Parser.Language); + parser.setLanguage(CPP); const tree = parser.parse(source); const names: string[] = []; const stack: Parser.SyntaxNode[] = [tree.rootNode]; @@ -249,7 +249,7 @@ describe('stripUeMacros — tree-sitter extraction (end-to-end)', () => { const src = `UCLASS()\nclass BRAWLUI_API UMyClass : public UObject\n{\n GENERATED_BODY()\n public:\n void Run();\n};`; const out = stripUeMacros(src); const parser = new Parser(); - parser.setLanguage(CPP as unknown as Parser.Language); + parser.setLanguage(CPP); const tree = parser.parse(out); const stack: Parser.SyntaxNode[] = [tree.rootNode]; let runLine: number | undefined; diff --git a/gitnexus/test/unit/embedding-restore-spill.test.ts b/gitnexus/test/unit/embedding-restore-spill.test.ts index b11695110..7f0fd8a8b 100644 --- a/gitnexus/test/unit/embedding-restore-spill.test.ts +++ b/gitnexus/test/unit/embedding-restore-spill.test.ts @@ -39,7 +39,7 @@ function row(id: string, fill: number, hash = `hash-${id}`) { } describe('embedding-restore-spill (#3306)', () => { - const spills: Array<{ path: string }> = []; + const spills: Array[0]> = []; afterEach(() => { for (const spill of spills) disposeEmbeddingSpill(spill); spills.length = 0; @@ -170,7 +170,7 @@ describe('embedding-restore-spill (#3306)', () => { const shortPath = path.join(os.tmpdir(), `gitnexus-embed-restore-short-${process.pid}.bin`); writeFileSync(shortPath, Buffer.from('NOPE')); - spills.push({ path: shortPath }); + spills.push({ path: shortPath, dims: DIMS, rowCount: 1 }); expect(() => readSpillVectors({ path: shortPath, dims: DIMS, rowCount: 1 }, [0])).toThrow( /invalid embedding spill header/, ); @@ -181,7 +181,7 @@ describe('embedding-restore-spill (#3306)', () => { badMagic.writeUInt32LE(DIMS, 5); const badPath = path.join(os.tmpdir(), `gitnexus-embed-restore-bad-${process.pid}.bin`); writeFileSync(badPath, badMagic); - spills.push({ path: badPath }); + spills.push({ path: badPath, dims: DIMS, rowCount: 1 }); expect(() => readSpillVectors({ path: badPath, dims: DIMS, rowCount: 1 }, [0])).toThrow( /invalid embedding spill header/, ); diff --git a/gitnexus/test/unit/embedding-sidecar-client.test.ts b/gitnexus/test/unit/embedding-sidecar-client.test.ts index 0bdf5745f..7e6943419 100644 --- a/gitnexus/test/unit/embedding-sidecar-client.test.ts +++ b/gitnexus/test/unit/embedding-sidecar-client.test.ts @@ -3,6 +3,8 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; +import type { ForkImpl } from '../../src/core/embeddings/embedding-sidecar-client.js'; import type { ChildProcess } from 'node:child_process'; import type { SidecarRequest, @@ -57,7 +59,7 @@ describe('embedding sidecar client', () => { const hostPlatform = process.platform; const hostArch = process.arch; - let forkMock: ReturnType; + let forkMock: Mock; let children: FakeChild[]; beforeEach(async () => { @@ -66,7 +68,7 @@ describe('embedding sidecar client', () => { Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); Object.defineProperty(process, 'arch', { value: 'x64', configurable: true }); children = []; - forkMock = vi.fn((_script: string, _args: string[], _opts: unknown) => { + forkMock = vi.fn((_script, _args, _opts) => { const child = new FakeChild(); children.push(child); return child as unknown as ChildProcess; diff --git a/gitnexus/test/unit/embeddings-install-command.test.ts b/gitnexus/test/unit/embeddings-install-command.test.ts index b60348c6c..00961e090 100644 --- a/gitnexus/test/unit/embeddings-install-command.test.ts +++ b/gitnexus/test/unit/embeddings-install-command.test.ts @@ -13,13 +13,15 @@ import type { LoggerCapture } from '../../src/core/logger.js'; const resolveEmbeddingRuntimeMock = vi.fn<() => { source: string } | null>(); const isPrefixRuntimeLoadableMock = vi.fn(() => true); -const installEmbeddingRuntimeMock = vi.fn(async () => undefined); +const installEmbeddingRuntimeMock = vi.fn< + typeof import('../../src/core/embeddings/runtime-install.js').installEmbeddingRuntime +>(async () => undefined); vi.mock('../../src/core/embeddings/runtime-install.js', async (importOriginal) => ({ ...(await importOriginal()), resolveEmbeddingRuntime: () => resolveEmbeddingRuntimeMock(), isPrefixRuntimeLoadable: () => isPrefixRuntimeLoadableMock(), - installEmbeddingRuntime: (opts?: unknown) => installEmbeddingRuntimeMock(opts), + installEmbeddingRuntime: installEmbeddingRuntimeMock, getEmbeddingRuntimeDir: () => '/fake/embedding-runtime', getEmbeddingStackSpecs: () => ({ '@huggingface/transformers': '^4.1.0' }), })); diff --git a/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts b/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts index 5dc089e56..6ae5835c6 100644 --- a/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts +++ b/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts @@ -29,7 +29,7 @@ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { }; }); -import { LocalBackend } from '../../src/mcp/local/local-backend'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; describe('ensureInitialized reinit watermark (tri-review NEW-7)', () => { const poolKey = '/tmp/nonexistent-repo/.gitnexus/lbug'; diff --git a/gitnexus/test/unit/entry-point-scoring.test.ts b/gitnexus/test/unit/entry-point-scoring.test.ts index 5d0992831..e468a94dd 100644 --- a/gitnexus/test/unit/entry-point-scoring.test.ts +++ b/gitnexus/test/unit/entry-point-scoring.test.ts @@ -1,3 +1,4 @@ +import { SupportedLanguages } from 'gitnexus-shared'; import { describe, it, expect } from 'vitest'; import { calculateEntryPointScore, @@ -8,34 +9,58 @@ import { describe('calculateEntryPointScore', () => { describe('base scoring', () => { it('returns 0 for functions with no outgoing calls', () => { - const result = calculateEntryPointScore('handler', 'typescript', true, 0, 0); + const result = calculateEntryPointScore('handler', SupportedLanguages.TypeScript, true, 0, 0); expect(result.score).toBe(0); expect(result.reasons).toContain('no-outgoing-calls'); }); it('calculates base score as calleeCount / (callerCount + 1)', () => { - const result = calculateEntryPointScore('doStuff', 'typescript', false, 0, 5); + const result = calculateEntryPointScore( + 'doStuff', + SupportedLanguages.TypeScript, + false, + 0, + 5, + ); // base = 5 / (0 + 1) = 5, no export bonus, no name bonus expect(result.score).toBe(5); }); it('reduces score for functions with many callers', () => { - const few = calculateEntryPointScore('doStuff', 'typescript', false, 1, 5); - const many = calculateEntryPointScore('doStuff', 'typescript', false, 10, 5); + const few = calculateEntryPointScore('doStuff', SupportedLanguages.TypeScript, false, 1, 5); + const many = calculateEntryPointScore('doStuff', SupportedLanguages.TypeScript, false, 10, 5); expect(few.score).toBeGreaterThan(many.score); }); }); describe('export multiplier', () => { it('applies 2.0 multiplier for exported functions', () => { - const exported = calculateEntryPointScore('doStuff', 'typescript', true, 0, 4); - const notExported = calculateEntryPointScore('doStuff', 'typescript', false, 0, 4); + const exported = calculateEntryPointScore( + 'doStuff', + SupportedLanguages.TypeScript, + true, + 0, + 4, + ); + const notExported = calculateEntryPointScore( + 'doStuff', + SupportedLanguages.TypeScript, + false, + 0, + 4, + ); expect(exported.score).toBe(notExported.score * 2); expect(exported.reasons).toContain('exported'); }); it('does not add exported reason when not exported', () => { - const result = calculateEntryPointScore('doStuff', 'typescript', false, 0, 4); + const result = calculateEntryPointScore( + 'doStuff', + SupportedLanguages.TypeScript, + false, + 0, + 4, + ); expect(result.reasons).not.toContain('exported'); }); }); @@ -44,7 +69,7 @@ describe('calculateEntryPointScore', () => { it.each(['main', 'init', 'bootstrap', 'start', 'run', 'setup', 'configure'])( 'recognizes "%s" as entry point pattern', (name) => { - const result = calculateEntryPointScore(name, 'typescript', false, 0, 3); + const result = calculateEntryPointScore(name, SupportedLanguages.TypeScript, false, 0, 3); expect(result.reasons).toContain('entry-pattern'); }, ); @@ -64,13 +89,19 @@ describe('calculateEntryPointScore', () => { 'fireEvent', 'emitEvent', ])('recognizes "%s" as entry point pattern', (name) => { - const result = calculateEntryPointScore(name, 'typescript', false, 0, 3); + const result = calculateEntryPointScore(name, SupportedLanguages.TypeScript, false, 0, 3); expect(result.reasons).toContain('entry-pattern'); }); it('applies 1.5x name multiplier for entry patterns', () => { - const matching = calculateEntryPointScore('handleLogin', 'typescript', false, 0, 4); - const plain = calculateEntryPointScore('doStuff', 'typescript', false, 0, 4); + const matching = calculateEntryPointScore( + 'handleLogin', + SupportedLanguages.TypeScript, + false, + 0, + 4, + ); + const plain = calculateEntryPointScore('doStuff', SupportedLanguages.TypeScript, false, 0, 4); // matching gets 1.5x, plain gets 1.0x expect(matching.score).toBe(plain.score * 1.5); }); @@ -78,63 +109,81 @@ describe('calculateEntryPointScore', () => { describe('language-specific patterns', () => { it('recognizes React hooks for TypeScript', () => { - const result = calculateEntryPointScore('useEffect', 'typescript', false, 0, 2); + const result = calculateEntryPointScore( + 'useEffect', + SupportedLanguages.TypeScript, + false, + 0, + 2, + ); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes React hooks for JavaScript', () => { - const result = calculateEntryPointScore('useState', 'javascript', false, 0, 2); + const result = calculateEntryPointScore( + 'useState', + SupportedLanguages.JavaScript, + false, + 0, + 2, + ); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes Python REST patterns', () => { - const result = calculateEntryPointScore('get_users', 'python', false, 0, 2); + const result = calculateEntryPointScore('get_users', SupportedLanguages.Python, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes Java servlet patterns', () => { - const result = calculateEntryPointScore('doGet', 'java', false, 0, 2); + const result = calculateEntryPointScore('doGet', SupportedLanguages.Java, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes Go handler patterns', () => { - const result = calculateEntryPointScore('NewServer', 'go', false, 0, 2); + const result = calculateEntryPointScore('NewServer', SupportedLanguages.Go, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes Rust entry patterns', () => { - const result = calculateEntryPointScore('handle_request', 'rust', false, 0, 2); + const result = calculateEntryPointScore( + 'handle_request', + SupportedLanguages.Rust, + false, + 0, + 2, + ); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes Swift UIKit lifecycle', () => { - const result = calculateEntryPointScore('viewDidLoad', 'swift', false, 0, 2); + const result = calculateEntryPointScore('viewDidLoad', SupportedLanguages.Swift, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes Swift SwiftUI body', () => { - const result = calculateEntryPointScore('body', 'swift', false, 0, 2); + const result = calculateEntryPointScore('body', SupportedLanguages.Swift, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes PHP Laravel patterns', () => { // __invoke starts with '_' which matches utility pattern first - const result = calculateEntryPointScore('handle', 'php', false, 0, 2); + const result = calculateEntryPointScore('handle', SupportedLanguages.PHP, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes PHP RESTful resource methods', () => { - const result = calculateEntryPointScore('index', 'php', false, 0, 2); + const result = calculateEntryPointScore('index', SupportedLanguages.PHP, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes C# ASP.NET patterns', () => { - const result = calculateEntryPointScore('GetUsers', 'csharp', false, 0, 2); + const result = calculateEntryPointScore('GetUsers', SupportedLanguages.CSharp, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes C main entry point', () => { - const result = calculateEntryPointScore('main', 'c', false, 0, 2); + const result = calculateEntryPointScore('main', SupportedLanguages.C, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); @@ -152,7 +201,7 @@ describe('calculateEntryPointScore', () => { 'session_create', 'window_resize', ])('recognizes C pattern "%s"', (name) => { - const result = calculateEntryPointScore(name, 'c', false, 0, 2); + const result = calculateEntryPointScore(name, SupportedLanguages.C, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); @@ -167,7 +216,7 @@ describe('calculateEntryPointScore', () => { 'OnEventReceived', 'on_click', ])('recognizes C++ pattern "%s"', (name) => { - const result = calculateEntryPointScore(name, 'cpp', false, 0, 2); + const result = calculateEntryPointScore(name, SupportedLanguages.CPlusPlus, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); @@ -186,7 +235,7 @@ describe('calculateEntryPointScore', () => { 'module', 'AuthService', ])('recognizes Kotlin pattern "%s"', (name) => { - const result = calculateEntryPointScore(name, 'kotlin', false, 0, 2); + const result = calculateEntryPointScore(name, SupportedLanguages.Kotlin, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); @@ -197,14 +246,14 @@ describe('calculateEntryPointScore', () => { it.each(['main', 'build', 'createState', 'initState', 'dispose', 'runApp', 'onEvent'])( 'recognizes Dart pattern "%s"', (name) => { - const result = calculateEntryPointScore(name, 'dart', false, 0, 2); + const result = calculateEntryPointScore(name, SupportedLanguages.Dart, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }, ); // Ruby-specific patterns (Rails callable/job/service objects) it.each(['call', 'perform', 'execute'])('recognizes Ruby pattern "%s"', (name) => { - const result = calculateEntryPointScore(name, 'ruby', false, 0, 2); + const result = calculateEntryPointScore(name, SupportedLanguages.Ruby, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); }); @@ -226,15 +275,21 @@ describe('calculateEntryPointScore', () => { 'cloneDeep', 'mergeObjects', ])('penalizes utility function "%s"', (name) => { - const result = calculateEntryPointScore(name, 'typescript', false, 0, 3); + const result = calculateEntryPointScore(name, SupportedLanguages.TypeScript, false, 0, 3); expect(result.reasons).toContain('utility-pattern'); // 0.3 multiplier - const plain = calculateEntryPointScore('doStuff', 'typescript', false, 0, 3); + const plain = calculateEntryPointScore('doStuff', SupportedLanguages.TypeScript, false, 0, 3); expect(result.score).toBeLessThan(plain.score); }); it('penalizes private-by-convention functions', () => { - const result = calculateEntryPointScore('_internal', 'typescript', false, 0, 3); + const result = calculateEntryPointScore( + '_internal', + SupportedLanguages.TypeScript, + false, + 0, + 3, + ); expect(result.reasons).toContain('utility-pattern'); }); }); @@ -243,7 +298,7 @@ describe('calculateEntryPointScore', () => { it('boosts Next.js page entry points', () => { const result = calculateEntryPointScore( 'render', - 'typescript', + SupportedLanguages.TypeScript, true, 0, 3, @@ -256,7 +311,7 @@ describe('calculateEntryPointScore', () => { it('does not apply framework bonus for non-framework paths', () => { const result = calculateEntryPointScore( 'render', - 'typescript', + SupportedLanguages.TypeScript, true, 0, 3, @@ -271,7 +326,7 @@ describe('calculateEntryPointScore', () => { // handleLogin: entry pattern (1.5x) + exported (2.0x) + base const result = calculateEntryPointScore( 'handleLogin', - 'typescript', + SupportedLanguages.TypeScript, true, 0, 4, diff --git a/gitnexus/test/unit/fetch-reason-parsing.test.ts b/gitnexus/test/unit/fetch-reason-parsing.test.ts index 4d81cc5db..ffb0fc802 100644 --- a/gitnexus/test/unit/fetch-reason-parsing.test.ts +++ b/gitnexus/test/unit/fetch-reason-parsing.test.ts @@ -84,7 +84,8 @@ describe('fetch reason field parsing', () => { describe('confidence derivation from fetchCount', () => { it('high confidence when fetchCount is undefined (single fetch)', () => { - const isMultiFetch = (undefined ?? 1) > 1; + const fetchCount: number | undefined = undefined; + const isMultiFetch = (fetchCount ?? 1) > 1; expect(isMultiFetch).toBe(false); }); diff --git a/gitnexus/test/unit/field-extraction.test.ts b/gitnexus/test/unit/field-extraction.test.ts index fd86c3aaa..81062ced2 100644 --- a/gitnexus/test/unit/field-extraction.test.ts +++ b/gitnexus/test/unit/field-extraction.test.ts @@ -62,7 +62,8 @@ const createMockContext = (): FieldExtractorContext => ({ fileScope: () => new Map(), allScopes: () => new Map(), constructorTypeMap: new Map(), - } as TypeEnvironment, + flush: () => {}, + } satisfies TypeEnvironment, symbolTable: createSemanticModel().symbols, filePath: 'test.ts', language: SupportedLanguages.TypeScript, @@ -1327,7 +1328,7 @@ describeKotlin('GenericFieldExtractor — Kotlin (F52 companion)', () => { /** The first companion_object node in `src`. */ function companion(src: string): Parser.SyntaxNode { - parser.setLanguage(Kotlin as Parser.Language); + parser.setLanguage(Kotlin); const tree = parser.parse(src); let found: Parser.SyntaxNode | undefined; const walk = (n: Parser.SyntaxNode) => { @@ -1401,7 +1402,7 @@ describeKotlin('GenericFieldExtractor — Kotlin (F52 companion)', () => { /** Parse `src` and return the first node of the given type (depth-first). */ function firstNodeOfType(src: string, type: string): Parser.SyntaxNode { - parser.setLanguage(Kotlin as Parser.Language); + parser.setLanguage(Kotlin); const tree = parser.parse(src); let found: Parser.SyntaxNode | undefined; const walk = (n: Parser.SyntaxNode) => { @@ -1436,7 +1437,7 @@ describeSwift('GenericFieldExtractor — Swift (F75 protocol property requiremen /** Parse `src` and return the first class/protocol declaration node. */ function declNode(src: string): Parser.SyntaxNode { - parser.setLanguage(Swift as Parser.Language); + parser.setLanguage(Swift); const tree = parser.parse(src); const node = tree.rootNode.child(0); if (!node) throw new Error('no declaration node'); diff --git a/gitnexus/test/unit/framework-detection.test.ts b/gitnexus/test/unit/framework-detection.test.ts index 7757f7e18..58a25dea7 100644 --- a/gitnexus/test/unit/framework-detection.test.ts +++ b/gitnexus/test/unit/framework-detection.test.ts @@ -227,14 +227,14 @@ describe('detectFrameworkFromPath', () => { it('detects main.rs', () => { const result = detectFrameworkFromPath('src/main.rs'); expect(result).not.toBeNull(); - expect(result!.framework).toBe('rust'); + expect(result!.framework).toBe(SupportedLanguages.Rust); expect(result!.entryPointMultiplier).toBe(3.0); }); it('detects bin folder', () => { const result = detectFrameworkFromPath('src/bin/cli.rs'); expect(result).not.toBeNull(); - expect(result!.framework).toBe('rust'); + expect(result!.framework).toBe(SupportedLanguages.Rust); }); }); @@ -325,52 +325,53 @@ describe('detectFrameworkFromPath', () => { }); describe('detectFrameworkFromAST', () => { + // Deliberately invalid enum inputs exercise runtime tolerance at the public boundary. it('returns null for empty inputs', () => { - expect(detectFrameworkFromAST('', '')).toBeNull(); - expect(detectFrameworkFromAST('typescript', '')).toBeNull(); - expect(detectFrameworkFromAST('', 'some code')).toBeNull(); + expect(detectFrameworkFromAST('' as SupportedLanguages, '')).toBeNull(); + expect(detectFrameworkFromAST(SupportedLanguages.TypeScript, '')).toBeNull(); + expect(detectFrameworkFromAST('' as SupportedLanguages, 'some code')).toBeNull(); }); it('detects NestJS decorators in TypeScript', () => { - const result = detectFrameworkFromAST('typescript', '@Controller("/users")'); + const result = detectFrameworkFromAST(SupportedLanguages.TypeScript, '@Controller("/users")'); expect(result).not.toBeNull(); expect(result!.framework).toBe('nestjs'); expect(result!.entryPointMultiplier).toBe(3.2); }); it('detects NestJS decorators in JavaScript', () => { - const result = detectFrameworkFromAST('javascript', '@Get("/")'); + const result = detectFrameworkFromAST(SupportedLanguages.JavaScript, '@Get("/")'); expect(result).not.toBeNull(); expect(result!.framework).toBe('nestjs'); }); it('detects FastAPI decorators in Python', () => { - const result = detectFrameworkFromAST('python', '@app.get("/users")'); + const result = detectFrameworkFromAST(SupportedLanguages.Python, '@app.get("/users")'); expect(result).not.toBeNull(); expect(result!.framework).toBe('fastapi'); }); it('detects Flask decorators in Python', () => { - const result = detectFrameworkFromAST('python', '@app.route("/users")'); + const result = detectFrameworkFromAST(SupportedLanguages.Python, '@app.route("/users")'); expect(result).not.toBeNull(); expect(result!.framework).toBe('flask'); }); it('detects Spring annotations in Java', () => { - const result = detectFrameworkFromAST('java', '@RestController'); + const result = detectFrameworkFromAST(SupportedLanguages.Java, '@RestController'); expect(result).not.toBeNull(); expect(result!.framework).toBe('spring'); }); it('detects ASP.NET attributes in C#', () => { - const result = detectFrameworkFromAST('csharp', '[ApiController]'); + const result = detectFrameworkFromAST(SupportedLanguages.CSharp, '[ApiController]'); expect(result).not.toBeNull(); expect(result!.framework).toBe('aspnet'); }); it('detects Laravel route definitions in PHP', () => { const result = detectFrameworkFromAST( - 'php', + SupportedLanguages.PHP, "Route::get('/users', [UserController::class, 'index'])", ); expect(result).not.toBeNull(); @@ -378,27 +379,33 @@ describe('detectFrameworkFromAST', () => { }); it('detects Actix-web route attributes in Rust', () => { - const result = detectFrameworkFromAST('rust', '#[get("/")]'); + const result = detectFrameworkFromAST(SupportedLanguages.Rust, '#[get("/")]'); expect(result).not.toBeNull(); expect(result!.framework).toBe('actix-web'); }); it('returns null for language with no matching pattern', () => { - expect(detectFrameworkFromAST('c', 'int main() { return 0; }')).toBeNull(); + expect(detectFrameworkFromAST(SupportedLanguages.C, 'int main() { return 0; }')).toBeNull(); }); it('is case-insensitive', () => { - const result = detectFrameworkFromAST('TypeScript', '@controller("/")'); + const result = detectFrameworkFromAST('TypeScript' as SupportedLanguages, '@controller("/")'); expect(result).not.toBeNull(); }); it('detects Expo Router useRouter hook', () => { - const result = detectFrameworkFromAST('typescript', 'const router = useRouter()'); + const result = detectFrameworkFromAST( + SupportedLanguages.TypeScript, + 'const router = useRouter()', + ); expect(result).not.toBeNull(); expect(result!.framework).toBe('expo-router'); }); it('detects Expo Router router.push', () => { - const result = detectFrameworkFromAST('javascript', "router.push('/settings')"); + const result = detectFrameworkFromAST( + SupportedLanguages.JavaScript, + "router.push('/settings')", + ); expect(result).not.toBeNull(); expect(result!.framework).toBe('expo-router'); }); diff --git a/gitnexus/test/unit/git.test.ts b/gitnexus/test/unit/git.test.ts index 7ed94cfa8..b48fa9f89 100644 --- a/gitnexus/test/unit/git.test.ts +++ b/gitnexus/test/unit/git.test.ts @@ -196,6 +196,9 @@ describe('git utilities', () => { stdout: '', stderr: '', error: Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' }), + pid: 0, + output: ['', '', ''], + signal: null, } as ReturnType); expect(listLocalHeads('/missing-git')).toBeNull(); }); diff --git a/gitnexus/test/unit/graph-collapse-wiring.test.ts b/gitnexus/test/unit/graph-collapse-wiring.test.ts index 13c09605a..1e09c1968 100644 --- a/gitnexus/test/unit/graph-collapse-wiring.test.ts +++ b/gitnexus/test/unit/graph-collapse-wiring.test.ts @@ -34,7 +34,7 @@ const graphWith = ( ): Pick => ({ forEachRelationshipFields(fn) { for (const [type, count] of Object.entries(byType)) { - for (let i = 0; i < (count ?? 0); i++) fn('src', 'dst', type as RelationshipType, 1); + for (let i = 0; i < (count ?? 0); i++) fn('src', 'dst', type as RelationshipType, 1, ''); } }, }); diff --git a/gitnexus/test/unit/graph.test.ts b/gitnexus/test/unit/graph.test.ts index 47be5125d..1106b3e12 100644 --- a/gitnexus/test/unit/graph.test.ts +++ b/gitnexus/test/unit/graph.test.ts @@ -6,7 +6,7 @@ */ import { describe, it, expect } from 'vitest'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import type { GraphNode, GraphRelationship } from '../../src/core/graph/types.js'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; function makeNode(id: string, name: string, filePath: string = 'src/test.ts'): GraphNode { return { @@ -410,8 +410,9 @@ describe('createKnowledgeGraph', () => { const node: Parameters[0] = { id: 'cluster:x', label: 'Community', - properties: { name: 'x' }, + properties: { name: 'x', filePath: '' }, }; + Reflect.deleteProperty(node.properties, 'filePath'); g.addNode(node); g.addNode(makeNode('fn:a', 'a', 'src/a.ts')); diff --git a/gitnexus/test/unit/group/cross-impact.test.ts b/gitnexus/test/unit/group/cross-impact.test.ts index bea545017..66b329a3a 100644 --- a/gitnexus/test/unit/group/cross-impact.test.ts +++ b/gitnexus/test/unit/group/cross-impact.test.ts @@ -60,7 +60,7 @@ describe('cross-impact', () => { direction: 'sideways', }); expect(r.ok).toBe(false); - if (!r.ok) expect(r.error).toContain('direction'); + if (r.ok === false) expect(r.error).toContain('direction'); }); it('test_validateGroupImpactParams_clamps_crossDepth_and_warns', () => { @@ -110,7 +110,7 @@ describe('cross-impact', () => { direction: 'upstream', }); expect(r.ok).toBe(false); - if (!r.ok) expect(r.error).toBe('target or target_uid is required'); + if (r.ok === false) expect(r.error).toBe('target or target_uid is required'); }); it('test_collectImpactSymbolUids_respects_service_prefix', () => { diff --git a/gitnexus/test/unit/group/fastapi-composed-provider.test.ts b/gitnexus/test/unit/group/fastapi-composed-provider.test.ts index b0d342ebd..01dcb37fb 100644 --- a/gitnexus/test/unit/group/fastapi-composed-provider.test.ts +++ b/gitnexus/test/unit/group/fastapi-composed-provider.test.ts @@ -34,6 +34,7 @@ function run(files: Record): RunResult { }; const readFile = (rel: string): string | null => files[rel] ?? null; const ctx = PYTHON_HTTP_PLUGIN.prepareRepo?.({ + repoPath: '/repo', files: Object.keys(files), parser, readFile, diff --git a/gitnexus/test/unit/group/grpc-extractor.test.ts b/gitnexus/test/unit/group/grpc-extractor.test.ts index bf1a98033..0c244b3f2 100644 --- a/gitnexus/test/unit/group/grpc-extractor.test.ts +++ b/gitnexus/test/unit/group/grpc-extractor.test.ts @@ -1391,6 +1391,7 @@ service Foo { rpc Bar (Req) returns (Res); }`; describe('resolveProtoConflict', () => { const makeInfo = (pkg: string, protoPath: string): ProtoServiceInfo => ({ package: pkg, + javaPackage: '', serviceName: 'Svc', methods: ['Do'], protoPath, diff --git a/gitnexus/test/unit/group/java-const-route-parity.test.ts b/gitnexus/test/unit/group/java-const-route-parity.test.ts index 62c028e8e..256959c3b 100644 --- a/gitnexus/test/unit/group/java-const-route-parity.test.ts +++ b/gitnexus/test/unit/group/java-const-route-parity.test.ts @@ -29,7 +29,6 @@ import { expandJavaWildcardStaticImports, extractJavaModuleConstants, foldJavaOperands, - type RepoConstants, } from '../../../src/core/ingestion/route-extractors/java-const-resolver.js'; const parser = new Parser(); @@ -42,6 +41,7 @@ const parse = (src: string): Parser.Tree => parseSource(parser, src); /** Group side: prepareRepo + a 3-argument scan over every .java file. */ function groupProviders(files: Record): string[] { const ctx = JAVA_HTTP_PLUGIN.prepareRepo?.({ + repoPath: '/repo', files: Object.keys(files), parser: new Parser(), readFile: (rel: string) => files[rel] ?? null, @@ -59,7 +59,7 @@ function groupProviders(files: Record): string[] { /** Ingestion side: extract routes, then fold operands against the same map. */ function ingestionRoutes(files: Record): string[] { - const repo: RepoConstants = new Map(); + const repo = new Map>(); for (const [rel, src] of Object.entries(files)) { repo.set(rel, extractJavaModuleConstants(parse(src))); } diff --git a/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts b/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts index e38e05079..c486a4ed3 100644 --- a/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts +++ b/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts @@ -61,7 +61,7 @@ const describeKotlin = Kotlin && KOTLIN_HTTP_PLUGIN ? describe : describe.skip; const plugin = KOTLIN_HTTP_PLUGIN as HttpLanguagePlugin; const parseSource = (p: Parser, src: string): Parser.Tree => { - p.setLanguage(Kotlin as Parser.Language); + p.setLanguage(Kotlin); return p.parse(src); }; diff --git a/gitnexus/test/unit/group/service.test.ts b/gitnexus/test/unit/group/service.test.ts index 770e6889f..fde1be468 100644 --- a/gitnexus/test/unit/group/service.test.ts +++ b/gitnexus/test/unit/group/service.test.ts @@ -303,7 +303,7 @@ describe('GroupService', () => { const { cleanup, tmpDir } = makeTmpGroup(); try { vi.stubEnv('GITNEXUS_HOME', tmpDir); - const query = vi.fn(async () => ({ processes: [] })); + const query = vi.fn(async () => ({ processes: [] })); const svc = new GroupService(makePort({ query })); await svc.groupQuery({ name: 'test-group', query: 'auth flow' }); expect(query).toHaveBeenCalled(); @@ -320,7 +320,7 @@ describe('GroupService', () => { const { cleanup, tmpDir } = makeTmpGroup(); try { vi.stubEnv('GITNEXUS_HOME', tmpDir); - const query = vi.fn(async () => ({ processes: [] })); + const query = vi.fn(async () => ({ processes: [] })); const svc = new GroupService(makePort({ query })); await svc.groupQuery({ name: 'test-group', @@ -342,7 +342,7 @@ describe('GroupService', () => { const { cleanup, tmpDir } = makeTmpGroup(); try { vi.stubEnv('GITNEXUS_HOME', tmpDir); - const query = vi.fn(async () => ({ processes: [] })); + const query = vi.fn(async () => ({ processes: [] })); const svc = new GroupService(makePort({ query })); const infiniteLimit = await svc.groupQuery({ @@ -403,7 +403,7 @@ describe('GroupService', () => { const { cleanup, tmpDir } = makeTmpGroup(); try { vi.stubEnv('GITNEXUS_HOME', tmpDir); - const query = vi.fn(async () => ({ processes: [] })); + const query = vi.fn(async () => ({ processes: [] })); const svc = new GroupService(makePort({ query })); await svc.groupQuery({ name: 'test-group', query: 'auth flow', chain_depth: 2 }); expect(query).toHaveBeenCalled(); @@ -575,7 +575,7 @@ repos: const { cleanup, tmpDir } = makeTmpGroup(); try { vi.stubEnv('GITNEXUS_HOME', tmpDir); - const context = vi.fn(async () => ({ status: 'found' })); + const context = vi.fn(async () => ({ status: 'found' })); const svc = new GroupService(makePort({ context })); const r = await svc.groupContext({ name: 'test-group', @@ -594,7 +594,7 @@ repos: const { cleanup, tmpDir } = makeTmpGroup(); try { vi.stubEnv('GITNEXUS_HOME', tmpDir); - const context = vi.fn(async () => ({ + const context = vi.fn(async () => ({ status: 'found', symbol: { filePath: 'services/auth/x.ts', uid: 'u1', name: 'X' }, })); diff --git a/gitnexus/test/unit/group/sync-partial-extraction.test.ts b/gitnexus/test/unit/group/sync-partial-extraction.test.ts index e1588be1c..d676cdcbe 100644 --- a/gitnexus/test/unit/group/sync-partial-extraction.test.ts +++ b/gitnexus/test/unit/group/sync-partial-extraction.test.ts @@ -54,10 +54,12 @@ const grpcExtract = vi.fn(); // Bound through an arrow so the test body can read its calls: which repos the // deferred manifest phase re-opens is the observable side of dropping a failed // repo's handle, and a `vi.fn()` created inside the factory is unreachable here. -const initLbugMock = vi.fn(async () => {}); +const initLbugMock = vi.fn( + async () => true, +); vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({ - initLbug: (...args: unknown[]) => initLbugMock(...args), + initLbug: initLbugMock, executeParameterized: vi.fn(async () => []), pinRepo: vi.fn(() => () => {}), getMaxResidentRepos: vi.fn(() => 5), diff --git a/gitnexus/test/unit/group/sync-registry-identity.test.ts b/gitnexus/test/unit/group/sync-registry-identity.test.ts index fb63a1386..f718ad515 100644 --- a/gitnexus/test/unit/group/sync-registry-identity.test.ts +++ b/gitnexus/test/unit/group/sync-registry-identity.test.ts @@ -9,10 +9,12 @@ import type { GroupConfig } from '../../../src/core/group/types.js'; import { GroupService } from '../../../src/core/group/service.js'; import type { GroupToolPort } from '../../../src/core/group/service.js'; -const initLbugMock = vi.fn(async () => {}); +const initLbugMock = vi.fn( + async () => true, +); vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({ - initLbug: (...args: unknown[]) => initLbugMock(...args), + initLbug: (...args: Parameters) => initLbugMock(...args), executeParameterized: vi.fn(async () => []), pinRepo: vi.fn(() => () => {}), getMaxResidentRepos: vi.fn(() => 5), diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index 9e844fced..878aa0581 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -1438,6 +1438,7 @@ describe('syncGroup windowed manifest resolution (issue #2189 / PR #2191 review) const n = (initCounts.get(id) ?? 0) + 1; initCounts.set(id, n); if (id === 'app-repo-2' && n === 2) throw new Error('window init boom'); + return true; }); const execSpy = vi.spyOn(poolAdapter, 'executeParameterized').mockResolvedValue([]); const pinSpy = vi.spyOn(poolAdapter, 'pinRepo').mockImplementation(() => { diff --git a/gitnexus/test/unit/has-method.test.ts b/gitnexus/test/unit/has-method.test.ts index 5c17cc730..7c3484b04 100644 --- a/gitnexus/test/unit/has-method.test.ts +++ b/gitnexus/test/unit/has-method.test.ts @@ -509,24 +509,17 @@ class Outer { }); it('returns null for a node without parent', () => { - // Simulate a node with null parent - const fakeNode = { parent: null }; - const result = findEnclosingClassId(fakeNode, filePath); + const root = parseCode(TypeScript.typescript, 'const value = 1;').rootNode; + expect(root.parent).toBeNull(); + const result = findEnclosingClassId(root, filePath); expect(result).toBeNull(); }); it('skips containers without a name node', () => { - // Simulate AST nodes where the class container has no name - const fakeClassNode = { - type: 'class_declaration', - childForFieldName: () => null, - children: [], - parent: null, - }; - const fakeChild = { - parent: fakeClassNode, - }; - const result = findEnclosingClassId(fakeChild, filePath); + const tree = parseCode(TypeScript.typescript, 'const Anonymous = class { method() {} };'); + const method = findNode(tree.rootNode, (node) => node.type === 'method_definition'); + expect(method).not.toBeNull(); + const result = findEnclosingClassId(method!, filePath); // The class has no name, so should return null expect(result).toBeNull(); }); diff --git a/gitnexus/test/unit/hook-db-lock-probe.test.ts b/gitnexus/test/unit/hook-db-lock-probe.test.ts index c31c214c1..91d035eb0 100644 --- a/gitnexus/test/unit/hook-db-lock-probe.test.ts +++ b/gitnexus/test/unit/hook-db-lock-probe.test.ts @@ -738,7 +738,7 @@ describe.skipIf(!isLinux)('Linux cmdline-first DB-owner scan (#2180)', () => { err.code = code; throw err; } - return (realReaddir as (...a: unknown[]) => unknown)(p, ...rest); + return realReaddir(p, ...rest); }); cleanups.push(() => spy.mockRestore()); // White-box: assert the exported tri-state verdict directly (the diff --git a/gitnexus/test/unit/impact-batching-grouping.test.ts b/gitnexus/test/unit/impact-batching-grouping.test.ts index 7f4d18bd8..d56aee1f5 100644 --- a/gitnexus/test/unit/impact-batching-grouping.test.ts +++ b/gitnexus/test/unit/impact-batching-grouping.test.ts @@ -9,7 +9,7 @@ const executeParameterizedMock = vi.fn(); // imports) and the re-export shim (mcp/core/lbug-adapter.js) so the mocks intercept // regardless of import path. vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, initLbug: vi.fn(), @@ -20,7 +20,7 @@ vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, initLbug: vi.fn(), @@ -31,7 +31,7 @@ vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { }; }); -import { LocalBackend } from '../../src/mcp/local/local-backend'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; describe('impact: batching and grouping', () => { beforeEach(() => { diff --git a/gitnexus/test/unit/impact-pagination.test.ts b/gitnexus/test/unit/impact-pagination.test.ts index 0a710a71e..2aecb445a 100644 --- a/gitnexus/test/unit/impact-pagination.test.ts +++ b/gitnexus/test/unit/impact-pagination.test.ts @@ -4,7 +4,7 @@ const executeQueryMock = vi.fn(); const executeParameterizedMock = vi.fn(); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, initLbug: vi.fn(), @@ -15,7 +15,7 @@ vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, initLbug: vi.fn(), @@ -26,8 +26,8 @@ vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { }; }); -import { LocalBackend } from '../../src/mcp/local/local-backend'; -import { collectImpactSymbolUids } from '../../src/core/group/cross-impact'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { collectImpactSymbolUids } from '../../src/core/group/cross-impact.js'; function makeBackend() { const backend = new LocalBackend(); diff --git a/gitnexus/test/unit/impact-pdg-id-bridge-gate.test.ts b/gitnexus/test/unit/impact-pdg-id-bridge-gate.test.ts index 6c08ec43d..0a11e244b 100644 --- a/gitnexus/test/unit/impact-pdg-id-bridge-gate.test.ts +++ b/gitnexus/test/unit/impact-pdg-id-bridge-gate.test.ts @@ -11,7 +11,6 @@ // `npm test`. import { describe, expect, it } from 'vitest'; -// @ts-expect-error — .mjs pure-JS harness module, no types (intentional; build-free). import * as M from '../../bench/impact-pdg/measure.mjs'; interface ReachedItem { diff --git a/gitnexus/test/unit/impact-pdg-id-vs-name-metrics.test.ts b/gitnexus/test/unit/impact-pdg-id-vs-name-metrics.test.ts index 757ee8668..990ade365 100644 --- a/gitnexus/test/unit/impact-pdg-id-vs-name-metrics.test.ts +++ b/gitnexus/test/unit/impact-pdg-id-vs-name-metrics.test.ts @@ -9,7 +9,6 @@ // `node --import tsx bench/impact-pdg/name-collision.mjs`, never in `npm test`. import { describe, expect, it } from 'vitest'; -// @ts-expect-error — .mjs pure-JS module, no types; intentional (build-free harness). import * as M from '../../bench/impact-pdg/name-collision.mjs'; interface ReachedItem { diff --git a/gitnexus/test/unit/impact-pdg-metric-math.test.ts b/gitnexus/test/unit/impact-pdg-metric-math.test.ts index e52051254..43007787b 100644 --- a/gitnexus/test/unit/impact-pdg-metric-math.test.ts +++ b/gitnexus/test/unit/impact-pdg-metric-math.test.ts @@ -9,7 +9,6 @@ // `measure.mjs`, never in `npm test`. import { describe, it, expect } from 'vitest'; -// @ts-expect-error — .mjs pure-JS module, no types; intentional (build-free harness). import * as M from '../../bench/impact-pdg/metrics.mjs'; const k = (sym: string, file = 'src/a.ts') => M.symbolKey(sym, file); diff --git a/gitnexus/test/unit/impact-route-enrichment.test.ts b/gitnexus/test/unit/impact-route-enrichment.test.ts index d2b53031c..a2ffdb901 100644 --- a/gitnexus/test/unit/impact-route-enrichment.test.ts +++ b/gitnexus/test/unit/impact-route-enrichment.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const executeParameterizedMock = vi.fn(); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...(actual as object), initLbug: vi.fn(), @@ -22,7 +22,7 @@ vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...(actual as object), initLbug: vi.fn(), @@ -33,7 +33,7 @@ vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { }; }); -import { LocalBackend } from '../../src/mcp/local/local-backend'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; const REPO = { id: 'repo', diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index fb2916ab4..e37895fd9 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -26,10 +26,8 @@ const minimalResult = (overrides: Partial = {}): ParseWorkerR nodes: [], relationships: [], symbols: [], - imports: [], calls: [], assignments: [], - heritage: [], routes: [], fetchCalls: [], fetchWrapperDefs: [], @@ -897,15 +895,12 @@ describe('loadParseCache / saveParseCache (round-trip)', () => { ['k2', 'v2'], ]); const innerSet = new Set(['s1', 's2']); - const fake = minimalResult({ - fileCount: 9, - imports: [ - { - typeBindings: innerMap, - extras: innerSet, - } as unknown as ParseWorkerResult['imports'][number], - ], - }); + // Exercise opaque structured-clone data without relying on a removed + // worker field (ParsedFiles intentionally live in a separate store). + const fake = { + ...minimalResult({ fileCount: 9 }), + serializationProbe: { typeBindings: innerMap, extras: innerSet }, + }; const key = 'f'.repeat(64); const cache: ParseCache = { version: PARSE_CACHE_VERSION, @@ -920,17 +915,29 @@ describe('loadParseCache / saveParseCache (round-trip)', () => { expect(names.some((n) => n.endsWith('.json') && n !== 'index.json')).toBe(false); const loaded = await loadParseCacheChunk(cache, key); expect(loaded?.[0]?.fileCount).toBe(9); - const smuggled = loaded?.[0]?.imports[0] as unknown as { - typeBindings?: unknown; - extras?: unknown; - }; + const restored = loaded?.[0]; + if (!restored || !('serializationProbe' in restored)) { + throw new Error('Missing serialized probe'); + } + const smuggled = restored.serializationProbe; + if ( + typeof smuggled !== 'object' || + smuggled === null || + !('typeBindings' in smuggled) || + !('extras' in smuggled) + ) { + throw new Error('Invalid serialized probe'); + } expect(smuggled.typeBindings).toBeInstanceOf(Map); - expect([...(smuggled.typeBindings as Map)]).toEqual([ + expect(smuggled.extras).toBeInstanceOf(Set); + if (!(smuggled.typeBindings instanceof Map) || !(smuggled.extras instanceof Set)) { + throw new Error('Structured-clone collections were not preserved'); + } + expect([...smuggled.typeBindings]).toEqual([ ['k1', 'v1'], ['k2', 'v2'], ]); - expect(smuggled.extras).toBeInstanceOf(Set); - expect([...(smuggled.extras as Set)].sort()).toEqual(['s1', 's2']); + expect([...smuggled.extras].sort()).toEqual(['s1', 's2']); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/gitnexus/test/unit/incremental-write-integrity.test.ts b/gitnexus/test/unit/incremental-write-integrity.test.ts index 14ac9fca9..bd201851a 100644 --- a/gitnexus/test/unit/incremental-write-integrity.test.ts +++ b/gitnexus/test/unit/incremental-write-integrity.test.ts @@ -105,7 +105,11 @@ describe('incremental graph identity before publication', () => { label: 'Tool', properties: { name: '王', filePath: 'src/Å.ts' }, }); - graph.addNode({ id: 'Destination:topic', label: 'Destination', properties: { name: 'topic' } }); + graph.addNode({ + id: 'Destination:topic', + label: 'Destination', + properties: { name: 'topic', filePath: '' }, + }); graph.addNode({ id: 'Function:src/Å.ts:王\u0000\uD800', label: 'Function', @@ -244,7 +248,7 @@ describe('incremental graph identity before publication', () => { const recovered = await runFullAnalysis(repo.dbPath, options, callbacks); expect(recovered.incrementalStats).toBeUndefined(); expect((await loadMeta(storagePath))?.incrementalInProgress).toBeUndefined(); - await adapter.initLbug(lbugPath, { readOnly: true }); + await adapter.initLbug(lbugPath); await expect( reconcileGraphNodeIdentities( recovered.pipelineResult.graph, diff --git a/gitnexus/test/unit/index-repo-command.test.ts b/gitnexus/test/unit/index-repo-command.test.ts index 6fcb6a7ca..88f129927 100644 --- a/gitnexus/test/unit/index-repo-command.test.ts +++ b/gitnexus/test/unit/index-repo-command.test.ts @@ -71,7 +71,7 @@ describe('indexCommand', () => { const storageFailure = ( state: 'empty' | 'owned' | 'unowned', hasCodeIndexDB: boolean, - ): StorageRequirementError => + ): InstanceType => new StorageRequirementError( { repoPath: resolvedRepo, diff --git a/gitnexus/test/unit/ipynb-python-captures.test.ts b/gitnexus/test/unit/ipynb-python-captures.test.ts index e6074a73c..1b886c1a7 100644 --- a/gitnexus/test/unit/ipynb-python-captures.test.ts +++ b/gitnexus/test/unit/ipynb-python-captures.test.ts @@ -45,8 +45,6 @@ describe('Python notebook scope captures', () => { expect(fnCapture?.['@scope.function']?.range.startLine).toBe(expectedJson + 1); const parsed = extractParsedFile(pythonProvider, notebook, 'analysis.ipynb'); expect(parsed).toBeDefined(); - expect(parsed!.localDefs.some((d) => d.qualifiedName === 'train' || d.name === 'train')).toBe( - true, - ); + expect(parsed!.localDefs.some((d) => d.qualifiedName === 'train')).toBe(true); }); }); diff --git a/gitnexus/test/unit/java-runtime-symbol-strategy.test.ts b/gitnexus/test/unit/java-runtime-symbol-strategy.test.ts index bfdc6f7da..d0932decf 100644 --- a/gitnexus/test/unit/java-runtime-symbol-strategy.test.ts +++ b/gitnexus/test/unit/java-runtime-symbol-strategy.test.ts @@ -3,7 +3,7 @@ import type { GraphNode } from 'gitnexus-shared'; import { javaProvider } from '../../src/core/ingestion/languages/java.js'; import { javaRuntimeSymbolStrategy } from '../../src/core/ingestion/languages/java/spring-actuator.js'; -function method(name: string, properties: GraphNode['properties'] = {}): GraphNode { +function method(name: string, properties: Partial = {}): GraphNode { return { id: `method:${name}:${JSON.stringify(properties.parameterTypes)}`, label: 'Method', diff --git a/gitnexus/test/unit/jvm-package-siblings.test.ts b/gitnexus/test/unit/jvm-package-siblings.test.ts index efa912408..bdcede07b 100644 --- a/gitnexus/test/unit/jvm-package-siblings.test.ts +++ b/gitnexus/test/unit/jvm-package-siblings.test.ts @@ -1,4 +1,5 @@ -import type { ParsedFile, ScopeResolutionIndexes, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../src/core/ingestion/model/scope-resolution-indexes.js'; +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; import { describe, expect, it } from 'vitest'; import { createJvmPackageSiblingVisibility } from '../../src/core/ingestion/languages/jvm/package-siblings.js'; import { collectJavaCaptureSideChannel } from '../../src/core/ingestion/languages/java/capture-side-channel.js'; diff --git a/gitnexus/test/unit/kotlin-route-const-resolver.test.ts b/gitnexus/test/unit/kotlin-route-const-resolver.test.ts index 1c2cd8b48..c92bddde0 100644 --- a/gitnexus/test/unit/kotlin-route-const-resolver.test.ts +++ b/gitnexus/test/unit/kotlin-route-const-resolver.test.ts @@ -51,7 +51,7 @@ try { } const parser = new Parser(); -if (Kotlin) parser.setLanguage(Kotlin as Parser.Language); +if (Kotlin) parser.setLanguage(Kotlin); const parse = (src: string): Parser.Tree => parser.parse(src); diff --git a/gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts b/gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts index 51c2225dd..7c98c11d6 100644 --- a/gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts +++ b/gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts @@ -24,7 +24,7 @@ try { } const parser = new Parser(); -if (Kotlin) parser.setLanguage(Kotlin as Parser.Language); +if (Kotlin) parser.setLanguage(Kotlin); const parse = (source: string): Parser.Tree => parser.parse(source); const describeKotlin = Kotlin ? describe : describe.skip; diff --git a/gitnexus/test/unit/lazy-action.test.ts b/gitnexus/test/unit/lazy-action.test.ts index 973d728ca..fcc6ce3ae 100644 --- a/gitnexus/test/unit/lazy-action.test.ts +++ b/gitnexus/test/unit/lazy-action.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from 'vitest'; import { createAnalyzerLbugLazyAction, createLazyAction } from '../../src/cli/lazy-action.js'; const { checkLbugNativeMock } = vi.hoisted(() => ({ - checkLbugNativeMock: vi.fn(() => ({ ok: true })), + checkLbugNativeMock: vi.fn( + () => ({ ok: true }), + ), })); vi.mock('../../src/core/lbug/native-check.js', () => ({ diff --git a/gitnexus/test/unit/lbug-pool-evict-reopen-race.test.ts b/gitnexus/test/unit/lbug-pool-evict-reopen-race.test.ts index 64cd767d5..346d6d742 100644 --- a/gitnexus/test/unit/lbug-pool-evict-reopen-race.test.ts +++ b/gitnexus/test/unit/lbug-pool-evict-reopen-race.test.ts @@ -122,6 +122,8 @@ describe('pool-adapter evict-then-reopen race (fire-and-forget close fix)', () = vi.mocked(createLbugDatabase).mockImplementationOnce(() => ({ init: vi.fn().mockResolvedValue(undefined), + initSync: vi.fn(), + closeSync: vi.fn(), close: vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); order.push('repo1-close-end'); @@ -155,6 +157,8 @@ describe('pool-adapter evict-then-reopen race (fire-and-forget close fix)', () = vi.mocked(createLbugDatabase).mockImplementationOnce(() => ({ init: vi.fn().mockResolvedValue(undefined), + initSync: vi.fn(), + closeSync: vi.fn(), close: vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); order.push('close-end'); diff --git a/gitnexus/test/unit/lbug-pool-pinning.test.ts b/gitnexus/test/unit/lbug-pool-pinning.test.ts index d6fb1dbf8..8276b136a 100644 --- a/gitnexus/test/unit/lbug-pool-pinning.test.ts +++ b/gitnexus/test/unit/lbug-pool-pinning.test.ts @@ -264,6 +264,8 @@ describe('pool-adapter repo pinning (issue #2189)', () => { it('a pin acquired while closeOne awaits db.close() does not survive teardown', async () => { vi.mocked(createLbugDatabase).mockImplementationOnce(() => ({ init: vi.fn().mockResolvedValue(undefined), + initSync: vi.fn(), + closeSync: vi.fn(), close: vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }), @@ -284,6 +286,8 @@ describe('pool-adapter repo pinning (issue #2189)', () => { it('initLbugWithDb waits for an in-flight closeOne before registering', async () => { vi.mocked(createLbugDatabase).mockImplementationOnce(() => ({ init: vi.fn().mockResolvedValue(undefined), + initSync: vi.fn(), + closeSync: vi.fn(), close: vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }), @@ -294,6 +298,8 @@ describe('pool-adapter repo pinning (issue #2189)', () => { await Promise.resolve(); const injected = { init: vi.fn().mockResolvedValue(undefined), + initSync: vi.fn(), + closeSync: vi.fn(), close: vi.fn().mockResolvedValue(undefined), }; const injecting = initLbugWithDb( diff --git a/gitnexus/test/unit/local-cli-subprocess.test.ts b/gitnexus/test/unit/local-cli-subprocess.test.ts index 9e46f75f4..2910334fe 100644 --- a/gitnexus/test/unit/local-cli-subprocess.test.ts +++ b/gitnexus/test/unit/local-cli-subprocess.test.ts @@ -56,7 +56,7 @@ describe('Claude CLI subprocess contract', () => { beforeEach(() => { vi.resetModules(); fakeChild = makeFakeChild({ stdout: 'Claude response text' }); - spawnSpy = vi.fn(() => fakeChild.child); + spawnSpy = vi.fn(() => fakeChild.child); }); afterEach(() => { @@ -147,7 +147,7 @@ describe('Claude CLI subprocess contract', () => { it('rejects with exit code and stderr on non-zero exit', async () => { fakeChild = makeFakeChild({ exitCode: 1, stderr: 'auth required' }); - spawnSpy = vi.fn(() => fakeChild.child); + spawnSpy = vi.fn(() => fakeChild.child); vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('claude 1.0.0'), @@ -162,7 +162,7 @@ describe('Claude CLI subprocess contract', () => { it('rejects with actionable error on empty stdout', async () => { fakeChild = makeFakeChild({ stdout: '' }); - spawnSpy = vi.fn(() => fakeChild.child); + spawnSpy = vi.fn(() => fakeChild.child); vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('claude 1.0.0'), @@ -183,7 +183,7 @@ describe('Codex CLI subprocess contract', () => { beforeEach(() => { vi.resetModules(); fakeChild = makeFakeChild({ stdout: 'codex response' }); - spawnSpy = vi.fn(() => fakeChild.child); + spawnSpy = vi.fn(() => fakeChild.child); }); afterEach(() => { @@ -435,7 +435,7 @@ describe('Codex output file fallback', () => { vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), - spawn: vi.fn(() => fakeChild.child), + spawn: vi.fn(() => fakeChild.child), })); const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); @@ -448,7 +448,7 @@ describe('Codex output file fallback', () => { vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), - spawn: vi.fn(() => fakeChild.child), + spawn: vi.fn(() => fakeChild.child), })); const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); @@ -563,7 +563,7 @@ describe('Codex CLI flag contract snapshot', () => { it('spawn args match the exact expected contract (flag rename = test failure)', async () => { const fakeChild = makeFakeChild({ stdout: 'codex output' }); - const spawnSpy = vi.fn(() => fakeChild.child); + const spawnSpy = vi.fn(() => fakeChild.child); vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), @@ -608,7 +608,7 @@ describe('Codex CLI flag contract snapshot', () => { it('--model appears before - (stdin marker) and after --output-last-message', async () => { const fakeChild = makeFakeChild({ stdout: 'codex output' }); - const spawnSpy = vi.fn(() => fakeChild.child); + const spawnSpy = vi.fn(() => fakeChild.child); vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), @@ -1070,13 +1070,14 @@ describe('Grok CLI timeout', () => { } async function loadGrokWithChild(child: any, execFileSyncImpl?: (...args: any[]) => unknown) { - const spawnSpy = vi.fn(() => child); - const execFileSync = + const spawnSpy = vi.fn(() => child); + const execFileSync = vi.fn( execFileSyncImpl ?? - vi.fn().mockImplementation((cmd: string) => { - if (cmd === 'taskkill') return ''; - return 'grok 1.0.5'; - }); + ((cmd: string) => { + if (cmd === 'taskkill') return ''; + return 'grok 1.0.5'; + }), + ); vi.doMock('../../src/core/logger.js', () => ({ logger: { info: vi.fn(), warn: vi.fn() }, })); diff --git a/gitnexus/test/unit/mcp-context-route-chain.test.ts b/gitnexus/test/unit/mcp-context-route-chain.test.ts index 7cd84ae4d..e2ff0440d 100644 --- a/gitnexus/test/unit/mcp-context-route-chain.test.ts +++ b/gitnexus/test/unit/mcp-context-route-chain.test.ts @@ -18,12 +18,12 @@ const { lbugMocks } = vi.hoisted(() => ({ })); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); diff --git a/gitnexus/test/unit/mcp-read-file-grep.test.ts b/gitnexus/test/unit/mcp-read-file-grep.test.ts index 3f9186011..48f80c52a 100644 --- a/gitnexus/test/unit/mcp-read-file-grep.test.ts +++ b/gitnexus/test/unit/mcp-read-file-grep.test.ts @@ -23,12 +23,12 @@ const { lbugMocks } = vi.hoisted(() => ({ })); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); diff --git a/gitnexus/test/unit/mcp-wal-feedback.test.ts b/gitnexus/test/unit/mcp-wal-feedback.test.ts index 65515a987..4de7c4f75 100644 --- a/gitnexus/test/unit/mcp-wal-feedback.test.ts +++ b/gitnexus/test/unit/mcp-wal-feedback.test.ts @@ -17,12 +17,12 @@ const { lbugMocks, repoMocks } = vi.hoisted(() => ({ })); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); diff --git a/gitnexus/test/unit/method-extraction.test.ts b/gitnexus/test/unit/method-extraction.test.ts index f792cfdce..9945727bd 100644 --- a/gitnexus/test/unit/method-extraction.test.ts +++ b/gitnexus/test/unit/method-extraction.test.ts @@ -49,7 +49,7 @@ try { Dart = requireVendoredGrammar('tree-sitter-dart'); // Verify the grammar actually works with the installed tree-sitter version const testParser = new Parser(); - testParser.setLanguage(Dart as Parser.Language); + testParser.setLanguage(Dart); } catch { Dart = null; } @@ -59,7 +59,7 @@ try { Swift = requireVendoredGrammar('tree-sitter-swift'); // Verify the grammar actually works with the installed tree-sitter version const testParser = new Parser(); - testParser.setLanguage(Swift as Parser.Language); + testParser.setLanguage(Swift); } catch { Swift = null; } @@ -73,7 +73,7 @@ const parseJava = (code: string) => { const parseKotlin = (code: string) => { if (!Kotlin) throw new Error('tree-sitter-kotlin not available'); - parser.setLanguage(Kotlin as Parser.Language); + parser.setLanguage(Kotlin); return parser.parse(code); }; @@ -3783,7 +3783,7 @@ end const parseDart = (code: string) => { if (!Dart) throw new Error('tree-sitter-dart not available'); - parser.setLanguage(Dart as Parser.Language); + parser.setLanguage(Dart); return parser.parse(code); }; @@ -4043,7 +4043,7 @@ extension StringExt on String { // --------------------------------------------------------------------------- const parsePHP = (code: string) => { - parser.setLanguage(PHP.php_only as Parser.Language); + parser.setLanguage(PHP.php_only); return parser.parse(code); }; @@ -4683,7 +4683,7 @@ describe('Rust MethodExtractor', () => { const parseSwift = (code: string) => { if (!Swift) throw new Error('tree-sitter-swift not available'); - parser.setLanguage(Swift as Parser.Language); + parser.setLanguage(Swift); return parser.parse(code); }; diff --git a/gitnexus/test/unit/model/owned-members-lookup.test.ts b/gitnexus/test/unit/model/owned-members-lookup.test.ts index 35ee5d56a..207df6f23 100644 --- a/gitnexus/test/unit/model/owned-members-lookup.test.ts +++ b/gitnexus/test/unit/model/owned-members-lookup.test.ts @@ -224,7 +224,7 @@ describe('Step 2 perf contract', () => { const callScope: Scope = { id: 'scope:method-body', parent: 'scope:module', - kind: 'Method', + kind: 'Function', range: { startLine: 2, startCol: 0, endLine: 99, endCol: 0 }, filePath: 'x.ts', bindings: new Map(), @@ -240,7 +240,9 @@ describe('Step 2 perf contract', () => { scopes: buildScopeTree([moduleScope, callScope]), defs, qualifiedNames: buildQualifiedNameIndex([userClass, saveMethod]), - moduleScopes: buildModuleScopeIndex([moduleScope]), + moduleScopes: buildModuleScopeIndex([ + { filePath: moduleScope.filePath, moduleScopeId: moduleScope.id }, + ]), methodDispatch: buildMethodDispatchIndex({ owners: ['def:User'], computeMro: () => [], diff --git a/gitnexus/test/unit/name-guessed-edges-excluded-from-flows.test.ts b/gitnexus/test/unit/name-guessed-edges-excluded-from-flows.test.ts index d879c0ec9..4bcff43bc 100644 --- a/gitnexus/test/unit/name-guessed-edges-excluded-from-flows.test.ts +++ b/gitnexus/test/unit/name-guessed-edges-excluded-from-flows.test.ts @@ -82,7 +82,9 @@ describe('process tracing excludes name-guessed CALLS edges', () => { const result = await processProcesses(graph, []); expect(result.processes.length).toBeGreaterThan(0); - const tracedNames = new Set(result.steps.map((step) => step.toName)); + const tracedNames = new Set( + result.steps.map((step) => graph.getNode(step.nodeId)?.properties.name), + ); expect(tracedNames.has('unrelatedHelper')).toBe(false); }); }); diff --git a/gitnexus/test/unit/objective-c-provider.test.ts b/gitnexus/test/unit/objective-c-provider.test.ts index c191be2e7..5d65dab27 100644 --- a/gitnexus/test/unit/objective-c-provider.test.ts +++ b/gitnexus/test/unit/objective-c-provider.test.ts @@ -1,3 +1,5 @@ +import type { ObjCFileFacts } from '../../src/core/ingestion/languages/objective-c/facts.js'; +import { finalizeScopeModel } from '../../src/core/ingestion/finalize-orchestrator.js'; import { describe, expect, it } from 'vitest'; import path from 'path'; import Parser from 'tree-sitter'; @@ -504,27 +506,41 @@ static int helper(void) { return 1; } graph.addNode({ id: container.nodeId, label: container.label, - properties: { filePath: facts.filePath, qualifiedName: container.qualifiedName }, + properties: { + name: container.name, + filePath: facts.filePath, + qualifiedName: container.qualifiedName, + }, }); } for (const method of facts.methods) { graph.addNode({ id: method.nodeId, label: 'Method', - properties: { filePath: facts.filePath, qualifiedName: method.qualifiedName }, + properties: { + name: method.name, + filePath: facts.filePath, + qualifiedName: method.qualifiedName, + }, }); } - objectiveCScopeResolver.emitPostResolutionEdges?.(graph, [ - { - filePath: facts.filePath, - moduleScope: 0, - scopes: [], - parsedImports: [], - localDefs: [], - referenceSites: [], - captureSideChannel: { kind: 'objective-c', facts }, - }, - ]); + objectiveCScopeResolver.emitPostResolutionEdges?.( + graph, + [ + { + filePath: facts.filePath, + moduleScope: `scope:${facts.filePath}#module`, + scopes: [], + parsedImports: [], + localDefs: [], + referenceSites: [], + captureSideChannel: { kind: 'objective-c', facts }, + }, + ], + new Map(), + finalizeScopeModel([]), + { fileContents: new Map() }, + ); const go = facts.methods.find( (method) => method.selector === 'go' && method.declarationRole === 'implementation', @@ -779,7 +795,7 @@ static int helper(void) { return 1; } protocols: [] as string[], }; const protocolRunQn = objcMethodQualifiedName(protocolQn, '-', 'run'); - const factsList = [ + const factsList: ObjCFileFacts[] = [ { providerVersion: '0', grammarPackage: 'tree-sitter-objc', @@ -893,7 +909,7 @@ static int helper(void) { return 1; } const graph = createKnowledgeGraph(); const parsedFiles = factsList.map((facts) => ({ filePath: facts.filePath, - moduleScope: 0, + moduleScope: `scope:${facts.filePath}#module`, scopes: [], parsedImports: [], localDefs: [], @@ -904,25 +920,43 @@ static int helper(void) { return 1; } graph.addNode({ id: `File:${facts.filePath}`, label: 'File', - properties: { filePath: facts.filePath, qualifiedName: facts.filePath }, + properties: { + name: facts.filePath, + filePath: facts.filePath, + qualifiedName: facts.filePath, + }, }); for (const container of facts.containers) { graph.addNode({ id: container.nodeId, label: container.label, - properties: { filePath: facts.filePath, qualifiedName: container.qualifiedName }, + properties: { + name: container.name, + filePath: facts.filePath, + qualifiedName: container.qualifiedName, + }, }); } for (const method of facts.methods) { graph.addNode({ id: method.nodeId, label: 'Method', - properties: { filePath: facts.filePath, qualifiedName: method.qualifiedName }, + properties: { + name: method.name, + filePath: facts.filePath, + qualifiedName: method.qualifiedName, + }, }); } } - objectiveCScopeResolver.emitPostResolutionEdges?.(graph, parsedFiles); + objectiveCScopeResolver.emitPostResolutionEdges?.( + graph, + parsedFiles, + new Map(), + finalizeScopeModel([]), + { fileContents: new Map() }, + ); let implementerUses = 0; let receiverUses = 0; diff --git a/gitnexus/test/unit/ops-snapshot.test.ts b/gitnexus/test/unit/ops-snapshot.test.ts index 29fa32a9f..298b823e1 100644 --- a/gitnexus/test/unit/ops-snapshot.test.ts +++ b/gitnexus/test/unit/ops-snapshot.test.ts @@ -27,8 +27,8 @@ describe('serializeOpsJob / summarizeOpsLane', () => { expect(view.durationMs).toBe(5_000); expect(view.lane).toBe('analyze'); expect(view.repoName).toBe('repo'); - expect(view.repoUrl).toBeUndefined(); - expect(view.repoPath).toBeUndefined(); + expect(view).not.toHaveProperty('repoUrl'); + expect(view).not.toHaveProperty('repoPath'); expect(view.branch).toBeUndefined(); }); @@ -72,7 +72,7 @@ describe('serializeOpsJob / summarizeOpsLane', () => { const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze'); expect(view.repoName).toBe('repo'); expect(view.repoName).not.toContain('access_token'); - expect(view.repoUrl).toBeUndefined(); + expect(view).not.toHaveProperty('repoUrl'); }); it('redacts the full repository URL from job.error on the public ops view', () => { diff --git a/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts b/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts index 216170a2b..9e58cedfb 100644 --- a/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts +++ b/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts @@ -12,6 +12,7 @@ import os from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import { runChunkedParseAndResolve } from '../../src/core/ingestion/pipeline-phases/parse-impl.js'; import { computeChunkHash, fileContentHash } from '../../src/storage/parse-cache.js'; @@ -27,16 +28,15 @@ const emptyWorkerResult = (filePath: string, name: string): ParseWorkerResult => filePath, startLine: 1, endLine: 1, - language: 'typescript', + language: SupportedLanguages.TypeScript, + isExported: true, }, }, ], relationships: [], symbols: [], - imports: [], calls: [], assignments: [], - heritage: [], routes: [], fetchCalls: [], fetchWrapperDefs: [], diff --git a/gitnexus/test/unit/parsedfile-store.test.ts b/gitnexus/test/unit/parsedfile-store.test.ts index 7834a9d3c..2f5c3536c 100644 --- a/gitnexus/test/unit/parsedfile-store.test.ts +++ b/gitnexus/test/unit/parsedfile-store.test.ts @@ -26,29 +26,40 @@ import { * Maps — the round-trip's fidelity hinges on those Maps surviving JSON * serialization (they would otherwise collapse to `{}`). */ -const makeParsedFile = (filePath: string): ParsedFile => - ({ - filePath, - moduleScope: `${filePath}:module`, - parsedImports: [], - localDefs: [ - { nodeId: `Function:${filePath}:fn`, filePath, type: 'Function', qualifiedName: 'fn' }, - ], - referenceSites: [], - scopes: [ - { - id: `${filePath}:module`, - parent: null, - kind: 'Module', - range: { startLine: 1, startCol: 0, endLine: 9, endCol: 0 }, - filePath, - bindings: new Map([['fn', [{ defId: `Function:${filePath}:fn`, origin: 'local' }]]]), - ownedDefs: [], - imports: [], - typeBindings: new Map([['x', { name: 'int' }]]), - }, - ], - }) as unknown as ParsedFile; +const makeParsedFile = (filePath: string): ParsedFile => ({ + filePath, + moduleScope: `${filePath}:module`, + parsedImports: [], + localDefs: [ + { nodeId: `Function:${filePath}:fn`, filePath, type: 'Function', qualifiedName: 'fn' }, + ], + referenceSites: [], + scopes: [ + { + id: `${filePath}:module`, + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 9, endCol: 0 }, + filePath, + bindings: new Map([ + [ + 'fn', + [ + { + def: { nodeId: `Function:${filePath}:fn`, filePath, type: 'Function' }, + origin: 'local', + }, + ], + ], + ]), + ownedDefs: [], + imports: [], + typeBindings: new Map([ + ['x', { rawName: 'int', declaredAtScope: `${filePath}:module`, source: 'annotation' }], + ]), + }, + ], +}); /** * Store payload with arbitrary (possibly corrupt) field overrides. The one @@ -79,9 +90,9 @@ describe('parsedfile-store', () => { const a = loaded.get('a.c')!; const scope = a.scopes[0]; expect(scope.bindings).toBeInstanceOf(Map); - expect(scope.bindings.get('fn')?.[0]?.defId).toBe('Function:a.c:fn'); + expect(scope.bindings.get('fn')?.[0]?.def.nodeId).toBe('Function:a.c:fn'); expect(scope.typeBindings).toBeInstanceOf(Map); - expect((scope.typeBindings.get('x') as { name: string }).name).toBe('int'); + expect(scope.typeBindings.get('x')?.rawName).toBe('int'); } finally { await rm(dir, { recursive: true, force: true }); } @@ -374,7 +385,7 @@ describe('parsedfile-store', () => { const loaded = await loadParsedFilesForPaths(dir, new Set(['a.c'])); const scope = loaded.get('a.c')!.scopes[0]; expect(scope.bindings).toBeInstanceOf(Map); - expect(scope.bindings.get('fn')?.[0]?.defId).toBe('Function:a.c:fn'); + expect(scope.bindings.get('fn')?.[0]?.def.nodeId).toBe('Function:a.c:fn'); expect(scope.typeBindings).toBeInstanceOf(Map); } finally { await rm(dir, { recursive: true, force: true }); @@ -916,9 +927,12 @@ describe('parsedfile-store receiverChain sanitation', () => { type: 'Function' as const, qualifiedName: 'fn', }; - const pf = makeParsedFile('a.c'); - (pf.localDefs as unknown as object[])[0] = def; - (pf.scopes[0] as { ownedDefs: object[] }).ownedDefs = [def]; + const base = makeParsedFile('a.c'); + const pf: ParsedFile = { + ...base, + localDefs: [def], + scopes: [{ ...base.scopes[0], ownedDefs: [def] }], + }; await persistParsedFileChunk(dir, 'ok', [pf]); const loadedFile = (await loadParsedFilesForPaths(dir, new Set(['a.c']))).get('a.c'); expect(loadedFile).toBeDefined(); diff --git a/gitnexus/test/unit/pdg-callee-id-capture.test.ts b/gitnexus/test/unit/pdg-callee-id-capture.test.ts index ef2804f72..efd1e2aaa 100644 --- a/gitnexus/test/unit/pdg-callee-id-capture.test.ts +++ b/gitnexus/test/unit/pdg-callee-id-capture.test.ts @@ -140,6 +140,7 @@ function makeIndexes( unresolvedEdges: 0, sccCount: 0, largestSccSize: 0, + ambiguousWildcardExports: [], }, }; } diff --git a/gitnexus/test/unit/pdg-impact-engine.test.ts b/gitnexus/test/unit/pdg-impact-engine.test.ts index 3ca021e0b..4d6f3f558 100644 --- a/gitnexus/test/unit/pdg-impact-engine.test.ts +++ b/gitnexus/test/unit/pdg-impact-engine.test.ts @@ -116,6 +116,8 @@ describe('runImpactPDG', () => { }); expect(bfsQueries).toBe(IMPACT_MAX_DEPTH); + expect('truncated' in result).toBe(true); + if (!('truncated' in result)) throw new Error('Expected a successful PDG result'); expect(result.truncated).toBe(true); expect(result.truncatedBy).toBe('depth'); }); diff --git a/gitnexus/test/unit/publish.test.ts b/gitnexus/test/unit/publish.test.ts index fdbccbb47..58230fbe7 100644 --- a/gitnexus/test/unit/publish.test.ts +++ b/gitnexus/test/unit/publish.test.ts @@ -113,7 +113,7 @@ describe('understand-quickly helpers (gitnexus-shared)', () => { describe('publishCommand (no-token no-op)', () => { let tempDir: string; let originalToken: string | undefined; - let exitCodeBefore: number | undefined; + let exitCodeBefore: typeof process.exitCode; beforeEach(async () => { vi.resetModules(); @@ -180,7 +180,7 @@ describe('publishCommand (no-token no-op)', () => { describe('publishCommand response branches (MEDIUM 5)', () => { let tempDir: string; let originalToken: string | undefined; - let exitCodeBefore: number | undefined; + let exitCodeBefore: typeof process.exitCode; let fetchSpy: ReturnType; beforeEach(async () => { diff --git a/gitnexus/test/unit/query-degraded-signal.test.ts b/gitnexus/test/unit/query-degraded-signal.test.ts index f4c9e763c..7e28c5f6a 100644 --- a/gitnexus/test/unit/query-degraded-signal.test.ts +++ b/gitnexus/test/unit/query-degraded-signal.test.ts @@ -41,13 +41,13 @@ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { }; }); -import { LocalBackend } from '../../src/mcp/local/local-backend'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; import { resetExtensionState } from '../../src/core/lbug/extension-loader.js'; // A backend whose hybrid search yields exactly one matched symbol, so the // enrichment chunk loop runs and can be made to fail. `ftsUsed` is parameterized // so we can exercise the FTS-missing + enrichment-degraded composition. -function makeBackend(ftsUsed = true, nonBenignErrors?: string[]): LocalBackend { +function makeBackend(ftsUsed = true, nonBenignErrors?: string[]) { const backend = new LocalBackend(); const repoHandle = { id: 'repo1', @@ -73,7 +73,7 @@ function makeBackend(ftsUsed = true, nonBenignErrors?: string[]): LocalBackend { .fn() .mockResolvedValue({ results: [sym], ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) }); (backend as any).semanticSearch = vi.fn().mockResolvedValue([]); - return { backend, repoHandle } as any; + return { backend, repoHandle }; } const runQuery = (b: any, params: any = { query: 'x' }) => diff --git a/gitnexus/test/unit/range-binding-parse-timeout.test.ts b/gitnexus/test/unit/range-binding-parse-timeout.test.ts index 7e07d0616..683d88759 100644 --- a/gitnexus/test/unit/range-binding-parse-timeout.test.ts +++ b/gitnexus/test/unit/range-binding-parse-timeout.test.ts @@ -1,5 +1,6 @@ +import type { ScopeResolutionIndexes } from '../../src/core/ingestion/model/scope-resolution-indexes.js'; import { describe, it, expect, afterEach } from 'vitest'; -import type { ParsedFile, ScopeResolutionIndexes } from 'gitnexus-shared'; +import type { ParsedFile } from 'gitnexus-shared'; import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js'; import { goScopeResolver } from '../../src/core/ingestion/languages/go/scope-resolver.js'; import { cppScopeResolver } from '../../src/core/ingestion/languages/cpp/scope-resolver.js'; diff --git a/gitnexus/test/unit/rate-limit.test.ts b/gitnexus/test/unit/rate-limit.test.ts index bfb2865df..90e428b25 100644 --- a/gitnexus/test/unit/rate-limit.test.ts +++ b/gitnexus/test/unit/rate-limit.test.ts @@ -119,7 +119,7 @@ describe('createRouteLimiter — integration with a real route', () => { const res = await fetch(`${baseUrl}/test/file`); expect(res.status).toBe(429); const body = await res.json(); - expect(body.error).toContain('Too many'); + expect(body).toHaveProperty('error', expect.stringContaining('Too many')); }); it('emits draft-7 RateLimit response header (combined form), not legacy X-RateLimit-*', async () => { diff --git a/gitnexus/test/unit/repo-manager-ensure-ignore-readonly.test.ts b/gitnexus/test/unit/repo-manager-ensure-ignore-readonly.test.ts index 8e49a01f3..abf620bf4 100644 --- a/gitnexus/test/unit/repo-manager-ensure-ignore-readonly.test.ts +++ b/gitnexus/test/unit/repo-manager-ensure-ignore-readonly.test.ts @@ -13,7 +13,7 @@ const fswCtx = vi.hoisted(() => ({ vi.mock('fs/promises', async (importOriginal) => { const actual = await importOriginal(); - const d = actual.default; + const d = actual; fswCtx.realWrite = d.writeFile.bind(d); fswCtx.writeFileMock.mockImplementation((...args) => fswCtx.realWrite!(...args)); return { diff --git a/gitnexus/test/unit/repo-manager-reconcile.test.ts b/gitnexus/test/unit/repo-manager-reconcile.test.ts index 4e6ab26d6..9d3b1b50c 100644 --- a/gitnexus/test/unit/repo-manager-reconcile.test.ts +++ b/gitnexus/test/unit/repo-manager-reconcile.test.ts @@ -91,9 +91,10 @@ describe('reconcileMetadataFiles', () => { // The dirty flag travels through this file; a reconciliation that // reconstructed a trimmed object instead of carrying fields verbatim // would silently drop it and skip the recovery full-rebuild. - const dirty = metaAt('2026-06-01T00:00:00.000Z', 'crashed-run', { + const dirty = { + ...metaAt('2026-06-01T00:00:00.000Z', 'crashed-run'), incrementalInProgress: true, - } as Partial); + }; await fs.writeFile(path.join(storagePath, 'meta.json'), JSON.stringify(dirty)); await reconcileMetadataFiles(tmpRepo.dbPath); diff --git a/gitnexus/test/unit/repo-manager-rm-failure.test.ts b/gitnexus/test/unit/repo-manager-rm-failure.test.ts index 427cc8367..ab8b8a076 100644 --- a/gitnexus/test/unit/repo-manager-rm-failure.test.ts +++ b/gitnexus/test/unit/repo-manager-rm-failure.test.ts @@ -14,7 +14,7 @@ const fsCtx = vi.hoisted(() => ({ vi.mock('fs/promises', async (importOriginal) => { const actual = await importOriginal(); - const d = actual.default; + const d = actual; fsCtx.realRm = d.rm.bind(d); fsCtx.rmMock.mockImplementation((...args) => fsCtx.realRm!(...args)); return { diff --git a/gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts b/gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts index fa79f6c85..a58963d31 100644 --- a/gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts +++ b/gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts @@ -162,9 +162,10 @@ describe('#3291 — tool staleness names the indexed ref', () => { // The graph answer itself is held constant so the only thing that can differ // between the two responses is the freshness signalling under test. - vi.spyOn(backend as unknown as { impact: unknown }, 'impact').mockResolvedValue( - IMPACT_RESULT as never, - ); + vi.spyOn( + backend as unknown as { impact: () => Promise }, + 'impact', + ).mockResolvedValue(IMPACT_RESULT as never); const fromMain = (await backend.callTool('impact', { target: 'doWork', @@ -218,9 +219,10 @@ describe('#3291 — tool staleness names the indexed ref', () => { vi.spyOn(backend, 'selectToolRepository').mockResolvedValue( handleFor(mainClone, twoBack, 'main') as never, ); - vi.spyOn(backend as unknown as { impact: unknown }, 'impact').mockResolvedValue( - IMPACT_RESULT as never, - ); + vi.spyOn( + backend as unknown as { impact: () => Promise }, + 'impact', + ).mockResolvedValue(IMPACT_RESULT as never); const result = (await backend.callTool('impact', { target: 'doWork', @@ -244,9 +246,10 @@ describe('#3291 — tool staleness names the indexed ref', () => { const { branch: _unlabelled, ...unlabelledHandle } = handleFor(mainClone, head, 'main'); vi.spyOn(backend, 'selectToolRepository').mockResolvedValue(unlabelledHandle as never); - vi.spyOn(backend as unknown as { impact: unknown }, 'impact').mockResolvedValue( - IMPACT_RESULT as never, - ); + vi.spyOn( + backend as unknown as { impact: () => Promise }, + 'impact', + ).mockResolvedValue(IMPACT_RESULT as never); const result = (await backend.callTool('impact', { target: 'doWork', diff --git a/gitnexus/test/unit/resolve-enclosing-owner.test.ts b/gitnexus/test/unit/resolve-enclosing-owner.test.ts index 6af25951b..4856d7291 100644 --- a/gitnexus/test/unit/resolve-enclosing-owner.test.ts +++ b/gitnexus/test/unit/resolve-enclosing-owner.test.ts @@ -30,7 +30,7 @@ const parseRuby = (code: string) => { }; const parseKotlin = (code: string) => { - parser.setLanguage(Kotlin as Parser.Language); + parser.setLanguage(Kotlin); return parser.parse(code); }; diff --git a/gitnexus/test/unit/resolve-route-handler-symbols.test.ts b/gitnexus/test/unit/resolve-route-handler-symbols.test.ts index 017a945f8..950a757bc 100644 --- a/gitnexus/test/unit/resolve-route-handler-symbols.test.ts +++ b/gitnexus/test/unit/resolve-route-handler-symbols.test.ts @@ -237,7 +237,7 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => { const owner = model.symbols.add('src/handlers.js', 'auth', 'object:auth', 'Variable'); model.methods.register(owner.nodeId, 'getCurrentUser', { filePath: 'src/handlers.js', - name: 'getCurrentUser', + qualifiedName: 'getCurrentUser', nodeId: 'method:auth.getCurrentUser', type: 'Method', ownerId: owner.nodeId, @@ -281,7 +281,7 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => { model.symbols.add('src/routes.js', 'auth', 'object:auth', 'Variable'); model.methods.register('object:auth', 'getCurrentUser', { filePath: 'src/routes.js', - name: 'getCurrentUser', + qualifiedName: 'getCurrentUser', nodeId: 'method:auth.getCurrentUser', type: 'Method', ownerId: 'object:auth', @@ -326,7 +326,7 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => { model.symbols.add('src/routes.js', 'services', 'object:services', 'Variable'); model.methods.register('object:services', 'getCurrentUser', { filePath: 'src/routes.js', - name: 'getCurrentUser', + qualifiedName: 'getCurrentUser', nodeId: 'method:decoy', type: 'Method', ownerId: 'object:services', diff --git a/gitnexus/test/unit/route-constant-baseline-gate.test.ts b/gitnexus/test/unit/route-constant-baseline-gate.test.ts index eb4919cff..0f4d4db2a 100644 --- a/gitnexus/test/unit/route-constant-baseline-gate.test.ts +++ b/gitnexus/test/unit/route-constant-baseline-gate.test.ts @@ -10,7 +10,6 @@ // `node --import tsx bench/*/measure.mjs --check`, never in `npm test`. import { describe, expect, it } from 'vitest'; -// @ts-expect-error — .mjs pure-JS harness module, no types (intentional; build-free). import * as G from '../../bench/lib/route-constant-guard.mjs'; const collectBaselineErrors = G.collectBaselineErrors as ( diff --git a/gitnexus/test/unit/route-process-linking.test.ts b/gitnexus/test/unit/route-process-linking.test.ts index 46bb69815..57397ec2d 100644 --- a/gitnexus/test/unit/route-process-linking.test.ts +++ b/gitnexus/test/unit/route-process-linking.test.ts @@ -101,7 +101,7 @@ describe('Route → process linking (ENTRY_POINT_OF) re-keying', () => { await processesPhase.execute( makeCtx(graph), - new Map([ + new Map>([ ['structure', phaseResult('structure', { totalFiles: 1 })], ['communities', phaseResult('communities', { communityResult: { memberships: [] } })], ['routes', phaseResult('routes', { routeRegistry })], @@ -168,7 +168,7 @@ describe('Route → process linking (ENTRY_POINT_OF) re-keying', () => { await processesPhase.execute( makeCtx(graph), - new Map([ + new Map>([ ['structure', phaseResult('structure', { totalFiles: 1 })], ['communities', phaseResult('communities', { communityResult: { memberships: [] } })], ['routes', phaseResult('routes', { routeRegistry })], @@ -239,7 +239,7 @@ describe('Route → process linking (ENTRY_POINT_OF) re-keying', () => { await processesPhase.execute( makeCtx(graph), - new Map([ + new Map>([ ['structure', phaseResult('structure', { totalFiles: 1 })], ['communities', phaseResult('communities', { communityResult: { memberships: [] } })], ['routes', phaseResult('routes', { routeRegistry })], diff --git a/gitnexus/test/unit/run-analyze-adopt-failure.test.ts b/gitnexus/test/unit/run-analyze-adopt-failure.test.ts index 7ffb6e6a2..4661b6ce6 100644 --- a/gitnexus/test/unit/run-analyze-adopt-failure.test.ts +++ b/gitnexus/test/unit/run-analyze-adopt-failure.test.ts @@ -130,7 +130,11 @@ describe('fast-path restamp failure modes (#2364 F3)', () => { const logs: string[] = []; rmCtx.adoptMock.mockRejectedValueOnce(new Error('mock adopt failure')); - const first = await runFullAnalysis(tmpRepo.dbPath, {}, { onLog: (m) => logs.push(m) }); + const first = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (m) => logs.push(m) }, + ); expect(first.alreadyUpToDate).toBe(true); expect(logs.some((m) => m.includes('could not restamp the workspace branch label'))).toBe(true); @@ -140,7 +144,7 @@ describe('fast-path restamp failure modes (#2364 F3)', () => { await expect(fs.access(branchMetaDir)).resolves.toBeUndefined(); // …and the next same-commit run retries and completes the whole sync. - const second = await runFullAnalysis(tmpRepo.dbPath, {}, {}); + const second = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} }); expect(second.alreadyUpToDate).toBe(true); const healed = await loadMeta(flatStorage); expect(healed?.branch).toBe('feature/x'); @@ -152,7 +156,7 @@ describe('fast-path restamp failure modes (#2364 F3)', () => { rmCtx.adoptMock.mockClear(); rmCtx.saveMetaMock.mockClear(); - const result = await runFullAnalysis(tmpRepo.dbPath, {}, {}); + const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} }); expect(result.alreadyUpToDate).toBe(true); expect(rmCtx.adoptMock).toHaveBeenCalledTimes(1); @@ -171,7 +175,11 @@ describe('fast-path restamp failure modes (#2364 F3)', () => { const logs: string[] = []; rmCtx.saveMetaMock.mockRejectedValueOnce(Object.assign(new Error('mock ro'), { code })); - const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onLog: (m) => logs.push(m) }); + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (m) => logs.push(m) }, + ); expect(result.alreadyUpToDate).toBe(true); expect(logs.some((m) => m.includes('read-only') && m.includes('#1549'))).toBe(true); @@ -187,7 +195,7 @@ describe('fast-path restamp failure modes (#2364 F3)', () => { Object.assign(new Error('mock read-only storage'), { code: 'EROFS' }), ); - const result = await runFullAnalysis(tmpRepo.dbPath, {}, {}); + const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} }); expect(result.alreadyUpToDate).toBe(true); expect(rmCtx.writableMock).not.toHaveBeenCalled(); @@ -199,7 +207,9 @@ describe('fast-path restamp failure modes (#2364 F3)', () => { const readOnly = Object.assign(new Error('mock read-only storage'), { code: 'EROFS' }); rmCtx.writableMock.mockRejectedValueOnce(readOnly); - await expect(runFullAnalysis(tmpRepo.dbPath, {}, {})).rejects.toBe(readOnly); + await expect(runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} })).rejects.toBe( + readOnly, + ); expect(rmCtx.writableMock).toHaveBeenCalledWith(flatStorage); }); }); diff --git a/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts b/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts index b372fb8dd..a68f4a5ae 100644 --- a/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts @@ -99,7 +99,7 @@ const fileGraph = () => { graph.addNode({ id: 'file:src/a.ts', label: 'File', - properties: { filePath: REL_FILE }, + properties: { name: REL_FILE, filePath: REL_FILE }, }); return graph; }; @@ -117,7 +117,7 @@ const mockLbugAdapter = async () => { // that identity instead of trusting the synthetic node count alone. executeQuery: vi.fn(async (query: string) => query.startsWith('MATCH (n:`File`) RETURN n.id AS id') - ? [{ id: 'file:src/a.ts', name: '', filePath: REL_FILE }] + ? [{ id: 'file:src/a.ts', name: REL_FILE, filePath: REL_FILE }] : [], ), executeWithReusedStatement: vi.fn(async () => []), diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts index b60cd60f1..452166590 100644 --- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts @@ -318,7 +318,17 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => { indexedAt: seededIndexedAt, stats: { files: 7, nodes: 42, edges: 10 }, runnerIdentity: { - source: { kind: 'source' as const, digest: 'src-digest' }, + schemaVersion: 4, + runtime: { + executablePath: '/usr/bin/node', + version: 'v24.11.0', + platform: 'linux', + architecture: 'x64', + modulesAbi: '137', + libc: 'glibc', + }, + cliVersion: '1.0.0', + invokedArtifact: { path: '/x/cli/index.ts', digest: 'src-digest' }, build: { kind: 'source' as const, rootPath: '/x', @@ -1108,7 +1118,9 @@ describe('runFullAnalysis wipe-and-restore vector-index stamp (tri-review 466951 properties: { filePath: 'src/app.ts' }, }; const buildVectorIndex = vi.fn(async () => false); - const executeWithReusedStatement = vi.fn(async () => []); + const executeWithReusedStatement = vi.fn< + typeof import('../../src/core/lbug/lbug-adapter.js').executeWithReusedStatement + >(async () => {}); vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ initLbug: vi.fn(async () => undefined), loadGraphToLbug: vi.fn(async () => undefined), @@ -1239,7 +1251,9 @@ describe('runFullAnalysis wipe-and-restore vector-index stamp (tri-review 466951 name: 'handler', properties: { filePath: 'src/app.ts' }, }; - const executeWithReusedStatement = vi.fn(async () => []); + const executeWithReusedStatement = vi.fn< + typeof import('../../src/core/lbug/lbug-adapter.js').executeWithReusedStatement + >(async () => {}); vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ initLbug: vi.fn(async () => undefined), loadGraphToLbug: vi.fn(async () => undefined), @@ -3073,6 +3087,7 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => const adapter = await import('../../src/core/lbug/lbug-adapter.js'); vi.mocked(adapter.initLbug).mockImplementation(async (dbPath) => { if (dbPath.includes('.staging.')) await fs.writeFile(dbPath, 'staged fixture'); + return { db: null, conn: null }; }); vi.mocked(adapter.wipeLbugDbFiles).mockImplementation(async (dbPath) => { await fs.rm(dbPath, { force: true }); @@ -3325,6 +3340,7 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => const adapter = await import('../../src/core/lbug/lbug-adapter.js'); vi.mocked(adapter.loadGraphToLbug).mockImplementation(async () => { await fs.writeFile(lbugPath, 'in-place replacement'); + return { success: true, insertedRels: 0, skippedRels: 0, warnings: [] }; }); // Import on the host first, then choose the Windows default in-place // branch. The native adapter is mocked; source files and lock cleanup are real. @@ -3629,6 +3645,7 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => const adapter = await import('../../src/core/lbug/lbug-adapter.js'); vi.mocked(adapter.initLbug).mockImplementation(async (dbPath) => { if (dbPath.includes('.staging.')) await fs.writeFile(dbPath, 'staged fixture'); + return { db: null, conn: null }; }); vi.mocked(adapter.wipeLbugDbFiles).mockImplementation(async (dbPath) => { await fs.rm(dbPath, { force: true }); @@ -3670,6 +3687,7 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => const adapter = await import('../../src/core/lbug/lbug-adapter.js'); vi.mocked(adapter.initLbug).mockImplementation(async (dbPath) => { if (dbPath.includes('.staging.')) await fs.writeFile(dbPath, 'staged fixture'); + return { db: null, conn: null }; }); vi.mocked(adapter.wipeLbugDbFiles).mockImplementation(async (dbPath) => { await fs.rm(dbPath, { force: true }); diff --git a/gitnexus/test/unit/scope-resolution/c/c-workspace.test.ts b/gitnexus/test/unit/scope-resolution/c/c-workspace.test.ts index 732f01b46..9134a0289 100644 --- a/gitnexus/test/unit/scope-resolution/c/c-workspace.test.ts +++ b/gitnexus/test/unit/scope-resolution/c/c-workspace.test.ts @@ -462,7 +462,7 @@ describe('C/C++ monorepo config', () => { targetRaw: string, fromFile: string, workspace: ReadonlySet, - ): string | null { + ): ReturnType { return cScopeResolver.resolveImportTarget( targetRaw, fromFile, diff --git a/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts b/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts index 9c0ffcea1..9de8ac5f3 100644 --- a/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts +++ b/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts @@ -98,6 +98,7 @@ function indexes(scopes: readonly Scope[]): ScopeResolutionIndexes { unresolvedEdges: 0, sccCount: 1, largestSccSize: 1, + ambiguousWildcardExports: [], }, }; } @@ -115,8 +116,8 @@ function operand(name: string, line: number): CallableFlowOperand { function runReverseChain(length: number) { const moduleBindings = new Map([ - ['target', [{ def: targetDef }]], - ['entry', [{ def: entryDef }]], + ['target', [{ def: targetDef, origin: 'local' }]], + ['entry', [{ def: entryDef, origin: 'local' }]], ]); const scopes = [ scope(MODULE, null, 'Module', [targetDef, entryDef], moduleBindings), diff --git a/gitnexus/test/unit/scope-resolution/construction-syntax-wiring.test.ts b/gitnexus/test/unit/scope-resolution/construction-syntax-wiring.test.ts index 3729f3937..962aea08e 100644 --- a/gitnexus/test/unit/scope-resolution/construction-syntax-wiring.test.ts +++ b/gitnexus/test/unit/scope-resolution/construction-syntax-wiring.test.ts @@ -12,6 +12,7 @@ */ import { describe, it, expect } from 'vitest'; +import { SupportedLanguages } from 'gitnexus-shared'; import { SCOPE_RESOLVERS } from '../../../src/core/ingestion/scope-resolution/pipeline/registry.js'; describe('constructionSyntax wiring inventory', () => { @@ -33,7 +34,13 @@ describe('constructionSyntax wiring inventory', () => { it('leaves the languages that already resolve the shape unwired', () => { // Java resolves it via the #2564 object_creation_expression capture // rewrite; php/swift/dart/kotlin via their own capture-side paths. - for (const language of ['java', 'php', 'swift', 'dart', 'kotlin'] as const) { + for (const language of [ + SupportedLanguages.Java, + SupportedLanguages.PHP, + SupportedLanguages.Swift, + SupportedLanguages.Dart, + SupportedLanguages.Kotlin, + ] as const) { const resolver = SCOPE_RESOLVERS.get(language); expect(resolver, `${language} resolver is registered`).toBeDefined(); expect(resolver!.constructionSyntax, `${language} stays unwired`).toBeUndefined(); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts index 032b8b0eb..00797e0d2 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts @@ -232,7 +232,7 @@ describe('Tier-A predicate registry', () => { type: 'Function', templateConstraints: payload, ...(opts.parameterTypeClasses !== undefined - ? { parameterTypeClasses: opts.parameterTypeClasses } + ? { parameterTypeClasses: [...opts.parameterTypeClasses] } : {}), }; return cppConstraintCompatibility({ arity: argumentTypes.length }, def, { diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts index de7fd9f06..098c832a4 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts @@ -1,3 +1,4 @@ +import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js'; /** * #2788 — `resolveCppQualifiedNamespaceMember` serves qualified `ns::member()` * lookups from a per-pipeline index instead of rescanning every parsed file per @@ -15,12 +16,7 @@ * This is the failure mode the index introduces; nothing else covers it. */ -import type { - ParsedFile, - ScopeId, - ScopeResolutionIndexes, - SymbolDefinition, -} from 'gitnexus-shared'; +import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import { beforeEach, describe, expect, it } from 'vitest'; import { clearCppInlineNamespaces, diff --git a/gitnexus/test/unit/scope-resolution/emit-references.test.ts b/gitnexus/test/unit/scope-resolution/emit-references.test.ts index c88dc998e..097ac269a 100644 --- a/gitnexus/test/unit/scope-resolution/emit-references.test.ts +++ b/gitnexus/test/unit/scope-resolution/emit-references.test.ts @@ -99,6 +99,12 @@ function makeIndexes(scopes: Scope[], allDefs: SymbolDefinition[]): ScopeResolut }), imports: new Map(), bindings: new Map(), + bindingAugmentations: new Map(), + workspaceFqnBindings: new Map(), + workspaceTypeBindings: new Map(), + namespaceFqnBindings: new Map(), + namespaceTypeBindings: new Map(), + accessibleNamespacesByScope: new Map(), referenceSites: [], sccs: [], stats: { @@ -108,6 +114,7 @@ function makeIndexes(scopes: Scope[], allDefs: SymbolDefinition[]): ScopeResolut unresolvedEdges: 0, sccCount: 0, largestSccSize: 0, + ambiguousWildcardExports: [], }, }; } diff --git a/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts b/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts index 45eaaebd6..8972ba40e 100644 --- a/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts +++ b/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect } from 'vitest'; import { emitTsScopeCaptures } from '../../../src/core/ingestion/languages/typescript/captures.js'; import { emitJsScopeCaptures } from '../../../src/core/ingestion/languages/javascript/captures.js'; import { extract } from '../../../src/core/ingestion/scope-extractor.js'; -import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js'; +import { typescriptProvider } from '../../../src/core/ingestion/languages/typescript.js'; type Emit = typeof emitTsScopeCaptures; @@ -309,7 +309,7 @@ describe('SymbolDefinition.isExported through the extractor', () => { const esm = extract( emitTsScopeCaptures('export function a() {}\nfunction b() {}\n', 'x.ts'), 'x.ts', - typescriptScopeResolver, + typescriptProvider, ); const byName = new Map(esm.localDefs.map((d) => [d.qualifiedName, d.isExported])); expect(byName.get('a')).toBe(true); @@ -317,7 +317,7 @@ describe('SymbolDefinition.isExported through the extractor', () => { const cjs = extract( emitTsScopeCaptures('function a() {}\nmodule.exports = a;\n', 'y.ts'), 'y.ts', - typescriptScopeResolver, + typescriptProvider, ); expect(cjs.localDefs.find((d) => d.qualifiedName === 'a')?.isExported).toBeUndefined(); expect('isExported' in cjs.localDefs.find((d) => d.qualifiedName === 'a')!).toBe(false); diff --git a/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts b/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts index 5b044246c..9d06c5054 100644 --- a/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts +++ b/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts @@ -1,4 +1,11 @@ import { describe, expect, it } from 'vitest'; +import { + buildDefIndex, + buildQualifiedNameIndex, + buildScopeTree, + buildMethodDispatchIndex, + buildModuleScopeIndex, +} from 'gitnexus-shared'; import type { BindingRef, Callsite, @@ -30,10 +37,6 @@ describe('Go arity compatibility', () => { it('returns unknown when no param count info', () => { const def = makeDef(); const callsite: Callsite = { - name: 'F', - inScope: 's', - atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, - kind: 'call', arity: 1, }; expect(goArityCompatibility(def, callsite)).toBe('unknown'); @@ -42,10 +45,6 @@ describe('Go arity compatibility', () => { it('exact match is compatible', () => { const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); const callsite: Callsite = { - name: 'F', - inScope: 's', - atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, - kind: 'call', arity: 2, }; expect(goArityCompatibility(def, callsite)).toBe('compatible'); @@ -54,10 +53,6 @@ describe('Go arity compatibility', () => { it('too few args is incompatible', () => { const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); const callsite: Callsite = { - name: 'F', - inScope: 's', - atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, - kind: 'call', arity: 1, }; expect(goArityCompatibility(def, callsite)).toBe('incompatible'); @@ -70,10 +65,6 @@ describe('Go arity compatibility', () => { parameterTypes: ['string', '...string'], }); const callsite: Callsite = { - name: 'F', - inScope: 's', - atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, - kind: 'call', arity: 5, }; expect(goArityCompatibility(def, callsite)).toBe('compatible'); @@ -82,10 +73,6 @@ describe('Go arity compatibility', () => { it('non-variadic rejects extra args', () => { const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); const callsite: Callsite = { - name: 'F', - inScope: 's', - atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, - kind: 'call', arity: 3, }; expect(goArityCompatibility(def, callsite)).toBe('incompatible'); @@ -222,32 +209,40 @@ function scopeIndexes( readonly imports?: ReadonlyMap; } = {}, ): ScopeResolutionIndexes { - const defsById = new Map(defs.map((def) => [def.nodeId, def])); - const qualifiedNames = new Map(); - for (const def of defs) { - const ids = qualifiedNames.get(def.qualifiedName) ?? []; - ids.push(def.nodeId); - qualifiedNames.set(def.qualifiedName, ids); - } - const scopesById = new Map(scopes.map((s) => [s.id, s])); return { - defs: { get: (id: string) => defsById.get(id) }, - qualifiedNames: { get: (name: string) => qualifiedNames.get(name) ?? [] }, - scopeTree: { getScope: (id: ScopeId) => scopesById.get(id) }, + defs: buildDefIndex(defs), + qualifiedNames: buildQualifiedNameIndex(defs), + scopeTree: buildScopeTree([...scopes]), bindings: new Map(), bindingAugmentations: options.bindingAugmentations ?? new Map(), imports: options.imports ?? new Map(), workspaceFqnBindings: new Map(), namespaceFqnBindings: new Map(), accessibleNamespacesByScope: new Map(), - methodDispatch: {} as any, - moduleScopes: {} as any, + methodDispatch: buildMethodDispatchIndex({ + owners: [], + computeMro: () => [], + implementsOf: () => [], + }), + moduleScopes: buildModuleScopeIndex( + scopes + .filter((scope) => scope.kind === 'Module') + .map((scope) => ({ filePath: scope.filePath, moduleScopeId: scope.id })), + ), workspaceTypeBindings: new Map(), namespaceTypeBindings: new Map(), referenceSites: [], sccs: [], - stats: {} as any, - } as ScopeResolutionIndexes; + stats: { + totalFiles: new Set(scopes.map((scope) => scope.filePath)).size, + totalEdges: 0, + linkedEdges: 0, + unresolvedEdges: 0, + sccCount: 0, + largestSccSize: 0, + ambiguousWildcardExports: [], + }, + }; } const emptyIndexes = scopeIndexes([]); @@ -257,13 +252,19 @@ function scope( kind: Scope['kind'], ownedDefs: readonly SymbolDefinition[], parent: ScopeId | null = null, + line = 1, ): Scope { return { id, parent, kind, filePath: 'repo.go', - range: { startLine: 1, startCol: 0, endLine: 1, endCol: 1 }, + range: { + startLine: kind === 'Module' ? 0 : line, + startCol: 0, + endLine: kind === 'Module' ? 100 : line, + endCol: 1, + }, bindings: new Map(), ownedDefs, imports: [], @@ -629,7 +630,11 @@ describe('Go structural interface detection', () => { returnType: 'error', }); const defs = [reader, base, file, readerRead, baseRead]; - const scopes = [scope('scope:Base', 'Class', [base]), scope('scope:File', 'Class', [file])]; + const scopes = [ + scope('scope:module', 'Module', []), + scope('scope:Base', 'Class', [base], 'scope:module', 1), + scope('scope:File', 'Class', [file], 'scope:module', 2), + ]; const result = detectGoInterfaceImplementations( parsedGoDefs(defs, { @@ -671,8 +676,9 @@ describe('Go structural interface detection', () => { ); const defs = [reader, base, shadowFile, readerRead, baseRead, shadowRead]; const scopes = [ - scope('scope:Base', 'Class', [base]), - scope('scope:ShadowFile', 'Class', [shadowFile]), + scope('scope:module', 'Module', []), + scope('scope:Base', 'Class', [base], 'scope:module', 1), + scope('scope:ShadowFile', 'Class', [shadowFile], 'scope:module', 2), ]; const result = detectGoInterfaceImplementations( @@ -709,9 +715,10 @@ describe('Go structural interface detection', () => { }); const defs = [reader, baseA, baseB, file, readerRead, baseARead, baseBRead]; const scopes = [ - scope('scope:BaseA', 'Class', [baseA]), - scope('scope:BaseB', 'Class', [baseB]), - scope('scope:File', 'Class', [file]), + scope('scope:module', 'Module', []), + scope('scope:BaseA', 'Class', [baseA], 'scope:module', 1), + scope('scope:BaseB', 'Class', [baseB], 'scope:module', 2), + scope('scope:File', 'Class', [file], 'scope:module', 3), ]; const result = detectGoInterfaceImplementations( @@ -749,10 +756,11 @@ describe('Go structural interface detection', () => { }); const defs = [reader, shallow, deepBase, deepWrapper, file, readerRead, shallowRead, deepRead]; const scopes = [ - scope('scope:Shallow', 'Class', [shallow]), - scope('scope:DeepBase', 'Class', [deepBase]), - scope('scope:DeepWrapper', 'Class', [deepWrapper]), - scope('scope:File', 'Class', [file]), + scope('scope:module', 'Module', []), + scope('scope:Shallow', 'Class', [shallow], 'scope:module', 1), + scope('scope:DeepBase', 'Class', [deepBase], 'scope:module', 2), + scope('scope:DeepWrapper', 'Class', [deepWrapper], 'scope:module', 3), + scope('scope:File', 'Class', [file], 'scope:module', 4), ]; const result = detectGoInterfaceImplementations( @@ -846,9 +854,9 @@ describe('Go structural interface detection', () => { ]; const scopes = [ scope('scope:module', 'Module', []), - scope('scope:ReaderA', 'Class', [readerA], 'scope:module'), - scope('scope:ReaderB', 'Class', [readerB], 'scope:module'), - scope('scope:ReadCloser', 'Class', [readCloser], 'scope:module'), + scope('scope:ReaderA', 'Class', [readerA], 'scope:module', 1), + scope('scope:ReaderB', 'Class', [readerB], 'scope:module', 2), + scope('scope:ReadCloser', 'Class', [readCloser], 'scope:module', 3), ]; const result = detectGoInterfaceImplementations( diff --git a/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts b/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts index 6c8c28561..6cbbea6d3 100644 --- a/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts @@ -42,7 +42,9 @@ describe('Go import decomposition', () => { ])('uses the same package qualifier for %s as route extraction', (spec, expected) => { const matches = parseThenSplit(`import ${spec}`); expect(matches[0]['@import.name']?.text).toBe(expected); - expect(interpretGoImport(matches[0])?.localName).toBe(expected); + const parsed = interpretGoImport(matches[0]); + expect(parsed).not.toBeNull(); + expect(parsed && 'localName' in parsed ? parsed.localName : undefined).toBe(expected); }); it('decomposes grouped imports', () => { diff --git a/gitnexus/test/unit/scope-resolution/go/go-range-binding-null-guard.test.ts b/gitnexus/test/unit/scope-resolution/go/go-range-binding-null-guard.test.ts index edf48b33a..93f7d3f43 100644 --- a/gitnexus/test/unit/scope-resolution/go/go-range-binding-null-guard.test.ts +++ b/gitnexus/test/unit/scope-resolution/go/go-range-binding-null-guard.test.ts @@ -1,8 +1,10 @@ +import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js'; import { describe, it, expect } from 'vitest'; import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js'; import { goScopeResolver } from '../../../../src/core/ingestion/languages/go/scope-resolver.js'; import { populateGoRangeBindings } from '../../../../src/core/ingestion/languages/go/range-binding.js'; -import type { ParsedFile, ScopeResolutionIndexes } from 'gitnexus-shared'; +import type { ParsedFile } from 'gitnexus-shared'; +import { finalizeScopeModel } from '../../../../src/core/ingestion/finalize-orchestrator.js'; function parseGo(src: string, path = 'main.go'): ParsedFile { const p = extractParsedFile(goScopeResolver.languageProvider, src, path); @@ -12,13 +14,7 @@ function parseGo(src: string, path = 'main.go'): ParsedFile { } function makeEmptyIndexes(): ScopeResolutionIndexes { - return { - bindings: new Map(), - imports: [], - scopeTree: { roots: [] } as any, - methodDispatch: new Map(), - sccs: [], - } as ScopeResolutionIndexes; + return finalizeScopeModel([]); } describe('Go range binding — null guard (#1346, #1366)', () => { diff --git a/gitnexus/test/unit/scope-resolution/import-target-index-parity.test.ts b/gitnexus/test/unit/scope-resolution/import-target-index-parity.test.ts index bc8384257..e029c4260 100644 --- a/gitnexus/test/unit/scope-resolution/import-target-index-parity.test.ts +++ b/gitnexus/test/unit/scope-resolution/import-target-index-parity.test.ts @@ -848,6 +848,8 @@ describe('import-target index hoist — built once per file set, not once per im filePath, bindings: new Map([[name, [{ def, origin: 'local' }]]]), ownedDefs: [def], + imports: [], + typeBindings: new Map(), }, ], parsedImports: [], diff --git a/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts b/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts index 976247da4..62a6763f2 100644 --- a/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts +++ b/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts @@ -1,3 +1,4 @@ +import { SupportedLanguages } from 'gitnexus-shared'; import type { NodeLabel } from 'gitnexus-shared'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -25,20 +26,28 @@ interface Candidate { function buildLookup(candidates: readonly Candidate[]) { const graph = createKnowledgeGraph(); - const nodes = candidates.map( - (candidate) => - ({ - id: candidate.id, - label: candidate.label ?? ('Method' as NodeLabel), - properties: { - name: candidate.name ?? 'save', - qualifiedName: candidate.qualifiedName ?? 'Service.save', - filePath: FILE, - ...(candidate.startLine !== undefined ? { startLine: candidate.startLine } : {}), - ...(candidate.startColumn !== undefined ? { startColumn: candidate.startColumn } : {}), - }, - }) satisfies ParseWorkerResult['nodes'][number], - ); + const nodes = candidates.map((candidate) => { + const node = { + id: candidate.id, + label: candidate.label ?? ('Method' as NodeLabel), + properties: { + name: candidate.name ?? 'save', + qualifiedName: candidate.qualifiedName ?? 'Service.save', + filePath: FILE, + startLine: candidate.startLine, + endLine: candidate.startLine, + language: SupportedLanguages.TypeScript, + isExported: false, + ...(candidate.startColumn !== undefined ? { startColumn: candidate.startColumn } : {}), + }, + } satisfies ParseWorkerResult['nodes'][number]; + // Missing positions deliberately exercise legacy worker-result tolerance. + if (candidate.startLine === undefined) { + Reflect.deleteProperty(node.properties, 'startLine'); + Reflect.deleteProperty(node.properties, 'endLine'); + } + return node; + }); const result: ParseWorkerResult = { nodes, relationships: [], diff --git a/gitnexus/test/unit/scope-resolution/php-import-target-parity.test.ts b/gitnexus/test/unit/scope-resolution/php-import-target-parity.test.ts index ef3238706..25118b542 100644 --- a/gitnexus/test/unit/scope-resolution/php-import-target-parity.test.ts +++ b/gitnexus/test/unit/scope-resolution/php-import-target-parity.test.ts @@ -323,10 +323,14 @@ function parsedFilesFor(testCase: Case): readonly ParsedFile[] { (filePath) => ({ filePath, + moduleScope: `scope:${filePath}#module`, + scopes: [], + parsedImports: [], + referenceSites: [], localDefs: (testCase.defs?.get(filePath) ?? []).map(([type, name]) => definition(filePath, type, name), ), - }) as ParsedFile, + }) satisfies ParsedFile, ); } diff --git a/gitnexus/test/unit/scope-resolution/property-name-index.test.ts b/gitnexus/test/unit/scope-resolution/property-name-index.test.ts index ec08650b9..a079bf494 100644 --- a/gitnexus/test/unit/scope-resolution/property-name-index.test.ts +++ b/gitnexus/test/unit/scope-resolution/property-name-index.test.ts @@ -56,11 +56,13 @@ describe('buildPropertyNameIndex', () => { label: 'Function', properties: { name: 'run', filePath: 'a.js', startLine: 1, endLine: 2 }, }); - graph.addNode({ + const noPathNode = { id: 'Property:a.js:noPath', - label: 'Property', - properties: { name: 'noPath', startLine: 1, endLine: 1 }, - }); + label: 'Property' as const, + properties: { name: 'noPath', filePath: '', startLine: 1, endLine: 1 }, + }; + Reflect.deleteProperty(noPathNode.properties, 'filePath'); + graph.addNode(noPathNode); const index = buildPropertyNameIndex(graph); expect(index.get('run')).toBeUndefined(); diff --git a/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts b/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts index 8ffadb3f2..9723253b5 100644 --- a/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts +++ b/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts @@ -216,10 +216,11 @@ describe('pythonMissingReceiverSubtypeDecision', () => { it('declines class receivers without suppressing their ordinary resolution path', () => { expect( - pythonMissingReceiverSubtypeDecision( - { ...selfType, source: 'cls' }, - { receiverBindingIsStatic: true, memberName: 'hook', callArity: 0 }, - ), + pythonMissingReceiverSubtypeDecision(selfType, { + receiverBindingIsStatic: true, + memberName: 'hook', + callArity: 0, + }), ).toBe(false); }); }); diff --git a/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts b/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts index 497b77db9..afeb935ce 100644 --- a/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts +++ b/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts @@ -47,7 +47,7 @@ const mkProperty = (opts: { filePath: string; name: string; ownerId: string; - type?: 'Property' | 'Variable'; + type?: 'Property' | 'Variable' | 'Const' | 'Static'; }): SymbolDefinition => ({ nodeId: opts.nodeId, filePath: opts.filePath, diff --git a/gitnexus/test/unit/scope-resolution/references-to-edges-call-form.test.ts b/gitnexus/test/unit/scope-resolution/references-to-edges-call-form.test.ts index 99cf0ad91..51d318553 100644 --- a/gitnexus/test/unit/scope-resolution/references-to-edges-call-form.test.ts +++ b/gitnexus/test/unit/scope-resolution/references-to-edges-call-form.test.ts @@ -97,6 +97,7 @@ function makeIndexes(scope: Scope, allDefs: readonly SymbolDefinition[]): ScopeR unresolvedEdges: 0, sccCount: 0, largestSccSize: 0, + ambiguousWildcardExports: [], }, }; } diff --git a/gitnexus/test/unit/scope-resolution/resolve-references.test.ts b/gitnexus/test/unit/scope-resolution/resolve-references.test.ts index e5524d9e6..670657cee 100644 --- a/gitnexus/test/unit/scope-resolution/resolve-references.test.ts +++ b/gitnexus/test/unit/scope-resolution/resolve-references.test.ts @@ -79,6 +79,11 @@ function makeIndexes( }), imports: new Map(), bindings: new Map(), + workspaceFqnBindings: new Map(), + workspaceTypeBindings: new Map(), + namespaceFqnBindings: new Map(), + namespaceTypeBindings: new Map(), + accessibleNamespacesByScope: new Map(), bindingAugmentations: new Map(), referenceSites, sccs: [], @@ -89,6 +94,7 @@ function makeIndexes( unresolvedEdges: 0, sccCount: 0, largestSccSize: 0, + ambiguousWildcardExports: [], }, }; } diff --git a/gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts b/gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts index 20a81a8e2..3ba7e5647 100644 --- a/gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts +++ b/gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts @@ -21,7 +21,13 @@ describe('collectResolvedCalleeNames', () => { fn('b', 'b', 'src/b.go'); fn('c', 'c', 'src/c.ts'); fn('d', 'd', 'src/d.go'); - g.addNode({ id: 'file', label: 'File' as NodeLabel, properties: { filePath: 'src/a.go' } }); + const namelessNode = { + id: 'file', + label: 'File' as const, + properties: { name: '', filePath: 'src/a.go' }, + }; + Reflect.deleteProperty(namelessNode.properties, 'name'); + g.addNode(namelessNode); g.addRelationship({ id: 'r1', sourceId: 'a', diff --git a/gitnexus/test/unit/scope-resolution/rust/rust-mod-scope-qualifier.test.ts b/gitnexus/test/unit/scope-resolution/rust/rust-mod-scope-qualifier.test.ts index 84610395a..a963570fd 100644 --- a/gitnexus/test/unit/scope-resolution/rust/rust-mod-scope-qualifier.test.ts +++ b/gitnexus/test/unit/scope-resolution/rust/rust-mod-scope-qualifier.test.ts @@ -16,7 +16,7 @@ import { import { couldNameAModule } from '../../../../src/core/ingestion/languages/rust/module-path.js'; const parser = new Parser(); -parser.setLanguage(Rust as unknown as Parser.Language); +parser.setLanguage(Rust as unknown); /** First node of `type` in a post-order walk — the innermost such node. */ function findNode(root: SyntaxNode, type: string): SyntaxNode { diff --git a/gitnexus/test/unit/scope-resolution/rust/rust-range-binding-order.test.ts b/gitnexus/test/unit/scope-resolution/rust/rust-range-binding-order.test.ts index 734f1d992..e2e6de192 100644 --- a/gitnexus/test/unit/scope-resolution/rust/rust-range-binding-order.test.ts +++ b/gitnexus/test/unit/scope-resolution/rust/rust-range-binding-order.test.ts @@ -1,5 +1,7 @@ +import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js'; import { describe, expect, it } from 'vitest'; -import type { ParsedFile, ScopeResolutionIndexes } from 'gitnexus-shared'; +import type { ParsedFile } from 'gitnexus-shared'; +import { finalizeScopeModel } from '../../../../src/core/ingestion/finalize-orchestrator.js'; import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js'; import { rustScopeResolver } from '../../../../src/core/ingestion/languages/rust/scope-resolver.js'; import { populateRustRangeBindings } from '../../../../src/core/ingestion/languages/rust/range-binding.js'; @@ -25,14 +27,7 @@ function parse(src: string, path: string): ParsedFile { } function makeEmptyIndexes(): ScopeResolutionIndexes { - return { - bindings: new Map(), - bindingAugmentations: new Map(), - imports: [], - scopeTree: { roots: [] }, - methodDispatch: new Map(), - sccs: [], - } as unknown as ScopeResolutionIndexes; + return finalizeScopeModel([]); } function boundTypeOf(parsed: ParsedFile, variableName: string): string | undefined { diff --git a/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts b/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts index 40ba4a332..bb41357b7 100644 --- a/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts @@ -18,7 +18,6 @@ import * as importResolverUtils from '../../../../src/core/ingestion/import-reso import { typescriptScopeResolver } from '../../../../src/core/ingestion/languages/typescript/scope-resolver.js'; import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js'; import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; -import { SupportedLanguages } from 'gitnexus-shared'; function importsFor(src: string): ParsedImport[] { const matches = emitTsScopeCaptures(src, 'test.ts'); @@ -116,17 +115,17 @@ describe('interpretTsImport — static imports', () => { expect(imps).toHaveLength(3); expect(imps.map((i) => i.kind)).toEqual(['alias', 'named', 'alias']); - const def = imps.find((i) => i.localName === 'D'); + const def = imps.find((i) => 'localName' in i && i.localName === 'D'); expect(def).toMatchObject({ importedName: 'default', targetRaw: './m' }); - const named = imps.find((i) => i.localName === 'X'); + const named = imps.find((i) => 'localName' in i && i.localName === 'X'); expect(named).toMatchObject({ kind: 'named', importedName: 'X', targetRaw: './m', }); - const aliased = imps.find((i) => i.localName === 'Z'); + const aliased = imps.find((i) => 'localName' in i && i.localName === 'Z'); expect(aliased).toMatchObject({ kind: 'alias', importedName: 'Y', @@ -139,11 +138,11 @@ describe('interpretTsImport — static imports', () => { const imps = importsFor('import D, * as N from "./m";'); expect(imps).toHaveLength(2); - const def = imps.find((i) => i.localName === 'D'); + const def = imps.find((i) => 'localName' in i && i.localName === 'D'); expect(def?.kind).toBe('alias'); expect((def as { importedName: string }).importedName).toBe('default'); - const ns = imps.find((i) => i.localName === 'N'); + const ns = imps.find((i) => 'localName' in i && i.localName === 'N'); expect(ns?.kind).toBe('namespace'); expect((ns as { importedName: string }).importedName).toBe('./m'); }); @@ -523,19 +522,14 @@ describe('resolveTsImportTarget — standard suffix + alias resolution', () => { expect(result).toBe(null); }); - it('switches extensions when language=JavaScript', () => { + it('resolves JavaScript extensions from a JavaScript source file', () => { const parsed: ParsedImport = { kind: 'named', localName: 'X', importedName: 'X', targetRaw: './a', }; - const result = resolveTsImportTarget( - parsed, - ctx('src/main.js', ['src/main.js', 'src/a.js'], { - language: SupportedLanguages.JavaScript, - }), - ); + const result = resolveTsImportTarget(parsed, ctx('src/main.js', ['src/main.js', 'src/a.js'])); expect(result).toBe('src/a.js'); }); diff --git a/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts b/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts index 5661467fb..99301bec6 100644 --- a/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts +++ b/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts @@ -465,25 +465,47 @@ describe('classifyReceiverShape', () => { }); it('reports no-chain for a chain with no steps', () => { - expect(classifyReceiverShape({ steps: [] })).toBe('no-chain'); + expect( + classifyReceiverShape({ baseReceiverName: 'receiver', truncated: false, steps: [] }), + ).toBe('no-chain'); }); it('reports chain-call when every step is a call', () => { - expect(classifyReceiverShape({ steps: [{ kind: 'call' }, { kind: 'call' }] })).toBe( - 'chain-call', - ); + expect( + classifyReceiverShape({ + baseReceiverName: 'receiver', + truncated: false, + steps: [ + { kind: 'call', name: 'method' }, + { kind: 'call', name: 'method' }, + ], + }), + ).toBe('chain-call'); }); it('reports chain-field when every step is a field', () => { - expect(classifyReceiverShape({ steps: [{ kind: 'field' }] })).toBe('chain-field'); + expect( + classifyReceiverShape({ + baseReceiverName: 'receiver', + truncated: false, + steps: [{ kind: 'field', name: 'property' }], + }), + ).toBe('chain-field'); }); // The distinction that makes the census actionable: a mixed chain fails for // different reasons than a pure one, so collapsing it into either bucket // would misattribute the population a fix has to target. it('reports chain-mixed when the chain interleaves calls and fields', () => { - expect(classifyReceiverShape({ steps: [{ kind: 'call' }, { kind: 'field' }] })).toBe( - 'chain-mixed', - ); + expect( + classifyReceiverShape({ + baseReceiverName: 'receiver', + truncated: false, + steps: [ + { kind: 'call', name: 'method' }, + { kind: 'field', name: 'property' }, + ], + }), + ).toBe('chain-mixed'); }); }); diff --git a/gitnexus/test/unit/scope-resolution/walkers-augmentations.test.ts b/gitnexus/test/unit/scope-resolution/walkers-augmentations.test.ts index a4dbb0e5b..60bbadb87 100644 --- a/gitnexus/test/unit/scope-resolution/walkers-augmentations.test.ts +++ b/gitnexus/test/unit/scope-resolution/walkers-augmentations.test.ts @@ -72,13 +72,14 @@ function scope(id: ScopeId, bindings = new Map()) function indexesForScopeLookup( moduleScope: Scope, augmented: Map, + bindings: ScopeResolutionIndexes['bindings'] = new Map(), ): ScopeResolutionIndexes { const scopeTree = { getScope: (id: ScopeId) => (id === moduleScope.id ? moduleScope : undefined), } as unknown as ScopeTree; return { scopeTree, - bindings: new Map(), + bindings, bindingAugmentations: new Map([[moduleScope.id, augmented]]), } as unknown as ScopeResolutionIndexes; } @@ -225,8 +226,11 @@ describe('walker helpers read bindingAugmentations', () => { origin: 'import', visibility: 'static-member-import', } as BindingRef; - const indexes = indexesForScopeLookup(moduleScope, new Map([['callMe', [staticImport]]])); - indexes.bindings.set(SCOPE, new Map([['callMe', [finalized]]])); + const indexes = indexesForScopeLookup( + moduleScope, + new Map([['callMe', [staticImport]]]), + new Map([[SCOPE, new Map([['callMe', [finalized]]])]]), + ); const candidates = findAllCallableBindingCandidatesInScope(SCOPE, 'callMe', indexes); diff --git a/gitnexus/test/unit/semantic-search-ready.test.ts b/gitnexus/test/unit/semantic-search-ready.test.ts index 0c2ad3f17..682f912d2 100644 --- a/gitnexus/test/unit/semantic-search-ready.test.ts +++ b/gitnexus/test/unit/semantic-search-ready.test.ts @@ -15,7 +15,7 @@ vi.mock('../../src/core/embeddings/embedder.js', () => ({ })); vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ - loadVectorExtension: (...args: unknown[]) => loadVectorExtensionMock(...args), + loadVectorExtension: loadVectorExtensionMock, createVectorIndex: vi.fn(), })); diff --git a/gitnexus/test/unit/server-api-repo-resolution.test.ts b/gitnexus/test/unit/server-api-repo-resolution.test.ts index 66d0c0b2c..aaea0ef8a 100644 --- a/gitnexus/test/unit/server-api-repo-resolution.test.ts +++ b/gitnexus/test/unit/server-api-repo-resolution.test.ts @@ -142,7 +142,7 @@ describe('resolveOmittedRepoSelection', () => { const second = entry({ name: 'beta', path: '/tmp/beta', storagePath: '/tmp/beta/.gitnexus' }); const result = resolveOmittedRepoSelection([first, second]); expect(result.ok).toBe(false); - if (result.ok) return; + if (result.ok !== false) throw new Error('expected ambiguous repository selection'); expect(result.status).toBe(400); expect(result.error).toMatch(/Multiple repositories indexed/); expect(result.error).toContain('alpha'); diff --git a/gitnexus/test/unit/server.test.ts b/gitnexus/test/unit/server.test.ts index da5c37d90..6d7e4f048 100644 --- a/gitnexus/test/unit/server.test.ts +++ b/gitnexus/test/unit/server.test.ts @@ -16,6 +16,7 @@ import { describe, it, expect, vi } from 'vitest'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { createMCPServer, installSignalShutdown, @@ -60,7 +61,7 @@ async function callToolThroughServer( try { await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); - const response = await client.callTool({ name, arguments: args }); + const response = CallToolResultSchema.parse(await client.callTool({ name, arguments: args })); const text = response.content.find((item) => item.type === 'text')?.text; if (typeof text !== 'string') throw new Error('Expected an MCP text response'); return { text, isError: response.isError === true }; diff --git a/gitnexus/test/unit/setup-codex.test.ts b/gitnexus/test/unit/setup-codex.test.ts index 10a19e38c..a30badf50 100644 --- a/gitnexus/test/unit/setup-codex.test.ts +++ b/gitnexus/test/unit/setup-codex.test.ts @@ -14,7 +14,7 @@ const execFileMock = vi.fn((...args: any[]) => { } }); -const execFileSyncMock = vi.fn(() => { +const execFileSyncMock = vi.fn((): string => { throw new Error('not found'); }); diff --git a/gitnexus/test/unit/setup-jsonc.test.ts b/gitnexus/test/unit/setup-jsonc.test.ts index d84b9a41b..a06c1f5bb 100644 --- a/gitnexus/test/unit/setup-jsonc.test.ts +++ b/gitnexus/test/unit/setup-jsonc.test.ts @@ -15,7 +15,7 @@ const execFileMock = vi.fn((...args: any[]) => { } }); -const execFileSyncMock = vi.fn(() => { +const execFileSyncMock = vi.fn((): string => { throw new Error('not found'); }); diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index 942211ae3..e9d0a5873 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -23,7 +23,7 @@ const execFileMock = vi.fn((...args: any[]) => { // By default, execFileSync throws (simulating `which gitnexus` not found) // so getMcpEntry() falls back to the npx path. -const execFileSyncMock = vi.fn(() => { +const execFileSyncMock = vi.fn((): string => { throw new Error('not found'); }); diff --git a/gitnexus/test/unit/shard-balance.test.ts b/gitnexus/test/unit/shard-balance.test.ts index ff2feb372..9b2f9f402 100644 --- a/gitnexus/test/unit/shard-balance.test.ts +++ b/gitnexus/test/unit/shard-balance.test.ts @@ -7,8 +7,8 @@ import { describe, it, expect } from 'vitest'; import { assignShards, specWeight } from '../helpers/shard-balance.js'; -const key = (s: { id: string }) => s.id; -const weight = (s: { w: number }) => s.w; +const key = (s: { id: string; w: number }) => s.id; +const weight = (s: { id: string; w: number }) => s.w; const make = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `f${i}`, w: (i % 5) + 1 })); describe('assignShards', () => { diff --git a/gitnexus/test/unit/skill-gen.test.ts b/gitnexus/test/unit/skill-gen.test.ts index 57167ce20..d21225a55 100644 --- a/gitnexus/test/unit/skill-gen.test.ts +++ b/gitnexus/test/unit/skill-gen.test.ts @@ -11,7 +11,8 @@ import path from 'path'; import os from 'os'; import { generateSkillFiles } from '../../src/cli/skill-gen.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import type { GraphNode, GraphRelationship, KnowledgeGraph } from '../../src/core/graph/types.js'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../src/core/graph/types.js'; import type { CommunityNode, CommunityMembership, @@ -125,6 +126,15 @@ function buildPipelineResult(opts: { ? opts.processes.reduce((s, p) => s + p.stepCount, 0) / opts.processes.length : 0, entryPointsFound: 0, + truncation: { + truncated: false, + entryPointCandidatesDropped: 0, + entryPointsUnexplored: 0, + walksCutByBudget: 0, + tracesDepthCapped: 0, + calleesDropped: 0, + processesDropped: 0, + }, }, } : undefined; @@ -135,6 +145,11 @@ function buildPipelineResult(opts: { totalFileCount: 0, communityResult, processResult, + resolutionOutcomes: [], + usedWorkerPool: false, + reparsedFileCount: 0, + scopeExtractionFailures: [], + unavailableScopeLanguageFiles: 0, }; } @@ -627,6 +642,11 @@ describe('generateSkillFiles — return values', () => { stats: { totalCommunities: 1, modularity: 0.5, nodesProcessed: 4 }, }, processResult: undefined, + resolutionOutcomes: [], + usedWorkerPool: false, + reparsedFileCount: 0, + scopeExtractionFailures: [], + unavailableScopeLanguageFiles: 0, }; const result = await generateSkillFiles(tmpDir, 'TestProject', pipeline); diff --git a/gitnexus/test/unit/spring-destinations-phase.test.ts b/gitnexus/test/unit/spring-destinations-phase.test.ts index 44fb534a6..a46d7ec4c 100644 --- a/gitnexus/test/unit/spring-destinations-phase.test.ts +++ b/gitnexus/test/unit/spring-destinations-phase.test.ts @@ -1,3 +1,4 @@ +import type { JavaAnnotationSyntaxFact } from '../../src/core/ingestion/languages/java/spring-di.js'; import { afterAll, beforeEach, describe, expect, it } from 'vitest'; import { mkdtemp, writeFile, symlink, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -31,6 +32,19 @@ import { generateId } from '../../src/lib/utils.js'; const OWNER_RANGE = { startLine: 4, startCol: 4, endLine: 6, endCol: 5 } as const; +function annotation( + fact: Omit, +): JavaAnnotationSyntaxFact { + const args = fact.args + ?.map((arg) => (arg.name ? `${arg.name} = ${arg.text}` : arg.text)) + .join(', '); + return { + ...fact, + line: OWNER_RANGE.startLine - 1, + text: `@${fact.name}${args === undefined ? '' : `(${args})`}`, + }; +} + function callableNode(graph: KnowledgeGraph, filePath: string, name: string): void { graph.addNode({ id: generateId('Method', `${filePath}:${name}`), @@ -87,10 +101,13 @@ describe('springDestinations phase', () => { ownerFilePath: filePath, ownerRange: OWNER_RANGE, annotations: [ - { name: 'KafkaListener', args: [{ name: 'topicPattern', text: '"orders.*"' }] }, - { name: 'KafkaListener', args: [{ name: 'topics', text: '{}' }] }, - { name: 'KafkaListener', args: [{ name: 'groupId', text: '"g"' }] }, - { name: 'KafkaListener' }, + annotation({ + name: 'KafkaListener', + args: [{ name: 'topicPattern', text: '"orders.*"' }], + }), + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '{}' }] }), + annotation({ name: 'KafkaListener', args: [{ name: 'groupId', text: '"g"' }] }), + annotation({ name: 'KafkaListener' }), ], }, ]); @@ -137,7 +154,10 @@ describe('springDestinations phase', () => { ownerFilePath: filePath, ownerRange: OWNER_RANGE, annotations: [ - { name: 'KafkaListener', args: [{ name: 'topics', text: '"${app.topic}"' }] }, + annotation({ + name: 'KafkaListener', + args: [{ name: 'topics', text: '"${app.topic}"' }], + }), ], }, ]); @@ -258,14 +278,14 @@ describe('springDestinations phase', () => { ownerScopeId: `${filePath}#first` as never, ownerFilePath: filePath, annotations: [ - { name: 'KafkaListener', args: [{ name: 'topics', text: '"${app.topic}"' }] }, + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"${app.topic}"' }] }), ], }, { ownerScopeId: `${filePath}#second` as never, ownerFilePath: filePath, annotations: [ - { name: 'KafkaListener', args: [{ name: 'topics', text: '"${app.topic}"' }] }, + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"${app.topic}"' }] }), ], }, ]); @@ -296,7 +316,10 @@ describe('springDestinations phase', () => { ownerFilePath: filePath, ownerRange: OWNER_RANGE, annotations: [ - { name: 'KafkaListener', args: [{ name: 'topics', text: `"\${${key}:events}"` }] }, + annotation({ + name: 'KafkaListener', + args: [{ name: 'topics', text: `"\${${key}:events}"` }], + }), ], }, ]); @@ -329,10 +352,10 @@ describe('springDestinations phase', () => { ownerFilePath: filePath, ownerRange: OWNER_RANGE, annotations: [ - { + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"#{@kafkaProps.ordersTopic}"' }], - }, + }), ], }, ]); @@ -361,7 +384,9 @@ describe('springDestinations phase', () => { ownerScopeId: `${filePath}#consume` as never, ownerFilePath: filePath, ownerRange: OWNER_RANGE, - annotations: [{ name: 'KafkaListener', args: [{ name: 'topics', text: '"${}"' }] }], + annotations: [ + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"${}"' }] }), + ], }, ]); setJavaSpringMessageProducerFacts(filePath, []); @@ -406,7 +431,9 @@ describe('springDestinations phase', () => { ownerScopeId: `${listenerFile}#consume` as never, ownerFilePath: listenerFile, ownerRange: OWNER_RANGE, - annotations: [{ name: 'KafkaListener', args: [{ name: 'topics', text: '"orders"' }] }], + annotations: [ + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"orders"' }] }), + ], }, ]); setJavaSpringMessageProducerFacts(listenerFile, []); @@ -417,7 +444,9 @@ describe('springDestinations phase', () => { ownerScopeId: `${strangerFile}#consume` as never, ownerFilePath: strangerFile, ownerRange: OWNER_RANGE, - annotations: [{ name: 'RabbitListener', args: [{ name: 'queues', text: '"orders"' }] }], + annotations: [ + annotation({ name: 'RabbitListener', args: [{ name: 'queues', text: '"orders"' }] }), + ], }, ]); setJavaSpringMessageProducerFacts(strangerFile, []); @@ -483,7 +512,9 @@ describe('springDestinations phase', () => { ownerScopeId: `${filePath}#consume` as never, ownerFilePath: filePath, ownerRange: OWNER_RANGE, - annotations: [{ name: 'RabbitListener', args: [{ name: 'queues', text: '"orders"' }] }], + annotations: [ + annotation({ name: 'RabbitListener', args: [{ name: 'queues', text: '"orders"' }] }), + ], }, ]); setJavaSpringMessageProducerFacts(filePath, [ @@ -535,7 +566,9 @@ describe('springDestinations phase', () => { ownerScopeId: `${filePath}#consume` as never, ownerFilePath: filePath, ownerRange: OWNER_RANGE, - annotations: [{ name: 'KafkaListener', args: [{ name: 'topics', text: '"orders"' }] }], + annotations: [ + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"orders"' }] }), + ], }, ]); setJavaSpringMessageProducerFacts(filePath, [ @@ -571,8 +604,8 @@ describe('springDestinations phase', () => { ownerFilePath: filePath, ownerRange: OWNER_RANGE, annotations: [ - { name: 'RabbitListener', args: [{ name: 'queues', text: '"orders"' }] }, - { name: 'KafkaListener', args: [{ name: 'topics', text: '"shipments"' }] }, + annotation({ name: 'RabbitListener', args: [{ name: 'queues', text: '"orders"' }] }), + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"shipments"' }] }), ], }, ]); @@ -834,7 +867,7 @@ operations: ownerFilePath: filePath, ownerRange: OWNER_RANGE, annotations: [ - { name: 'KafkaListener', args: [{ name: 'topics', text: '"${app.topic}"' }] }, + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"${app.topic}"' }] }), ], }, ]); diff --git a/gitnexus/test/unit/stream-graph-emit-config.test.ts b/gitnexus/test/unit/stream-graph-emit-config.test.ts index 878698b8c..96c901806 100644 --- a/gitnexus/test/unit/stream-graph-emit-config.test.ts +++ b/gitnexus/test/unit/stream-graph-emit-config.test.ts @@ -1,3 +1,4 @@ +import type { GraphNode } from 'gitnexus-shared'; /** * Streamed structural graph emit — config gate and pruner integration (#2680). * @@ -98,7 +99,7 @@ describe('buildPhaseList under streamGraphEmit', () => { // The sink answers a complete relationship read, so these phases work // unchanged. If this ever regresses to filtering them out, streaming can no // longer be the default. - const streamed = names({ streamGraphEmit: true, pdg: true, force: true }); + const streamed = names({ streamGraphEmit: true, pdg: true }); expect(streamed).toContain('communities'); expect(streamed).toContain('processes'); @@ -107,7 +108,7 @@ describe('buildPhaseList under streamGraphEmit', () => { }); it('keeps mro and di, whose reads are all in the retained set', () => { - const streamed = names({ streamGraphEmit: true, pdg: true, force: true }); + const streamed = names({ streamGraphEmit: true, pdg: true }); expect(streamed).toContain('mro'); expect(streamed).toContain('di'); @@ -119,7 +120,7 @@ describe('buildPhaseList under streamGraphEmit', () => { it('leaves the phase list untouched when the flag is off', () => { // Guards the default path: the gating predicates must not filter anything // for existing (flag-off) users. - const withPdg = names({ pdg: true, force: true }); + const withPdg = names({ pdg: true }); expect(withPdg).toContain('communities'); expect(withPdg).toContain('processes'); diff --git a/gitnexus/test/unit/symbol-identity-validation.test.ts b/gitnexus/test/unit/symbol-identity-validation.test.ts index 7fe380a20..230f78a9b 100644 --- a/gitnexus/test/unit/symbol-identity-validation.test.ts +++ b/gitnexus/test/unit/symbol-identity-validation.test.ts @@ -12,12 +12,12 @@ const { lbugMocks } = vi.hoisted(() => ({ })); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); diff --git a/gitnexus/test/unit/taint/path-codec.test.ts b/gitnexus/test/unit/taint/path-codec.test.ts index a7b891844..70005df8e 100644 --- a/gitnexus/test/unit/taint/path-codec.test.ts +++ b/gitnexus/test/unit/taint/path-codec.test.ts @@ -29,7 +29,7 @@ function unescapeCSVField(cell: string): string { const roundTrip = (hops: readonly TaintPathHopInput[]) => { const { reason } = encodeTaintPath(hops); const decoded = decodeTaintPath(reason); - if (!decoded.ok) throw new Error(`decode failed: ${decoded.error}`); + if (decoded.ok === false) throw new Error(`decode failed: ${decoded.error}`); return { reason, decoded }; }; @@ -293,7 +293,7 @@ describe('typed parse failures (never a throw)', () => { it(`fails typed on ${label}`, () => { const decoded = decodeTaintPath(input); expect(decoded.ok).toBe(false); - if (!decoded.ok) expect(decoded.error.length).toBeGreaterThan(0); + if (decoded.ok === false) expect(decoded.error.length).toBeGreaterThan(0); }); } diff --git a/gitnexus/test/unit/taint/propagate.test.ts b/gitnexus/test/unit/taint/propagate.test.ts index a1bb61e3d..3ab469240 100644 --- a/gitnexus/test/unit/taint/propagate.test.ts +++ b/gitnexus/test/unit/taint/propagate.test.ts @@ -36,8 +36,15 @@ import { computeTaintFlows, type FunctionTaintResult, type TaintLimits, + type TaintSourceOccurrence, } from '../../../src/core/ingestion/taint/propagate.js'; +function sourceProperty(source: TaintSourceOccurrence): string { + expect(source.type).toBe('member-read'); + if (source.type !== 'member-read') throw new Error('Expected a member-read source'); + return source.property; +} + /** The single binding index for `name` (throws when shadowed/ambiguous). */ function bindingIdx(cfg: FunctionCfg, name: string): number { const idxs = (cfg.bindings ?? []).map((b, i) => (b.name === name ? i : -1)).filter((i) => i >= 0); @@ -153,7 +160,7 @@ describe('rule (b) — statement-local findings', () => { expect(r.findings).toHaveLength(1); const f = r.findings[0]; expect(f.sinkKind).toBe('command-injection'); - expect(f.source.property).toBe('body'); + expect(sourceProperty(f.source)).toBe('body'); expect(f.source.point.line).toBe(2); expect(f.sink.point.line).toBe(2); expect(f.sink.argIndex).toBe(0); @@ -168,14 +175,14 @@ describe('rule (b) — statement-local findings', () => { }; const r = analyze(`function f(req) { exec(req.body, req.query); }`, { spec }); expect(r.findings).toHaveLength(2); - const ids = r.findings.map((f) => `${f.source.property}@arg${f.sink.argIndex}`); + const ids = r.findings.map((f) => `${sourceProperty(f.source)}@arg${f.sink.argIndex}`); expect(ids).toEqual(['body@arg0', 'query@arg1']); }); it('exec(req.body.toString()) → finding via the statement-local rule', () => { const r = analyze(`function f(req) { exec(req.body.toString()); }`); expect(r.findings).toHaveLength(1); - expect(r.findings[0].source.property).toBe('body'); + expect(sourceProperty(r.findings[0].source)).toBe('body'); }); it('a source read at an UNREGISTERED sink position produces no finding', () => { @@ -194,7 +201,7 @@ describe('rule (a) — worklist over def→use facts', () => { }`); expect(r.findings).toHaveLength(1); expect(hopSummary(r)).toEqual(['b@2', 'b@3']); - expect(r.findings[0].source.property).toBe('body'); + expect(sourceProperty(r.findings[0].source)).toBe('body'); expect(r.findings[0].sink.point.line).toBe(3); }); @@ -491,7 +498,7 @@ describe('multi-source identity — distinct sources do not merge at one def', ( spec: MULTI, }); expect(r.findings).toHaveLength(2); - const props = r.findings.map((f) => f.source.property).sort(); + const props = r.findings.map((f) => sourceProperty(f.source)).sort(); expect(props).toEqual(['body', 'query']); }); @@ -691,7 +698,7 @@ describe('caps — deterministic truncation (R6 substrate)', () => { expect(r.findings).toHaveLength(2); expect(r.droppedFindings).toBe(2); // deterministic prefix of the sorted order: statement order - expect(r.findings.map((f) => f.source.property)).toEqual(['body', 'query']); + expect(r.findings.map((f) => sourceProperty(f.source))).toEqual(['body', 'query']); }); it('without a cap all findings emit and droppedFindings is 0', () => { diff --git a/gitnexus/test/unit/taint/taint-emit.test.ts b/gitnexus/test/unit/taint/taint-emit.test.ts index c6d8f734a..dda9e1cb9 100644 --- a/gitnexus/test/unit/taint/taint-emit.test.ts +++ b/gitnexus/test/unit/taint/taint-emit.test.ts @@ -189,7 +189,7 @@ function handler(req: { body: string; query: string }) { expect(new Set(tainted.map((t) => t.id)).size).toBe(2); const variables = tainted.map((t) => { const d = decodeTaintPath(t.reason); - if (!d.ok) throw new Error(d.error); + if (d.ok === false) throw new Error(d.error); return d.hops[d.hops.length - 1].variable; }); expect(variables.sort()).toEqual(['a', 'b']); diff --git a/gitnexus/test/unit/tool-process-linking.test.ts b/gitnexus/test/unit/tool-process-linking.test.ts index d28dd9329..0c7203db2 100644 --- a/gitnexus/test/unit/tool-process-linking.test.ts +++ b/gitnexus/test/unit/tool-process-linking.test.ts @@ -127,7 +127,7 @@ describe('Tool handler and process linking phases', () => { await processesPhase.execute( makeCtx(graph), - new Map([ + new Map>([ ['structure', phaseResult('structure', { totalFiles: 1 })], ['communities', phaseResult('communities', { communityResult: { memberships: [] } })], ['routes', phaseResult('routes', { routeRegistry: new Map() })], diff --git a/gitnexus/test/unit/trace-bfs.test.ts b/gitnexus/test/unit/trace-bfs.test.ts index c76ef9a01..bf287ec1c 100644 --- a/gitnexus/test/unit/trace-bfs.test.ts +++ b/gitnexus/test/unit/trace-bfs.test.ts @@ -17,12 +17,12 @@ const { lbugMocks } = vi.hoisted(() => ({ })); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); diff --git a/gitnexus/test/unit/trpc-entry-point-scoring.test.ts b/gitnexus/test/unit/trpc-entry-point-scoring.test.ts index 03b1de78c..f6a81558e 100644 --- a/gitnexus/test/unit/trpc-entry-point-scoring.test.ts +++ b/gitnexus/test/unit/trpc-entry-point-scoring.test.ts @@ -1,3 +1,4 @@ +import { SupportedLanguages } from 'gitnexus-shared'; import { describe, it, expect } from 'vitest'; import { calculateEntryPointScore } from '../../src/core/ingestion/entry-point-scoring.js'; @@ -5,7 +6,7 @@ describe('tRPC entry-point scoring', () => { it('scores a T3 router procedure higher than the same name on a utility file', () => { const t3 = calculateEntryPointScore( 'setSettings', - 'typescript', + SupportedLanguages.TypeScript, true, 0, 3, @@ -13,7 +14,7 @@ describe('tRPC entry-point scoring', () => { ); const util = calculateEntryPointScore( 'setSettings', - 'typescript', + SupportedLanguages.TypeScript, true, 0, 3, @@ -28,7 +29,14 @@ describe('tRPC entry-point scoring', () => { it('still applies utility-pattern to non-accessor helpers in a T3 router file', () => { const routerPath = 'src/server/api/routers/settings.ts'; for (const name of ['formatDate', '_internal', 'parseInput'] as const) { - const result = calculateEntryPointScore(name, 'typescript', true, 0, 3, routerPath); + const result = calculateEntryPointScore( + name, + SupportedLanguages.TypeScript, + true, + 0, + 3, + routerPath, + ); expect(result.reasons).toContain('utility-pattern'); expect(result.reasons).toContain('framework:trpc-router'); } @@ -37,7 +45,7 @@ describe('tRPC entry-point scoring', () => { it('skips utility-pattern for accessor procedures on a T3 JavaScript router', () => { const result = calculateEntryPointScore( 'setSettings', - 'javascript', + SupportedLanguages.JavaScript, true, 0, 3, @@ -50,7 +58,7 @@ describe('tRPC entry-point scoring', () => { it('still applies utility-pattern to non-accessor helpers on a T3 JavaScript router', () => { const result = calculateEntryPointScore( 'formatDate', - 'javascript', + SupportedLanguages.JavaScript, true, 0, 3, @@ -63,7 +71,7 @@ describe('tRPC entry-point scoring', () => { it('does not crash on a .js router path whose framework detection returns null', () => { const jsResult = calculateEntryPointScore( 'settingsRouter', - 'javascript', + SupportedLanguages.JavaScript, true, 0, 3, diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 785c1ec83..62830ccac 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { buildTypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js'; import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js'; -import { type SymbolDefinition } from 'gitnexus-shared'; +import { SupportedLanguages, type SymbolDefinition } from 'gitnexus-shared'; import { createSemanticModel, type SemanticModel, @@ -29,7 +29,7 @@ let Dart: unknown; try { Dart = requireVendoredGrammar('tree-sitter-dart'); const testParser = new Parser(); - testParser.setLanguage(Dart as Parser.Language); + testParser.setLanguage(Dart); } catch { Dart = null; } @@ -38,27 +38,27 @@ let Swift: unknown; try { Swift = requireVendoredGrammar('tree-sitter-swift'); const testParser = new Parser(); - testParser.setLanguage(Swift as Parser.Language); + testParser.setLanguage(Swift); } catch { Swift = null; } const parser = new Parser(); -const parse = (code: string, lang: any) => { +const parse = (code: string, lang: unknown) => { parser.setLanguage(lang); return parser.parse(code); }; const parseDart = (code: string) => { if (!Dart) throw new Error('tree-sitter-dart not available'); - parser.setLanguage(Dart as Parser.Language); + parser.setLanguage(Dart); return parser.parse(code); }; const parseSwift = (code: string) => { if (!Swift) throw new Error('tree-sitter-swift not available'); - parser.setLanguage(Swift as Parser.Language); + parser.setLanguage(Swift); return parser.parse(code); }; @@ -95,56 +95,56 @@ describe('buildTypeEnv', () => { describe('TypeScript', () => { it('extracts type from const declaration', () => { const tree = parse('const user: User = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); it('extracts type from let declaration', () => { const tree = parse('let repo: Repository;', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'repo')).toBe('Repository'); }); it('extracts type from function parameters', () => { const tree = parse('function save(user: User, repo: Repository) {}', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repository'); }); it('extracts type from arrow function parameters', () => { const tree = parse('const fn = (user: User) => user.save();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); it('ignores variables without type annotations', () => { const tree = parse('const x = 5; let y = "hello";', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatSize(typeEnv)).toBe(0); }); it('extracts type from nullable union User | null', () => { const tree = parse('const user: User | null = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); it('extracts type from optional union User | undefined', () => { const tree = parse('let user: User | undefined;', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); it('extracts type from triple nullable union User | null | undefined', () => { const tree = parse('const user: User | null | undefined = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); it('ignores non-nullable unions like User | Repo', () => { const tree = parse('const entity: User | Repo = getEntity();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'entity')).toBeUndefined(); }); }); @@ -162,7 +162,7 @@ describe('buildTypeEnv', () => { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repository'); }); @@ -176,7 +176,7 @@ describe('buildTypeEnv', () => { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repository'); }); @@ -190,7 +190,7 @@ describe('buildTypeEnv', () => { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -207,7 +207,7 @@ describe('buildTypeEnv', () => { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -222,7 +222,7 @@ describe('buildTypeEnv', () => { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -235,7 +235,7 @@ describe('buildTypeEnv', () => { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repository'); }); @@ -254,7 +254,7 @@ describe('buildTypeEnv', () => { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -270,7 +270,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -284,7 +284,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -298,7 +298,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -312,7 +312,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repo'); }); @@ -327,7 +327,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -341,7 +341,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); @@ -355,7 +355,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'sl')).toBe('User'); }); @@ -369,7 +369,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'm')).toBe('User'); }); @@ -384,7 +384,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -399,7 +399,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -411,7 +411,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); // Go parameter extraction depends on tree-sitter grammar structure // Parameters may or may not have 'name'/'type' fields }); @@ -427,7 +427,7 @@ describe('buildTypeEnv', () => { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -438,7 +438,7 @@ describe('buildTypeEnv', () => { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repository'); }); @@ -452,7 +452,7 @@ describe('buildTypeEnv', () => { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -460,19 +460,19 @@ describe('buildTypeEnv', () => { describe('Python', () => { it('extracts type from annotated assignment (PEP 484)', () => { const tree = parse('user: User = get_user()', Python); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); it('extracts type from standalone annotation without value (file scope)', () => { const tree = parse('active_user: User', Python); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'active_user')).toBe('User'); }); it('extracts type from function parameters', () => { const tree = parse('def process(user: User, repo: Repository): pass', Python); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); // Python uses typed_parameter nodes, check if they match }); @@ -484,7 +484,7 @@ describe('buildTypeEnv', () => { `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'name')).toBe('str'); expect(flatGet(typeEnv, 'age')).toBe('int'); }); @@ -496,7 +496,7 @@ describe('buildTypeEnv', () => { `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'repo')).toBe('UserRepo'); }); @@ -512,7 +512,7 @@ describe('buildTypeEnv', () => { `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'name')).toBe('str'); expect(flatGet(typeEnv, 'age')).toBe('int'); expect(flatGet(typeEnv, 'repo')).toBe('UserRepo'); @@ -533,7 +533,7 @@ def process(x): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'u')).toBe('User'); }); @@ -551,7 +551,7 @@ def process(x): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); // u is already bound from the annotation, pattern binding should not overwrite expect(flatGet(typeEnv, 'u')).toBe('User'); }); @@ -574,7 +574,7 @@ def process(x): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'r')).toBe('Repo'); }); @@ -590,7 +590,7 @@ def process(x): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); // No class_pattern child — should return undefined expect(flatGet(typeEnv, 'n')).toBeUndefined(); }); @@ -607,7 +607,7 @@ def process(x): `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -620,7 +620,7 @@ def process(x): `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -633,7 +633,7 @@ def process(x): `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -644,7 +644,7 @@ def process(x): `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repository'); }); @@ -661,7 +661,7 @@ def process(x): `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -677,7 +677,7 @@ def process(x): `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -690,7 +690,7 @@ def process(x): `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); // PHP parameter type extraction expect(flatGet(typeEnv, '$user')).toBe('User'); expect(flatGet(typeEnv, '$repo')).toBe('Repository'); @@ -704,7 +704,7 @@ class UserService { } }`; const tree = parse(code, PHP.php); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); // Find the call node ($this->save()) const calls: any[] = []; @@ -731,7 +731,7 @@ class User { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$repo')).toBe('UserRepo'); }); @@ -744,7 +744,7 @@ class UserService { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$repo')).toBe('UserRepo'); }); @@ -757,7 +757,7 @@ class UserService { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$name')).toBe('string'); }); @@ -774,7 +774,7 @@ function create($repo, $name) { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$repo')).toBe('UserRepo'); expect(flatGet(typeEnv, '$name')).toBe('string'); }); @@ -792,7 +792,7 @@ function process($repo, $name) { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$repo')).toBe('UserRepo'); expect(flatGet(typeEnv, '$name')).toBe('string'); }); @@ -812,7 +812,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(flatGet(typeEnv, 'repo')).toBe('UserRepo'); expect(flatGet(typeEnv, 'name')).toBe('String'); }); @@ -826,7 +826,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -839,7 +839,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(flatGet(typeEnv, 'name')).toBe('String'); }); @@ -852,7 +852,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(flatGet(typeEnv, 'value')).toBeUndefined(); }); @@ -865,7 +865,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(flatSize(typeEnv)).toBe(0); }); @@ -881,7 +881,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(flatGet(typeEnv, 'name')).toBe('String'); }); @@ -894,7 +894,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(flatGet(typeEnv, 'users')).toBe('Array'); }); }); @@ -912,7 +912,7 @@ class User extends BaseModel { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const calls: any[] = []; function findCalls(node: any) { @@ -942,7 +942,7 @@ class User extends BaseModel { } }`; const tree = parse(code, Java); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); const calls: any[] = []; function findCalls(node: any) { @@ -968,7 +968,7 @@ class User(BaseModel): return True `; const tree = parse(code, Python); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); const calls: any[] = []; function findCalls(node: any) { @@ -991,7 +991,7 @@ class Standalone { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // No calls in this code — test the resolution function directly // by using the class body as the context node @@ -1009,7 +1009,7 @@ object AppConfig { } }`; const tree = parse(code, Kotlin); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); const calls: any[] = []; function findCalls(node: any) { @@ -1036,7 +1036,7 @@ object AppConfig { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Each function has its own scope for 'user' (keyed by funcName@startIndex) // Find the scope keys that start with handleUser/handleRepo @@ -1058,7 +1058,7 @@ function handleRepo(user: Repo) { user.save(); }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Find the call nodes inside each function const calls: any[] = []; @@ -1090,7 +1090,7 @@ class RepoService { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Find the call nodes inside each process method const calls: any[] = []; @@ -1120,7 +1120,7 @@ class RepoService { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // config is at file-level scope const fileScope = typeEnv.fileScope(); @@ -1150,7 +1150,7 @@ class RepoService { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // The typed variable is captured expect(flatGet(typeEnv, 'user')).toBe('User'); // Destructured bindings (name, email) would need type inference to resolve @@ -1168,53 +1168,32 @@ class RepoService { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Complex type annotation (object type) — extractSimpleTypeName returns undefined expect(flatSize(typeEnv)).toBe(0); }); }); describe('destructured call results', () => { - // Minimal mock SemanticModel for call-result return type lookup - // (SM-21 inversion — buildTypeEnv takes a SemanticModel via `model:`). - const makeSymbolTable = (callables: Array<{ name: string; returnType?: string }>) => ({ - types: { - lookupClassByName: () => [], - lookupClassByQualifiedName: () => [], - lookupImplByName: () => [], - }, - methods: { - lookupMethodByOwner: () => undefined, - lookupMethodByName: () => [], - }, - fields: { - lookupFieldByOwner: () => undefined, - }, - symbols: { - add: () => {}, - lookupExact: () => undefined, - lookupExactFull: () => undefined, - lookupExactAll: () => [], - lookupCallableByName: (name: string) => - callables - .filter((c) => c.name === name) - .map((c) => ({ - nodeId: 'n1', - filePath: 'src.ts', - type: 'Function' as const, - returnType: c.returnType, - })), - getFiles: () => [][Symbol.iterator](), - getStats: () => ({ fileCount: 0 }), - }, - }); + // Register only the callable return types needed by these fixtures. + const makeSymbolTable = ( + callables: Array<{ name: string; returnType?: string }>, + ): SemanticModel => { + const model = createSemanticModel(); + for (const [index, callable] of callables.entries()) { + model.symbols.add('src.ts', callable.name, `function:${index}`, 'Function', { + returnType: callable.returnType, + }); + } + return model; + }; it('emits callResult + fieldAccess items for const { x } = fn()', () => { const symbolTable = makeSymbolTable([{ name: 'getUser', returnType: 'User' }]); const tree = parse('const { name } = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model: symbolTable }); // callResult resolves __destr_getUser_N → User - // fieldAccess resolves name via User's properties (no Property nodes in mock → undefined) + // fieldAccess resolves name via User's properties (no Property nodes in model → undefined) // But the callResult itself IS emitted — verify constructorBindings is still empty expect(typeEnv.constructorBindings).toEqual([]); }); @@ -1225,14 +1204,14 @@ class RepoService { 'async function f() { const { data } = await fetchData(); }', TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model: symbolTable }); expect(typeEnv.constructorBindings).toEqual([]); }); it('gracefully handles no return type (composable without annotation)', () => { const symbolTable = makeSymbolTable([{ name: 'useUserRole' }]); // no returnType const tree = parse('const { isMaker } = useUserRole();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model: symbolTable }); // No return type → callResult unresolved → fieldAccess unresolved expect(flatGet(typeEnv, 'isMaker')).toBeUndefined(); }); @@ -1240,7 +1219,7 @@ class RepoService { it('resolves destructured properties when return type has declared fields', () => { // importedReturnTypes provides the return type since no real SymbolTable const tree = parse('const { name } = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedReturnTypes: new Map([['getUser', 'User']]), }); // callResult resolves __destr_getUser_N → User via importedReturnTypes @@ -1264,7 +1243,7 @@ class RepoService { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // repo is resolved to Repo via Tier 1 (constructor inference) expect(flatGet(typeEnv, 'repo')).toBe('Repo'); // Destructured method call emits methodCallResult + fieldAccess @@ -1274,7 +1253,7 @@ class RepoService { it('handles renamed destructuring: const { address: addr } = fn()', () => { const tree = parse('const { address: addr } = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedReturnTypes: new Map([['getUser', 'User']]), }); // 'addr' should be the binding, not 'address' @@ -1294,53 +1273,53 @@ class RepoService { describe('TypeScript', () => { it('infers type from new expression when no annotation', () => { const tree = parse('const user = new User();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); it('prefers explicit annotation over constructor inference', () => { const tree = parse('const user: BaseUser = new User();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('BaseUser'); }); it('infers from namespaced constructor: new ns.Service()', () => { // extractSimpleTypeName handles member_expression via property_identifier const tree = parse('const svc = new ns.Service();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'svc')).toBe('Service'); }); it('infers type from new expression with as cast', () => { const tree = parse('const x = new User() as BaseUser;', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Unwraps as_expression to find the inner new_expression → User expect(flatGet(typeEnv, 'x')).toBe('User'); }); it('infers type from new expression with non-null assertion', () => { const tree = parse('const x = new User()!;', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Unwraps non_null_expression to find the inner new_expression → User expect(flatGet(typeEnv, 'x')).toBe('User'); }); it('infers type from double-cast (new X() as unknown as T)', () => { const tree = parse('const x = new User() as unknown as Admin;', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Unwraps nested as_expression to find inner new_expression → User expect(flatGet(typeEnv, 'x')).toBe('User'); }); it('ignores non-new assignments', () => { const tree = parse('const x = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatSize(typeEnv)).toBe(0); }); it('handles mixed annotated + unannotated declarators', () => { const tree = parse('const a: A = getA(), b = new B();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'a')).toBe('A'); expect(flatGet(typeEnv, 'b')).toBe('B'); }); @@ -1358,7 +1337,7 @@ class RepoService { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1373,7 +1352,7 @@ class RepoService { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1388,7 +1367,7 @@ class RepoService { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'x')).toBeUndefined(); }); }); @@ -1403,7 +1382,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1416,7 +1395,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'config')).toBe('Config'); }); @@ -1429,7 +1408,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // ::default() should be excluded from scanConstructorBinding just like ::new() // extractInitializer already resolves it, so a scanner binding would be redundant const defaultBinding = typeEnv.constructorBindings.find((b) => b.calleeName === 'default'); @@ -1445,7 +1424,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); const newBinding = typeEnv.constructorBindings.find((b) => b.calleeName === 'new'); expect(newBinding).toBeUndefined(); }); @@ -1460,7 +1439,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('BaseUser'); }); @@ -1473,7 +1452,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1489,7 +1468,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'instance')).toBe('User'); }); @@ -1505,7 +1484,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'cfg')).toBe('Config'); }); @@ -1518,7 +1497,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'x')).toBeUndefined(); }); @@ -1531,7 +1510,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); @@ -1544,7 +1523,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1557,7 +1536,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'config')).toBe('Config'); }); @@ -1570,7 +1549,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('BaseUser'); }); @@ -1586,7 +1565,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'fresh')).toBe('User'); }); @@ -1599,7 +1578,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'x')).toBeUndefined(); }); }); @@ -1616,7 +1595,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1631,7 +1610,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1646,7 +1625,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'item')).toBe('Config'); }); @@ -1661,7 +1640,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // Option is unwrapped to "User" in TypeEnv via NULLABLE_WRAPPER_TYPES. // extractPatternBinding maps `user` → "User" from the scopeEnv lookup for `opt`. expect(flatGet(typeEnv, 'user')).toBe('User'); @@ -1678,7 +1657,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // 'name' is a field of User — we don't know its type without field-type resolution expect(flatGet(typeEnv, 'name')).toBeUndefined(); // 'val' should still be extracted from the parameter annotation @@ -1696,7 +1675,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // scoped_type_identifier: Message::Data — extractSimpleTypeName returns "Data" expect(flatGet(typeEnv, 'msg')).toBe('Data'); }); @@ -1712,7 +1691,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // Option unwraps to User (nullable wrapper unwrapping) expect(flatGet(typeEnv, 'opt')).toBe('User'); expect(flatGet(typeEnv, 'user')).toBe('User'); @@ -1729,7 +1708,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // opt: Option → scopeEnv stores "User" (NULLABLE_WRAPPER_TYPES unwrapping) // extractPatternBinding maps user → opt's type → "User" expect(flatGet(typeEnv, 'user')).toBe('User'); @@ -1746,7 +1725,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // unknown_var is not in scopeEnv — conservative, produces no binding expect(flatGet(typeEnv, 'x')).toBeUndefined(); }); @@ -1762,7 +1741,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // SomeOtherVariant is not a known unwrap wrapper — no binding expect(flatGet(typeEnv, 'x')).toBeUndefined(); }); @@ -1782,7 +1761,7 @@ class RepoService { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1797,7 +1776,7 @@ class RepoService { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); // No pattern variable — b gets its declared type 'boolean', not 'User' expect(flatGet(typeEnv, 'b')).toBe('boolean'); }); @@ -1818,7 +1797,7 @@ class RepoService { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repo'); }); @@ -1832,7 +1811,7 @@ class RepoService { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$user')).toBe('User'); }); @@ -1848,7 +1827,7 @@ class RepoService { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$a')).toBe('Foo'); expect(flatGet(typeEnv, '$b')).toBe('Foo'); }); @@ -1865,7 +1844,7 @@ class RepoService { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$p')).toBe('Bar'); }); @@ -1876,7 +1855,7 @@ class RepoService { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$a')).toBeUndefined(); }); @@ -1887,7 +1866,7 @@ class RepoService { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$user')).toBeUndefined(); }); }); @@ -1902,7 +1881,7 @@ class RepoService { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1916,7 +1895,7 @@ class RepoService { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1929,7 +1908,7 @@ class RepoService { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1944,7 +1923,7 @@ class RepoService { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); // getUser is an identifier but NOT a known class — no inference expect(flatGet(typeEnv, 'x')).toBeUndefined(); }); @@ -1959,7 +1938,7 @@ class RepoService { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1973,7 +1952,7 @@ class RepoService { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'cfg')).toBe('Config'); }); @@ -1987,7 +1966,7 @@ class RepoService { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -2002,7 +1981,7 @@ class RepoService { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -2016,7 +1995,7 @@ class RepoService { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -2030,7 +2009,7 @@ class RepoService { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); // getUser is not a class name — should NOT produce a binding expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); @@ -2047,7 +2026,7 @@ class RepoService { // User is NOT defined in this file, but SemanticModel knows it's a Class const model = createSemanticModel(); model.symbols.add('models.kt', 'User', 'n1', 'Class'); - const typeEnv = buildTypeEnv(tree, 'kotlin', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin, { model }); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -2061,7 +2040,7 @@ class RepoService { Kotlin, ); const model = createSemanticModel(); - const typeEnv = buildTypeEnv(tree, 'kotlin', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin, { model }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2075,7 +2054,7 @@ class RepoService { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); // Tier 0 (explicit annotation) wins over Tier 1 (constructor inference) expect(flatGet(typeEnv, 'user')).toBe('BaseEntity'); }); @@ -2093,7 +2072,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -2108,7 +2087,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); // extractSimpleTypeName extracts "User" from attribute node "models.User" expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -2121,7 +2100,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); }); @@ -2145,7 +2124,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.py')], }), @@ -2161,7 +2140,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python, { model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); @@ -2175,7 +2154,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.py')], }), @@ -2192,7 +2171,7 @@ void run() { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.h')], }), @@ -2209,7 +2188,7 @@ void run() { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus, { model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); @@ -2224,7 +2203,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models/user.rb')], }), @@ -2241,7 +2220,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby, { model: makeClassLookupTable({ UserService: [createClassDef('UserService', 'Class', 'models/user_service.rb')], }), @@ -2258,7 +2237,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby, { model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); @@ -2273,7 +2252,7 @@ void run() { } `, ); - const typeEnv = buildTypeEnv(tree, 'dart', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Dart, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.dart')], }), @@ -2289,7 +2268,7 @@ void run() { } `, ); - const typeEnv = buildTypeEnv(tree, 'dart', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Dart, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.dart')], }), @@ -2305,7 +2284,7 @@ void run() { } `, ); - const typeEnv = buildTypeEnv(tree, 'dart', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Dart, { model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); @@ -2321,7 +2300,7 @@ fn run() { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust, { model: makeClassLookupTable({ UserService: [createClassDef('UserService', 'Struct', 'models.rs')], }), @@ -2338,7 +2317,7 @@ fn run() { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust, { model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'value')).toBeUndefined(); @@ -2353,7 +2332,7 @@ func run() { } `, ); - const typeEnv = buildTypeEnv(tree, 'swift', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Swift, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'Models/User.swift')], }), @@ -2369,7 +2348,7 @@ func run() { } `, ); - const typeEnv = buildTypeEnv(tree, 'swift', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Swift, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'Models/User.swift')], }), @@ -2385,7 +2364,7 @@ func run() { } `, ); - const typeEnv = buildTypeEnv(tree, 'swift', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Swift, { model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); @@ -2407,7 +2386,7 @@ function process(user: User) { ownerId: 'class:User', declaredType: 'Address', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model }); expect(flatGet(typeEnv, 'addr')).toBe('Address'); }); @@ -2421,7 +2400,7 @@ function process(user: User) { TypeScript.typescript, ); const model = createSemanticModel(); - const typeEnv = buildTypeEnv(tree, 'typescript', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model }); expect(flatGet(typeEnv, 'addr')).toBeUndefined(); }); @@ -2441,7 +2420,7 @@ function process(repo: Repo) { returnType: 'Profile', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2463,7 +2442,7 @@ function process(repo: Repo) { returnType: 'Profile', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); @@ -2488,7 +2467,7 @@ function process(repo: Repo) { returnType: 'Profile', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2511,7 +2490,7 @@ function process(repo: Repo) { returnType: 'Profile', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); @@ -2540,7 +2519,7 @@ function process(repo: Repo) { returnType: 'Profile', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2565,7 +2544,7 @@ function process(repo: Repo) { returnType: 'Profile', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2595,7 +2574,7 @@ function process(repo: Repo) { returnType: 'Admin', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); @@ -2624,7 +2603,7 @@ function process(repo: Repo) { returnType: 'Profile', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); @@ -2657,7 +2636,7 @@ function process(repo: Repo) { returnType: 'Admin', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); @@ -2679,7 +2658,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -2692,7 +2671,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'data')).toBeUndefined(); }); }); @@ -2701,7 +2680,7 @@ def main(): describe('edge cases', () => { it('returns empty map for code without type annotations', () => { const tree = parse('const x = 5;', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatSize(typeEnv)).toBe(0); }); @@ -2713,7 +2692,7 @@ def main(): `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Both declarations are at file level; last one wins expect(flatGet(typeEnv, 'x')).toBeDefined(); }); @@ -2732,7 +2711,7 @@ class User extends BaseModel { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const calls: any[] = []; function findCalls(node: any) { @@ -2759,7 +2738,7 @@ class User extends BaseModel { } }`; const tree = parse(code, Java); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); const calls: any[] = []; function findCalls(node: any) { @@ -2785,7 +2764,7 @@ class User(Model): super().save() `; const tree = parse(code, Python); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); const calls: any[] = []; function findCalls(node: any) { @@ -2811,7 +2790,7 @@ class User : BaseModel { } }`; const tree = parse(code, CSharp); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); const calls: any[] = []; function findCalls(node: any) { @@ -2840,7 +2819,7 @@ class User : BaseModel { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); // Should extract "HttpClient" from the scoped_identifier ns::HttpClient const binding = typeEnv.constructorBindings.find((b) => b.varName === 'client'); expect(binding).toBeDefined(); @@ -2857,7 +2836,7 @@ class User : BaseModel { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); // User() with known class resolves via extractInitializer, not constructor bindings expect(flatGet(typeEnv, 'user')).toBe('User'); // No unresolved bindings since User is locally known @@ -2875,7 +2854,7 @@ class User : BaseModel { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); // UnknownClass is not defined locally — should appear as unverified binding expect(flatGet(typeEnv, 'user')).toBeUndefined(); expect(typeEnv.constructorBindings.length).toBe(1); @@ -2892,7 +2871,7 @@ class User : BaseModel { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); // Explicit annotation resolves it — no unverified binding needed expect(flatGet(typeEnv, 'user')).toBe('User'); expect(typeEnv.constructorBindings.find((b) => b.varName === 'user')).toBeUndefined(); @@ -2905,7 +2884,7 @@ func run() { let user = User.init(name: "alice") } `); - const typeEnv = buildTypeEnv(tree, 'swift'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Swift); expect(flatGet(typeEnv, 'user')).toBeUndefined(); expect(typeEnv.constructorBindings).toEqual([ expect.objectContaining({ @@ -2924,7 +2903,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBeUndefined(); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('user'); @@ -2939,7 +2918,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('user'); expect(typeEnv.constructorBindings[0].calleeName).toBe('User'); @@ -2954,7 +2933,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBeUndefined(); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('user'); @@ -2971,7 +2950,7 @@ def main(): `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(typeEnv.constructorBindings).toEqual([]); }); @@ -2982,7 +2961,7 @@ REPO = Repo.new `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('REPO'); expect(typeEnv.constructorBindings[0].calleeName).toBe('Repo'); @@ -2995,7 +2974,7 @@ service = Models::UserService.new `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('service'); expect(typeEnv.constructorBindings[0].calleeName).toBe('UserService'); @@ -3008,7 +2987,7 @@ svc = App::Models::Service.new `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('svc'); expect(typeEnv.constructorBindings[0].calleeName).toBe('Service'); @@ -3023,14 +3002,14 @@ svc = App::Models::Service.new `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].scope).toMatch(/^process@\d+$/); }); it('returns constructor bindings for TypeScript const user = getUser()', () => { const tree = parse('const user = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBeUndefined(); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('user'); @@ -3039,20 +3018,20 @@ svc = App::Models::Service.new it('does NOT emit constructor binding when TypeScript var has explicit type annotation', () => { const tree = parse('const user: User = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(typeEnv.constructorBindings.find((b) => b.varName === 'user')).toBeUndefined(); }); it('skips destructuring patterns (array_pattern) for TypeScript', () => { const tree = parse('const [a, b] = getPair();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(typeEnv.constructorBindings).toEqual([]); }); it('skips destructuring patterns (object_pattern) for TypeScript', () => { const tree = parse('const { name, age } = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(typeEnv.constructorBindings).toEqual([]); }); @@ -3061,7 +3040,7 @@ svc = App::Models::Service.new 'async function f() { const user = await fetchUser(); }', TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('user'); expect(typeEnv.constructorBindings[0].calleeName).toBe('fetchUser'); @@ -3069,7 +3048,7 @@ svc = App::Models::Service.new it('handles qualified callee in TypeScript: const user = repo.getUser()', () => { const tree = parse('const user = repo.getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('user'); expect(typeEnv.constructorBindings[0].calleeName).toBe('getUser'); @@ -3077,7 +3056,7 @@ svc = App::Models::Service.new it('does not emit binding for TypeScript new expression (handled by extractInitializer)', () => { const tree = parse('const user = new User();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(typeEnv.constructorBindings.find((b) => b.varName === 'user')).toBeUndefined(); }); @@ -3094,7 +3073,7 @@ svc = App::Models::Service.new `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); const binding = typeEnv.constructorBindings.find((b) => b.varName === 'user'); expect(binding).toBeDefined(); expect(binding!.calleeName).toBe('GetUser'); @@ -3109,7 +3088,7 @@ svc = App::Models::Service.new `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('user'); expect(typeEnv.constructorBindings[0].calleeName).toBe('get_user'); @@ -3127,7 +3106,7 @@ svc = App::Models::Service.new `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); const binding = typeEnv.constructorBindings.find((b) => b.varName === 'user'); expect(binding).toBeDefined(); expect(binding!.calleeName).toBe('GetUserAsync'); @@ -3144,7 +3123,7 @@ svc = App::Models::Service.new `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); const binding = typeEnv.constructorBindings.find((b) => b.varName === 'user'); expect(binding).toBeDefined(); expect(binding!.calleeName).toBe('GetUser'); @@ -3160,7 +3139,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'a')).toBe('User'); expect(flatGet(typeEnv, 'b')).toBe('User'); }); @@ -3173,7 +3152,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'a')).toBe('User'); expect(flatGet(typeEnv, 'b')).toBe('User'); }); @@ -3190,7 +3169,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'a')).toBe('User'); expect(flatGet(typeEnv, 'b')).toBe('User'); expect(flatGet(typeEnv, 'c')).toBe('User'); @@ -3206,7 +3185,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // 'alias' should get User from the parameter 'user' const scopeKey = [...typeEnv.allScopes().keys()].find((k) => k.startsWith('process@')); expect(scopeKey).toBeDefined(); @@ -3224,7 +3203,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // cfg in process scope picks up Config from the file-level config binding const scopeKey = [...typeEnv.allScopes().keys()].find((k) => k.startsWith('process@')); expect(scopeKey).toBeDefined(); @@ -3238,7 +3217,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // getUser() is a call_expression — should not create a binding expect(flatGet(typeEnv, 'x')).toBeUndefined(); }); @@ -3338,7 +3317,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const scopeKey = [...typeEnv.allScopes().keys()].find((k) => k.startsWith('process@')); expect(scopeKey).toBeDefined(); expect(typeEnv.allScopes().get(scopeKey!)?.get('a')).toBe('User'); @@ -3360,7 +3339,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const scopeKey = [...typeEnv.allScopes().keys()].find((k) => k.startsWith('process@')); expect(scopeKey).toBeDefined(); expect(typeEnv.allScopes().get(scopeKey!)?.get('c')).toBe('User'); @@ -3384,7 +3363,7 @@ svc = App::Models::Service.new `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'a')).toBe('User'); expect(flatGet(typeEnv, 'b')).toBe('User'); }); @@ -3403,7 +3382,7 @@ svc = App::Models::Service.new `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3420,7 +3399,7 @@ svc = App::Models::Service.new `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3437,7 +3416,7 @@ svc = App::Models::Service.new `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3456,7 +3435,7 @@ svc = App::Models::Service.new `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3472,7 +3451,7 @@ def process(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3487,7 +3466,7 @@ def process(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3504,7 +3483,7 @@ def process(): `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3521,7 +3500,7 @@ def process(): `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$u')).toBe('User'); expect(flatGet(typeEnv, '$alias')).toBe('User'); }); @@ -3542,7 +3521,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); // Without a known type for 'user' (no annotation in Ruby), alias_user stays undefined. // This verifies the extractor doesn't crash or produce false bindings. expect(flatGet(typeEnv, 'alias_user')).toBeUndefined(); @@ -3558,7 +3537,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); // get_user() is a call — user has no resolved type, so alias_user should not resolve either expect(flatGet(typeEnv, 'alias_user')).toBeUndefined(); }); @@ -3576,7 +3555,7 @@ end `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Find the call node for .save() const scopeKey = [...typeEnv.allScopes().keys()].find((k) => k.startsWith('process@')); expect(scopeKey).toBeDefined(); @@ -3592,7 +3571,7 @@ def process(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); // Python 3.10+ union syntax is stored as raw text "User | None" // which stripNullable resolves at lookup time const rawVal = flatGet(typeEnv, 'user'); @@ -3616,7 +3595,7 @@ def process(): `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -3629,7 +3608,7 @@ def process(): `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -3644,7 +3623,7 @@ def process(): `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'users')).toBe('List'); }); @@ -3659,7 +3638,7 @@ def process(): `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'lookup')).toBe('Map'); }); @@ -3674,7 +3653,7 @@ def process(): `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'future')).toBe('CompletableFuture'); }); @@ -3705,7 +3684,7 @@ def process(): `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3725,7 +3704,7 @@ def process(): `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -3740,7 +3719,7 @@ def process(): `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -3755,7 +3734,7 @@ def process(): `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // for...in yields string keys — extractor must NOT bind 'key' to User expect(flatGet(typeEnv, 'key')).toBeUndefined(); }); @@ -3771,7 +3750,7 @@ def process(): `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); @@ -3787,7 +3766,7 @@ def process(): `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Note: users itself is stored with no binding (extractSimpleTypeName returns undefined // for array_type), but the for-loop extractor uses AST walking to resolve the element type. expect(flatGet(typeEnv, 'user')).toBe('User'); @@ -3804,7 +3783,7 @@ def process(): `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -3819,7 +3798,7 @@ def process(users: List[User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -3832,7 +3811,7 @@ def process(users: Sequence[User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -3845,7 +3824,7 @@ def process(users): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); @@ -3859,7 +3838,7 @@ def process(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); // List[User] → extractSimpleTypeName returns 'List' (base name), stored as 'List' // extractElementTypeFromString('List') → undefined (no brackets in the string) // So user is unresolved unless users is stored as 'List[User]' raw. @@ -3883,7 +3862,7 @@ func process(users []User) { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -3899,7 +3878,7 @@ func process(users []User) { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); // In Go, `for v := range slice` gives the INDEX (int), not the element. expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); @@ -3916,7 +3895,7 @@ func process(myMap map[string]User) { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'v')).toBe('User'); }); @@ -3932,7 +3911,7 @@ func process(myMap map[string]User) { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); // Single-var map range gives the KEY, not the value expect(flatGet(typeEnv, 'k')).toBeUndefined(); }); @@ -3948,7 +3927,7 @@ func process() { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); // C-style for loop has no range_clause — extractor must return early expect(flatGet(typeEnv, 'i')).toBeUndefined(); }); @@ -3966,7 +3945,7 @@ func process() { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); // users has no type annotation — only a constructor binding candidate // Without a resolved type for users, user cannot be inferred expect(flatGet(typeEnv, 'user')).toBeUndefined(); @@ -3985,7 +3964,7 @@ fn process(users: Vec) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4000,7 +3979,7 @@ fn process(users: &[User]) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4015,7 +3994,7 @@ fn process() { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'i')).toBeUndefined(); }); @@ -4031,7 +4010,7 @@ fn process() { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); }); @@ -4051,7 +4030,7 @@ class Foo { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4068,7 +4047,7 @@ class Foo { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4086,7 +4065,7 @@ class Foo { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); }); @@ -4103,7 +4082,7 @@ fun process(users: List) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4118,7 +4097,7 @@ fun process(users: List) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4134,7 +4113,7 @@ fun process() { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); }); @@ -4153,7 +4132,7 @@ class Foo { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4171,7 +4150,7 @@ class Foo { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); }); @@ -4190,7 +4169,7 @@ function process(entries: Map) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4206,7 +4185,7 @@ def process(data: dict[str, User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4222,7 +4201,7 @@ def process(data: dict[str, User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'v')).toBe('User'); // i is the int index from enumerate — must NOT be bound to User expect(flatGet(typeEnv, 'i')).toBeUndefined(); @@ -4240,7 +4219,7 @@ def process(data: dict[str, User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'v')).toBe('User'); expect(flatGet(typeEnv, 'i')).toBeUndefined(); }); @@ -4257,7 +4236,7 @@ def process(users: List[User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); // enumerate yields (index, element) — k is int (unbound), v is User expect(flatGet(typeEnv, 'v')).toBe('User'); expect(flatGet(typeEnv, 'k')).toBeUndefined(); @@ -4277,7 +4256,7 @@ function process(x) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'x')).toBe('User'); }); @@ -4294,7 +4273,7 @@ fn process(users: Vec) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -4311,7 +4290,7 @@ function process(data: Map) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4326,7 +4305,7 @@ function process(data: Map) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'key')).toBe('string'); }); @@ -4339,7 +4318,7 @@ def process(data: dict[str, User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'key')).toBe('str'); }); @@ -4352,7 +4331,7 @@ def process(data: dict[str, User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4367,7 +4346,7 @@ fn process(data: HashMap) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'key')).toBe('String'); }); @@ -4382,7 +4361,7 @@ fn process(data: HashMap) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -4399,7 +4378,7 @@ fn process(data: HashMap) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'key')).toBe('String'); }); @@ -4414,7 +4393,7 @@ fn process(data: HashMap) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4429,7 +4408,7 @@ fn process(users: Vec) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4445,7 +4424,7 @@ function process(cache: MyCache) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'key')).toBe('string'); }); }); @@ -4462,7 +4441,7 @@ function process(users: User[]) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Object destructuring should NOT produce bindings — field types are unknown expect(flatGet(typeEnv, 'id')).toBeUndefined(); expect(flatGet(typeEnv, 'name')).toBeUndefined(); @@ -4479,7 +4458,7 @@ function process(users: User[]) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4492,7 +4471,7 @@ def process(users: List[User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4507,7 +4486,7 @@ void process(std::map& map) { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'value')).toBe('User'); }); @@ -4534,7 +4513,7 @@ void processRepoMap(std::map repoMap) { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repo'); }); @@ -4553,7 +4532,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -4570,7 +4549,7 @@ fun process(x: Any) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'x')).toBe('User'); }); @@ -4586,7 +4565,7 @@ fun process(x: Any) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); // allowPatternBindingOverwrite means each arm overwrites — last one wins expect(flatGet(typeEnv, 'x')).toBe('Admin'); }); @@ -4603,7 +4582,7 @@ fun process() { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); // x has type String from its declaration — no pattern binding should narrow it // (else branch has no type_test node, so extractKotlinPatternBinding never fires) expect(flatGet(typeEnv, 'x')).toBe('String'); @@ -4622,7 +4601,7 @@ fun processValues(data: HashMap) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4637,7 +4616,7 @@ fun processList(users: List) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -4656,7 +4635,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'u')).toBe('User'); }); @@ -4674,7 +4653,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'a')).toBe('Admin'); }); @@ -4692,7 +4671,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); // Only the parameter x:Object should exist, no extra bindings from case 42 expect(flatGet(typeEnv, 'x')).toBe('Object'); }); @@ -4710,7 +4689,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -4730,7 +4709,7 @@ public class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4745,7 +4724,7 @@ fun process(data: MutableMap) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4760,7 +4739,7 @@ fun process(users: MutableList) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4778,7 +4757,7 @@ public class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4795,7 +4774,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -4814,7 +4793,7 @@ public class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'u')).toBe('User'); }); @@ -4832,7 +4811,7 @@ public class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'r')).toBe('Repo'); }); @@ -4848,7 +4827,7 @@ public class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); // obj → object from the parameter, but no pattern binding expect(flatGet(typeEnv, 'obj')).toBe('object'); expect(flatSize(typeEnv)).toBe(1); // only the parameter binding @@ -4870,7 +4849,7 @@ public class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4889,7 +4868,7 @@ public class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); // Verify lookup works from the call site (user.Save()) const saveCall = tree.rootNode.descendantsOfType('invocation_expression')[0]; @@ -4912,7 +4891,7 @@ class UserService { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // User[] is an array_type — extractSimpleTypeName returns undefined (no simple base name). // But declarationTypeNodes captures the raw AST node, so for-loop resolution // uses Strategy 1 (extractTsElementTypeFromAnnotation) to resolve the element type. @@ -4928,7 +4907,7 @@ class RepoService { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'repos')).toBe('Map'); }); }); @@ -4949,7 +4928,7 @@ class App { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$user')).toBe('User'); }); @@ -4967,7 +4946,7 @@ class App { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$item')).toBeUndefined(); }); @@ -4995,7 +4974,7 @@ class B { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); // Both $item bindings exist but may share the same key if scoped to method name // Conservative: just verify at least one resolves correctly expect(flatGet(typeEnv, '$item')).toBeDefined(); @@ -5015,7 +4994,7 @@ fn process(opt: Option) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // user should be typed from the first arm (Some unwrap) // Known limitation: binding leaks across arms (first-writer-wins) expect(flatGet(typeEnv, 'user')).toBe('User'); @@ -5025,7 +5004,7 @@ fn process(opt: Option) { describe('performance optimizations — coverage for new code paths', () => { it('fastStripNullable: passes through simple identifier without stripping', () => { const tree = parse('function f(user: User) { user.save(); }', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // lookup exercises fastStripNullable — "User" has no | or ? markers const callNode = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('save')); expect(typeEnv.lookup('user', callNode)).toBe('User'); @@ -5033,14 +5012,14 @@ fn process(opt: Option) { it('fastStripNullable: strips nullable union type via full stripNullable', () => { const tree = parse('function f(user: User | null) { user.save(); }', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const callNode = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('save')); expect(typeEnv.lookup('user', callNode)).toBe('User'); }); it('fastStripNullable: rejects bare nullable keyword', () => { const tree = parse('function f(x: null) { x.save(); }', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const callNode = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('save')); expect(typeEnv.lookup('x', callNode)).toBeUndefined(); }); @@ -5056,7 +5035,7 @@ class Foo { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const callNode = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('save')); expect(typeEnv.lookup('user', callNode)).toBe('User'); }); @@ -5071,7 +5050,7 @@ function f(user: User) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -5087,7 +5066,7 @@ function calculate(service: Service) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'service')).toBe('Service'); }); }); @@ -5101,7 +5080,7 @@ function process(x: User | null) { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Inside the if-body, x should resolve to User (nullable stripped) const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.save')); expect(typeEnv.lookup('x', saveCall)).toBe('User'); @@ -5115,7 +5094,7 @@ function process(x: User | undefined) { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.save')); expect(typeEnv.lookup('x', saveCall)).toBe('User'); }); @@ -5128,7 +5107,7 @@ function process(x: User | null) { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.save')); expect(typeEnv.lookup('x', saveCall)).toBe('User'); }); @@ -5143,7 +5122,7 @@ function process(x: User | null) { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Inside else branch, x should retain original nullable type (User via fastStripNullable) const fallbackCall = tree.rootNode.descendantForIndex( tree.rootNode.text.indexOf('x.fallback'), @@ -5165,7 +5144,7 @@ function process(x: User | null) { x.other(); }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // After the if-block, x should use the flat scopeEnv (User | null → User via fastStripNullable) const otherCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.other')); expect(typeEnv.lookup('x', otherCall)).toBe('User'); @@ -5179,7 +5158,7 @@ function process(x: User) { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // x is already non-nullable — no narrowing override is emitted, but lookup still works const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.save')); expect(typeEnv.lookup('x', saveCall)).toBe('User'); @@ -5196,7 +5175,7 @@ function process(x) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'x')).toBe('User'); }); @@ -5208,7 +5187,7 @@ fun process(x: User?) { } }`; const tree = parse(code, Kotlin); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.save')); expect(typeEnv.lookup('x', saveCall)).toBe('User'); }); @@ -5224,7 +5203,7 @@ fun process(x: Any) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'x')).toBe('User'); }); @@ -5238,7 +5217,7 @@ class App { } }`; const tree = parse(code, CSharp); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.Save')); expect(typeEnv.lookup('x', saveCall)).toBe('User'); }); @@ -5256,7 +5235,7 @@ class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -5275,7 +5254,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'a')).toBe('User'); expect(flatGet(typeEnv, 'b')).toBe('User'); }); @@ -5295,7 +5274,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); // x should NOT have a type binding (it's untyped via var) expect(flatGet(typeEnv, 'x')).toBeUndefined(); @@ -5314,7 +5293,7 @@ class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'a')).toBe('User'); expect(flatGet(typeEnv, 'b')).toBe('User'); }); @@ -5331,7 +5310,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -5351,7 +5330,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'users')).toBe('List'); expect(flatGet(typeEnv, 'admins')).toBe('List'); expect(flatGet(typeEnv, 'u')).toBe('User'); @@ -5372,7 +5351,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); // Find the entry for variable 'a' let ctorType: string | undefined; for (const [key, value] of typeEnv.constructorTypeMap) { @@ -5396,7 +5375,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); let found = false; for (const [key] of typeEnv.constructorTypeMap) { if (key.endsWith('\0u')) { @@ -5419,7 +5398,7 @@ const a: Animal = new Dog(); `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'a')).toBe('Animal'); let found = false; for (const [key] of typeEnv.constructorTypeMap) { @@ -5442,7 +5421,7 @@ void run() { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); let ctorType: string | undefined; for (const [key, value] of typeEnv.constructorTypeMap) { if (key.endsWith('\0a')) { @@ -5466,7 +5445,7 @@ class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); let ctorType: string | undefined; for (const [key, value] of typeEnv.constructorTypeMap) { if (key.endsWith('\0a')) { @@ -5489,7 +5468,7 @@ class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); // d should be bound via declared type path expect(flatGet(typeEnv, 'd')).toBe('Dog'); // constructorTypeMap should NOT have an entry (same type, no override needed) @@ -5508,7 +5487,7 @@ class App { it('seeds imported bindings into file scope for unbound names', () => { // Source has no local declaration of 'config', so the imported binding wins const tree = parse('', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedBindings: new Map([['config', 'Config']]), }); const fileScope = typeEnv.fileScope(); @@ -5518,7 +5497,7 @@ class App { it('local declarations take precedence over imported bindings', () => { // Source declares config: AppConfig — imported binding for 'config' must not overwrite it const tree = parse('const config: AppConfig = getConfig();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedBindings: new Map([['config', 'Config']]), }); const fileScope = typeEnv.fileScope(); @@ -5528,8 +5507,8 @@ class App { it('does nothing when importedBindings is empty', () => { const tree = parse('const user: User = getUser();', TypeScript.typescript); - const typeEnvWithout = buildTypeEnv(tree, 'typescript'); - const typeEnvWith = buildTypeEnv(tree, 'typescript', { + const typeEnvWithout = buildTypeEnv(tree, SupportedLanguages.TypeScript); + const typeEnvWith = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedBindings: new Map(), }); // Both envs should produce the same file-scope content @@ -5541,7 +5520,7 @@ class App { it('seeds multiple bindings', () => { const tree = parse('', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedBindings: new Map([ ['user', 'User'], ['config', 'Config'], @@ -5559,7 +5538,7 @@ function process() { config.validate(); }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedBindings: new Map([['config', 'Config']]), }); @@ -5577,7 +5556,7 @@ function process() { it('seeds bindings with no conflict when local file has unrelated declarations', () => { // File has 'user' declared locally; 'config' comes from importedBindings const tree = parse('const user: User = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedBindings: new Map([['config', 'Config']]), }); const fileScope = typeEnv.fileScope(); @@ -5609,7 +5588,7 @@ function process() { // importedReturnTypes says getConfig → WrongType — SymbolTable must win const symbolTable = makeSymbolTable([{ name: 'getConfig', returnType: 'Config' }]); const tree = parse('const c = getConfig();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model: symbolTable, importedReturnTypes: new Map([['getConfig', 'WrongType']]), }); @@ -5621,7 +5600,7 @@ function process() { // SymbolTable knows nothing about getConfig const symbolTable = makeSymbolTable([]); const tree = parse('const c = getConfig();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model: symbolTable, importedReturnTypes: new Map([['getConfig', 'Config']]), }); @@ -5636,7 +5615,7 @@ function process() { { name: 'process', returnType: 'Admin' }, ]); const tree = parse('const r = process();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model: symbolTable, importedReturnTypes: new Map([['process', 'User']]), }); @@ -5646,7 +5625,7 @@ function process() { it('no SymbolTable → uses cross-file return types directly', () => { const tree = parse('const c = getConfig();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedReturnTypes: new Map([['getConfig', 'Config']]), }); expect(flatGet(typeEnv, 'c')).toBe('Config'); @@ -5656,7 +5635,7 @@ function process() { // SymbolTable provides the authoritative return type; cross-file entry is ignored const symbolTable = makeSymbolTable([{ name: 'getUser', returnType: 'User' }]); const tree = parse('const u = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model: symbolTable, importedReturnTypes: new Map([['getUser', 'CrossFileUser']]), }); @@ -5666,7 +5645,7 @@ function process() { it('does nothing when importedReturnTypes is absent', () => { const tree = parse('const c = getConfig();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // No annotation, no SymbolTable, no cross-file → no binding expect(flatGet(typeEnv, 'c')).toBeUndefined(); }); @@ -5681,7 +5660,7 @@ function process() { c.validate(); }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedReturnTypes: new Map([['getConfig', 'Config']]), }); @@ -5704,7 +5683,7 @@ function process() { it('flushes file-scope bindings into accumulator', () => { const code = `const user: User = getUser();\nconst count: number = 0;`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); typeEnv.flush('/src/test.ts', acc); @@ -5726,7 +5705,7 @@ function process() { it('does NOT flush function-scoped bindings into accumulator (file-scope narrowing)', () => { const code = `function process() {\n const result: Response = fetch();\n}`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); typeEnv.flush('/src/test.ts', acc); @@ -5746,7 +5725,7 @@ function process() { // the primary narrowing-contract assertion for the sequential path. const code = `const dbClient: Database = connectDb();\nfunction handleRequest() {\n const localRequest: Request = parseRequest();\n const localUser: User = loadUser();\n}`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); typeEnv.flush('/src/service.ts', acc); @@ -5767,7 +5746,7 @@ function process() { it('flushes nothing for an empty TypeEnv', () => { const code = `// empty file`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); typeEnv.flush('/src/empty.ts', acc); @@ -5780,8 +5759,8 @@ function process() { const code2 = `const b: B = makeB();`; const tree1 = parse(code1, TypeScript.typescript); const tree2 = parse(code2, TypeScript.typescript); - const typeEnv1 = buildTypeEnv(tree1, 'typescript'); - const typeEnv2 = buildTypeEnv(tree2, 'typescript'); + const typeEnv1 = buildTypeEnv(tree1, SupportedLanguages.TypeScript); + const typeEnv2 = buildTypeEnv(tree2, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); typeEnv1.flush('/src/a.ts', acc); @@ -5795,7 +5774,7 @@ function process() { it('throws on second flush of the same TypeEnv (single-use)', () => { const code = `const x: X = makeX();`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); typeEnv.flush('/src/a.ts', acc); @@ -5829,7 +5808,7 @@ function internal() { } `; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); // Real flush — exercises the narrowed FILE_SCOPE-only iteration. @@ -5883,7 +5862,7 @@ function internal() { const code = `export const helper: WorkerInferredType = makeHelper();`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); typeEnv.flush('src/utils.ts', acc); acc.finalize(); diff --git a/gitnexus/test/unit/upload-ingest.test.ts b/gitnexus/test/unit/upload-ingest.test.ts index 03d04d49d..ed31b37a6 100644 --- a/gitnexus/test/unit/upload-ingest.test.ts +++ b/gitnexus/test/unit/upload-ingest.test.ts @@ -43,7 +43,6 @@ describe('resolveContainedDest', () => { }); it('rejects non-string input', () => { - // @ts-expect-error testing runtime guard expect(() => resolveContainedDest(ROOT, ['a', 'b'])).toThrow(); }); diff --git a/gitnexus/test/unit/variable-extraction.test.ts b/gitnexus/test/unit/variable-extraction.test.ts index b9b2e7279..e0502545c 100644 --- a/gitnexus/test/unit/variable-extraction.test.ts +++ b/gitnexus/test/unit/variable-extraction.test.ts @@ -894,7 +894,7 @@ describeKotlin('VariableExtractor — Kotlin (F51 destructuring)', () => { /** The first property_declaration whose text starts with `prefix`. */ function propertyDecl(src: string, prefix: string): SyntaxNode { - parser.setLanguage(Kotlin as Parser.Language); + parser.setLanguage(Kotlin); const tree = parser.parse(src); let found: SyntaxNode | undefined; const walk = (n: SyntaxNode) => { diff --git a/gitnexus/test/unit/watch-queue.test.ts b/gitnexus/test/unit/watch-queue.test.ts index 260008a96..741778aee 100644 --- a/gitnexus/test/unit/watch-queue.test.ts +++ b/gitnexus/test/unit/watch-queue.test.ts @@ -24,7 +24,9 @@ describe('WatchRefreshQueue', () => { const batches: readonly string[][] = []; const mutable = batches as string[][]; const queue = new WatchRefreshQueue( - async (paths) => mutable.push([...paths]), + async (paths) => { + mutable.push([...paths]); + }, () => {}, 100, ); @@ -102,7 +104,9 @@ describe('WatchRefreshQueue', () => { vi.useFakeTimers(); const batches: string[][] = []; const queue = new WatchRefreshQueue( - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, () => {}, 50, { holdEventsUntilInitialRefresh: true }, @@ -250,7 +254,9 @@ describe('WatchRefreshQueue', () => { vi.useFakeTimers(); const batches: string[][] = []; const queue = new WatchRefreshQueue( - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, () => {}, 100, { maxWaitMs: 250 }, @@ -271,7 +277,9 @@ describe('WatchRefreshQueue', () => { vi.useFakeTimers(); const batches: string[][] = []; const queue = new WatchRefreshQueue( - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, () => {}, 10, { @@ -292,7 +300,9 @@ describe('WatchRefreshQueue', () => { vi.useFakeTimers(); const batches: string[][] = []; const queue = new WatchRefreshQueue( - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, () => {}, 10, { @@ -314,7 +324,9 @@ describe('WatchRefreshQueue', () => { vi.useFakeTimers(); const batches: string[][] = []; const queue = new WatchRefreshQueue( - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, () => {}, 10, { maxPendingPaths: 2 }, @@ -333,7 +345,9 @@ describe('WatchRefreshQueue', () => { vi.useFakeTimers(); const batches: string[][] = []; const queue = new WatchRefreshQueue( - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, () => {}, 10, { maxPendingPaths: 0 }, diff --git a/gitnexus/test/unit/wiki-db-pin.test.ts b/gitnexus/test/unit/wiki-db-pin.test.ts index 8af799710..3cba3d7ae 100644 --- a/gitnexus/test/unit/wiki-db-pin.test.ts +++ b/gitnexus/test/unit/wiki-db-pin.test.ts @@ -84,6 +84,8 @@ describe('WikiGenerator DB pinning', () => { model: 'test-model', baseUrl: 'http://127.0.0.1:1/v1', apiKey: 'test', + maxTokens: 1024, + temperature: 0, }, { force: true, reviewOnly: true }, ); diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index 6ffd46d57..935b8953b 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -1532,7 +1532,7 @@ describe('local agent CLI calls', () => { return stdinText; }); - const spawnSpy = vi.fn(() => child); + const spawnSpy = vi.fn(() => child); vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), spawn: spawnSpy, @@ -1583,7 +1583,7 @@ describe('local agent CLI calls', () => { }); }); - const spawnSpy = vi.fn(() => child); + const spawnSpy = vi.fn(() => child); vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), spawn: spawnSpy, @@ -1780,7 +1780,7 @@ describe('local agent CLI calls', () => { }); }); - const spawnSpy = vi.fn(() => child); + const spawnSpy = vi.fn(() => child); vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('codex-cli 0.132.0'), spawn: spawnSpy, diff --git a/gitnexus/test/unit/worker-pool-ready-timeout-env.test.ts b/gitnexus/test/unit/worker-pool-ready-timeout-env.test.ts index f64135974..b61d68b38 100644 --- a/gitnexus/test/unit/worker-pool-ready-timeout-env.test.ts +++ b/gitnexus/test/unit/worker-pool-ready-timeout-env.test.ts @@ -15,6 +15,7 @@ */ import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'node:events'; +import type { Worker } from 'node:worker_threads'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; @@ -63,9 +64,10 @@ describe('worker pool — GITNEXUS_WORKER_READY_TIMEOUT_MS override', () => { workerFactory: () => new NeverReadyWorker() as unknown as Worker, }); - const err = await pool - .dispatch([{ path: 'a.ts', content: 'x' }]) - .catch((e: unknown) => e as InstanceType); + const err = await pool.dispatch([{ path: 'a.ts', content: 'x' }]).catch((e: unknown) => e); + if (!(err instanceof WorkerPoolInitializationError)) { + throw new Error('expected worker initialization failure'); + } expect(err).toBeInstanceOf(WorkerPoolInitializationError); expect(err.readinessFailures.join('\n')).toContain('within 50ms'); @@ -79,9 +81,10 @@ describe('worker pool — GITNEXUS_WORKER_READY_TIMEOUT_MS override', () => { workerFactory: () => new NeverReadyWorker() as unknown as Worker, }); - const err = await pool - .dispatch([{ path: 'a.ts', content: 'x' }]) - .catch((e: unknown) => e as InstanceType); + const err = await pool.dispatch([{ path: 'a.ts', content: 'x' }]).catch((e: unknown) => e); + if (!(err instanceof WorkerPoolInitializationError)) { + throw new Error('expected worker initialization failure'); + } expect(err).toBeInstanceOf(WorkerPoolInitializationError); expect(err.readinessFailures.join('\n')).toContain('within 5000ms'); diff --git a/gitnexus/test/unit/worker-pool-resilience.test.ts b/gitnexus/test/unit/worker-pool-resilience.test.ts index 1427f45f1..997773e9a 100644 --- a/gitnexus/test/unit/worker-pool-resilience.test.ts +++ b/gitnexus/test/unit/worker-pool-resilience.test.ts @@ -286,7 +286,7 @@ describe('worker pool resilience', () => { expect(results).toEqual([{ fileCount: 2 }]); // Two bad files quarantined; both pre-crash 'starting-file' targets. - expect(pool.getQuarantinedPaths().sort()).toEqual(['src/a.ts', 'src/b.ts']); + expect([...pool.getQuarantinedPaths()].sort()).toEqual(['src/a.ts', 'src/b.ts']); await pool.terminate(); }); @@ -306,7 +306,7 @@ describe('worker pool resilience', () => { ]), ).rejects.toBeInstanceOf(WorkerPoolDispatchError); - expect(pool.getQuarantinedPaths().sort()).toEqual(['src/x.ts', 'src/y.ts']); + expect([...pool.getQuarantinedPaths()].sort()).toEqual(['src/x.ts', 'src/y.ts']); // Subsequent dispatch on a tripped pool rejects without running anything. await expect( pool.dispatch<{ path: string; content: string }, unknown>([ @@ -348,7 +348,7 @@ describe('worker pool resilience', () => { { path: 'src/ok.ts', content: '' }, ]); expect(r2).toEqual([{ fileCount: 1 }]); - expect(pool.getQuarantinedPaths().sort()).toEqual(['src/bad.ts', 'src/bad2.ts']); + expect([...pool.getQuarantinedPaths()].sort()).toEqual(['src/bad.ts', 'src/bad2.ts']); await pool.terminate(); }); diff --git a/gitnexus/test/unit/worker-pool-startup-stderr.test.ts b/gitnexus/test/unit/worker-pool-startup-stderr.test.ts index b09568e95..67fa0cd7d 100644 --- a/gitnexus/test/unit/worker-pool-startup-stderr.test.ts +++ b/gitnexus/test/unit/worker-pool-startup-stderr.test.ts @@ -15,6 +15,7 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'node:events'; +import type { Worker } from 'node:worker_threads'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; @@ -93,7 +94,10 @@ describe('worker pool — startup stderr surfacing (#1741)', () => { const dispatch = pool.dispatch([{ path: 'src/a.ts', content: 'x' }]); await expect(dispatch).rejects.toBeInstanceOf(WorkerPoolInitializationError); - const err = await dispatch.catch((e: unknown) => e as WorkerPoolInitializationError); + const err = await dispatch.catch((e: unknown) => e); + if (!(err instanceof WorkerPoolInitializationError)) { + throw new Error('expected worker initialization failure'); + } expect(err.readinessFailures.length).toBeGreaterThan(0); // The real crash reason, recovered from the worker's stderr, is present. const joined = err.readinessFailures.join('\n'); @@ -127,7 +131,7 @@ describe('worker pool — startup self-healing (#1741)', () => { expect(pool.getStats().poolBroken).toBe(false); // R1 (clear-on-settle): the ref'd backoff timer self-cleared when it fired, // so no startup timer lingers after the slot's retry loop exited. - expect(pool.getStats().pendingStartupTimers).toBe(0); + expect(pool.getStats()).toHaveProperty('pendingStartupTimers', 0); await pool.terminate().catch(() => undefined); }); @@ -143,9 +147,10 @@ describe('worker pool — startup self-healing (#1741)', () => { }, }); - const err = await pool - .dispatch([{ path: 'a.ts', content: 'x' }]) - .catch((e: unknown) => e as WorkerPoolInitializationError); + const err = await pool.dispatch([{ path: 'a.ts', content: 'x' }]).catch((e: unknown) => e); + if (!(err instanceof WorkerPoolInitializationError)) { + throw new Error('expected worker initialization failure'); + } expect(err).toBeInstanceOf(WorkerPoolInitializationError); expect(err.crashClass).toBe('deterministic-startup'); @@ -193,9 +198,10 @@ describe('worker pool — startup self-healing (#1741)', () => { }, }); - const err = await pool - .dispatch([{ path: 'a.ts', content: 'x' }]) - .catch((e: unknown) => e as WorkerPoolInitializationError); + const err = await pool.dispatch([{ path: 'a.ts', content: 'x' }]).catch((e: unknown) => e); + if (!(err instanceof WorkerPoolInitializationError)) { + throw new Error('expected worker initialization failure'); + } expect(err).toBeInstanceOf(WorkerPoolInitializationError); expect(err.crashClass).toBe('transient-exhausted'); @@ -217,9 +223,10 @@ describe('worker pool — startup self-healing (#1741)', () => { }, }); - const err = await pool - .dispatch([{ path: 'a.ts', content: 'x' }]) - .catch((e: unknown) => e as WorkerPoolInitializationError); + const err = await pool.dispatch([{ path: 'a.ts', content: 'x' }]).catch((e: unknown) => e); + if (!(err instanceof WorkerPoolInitializationError)) { + throw new Error('expected worker initialization failure'); + } expect(err.crashClass).toBe('deterministic-startup'); expect(calls).toBe(2); // attempt 0 + one respawn that reproduced @@ -246,7 +253,7 @@ describe('worker pool — startup self-healing (#1741)', () => { await pool.terminate(); expect(pool.getStats().terminated).toBe(true); // No ref'd backoff timer left pinning the loop after terminate. - expect(pool.getStats().pendingStartupTimers).toBe(0); + expect(pool.getStats()).toHaveProperty('pendingStartupTimers', 0); expect(pool.getStats().activeSlots).toBe(0); // No worker is spawned after terminate — the woken loop sees `terminated` and gives up. diff --git a/gitnexus/test/unit/worker-pool-stdout-forward.test.ts b/gitnexus/test/unit/worker-pool-stdout-forward.test.ts index 79e2654de..a6ab9c871 100644 --- a/gitnexus/test/unit/worker-pool-stdout-forward.test.ts +++ b/gitnexus/test/unit/worker-pool-stdout-forward.test.ts @@ -15,6 +15,7 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'node:events'; +import type { Worker } from 'node:worker_threads'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; diff --git a/gitnexus/test/unit/write-lock-required.test.ts b/gitnexus/test/unit/write-lock-required.test.ts index c4d3a5bd2..617f4205b 100644 --- a/gitnexus/test/unit/write-lock-required.test.ts +++ b/gitnexus/test/unit/write-lock-required.test.ts @@ -16,7 +16,7 @@ vi.mock('../../src/storage/index-lock.js', async (original) => ({ vi.mock('../../src/core/ingestion/pipeline.js', () => ({ runPipelineFromRepo: pipeline })); vi.mock('../../src/storage/repo-manager.js', async (original) => ({ ...(await original()), - resolveBranchPlacement: (...args: unknown[]) => resolvePlacement(...args), + resolveBranchPlacement: resolvePlacement, })); import { runFullAnalysis } from '../../src/core/run-analyze.js'; diff --git a/gitnexus/tsconfig.test.json b/gitnexus/tsconfig.test.json index 425ff7485..3f22f556a 100644 --- a/gitnexus/tsconfig.test.json +++ b/gitnexus/tsconfig.test.json @@ -6,5 +6,5 @@ "types": ["node", "vitest/globals"] }, "include": ["src/**/*", "test/**/*"], - "exclude": ["test/fixtures/mini-repo/**", "test/fixtures/sample-code/**"] + "exclude": ["node_modules", "test/fixtures/**"] } From 4758df1c6c56e3b70dfc9d36ad9228dd942ba427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 5 Oct 2026 05:13:27 +0100 Subject: [PATCH 05/13] fix(ci): fail on TypeScript errors in tests (#3472) --- .github/workflows/ci-quality.yml | 3 +++ TESTING.md | 11 ++++++++++- gitnexus/package.json | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 1b7185d1b..dffb6550b 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -53,6 +53,9 @@ jobs: lifecycle-scripts: 'false' - run: npx tsc --noEmit working-directory: gitnexus + - name: Typecheck tests + run: npm run typecheck:tests + working-directory: gitnexus typecheck-web: runs-on: ubuntu-latest diff --git a/TESTING.md b/TESTING.md index 9febb8283..4be0f73c5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -19,6 +19,7 @@ From `gitnexus/`: | Command | What it runs | When to use | | ----------------------------- | -------------------------------------------------- | ------------------------------------- | | `npm test` | Full suite (all 3 vitest projects) | Before opening a PR | +| `npm run typecheck:tests` | TypeScript checks for source, tests, and helpers | Before opening a PR | | `npm run test:unit` | Unit tests only (`test/unit/`) | Tight development loop | | `npm run test:integration` | Integration tests (`test/integration/`) | After changing pipelines, DB, workers | | `npm run test:coverage` | Full suite + v8 coverage with thresholds | Checking coverage impact | @@ -26,6 +27,11 @@ From `gitnexus/`: | `npm run test:cross-platform` | Platform-sensitive subset only | Debugging a Windows/macOS issue | | `npm run test:watch` | Vitest in watch mode | Active development | +Vitest transpiles TypeScript without checking types. Run `npm run typecheck:tests` +in `gitnexus/` to check test code and helpers against the production types. This +uses `tsc --noEmit -p tsconfig.test.json`; parser input files under `test/fixtures/` +are excluded because they are sample source code, not part of the test program. + ### `gitnexus-web/` commands From `gitnexus-web/`: @@ -41,7 +47,7 @@ From `gitnexus-web/`: ```bash # gitnexus-shared/dist must exist first. `npm install` / `npm run build` in # gitnexus/ compiles it via parent `lib/tsc.js` (do not npm ci gitnexus-shared). -cd gitnexus && npx tsc --noEmit && npm test +cd gitnexus && npx tsc --noEmit && npm run typecheck:tests && npm test cd ../gitnexus-web && npx tsc -b --noEmit && npm test ``` @@ -121,6 +127,9 @@ GitHub Actions (`.github/workflows/ci.yml`) orchestrate: The `CI Gate` job in `ci.yml` is the single required check for branch protection. It requires quality, tests, e2e, and scope-parity to all pass. +The `typecheck` job runs both the production compiler check and +`npm run typecheck:tests`. A type error in either check fails the job and the CI gate. + ## Regression testing Re-run the full relevant suite when: diff --git a/gitnexus/package.json b/gitnexus/package.json index 1db831d0d..99422e958 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -52,6 +52,7 @@ "serve": "tsx src/cli/index.ts serve", "dev": "tsx watch src/cli/index.ts", "test": "vitest run", + "typecheck:tests": "tsc --noEmit -p tsconfig.test.json", "test:unit": "vitest run test/unit", "pretest:integration": "node scripts/build.js", "test:integration": "vitest run test/integration", From 10947d4b525325774cc8dca3d701c28ca80184dd Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Mon, 5 Oct 2026 06:19:46 +0100 Subject: [PATCH 06/13] fix(setup): preserve existing HTTP GitNexus MCP entries (#3460) --- gitnexus/README.md | 4 +- gitnexus/src/cli/editor-targets.ts | 11 +-- gitnexus/src/cli/setup.ts | 82 ++++++++++++++++++++--- gitnexus/test/unit/setup-codex.test.ts | 92 ++++++++++++++++++++++++++ gitnexus/test/unit/setup.test.ts | 61 +++++++++++++++++ gitnexus/test/unit/uninstall.test.ts | 51 ++++++++++++++ 6 files changed, 285 insertions(+), 16 deletions(-) diff --git a/gitnexus/README.md b/gitnexus/README.md index eff30e026..6e0f6e05d 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -36,6 +36,8 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up `gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list or repeat the option, for example `gitnexus setup -c cursor,codex`. +For Claude Code, Cursor, and Codex, setup keeps an existing URL-based `gitnexus` MCP entry and its authentication settings. Skills are still installed; setup installs Claude Code and Codex hooks, while Cursor hooks require [manual installation](../gitnexus-cursor-integration/README.md#hook-install). + ### Editor Support | Editor | MCP | Skills | Hooks (auto-augment) | Support | @@ -398,7 +400,7 @@ An operation must name a protocol, either through its own `bindings` or through Like Actuator snapshots, documents are external to git freshness — replacing one moves no commit and dirties no file — so an enabled run always rebuilds, and the first later run without the option rebuilds once to remove document-derived evidence. There is no glob-based auto-discovery, and the option is unsupported with `--watch`. -> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove. +> **`gitnexus uninstall`** removes the named GitNexus MCP entries, hooks, and skill directories from detected editors. This includes a URL-based MCP entry you configured yourself, even if setup preserved it. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove. ## Remote Embeddings diff --git a/gitnexus/src/cli/editor-targets.ts b/gitnexus/src/cli/editor-targets.ts index 212a4c400..f3267aa4b 100644 --- a/gitnexus/src/cli/editor-targets.ts +++ b/gitnexus/src/cli/editor-targets.ts @@ -56,7 +56,7 @@ export interface McpJsoncTarget { export interface CodexMcpTarget { id: 'codex'; label: string; - /** Absolute path to ~/.codex/config.toml. */ + /** Absolute path to CODEX_HOME/config.toml (default ~/.codex/config.toml). */ configFile: string; /** The TOML table header (without brackets) setup writes / uninstall strips. */ tomlSection: string; @@ -100,6 +100,9 @@ export interface EditorTargets { * `process.env.HOME` before invoking sees the right locations. */ export function getEditorTargets(home: string = os.homedir()): EditorTargets { + const codexHome = process.env.CODEX_HOME + ? path.resolve(process.env.CODEX_HOME) + : path.join(home, '.codex'); const mcpJsonc: McpJsoncTarget[] = [ { id: 'cursor', @@ -166,7 +169,7 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets { const codex: CodexMcpTarget = { id: 'codex', label: 'Codex', - configFile: path.join(home, '.codex', 'config.toml'), + configFile: path.join(codexHome, 'config.toml'), tomlSection: 'mcp_servers.gitnexus', }; @@ -205,10 +208,10 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets { // Codex hooks use Claude Code's exact {hooks: {Event: [...]}} JSON shape // and hookSpecificOutput response contract, in a dedicated hooks.json // (https://developers.openai.com/codex/hooks). - settingsFile: path.join(home, '.codex', 'hooks.json'), + settingsFile: path.join(codexHome, 'hooks.json'), events: ['PreToolUse', 'PostToolUse'], needle: 'gitnexus-hook', - scriptDir: path.join(home, '.codex', 'hooks', 'gitnexus'), + scriptDir: path.join(codexHome, 'hooks', 'gitnexus'), }, { id: 'antigravity', diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index f03c58aa3..33de0ad6b 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -12,7 +12,16 @@ import os from 'os'; import { execFile, execFileSync } from 'child_process'; import { promisify } from 'util'; import { fileURLToPath } from 'url'; -import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser'; +import { + parseTree, + findNodeAtLocation, + getNodeValue, + modify, + applyEdits, + ParseError, + parse as parseJsonc, +} from 'jsonc-parser'; +import { parse as parseToml, TomlError } from 'smol-toml'; import { packageVersion } from '../core/package-version.js'; import { getGlobalDir } from '../storage/repo-manager.js'; import { @@ -214,7 +223,8 @@ async function mergeJsoncFile( filePath: string, keyPath: string[], value: unknown, -): Promise { + options?: { preserveHttpEntry?: boolean }, +): Promise { let raw: string; try { raw = await fs.readFile(filePath, 'utf-8'); @@ -240,6 +250,15 @@ async function mergeJsoncFile( const tree = parseTree(raw, parseErrors); if (tree && tree.type === 'object' && parseErrors.length === 0) { + const existingNode = options?.preserveHttpEntry ? findNodeAtLocation(tree, keyPath) : undefined; + const existingEntry = existingNode === undefined ? undefined : getNodeValue(existingNode); + if ( + existingEntry !== null && + typeof existingEntry === 'object' && + typeof (existingEntry as Record).url === 'string' + ) { + return 'preserved-http'; + } const formattingOptions = detectIndentation(raw); const edits = modify(raw, keyPath, value, { formattingOptions }); const result = applyEdits(raw, edits); @@ -306,8 +325,12 @@ async function setupCursor(result: SetupResult): Promise { const { file: mcpPath, keyPath } = mcpTarget('cursor'); try { - const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry()); - if (ok) { + const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry(), { + preserveHttpEntry: true, + }); + if (ok === 'preserved-http') { + result.configured.push('Cursor (existing HTTP MCP entry kept)'); + } else if (ok) { result.configured.push('Cursor'); } else { result.errors.push('Cursor: mcp.json is corrupt — skipping to preserve existing content'); @@ -327,8 +350,12 @@ async function setupClaudeCode(result: SetupResult): Promise { // Claude Code stores MCP config in ~/.claude.json const { file: mcpPath, keyPath } = mcpTarget('claude'); try { - const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry()); - if (ok) { + const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry(), { + preserveHttpEntry: true, + }); + if (ok === 'preserved-http') { + result.configured.push('Claude Code (existing HTTP MCP entry kept)'); + } else if (ok) { result.configured.push('Claude Code'); } else { result.errors.push( @@ -1044,13 +1071,47 @@ async function upsertCodexConfigToml(configPath: string): Promise { await fs.writeFile(configPath, `${nextContent.trimEnd()}\n`, 'utf-8'); } +async function codexHasHttpMcpEntry(configPath: string): Promise { + let raw: string; + try { + raw = await fs.readFile(configPath, 'utf-8'); + } catch (err) { + if (isEnoent(err)) return false; + throw err; + } + const config = parseToml(raw); + const servers = config.mcp_servers; + if (servers === null || typeof servers !== 'object') return false; + const entry = (servers as Record).gitnexus; + return ( + entry !== null && + typeof entry === 'object' && + typeof (entry as Record).url === 'string' + ); +} + async function setupCodex(result: SetupResult): Promise { - const codexDir = path.join(os.homedir(), '.codex'); - if (!(await dirExists(codexDir))) { + const configPath = getEditorTargets().codex.configFile; + const codexDir = path.dirname(configPath); + if (!process.env.CODEX_HOME && !(await dirExists(codexDir))) { result.skipped.push('Codex (not installed)'); return; } + try { + if (await codexHasHttpMcpEntry(configPath)) { + result.configured.push('Codex (existing HTTP MCP entry kept)'); + return; + } + } catch (err) { + result.errors.push( + err instanceof TomlError + ? `Codex: invalid config.toml (line ${err.line}, column ${err.column}); MCP entry unchanged` + : `Codex: ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } + try { const entry = getMcpEntry(); await execFileAsync('codex', ['mcp', 'add', 'gitnexus', '--', entry.command, ...entry.args], { @@ -1064,9 +1125,8 @@ async function setupCodex(result: SetupResult): Promise { } try { - const configPath = getEditorTargets().codex.configFile; await upsertCodexConfigToml(configPath); - result.configured.push('Codex (MCP added to ~/.codex/config.toml)'); + result.configured.push('Codex (MCP added to active config.toml)'); } catch (err: any) { result.errors.push(`Codex: ${err.message}`); } @@ -1265,7 +1325,7 @@ async function installOpenCodeSkills(result: SetupResult): Promise { * Install global Codex skills to ~/.agents/skills/gitnexus/ */ async function installCodexSkills(result: SetupResult): Promise { - const codexDir = path.join(os.homedir(), '.codex'); + const codexDir = path.dirname(getEditorTargets().codex.configFile); if (!(await dirExists(codexDir))) return; const skillsDir = skillTarget('codex').dir; diff --git a/gitnexus/test/unit/setup-codex.test.ts b/gitnexus/test/unit/setup-codex.test.ts index a30badf50..6b19738dd 100644 --- a/gitnexus/test/unit/setup-codex.test.ts +++ b/gitnexus/test/unit/setup-codex.test.ts @@ -27,6 +27,7 @@ describe('setupCommand codex execution', () => { let tempHome: string; let originalHome: string | undefined; let originalUserProfile: string | undefined; + let originalCodexHome: string | undefined; let platformDescriptor: PropertyDescriptor | undefined; const setPlatform = (value: NodeJS.Platform) => { @@ -42,6 +43,8 @@ describe('setupCommand codex execution', () => { originalHome = process.env.HOME; originalUserProfile = process.env.USERPROFILE; + originalCodexHome = process.env.CODEX_HOME; + delete process.env.CODEX_HOME; tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codex-setup-')); process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; @@ -62,6 +65,8 @@ describe('setupCommand codex execution', () => { process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; await fs.rm(tempHome, { recursive: true, force: true }); }); @@ -110,6 +115,93 @@ describe('setupCommand codex execution', () => { await expect(fs.access(path.join(tempHome, '.codex', 'config.toml'))).rejects.toThrow(); }); + it('keeps an existing HTTP entry and its bearer-token setting without invoking codex mcp add', async () => { + const configPath = path.join(tempHome, '.codex', 'config.toml'); + const raw = + '[mcp_servers.gitnexus]\nurl = "http://127.0.0.1:4748/mcp"\nbearer_token_env_var = "GITNEXUS_TOKEN"\n'; + await fs.writeFile(configPath, raw, 'utf-8'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + expect(execFileMock).not.toHaveBeenCalled(); + expect(await fs.readFile(configPath, 'utf-8')).toBe(raw); + expect( + await fs.stat(path.join(tempHome, '.agents', 'skills', 'gitnexus-guide', 'SKILL.md')), + ).toBeDefined(); + }); + + it('does not mistake an HTTP entry in the default home for the active CODEX_HOME entry', async () => { + const defaultConfig = path.join(tempHome, '.codex', 'config.toml'); + const activeHome = path.join(tempHome, 'active-codex'); + await fs.writeFile(defaultConfig, '[mcp_servers.gitnexus]\nurl = "https://example.test/mcp"\n'); + await fs.mkdir(activeHome); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + expect(execFileMock).toHaveBeenCalledWith( + 'codex', + ['mcp', 'add', 'gitnexus', '--', 'cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'], + { shell: true, windowsHide: true }, + expect.any(Function), + ); + }); + + it('preserves an HTTP entry in the active CODEX_HOME even when the default home is empty', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + const activeConfig = path.join(activeHome, 'config.toml'); + const raw = '[mcp_servers.gitnexus]\nurl = "https://example.test/mcp"\n'; + await fs.mkdir(activeHome); + await fs.writeFile(activeConfig, raw); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + expect(execFileMock).not.toHaveBeenCalled(); + expect(await fs.readFile(activeConfig, 'utf-8')).toBe(raw); + }); + + it('writes the fallback entry to the active CODEX_HOME when the Codex CLI is unavailable', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + process.env.CODEX_HOME = activeHome; + execFileMock.mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1); + if (typeof callback === 'function') callback(new Error('codex unavailable')); + }); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + expect(await fs.readFile(path.join(activeHome, 'config.toml'), 'utf-8')).toContain( + '[mcp_servers.gitnexus]', + ); + await expect(fs.access(path.join(tempHome, '.codex', 'config.toml'))).rejects.toThrow(); + }); + + it('does not print TOML source or nearby credentials when the active config is malformed', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + const configPath = path.join(activeHome, 'config.toml'); + const token = 'Bearer FAKE_PR3460_REVIEW_TOKEN'; + await fs.mkdir(activeHome); + await fs.writeFile( + configPath, + `[mcp_servers.gitnexus]\nhttp_headers = { Authorization = "${token}" }\ninvalid =\n`, + ); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + const output = JSON.stringify(vi.mocked(console.log).mock.calls); + expect(output).toMatch(/invalid config\.toml \(line 3, column \d+\)/); + expect(output).not.toContain(token); + expect(execFileMock).not.toHaveBeenCalled(); + expect(await fs.readFile(configPath, 'utf-8')).toContain(token); + }); + it('skips Codex setup entirely when ~/.codex is missing', async () => { await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true }); diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index e9d0a5873..e61859ab4 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -134,6 +134,40 @@ describe('setupClaudeCode', () => { expect(config.mcpServers.gitnexus).toBeDefined(); }); + it('keeps existing Claude and Cursor HTTP MCP entries while installing skills', async () => { + const claudePath = path.join(tempHome, '.claude.json'); + const cursorDir = path.join(tempHome, '.cursor'); + const cursorPath = path.join(cursorDir, 'mcp.json'); + await fs.mkdir(cursorDir, { recursive: true }); + const claudeRaw = `{ + // Shared GitNexus server + "mcpServers": { + "gitnexus": { "type": "http", "url": "http://127.0.0.1:4748/mcp", "headers": { "Authorization": "Bearer $GITNEXUS_TOKEN" } } + } +}`; + const cursorRaw = JSON.stringify({ + mcpServers: { + gitnexus: { + url: 'http://127.0.0.1:4748/mcp', + headers: { Authorization: 'Bearer $GITNEXUS_TOKEN' }, + }, + }, + }); + await fs.writeFile(claudePath, claudeRaw, 'utf-8'); + await fs.writeFile(cursorPath, cursorRaw, 'utf-8'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: ['claude', 'cursor'] }); + + expect(await fs.readFile(claudePath, 'utf-8')).toBe(claudeRaw); + expect(await fs.readFile(cursorPath, 'utf-8')).toBe(cursorRaw); + expect(logLines()).toContain('Claude Code (existing HTTP MCP entry kept)'); + expect(logLines()).toContain('Cursor (existing HTTP MCP entry kept)'); + expect( + await fs.stat(path.join(tempHome, '.claude', 'skills', 'gitnexus-guide', 'SKILL.md')), + ).toBeDefined(); + }); + it('handles missing ~/.claude.json (creates fresh)', async () => { setPlatform('linux'); @@ -800,6 +834,7 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { let tempHome: string; let originalHome: string | undefined; let originalUserProfile: string | undefined; + let originalCodexHome: string | undefined; const hooksJsonPath = () => path.join(tempHome, '.codex', 'hooks.json'); @@ -809,6 +844,8 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { originalHome = process.env.HOME; originalUserProfile = process.env.USERPROFILE; + originalCodexHome = process.env.CODEX_HOME; + delete process.env.CODEX_HOME; tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codex-hooks-')); process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; @@ -824,6 +861,8 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { vi.restoreAllMocks(); process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; await fs.rm(tempHome, { recursive: true, force: true }); }); @@ -844,6 +883,28 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { ).resolves.toBeUndefined(); }); + it('installs hooks and skills for active CODEX_HOME without touching the default home', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + const defaultHooks = hooksJsonPath(); + await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true }); + await fs.mkdir(activeHome); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + const hooks = JSON.parse(await fs.readFile(path.join(activeHome, 'hooks.json'), 'utf-8')).hooks; + expect(hooks.PreToolUse).toHaveLength(1); + expect(hooks.PostToolUse).toHaveLength(1); + await expect( + fs.access(path.join(activeHome, 'hooks', 'gitnexus', 'gitnexus-hook.cjs')), + ).resolves.toBeUndefined(); + await expect( + fs.access(path.join(tempHome, '.agents', 'skills', 'gitnexus-guide', 'SKILL.md')), + ).resolves.toBeUndefined(); + await expect(fs.access(defaultHooks)).rejects.toThrow(); + }); + it('is idempotent — a second setup run adds no duplicate entries', async () => { const { setupCommand } = await import('../../src/cli/setup.js'); await setupCommand(); diff --git a/gitnexus/test/unit/uninstall.test.ts b/gitnexus/test/unit/uninstall.test.ts index ad20eb5ca..5c3a2e285 100644 --- a/gitnexus/test/unit/uninstall.test.ts +++ b/gitnexus/test/unit/uninstall.test.ts @@ -23,6 +23,7 @@ describe('uninstallCommand', () => { let tempHome: string; let originalHome: string | undefined; let originalUserProfile: string | undefined; + let originalCodexHome: string | undefined; let originalSkillsRoot: string | undefined; let originalExitCode: typeof process.exitCode; let skillsRoot: string; @@ -35,6 +36,8 @@ describe('uninstallCommand', () => { originalHome = process.env.HOME; originalUserProfile = process.env.USERPROFILE; + originalCodexHome = process.env.CODEX_HOME; + delete process.env.CODEX_HOME; originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT; originalExitCode = process.exitCode; @@ -57,6 +60,8 @@ describe('uninstallCommand', () => { vi.restoreAllMocks(); process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; if (originalSkillsRoot === undefined) delete process.env.GITNEXUS_TEST_SKILLS_ROOT; else process.env.GITNEXUS_TEST_SKILLS_ROOT = originalSkillsRoot; // The command sets process.exitCode=1 on partial failure; restore it so a @@ -203,6 +208,52 @@ describe('uninstallCommand', () => { expect(result).toContain('command = "other"'); }); + it('dry-runs and removes only the active CODEX_HOME MCP entry', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + const activeConfig = path.join(activeHome, 'config.toml'); + const defaultConfig = path.join(tempHome, '.codex', 'config.toml'); + const activeHooks = path.join(activeHome, 'hooks.json'); + const defaultHooks = path.join(tempHome, '.codex', 'hooks.json'); + const activeScriptDir = path.join(activeHome, 'hooks', 'gitnexus'); + const defaultScriptDir = path.join(tempHome, '.codex', 'hooks', 'gitnexus'); + const activeRaw = '[mcp_servers.gitnexus]\ncommand = "active"\n'; + const defaultRaw = '[mcp_servers.gitnexus]\ncommand = "default"\n'; + const hooksRaw = JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: 'Bash', hooks: [{ type: 'command', command: 'node gitnexus-hook.cjs' }] }, + { matcher: 'Read', hooks: [{ type: 'command', command: 'user-hook' }] }, + ], + }, + }); + await fs.mkdir(activeHome); + await fs.mkdir(path.dirname(defaultConfig)); + await fs.mkdir(activeScriptDir, { recursive: true }); + await fs.mkdir(defaultScriptDir, { recursive: true }); + await fs.writeFile(activeConfig, activeRaw); + await fs.writeFile(defaultConfig, defaultRaw); + await fs.writeFile(activeHooks, hooksRaw); + await fs.writeFile(defaultHooks, hooksRaw); + process.env.CODEX_HOME = activeHome; + + const uninstallCommand = await importUninstall(); + await uninstallCommand(); + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain(activeConfig); + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain(activeHooks); + expect(await fs.readFile(activeConfig, 'utf-8')).toBe(activeRaw); + expect(await fs.readFile(activeHooks, 'utf-8')).toBe(hooksRaw); + + await uninstallCommand({ force: true }); + expect(await fs.readFile(activeConfig, 'utf-8')).not.toContain('[mcp_servers.gitnexus]'); + expect(await fs.readFile(defaultConfig, 'utf-8')).toBe(defaultRaw); + const remaining = JSON.parse(await fs.readFile(activeHooks, 'utf-8')); + expect(remaining.hooks.PreToolUse).toHaveLength(1); + expect(remaining.hooks.PreToolUse[0].hooks[0].command).toBe('user-hook'); + expect(await fs.readFile(defaultHooks, 'utf-8')).toBe(hooksRaw); + await expect(fs.access(activeScriptDir)).rejects.toThrow(); + await expect(fs.access(defaultScriptDir)).resolves.toBeUndefined(); + }); + it('leaves a corrupt JSON config untouched', async () => { const claudeJson = path.join(tempHome, '.claude.json'); const corrupt = '{ not valid json !!!'; From ced9660e776469735bfe2dda8cb119919ac4fa43 Mon Sep 17 00:00:00 2001 From: Parafee41 Date: Mon, 5 Oct 2026 15:27:59 +0800 Subject: [PATCH 07/13] fix(augment): recover symbols missed by FTS file ranking (#3422) --- gitnexus/src/core/augmentation/engine.ts | 14 +++--- .../test/integration/augmentation.test.ts | 44 ++++++++++++++----- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/gitnexus/src/core/augmentation/engine.ts b/gitnexus/src/core/augmentation/engine.ts index f72223b5c..ef6fe0c8c 100644 --- a/gitnexus/src/core/augmentation/engine.ts +++ b/gitnexus/src/core/augmentation/engine.ts @@ -129,7 +129,7 @@ export async function augment(pattern: string, cwd?: string): Promise { } // Step 1: BM25 search (fast, no embeddings) - const { results: bm25Results, ftsAvailable } = await searchFTSFromLbug(pattern, 10, repoId); + const { results: bm25Results } = await searchFTSFromLbug(pattern, 10, repoId); // Step 2: Map BM25 file results to symbols const symbolMatches: Array<{ @@ -167,16 +167,18 @@ export async function augment(pattern: string, cwd?: string): Promise { } } - // When FTS indexes are unavailable (read-only DB, first run before indexes are built), - // fall back to a direct name CONTAINS query so enrichment still works. - if (symbolMatches.length === 0 && !ftsAvailable) { + // FTS ranks files by mentions, so a widely referenced symbol's definition + // may not be present in the top file results. Fall back to graph names + // whenever those files produce no symbol match, regardless of FTS health. + if (symbolMatches.length === 0) { const fallbackRows = await executeQuery( repoId, ` MATCH (n) WHERE n.name CONTAINS '${patternFirstWord}' - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath - ORDER BY id + RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, + CASE WHEN n.name = '${patternFirstWord}' THEN 0 ELSE 1 END AS exactRank + ORDER BY exactRank, id LIMIT 5 `, ).catch(() => []); diff --git a/gitnexus/test/integration/augmentation.test.ts b/gitnexus/test/integration/augmentation.test.ts index 460df12a6..479fe393f 100644 --- a/gitnexus/test/integration/augmentation.test.ts +++ b/gitnexus/test/integration/augmentation.test.ts @@ -21,6 +21,11 @@ const AUGMENT_SEED_DATA = [ `CREATE (n:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 15, isExported: true, content: 'function login authenticates user credentials', description: 'user login'})`, `CREATE (n:Function {id: 'func:validate', name: 'validate', filePath: 'src/auth.ts', startLine: 17, endLine: 25, isExported: true, content: 'function validate checks user input', description: 'input validation'})`, `CREATE (n:Function {id: 'func:hash', name: 'hash', filePath: 'src/utils.ts', startLine: 1, endLine: 8, isExported: true, content: 'function hash computes bcrypt hash', description: 'password hashing'})`, + ...Array.from( + { length: 5 }, + (_, i) => + `CREATE (n:Function {id: 'a:login-${i}', name: 'loginPartial${i}', filePath: 'src/login-partial-${i}.ts', startLine: 1, endLine: 2, isExported: true, content: 'partial login match ${i}', description: 'login partial'})`, + ), // Class / Method / Interface nodes `CREATE (n:Class {id: 'class:AuthService', name: 'AuthService', filePath: 'src/auth.ts', startLine: 30, endLine: 60, isExported: true, content: 'class AuthService handles authentication', description: 'auth service'})`, @@ -130,23 +135,40 @@ withTestLbugDB( expect(result).toBe(''); }); - // ─── Negative-safety: fallback must stay gated on !ftsAvailable ─── - // - // When FTS is available but happens to return zero BM25 hits, the - // CONTAINS fallback must NOT fire — preserving the original early-return - // semantics. If anyone later loosens the gate to `symbolMatches.length - // === 0` alone, this test fails. - - it('does NOT fire CONTAINS fallback when FTS is available but BM25 returns empty', async () => { + it('falls back to graph names when FTS is available but BM25 returns empty', async () => { const bm25 = await import('../../src/core/search/bm25-index.js'); const spy = vi .spyOn(bm25, 'searchFTSFromLbug') .mockResolvedValue({ results: [], ftsAvailable: true }); try { - // 'login' WOULD match a graph node via CONTAINS, but FTS is available - // and empty → fallback gate must hold → result must be ''. + // FTS health does not imply its top file results contain the symbol's + // definition. The graph-name fallback must recover the exact node. const result = await augment('login', handle.dbPath); - expect(result).toBe(''); + expect(result).toContain('[GitNexus]'); + expect(result).toMatch(/^login \(src\/auth\.ts\)$/m); + } finally { + spy.mockRestore(); + } + }); + + it('finds an exact-name symbol when FTS ranks only mentioning files', async () => { + const bm25 = await import('../../src/core/search/bm25-index.js'); + const spy = vi.spyOn(bm25, 'searchFTSFromLbug').mockResolvedValue({ + results: Array.from({ length: 5 }, (_, i) => ({ + filePath: `src/caller-${i}.ts`, + score: 100 - i, + rank: i + 1, + nodeIds: [`file:caller-${i}`], + })), + ftsAvailable: true, + }); + try { + // Five partial login matches have IDs that sort before func:login, so + // the five-row fallback only retains the exact node when exact names + // rank ahead of partial matches. + const result = await augment('login', handle.dbPath); + expect(result).toContain('[GitNexus]'); + expect(result).toMatch(/^login \(src\/auth\.ts\)$/m); } finally { spy.mockRestore(); } From dbcfa63d40743f2a13e9350accbffb2093b9f26c Mon Sep 17 00:00:00 2001 From: rgb-vgx Date: Mon, 5 Oct 2026 19:14:03 +0700 Subject: [PATCH 08/13] fix(group): match Go framework imports exactly and follow bare-block group writes (#3473) --- .../core/group/extractors/http-patterns/go.ts | 254 ++++++++++++++++-- .../ingestion/route-extractors/go-gin-echo.ts | 6 +- .../ingestion/route-extractors/go-shared.ts | 14 + .../unit/group/go-gin-route-groups.test.ts | 232 +++++++++++++++- 4 files changed, 472 insertions(+), 34 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/go.ts b/gitnexus/src/core/group/extractors/http-patterns/go.ts index 6d29de5fe..5b1557831 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/go.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/go.ts @@ -1,7 +1,11 @@ import type Parser from 'tree-sitter'; import Go from 'tree-sitter-go'; import { goImportPackageName } from '../../../ingestion/languages/go/import-package-name.js'; -import { stringLiteral } from '../../../ingestion/route-extractors/go-shared.js'; +import { + isEchoImportPath, + isGinImportPath, + stringLiteral, +} from '../../../ingestion/route-extractors/go-shared.js'; import { compilePatterns, runCompiledPatterns, @@ -65,7 +69,8 @@ const HANDLER_ARG_TYPES: ReadonlySet = new Set([ /** * The file's framework import aliases: which local qualifiers resolve to - * echo and to gin. Matched on the import path rather than the local name, so + * echo and to gin. Matched on the EXACT import path (shared with ingestion via + * go-shared.ts) rather than the local name, so * an aliased import still counts; an unaliased import is keyed by its * conventional package name (`goImportPackageName`). `_` and `.` imports bind * no qualifier this file can route through. An empty set means the file @@ -89,8 +94,8 @@ function readFrameworkImports(root: Parser.SyntaxNode): { if (importPath === null) continue; const local = spec.childForFieldName('name')?.text ?? goImportPackageName(importPath); if (local === '_' || local === '.') continue; - if (importPath.includes('labstack/echo')) echo.add(local); - else if (importPath.includes('gin-gonic/gin')) gin.add(local); + if (isEchoImportPath(importPath)) echo.add(local); + else if (isGinImportPath(importPath)) gin.add(local); } return { echo, gin }; } @@ -153,13 +158,25 @@ function asGroupCall( return { parent, prefix }; } +/** The `var_spec`s of a `var_declaration`, flattened out of a grouped `var (…)`. */ +function varSpecs(decl: Parser.SyntaxNode): Parser.SyntaxNode[] { + return decl.namedChildren + .flatMap((c) => (c.type === 'var_spec_list' ? c.namedChildren : [c])) + .filter((spec) => spec.type === 'var_spec'); +} + /** The expression `name` is assigned by `stmt` (`:=`, `=`, or `var`), if any. */ function boundValue(stmt: Parser.SyntaxNode, name: string): Parser.SyntaxNode | null | undefined { const pick = ( names: Parser.SyntaxNode[], values: Parser.SyntaxNode | null, ): Parser.SyntaxNode | null | undefined => { - const i = names.findIndex((n) => n.type === 'identifier' && n.text === name); + // Go carries an assignment out left to right, so a repeated target leaves + // the LAST one in place (`g, g = a, b` ends with g == b). + let i = -1; + for (let k = 0; k < names.length; k++) { + if (names[k].type === 'identifier' && names[k].text === name) i = k; + } if (i < 0) return undefined; return codeChildren(values)[i] ?? null; }; @@ -170,11 +187,7 @@ function boundValue(stmt: Parser.SyntaxNode, name: string): Parser.SyntaxNode | case 'var_spec': return pick(stmt.childrenForFieldName('name'), stmt.childForFieldName('value')); case 'var_declaration': { - const specs = stmt.namedChildren.flatMap((c) => - c.type === 'var_spec_list' ? c.namedChildren : [c], - ); - for (const spec of specs) { - if (spec.type !== 'var_spec') continue; + for (const spec of varSpecs(stmt)) { const value = pick(spec.childrenForFieldName('name'), spec.childForFieldName('value')); if (value !== undefined) return value; } @@ -221,21 +234,206 @@ function within(outer: Parser.SyntaxNode | null, inner: Parser.SyntaxNode): bool } /** - * Whether `stmt` writes `name` with a plain assignment somewhere inside it - * (`g = …` in a nested block, branch, loop, or closure body). A nested `:=` - * declares a new variable and is not a write to the outer one. + * Whether `node` is itself a write to `name`: a plain `assignment_statement` + * (`g = …`) or the `=` form of a range clause (`for _, g = range xs`) or a + * select receive (`case g = <-ch`), which tree-sitter parses as their own node + * types. A `:=` declares a new variable — loop- or case-local — and is not a + * write to the outer one. + */ +function isWriteTo(node: Parser.SyntaxNode, name: string): boolean { + if (node.type === 'assignment_statement') { + return declaresName(node.childForFieldName('left'), name); + } + return ( + (node.type === 'range_clause' || node.type === 'receive_statement') && + assignsExistingName(node) && + declaresName(node.childForFieldName('left'), name) + ); +} + +/** + * Every write to `name` inside `scope` (a statement or a function literal). + * With `executedOnly`, a nested function literal is entered only through a + * call: one merely stored in a value does not run when its enclosing body + * runs. + */ +function writesInScope( + scope: Parser.SyntaxNode, + name: string, + executedOnly: boolean, +): Parser.SyntaxNode[] { + const out: Parser.SyntaxNode[] = []; + const walk = (node: Parser.SyntaxNode): void => { + if (node.type === 'func_literal') { + if (executedOnly && !isInvokedLiteral(node)) return; + const body = node.childForFieldName('body'); + if (body) walk(body); + return; + } + if (isWriteTo(node, name)) out.push(node); + for (const child of node.namedChildren) walk(child); + }; + walk(scope); + return out; +} + +/** + * Whether `scope` writes `name` through a binding declared OUTSIDE it: an + * assignment (or `=` range/receive) whose target resolves to a name the scope + * itself does not declare — a captured outer group rather than a local `:=`, + * `var`, or parameter. An unresolvable or conflicting write reaches out + * conservatively. With `executedOnly`, writes that only run if a nested + * function literal is later called are ignored. + */ +function writesOuterName(scope: Parser.SyntaxNode, name: string, executedOnly = false): boolean { + for (const write of writesInScope(scope, name, executedOnly)) { + const ident = identifierNamed(write.childForFieldName('left'), name); + if (!ident) continue; + // A range/receive `=` target resolves from OUTSIDE its clause: the + // clause's own binding logic describes the loop/case variable, not the + // existing name the `=` writes. + const from = + write.type === 'range_clause' || write.type === 'receive_statement' + ? write.parent + : undefined; + const binding = lookupBinding(ident, from); + // null is a declaration without a traceable value; it carries no position + // to place, so a write through it reaches out conservatively. + if (binding === undefined || binding === null || binding === CONFLICT) return true; + const declared = isParamBinding(binding) ? binding.param : binding; + if (!within(scope, declared)) return true; + } + return false; +} + +/** + * Whether `stmt` writes `name` through a binding declared outside it — a + * nested assignment, branch, loop, or closure body. A declaration that + * shadows `name` inside the statement (a nested `:=`, a parameter) writes the + * local, not the target the use would see, so it does not count. */ function writesNameInside(stmt: Parser.SyntaxNode, name: string): boolean { - return [stmt, ...stmt.descendantsOfType('assignment_statement')].some( - (a) => a.type === 'assignment_statement' && declaresName(a.childForFieldName('left'), name), + return writesOuterName(stmt, name); +} + +/** A range clause or receive that assigns (`=`) rather than declares (`:=`). */ +function assignsExistingName(clause: Parser.SyntaxNode): boolean { + return !clause.children.some((c) => c.type === ':='); +} + +/** + * Whether `fn` is the callee of a call (`func(){ … }()`), ignoring + * parentheses, so its body is known to run. + */ +function isInvokedLiteral(fn: Parser.SyntaxNode): boolean { + let callee: Parser.SyntaxNode = fn; + while (callee.parent?.type === 'parenthesized_expression') callee = callee.parent; + const parent = callee.parent; + return ( + !!parent && + parent.type === 'call_expression' && + parent.childForFieldName('function')?.id === callee.id ); } +/** + * Whether evaluating `node` executes a write to `name`. A function literal is + * only entered when it is the callee of a call: `g := func(){ g = … }` merely + * stores the literal, so the outer `g` is untouched, while `func(){ … }()` + * runs its body (writes that resolve to the literal's own locals excepted). + */ +function executedWritesName(node: Parser.SyntaxNode, name: string): boolean { + if (node.type === 'func_literal') { + return isInvokedLiteral(node) && writesOuterName(node, name, true); + } + if (isWriteTo(node, name)) return true; + return node.namedChildren.some((child) => executedWritesName(child, name)); +} + +/** + * Whether the initializer of the declaration that brings `name` into scope in + * a block writes the OUTER `name`. The right-hand side runs before the new + * variable exists, so `g := func(){ g = … }()` still writes the binding the + * use outside the block would inherit — but a literal only stored in a value + * never runs its body. Grouped `var` specs up to and including the declaring + * one are scanned; later specs read the new local. + */ +function initializerWritesName(decl: Parser.SyntaxNode, name: string): boolean { + if (decl.type === 'short_var_declaration') { + const values = decl.childForFieldName('right'); + return !!values && executedWritesName(values, name); + } + if (decl.type === 'var_declaration') { + for (const spec of varSpecs(decl)) { + const value = spec.childForFieldName('value'); + if (value && executedWritesName(value, name)) return true; + if (boundValue(spec, name) !== undefined) return false; + } + } + return false; +} + +/** + * Whether `stmt` holds a function literal that writes the outer `name`. Such + * a literal can run after any later direct assignment (`reset := func(){ g = … }; + * g = …; reset()`), so captured writes are found independently of statement + * order instead of being lost behind the last-assignment return. Writes that + * resolve to a declaration inside the literal (a parameter, a nested `:=`) are + * local and do not count. + */ +function closureWritesName(stmt: Parser.SyntaxNode, name: string): boolean { + const literals = + stmt.type === 'func_literal' + ? [stmt, ...stmt.descendantsOfType('func_literal')] + : stmt.descendantsOfType('func_literal'); + return literals.some((fn) => writesOuterName(fn, name)); +} + +/** + * The effect a bare `{ … }` block placed before the use has on the outer + * `name`. A bare block always runs, so its last top-level `name = v` is the + * value that reaches the use (`{ g = r.Group("/new") }` → `/new`); a write + * nested in a branch, loop, or closure inside it is CONFLICT. A `:=` or `var` + * of `name` in the block starts a new variable, so only the statements before + * it touch the outer one — but the declaring statement's initializer still + * does, because it evaluates before the new variable is in scope. undefined + * when the block leaves `name` alone. + */ +function bareBlockWrite(block: Parser.SyntaxNode, name: string): Binding { + const stmts = codeChildren(block); + const declIndex = stmts.findIndex( + (s) => + (s.type === 'short_var_declaration' || s.type === 'var_declaration') && + boundValue(s, name) !== undefined, + ); + const outerRegion = declIndex < 0 ? stmts : stmts.slice(0, declIndex); + if (declIndex >= 0 && initializerWritesName(stmts[declIndex], name)) return CONFLICT; + if (outerRegion.some((stmt) => closureWritesName(stmt, name))) return CONFLICT; + for (const stmt of outerRegion.reverse()) { + if (stmt.type === 'assignment_statement') { + const value = boundValue(stmt, name); + if (value !== undefined) return value; + } + if (stmt.type === 'block') { + const inner = bareBlockWrite(stmt, name); + if (inner !== undefined) return inner; + continue; + } + if (writesNameInside(stmt, name)) return CONFLICT; + } + return undefined; +} + +/** The identifier named `name` on an assignment side (or null). */ +function identifierNamed(node: Parser.SyntaxNode | null, name: string): Parser.SyntaxNode | null { + if (!node) return null; + if (node.type === 'identifier') return node.text === name ? node : null; + return node.namedChildren.find((n) => n.type === 'identifier' && n.text === name) ?? null; +} + /** Whether an identifier or expression_list (e.g. a range left side) declares `name`. */ function declaresName(node: Parser.SyntaxNode | null, name: string): boolean { - if (!node) return false; - if (node.type === 'identifier') return node.text === name; - return node.namedChildren.some((n) => n.type === 'identifier' && n.text === name); + return identifierNamed(node, name) !== null; } /** @@ -262,12 +460,14 @@ function findBinding(ident: Parser.SyntaxNode): Parser.SyntaxNode | null | typeo * CONFLICT means a preceding statement writes the name in a nested scope, so * the value at the use is control-flow dependent. A parameter or method * receiver of the enclosing function returns its declaration (ParamBinding): - * no value, but a static type. + * no value, but a static type. `from` starts the walk at that node's parent + * instead of the identifier's, so an `=` range/receive target can be resolved + * outside its own clause. */ -function lookupBinding(ident: Parser.SyntaxNode): Binding { +function lookupBinding(ident: Parser.SyntaxNode, from?: Parser.SyntaxNode | null): Binding { const name = ident.text; - let child: Parser.SyntaxNode = ident; - for (let node = ident.parent; node; child = node, node = node.parent) { + let child: Parser.SyntaxNode = from ?? ident; + for (let node = child.parent; node; child = node, node = node.parent) { if ( node.type === 'function_declaration' || node.type === 'method_declaration' || @@ -317,8 +517,14 @@ function lookupBinding(ident: Parser.SyntaxNode): Binding { for (const stmt of stmts.slice(0, useIndex).reverse()) { const value = boundValue(stmt, name); if (value !== undefined) return value; - // A write nested in an earlier statement (block, branch, loop) may or - // may not run before the use: the reaching value is unprovable. + // A bare block always runs: its unconditional writes are bindings. + if (stmt.type === 'block') { + const written = bareBlockWrite(stmt, name); + if (written !== undefined) return written; + continue; + } + // A write nested in an earlier branch, loop, or closure may or may + // not run before the use: the reaching value is unprovable. if (writesNameInside(stmt, name)) return CONFLICT; } continue; diff --git a/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts index e77b1b82b..125f02c72 100644 --- a/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts +++ b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts @@ -26,7 +26,7 @@ import type Parser from 'tree-sitter'; import { goImportPackageName } from '../languages/go/import-package-name.js'; import { GoRouteBindings, type GoRouteBinding } from '../languages/go/route-bindings.js'; -import { stringLiteral } from './go-shared.js'; +import { isEchoImportPath, isGinImportPath, stringLiteral } from './go-shared.js'; import { normalizeExtractedRoutePath } from './route-path.js'; import type { SyntaxNode } from 'tree-sitter'; import type { ExtractedDecoratorRoute, RouteHandlerReceiver } from '../workers/parse-worker.js'; @@ -77,8 +77,8 @@ function readImports(root: SyntaxNode): { if (explicit === '_' || explicit === '.') continue; const local = explicit ?? goImportPackageName(importPath); if (!local) continue; - if (importPath === 'github.com/gin-gonic/gin') gin = local; - if (/^github\.com\/labstack\/echo(\/v\d+)?$/.test(importPath)) echo = local; + if (isGinImportPath(importPath)) gin = local; + if (isEchoImportPath(importPath)) echo = local; } // Both, or neither: no way to tell which argument is the handler. if (gin !== null && echo === null) { diff --git a/gitnexus/src/core/ingestion/route-extractors/go-shared.ts b/gitnexus/src/core/ingestion/route-extractors/go-shared.ts index a2d13faef..c2fba1932 100644 --- a/gitnexus/src/core/ingestion/route-extractors/go-shared.ts +++ b/gitnexus/src/core/ingestion/route-extractors/go-shared.ts @@ -1,5 +1,19 @@ import type { SyntaxNode } from 'tree-sitter'; +/** + * Framework import paths, matched EXACTLY on both layers (ingestion's + * `go-gin-echo.ts` and the group-mode `http-patterns/go.ts`): a substring + * test would classify `example.com/labstack/echo-wrapper` as echo on one + * side only, flipping the handler-argument order and splitting contracts. + */ +export function isGinImportPath(importPath: string): boolean { + return importPath === 'github.com/gin-gonic/gin'; +} + +export function isEchoImportPath(importPath: string): boolean { + return /^github\.com\/labstack\/echo(\/v\d+)?$/.test(importPath); +} + /** * Go string-literal decoding shared by the Go route extractors on both * layers: the ingestion extractor (`go-gin-echo.ts`, Strategy A) and the diff --git a/gitnexus/test/unit/group/go-gin-route-groups.test.ts b/gitnexus/test/unit/group/go-gin-route-groups.test.ts index bdb5bde56..5a2a0413c 100644 --- a/gitnexus/test/unit/group/go-gin-route-groups.test.ts +++ b/gitnexus/test/unit/group/go-gin-route-groups.test.ts @@ -576,6 +576,30 @@ func (s *Server) routes(api *echo.Group) { ]); }); + it('matches framework imports by exact path, as ingestion does', () => { + // `example.com/labstack/echo-wrapper` merely contains "labstack/echo": it + // is not echo, so this gin-style call keeps the last-argument handler. + expect( + providers(`package main +import "example.com/labstack/echo-wrapper" + +func routes(r *Router) { + r.GET("/x", auth.Middleware, h.Handler) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'Handler' }]); + // The versioned module path is echo. + expect( + providers(`package main +import "github.com/labstack/echo/v4" + +func routes(e *echo.Echo) { + e.GET("/x", h.Handler, auth.Middleware) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'Handler' }]); + }); + it('picks the handler order per receiver constructor in a mixed-import file', () => { // Mixed imports are ambiguous at file scope, but `e := echo.New()` proves // this call follows echo's order (handler FIRST after the path) and @@ -770,11 +794,12 @@ func routes(r *gin.Engine) { ).toEqual([{ method: 'GET', path: '/api/admin/x', name: 'handler' }]); }); - it('declines a route whose group is reassigned in an earlier nested scope', () => { - // `{ g = r.Group("/new") }` (or a branch) writes the outer g: which value - // reaches the use depends on control flow, and ingestion declines it too, - // so emitting the older `/old/x` would invent a route. A nested `:=` - // declares a new variable and leaves the outer binding intact. + it('follows unconditional block writes and declines conditional ones', () => { + // A bare `{ … }` always runs, so `{ g = r.Group("/new") }` makes the + // route deterministically /new/x. A write in a branch (`if cond { k = … }`, + // also when nested inside a bare block) may or may not run, so that + // prefix is unprovable and the route is declined. A `:=` in a nested + // block declares a new variable: writes after it never reach the outer m. expect( providers(`package main func routes(r *gin.Engine, cond bool) { @@ -784,17 +809,112 @@ func routes(r *gin.Engine, cond bool) { k := r.Group("/k") if cond { k = r.Group("/other") } k.GET("/y", handler) + n := r.Group("/n") + { if cond { n = r.Group("/maybe") } } + n.GET("/w", handler) m := r.Group("/m") - { m := r.Group("/inner"); m.GET("/i", handler) } + { m := r.Group("/inner"); m = r.Group("/inner2"); m.GET("/i", handler) } m.GET("/z", handler) } `), ).toEqual([ - { method: 'GET', path: '/inner/i', name: 'handler' }, + { method: 'GET', path: '/new/x', name: 'handler' }, + { method: 'GET', path: '/inner2/i', name: 'handler' }, { method: 'GET', path: '/m/z', name: 'handler' }, ]); }); + it('declines a route whose group a shadowing initializer or a later closure writes', () => { + // A `:=`/`var` shadowing the name runs its initializer BEFORE the new + // variable is in scope, so a closure body there writes the OUTER group + // and the prefix of a use outside the block is unprovable. A closure + // declared before a direct assignment can also run AFTER it (`reset()`), + // so captured writes are checked independently of statement order rather + // than being hidden behind the block's last direct assignment. Both + // shapes were declined before bare-block writes were followed, and a + // closure that only READS the group must not decline anything. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/old") + { + g := func() *gin.RouterGroup { + g = r.Group("/new") + return r.Group("/inner") + }() + _ = g + } + g.GET("/a", h.A) + k := r.Group("/k") + { + var k = func() *gin.RouterGroup { + k = r.Group("/k2") + return r.Group("/k3") + }() + _ = k + } + k.GET("/b", h.B) + m := r.Group("/m") + { + reset := func() { m = r.Group("/closure") } + m = r.Group("/new") + reset() + } + m.GET("/c", h.C) + n := r.Group("/n") + { + n = r.Group("/n2") + register := func() { n.GET("/registered", h.R) } + register() + } + n.GET("/d", h.D) +} +`), + ).toEqual([ + { method: 'GET', path: '/n2/registered', name: 'R' }, + { method: 'GET', path: '/n2/d', name: 'D' }, + ]); + }); + + it('takes the last target of a repeated assignment and declines range and receive writes', () => { + // Go carries an assignment out left to right, so `g, g = a, b` leaves the + // LAST value in place. `for _, g = range xs` and `case g = <-ch` write the + // existing name — the range only when its body runs, the receive with a + // statically unknown value — so those blocks stay CONFLICT. Their `:=` + // forms declare a loop- or case-local variable instead and leave the + // outer group alone. + expect( + providers(`package main +func routes(r *gin.Engine, groups []*gin.RouterGroup, ch chan *gin.RouterGroup) { + g := r.Group("/old") + { g, g = r.Group("/first"), r.Group("/second") } + g.GET("/a", h.A) + k := r.Group("/k") + { + k = r.Group("/k2") + for _, k = range groups {} + } + k.GET("/b", h.B) + m := r.Group("/m") + { + m = r.Group("/m2") + select { case m = <-ch: } + } + m.GET("/c", h.C) + n := r.Group("/n") + { + n = r.Group("/n2") + for _, n := range groups {} + } + n.GET("/d", h.D) +} +`), + ).toEqual([ + { method: 'GET', path: '/second/a', name: 'A' }, + { method: 'GET', path: '/n2/d', name: 'D' }, + ]); + }); + it('resolves a name used in its own statement initializer to the outer binding', () => { // `if g := g.Group("/inner"); …` — the right-hand g is the OUTER group; // the new g only scopes over what follows. Same for a for initializer. @@ -859,6 +979,104 @@ func main() { `), ).toEqual([{ method: 'GET', path: '/api/users/:id', name: 'Get' }]); }); + + it('does not decline for a stored closure or a shadowed closure write', () => { + // Storing a function literal in a value does not run its body, so a `:=` + // whose initializer is an UNCALLED literal leaves the outer group alone; + // only an invoked literal (`… }()`), which runs during initialization, + // writes it. A closure whose own body declares the name — a nested `:=` + // or a parameter — writes that local, not the captured outer group. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/old") + { + g := func() { g = r.Group("/stored") } + _ = g + } + g.GET("/a", h.A) + k := r.Group("/k") + { + reset := func() { k := r.Group("/local"); k = r.Group("/local2") } + k = r.Group("/new") + reset() + } + k.GET("/b", h.B) + m := r.Group("/m") + { + reset := func(m *gin.RouterGroup) { m = r.Group("/param") } + m = r.Group("/new") + reset(m) + } + m.GET("/c", h.C) +} +`), + ).toEqual([ + { method: 'GET', path: '/old/a', name: 'A' }, + { method: 'GET', path: '/new/b', name: 'B' }, + { method: 'GET', path: '/new/c', name: 'C' }, + ]); + }); + + it('resolves through closure-local range/receive shadows and parenthesized IIFEs', () => { + // A range or receive `=` inside a closure writes the name that closure's + // own body declares, so an earlier `if` holding it does not make the outer + // prefix unprovable — while the same `=` against the captured outer name + // does. A parenthesized immediately-invoked literal still runs its body, + // so it writes the outer group exactly like the unparenthesized form, + // whereas a stored parenthesized literal does not. + expect( + providers(`package main +func routes(r *gin.Engine, cond bool, groups []*gin.RouterGroup, ch chan *gin.RouterGroup) { + g := r.Group("/old") + { + if cond { + func() { + g := r.Group("/local") + for _, g = range groups {} + }() + } + g.GET("/a", h.A) + } + k := r.Group("/k") + { + if cond { + func() { + k := r.Group("/local") + select { case k = <-ch: _ = k } + }() + } + k.GET("/b", h.B) + } + m := r.Group("/m") + { + if cond { + func() { + for _, m = range groups {} + }() + } + m.GET("/c", h.C) + } + n := r.Group("/n") + { + n := (func() *gin.RouterGroup { n = r.Group("/paren"); return r.Group("/inner") })() + _ = n + } + n.GET("/d", h.D) + p := r.Group("/p") + { + p := (func() { p = r.Group("/stored") }) + _ = p + } + p.GET("/e", h.E) +} +`), + ).toEqual([ + { method: 'GET', path: '/old/a', name: 'A' }, + { method: 'GET', path: '/k/b', name: 'B' }, + { method: 'GET', path: '/p/e', name: 'E' }, + ]); + }); }); describe('Go gin provider ↔ fetch() consumer pairing', () => { From 526b6f249b7681c3adea58ddc97e4b589d76027a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 5 Oct 2026 13:49:07 +0100 Subject: [PATCH 09/13] test(search): guard native lookup and Unicode FTS after COPY (#3477) --- .../incremental-write-integrity/README.md | 3 + .../reproduce-search.cjs | 251 ++++++++++++++++++ .../search-root-cause.md | 100 +++++++ gitnexus/scripts/cross-platform-tests.ts | 1 + .../lbug-incremental-search.test.ts | 62 +++++ gitnexus/vitest.config.ts | 2 + 6 files changed, 419 insertions(+) create mode 100644 gitnexus/bench/incremental-write-integrity/reproduce-search.cjs create mode 100644 gitnexus/bench/incremental-write-integrity/search-root-cause.md create mode 100644 gitnexus/test/integration/lbug-incremental-search.test.ts diff --git a/gitnexus/bench/incremental-write-integrity/README.md b/gitnexus/bench/incremental-write-integrity/README.md index b5b14d881..a75861500 100644 --- a/gitnexus/bench/incremental-write-integrity/README.md +++ b/gitnexus/bench/incremental-write-integrity/README.md @@ -1,5 +1,8 @@ # Incremental node identity reconciliation +For the native cause of #3421/#3423 and regressions covering both missing +context results and Unicode Property FTS, see [search-root-cause.md](search-root-cause.md). + From `gitnexus/`: ```sh diff --git a/gitnexus/bench/incremental-write-integrity/reproduce-search.cjs b/gitnexus/bench/incremental-write-integrity/reproduce-search.cjs new file mode 100644 index 000000000..8e4c2d2e9 --- /dev/null +++ b/gitnexus/bench/incremental-write-integrity/reproduce-search.cjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node +/** Native search contracts for #3421/#3423. See search-root-cause.md. */ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +// Diagnostic overrides allow the identical workload to test an older native engine. +const lbug = require(process.env.LBUG_MODULE || '@ladybugdb/core'); +const scenario = process.argv[2]; +assert.ok( + ['context', 'property'].includes(scenario), + 'Usage: reproduce-search.cjs context|property [--keep]', +); + +(async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ladybug-search-')); + const checks = []; + let database; + let connection; + const record = async (name, fn) => { + try { + checks.push({ name, ok: true, ...(await fn()) }); + } catch (error) { + checks.push({ name, ok: false, error: String(error) }); + } + }; + const close = async () => { + const activeConnection = connection; + const activeDatabase = database; + connection = undefined; + database = undefined; + const errors = []; + try { + await activeConnection?.close(); + } catch (error) { + errors.push(error); + } + try { + await activeDatabase?.close(); + } catch (error) { + errors.push(error); + } + if (errors.length) throw new AggregateError(errors, 'Native cleanup failed'); + }; + const query = async (sql, params) => { + const raw = params + ? await connection.execute(await connection.prepare(sql), params) + : await connection.query(sql); + const rows = []; + const errors = []; + // FTS expands into multiple statements: consume and close every result, + // including when an earlier statement reports a deferred native error. + for (const result of Array.isArray(raw) ? raw : [raw]) { + try { + rows.push(...(await result.getAll())); + } catch (error) { + errors.push(error); + } + try { + await result.close(); + } catch (error) { + errors.push(error); + } + } + if (errors.length) throw new AggregateError(errors, errors.map(String).join('; ')); + return rows; + }; + const table = scenario === 'context' ? 'Function' : 'Property'; + const columns = ['id', 'name', 'filePath', 'startLine', 'endLine']; + if (scenario === 'property') columns.push('content', 'description'); + const indices = Array.from({ length: 8192 }, (_, i) => i); + const changed = (i) => i < 32 || i >= 8192 - 32; + const tuple = (i) => + scenario === 'context' + ? [ + `Function:src/owner${Math.floor(i / 32)}.ts:unchangedSwiftTarget${i}`, + `unchangedSwiftTarget${i}`, // Out-of-line name, unlike the short fn64 fixture. + `src/owner${Math.floor(i / 32)}.ts`, + (i % 32) * 4, + (i % 32) * 4 + 2, + ] + : [ + `Property:src/owner${Math.floor(i / 32)}.swift:property${i}`, + `unchangedPropertyValue${i}${'王'.repeat(i % 17)}`, + `src/owner${Math.floor(i / 32)}.swift`, + i, + i + 1, + `var unchangedPropertyValue${i} = "Valid UTF8 Å王 café 東京 ${i} ${'Résumé Ελληνικά Привет 🙂 '.repeat(i % 53)}"${i === 64 ? ' retainedsearchsentinel' : ''}`, + `Documentation for property ${i} ${'正常な日本語 naïve façade Å王 '.repeat(i % 37)}`, + ]; + const csv = (selected) => + columns.join(',') + + '\n' + + selected + .map((i) => + tuple(i) + .map((value) => '"' + String(value).replaceAll('"', '""') + '"') + .join(','), + ) + .join('\n') + + '\n'; + const literal = (value) => "'" + value.replace(/\\/g, '/').replaceAll("'", "\\'") + "'"; + const copy = (file) => + query( + `COPY ${table} FROM ${literal(path.join(directory, file))} ` + + `(HEADER=true, ESCAPE='"', DELIM=',', QUOTE='"', PARALLEL=false, auto_detect=false)`, + ); + const extension = + process.env.FTS_LIBRARY || + path.resolve( + __dirname, + '../../vendor/lbug-fts/prebuilds', + `${process.platform}-${process.arch}`, + 'libfts.lbug_extension', + ); + const open = async (readOnly) => { + database = new lbug.Database( + path.join(directory, 'db'), + 256 * 1024 * 1024, + false, + readOnly, + 4 * 1024 ** 3, + true, + 64 * 1024 * 1024, + true, + true, + ); + connection = new lbug.Connection(database); + if (scenario === 'property') await query(`LOAD ${literal(extension)}`); + }; + const scan = async (stage, selected = indices) => + record(`${stage}:scan`, async () => { + const rows = await query( + `MATCH (n:${table}) RETURN ${columns.map((c) => `n.${c} AS ${c}`).join(', ')}`, + ); + const expected = new Set(selected.map((i) => JSON.stringify(tuple(i)))); + const actual = rows.map((row) => JSON.stringify(columns.map((c) => row[c]))); + const actualSet = new Set(actual); + const wrong = actual.filter((row) => !expected.has(row)).length; + const missing = [...expected].filter((row) => !actualSet.has(row)).length; + assert.deepEqual( + { rows: rows.length, wrong, missing }, + { rows: selected.length, wrong: 0, missing: 0 }, + ); + return { rows: rows.length, wrong, missing }; + }); + const lookup = async (stage) => { + const target = tuple(64); // Retained throughout DELETE/COPY. + const select = + 'n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine'; + for (const [kind, sql, params] of [ + ['primary-key', `MATCH (n:Function {id: $uid}) RETURN ${select} LIMIT 1`, { uid: target[0] }], + // Match LocalBackend.resolveContextSymbol's label-free UID/name queries. + ['uid', `MATCH (n {id: $uid}) RETURN ${select} LIMIT 1`, { uid: target[0] }], + [ + 'name', + `MATCH (n) WHERE n.name = $symName RETURN ${select} ORDER BY n.id LIMIT 20`, + { symName: target[1] }, + ], + ]) + await record(`${stage}:${kind}`, async () => { + const rows = await query(sql, params); + assert.deepEqual( + rows.map((row) => columns.map((column) => row[column])), + [target], + ); + return { ids: rows.map((row) => row.id) }; + }); + }; + const build = () => + query("CALL CREATE_FTS_INDEX('Property', 'property_fts', ['name', 'content', 'description'])"); + const drop = () => query("CALL DROP_FTS_INDEX('Property', 'property_fts')"); + const search = async (stage) => { + await record(`${stage}:catalog`, async () => { + const indexes = await query('CALL SHOW_INDEXES() RETURN *'); + const matches = indexes.filter( + (row) => row.table_name === 'Property' && row.index_name === 'property_fts', + ); + assert.equal(matches.length, 1); + assert.deepEqual(matches[0].property_names, ['name', 'content', 'description']); + return { indexes: matches.length }; + }); + await record(`${stage}:fts-query`, async () => { + const rows = await query( + "CALL QUERY_FTS_INDEX('Property', 'property_fts', 'retainedsearchsentinel') RETURN node, score ORDER BY score DESC, node.id LIMIT 20", + ); + const ids = rows.map((row) => row.node.id); + assert.deepEqual(ids, [tuple(64)[0]]); + return { ids }; + }); + for (const column of ['name', 'content', 'description']) + await record(`${stage}:lower-${column}`, async () => { + const rows = await query(`MATCH (n:Property) RETURN LOWER(n.${column}) AS value`); + assert.equal(rows.length, indices.length); + return { rows: rows.length }; + }); + }; + const check = async (stage) => { + await scan(stage); + if (scenario === 'context') await lookup(stage); + else await search(stage); + }; + try { + await fs.writeFile(path.join(directory, 'full.csv'), csv(indices)); + await fs.writeFile(path.join(directory, 'delta.csv'), csv(indices.filter(changed))); + await open(false); + const schema = columns.map((c) => `${c} ${c.endsWith('Line') ? 'INT64' : 'STRING'}`).join(', '); + await query(`CREATE NODE TABLE ${table}(${schema}, PRIMARY KEY(id))`); + // Multiple labels make the UID query use a filtered scan, as in GitNexus. + if (scenario === 'context') await query(`CREATE NODE TABLE Method(${schema}, PRIMARY KEY(id))`); + await copy('full.csv'); + if (scenario === 'property') await record('baseline:build', build); + await query('CHECKPOINT'); + await check('baseline'); + if (scenario === 'property') await drop(); + const suffix = scenario === 'context' ? 'ts' : 'swift'; + await query( + `MATCH (n:${table}) WHERE n.filePath IN ['src/owner0.${suffix}', 'src/owner255.${suffix}'] DETACH DELETE n`, + ); + await scan( + 'after-delete', + indices.filter((i) => !changed(i)), + ); + await copy('delta.csv'); + // Build first: scanning strings beforehand can change the old engine's error manifestation. + if (scenario === 'property') await record('after-copy:build', build); + await check('after-copy'); + if (scenario === 'property') { + await record('repair:drop', drop); + await record('repair:build', build); + await check('repair'); + } + await query('CHECKPOINT'); + await check('after-checkpoint'); + await close(); + await open(true); + await check('reopened'); + } catch (error) { + checks.push({ name: 'setup-or-write', ok: false, error: String(error) }); + } finally { + await record('close', close); + if (!process.argv.includes('--keep')) { + await record('remove-fixture', () => fs.rm(directory, { recursive: true, force: true })); + } + } + console.log(JSON.stringify({ scenario, native: lbug.VERSION, directory, checks })); + if (checks.some((check) => !check.ok)) process.exitCode = 1; +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/gitnexus/bench/incremental-write-integrity/search-root-cause.md b/gitnexus/bench/incremental-write-integrity/search-root-cause.md new file mode 100644 index 000000000..fc09ee6dd --- /dev/null +++ b/gitnexus/bench/incremental-write-integrity/search-root-cause.md @@ -0,0 +1,100 @@ +# Incremental search failures: native cause and regression contracts + +Issues [#3421](https://github.com/abhigyanpatwari/GitNexus/issues/3421) and +[#3423](https://github.com/abhigyanpatwari/GitNexus/issues/3423) reported two +symptoms after a small incremental write: valid source strings caused Property +FTS to fail in `LOWER`, and unchanged symbols stopped resolving by both name and +UID. A full rebuild restored service in both reports. + +## Cause and effective fix + +LadybugDB 0.18.3's `StringColumn::scanFiltered` compared a position in the output +vector with a segment-local length. When a scan crossed segments after selective +DELETE/COPY, it could skip valid string positions. The native scan could return +blank or stale string values while a labeled primary-key lookup still returned +the correct tuple. This establishes an incorrect read, not physical byte loss. + +Upstream [LadybugDB #737](https://github.com/LadybugDB/ladybug/pull/737), commit +`a10cecbc76e05f993af6c6f4a57edbbf438bb376`, passes the current segment's scan length +into `scanFiltered` and bounds positions by +`offsetInResult <= pos < offsetInResult + numValuesToScan`. The local read offset +is `startOffsetInChunk + pos - offsetInResult`. The correction first shipped in +LadybugDB 0.19.0. + +GitNexus already includes it through the 0.21.1 dependency in +[#3442](https://github.com/abhigyanpatwari/GitNexus/pull/3442), commit +`412446408d0e3f286b9e461fc451c25bf6283b3c`. Keep that native correction and the +existing publication reconciliation. Changing only the UID query to a labeled +lookup would hide one symptom while leaving name scans and FTS vulnerable. + +## Controlled causal check + +On Linux x64, a clean build of upstream tag `v0.18.3` reproduced both symptoms via +the native C API, without GitNexus, its parser, or the Node binding. Applying +**only the two production-file changes from #737** to that same checkout and +rebuilding removed both failures using the same CSV input and FTS extension +(`v0.18.1/linux_amd64`, the extension ABI used by core 0.18.3). + +| Observation after selective COPY | Untouched 0.18.3 | 0.18.3 + #737 only | +| --- | --- | --- | +| Label-free retained UID lookup | 0 rows | Correct row | +| Label-free retained name lookup | 0 rows | Correct row | +| Labeled primary-key control | Correct row | Correct row | +| Function scan with blank ID/name | 1,984 rows | 0 rows | +| Property FTS creation | `LOWER: Invalid UTF-8` | Success | +| Property scan with blank ID/name | 1,984 rows | 0 rows | +| Property `LOWER(content)` | `Invalid UTF-8` | Success | +| Drop/recreate Property FTS | Missing index / orphan table | Success | + +The Node fixtures also fail on published core 0.18.3 and pass on 0.21.1. Incorrect +tuple counts vary with the fixture and scan shape (1,984 or 3,936 observed). +The UTF-8 exception itself varies between runs: a failed string scan can return +blank values without throwing. Therefore the regression oracle compares every +source field and checks lookup results, rather than requiring an exception or +treating a successful FTS build as sufficient. + +FTS creation expands into multiple statements in the extension's +`create_fts_index.cpp`: it creates internal tables, tokenizes property values +with `LOWER`, then registers the index. A failure during tokenization can leave +`0_property_fts_appears_info` without a catalog index. Dropping by index name then +fails, and creating again can encounter the orphan table. FTS-only repair cannot +correct the underlying string scan. The experiment reproduced this failure +chain, but not #3421's exact missing-index message without an appended native +error. Neither reporter's overwritten private database was available, so this +is a controlled reproduction of both reported symptoms, not a historical replay. + +## Permanent regressions + +From `gitnexus/`: + +```sh +node bench/incremental-write-integrity/reproduce-search.cjs context +node bench/incremental-write-integrity/reproduce-search.cjs property +npx vitest run test/integration/lbug-incremental-search.test.ts +``` + +Each workload loads 8,192 rows, checkpoints, deletes the first and last owners +(64 rows), then copies back exactly those rows. Row 64 remains untouched. + +- `context` uses out-of-line ASCII names and a second node table. Those details + matter: a single table can turn the UID query into a healthy primary-key scan, + and short names can stay readable while their IDs are blank. It runs the + label-free UID/name query shapes from `LocalBackend.resolveContextSymbol` plus + the labeled primary-key control. +- `property` uses valid, variable-length Unicode in name/content/description, + builds all three FTS columns, checks the catalog and a retained unique search + term, exercises `LOWER` on each column, and drops/recreates the index. It uses + the production FTS result shape (`RETURN node, score`). + +Both compare the complete expected tuple set before/after the write, after an +explicit checkpoint, and after read-only reopen. The Property fixture also +checks the repair cycle. The Vitest wrapper runs each native workload in a child +process, requires every expected phase, and rejects nonzero exits and signals. +It belongs to the serialized `lbug-db` project and the Windows/macOS native test list. + +The harness requires no network access. It defaults to the pinned core and +vendored platform FTS library. For a diagnostic comparison, set `LBUG_MODULE` +to an absolute path to another installed `@ladybugdb/core` and `FTS_LIBRARY` +to its matching native extension. Core 0.18.3 is expected to exit nonzero; do not +skip the test on that version. `--keep` retains only the synthetic fixture files; +otherwise cleanup errors are reported and fail the command. diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 037109640..3fdb26ee0 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -167,6 +167,7 @@ const LBUG_NATIVE = [ 'test/integration/lbug-close-handle-release.test.ts', 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-interrupted-checkpoint-recovery.test.ts', + 'test/integration/lbug-incremental-search.test.ts', 'test/integration/lbug-readonly-init.test.ts', 'test/integration/lbug-non-ascii-path.test.ts', // Cross-repo trace e2e: builds two real lbug indexes + a real bridge and diff --git a/gitnexus/test/integration/lbug-incremental-search.test.ts b/gitnexus/test/integration/lbug-incremental-search.test.ts new file mode 100644 index 000000000..ac94170be --- /dev/null +++ b/gitnexus/test/integration/lbug-incremental-search.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const reproducer = fileURLToPath( + new URL('../../bench/incremental-write-integrity/reproduce-search.cjs', import.meta.url), +); +interface Check { + name: string; + ok: boolean; + error?: string; + rows?: number; + wrong?: number; + missing?: number; + ids?: string[]; + indexes?: number; +} + +describe('native incremental search (#3421, #3423)', () => { + for (const scenario of ['context', 'property'] as const) { + it(`preserves retained ${scenario} strings and search through COPY, checkpoint and reopen`, () => { + // Own process: isolate native lifetimes and allow old-engine diagnostic runs. + const result = spawnSync(process.execPath, [reproducer, scenario], { + encoding: 'utf8', + timeout: 120_000, + maxBuffer: 2 * 1024 * 1024, + }); + expect(result.error, result.stderr).toBeUndefined(); + expect(result.signal, result.stderr).toBeNull(); + expect(result.status, result.stdout + result.stderr).toBe(0); + const report = JSON.parse(result.stdout.trim()) as { checks: Check[] }; + expect(report.checks.filter((check) => !check.ok)).toEqual([]); + const check = (name: string) => report.checks.find((entry) => entry.name === name); + expect(check('after-delete:scan')).toMatchObject({ rows: 8128, wrong: 0, missing: 0 }); + const stages = ['baseline', 'after-copy', 'after-checkpoint', 'reopened']; + if (scenario === 'property') stages.push('repair'); + for (const stage of stages) { + expect(check(`${stage}:scan`)).toMatchObject({ rows: 8192, wrong: 0, missing: 0 }); + if (scenario === 'context') { + for (const lookup of ['primary-key', 'uid', 'name']) { + expect(check(`${stage}:${lookup}`)?.ids).toEqual([ + 'Function:src/owner2.ts:unchangedSwiftTarget64', + ]); + } + } else { + expect(check(`${stage}:catalog`)?.indexes).toBe(1); + expect(check(`${stage}:fts-query`)?.ids).toEqual([ + 'Property:src/owner2.swift:property64', + ]); + for (const column of ['name', 'content', 'description']) { + expect(check(`${stage}:lower-${column}`)?.rows).toBe(8192); + } + } + } + if (scenario === 'property') { + for (const name of ['baseline:build', 'after-copy:build', 'repair:drop', 'repair:build']) { + expect(check(name)?.ok).toBe(true); + } + } + }, 150_000); + } +}); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 37c5889f3..53e9f0ba1 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -98,6 +98,7 @@ export default defineConfig({ 'test/integration/class-impact-all-languages.test.ts', 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-interrupted-checkpoint-recovery.test.ts', + 'test/integration/lbug-incremental-search.test.ts', 'test/integration/lbug-readonly-init.test.ts', // Shared sibling store (#3352): each file runs real analyses and opens // the resulting LadybugDB graphs. @@ -191,6 +192,7 @@ export default defineConfig({ 'test/integration/class-impact-all-languages.test.ts', 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-interrupted-checkpoint-recovery.test.ts', + 'test/integration/lbug-incremental-search.test.ts', 'test/integration/lbug-readonly-init.test.ts', 'test/integration/analyze-wal-checkpoint-failure.test.ts', 'test/integration/lbug-non-ascii-path.test.ts', From e8a09d067b764140d3423d318e2585dcb30cda24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 5 Oct 2026 14:19:10 +0100 Subject: [PATCH 10/13] fix(search): reject incomplete FTS repairs and verify worktree lookup (#3475) --- gitnexus/src/core/run-analyze.ts | 37 ++- gitnexus/src/core/search/fts-indexes.ts | 139 ++++++--- .../incremental-worktree-context.test.ts | 288 ++++++++++++++++++ gitnexus/test/unit/bm25-search.test.ts | 6 + gitnexus/test/unit/fts-indexes.test.ts | 156 +++++++++- .../unit/lbug-checkpoint-lifecycle.test.ts | 62 ++++ .../test/unit/run-analyze-fts-repair.test.ts | 117 +++++++ gitnexus/vitest.config.ts | 2 + 8 files changed, 760 insertions(+), 47 deletions(-) create mode 100644 gitnexus/test/integration/incremental-worktree-context.test.ts diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 5f43df3e2..6c6b79b51 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -1728,20 +1728,33 @@ async function runFullAnalysisInner( ? (table, indexName) => log(`FTS: ready ${table}.${indexName}`) : undefined, }); - const missing = await verifySearchFTSIndexes(executeQuery, ftsIndexes); - if (missing.length > 0) { - // #2889: name WHY each index is missing when the build itself said so. - // Repair now rebuilds every table it can before reporting, so the tables - // absent from this list were genuinely repaired even on a failed run — - // previously the first failure aborted the sweep and the message could - // only ever list "missing", never a reason. Same sentence the analyze - // degrade path prints, so one failure does not read two ways. - const reasons = + const catalogDiagnostics: string[] = []; + let missing: string[] = []; + let catalogError: string | undefined; + try { + missing = await verifySearchFTSIndexes(executeQuery, ftsIndexes, (diagnostic) => { + catalogDiagnostics.push(diagnostic.message); + }); + } catch (error) { + // An unreadable catalog is unknown, not proof of missing indexes. + // Keep earlier native build errors when this read also fails. + catalogError = error instanceof Error ? error.message : String(error); + } + if (repairFailures.length > 0 || missing.length > 0 || catalogError !== undefined) { + // A failed DROP may leave a valid old catalog entry. Its presence must + // not certify that this repair succeeded or clear the recovery marker. + const reasons = [ + missing.length > 0 ? `missing indexes after rebuild: ${missing.join(', ')}` : '', repairFailures.length > 0 - ? ` ${summarizeFtsIndexBuildFailures(repairFailures, ftsIndexes)}.` - : ''; + ? summarizeFtsIndexBuildFailures(repairFailures, ftsIndexes) + : missing.length > 0 + ? 'no build error was returned' + : '', + ...catalogDiagnostics, + catalogError !== undefined ? `catalog verification failed: ${catalogError}` : '', + ].filter(Boolean); throw new Error( - `FTS repair failed - missing indexes after rebuild: ${missing.join(', ')}.${reasons} ` + + `FTS repair failed - ${reasons.join('. ')}. ` + 'Run `gitnexus analyze --force` to perform a full graph+FTS rebuild; ' + 'if that also fails, verify FTS extension availability via `gitnexus doctor`.', ); diff --git a/gitnexus/src/core/search/fts-indexes.ts b/gitnexus/src/core/search/fts-indexes.ts index 5f8b59f13..00d08f23a 100644 --- a/gitnexus/src/core/search/fts-indexes.ts +++ b/gitnexus/src/core/search/fts-indexes.ts @@ -3,6 +3,7 @@ import { dropFTSIndex, indexRowName, indexRowTable, + indexRowType, resolveGateRows, DEFAULT_FTS_STEMMER, type IndexCatalogSnapshot, @@ -394,9 +395,40 @@ export const summarizeFtsIndexBuildFailures = ( `FTS index build failed for ${failures.length} of ${indexes.length} tables: ` + failures.map((f) => `${f.table}.${f.indexName} (${f.error})`).join(', '); +/** Bounded catalog evidence for CLI/log diagnostics, never source or index definitions. */ +export interface FtsIndexVerificationDiagnostic { + table: string; + indexName: string; + message: string; +} + +const boundCatalogText = (text: string, limit: number): string => + text.length > limit ? `${text.slice(0, limit - 14)}… [truncated]` : text; + +const catalogFieldEvidence = (value: unknown): string => + typeof value === 'string' + ? JSON.stringify(boundCatalogText(value, 80)) + : value === undefined + ? '' + : ``; + +const catalogPropertiesEvidence = (value: unknown): string => { + if (!Array.isArray(value)) return ``; + return ( + `[${value.slice(0, 8).map(catalogFieldEvidence).join(', ')}]` + + (value.length > 8 ? ` (${value.length - 8} properties truncated)` : '') + ); +}; + +/** + * Return missing or invalid configured indexes. An optional callback receives + * bounded evidence from the same catalog read; a failed read still throws and + * proves neither presence nor absence. + */ export async function verifySearchFTSIndexes( executeQuery: (cypher: string) => Promise, indexes: readonly FTSIndexDefinition[] = FTS_INDEXES, + onMismatch?: (diagnostic: FtsIndexVerificationDiagnostic) => void, ): Promise { // Read the catalog once and check each configured index both EXISTS and // covers its expected columns. A queryability-only probe (CALL QUERY_FTS_INDEX @@ -404,34 +436,51 @@ export async function verifySearchFTSIndexes( // pre-#2299 DB stays queryable yet silently misses `description`, so the probe // would pass while doc-comment search is still broken (#2299). SHOW_INDEXES // exposes `property_names` (STRING[]) per index, so we assert coverage directly. - const rows = await executeQuery('CALL SHOW_INDEXES() RETURN *'); - - const propsByIndex = new Map(); - for (const row of rows) { - if (typeof row !== 'object' || row === null) continue; - const record = row as Record; - const indexName = indexRowName(record); - // LADYBUGDB-CONTRACT: `property_names` is the one SHOW_INDEXES column with a - // single reader, so it has no shared accessor — see {@link IndexCatalogRow} - // in lbug-adapter.ts for the full column list and the re-validation rule. - // Unlike the gates, an unreadable shape here is safe: it reports the index as - // not covering its columns, i.e. "missing", which degrades keyword search - // loudly rather than passing a broken index off as verified. - const propertyNames = record.property_names; - if (typeof indexName !== 'string' || !Array.isArray(propertyNames)) continue; - propsByIndex.set( - indexName, - propertyNames.filter((p): p is string => typeof p === 'string'), - ); - } + const rows = (await executeQuery('CALL SHOW_INDEXES() RETURN *')).filter( + (row): row is Record => typeof row === 'object' && row !== null, + ); const missing: string[] = []; for (const { table, indexName, properties } of indexes) { - const actual = propsByIndex.get(indexName); - // Absent from the catalog, or present but not covering every expected column. - if (!actual || !properties.every((p) => actual.includes(p))) { - missing.push(`${table}.${indexName}`); - } + const valid = rows.some((row) => { + // LADYBUGDB-CONTRACT: `property_names` is a named STRING[] column; + // identity/type use the shared catalog readers in lbug-adapter.ts. + const actual = row.property_names; + return ( + indexRowTable(row) === table && + indexRowName(row) === indexName && + indexRowType(row) === 'FTS' && + Array.isArray(actual) && + actual.every((property) => typeof property === 'string') && + properties.every((property) => actual.includes(property)) + ); + }); + if (valid) continue; + missing.push(`${table}.${indexName}`); + if (!onMismatch) continue; + + // Show only rows that could explain this identity mismatch, and only + // catalog identity/type/property fields. Malformed objects are represented + // by their type rather than serialized (they can carry arbitrary content). + const relevant = rows.filter( + (row) => indexRowTable(row) === table || indexRowName(row) === indexName, + ); + const observed = relevant + .slice(0, 3) + .map( + (row) => + `{table=${catalogFieldEvidence(indexRowTable(row))}, ` + + `index=${catalogFieldEvidence(indexRowName(row))}, ` + + `type=${catalogFieldEvidence(indexRowType(row))}, ` + + `properties=${catalogPropertiesEvidence(row.property_names)}}`, + ); + const message = + `${table}.${indexName}: expected table=${catalogFieldEvidence(table)}, ` + + `index=${catalogFieldEvidence(indexName)}, type=FTS, ` + + `properties=${catalogPropertiesEvidence(properties)}; observed ` + + (observed.length > 0 ? observed.join(', ') : 'no matching table or index name') + + (relevant.length > 3 ? ` (${relevant.length - 3} catalog rows truncated)` : ''); + onMismatch({ table, indexName, message: boundCatalogText(message, 2048) }); } return missing; } @@ -535,19 +584,39 @@ export async function buildSearchIndexesOrDegrade( // means description search is broken (#2299). const failures = await createSearchFTSIndexes(options); const indexes = options?.indexes ?? FTS_INDEXES; - const missing = await verifySearchFTSIndexes(executeQuery, indexes); - if (failures.length === 0 && missing.length === 0) return { ok: true }; + const diagnostics: FtsIndexVerificationDiagnostic[] = []; + let missing: string[] = []; + let verificationError: string | undefined; + try { + missing = await verifySearchFTSIndexes(executeQuery, indexes, (diagnostic) => + diagnostics.push(diagnostic), + ); + } catch (e) { + // A failed catalog read is unknown, not absence. Keep the native build + // failures already collected rather than replacing them with this error. + verificationError = e instanceof Error ? e.message : String(e); + } + if (failures.length === 0 && missing.length === 0 && verificationError === undefined) { + return { ok: true }; + } - // A table that failed to build is necessarily missing too — report it once, - // with its reason, and keep `missing` for indexes nothing explains. + // A failed DROP may leave an old catalog row present. Build failures stand + // on their own; avoid naming the same table again as an unexplained failure. const named = new Set(failures.map((f) => `${f.table}.${f.indexName}`)); - const unexplained = missing.filter((name) => !named.has(name)); + const unexplained = diagnostics.filter((d) => !named.has(`${d.table}.${d.indexName}`)); const error = [ failures.length > 0 ? summarizeFtsIndexBuildFailures(failures, indexes) : '', // Structural incompleteness with no thrown error — classified capability // (degrade) below, matching prior behavior; a broken *write* surfaces as // a thrown IO/checkpoint error and is classified integrity there. - unexplained.length > 0 ? `missing indexes after build: ${unexplained.join(', ')}` : '', + unexplained.length > 0 + ? 'missing indexes or mismatched catalog rows after build ' + + '(no build error was returned for these indexes): ' + + unexplained.map((d) => d.message).join('; ') + : '', + verificationError !== undefined + ? `FTS catalog verification failed: ${verificationError}` + : '', ] .filter((part) => part.length > 0) .join('; '); @@ -555,9 +624,11 @@ export async function buildSearchIndexesOrDegrade( // Classify per failure, not over the joined text: capability signatures are // checked first, so folding the messages together would let an untokenizable // row mask a genuinely broken write and downgrade an abort into a degrade. - const failureClass = failures.some((f) => classifyFtsBuildError(f.error) === 'integrity') - ? 'integrity' - : classifyFtsBuildError(error); + const failureClass = + failures.some((f) => classifyFtsBuildError(f.error) === 'integrity') || + (verificationError !== undefined && classifyFtsBuildError(verificationError) === 'integrity') + ? 'integrity' + : 'capability'; return { ok: false, error, failureClass }; } catch (e) { const error = e instanceof Error ? e.message : String(e); diff --git a/gitnexus/test/integration/incremental-worktree-context.test.ts b/gitnexus/test/integration/incremental-worktree-context.test.ts new file mode 100644 index 000000000..d8292813b --- /dev/null +++ b/gitnexus/test/integration/incremental-worktree-context.test.ts @@ -0,0 +1,288 @@ +/** + * Characterize #3423's public workflow against the current analyzer. The + * original private database is unavailable; this fixture does not reproduce + * or establish the cause of that historical incident. + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; +import * as adapter from '../../src/core/lbug/lbug-adapter.js'; +import { runFullAnalysis } from '../../src/core/run-analyze.js'; +import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { getStoragePaths, loadMeta } from '../../src/storage/repo-manager.js'; +import { CLI_SPAWN_PREFIX, tsxLoaderUrl } from '../helpers/cli-entry.js'; +import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js'; +import { createTempDir } from '../helpers/test-db.js'; + +const SWIFT_PATH = 'Sources/Numbers.swift'; +const TEST_PATH = 'checks/test_numbers.py'; +const IMPORTER_PATH = 'checks/runner.py'; +const TARGET_NAME = 'extractLeadingNumber'; +const PROPERTY_NAME = 'preservedSearchNeedle'; +const CALLER_NAMES = ['parsePrimaryNumber', 'parseSecondaryNumber']; +const options = { skipAgentsMd: true, skipSkills: true }; +const callbacks = { onProgress: () => {} }; + +const SWIFT_SOURCE = `func extractLeadingNumber(_ raw: String) -> Int { + return 7 +} + +func parsePrimaryNumber() -> Int { + return extractLeadingNumber("17") +} + +func parseSecondaryNumber() -> Int { + return extractLeadingNumber("23") +} + +class NumberSettings { + var preservedSearchNeedle: String = "retained property" +} +`; + +type SymbolRef = { uid: string; name: string; filePath: string }; +type ContextResult = { + status: string; + symbol: SymbolRef; + incoming: { calls?: SymbolRef[] }; +}; +type StatusResult = { + repository: string; + status: string; + contentDrift: { status: string; coveredFiles?: number }; + index: { incompleteReasons: string[]; runnerIdentityStatus: string }; +}; + +const git = (cwd: string, ...args: string[]): string => + execFileSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe' }).trim(); + +function runCli(cwd: string, args: string[], prefix = CLI_SPAWN_PREFIX): T { + const stdout = execFileSync(process.execPath, [...prefix, ...args], { + cwd, + env: process.env, + encoding: 'utf8', + timeout: 60_000, + maxBuffer: 4 * 1024 * 1024, + windowsHide: true, + }); + // Context prints a human-readable banner before its JSON response. + const start = stdout.indexOf('{'); + expect(start, stdout).toBeGreaterThanOrEqual(0); + return JSON.parse(stdout.slice(start)) as T; +} + +function expectContext(result: ContextResult, target: SymbolRef, callers: SymbolRef[]): void { + expect(result).not.toHaveProperty('error'); + expect(result.status).toBe('found'); + expect(result.symbol).toMatchObject(target); + const actual = (result.incoming.calls ?? []).map(({ uid, name, filePath }) => ({ + uid, + name, + filePath, + })); + expect(actual.sort((a, b) => a.uid.localeCompare(b.uid))).toEqual( + [...callers].sort((a, b) => a.uid.localeCompare(b.uid)), + ); +} + +describe.skipIf(!isLanguageAvailable(SupportedLanguages.Swift))( + 'unchanged Swift context across a selective linked-worktree refresh (#3423)', + () => { + it('retains CLI/MCP name and UID context, both callers, and native Property FTS', async (ctx) => { + const temp = await createTempDir(); + let backend: LocalBackend | undefined; + try { + const root = await fs.realpath(temp.dbPath); + const main = path.join(root, 'main'); + const worktree = path.join(root, 'linked'); + const nativeHome = path.join(root, 'user-home'); + await fs.mkdir(nativeHome); + vi.stubEnv('HOME', nativeHome); + vi.stubEnv('USERPROFILE', nativeHome); + vi.stubEnv('GITNEXUS_HOME', path.join(root, 'home')); + vi.stubEnv('GITNEXUS_STORAGE_PATH', undefined); + vi.stubEnv('GITNEXUS_STORAGE_ROOT', undefined); + // Keep a real linked checkout, but avoid the shared-store seed/full + // rebuild path: the behavior under test is selective row replacement. + vi.stubEnv('GITNEXUS_SHARED_STORE', 'off'); + vi.stubEnv('GITNEXUS_CONTENT_RETENTION', 'full'); + vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '1'); + vi.stubEnv('GITNEXUS_LBUG_EXTENSION_INSTALL', 'load-only'); + + await adapter.initLbug(path.join(root, 'fts-probe')); + // Require an existing extension; a network install would make this + // isolated fixture depend on the developer's machine or connectivity. + if (!(await adapter.loadFTSExtension(undefined, { policy: 'load-only' }))) { + if (process.env.GITNEXUS_REQUIRE_FTS === '1') { + throw new Error( + 'FTS is required (GITNEXUS_REQUIRE_FTS=1) but could not load in the isolated fixture.', + ); + } + ctx.skip('FTS extension unavailable under the load-only policy'); + } + await adapter.closeLbug(); + + await fs.mkdir(path.join(main, 'Sources'), { recursive: true }); + await fs.mkdir(path.join(main, 'checks'), { recursive: true }); + await fs.mkdir(path.join(main, 'retained'), { recursive: true }); + await fs.writeFile(path.join(main, '.gitignore'), '.gitnexus/\n'); + await fs.writeFile(path.join(main, SWIFT_PATH), SWIFT_SOURCE); + await fs.writeFile(path.join(main, 'checks/__init__.py'), ''); + await fs.writeFile(path.join(main, TEST_PATH), 'def fixture_number():\n return 7\n'); + await fs.writeFile( + path.join(main, IMPORTER_PATH), + 'from checks.test_numbers import fixture_number\n\ndef run_checks():\n return fixture_number()\n', + ); + // Keep the two-file write set below the 50-file / 50% escalation gate. + for (let i = 0; i < 55; i++) { + await fs.writeFile( + path.join(main, `retained/value_${i}.py`), + `def retained_value_${i}():\n return ${i}\n`, + ); + } + initGitRepo(main); + git(main, 'config', 'commit.gpgsign', 'false'); + commitAll(main, 'baseline mixed-language fixture'); + git(main, 'worktree', 'add', '-q', '-b', 'lookup-refresh', worktree); + expect((await fs.stat(path.join(worktree, '.git'))).isFile()).toBe(true); + expect(git(worktree, 'rev-parse', 'HEAD')).toBe(git(main, 'rev-parse', 'HEAD')); + + const { storagePath, lbugPath } = getStoragePaths(worktree); + expect(storagePath).toBe(path.join(worktree, '.gitnexus')); + const baseline = await runFullAnalysis(worktree, options, callbacks); + const baselineMeta = await loadMeta(storagePath); + expect(baselineMeta?.runnerIdentity).toBeDefined(); + const swiftFunctions = [...baseline.pipelineResult.graph.iterNodes()].filter( + (node) => node.label === 'Function' && node.properties.filePath === SWIFT_PATH, + ); + function symbol(name: string): SymbolRef { + const matches = swiftFunctions.filter((node) => node.properties.name === name); + expect(matches, name).toHaveLength(1); + return { uid: matches[0].id, name, filePath: SWIFT_PATH }; + } + const target = symbol(TARGET_NAME); + const callers = CALLER_NAMES.map(symbol); + expect(new Set(callers.map(({ uid }) => uid)).size).toBe(2); + + // runFullAnalysis is imported from source. Use the same source build + // for status so it actually measures content drift instead of stopping + // at a source/dist runner-identity mismatch. Context may use either. + const statusPrefix = [ + '--import', + tsxLoaderUrl(), + fileURLToPath(new URL('../../src/cli/index.ts', import.meta.url)), + ]; + const readStatus = (): StatusResult => + runCli(worktree, ['status', '--json'], statusPrefix); + const expectCurrentStatus = (): void => { + const status = readStatus(); + expect(status.repository).toBe(worktree); + expect(status.index.runnerIdentityStatus).toBe('current'); + expect(status.contentDrift.status).toBe('current'); + expect(status.contentDrift.coveredFiles).toBeGreaterThan(50); + expect(status.index.incompleteReasons).toEqual([]); + expect(status.status).toBe('up-to-date'); + }; + const readPublicContexts = async (): Promise => { + // CLI and MCP own separate native readers, released before writes. + expectContext(runCli(worktree, ['context', TARGET_NAME]), target, callers); + expectContext(runCli(worktree, ['context', '--uid', target.uid]), target, callers); + backend = new LocalBackend(); + try { + expect(await backend.init()).toBe(true); + for (let repeat = 0; repeat < 2; repeat++) { + expectContext( + await backend.callTool('context', { name: TARGET_NAME }), + target, + callers, + ); + expectContext( + await backend.callTool('context', { uid: target.uid }), + target, + callers, + ); + } + } finally { + await backend.dispose(); + backend = undefined; + } + }; + const readPropertyFts = async (): Promise => { + await adapter.initLbug(lbugPath); + try { + const rows = await adapter.executeQuery( + `CALL QUERY_FTS_INDEX('Property', 'property_fts', '${PROPERTY_NAME}') + RETURN node.id AS uid, node.name AS name, node.filePath AS filePath`, + ); + expect(rows).toEqual([ + { uid: expect.any(String), name: PROPERTY_NAME, filePath: SWIFT_PATH }, + ]); + return rows; + } finally { + await adapter.closeLbug(); + } + }; + + await readPublicContexts(); + expectCurrentStatus(); + const propertyBefore = await readPropertyFts(); + + await fs.writeFile(path.join(worktree, TEST_PATH), 'def fixture_number():\n return 8\n'); + expect(git(worktree, 'diff', '--name-only')).toBe(TEST_PATH); + const dirtyStatus = readStatus(); + expect(dirtyStatus.contentDrift.status).toBe('drifted'); + expect(dirtyStatus.index.incompleteReasons).toEqual([]); + + // A call-through spy observes the exact native deletion boundary; it + // neither supplies graph rows nor substitutes for the incremental write. + const deleteNodes = vi.spyOn(adapter, 'deleteNodesForFiles'); + const refreshed = await runFullAnalysis(worktree, options, callbacks); + expect(refreshed.incrementalStats).toMatchObject({ + changedFiles: 1, + writeMode: 'incremental', + }); + expect(refreshed.incrementalStats?.affectedDependents).toBeGreaterThan(0); + expect(deleteNodes).toHaveBeenCalledTimes(1); + const writePaths = [...deleteNodes.mock.calls[0][0]].sort(); + // Effective write-set expansion also refreshes the containing Folder + // row through graph edges; no other source file enters the write set. + expect(writePaths).toEqual(['checks', TEST_PATH, IMPORTER_PATH].sort()); + expect(writePaths.filter((filePath) => filePath.endsWith('.py'))).toEqual( + [TEST_PATH, IMPORTER_PATH].sort(), + ); + expect(writePaths).not.toContain(SWIFT_PATH); + expect((await loadMeta(storagePath))?.runnerIdentity).toEqual(baselineMeta?.runnerIdentity); + expect(await fs.readFile(path.join(worktree, SWIFT_PATH), 'utf8')).toBe(SWIFT_SOURCE); + await readPublicContexts(); + expectCurrentStatus(); + expect(await readPropertyFts()).toEqual(propertyBefore); + + deleteNodes.mockClear(); + const noop = await runFullAnalysis(worktree, options, callbacks); + // The edit remains uncommitted, so clean-porcelain early return is + // unavailable. Identical content instead takes a zero-file refresh. + expect(noop.incrementalStats).toMatchObject({ + changedFiles: 0, + affectedDependents: 0, + deletedFiles: 0, + writeMode: 'incremental', + }); + expect(deleteNodes.mock.calls.flatMap(([filePaths]) => filePaths)).toEqual([]); + expect((await loadMeta(storagePath))?.runnerIdentity).toEqual(baselineMeta?.runnerIdentity); + await readPublicContexts(); + expectCurrentStatus(); + expect(await readPropertyFts()).toEqual(propertyBefore); + } finally { + await backend?.dispose(); + await adapter.closeLbug(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + await temp.cleanup(); + } + }, 180_000); + }, +); diff --git a/gitnexus/test/unit/bm25-search.test.ts b/gitnexus/test/unit/bm25-search.test.ts index 08b2fc46a..87b750fb7 100644 --- a/gitnexus/test/unit/bm25-search.test.ts +++ b/gitnexus/test/unit/bm25-search.test.ts @@ -45,7 +45,9 @@ describe('BM25 search', () => { // One SHOW_INDEXES call returns a catalog row per configured index, each // covering exactly its expected properties. const showIndexesRows = FTS_INDEXES.map((i) => ({ + table_name: i.table, index_name: i.indexName, + index_type: 'FTS', property_names: [...i.properties], })); const executeQuery = vi.fn().mockResolvedValue(showIndexesRows); @@ -62,7 +64,9 @@ describe('BM25 search', () => { // missing `description`. Every other index covers its columns. const staleIndex = 'function_fts'; const showIndexesRows = FTS_INDEXES.map((i) => ({ + table_name: i.table, index_name: i.indexName, + index_type: 'FTS', property_names: i.indexName === staleIndex ? ['name', 'content'] : [...i.properties], })); const executeQuery = vi.fn().mockResolvedValue(showIndexesRows); @@ -77,7 +81,9 @@ describe('BM25 search', () => { // Every configured index present and covering, except const_fts is missing. const absentIndex = 'const_fts'; const showIndexesRows = FTS_INDEXES.filter((i) => i.indexName !== absentIndex).map((i) => ({ + table_name: i.table, index_name: i.indexName, + index_type: 'FTS', property_names: [...i.properties], })); const executeQuery = vi.fn().mockResolvedValue(showIndexesRows); diff --git a/gitnexus/test/unit/fts-indexes.test.ts b/gitnexus/test/unit/fts-indexes.test.ts index 84a9dd0ee..37026dede 100644 --- a/gitnexus/test/unit/fts-indexes.test.ts +++ b/gitnexus/test/unit/fts-indexes.test.ts @@ -34,13 +34,19 @@ const { getSearchFTSStemmer, initialiseSearchFTSStemmer, missingSearchFTSIndexTables, + verifySearchFTSIndexes, } = await import('../../src/core/search/fts-indexes.js'); const { FTS_INDEXES, getFtsIndexes } = await import('../../src/core/search/fts-schema.js'); const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js'); /** SHOW_INDEXES rows covering every configured FTS index's expected properties. */ const fullCoverageRows = () => - FTS_INDEXES.map((i) => ({ index_name: i.indexName, property_names: [...i.properties] })); + FTS_INDEXES.map((i) => ({ + table_name: i.table, + index_name: i.indexName, + index_type: 'FTS', + property_names: [...i.properties], + })); /** The row-level tokenizer error of #2544/#2546/#2889, verbatim. */ const POISON = 'Runtime exception: Failed calling LOWER: Invalid UTF-8.'; @@ -141,6 +147,91 @@ describe('createSearchFTSIndexes', () => { }); }); +describe('verifySearchFTSIndexes', () => { + const propertyIndex = FTS_INDEXES.find((index) => index.table === 'Property')!; + const propertyRow = () => fullCoverageRows().find((row) => row.table_name === 'Property')!; + + it('accepts exact identity/type with additional properties, using one catalog read', async () => { + const executeQuery = vi.fn(async () => [ + { ...propertyRow(), property_names: [...propertyIndex.properties, 'extra'] }, + ]); + const onMismatch = vi.fn(); + + expect(await verifySearchFTSIndexes(executeQuery, [propertyIndex], onMismatch)).toEqual([]); + expect(executeQuery).toHaveBeenCalledExactlyOnceWith('CALL SHOW_INDEXES() RETURN *'); + expect(onMismatch).not.toHaveBeenCalled(); + }); + + it.each([ + ['wrong table', { table_name: 'Function' }], + ['wrong type', { index_type: 'HASH' }], + ['missing type', { index_type: undefined }], + ['missing properties', { property_names: undefined }], + ['non-array properties', { property_names: 'name,content,description' }], + ['malformed properties', { property_names: ['name', 'content', 'description', 17] }], + ['incomplete properties', { property_names: ['name', 'content'] }], + ])('rejects %s without accepting the matching index name', async (_label, mismatch) => { + const executeQuery = vi.fn(async () => [{ ...propertyRow(), ...mismatch }]); + const onMismatch = vi.fn(); + + expect(await verifySearchFTSIndexes(executeQuery, [propertyIndex], onMismatch)).toEqual([ + 'Property.property_fts', + ]); + expect(onMismatch).toHaveBeenCalledExactlyOnceWith({ + table: 'Property', + indexName: 'property_fts', + message: expect.stringContaining('Property.property_fts'), + }); + expect(onMismatch.mock.calls[0][0].message).toContain('observed'); + }); + + it('accepts a valid row even when another table has the same index name', async () => { + const executeQuery = vi.fn(async () => [ + propertyRow(), + { ...propertyRow(), table_name: 'Function', property_names: ['name'] }, + ]); + + expect(await verifySearchFTSIndexes(executeQuery, [propertyIndex])).toEqual([]); + }); + + it('bounds diagnostic evidence and excludes source and index definitions', async () => { + const privateSource = 'PRIVATE_SOURCE_MUST_NOT_APPEAR'; + const executeQuery = vi.fn(async () => + Array.from({ length: 50 }, (_, i) => ({ + ...propertyRow(), + table_name: `Wrong${i}${'x'.repeat(10_000)}`, + property_names: [{ source: privateSource }, ...Array(100).fill('x'.repeat(10_000))], + content: privateSource, + index_definition: privateSource, + })), + ); + const onMismatch = vi.fn(); + + expect(await verifySearchFTSIndexes(executeQuery, [propertyIndex], onMismatch)).toEqual([ + 'Property.property_fts', + ]); + const message = onMismatch.mock.calls[0][0].message as string; + expect(message).toContain('Wrong0'); + expect(message).toContain('truncated'); + expect(message.length).toBeLessThanOrEqual(2048); + expect(message).not.toContain(privateSource); + expect(message).not.toContain('index_definition'); + expect(executeQuery).toHaveBeenCalledTimes(1); + }); + + it('preserves unknown catalog state as an exception, without reporting absence', async () => { + const executeQuery = vi.fn(async () => { + throw new Error('catalog unavailable'); + }); + const onMismatch = vi.fn(); + + await expect(verifySearchFTSIndexes(executeQuery, [propertyIndex], onMismatch)).rejects.toThrow( + 'catalog unavailable', + ); + expect(onMismatch).not.toHaveBeenCalled(); + }); +}); + describe('buildSearchIndexesOrDegrade', () => { it('returns ok:true when every index builds and verifies (#2544/#2546)', async () => { const executeQuery = vi.fn(async () => fullCoverageRows()); @@ -215,6 +306,69 @@ describe('buildSearchIndexesOrDegrade', () => { expect(result.error).toContain(`${failed} (${POISON})`); expect(result.error).not.toContain('missing indexes'); }); + + it('explains missing catalog coverage when CREATE returned no build error', async () => { + const executeQuery = vi.fn(async () => + fullCoverageRows().filter((row) => row.table_name !== 'Property'), + ); + + const result = await buildSearchIndexesOrDegrade(executeQuery); + + expect(result.ok).toBe(false); + expect(result.failureClass).toBe('capability'); + expect(result.error).toContain('Property.property_fts'); + expect(result.error).toContain('no build error was returned'); + expect(result.error).toContain('observed'); + expect(result.error).toContain('no matching'); + expect(executeQuery).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['integrity', 'IO exception: checkpoint failed', 'integrity'], + ['capability', POISON, 'capability'], + ['unknown', 'catalog unavailable', 'capability'], + ])( + 'classifies a catalog-only %s error after every index builds', + async (_label, error, failureClass) => { + const onIndexReady = vi.fn(); + const executeQuery = vi.fn(async () => { + throw new Error(error); + }); + + const result = await buildSearchIndexesOrDegrade(executeQuery, { onIndexReady }); + + expect(createFTSIndex).toHaveBeenCalledTimes(FTS_INDEXES.length); + expect(onIndexReady.mock.calls).toEqual( + FTS_INDEXES.map(({ table, indexName }) => [table, indexName]), + ); + expect(executeQuery).toHaveBeenCalledExactlyOnceWith('CALL SHOW_INDEXES() RETURN *'); + expect(result).toEqual({ + ok: false, + error: `FTS catalog verification failed: ${error}`, + failureClass, + }); + }, + ); + + it('retains integrity and tokenizer causes when catalog verification also throws', async () => { + const integrityError = 'IO exception: checkpoint failed'; + vi.mocked(createFTSIndex) + .mockRejectedValueOnce(new Error(integrityError)) + .mockRejectedValueOnce(new Error(POISON)); + const executeQuery = vi.fn(async () => { + throw new Error('catalog unavailable'); + }); + + const result = await buildSearchIndexesOrDegrade(executeQuery); + + expect(result.ok).toBe(false); + expect(result.failureClass).toBe('integrity'); + expect(result.error).toContain(integrityError); + expect(result.error).toContain(POISON); + expect(result.error).toContain('catalog unavailable'); + expect(result.error).not.toContain('missing indexes'); + expect(executeQuery).toHaveBeenCalledTimes(1); + }); }); describe('missingSearchFTSIndexTables (#3016)', () => { diff --git a/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts b/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts index 8592546dc..aaa5c58a8 100644 --- a/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts +++ b/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts @@ -730,6 +730,68 @@ describe('lbug adapter CHECKPOINT lifecycle', () => { await adapter.closeLbug(); }); + it.each([false, true])( + 'drains every FTS CREATE result and preserves a deferred failure (failure=%s)', + async (fails) => { + vi.resetModules(); + const nativeError = new Error('Runtime exception: Failed calling LOWER: Invalid UTF-8.'); + const events: string[] = []; + const results = ['first', 'second', 'third'].map((name) => ({ + getAll: vi.fn(async () => { + events.push(`${name}:getAll`); + if (fails && name === 'second') throw nativeError; + return []; + }), + close: vi.fn(() => { + events.push(`${name}:close`); + }), + })); + const genericResult = { getAll: vi.fn(async () => []), close: vi.fn() }; + const conn = makeConn(async (sql: string) => + sql.startsWith('CALL CREATE_FTS_INDEX') ? results : genericResult, + ); + const db = { close: vi.fn(async () => {}) }; + const dbPath = '/tmp/gitnexus-lbug-fts-create-drain/lbug'; + mockFsForInit(dbPath); + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: vi.fn(async () => ({ db, conn })), + closeLbugConnection: vi.fn(async () => {}), + isDbBusyError: vi.fn(() => false), + isOpenRetryExhausted: vi.fn(() => false), + waitForWindowsHandleRelease: vi.fn(async () => true), + isStorageVersionMismatchError: vi.fn(() => false), + throwIfStorageVersionMismatch: vi.fn(), + STORAGE_VERSION_MISMATCH_SUGGESTION: '', + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: vi.fn(async () => true), + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbug(dbPath); + try { + const create = adapter.createFTSIndex('Property', 'property_fts', ['name']); + if (fails) await expect(create).rejects.toBe(nativeError); + else await expect(create).resolves.toBeUndefined(); + expect(events).toEqual([ + 'first:getAll', + 'first:close', + 'second:getAll', + 'second:close', + 'third:getAll', + 'third:close', + ]); + for (const result of results) expect(result.close).toHaveBeenCalledOnce(); + } finally { + await adapter.closeLbug(); + } + }, + ); + it('closes non-first stream query results when LadybugDB returns an array', async () => { vi.resetModules(); diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts index 452166590..b6dda595c 100644 --- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts @@ -295,6 +295,123 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => { ...overrides, }); + it.each([ + { label: 'old catalog entry survives a failed DROP', buildError: true, catalog: 'complete' }, + { label: 'CREATE succeeds without a catalog entry', buildError: false, catalog: 'missing' }, + { label: 'build and catalog verification both fail', buildError: true, catalog: 'missing' }, + { label: 'catalog read throws after a build failure', buildError: true, catalog: 'throws' }, + { label: 'catalog read alone throws', buildError: false, catalog: 'throws' }, + ])('keeps repair incomplete when $label', async ({ buildError, catalog }) => { + const closeLbug = vi.fn(async () => undefined); + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => + mockRepairSuccessLbugAdapter({ closeLbug }), + ); + vi.doMock('../../src/core/search/fts-indexes.js', async (importActual) => ({ + ...(await importActual()), + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + createSearchFTSIndexes: vi.fn(async () => + buildError + ? [ + { + table: 'Property', + indexName: 'property_fts', + error: 'DROP failed: native I/O error', + }, + ] + : [], + ), + verifySearchFTSIndexes: vi.fn(async (_query, _indexes, onMismatch) => { + if (catalog === 'throws') throw new Error('catalog read unavailable'); + if (catalog === 'missing') { + onMismatch?.({ + table: 'Property', + indexName: 'property_fts', + message: 'Property.property_fts: no catalog row returned', + }); + return ['Property.property_fts']; + } + return []; + }), + })); + + const tmpRepo = await createTempDir('gitnexus-repair-incomplete-'); + try { + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + const seeded: RepoMeta = { + repoPath: tmpRepo.dbPath, + lastCommit: 'healthy-commit', + indexedAt: '2026-01-01T00:00:00.000Z', + stats: { files: 7, nodes: 42, edges: 10, embeddings: 3 }, + runnerIdentity: { + schemaVersion: 4, + runtime: { + executablePath: '/usr/bin/node', + version: 'v24.11.0', + platform: 'linux', + architecture: 'x64', + modulesAbi: '137', + libc: 'glibc', + }, + cliVersion: '1.0.0', + invokedArtifact: { path: '/x/cli/index.ts', digest: 'src-digest' }, + build: { + kind: 'source' as const, + rootPath: '/x', + canonicalization: 'gitnexus-analyzer-build-v2', + digest: 'build-digest', + }, + dependencyRuntime: { + manifestPath: '/x/package.json', + lockfilePath: null, + canonicalization: 'gitnexus-analyzer-dependency-runtime-v4', + packageCount: 1, + artifactCount: 1, + digest: 'dep-digest', + }, + }, + capabilities: { + graph: { provider: 'ladybugdb', status: 'available' }, + fts: { provider: 'ladybugdb-fts', status: 'degraded' }, + vectorSearch: { provider: 'exact-scan', status: 'unavailable', exactScanLimit: 500 }, + }, + }; + await saveMeta(storagePath, seeded); + await createPlaceholderGraphStore(lbugPath); + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const error = await runFullAnalysis( + tmpRepo.dbPath, + { repairFts: true }, + { onProgress: () => {} }, + ).then( + () => null, + (cause: unknown) => cause, + ); + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain('FTS repair failed'); + if (buildError) expect(message).toContain('DROP failed: native I/O error'); + if (catalog === 'missing') { + expect(message).toContain('Property.property_fts: no catalog row returned'); + if (!buildError) expect(message).toContain('no build error was returned'); + } + if (catalog === 'throws') { + expect(message).toContain('catalog read unavailable'); + expect(message).not.toContain('missing indexes'); + } + const after = await loadMeta(storagePath); + expect(after?.incrementalInProgress).toMatchObject({ phase: 'fts' }); + expect(after?.capabilities).toEqual(seeded.capabilities); + expect(after?.indexedAt).toBe(seeded.indexedAt); + expect(after?.lastCommit).toBe(seeded.lastCommit); + expect(after?.stats).toEqual(seeded.stats); + expect(after?.runnerIdentity).toEqual(seeded.runnerIdentity); + expect(closeLbug).toHaveBeenCalled(); + } finally { + await tmpRepo.cleanup(); + } + }); + it('--repair-fts stamps capabilities.fts.status while leaving indexedAt/lastCommit/runnerIdentity/stats byte-identical (#2767)', async () => { vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter()); vi.doMock('../../src/core/search/fts-indexes.js', () => ({ diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 53e9f0ba1..2b1d43c38 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -69,6 +69,7 @@ export default defineConfig({ 'test/integration/skip-fts.test.ts', 'test/integration/impact-callable-value-references.test.ts', 'test/integration/impact-context-integrity.test.ts', + 'test/integration/incremental-worktree-context.test.ts', 'test/integration/impact-epistemic-lower-bound.test.ts', 'test/integration/impact-scope-omission-persistence.test.ts', 'test/integration/lbug-core-adapter.test.ts', @@ -165,6 +166,7 @@ export default defineConfig({ 'test/integration/skip-fts.test.ts', 'test/integration/impact-callable-value-references.test.ts', 'test/integration/impact-context-integrity.test.ts', + 'test/integration/incremental-worktree-context.test.ts', 'test/integration/impact-epistemic-lower-bound.test.ts', 'test/integration/impact-scope-omission-persistence.test.ts', 'test/integration/lbug-core-adapter.test.ts', From df49f908893ab42cafab1b1ed09633ede93c2260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Tue, 6 Oct 2026 07:34:17 +0300 Subject: [PATCH 11/13] fix(ci): require every test to execute across the CI matrix (#3479) --- .github/workflows/ci-report.yml | 28 +- .github/workflows/ci-tests.yml | 190 ++++++++++-- .gitignore | 8 + README.md | 1 + TESTING.md | 37 ++- eval/check_test_execution.py | 121 ++++++++ eval/tests/test_check_test_execution.py | 142 +++++++++ eval/tests/test_evolve.py | 22 +- eval/tests/test_workflow_bench.py | 71 ++++- gitnexus/package.json | 1 + gitnexus/scripts/cross-platform-tests.ts | 9 +- gitnexus/scripts/execution-reporter.ts | 33 ++ gitnexus/scripts/run-benchmarks.ts | 32 ++ gitnexus/scripts/run-cross-platform.ts | 13 +- gitnexus/scripts/test-completeness.ts | 203 +++++++++++++ .../languages/dart/package-config.ts | 57 +++- .../integration/go-pipeline-benchmark.test.ts | 111 +++---- gitnexus/test/unit/calltool-dispatch.test.ts | 15 +- ...canonicalize-path-long-path-prefix.test.ts | 34 ++- .../test/unit/cross-platform-shard.test.ts | 10 +- .../test/unit/dart-package-imports.test.ts | 157 ++++++---- .../unit/eval-server-bind-restriction.test.ts | 4 +- .../unit/evidence-provenance-helper.test.ts | 31 +- .../test/unit/mcp-repository-policy.test.ts | 2 +- gitnexus/test/unit/repo-manager.test.ts | 2 +- .../test/unit/run-analyze-fts-repair.test.ts | 85 ++++-- .../name-fallback-visibility.test.ts | 2 +- .../rust-cargo-targets.test.ts | 2 +- gitnexus/test/unit/spring-aop.test.ts | 2 +- gitnexus/test/unit/test-completeness.test.ts | 284 ++++++++++++++++++ gitnexus/test/unit/type-env.test.ts | 11 +- gitnexus/vitest.config.ts | 2 + 32 files changed, 1478 insertions(+), 244 deletions(-) create mode 100644 eval/check_test_execution.py create mode 100644 eval/tests/test_check_test_execution.py create mode 100644 gitnexus/scripts/execution-reporter.ts create mode 100644 gitnexus/scripts/run-benchmarks.ts create mode 100644 gitnexus/scripts/test-completeness.ts create mode 100644 gitnexus/test/unit/test-completeness.test.ts diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index a16cb1103..83a05c35e 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -287,6 +287,7 @@ jobs: # ── Locate test results ── RESULTS_FILE=$(find_first "$DIR/test-reports" "test-results.json") WEB_RESULTS_FILE=$(find_first "$DIR/test-reports" "web-test-results.json") + PYTEST_RESULTS_FILE=$(find_first "$DIR/test-reports" "pytest-results.json") sum_results() { local file=$1 @@ -303,12 +304,21 @@ jobs: # framework, not as a top-line metric). read -r CLI_T CLI_P CLI_F CLI_S _ CLI_D <<< "$(sum_results "$RESULTS_FILE")" read -r WEB_T WEB_P WEB_F WEB_S _ WEB_D <<< "$(sum_results "$WEB_RESULTS_FILE")" + read -r PY_T PY_P PY_F PY_S _ PY_D <<< "$(sum_results "$PYTEST_RESULTS_FILE")" - TOTAL=$((CLI_T + WEB_T)) - PASSED=$((CLI_P + WEB_P)) - FAILED=$((CLI_F + WEB_F)) - SKIPPED=$((CLI_S + WEB_S)) + TOTAL=$((CLI_T + WEB_T + PY_T)) + PASSED=$((CLI_P + WEB_P + PY_P)) + FAILED=$((CLI_F + WEB_F + PY_F)) + SKIPPED=$((CLI_S + WEB_S + PY_S)) DURATION=$((CLI_D > WEB_D ? CLI_D : WEB_D)) + DURATION=$((DURATION > PY_D ? DURATION : PY_D)) + EXECUTION_ERRORS=0 + for rf in "$RESULTS_FILE" "$WEB_RESULTS_FILE" "$PYTEST_RESULTS_FILE"; do + if [ -n "$rf" ] && [ -f "$rf" ]; then + errors=$(jq -r '(.executionFailures // []) | length' "$rf") + EXECUTION_ERRORS=$((EXECUTION_ERRORS + errors)) + fi + done # ── Status helpers ── status_icon() { @@ -385,22 +395,22 @@ jobs: if [ "$TOTAL" -gt 0 ] 2>/dev/null; then echo "### Test Results" echo "" - echo "| Tests | Passed | Failed | Skipped | Duration |" + echo "| Tests | Passed | Failed | Unverified | Duration |" echo "|-------|--------|--------|---------|----------|" echo "| ${TOTAL} | ${PASSED} | ${FAILED} | ${SKIPPED} | ${DURATION}s |" echo "" - if [ "$FAILED" = "0" ]; then + if [[ "$FAILED" == "0" && "$SKIPPED" == "0" && "$EXECUTION_ERRORS" == "0" && "$TESTS" == "success" ]]; then echo "✅ All **${PASSED}** tests passed" else - echo "❌ **${FAILED}** failed / **${PASSED}** passed" + echo "❌ Test execution is incomplete or failed: **${FAILED}** failed, **${SKIPPED}** unverified, **${EXECUTION_ERRORS}** runner/suite errors." fi if [ "$SKIPPED" != "0" ]; then echo "" echo "
" - echo "${SKIPPED} test(s) skipped — expand for details" + echo "${SKIPPED} test(s) have no recorded pass — expand for details" echo "" - for rf in "$RESULTS_FILE" "$WEB_RESULTS_FILE"; do + for rf in "$RESULTS_FILE" "$WEB_RESULTS_FILE" "$PYTEST_RESULTS_FILE"; do if [ -n "$rf" ] && [ -f "$rf" ]; then jq -r ' .testResults[] diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index e6d93f74b..4862fc6ec 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -32,6 +32,7 @@ jobs: env: GITNEXUS_REQUIRE_FTS: '1' GITNEXUS_REQUIRE_ZIG: '1' + GITNEXUS_REQUIRE_VECTOR: '1' steps: # persist-credentials: false — runs tests + uploads a blob artifact; the # default-persisted token must not be capturable through it (zizmor @@ -115,7 +116,7 @@ jobs: run: >- npx vitest --mergeReports --reporter=default - --reporter=json + --reporter=./scripts/execution-reporter.ts --outputFile=test-results.json --coverage --coverage.reporter=json-summary @@ -131,7 +132,8 @@ jobs: run: >- npx vitest run --reporter=default - --reporter=json + --reporter=../gitnexus/scripts/execution-reporter.ts + --includeTaskLocation --outputFile=web-test-results.json working-directory: gitnexus-web - name: Run docker-server integration tests @@ -140,7 +142,7 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: test-reports + name: coverage-reports path: | gitnexus/coverage/coverage-summary.json gitnexus/coverage/coverage-final.json @@ -162,7 +164,7 @@ jobs: steps: - id: gen run: | - TOTAL=3 # cross-platform (windows/macOS) shards per OS + TOTAL=4 # cross-platform (windows/macOS) shards per OS COV_TOTAL=3 # ubuntu coverage shards (merged before thresholds) if [ "$TOTAL" -lt 1 ] || [ "$COV_TOTAL" -lt 1 ]; then echo "shard totals must be >= 1" >&2; exit 1 @@ -254,8 +256,48 @@ jobs: shell: bash env: SHARD: ${{ matrix.shard }}/${{ needs.shard-plan.outputs.total }} + GITNEXUS_TEST_REPORT: ${{ matrix.os }}-${{ matrix.shard }}.json run: npx tsx scripts/run-cross-platform.ts --shard="$SHARD" working-directory: gitnexus + - name: Upload platform execution report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: execution-${{ matrix.os }}-${{ matrix.shard }} + path: gitnexus/${{ matrix.os }}-${{ matrix.shard }}.json + if-no-files-found: error + retention-days: 5 + + # Branch protection still requires these six names from the three-shard + # matrix. Keep them as aggregate gates as the real matrix grows: every native + # shard and the complete execution audit must pass before any gate succeeds. + # These jobs only verify dependency results; tests run in cross-platform above. + protected-platform-checks: + name: ${{ matrix.os }} (platform-sensitive) ${{ matrix.check }}/3 + needs: [cross-platform, test-completeness] + if: always() + permissions: {} + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + check: [1, 2, 3] + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Require all native shards and the execution audit + shell: bash + env: + NATIVE_RESULT: ${{ needs.cross-platform.result }} + EXECUTION_RESULT: ${{ needs.test-completeness.result }} + run: | + echo "Compatibility gate for the former three-shard check name." + echo "All native shards: $NATIVE_RESULT" + echo "Complete execution audit: $EXECUTION_RESULT" + if [[ "$NATIVE_RESULT" != "success" || "$EXECUTION_RESULT" != "success" ]]; then + echo "::error::Every native shard and the execution audit must succeed." + exit 1 + fi # Tree-sitter ABI gate (#1922). Two halves, both blocking: # 1. Static, offline: assert every grammar's compiled ABI loads on the @@ -482,11 +524,8 @@ jobs: # heap, so parallel forks both skew the timings and OOM the worker pool — they # must run one file at a time. # - # go-pipeline-benchmark.test.ts is deliberately NOT included: its - # worker-pool (#1848) suite spins a real worker pool that exits unexpectedly - # under vitest's fork pool (reproduced in validation), which would make this - # gate flaky. Go is already guarded by its non-gated O(n^2) tripwire (runs in - # the main coverage job) plus its golden capture-parity test. + # Every opt-in suite is discovered by run-benchmarks.ts. A failing worker + # benchmark is a failure to investigate, never a reason to omit that suite. benchmarks: name: benchmarks (GITNEXUS_BENCH) runs-on: ubuntu-latest @@ -876,28 +915,18 @@ jobs: - name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial) if: ${{ !cancelled() }} - # cpp-adl-benchmark.test.ts and csharp-razor-view-components-benchmark.test.ts - # are not `*-pipeline-benchmark.test.ts` files but belong here for the - # same reason: they are skipIf-gated on GITNEXUS_BENCH, so the scaling - # guards they hold never run in the main coverage job. env: - GITNEXUS_BENCH: '1' GITNEXUS_WORKER_READY_TIMEOUT_MS: '60000' - run: >- - npx vitest run --no-file-parallelism - test/integration/cobol-pipeline-benchmark.test.ts - test/integration/csharp-pipeline-benchmark.test.ts - test/integration/csharp-razor-view-components-benchmark.test.ts - test/integration/objective-c-pipeline-benchmark.test.ts - test/integration/cpp-adl-benchmark.test.ts - test/integration/data-route-table-benchmark.test.ts - test/integration/instance-ownership-pipeline-benchmark.test.ts - test/integration/spring-bean-resource-benchmark.test.ts - test/integration/spring-dynamic-lookup-benchmark.test.ts - test/integration/rust-pipeline-benchmark.test.ts - test/integration/php-pipeline-benchmark.test.ts - test/integration/ruby-pipeline-benchmark.test.ts + run: npm run test:benchmarks working-directory: gitnexus + - name: Upload benchmark execution report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: execution-benchmarks + path: gitnexus/benchmarks.json + if-no-files-found: error + retention-days: 5 # Locked eval suite. setup-uv and uv itself are immutable so CI exercises # exactly the dependency graph developers run from eval/uv.lock. @@ -916,8 +945,88 @@ jobs: python-version: '3.13' enable-cache: true cache-dependency-glob: eval/uv.lock - - run: uv run --locked --extra dev python -m pytest tests -q + - run: uv run --locked --extra dev python -m pytest tests -q --junitxml=pytest-locked.xml working-directory: eval + - name: Upload pytest execution report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: execution-pytest-locked + path: eval/pytest-locked.xml + if-no-files-found: error + retention-days: 5 + - uses: ./.github/actions/setup-gitnexus + with: + build: 'true' + - name: Exercise real workflow evidence preflight + if: ${{ !cancelled() }} + run: >- + npx vitest run test/unit/skill-evolution-workflow.test.ts + --reporter=default --reporter=./scripts/execution-reporter.ts --outputFile=preflight.json + working-directory: gitnexus + - name: Upload preflight execution report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: execution-preflight + path: gitnexus/preflight.json + if-no-files-found: error + retention-days: 5 + + test-completeness: + name: every test executed + needs: + [ + shard-plan, + coverage-merge, + cross-platform, + benchmarks, + eval-tests, + eval-containment-linux, + eval-containment-windows, + ] + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-gitnexus + - name: Download coverage and web reports + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: coverage-reports + - name: Download all execution receipts + if: ${{ !cancelled() }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: execution-* + path: execution-reports + merge-multiple: true + - name: Require a real pass for every collected test + env: + PLATFORM_SHARDS: ${{ needs.shard-plan.outputs.total }} + run: | + cp gitnexus/test-results.json execution-reports/coverage.json + cd gitnexus + node --import tsx scripts/test-completeness.ts ../execution-reports test-results.json "$PLATFORM_SHARDS" + - name: Require a real pass for every pytest case + if: ${{ !cancelled() }} + run: python3 eval/check_test_execution.py execution-reports eval/pytest-results.json + - name: Upload complete test reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-reports + path: | + gitnexus/coverage/coverage-summary.json + gitnexus/coverage/coverage-final.json + gitnexus/test-results.json + gitnexus-web/web-test-results.json + eval/pytest-results.json + if-no-files-found: error + retention-days: 5 # Native Linux ownership and Bubblewrap boundary. The environment flag makes # the real namespace test mandatory; a missing/blocked bwrap is a failure. @@ -992,8 +1101,19 @@ jobs: tests/test_workflow_bench_sessions.py tests/test_ce_plugin_runtime.py tests/test_offline_sweep_integration.py - tests/test_mock_provider.py -q + tests/test_mock_provider.py + tests/test_evolve.py::test_outer_runner_pid_namespace_kills_setsid_descendant + tests/test_oracle_assets.py::test_hidden_vitest_config_executes_sibling_oracle_against_candidate_checkout + -q --junitxml=pytest-ubuntu.xml working-directory: eval + - name: Upload pytest execution report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: execution-pytest-ubuntu + path: eval/pytest-ubuntu.xml + if-no-files-found: error + retention-days: 5 # Native Windows Job Object canary. POSIX-only tests skip by platform, while # the grandchild delayed-write test must execute and pass on this runner. @@ -1015,5 +1135,13 @@ jobs: run: >- uv run --locked --extra dev python -m pytest tests/test_process_control.py tests/test_model_gateway.py - -k "not locked_litellm" -q + -k "not locked_litellm" -q --junitxml=pytest-windows.xml working-directory: eval + - name: Upload pytest execution report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: execution-pytest-windows + path: eval/pytest-windows.xml + if-no-files-found: error + retention-days: 5 diff --git a/.gitignore b/.gitignore index 873f4082f..ffa32d659 100644 --- a/.gitignore +++ b/.gitignore @@ -131,5 +131,13 @@ local_docs/ .context/ gitnexus/web/ +# Local copies of CI execution receipts. +gitnexus/benchmarks.json +gitnexus/preflight.json +gitnexus/test-results.json +gitnexus/windows-latest-*.json +gitnexus/macos-latest-*.json +gitnexus-web/web-test-results.json + # Machine-local skill-evolution evidence (consumed by eval/workflow_bench/evolve.py) eval/workflow_bench/learnings.jsonl diff --git a/README.md b/README.md index a761716e7..d89a7ef05 100644 --- a/README.md +++ b/README.md @@ -611,6 +611,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | Variable | Default | Effect | Tune when… | | ----------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GITNEXUS_TEST_REPORT` | unset | Write a JSON execution receipt from `scripts/run-cross-platform.ts`; accepts a basename ending in `.json`. | CI platform shards use distinct filenames so the completeness check can require every report. | | `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers `. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. | | `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. | | `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. | diff --git a/TESTING.md b/TESTING.md index 4be0f73c5..450a0c792 100644 --- a/TESTING.md +++ b/TESTING.md @@ -125,11 +125,46 @@ GitHub Actions (`.github/workflows/ci.yml`) orchestrate: | `ci-scope-parity.yml` | discover, parity | Scope-resolution parity for all migrated languages | | `ci-e2e.yml` | e2e (chromium) | Playwright E2E, gated on `gitnexus-web/**` changes | -The `CI Gate` job in `ci.yml` is the single required check for branch protection. It requires quality, tests, e2e, and scope-parity to all pass. +The `CI Gate` job in `ci.yml` requires the quality and test workflows to pass. +The browser E2E workflow must pass or be skipped because no web files changed. + +Branch protection also requires six platform check names from the former +three-shard matrix. These names remain as aggregate gates: all native shards +and the `every test executed` audit must succeed before any of them passes. +Failed, cancelled, skipped, or missing dependency results fail these gates. +The actual native tests run in the current Windows/macOS shard matrix. The `typecheck` job runs both the production compiler check and `npm run typecheck:tests`. A type error in either check fails the job and the CI gate. +### Complete execution, including platform and benchmark tests + +The required `every test executed` job reconciles execution receipts from Ubuntu +coverage, every Windows/macOS shard, the serial benchmark run, and the real Python +workflow preflight. It requires a recorded pass for every collected test. A skip +on Linux is satisfied only by a pass of that exact test in another required job. +Test identities include the file, suite/title and source location; ambiguous +parameterized cases must have unique titles. Missing receipts, missing test files, +unhandled runner errors, failed hooks, failed assertions, and tests with no pass +all fail the gate. Web tests are checked separately with the same rules. + +The locked pytest suite and Linux/Windows containment jobs also upload JUnit +receipts. The same gate checks every Python test file was collected and every +case passed in at least one job, while preserving failures from any job. The +Linux containment job supplies Bubblewrap, the pinned CLI, and built Vitest +dependencies for tests that cannot run in the basic Python job. Python results +are included in the combined PR report. + +The PR report shows the reconciled result as **Unverified**. Zero means every test +has execution evidence; individual OS logs still show tests that require another +OS as skipped. Failed executions remain failures even if another job passes. + +`npm run test:benchmarks` discovers all tests gated by `GITNEXUS_BENCH` and runs +them serially. Keep timing measurements out of parallel coverage workers. The +`eval-tests` job installs the locked Python dependencies and runs the workflow +preflight Vitest tests as well as pytest; those tests must exercise real Python +validation, never a stubbed success. + ## Regression testing Re-run the full relevant suite when: diff --git a/eval/check_test_execution.py b/eval/check_test_execution.py new file mode 100644 index 000000000..1d5e0f71c --- /dev/null +++ b/eval/check_test_execution.py @@ -0,0 +1,121 @@ +"""Require a real passing execution of every pytest case across the CI jobs.""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys +import xml.etree.ElementTree as ET + + +RECEIPTS = ("pytest-locked.xml", "pytest-ubuntu.xml", "pytest-windows.xml") + + +def reconcile(reports: list[Path], expected_files: list[str]) -> dict: + if not reports: + raise ValueError("No pytest execution reports supplied") + tests: dict[tuple[str, str], set[str]] = {} + durations: list[float] = [] + for report in reports: + root = ET.parse(report).getroot() + suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite")) + if root.tag not in {"testsuite", "testsuites"} or not suites: + raise ValueError(f"Invalid pytest report: {report}") + seen: set[tuple[str, str]] = set() + duration = 0.0 + for suite in suites: + cases = suite.findall("testcase") + if not cases: + raise ValueError(f"Empty pytest suite in {report}") + counts = {"tests": len(cases), "failures": 0, "errors": 0, "skipped": 0} + for case in cases: + key = (case.get("classname", ""), case.get("name", "")) + if not all(key) or key in seen: + raise ValueError(f"Missing or ambiguous pytest identity in {report}: {key}") + seen.add(key) + for status in ("failures", "errors", "skipped"): + tag = {"failures": "failure", "errors": "error", "skipped": "skipped"}[status] + counts[status] += len(case.findall(tag)) + status = ( + "failed" + if case.find("failure") is not None or case.find("error") is not None + else "pending" + if case.find("skipped") is not None + else "passed" + ) + tests.setdefault(key, set()).add(status) + for name, count in counts.items(): + if int(suite.get(name, "-1")) != count: + raise ValueError(f"Inconsistent pytest {name} count in {report}") + duration += float(suite.get("time", "0")) + durations.append(duration) + + modules = {module for module, _ in tests} + for file in expected_files: + module = file.removesuffix(".py").replace("/", ".") + if not any(name == module or name.startswith(module + ".") for name in modules): + raise ValueError(f"Pytest file was never collected: {file}") + + suites_by_module: dict[str, dict] = {} + failed: list[str] = [] + pending = 0 + for (module, name), statuses in sorted(tests.items()): + status = "failed" if "failed" in statuses else "passed" if "passed" in statuses else "pending" + full_name = f"{module}::{name}" + if status == "failed": + failed.append(full_name) + pending += status == "pending" + suite = suites_by_module.setdefault( + module, + { + "name": module, + "status": "passed", + "assertionResults": [], + "endTime": round(max(durations) * 1000), + }, + ) + suite["assertionResults"].append( + { + "fullName": full_name, + "ancestorTitles": [module], + "title": name, + "status": status, + } + ) + if status == "failed": + suite["status"] = "failed" + return { + "success": not failed and pending == 0, + "numTotalTests": len(tests), + "numPassedTests": len(tests) - len(failed) - pending, + "numFailedTests": len(failed), + "numPendingTests": pending, + "numTotalTestSuites": len(suites_by_module), + "numFailedTestSuites": sum(s["status"] == "failed" for s in suites_by_module.values()), + "executionFailures": failed, + "startTime": 0, + "testResults": list(suites_by_module.values()), + } + + +def main() -> int: + if len(sys.argv) != 3: + raise ValueError("Usage: check_test_execution.py ") + directory, output = map(Path, sys.argv[1:]) + root = Path(__file__).resolve().parent + expected = [p.relative_to(root).as_posix() for p in (root / "tests").rglob("test_*.py")] + report = reconcile([directory / name for name in RECEIPTS], expected) + output.write_text(json.dumps(report), encoding="utf-8") + print( + f"Eval execution: {report['numPassedTests']}/{report['numTotalTests']} passed; " + f"{report['numPendingTests']} unverified; {report['numFailedTests']} failures" + ) + for suite in report["testResults"]: + for case in suite["assertionResults"]: + if case["status"] != "passed": + print(f"{case['status']}: {case['fullName']}", file=sys.stderr) + return 0 if report["success"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/eval/tests/test_check_test_execution.py b/eval/tests/test_check_test_execution.py new file mode 100644 index 000000000..22950081f --- /dev/null +++ b/eval/tests/test_check_test_execution.py @@ -0,0 +1,142 @@ +"""The execution gate must reject absent, skipped, failed, or ambiguous evidence.""" + +from pathlib import Path +import json +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET + +import pytest + +from check_test_execution import reconcile + + +def receipt(tmp_path: Path, filename: str, cases: list[tuple[str, str]], module="tests.test_native") -> Path: + suite = ET.Element("testsuite", tests=str(len(cases)), failures="0", errors="0", skipped="0", time="1.5") + for name, status in cases: + case = ET.SubElement(suite, "testcase", classname=module, name=name) + if status != "passed": + ET.SubElement(case, status) + count = {"failure": "failures", "error": "errors", "skipped": "skipped"}[status] + suite.set(count, str(int(suite.get(count)) + 1)) + path = tmp_path / filename + ET.ElementTree(suite).write(path, encoding="utf-8") + return path + + +def test_platform_skip_requires_a_matching_real_pass(tmp_path): + linux = receipt(tmp_path, "linux.xml", [("test_windows", "skipped"), ("test_posix", "passed")]) + windows = receipt(tmp_path, "windows.xml", [("test_windows", "passed"), ("test_posix", "skipped")]) + report = reconcile([linux, windows], ["tests/test_native.py"]) + assert report["success"] is True + assert report["numPassedTests"] == report["numTotalTests"] == 2 + assert report["numPendingTests"] == report["numFailedTests"] == 0 + + +def test_unresolved_skip_is_not_a_pass(tmp_path): + path = receipt(tmp_path, "report.xml", [("test_missing_runtime", "skipped")]) + report = reconcile([path], []) + assert report["success"] is False + assert report["numPendingTests"] == 1 + assert report["numPassedTests"] == 0 + assert report["testResults"][0]["assertionResults"][0]["ancestorTitles"] == ["tests.test_native"] + + +@pytest.mark.parametrize("status", ["failure", "error"]) +def test_failure_is_not_hidden_by_another_job_passing(tmp_path, status): + failed = receipt(tmp_path, "failed.xml", [("test_shared", status)]) + passed = receipt(tmp_path, "passed.xml", [("test_shared", "passed")]) + report = reconcile([failed, passed], []) + assert report["success"] is False + assert report["numFailedTests"] == 1 + assert report["numPassedTests"] == 0 + assert report["executionFailures"] == ["tests.test_native::test_shared"] + + +def test_same_title_in_another_file_does_not_resolve_a_skip(tmp_path): + pending = receipt(tmp_path, "pending.xml", [("test_same", "skipped")]) + unrelated = receipt(tmp_path, "unrelated.xml", [("test_same", "passed")], module="tests.test_other") + report = reconcile([pending, unrelated], []) + assert report["numTotalTests"] == 2 + assert report["numPendingTests"] == 1 + + +def test_duplicate_identity_is_rejected(tmp_path): + path = receipt(tmp_path, "duplicate.xml", [("test_same", "passed"), ("test_same", "skipped")]) + with pytest.raises(ValueError, match="ambiguous pytest identity"): + reconcile([path], []) + + +def test_missing_receipt_is_rejected(tmp_path): + with pytest.raises(FileNotFoundError): + reconcile([tmp_path / "missing.xml"], []) + + +def test_empty_receipt_is_rejected(tmp_path): + path = receipt(tmp_path, "empty.xml", []) + with pytest.raises(ValueError, match="Empty pytest suite"): + reconcile([path], []) + + +def test_missing_file_is_rejected(tmp_path): + path = receipt(tmp_path, "report.xml", [("test_present", "passed")]) + with pytest.raises(ValueError, match="never collected: tests/test_missing.py"): + reconcile([path], ["tests/test_missing.py"]) + + +def test_inconsistent_counts_are_rejected(tmp_path): + path = receipt(tmp_path, "report.xml", [("test_present", "passed")]) + path.write_text(path.read_text().replace('tests="1"', 'tests="2"')) + with pytest.raises(ValueError, match="Inconsistent pytest tests count"): + reconcile([path], []) + + +def test_pytest_testsuites_wrapper_and_test_classes(tmp_path): + path = receipt(tmp_path, "report.xml", [("test_method", "passed")], module="tests.test_native.TestNative") + suite = ET.parse(path).getroot() + root = ET.Element("testsuites") + root.append(suite) + ET.ElementTree(root).write(path, encoding="utf-8") + assert reconcile([path], ["tests/test_native.py"])["success"] is True + + +def test_missing_report_list_is_rejected(): + with pytest.raises(ValueError, match="No pytest execution reports"): + reconcile([], []) + + +@pytest.mark.parametrize("scenario", ["passed", "pending", "failed", "missing-receipt", "missing-file"]) +def test_cli_requires_complete_execution_evidence(tmp_path, scenario): + script = tmp_path / "check_test_execution.py" + shutil.copyfile(Path(__file__).parents[1] / script.name, script) + tests = tmp_path / "tests" + tests.mkdir() + (tests / "test_native.py").write_text("# inventory fixture\n") + reports = tmp_path / "reports" + reports.mkdir() + receipt(reports, "pytest-locked.xml", [("test_native", "skipped" if scenario == "pending" else "passed")]) + receipt(reports, "pytest-ubuntu.xml", [("test_native", "error" if scenario == "failed" else "skipped")]) + if scenario != "missing-receipt": + receipt(reports, "pytest-windows.xml", [("test_native", "skipped")]) + if scenario == "missing-file": + (tests / "test_missing.py").write_text("# must be collected\n") + output = tmp_path / "pytest-results.json" + result = subprocess.run( + [sys.executable, str(script), str(reports), str(output)], + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == (0 if scenario == "passed" else 1) + if scenario.startswith("missing-"): + assert not output.exists() + assert ("pytest-windows.xml" if scenario == "missing-receipt" else "never collected") in result.stderr + else: + report = json.loads(output.read_text()) + assert report["success"] is (scenario == "passed") + assert report["numTotalTests"] == 1 + assert report["numPassedTests"] == (scenario == "passed") + assert report["numPendingTests"] == (scenario == "pending") + assert report["numFailedTests"] == (scenario == "failed") + assert "Eval execution:" in result.stdout diff --git a/eval/tests/test_evolve.py b/eval/tests/test_evolve.py index 32d50fd85..9267a6c6b 100644 --- a/eval/tests/test_evolve.py +++ b/eval/tests/test_evolve.py @@ -5,6 +5,7 @@ import json import os import subprocess import sys +import threading import time from contextlib import contextmanager from datetime import UTC, datetime, timedelta @@ -1428,21 +1429,32 @@ def test_outer_runner_pid_namespace_kills_setsid_descendant(tmp_path): pytest.skip(str(exc)) raise AssertionError("pytest.skip() returned unexpectedly") sentinel = tmp_path / "escaped" + ready = tmp_path / "descendant-ready" + cancel = threading.Event() + descendant = ( + "import time,pathlib; " + f"pathlib.Path({str(ready)!r}).touch(); print('ready', flush=True); " + f"time.sleep(1); pathlib.Path({str(sentinel)!r}).touch()" + ) child = ( "import os,subprocess,sys,time; " - f"subprocess.Popen([sys.executable,'-c',\"import time,pathlib;time.sleep(1);pathlib.Path({str(sentinel)!r}).touch()\"],preexec_fn=os.setsid); " + f"subprocess.Popen([sys.executable,'-c',{descendant!r}],preexec_fn=os.setsid); " "time.sleep(10)" ) result = run_managed( pid_namespace_command([sys.executable, "-c", child], bwrap_bin=bwrap), - timeout=0.15, + # Cancel only after the escaped-session descendant has actually started. + # A timeout during bwrap/Python startup must not count as containment. + timeout=10, terminate_grace=0.1, require_pid_namespace=True, + cancel_event=cancel, + stdout_observer=lambda _chunk: cancel.set(), ) - time.sleep(1.1) - + assert ready.exists(), "the setsid descendant must start before it can be contained" assert not result.ok - assert result.state in {"timeout", "forced-kill"} + assert result.state == "cancelled" + time.sleep(1.1) assert not sentinel.exists() diff --git a/eval/tests/test_workflow_bench.py b/eval/tests/test_workflow_bench.py index 4b197d9dc..4a03ed243 100644 --- a/eval/tests/test_workflow_bench.py +++ b/eval/tests/test_workflow_bench.py @@ -250,7 +250,11 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs(): # Carries the real-CLI identity probe, which needs CLAUDE_CANARY_BIN - # set only on this job. Omitted from this list it skipped everywhere. "tests/test_mock_provider.py", + # These also need runtime dependencies absent from the locked pytest job. + "tests/test_evolve.py::test_outer_runner_pid_namespace_kills_setsid_descendant", + "tests/test_oracle_assets.py::test_hidden_vitest_config_executes_sibling_oracle_against_candidate_checkout", "-q", + "--junitxml=pytest-ubuntu.xml", ] bwrap_canary_marker = re.compile( r'@pytest\.mark\.skipif\(\s*os\.environ\.get\("GITNEXUS_REQUIRE_BWRAP_CANARY"\)', @@ -266,6 +270,72 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs(): assert "eval-containment-windows:" in workflow +@pytest.fixture(scope="module") +def ci_test_jobs(): + repo_root = Path(__file__).resolve().parents[2] + workflow = yaml.safe_load((repo_root / ".github" / "workflows" / "ci-tests.yml").read_text()) + return workflow["jobs"] + + +def test_platform_ci_preserves_required_check_names_with_complete_execution_gates(ci_test_jobs): + jobs = ci_test_jobs + gate = jobs["protected-platform-checks"] + matrix = gate["strategy"]["matrix"] + names = { + "tests / " + gate["name"].replace("${{ matrix.os }}", platform).replace("${{ matrix.check }}", str(check)) + for platform in matrix["os"] + for check in matrix["check"] + } + assert names == { + f"tests / {platform} (platform-sensitive) {check}/3" + for platform in ("windows-latest", "macos-latest") + for check in (1, 2, 3) + } + plan = jobs["shard-plan"]["steps"][0]["run"] + total = int(re.search(r"^TOTAL=(\d+)\b", plan, re.MULTILINE)[1]) + native_names = { + "tests / " + + jobs["cross-platform"]["name"] + .replace("${{ matrix.os }}", platform) + .replace("${{ matrix.shard }}", str(shard)) + .replace("${{ needs.shard-plan.outputs.total }}", str(total)) + for platform in jobs["cross-platform"]["strategy"]["matrix"]["os"] + for shard in range(1, total + 1) + } + assert names.isdisjoint(native_names) + assert set(gate["needs"]) == {"cross-platform", "test-completeness"} + assert gate["if"] == "always()" + assert not gate.get("continue-on-error", False) + assert not jobs["cross-platform"].get("continue-on-error", False) + assert not jobs["test-completeness"].get("continue-on-error", False) + assert gate["permissions"] == {} + assert len(gate["steps"]) == 1 + step = gate["steps"][0] + assert not step.get("continue-on-error", False) + assert "if" not in step + assert step["shell"] == "bash" + assert step["env"] == { + "NATIVE_RESULT": "${{ needs.cross-platform.result }}", + "EXECUTION_RESULT": "${{ needs.test-completeness.result }}", + } + + +@pytest.mark.parametrize("native_result", ["success", "failure", "cancelled", "skipped", ""]) +@pytest.mark.parametrize("execution_result", ["success", "failure", "cancelled", "skipped", ""]) +def test_required_platform_check_fails_unless_every_dependency_passed(ci_test_jobs, native_result, execution_result): + step = ci_test_jobs["protected-platform-checks"]["steps"][0] + result = subprocess.run( + ["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", step["run"]], + env={**os.environ, "NATIVE_RESULT": native_result, "EXECUTION_RESULT": execution_result}, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + expected = 0 if native_result == execution_result == "success" else 1 + assert result.returncode == expected, result.stdout + result.stderr + + def test_shipped_scenarios_opt_out_the_cross_module_cell_and_rebuild_graph_assets(): task_file = Path(__file__).resolve().parents[1] / "workflow_bench" / "tasks.scenarios.yaml" tasks = yaml.safe_load(task_file.read_text())["tasks"] @@ -1092,4 +1162,3 @@ def test_an_uninvoked_skill_still_counts_toward_the_arm_median(): # The invariant that makes the half-fix unsafe: the median and the run count # the gate reads must cover the same rows. assert agg["valid_runs"] == 2 - diff --git a/gitnexus/package.json b/gitnexus/package.json index 99422e958..afb4b5f37 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -59,6 +59,7 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:cross-platform": "tsx scripts/run-cross-platform.ts", + "test:benchmarks": "node --import tsx scripts/run-benchmarks.ts", "postinstall": "node scripts/build-tree-sitter-grammars.cjs", "assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs", "prepare": "node scripts/build.js", diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 3fdb26ee0..55bade8f0 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -206,9 +206,8 @@ const LBUG_NATIVE = [ // (the same reason fts-extension-e2e.test.ts is registered below), so the // FTS-unavailable branch has to run on a real Windows/macOS runner rather // than only on Ubuntu where FTS always loads. And its both-blocked case is - // gated on GITNEXUS_REQUIRE_VECTOR=1, which ci-tests.yml sets ONLY on this - // job — everywhere else an unavailable VECTOR extension skips instead of - // failing. Budget: four real analyze runs, so expect it to sit alongside the + // gated on GITNEXUS_REQUIRE_VECTOR=1, which ci-tests.yml requires in both + // coverage and platform jobs so an unavailable VECTOR fails loudly. Budget: four real analyze runs, so expect it to sit alongside the // VECTOR sibling's ~87s Windows measurement. 'test/unit/incremental-index-extension-dml-gate.test.ts', ]; @@ -308,6 +307,10 @@ const NATIVE_ADDON_SMOKE = [ // Filesystem behavior tests — exercise operations that vary across // platforms (CRLF, symlinks, permissions, temp dirs) const FILESYSTEM = [ + // The deletion-guard cases in this file require real Windows path semantics. + 'test/unit/canonicalize-path-long-path-prefix.test.ts', + 'test/unit/storage-resolver.test.ts', + 'test/unit/dart-package-imports.test.ts', // Cargo membership uses path normalization, descriptor validation, symlinks, // and Rust native parsing (including long Windows source strings). 'test/unit/scope-resolution/rust-cargo-targets.test.ts', diff --git a/gitnexus/scripts/execution-reporter.ts b/gitnexus/scripts/execution-reporter.ts new file mode 100644 index 000000000..83a93a035 --- /dev/null +++ b/gitnexus/scripts/execution-reporter.ts @@ -0,0 +1,33 @@ +import { JsonReporter, type TestRunEndReason } from 'vitest/node'; + +/** Vitest's stock JSON omits unhandled errors, even when success is true. */ +export default class ExecutionReporter extends JsonReporter { + private executionErrors = 0; + private executionReason: TestRunEndReason = 'interrupted'; + + constructor() { + super({}); + } + + override async onTestRunEnd( + modules: Parameters[0], + errors: readonly unknown[] = [], + reason: TestRunEndReason = 'interrupted', + ): Promise { + this.executionErrors = errors.length; + this.executionReason = reason; + await super.onTestRunEnd(modules); + } + + override async writeReport(json: string): Promise { + const report = JSON.parse(json); + await super.writeReport( + JSON.stringify({ + ...report, + success: report.success && this.executionErrors === 0 && this.executionReason === 'passed', + executionErrors: this.executionErrors, + executionReason: this.executionReason, + }), + ); + } +} diff --git a/gitnexus/scripts/run-benchmarks.ts b/gitnexus/scripts/run-benchmarks.ts new file mode 100644 index 000000000..e2a58d5a3 --- /dev/null +++ b/gitnexus/scripts/run-benchmarks.ts @@ -0,0 +1,32 @@ +import { execFileSync } from 'node:child_process'; +import { globSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +// Discover the opt-in suites from source so a new benchmark cannot silently +// miss a hand-maintained workflow list. Keep wall-clock measurements serial. +const files = globSync('test/**/*.test.ts', { cwd: root }) + .filter((file) => + /process\.env(?:\.GITNEXUS_BENCH|\[['"]GITNEXUS_BENCH['"]\])/.test( + readFileSync(new URL(`../${file.replaceAll('\\', '/')}`, import.meta.url), 'utf8'), + ), + ) + .sort(); +if (!files.length) throw new Error('No benchmark test suites discovered'); +execFileSync( + process.execPath, + [ + fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)), + 'run', + '--no-file-parallelism', + ...files, + '--reporter=default', + '--reporter=./scripts/execution-reporter.ts', + '--outputFile=benchmarks.json', + ], + { + cwd: root, + stdio: 'inherit', + env: { ...process.env, GITNEXUS_BENCH: '1' }, + }, +); diff --git a/gitnexus/scripts/run-cross-platform.ts b/gitnexus/scripts/run-cross-platform.ts index 5404a87c3..630fd0b93 100644 --- a/gitnexus/scripts/run-cross-platform.ts +++ b/gitnexus/scripts/run-cross-platform.ts @@ -80,8 +80,19 @@ console.log( ); const startedAt = Date.now(); +const reportName = process.env.GITNEXUS_TEST_REPORT; +if (reportName && !/^[a-zA-Z0-9_-]+\.json$/.test(reportName)) { + throw new Error('GITNEXUS_TEST_REPORT must be a JSON basename'); +} +const reportArgs = reportName + ? [ + '--reporter=default', + '--reporter=./scripts/execution-reporter.ts', + `--outputFile=${reportName}`, + ] + : []; try { - execFileSync('npx', ['vitest', 'run', ...files], { + execFileSync('npx', ['vitest', 'run', ...files, ...reportArgs], { cwd: ROOT, stdio: 'inherit', timeout: timeoutMs, diff --git a/gitnexus/scripts/test-completeness.ts b/gitnexus/scripts/test-completeness.ts new file mode 100644 index 000000000..89ca86892 --- /dev/null +++ b/gitnexus/scripts/test-completeness.ts @@ -0,0 +1,203 @@ +import { globSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { TestRunEndReason } from 'vitest/node'; + +type Assertion = { + ancestorTitles: string[]; + title: string; + fullName: string; + status: string; + location?: { line: number; column: number }; +}; +type Suite = { + name: string; + status: string; + assertionResults: Assertion[]; + endTime?: number; + message?: string; +}; +type Report = { + success?: boolean; + numTotalTests?: number; + startTime?: number; + executionErrors: number; + executionReason: TestRunEndReason; + testResults: Suite[]; +}; + +/** Stable across GitHub's Linux, macOS and Windows checkout roots. */ +function testPath(name: string, packageName: string): string { + const normalized = name.replaceAll('\\', '/'); + const relative = + normalized.match(new RegExp(`(?:^|/)${packageName}/((?:test|src)/.+)$`))?.[1] ?? normalized; + if (!/^(?:test|src)\/.+\.test\.[jt]sx?$/.test(relative)) { + throw new Error(`Invalid test report path: ${name}`); + } + return relative; +} + +/** A skip is resolved only by a recorded pass of that same test in another job. */ +export function reconcileTestReports( + inputs: unknown[], + expectedFiles: string[] = [], + packageName = 'gitnexus', +) { + if (inputs.length === 0) throw new Error('No execution reports supplied'); + const tests = new Map< + string, + { file: string; assertion: Assertion; passed: boolean; failed: boolean } + >(); + const failures = new Set(); + const failedSuites = new Set(); + const startTimes: number[] = []; + const endTimes: number[] = []; + for (const [index, input] of inputs.entries()) { + const report = input as Report; + if (!report || !Array.isArray(report.testResults) || report.testResults.length === 0) { + throw new Error(`Missing or empty execution report ${index}`); + } + if (report.success === false) failures.add(`Report ${index}: unsuccessful runner result`); + if ( + !Number.isInteger(report.executionErrors) || + report.executionErrors < 0 || + !['passed', 'failed', 'interrupted'].includes(report.executionReason) + ) { + throw new Error(`Report ${index}: missing execution reporter evidence`); + } + if (report.executionErrors || report.executionReason !== 'passed') { + failures.add( + `Report ${index}: ${report.executionErrors} unhandled errors; ${report.executionReason}`, + ); + } + if (typeof report.startTime === 'number') startTimes.push(report.startTime); + let count = 0; + const seen = new Set(); + for (const suite of report.testResults) { + const file = testPath(suite.name, packageName); + if (!Array.isArray(suite.assertionResults) || suite.assertionResults.length === 0) { + throw new Error(`No collected tests in ${file}`); + } + if (suite.status === 'failed') { + failures.add(`${file}: failed suite`); + failedSuites.add(file); + } + if (typeof suite.endTime === 'number') endTimes.push(suite.endTime); + for (const assertion of suite.assertionResults) { + if ( + !['passed', 'failed', 'pending', 'skipped', 'todo', 'disabled'].includes( + assertion.status, + ) || + !Array.isArray(assertion.ancestorTitles) || + typeof assertion.title !== 'string' || + (assertion.location !== undefined && + (!Number.isInteger(assertion.location.line) || + !Number.isInteger(assertion.location.column))) + ) { + throw new Error(`Malformed assertion in ${file}`); + } + // Collection order differs across platforms. Source location, not an + // ordinal, distinguishes equal titles declared at different locations. + // Helper-generated tests may have no location; their complete title + // must then be unique within the file (the duplicate check still applies). + const title = JSON.stringify([...assertion.ancestorTitles, assertion.title]); + const key = JSON.stringify([ + file, + title, + assertion.location?.line ?? null, + assertion.location?.column ?? null, + ]); + if (seen.has(key)) + throw new Error( + `Ambiguous test identity in ${file}: ${title}; give parameterized cases unique titles`, + ); + seen.add(key); + const result = tests.get(key) ?? { file, assertion, passed: false, failed: false }; + result.passed ||= assertion.status === 'passed'; + result.failed ||= assertion.status === 'failed'; + tests.set(key, result); + if (result.failed) failures.add(`${file}: ${assertion.fullName ?? title}`); + count++; + } + } + if (typeof report.numTotalTests === 'number' && report.numTotalTests !== count) { + throw new Error(`Report ${index}: expected ${report.numTotalTests} tests, found ${count}`); + } + } + const unverified = [...tests.values()] + .filter((test) => !test.passed && !test.failed) + .map((test) => `${test.file}: ${test.assertion.fullName}`); + const suites = new Map(); + for (const test of tests.values()) { + const status = test.failed ? 'failed' : test.passed ? 'passed' : 'pending'; + const suite = suites.get(test.file) ?? { + name: test.file, + status: 'passed', + assertionResults: [], + }; + suite.assertionResults.push({ ...test.assertion, status }); + if (status === 'failed' || failedSuites.has(test.file)) suite.status = 'failed'; + suites.set(test.file, suite); + } + for (const file of expectedFiles) { + if (!suites.has(file.replaceAll('\\', '/'))) + throw new Error(`Test file was never collected: ${file}`); + } + const passed = [...tests.values()].filter((test) => test.passed && !test.failed).length; + const endTime = endTimes.length ? Math.max(...endTimes) : 0; + return { + total: tests.size, + passed, + unverified, + failures: [...failures], + report: { + success: failures.size === 0 && unverified.length === 0, + numTotalTests: tests.size, + numPassedTests: passed, + numFailedTests: [...tests.values()].filter((test) => test.failed).length, + numPendingTests: unverified.length, + numTotalTestSuites: suites.size, + numFailedTestSuites: failedSuites.size, + executionFailures: [...failures], + startTime: startTimes.length ? Math.min(...startTimes) : 0, + testResults: [...suites.values()].map((suite) => ({ ...suite, endTime })), + }, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const [directory, output, shardsText] = process.argv.slice(2); + const shards = Number(shardsText); + if (!directory || !output || !Number.isInteger(shards) || shards < 1) { + throw new Error('Usage: test-completeness.ts '); + } + const names = ['coverage.json', 'benchmarks.json', 'preflight.json']; + for (const os of ['windows-latest', 'macos-latest']) { + for (let shard = 1; shard <= shards; shard++) names.push(`${os}-${shard}.json`); + } + // Exact expected receipts: an absent/cancelled job is never an empty success. + const root = fileURLToPath(new URL('../', import.meta.url)); + const expected = globSync('test/**/*.test.ts', { cwd: root }); + const result = reconcileTestReports( + names.map((name) => JSON.parse(readFileSync(path.join(directory, name), 'utf8'))), + expected, + ); + writeFileSync(output, JSON.stringify(result.report)); + console.log( + `Test execution: ${result.passed}/${result.total} passed; ${result.unverified.length} unverified; ${result.failures.length} failures`, + ); + for (const message of [...result.unverified, ...result.failures]) console.error(message); + if (!result.report.success) process.exitCode = 1; + const webPath = path.join(root, '../gitnexus-web/web-test-results.json'); + const web = reconcileTestReports( + [JSON.parse(readFileSync(webPath, 'utf8'))], + globSync('{test,src}/**/*.test.{ts,tsx}', { cwd: path.join(root, '../gitnexus-web') }), + 'gitnexus-web', + ); + writeFileSync(webPath, JSON.stringify(web.report)); + console.log( + `Web execution: ${web.passed}/${web.total} passed; ${web.unverified.length} unverified; ${web.failures.length} failures`, + ); + for (const message of [...web.unverified, ...web.failures]) console.error(message); + if (!web.report.success) process.exitCode = 1; +} diff --git a/gitnexus/src/core/ingestion/languages/dart/package-config.ts b/gitnexus/src/core/ingestion/languages/dart/package-config.ts index 4503a57bd..2040d6d94 100644 --- a/gitnexus/src/core/ingestion/languages/dart/package-config.ts +++ b/gitnexus/src/core/ingestion/languages/dart/package-config.ts @@ -39,6 +39,11 @@ export interface DartPackageConfigOptions { * is listed and before its entries are opened. */ readonly beforeEntryOpen?: (relativePath: string) => void | Promise; + /** + * Test seam. Production calls omit it. Invoked after the directory is + * opened and before it is listed. + */ + readonly beforeDirectoryList?: (relativePath: string) => void | Promise; } type ManifestRead = @@ -123,12 +128,13 @@ function directoryIdentity(stat: BigIntStats): string { /** * Path that lists the directory inode already open on `fd`. - * Linux uses `/proc/self/fd/N` and macOS uses `/dev/fd/N`. - * Other platforms have no such path; callers refuse instead of listing by name. + * Only Linux has one: `/proc/self/fd/N`. macOS refuses `opendir` on + * `/dev/fd/N` for a directory (ENOTDIR) and Node has no `fdopendir`, so the + * walker lists the lexical path and verifies the pinned chain around it. + * Every other platform returns null and is not walked. */ export function descriptorDirectoryPath(fd: number): string | null { if (process.platform === 'linux') return `/proc/self/fd/${fd}`; - if (process.platform === 'darwin') return `/dev/fd/${fd}`; return null; } @@ -264,11 +270,33 @@ async function openChildFile( throw Object.assign(new Error('no descriptor anchor'), { code: 'ENOTSUP' }); } -async function listOpenedDirectory(handle: FileHandle, entryLimit: number): Promise { - const listing = descriptorDirectoryPath(handle.fd); - if (listing === null) { +/** + * List the directory open on the last frame of `frames`. Linux lists the + * pinned inode through its descriptor. macOS re-checks the pinned chain, lists + * the lexical path, then re-checks the chain, so a path replaced around the + * listing fails the walk instead of being listed. + */ +async function listOpenedDirectory( + frames: readonly WalkFrame[], + entryLimit: number, + beforeList?: () => void | Promise, +): Promise { + const frame = frames[frames.length - 1]; + if (frame === undefined) { + throw Object.assign(new Error('no directory to list'), { code: 'EINVAL' }); + } + const anchored = descriptorDirectoryPath(frame.handle.fd); + if (anchored === null && process.platform !== 'darwin') { throw Object.assign(new Error('no descriptor listing'), { code: 'ENOTSUP' }); } + if (anchored === null) await assertPinnedChain(frames); + if (beforeList) await beforeList(); + const entries = await readDirectoryBounded(anchored ?? frame.absolute, entryLimit); + if (anchored === null) await assertPinnedChain(frames); + return entries; +} + +async function readDirectoryBounded(listing: string, entryLimit: number): Promise { const dir = await opendir(listing); const entries: Dirent[] = []; try { @@ -295,8 +323,16 @@ async function listOpenedDirectory(handle: FileHandle, entryLimit: number): Prom */ export async function readDirectoryNoFollow(directory: string): Promise { const opened = await openVerifiedDirectory(directory); + const frame: WalkFrame = { + relative: '', + absolute: directory, + handle: opened.handle, + identity: opened.identity, + entries: [], + next: 0, + }; try { - return await listOpenedDirectory(opened.handle, DART_PUBSPEC_DIRECTORY_ENTRY_LIMIT); + return await listOpenedDirectory([frame], DART_PUBSPEC_DIRECTORY_ENTRY_LIMIT); } finally { await opened.handle.close(); } @@ -345,7 +381,12 @@ export async function loadDartPackageConfig( const fillFrame = async (frame: WalkFrame): Promise => { if (++visited > directoryLimit) return incomplete('directory-limit', frame.relative || '.'); try { - frame.entries = await listOpenedDirectory(frame.handle, directoryEntryLimit); + const beforeList = options?.beforeDirectoryList; + frame.entries = await listOpenedDirectory( + stack, + directoryEntryLimit, + beforeList ? () => beforeList(frame.relative) : undefined, + ); } catch (error) { const reason = (error as NodeJS.ErrnoException).code === 'E2BIG' ? 'directory-entries' : 'read-directory'; diff --git a/gitnexus/test/integration/go-pipeline-benchmark.test.ts b/gitnexus/test/integration/go-pipeline-benchmark.test.ts index 095c1f330..04af7af9b 100644 --- a/gitnexus/test/integration/go-pipeline-benchmark.test.ts +++ b/gitnexus/test/integration/go-pipeline-benchmark.test.ts @@ -473,13 +473,10 @@ describe('Go scope-capture O(n^2) regression tripwire', () => { */ function generateSyntheticInterfaceData(interfaceCount: number, structCount: number): ParsedFile[] { const defs: SymbolDefinition[] = []; - const ifaceIds: string[] = []; - const structIds: string[] = []; // Interfaces for (let i = 0; i < interfaceCount; i++) { const ifaceId = `iface:Repo${i}`; - ifaceIds.push(ifaceId); defs.push({ nodeId: ifaceId, filePath: 'repo.go', @@ -515,37 +512,34 @@ function generateSyntheticInterfaceData(interfaceCount: number, structCount: num // Structs — each implements all interfaces for (let s = 0; s < structCount; s++) { const structId = `struct:Impl${s}`; - structIds.push(structId); defs.push({ nodeId: structId, filePath: 'repo.go', type: 'Struct', qualifiedName: `Impl${s}`, }); - for (let i = 0; i < interfaceCount; i++) { - defs.push({ - nodeId: `struct:Impl${s}.Repo${i}.Find`, - filePath: 'repo.go', - type: 'Method', - qualifiedName: `Impl${s}.Find`, - ownerId: structId, - parameterCount: 1, - requiredParameterCount: 1, - parameterTypes: ['string'], - returnType: 'User', - }); - defs.push({ - nodeId: `struct:Impl${s}.Repo${i}.Save`, - filePath: 'repo.go', - type: 'Method', - qualifiedName: `Impl${s}.Save`, - ownerId: structId, - parameterCount: 1, - requiredParameterCount: 1, - parameterTypes: ['User'], - returnType: 'error', - }); - } + defs.push({ + nodeId: `struct:Impl${s}.Find`, + filePath: 'repo.go', + type: 'Method', + qualifiedName: `Impl${s}.Find`, + ownerId: structId, + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + returnType: 'User', + }); + defs.push({ + nodeId: `struct:Impl${s}.Save`, + filePath: 'repo.go', + type: 'Method', + qualifiedName: `Impl${s}.Save`, + ownerId: structId, + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['User'], + returnType: 'error', + }); } // BadStructs — wrong Save signature (string instead of User), should NOT match @@ -557,31 +551,29 @@ function generateSyntheticInterfaceData(interfaceCount: number, structCount: num type: 'Struct', qualifiedName: `Bad${b}`, }); - for (let i = 0; i < interfaceCount; i++) { - defs.push({ - nodeId: `struct:Bad${b}.Repo${i}.Find`, - filePath: 'repo.go', - type: 'Method', - qualifiedName: `Bad${b}.Find`, - ownerId: badId, - parameterCount: 1, - requiredParameterCount: 1, - parameterTypes: ['string'], - returnType: 'User', - }); - // Mismatched Save: string param instead of User - defs.push({ - nodeId: `struct:Bad${b}.Repo${i}.Save`, - filePath: 'repo.go', - type: 'Method', - qualifiedName: `Bad${b}.Save`, - ownerId: badId, - parameterCount: 1, - requiredParameterCount: 1, - parameterTypes: ['string'], - returnType: 'error', - }); - } + defs.push({ + nodeId: `struct:Bad${b}.Find`, + filePath: 'repo.go', + type: 'Method', + qualifiedName: `Bad${b}.Find`, + ownerId: badId, + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + returnType: 'User', + }); + // Mismatched Save: string param instead of User + defs.push({ + nodeId: `struct:Bad${b}.Save`, + filePath: 'repo.go', + type: 'Method', + qualifiedName: `Bad${b}.Save`, + ownerId: badId, + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + returnType: 'error', + }); } return [ @@ -606,6 +598,15 @@ function generateSyntheticInterfaceData(interfaceCount: number, structCount: num * path (structIdsByMethodName intersection) keeps this well under budget. */ describe('Go structural interface detection O(n²) regression tripwire', () => { + it('uses legal Go method sets with one declaration per receiver and method name', () => { + const [parsed] = generateSyntheticInterfaceData(3, 3); + const methods = parsed.localDefs.filter((def) => def.type === 'Method'); + const identities = methods.map((def) => `${def.ownerId}:${def.qualifiedName}`); + expect(new Set(identities).size).toBe(methods.length); + // Three interfaces, three matching structs and three negative controls. + expect(methods).toHaveLength(18); + }); + it('detects implementations for 100 interfaces × 100 structs within budget', () => { const IFACE_COUNT = 100; const STRUCT_COUNT = 100; @@ -779,7 +780,7 @@ describe.skipIf(!BENCH_ENABLED)('Go structural interface detection split-phase b console.log('\nScaling analysis (total time ratio / pair-count ratio):'); // We can't split phases without exporting internals, but we can report // how total time scales relative to pair count. - // The detection loop is O(I×C×M×O_a) where O_a grows with I in this - // synthetic case, so pair-count ratio alone underestimates expected growth. + // Each receiver declares Find and Save once, as real Go requires. + // Required signatures stay constant while interface/struct pairs grow. }, 300_000); }); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index df5895120..aa4bc9d82 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -575,14 +575,17 @@ describe('LocalBackend.callTool', () => { ['impact', { name: 'validate', symbol: 'login', direction: 'upstream' }], ['impact', { target: 'validate', direction: 'upstream', maxDepth: 3, depth: 1 }], ['context', { name: 'validate', file_path: 'src/auth.ts', file: 'src/login.ts' }], - ])('rejects conflicting %s aliases before repository resolution', async (method, params) => { - const resolveSpy = vi.spyOn(backend, 'selectToolRepository'); + ])( + 'rejects conflicting %s aliases before repository resolution (case %#)', + async (method, params) => { + const resolveSpy = vi.spyOn(backend, 'selectToolRepository'); - const result = await backend.callTool(method, params); + const result = await backend.callTool(method, params); - expect(result.error).toMatch(/conflicting mcp parameters/i); - expect(resolveSpy).not.toHaveBeenCalled(); - }); + expect(result.error).toMatch(/conflicting mcp parameters/i); + expect(resolveSpy).not.toHaveBeenCalled(); + }, + ); it.each([ ['impact', { name: 42, direction: 'upstream' }], diff --git a/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts b/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts index 0e4730a00..9783d9553 100644 --- a/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts +++ b/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts @@ -3,7 +3,7 @@ * * This is the Linux-runnable guard for the `canonicalizePath` wiring. The * companion assertions in `repo-manager.test.ts` exercise the real - * `realpathSync.native` and are therefore `it.skipIf(win32)`, so they only run on + * `realpathSync.native` and are therefore Windows-only, so they only run on * the windows-latest matrix leg — leaving the Ubuntu gate with no coverage of the * behaviour at all. This file closes that hole by injecting the two platform * primitives and nothing else: @@ -26,8 +26,8 @@ * and the realpath case passes, which is exactly the asymmetry the fix targets: * the realpath branch never leaked, because libuv strips the prefix itself. * - * Deliberately NOT registered in `scripts/cross-platform-tests.ts`: it simulates - * Windows rather than needing it, so its home is the Ubuntu suite. + * Registered in `scripts/cross-platform-tests.ts` as well: the injected Windows + * path rules must agree with the native Windows run. */ import { describe, it, expect, vi } from 'vitest'; @@ -139,13 +139,10 @@ describe('canonicalizePath vs the `\\\\?\\` long-path prefix (#2667)', () => { }); }); -// The guard in front of `fs.rm(recursive)` in remove.ts / clean.ts. It compares -// `path.resolve` forms on both sides and deliberately does NOT canonicalize, so a -// prefixed entry stays self-consistent while a mixed-form entry fails closed. -// Pinned here because "complete the fix by stripping here too" is the tempting -// follow-up refactor, and it would widen what the recursive delete accepts. +// The storage resolver now compares canonical paths for repository-local slots. +// Equivalent prefix spellings name the same .gitnexus directory; normalization +// must still reject the repository itself, its parents, and unowned external paths. describe('assertSafeStoragePath vs the `\\\\?\\` prefix (#2667)', () => { - const itOnWindows = process.platform === 'win32' ? it : it.skip; const base: Omit = { name: 'repo', path: '\\\\?\\D:\\Projects\\repo', @@ -153,16 +150,29 @@ describe('assertSafeStoragePath vs the `\\\\?\\` prefix (#2667)', () => { lastCommit: 'deadbee', }; - itOnWindows('accepts an entry whose path and storagePath share the prefix', async () => { + it('accepts an entry whose path and storagePath share the prefix', async () => { await expect( assertSafeStoragePath({ ...base, storagePath: '\\\\?\\D:\\Projects\\repo\\.gitnexus' }), ).resolves.toBeUndefined(); }); - itOnWindows('rejects a mixed-form entry instead of deleting through it', async () => { + it('accepts mixed prefix spellings of the same repository-local slot', async () => { await expect( assertSafeStoragePath({ ...base, storagePath: 'D:\\Projects\\repo\\.gitnexus' }), - ).rejects.toThrow(); + ).resolves.toBeUndefined(); + }); + + it.each([ + 'D:\\Projects\\repo', + '\\\\?\\D:\\Projects\\repo', + 'D:\\Projects', + 'D:\\', + 'D:\\Projects\\other\\.gitnexus', + '\\\\?\\D:\\Projects\\other\\.gitnexus', + ])('rejects an unsafe deletion target despite prefix normalization: %s', async (storagePath) => { + await expect(assertSafeStoragePath({ ...base, storagePath })).rejects.toThrow( + /Refusing to remove storage path for safety/, + ); }); }); diff --git a/gitnexus/test/unit/cross-platform-shard.test.ts b/gitnexus/test/unit/cross-platform-shard.test.ts index 66ea6a430..81722c941 100644 --- a/gitnexus/test/unit/cross-platform-shard.test.ts +++ b/gitnexus/test/unit/cross-platform-shard.test.ts @@ -13,6 +13,7 @@ * reproduce the outage exactly. */ +import { readFileSync } from 'node:fs'; import { describe, it, expect } from 'vitest'; import { shardFiles, @@ -22,7 +23,12 @@ import { } from '../../scripts/cross-platform-shard.js'; import { ALL_CROSS_PLATFORM } from '../../scripts/cross-platform-tests.js'; -const SHARD_TOTAL = 3; +// Replay the actual CI partition, including shard-count changes as the suite grows. +const workflow = readFileSync( + new URL('../../../.github/workflows/ci-tests.yml', import.meta.url), + 'utf8', +); +const SHARD_TOTAL = Number(workflow.match(/^\s+TOTAL=(\d+)\b/m)?.[1]); /** Every shard of a split, as file lists. */ const allShards = (files: readonly string[], total: number): readonly (readonly string[])[] => @@ -54,7 +60,7 @@ describe('cross-platform shard partition', () => { 'test/unit/incremental-index-extension-dml-gate.test.ts', ].map((file) => shards.findIndex((files) => files.includes(file))); expect(heavyweightLocations).not.toContain(-1); - expect(new Set(heavyweightLocations).size).toBe(SHARD_TOTAL); + expect(new Set(heavyweightLocations).size).toBe(heavyweightLocations.length); expect( shards.filter( (s) => diff --git a/gitnexus/test/unit/dart-package-imports.test.ts b/gitnexus/test/unit/dart-package-imports.test.ts index 0e33bc463..c127838d1 100644 --- a/gitnexus/test/unit/dart-package-imports.test.ts +++ b/gitnexus/test/unit/dart-package-imports.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { constants } from 'node:fs'; import { mkdtemp, @@ -463,47 +463,52 @@ describe.skipIf(!pubspecWalkAnchored())('Dart pubspec package discovery', () => }, ); - it('does not follow a listed directory replaced by a symlink on macOS', async () => { - if (process.platform !== 'darwin') return; - const outside = await fixture({ - 'pubspec.yaml': 'name: foreign', - 'nested/pubspec.yaml': 'name: foreign', - }); - const root = await fixture({ - 'pkg/pubspec.yaml': 'name: data', - 'pkg/nested/pubspec.yaml': 'name: nested_data', - }); - const pkg = path.join(root, 'pkg'); - await expect( - loadDartPackageConfig(root, { - beforeEntryOpen: async (relative) => { - if (relative !== 'pkg') return; - await rename(pkg, path.join(root, 'pkg-moved')); - await symlink(outside, pkg, 'dir'); - }, - }), - ).rejects.toThrow(/Dart pubspec discovery failed \((read-directory|read-pubspec)\)/); - }); + it.skipIf(process.platform !== 'darwin')( + 'does not follow a listed directory replaced by a symlink on macOS', + async () => { + const outside = await fixture({ + 'pubspec.yaml': 'name: foreign', + 'nested/pubspec.yaml': 'name: foreign', + }); + const root = await fixture({ + 'pkg/pubspec.yaml': 'name: data', + 'pkg/nested/pubspec.yaml': 'name: nested_data', + }); + const pkg = path.join(root, 'pkg'); + await expect( + loadDartPackageConfig(root, { + beforeEntryOpen: async (relative) => { + if (relative !== 'pkg') return; + await rename(pkg, path.join(root, 'pkg-moved')); + await symlink(outside, pkg, 'dir'); + }, + }), + ).rejects.toThrow(/Dart pubspec discovery failed \((read-directory|read-pubspec)\)/); + }, + ); - it('reads listed manifests from the opened directory inode after that path is replaced', async () => { - if (descriptorEntryPath(0, 'pubspec.yaml') === null) return; - const outside = await fixture({ - 'pubspec.yaml': 'name: foreign', - 'nested/pubspec.yaml': 'name: foreign', - }); + // Linux lists the opened inode through /proc/self/fd/N. macOS cannot list a + // descriptor (opendir on /dev/fd/N is ENOTDIR), so it lists the path and + // re-checks the pinned chain afterwards; the swap must fail that check. + it('lists the opened directory, or refuses, when its path is replaced before listing', async () => { + const outside = await fixture({ 'pubspec.yaml': 'name: foreign' }); const root = await fixture({ 'pkg/pubspec.yaml': 'name: data', 'pkg/nested/pubspec.yaml': 'name: nested_data', }); const pkg = path.join(root, 'pkg'); - const loaded = await loadDartPackageConfig(root, { - beforeEntryOpen: async (relative) => { + const load = loadDartPackageConfig(root, { + beforeDirectoryList: async (relative) => { if (relative !== 'pkg') return; await rename(pkg, path.join(root, 'pkg-moved')); await symlink(outside, pkg, 'dir'); }, }); - expect(loaded.packages).toEqual( + if (process.platform === 'darwin') { + await expect(load).rejects.toThrow('Dart pubspec discovery failed (read-directory): pkg'); + return; + } + expect((await load).packages).toEqual( new Map([ ['data', 'pkg/lib'], ['nested_data', 'pkg/nested/lib'], @@ -511,26 +516,55 @@ describe.skipIf(!pubspecWalkAnchored())('Dart pubspec package discovery', () => ); }); - it('lists the opened directory inode after its path becomes a symlink', async () => { - const listingRoot = descriptorDirectoryPath(0); - if (listingRoot === null) return; - const outside = await fixture({ 'outside.txt': 'out' }); - const root = await fixture({}); - const real = path.join(root, 'real'); - await mkdir(real); - await writeFile(path.join(real, 'inside.txt'), 'in'); - const handle = await open(real, directoryOpenFlags()); - try { - const listed = descriptorDirectoryPath(handle.fd); - if (listed === null) throw new Error('descriptor listing path missing'); - await rename(real, path.join(root, 'real-moved')); - await symlink(outside, real, process.platform === 'win32' ? 'junction' : 'dir'); - await expect(readDirectoryNoFollow(real)).rejects.toThrow(); - expect(await readdir(listed)).toEqual(['inside.txt']); - } finally { - await handle.close(); - } - }); + it.skipIf(descriptorEntryPath(0, 'pubspec.yaml') === null)( + 'reads listed manifests from the opened directory inode after that path is replaced', + async () => { + const outside = await fixture({ + 'pubspec.yaml': 'name: foreign', + 'nested/pubspec.yaml': 'name: foreign', + }); + const root = await fixture({ + 'pkg/pubspec.yaml': 'name: data', + 'pkg/nested/pubspec.yaml': 'name: nested_data', + }); + const pkg = path.join(root, 'pkg'); + const loaded = await loadDartPackageConfig(root, { + beforeEntryOpen: async (relative) => { + if (relative !== 'pkg') return; + await rename(pkg, path.join(root, 'pkg-moved')); + await symlink(outside, pkg, 'dir'); + }, + }); + expect(loaded.packages).toEqual( + new Map([ + ['data', 'pkg/lib'], + ['nested_data', 'pkg/nested/lib'], + ]), + ); + }, + ); + + it.skipIf(descriptorDirectoryPath(0) === null)( + 'lists the opened directory inode after its path becomes a symlink', + async () => { + const outside = await fixture({ 'outside.txt': 'out' }); + const root = await fixture({}); + const real = path.join(root, 'real'); + await mkdir(real); + await writeFile(path.join(real, 'inside.txt'), 'in'); + const handle = await open(real, directoryOpenFlags()); + try { + const listed = descriptorDirectoryPath(handle.fd); + if (listed === null) throw new Error('descriptor listing path missing'); + await rename(real, path.join(root, 'real-moved')); + await symlink(outside, real, process.platform === 'win32' ? 'junction' : 'dir'); + await expect(readDirectoryNoFollow(real)).rejects.toThrow(); + expect(await readdir(listed)).toEqual(['inside.txt']); + } finally { + await handle.close(); + } + }, + ); }); describe('directory symlink refusal', () => { @@ -549,13 +583,18 @@ describe('directory symlink refusal', () => { await expect(readDirectoryNoFollow(link)).rejects.toThrow(); }); - it.skipIf(pubspecWalkAnchored())( - 'does not discover packages when the walk cannot honor no-follow', - async () => { - const root = await mkdtemp(path.join(os.tmpdir(), 'gitnexus-dart-pubspec-')); - roots.push(root); - await writeFile(path.join(root, 'pubspec.yaml'), 'name: app\n'); + it('does not discover packages when the walk cannot honor no-follow', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'gitnexus-dart-pubspec-')); + roots.push(root); + await writeFile(path.join(root, 'pubspec.yaml'), 'name: app\n'); + // Exercise the unsupported-platform branch on every host. The real + // capability check must refuse before attempting any directory walk. + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + try { + expect(pubspecWalkAnchored()).toBe(false); expect((await loadDartPackageConfig(root)).packages.size).toBe(0); - }, - ); + } finally { + platform.mockRestore(); + } + }); }); diff --git a/gitnexus/test/unit/eval-server-bind-restriction.test.ts b/gitnexus/test/unit/eval-server-bind-restriction.test.ts index 0f96e301e..0e46a05cb 100644 --- a/gitnexus/test/unit/eval-server-bind-restriction.test.ts +++ b/gitnexus/test/unit/eval-server-bind-restriction.test.ts @@ -22,7 +22,7 @@ describe('isEvalServerBindRestriction', () => { ['Error: listen EADDRNOTAVAIL: address not available 192.168.1.99:0'], ['something then bind EACCES: permission denied'], ['LISTEN EACCES: permission denied'], - ])('matches %p', (stderr) => { + ])('matches %s', (stderr) => { expect(isEvalServerBindRestriction(stderr)).toBe(true); }); }); @@ -35,7 +35,7 @@ describe('isEvalServerBindRestriction', () => { ['GITNEXUS_EVAL_SERVER_READY:127.0.0.1:5173'], [''], ['unknown option --host'], - ])('does not match %p', (stderr) => { + ])('does not match %s', (stderr) => { expect(isEvalServerBindRestriction(stderr)).toBe(false); }); }); diff --git a/gitnexus/test/unit/evidence-provenance-helper.test.ts b/gitnexus/test/unit/evidence-provenance-helper.test.ts index 7159a38b8..84597e23b 100644 --- a/gitnexus/test/unit/evidence-provenance-helper.test.ts +++ b/gitnexus/test/unit/evidence-provenance-helper.test.ts @@ -953,20 +953,23 @@ SAFE_WRITE_FIXTURES('generated-plan safe writer', () => { ['--expected-plan-path', SAFE_PLAN_PATH], /--expected-plan-path and --expected-plan-digest require --replace/, ], - ] as const)('rejects command-inapplicable CLI options for %s', (command, extra, pattern) => { - const repo = createBaseRepo('gitnexus-plan-cli-options-'); - try { - const result = spawnSync( - process.execPath, - [PLAN_HELPER, command, '--repo', repo, '--generated-plan', SAFE_PLAN_PATH, ...extra], - { encoding: 'utf8', input: '# plan\n' }, - ); - expect(result.status).toBe(1); - expect(result.stderr).toMatch(pattern); - } finally { - fs.rmSync(repo, { recursive: true, force: true }); - } - }); + ] as const)( + 'rejects command-inapplicable CLI options for %s (case %#)', + (command, extra, pattern) => { + const repo = createBaseRepo('gitnexus-plan-cli-options-'); + try { + const result = spawnSync( + process.execPath, + [PLAN_HELPER, command, '--repo', repo, '--generated-plan', SAFE_PLAN_PATH, ...extra], + { encoding: 'utf8', input: '# plan\n' }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toMatch(pattern); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }, + ); it('never overwrites a destination created immediately before initial publication', async () => { const repo = createBaseRepo('gitnexus-plan-writer-'); diff --git a/gitnexus/test/unit/mcp-repository-policy.test.ts b/gitnexus/test/unit/mcp-repository-policy.test.ts index 3c55fcc19..1e4322837 100644 --- a/gitnexus/test/unit/mcp-repository-policy.test.ts +++ b/gitnexus/test/unit/mcp-repository-policy.test.ts @@ -283,7 +283,7 @@ describe('MCP repository policy', () => { ); it.each([{ GITNEXUS_MCP_ALLOWED_REPOS: ' ' }, { GITNEXUS_MCP_DEFAULT_REPO: ' ' }])( - 'fails closed for explicitly blank repository configuration', + 'fails closed for explicitly blank repository configuration (case %#)', async (env) => { await expect(createMcpRepositoryPolicy(createBackend(), env)).rejects.toThrow( /must not be blank/i, diff --git a/gitnexus/test/unit/repo-manager.test.ts b/gitnexus/test/unit/repo-manager.test.ts index fddafbe39..d168aef57 100644 --- a/gitnexus/test/unit/repo-manager.test.ts +++ b/gitnexus/test/unit/repo-manager.test.ts @@ -1959,7 +1959,7 @@ describe('assertSafeStoragePath (#1003)', () => { await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError); }); - it.each([null, 42])('rejects malformed storagePath %p with the safety error', async (value) => { + it.each([null, 42])('rejects malformed storagePath %s with the safety error', async (value) => { const entry = { ...base, storagePath: value, diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts index b6dda595c..007cbc391 100644 --- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts @@ -1,6 +1,7 @@ import { execSync } from 'child_process'; import fs from 'fs/promises'; import { basename } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { getStoragePaths, @@ -2747,6 +2748,7 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => vi.doUnmock('../../src/core/embeddings/embedding-identity.js'); vi.doUnmock('../../src/core/embeddings/embedding-pipeline.js'); vi.doUnmock('../../src/core/embeddings/staged-embedding-recovery.js'); + vi.doUnmock('../../src/core/analyzer-identity.js'); vi.restoreAllMocks(); vi.resetModules(); vi.clearAllMocks(); @@ -3404,30 +3406,58 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => await fs.writeFile(`${storagePath}/${source.filename}`, source.contents); } await fs.writeFile(lbugPath, 'previous published index'); + // This unit fixture switches process.platform to exercise Windows + // recovery. Hash an immutable, test-owned analyzer runtime instead of + // the shared checkout under concurrent CI activity. Keep the real + // identity validation and finalization. + const identity = await import('../../src/core/analyzer-identity.js'); + const runtimeRoot = `${tmpRepo.dbPath}/analyzer-runtime`; + await fs.mkdir(`${runtimeRoot}/src`, { recursive: true }); + await fs.writeFile( + `${runtimeRoot}/package.json`, + '{"name":"recovery-fixture","version":"1.0.0"}', + ); + await fs.writeFile(`${runtimeRoot}/package-lock.json`, '{"lockfileVersion":3}'); + await fs.writeFile(`${runtimeRoot}/src/analyzer.ts`, 'export const analyzer = 1;'); + const runtimeUrl = pathToFileURL(`${runtimeRoot}/src/analyzer.ts`).href; + const identityOptions = { cacheDirectory: `${tmpRepo.dbPath}/identity-cache` }; + const resolveRunnerIdentity = vi.fn(() => + identity.resolveAnalyzerRunnerIdentity(runtimeUrl, identityOptions), + ); + vi.doMock('../../src/core/analyzer-identity.js', () => ({ + ...identity, + resolveAnalyzerRunnerIdentity: resolveRunnerIdentity, + finalizeAnalyzerRunnerIdentity: ( + _url: string, + startedWith: NonNullable, + ) => identity.finalizeAnalyzerRunnerIdentity(runtimeUrl, startedWith, identityOptions), + })); const { normalizeCachedEmbeddings } = await import('../../src/core/embeddings/embedding-restore-spill.js'); - vi.doMock( - '../../src/core/embeddings/staged-embedding-recovery.js', - async (importActual) => ({ - ...(await importActual< - typeof import('../../src/core/embeddings/staged-embedding-recovery.js') - >()), - recoverStagedEmbeddings: vi.fn(async () => - normalizeCachedEmbeddings({ - embeddings: [ - { - nodeId: RESILIENCE_NODE_ID, - chunkIndex: 0, - startLine: 1, - endLine: 2, - contentHash: 'current-hash', - embedding: new Array(EMBEDDING_DIMS).fill(0), - }, - ], - }), - ), + // Resolve the real merge implementation on the host, before selecting + // the Windows branch. A lazy importActual factory otherwise loads and + // transforms real modules while process.platform is temporarily win32. + const recovery = await vi.importActual< + typeof import('../../src/core/embeddings/staged-embedding-recovery.js') + >('../../src/core/embeddings/staged-embedding-recovery.js'); + const recoverStagedEmbeddings = vi.fn(async () => + normalizeCachedEmbeddings({ + embeddings: [ + { + nodeId: RESILIENCE_NODE_ID, + chunkIndex: 0, + startLine: 1, + endLine: 2, + contentHash: 'current-hash', + embedding: new Array(EMBEDDING_DIMS).fill(0), + }, + ], }), ); + vi.doMock('../../src/core/embeddings/staged-embedding-recovery.js', () => ({ + ...recovery, + recoverStagedEmbeddings, + })); const snapshots: Array = []; const rename = fs.rename.bind(fs); const { batchInsertEmbeddings } = mockResilienceHarness({ @@ -3463,16 +3493,25 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => // branch. The native adapter is mocked; source files and lock cleanup are real. await import('../../src/core/run-analyze.js'); vi.stubEnv('GITNEXUS_ATOMIC_WINDOWS_SWAP', '0'); + const logs: string[] = []; Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); const error = await runAnalyze( tmpRepo.dbPath, { force: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, - [], + logs, ); if (actualPlatformDescriptor) { Object.defineProperty(process, 'platform', actualPlatformDescriptor); } - expect(batchInsertEmbeddings).toHaveBeenCalled(); + expect(error, logs.join('\n')).toEqual( + outcome === 'success' ? null : expect.objectContaining({ message: outcome }), + ); + expect(resolveRunnerIdentity, logs.join('\n')).toHaveBeenCalledOnce(); + expect(recoverStagedEmbeddings, logs.join('\n')).toHaveBeenCalledOnce(); + expect(logs).toContain( + 'Recovered 1 complete staged embedding chunk(s) for 1 node(s); unchanged content can reuse them.', + ); + expect(batchInsertEmbeddings, logs.join('\n')).toHaveBeenCalled(); expect(vi.mocked(adapter.initLbug).mock.calls.at(-1)?.[0]).toBe(lbugPath); const finalMeta = await loadMeta(storagePath); // Reacquiring the real lock performs the next retry's orphan sweep. @@ -3481,7 +3520,6 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => const lock = await acquireIndexLock(storagePath, { timeoutMs: 1000 }); try { if (outcome === 'success') { - expect(error).toBeNull(); expect(finalMeta?.embeddingCheckpoint).toBeUndefined(); expect(finalMeta?.stats?.embeddings).toBe(9); expect(await fs.readFile(lbugPath, 'utf8')).toBe('in-place replacement'); @@ -3491,7 +3529,6 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => }); } } else { - expect(error).toMatchObject({ message: outcome }); for (const source of sourceFiles) { expect(await fs.readFile(`${storagePath}/${source.filename}`, 'utf8')).toBe( source.contents, diff --git a/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts b/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts index 228fcead8..a02d40f2d 100644 --- a/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts +++ b/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts @@ -389,7 +389,7 @@ describe('Rust: isGlobalNameFallbackPlausible', () => { ['src/a/b.rs', 'super::unique_helper_xyz', 'src/a.rs'], ['src/a/b/c.rs', 'super::super::unique_helper_xyz', 'src/a/mod.rs'], ['src/a/b.rs', 'self::child::unique_helper_xyz', 'src/a/b/child.rs'], - ])('resolves relative imports from %s', (caller, target, candidate) => { + ])('resolves relative imports from %s via %s', (caller, target, candidate) => { expect( rustIsGlobalNameFallbackPlausible({ site: BARE_SITE, diff --git a/gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts b/gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts index 7458c5fcc..97ece0be7 100644 --- a/gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts +++ b/gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts @@ -150,7 +150,7 @@ describe('Cargo manifest target metadata', () => { `${PACKAGE}build=1\n`, `${PACKAGE}[[bin]]\npath="custom/entry.rs"\n`, `${PACKAGE}[[test]]\npath="custom/entry.rs"\n`, - ])('does not manufacture evidence from malformed metadata', (manifest) => { + ])('does not manufacture evidence from malformed metadata (case %#)', (manifest) => { expect(cargoTargetRoots('Cargo.toml', manifest, files)).toBeUndefined(); }); }); diff --git a/gitnexus/test/unit/spring-aop.test.ts b/gitnexus/test/unit/spring-aop.test.ts index 93e42436c..407ef86b6 100644 --- a/gitnexus/test/unit/spring-aop.test.ts +++ b/gitnexus/test/unit/spring-aop.test.ts @@ -326,7 +326,7 @@ describe('Spring AOP persisted reason contract (#2416)', () => { }, ]; - it.each(reasons)('round-trips the $kind reason', (reason) => { + it.each(reasons)('round-trips the $kind reason for $annotation', (reason) => { expect(decodeSpringAopReason(encodeSpringAopReason(reason))).toEqual(reason); }); diff --git a/gitnexus/test/unit/test-completeness.test.ts b/gitnexus/test/unit/test-completeness.test.ts new file mode 100644 index 000000000..93cf5466f --- /dev/null +++ b/gitnexus/test/unit/test-completeness.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { reconcileTestReports } from '../../scripts/test-completeness.js'; + +const report = (name: string, statuses: string[]) => ({ + success: !statuses.includes('failed'), + executionErrors: 0, + executionReason: 'passed', + testResults: [ + { + name, + status: statuses.includes('failed') ? 'failed' : 'passed', + assertionResults: statuses.map((status, i) => ({ + ancestorTitles: ['contract'], + title: `case ${i}`, + fullName: `contract case ${i}`, + status, + location: { line: i + 1, column: 1 }, + })), + }, + ], +}); + +describe('required test execution across CI jobs', () => { + it('fails when a collected test never executes, including todo', () => { + const result = reconcileTestReports([ + report('/home/runner/work/GitNexus/GitNexus/gitnexus/test/unit/a.test.ts', [ + 'passed', + 'pending', + 'todo', + ]), + ]); + expect(result.total).toBe(3); + expect(result.passed).toBe(1); + expect(result.unverified).toHaveLength(2); + }); + + it('accepts a platform skip only with an actual pass from another job', () => { + const result = reconcileTestReports([ + report('/home/runner/work/GitNexus/GitNexus/gitnexus/test/unit/a.test.ts', [ + 'passed', + 'pending', + ]), + report('D:\\a\\GitNexus\\GitNexus\\gitnexus\\test\\unit\\a.test.ts', ['pending', 'passed']), + ]); + expect(result.total).toBe(2); + expect(result.passed).toBe(2); + expect(result.unverified).toEqual([]); + expect(result.failures).toEqual([]); + }); + + it('never lets a passing job erase a failure on another platform', () => { + const result = reconcileTestReports([ + report('/repo/gitnexus/test/unit/a.test.ts', ['failed']), + report('/repo/gitnexus/test/unit/a.test.ts', ['passed']), + ]); + expect(result.failures.some((message) => message.includes('contract case 0'))).toBe(true); + }); + + it('counts failed tests separately from tests that never executed', () => { + const result = reconcileTestReports([ + report('/repo/gitnexus/test/unit/a.test.ts', ['passed', 'failed', 'pending']), + ]); + expect(result.report.numPassedTests).toBe(1); + expect(result.report.numFailedTests).toBe(1); + expect(result.report.numPendingTests).toBe(1); + expect(result.unverified).toHaveLength(1); + expect(result.report.success).toBe(false); + }); + + it('does not collapse identical titles from different files or repeated cases', () => { + const repeated = report('/repo/gitnexus/test/unit/a.test.ts', ['passed', 'pending']); + repeated.testResults[0].assertionResults[1].title = 'case 0'; + repeated.testResults[0].assertionResults[1].fullName = 'contract case 0'; + const result = reconcileTestReports([ + repeated, + report('/repo/gitnexus/test/unit/b.test.ts', ['passed']), + ]); + expect(result.total).toBe(3); + expect(result.unverified).toHaveLength(1); + }); + + it('matches source locations when repeated titles are collected in a different order', () => { + const first = report('/repo/gitnexus/test/unit/a.test.ts', ['pending', 'passed']); + first.testResults[0].assertionResults[1].title = 'case 0'; + first.testResults[0].assertionResults[1].fullName = 'contract case 0'; + const second = structuredClone(first); + second.testResults[0].assertionResults.reverse(); + const result = reconcileTestReports([first, second]); + expect(result.total).toBe(2); + expect(result.unverified).toHaveLength(1); + }); + + it('rejects ambiguous parameterized cases with the same name and location', () => { + const input = report('/repo/gitnexus/test/unit/a.test.ts', ['passed']); + input.testResults[0].assertionResults.push({ ...input.testResults[0].assertionResults[0] }); + expect(() => reconcileTestReports([input])).toThrow(/ambiguous/i); + }); + + it('requires an unambiguous title and a real pass for helper-generated tests without locations', () => { + const input = report('/repo/gitnexus/test/unit/a.test.ts', ['pending']); + Reflect.deleteProperty(input.testResults[0].assertionResults[0], 'location'); + expect(reconcileTestReports([input]).unverified).toHaveLength(1); + const passing = structuredClone(input); + passing.testResults[0].assertionResults[0].status = 'passed'; + expect(reconcileTestReports([input, passing]).report.success).toBe(true); + passing.testResults[0].assertionResults.push({ ...passing.testResults[0].assertionResults[0] }); + expect(() => reconcileTestReports([passing])).toThrow(/ambiguous/i); + }); + + it('reconciles source-adjacent web tests on Linux and Windows', () => { + const result = reconcileTestReports( + [ + report('/repo/gitnexus-web/src/lib/upload-filter.test.ts', ['passed']), + report('C:/repo/gitnexus-web/src/lib/upload-filter.test.ts', ['passed']), + ], + ['src/lib/upload-filter.test.ts'], + 'gitnexus-web', + ); + expect(result.report.success).toBe(true); + expect(result.total).toBe(1); + expect(() => + reconcileTestReports( + [report('/repo/gitnexus-web/test/unit/a.test.ts', ['passed'])], + ['test/unit/a.test.ts', 'src/lib/upload-filter.test.ts'], + 'gitnexus-web', + ), + ).toThrow(/never collected/); + }); + + it('requires every shard receipt and the full web inventory through the CLI', () => { + const temp = mkdtempSync(path.join(tmpdir(), 'gitnexus-completeness-cli-')); + const packageRoot = fileURLToPath(new URL('../../', import.meta.url)); + const root = path.join(temp, 'gitnexus'); + const webRoot = path.join(temp, 'gitnexus-web'); + const receipts = path.join(temp, 'receipts'); + const output = path.join(temp, 'combined.json'); + for (const directory of [ + path.join(root, 'scripts'), + path.join(root, 'test/unit'), + path.join(webRoot, 'src/lib'), + receipts, + ]) { + mkdirSync(directory, { recursive: true }); + } + const script = path.join(root, 'scripts/test-completeness.ts'); + copyFileSync(path.join(packageRoot, 'scripts/test-completeness.ts'), script); + writeFileSync(path.join(temp, 'package.json'), '{"type":"module"}'); + writeFileSync(path.join(root, 'test/unit/a.test.ts'), '// inventory fixture'); + writeFileSync(path.join(webRoot, 'src/lib/a.test.ts'), '// inventory fixture'); + const core = report(path.join(root, 'test/unit/a.test.ts'), ['passed']); + const web = report(path.join(webRoot, 'src/lib/a.test.ts'), ['passed']); + const webReport = path.join(webRoot, 'web-test-results.json'); + for (const name of [ + 'coverage', + 'benchmarks', + 'preflight', + 'windows-latest-1', + 'windows-latest-2', + 'macos-latest-1', + 'macos-latest-2', + ]) { + writeFileSync(path.join(receipts, `${name}.json`), JSON.stringify(core)); + } + const run = () => { + writeFileSync(webReport, JSON.stringify(web)); + return spawnSync(process.execPath, ['--import', 'tsx', script, receipts, output, '2'], { + cwd: packageRoot, + encoding: 'utf8', + timeout: 30_000, + }); + }; + try { + const complete = run(); + expect(complete.error, complete.stderr).toBeUndefined(); + expect(complete.status, complete.stderr).toBe(0); + expect(JSON.parse(readFileSync(output, 'utf8')).numPendingTests).toBe(0); + writeFileSync(path.join(webRoot, 'src/lib/missing.test.ts'), '// missing receipt'); + const missingSource = run(); + expect(missingSource.status).not.toBe(0); + expect(missingSource.stderr).toContain( + 'Test file was never collected: src/lib/missing.test.ts', + ); + rmSync(path.join(webRoot, 'src/lib/missing.test.ts')); + rmSync(path.join(receipts, 'windows-latest-2.json')); + const missingShard = run(); + expect(missingShard.status).not.toBe(0); + expect(missingShard.stderr).toContain('windows-latest-2.json'); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it('rejects test files missing from the collected inventory', () => { + expect(() => + reconcileTestReports( + [report('/repo/gitnexus/test/unit/a.test.ts', ['passed'])], + ['test/unit/a.test.ts', 'test/unit/b.test.ts'], + ), + ).toThrow(/b.test.ts/); + }); + + it('rejects missing, empty or malformed reports instead of reporting zero skips', () => { + expect(() => reconcileTestReports([])).toThrow(); + expect(() => reconcileTestReports([{ testResults: [] }])).toThrow(); + expect(() => + reconcileTestReports([{ testResults: [{ name: 'a', assertionResults: [] }] }]), + ).toThrow(); + expect(() => + reconcileTestReports([report('/repo/gitnexus/test/unit/a.test.ts', ['unknown'])]), + ).toThrow(); + }); + + it('keeps failed collection and unhandled runner errors fatal', () => { + const broken = report('/repo/gitnexus/test/unit/a.test.ts', ['passed']); + broken.success = false; + expect(reconcileTestReports([broken]).failures).not.toHaveLength(0); + broken.success = true; + broken.executionErrors = 1; + expect(reconcileTestReports([broken]).failures).not.toHaveLength(0); + broken.executionErrors = 0; + broken.testResults[0].status = 'failed'; + expect(reconcileTestReports([broken]).failures).not.toHaveLength(0); + expect(reconcileTestReports([broken]).report.numFailedTestSuites).toBe(1); + expect(reconcileTestReports([broken]).report.testResults[0].status).toBe('failed'); + }); + + it('captures a real unhandled rejection even when Vitest exits successfully', () => { + const temp = mkdtempSync(path.join(tmpdir(), 'gitnexus-receipt-')); + const packageRoot = fileURLToPath(new URL('../../', import.meta.url)); + const root = path.join(temp, 'gitnexus'); + const output = path.join(temp, 'receipt.json'); + mkdirSync(path.join(root, 'test/unit'), { recursive: true }); + const vitestImport = pathToFileURL( + path.join(packageRoot, 'node_modules/vitest/dist/index.js'), + ).href; + writeFileSync( + path.join(root, 'test/unit/rejection.test.ts'), + ` +import { it, expect } from ${JSON.stringify(vitestImport)}; +it('executes its assertion but leaks a rejection', async () => { + expect(2 + 2).toBe(4); + setTimeout(() => { void Promise.reject(new Error('receipt rejection probe')); }, 0); + await new Promise(resolve => setTimeout(resolve, 50)); +}); +`, + ); + const config = path.join(root, 'vitest.config.mjs'); + writeFileSync( + config, + 'export default { test: { include: ["test/**/*.test.ts"], includeTaskLocation: true, dangerouslyIgnoreUnhandledErrors: true } };', + ); + try { + const child = spawnSync( + process.execPath, + [ + path.join(packageRoot, 'node_modules/vitest/vitest.mjs'), + 'run', + '--root', + root, + '--config', + config, + '--reporter', + path.join(packageRoot, 'scripts/execution-reporter.ts'), + '--outputFile', + output, + ], + { cwd: packageRoot, encoding: 'utf8', timeout: 30_000 }, + ); + expect(child.error, child.stderr).toBeUndefined(); + expect(child.status, child.stderr).toBe(0); + const receipt = JSON.parse(readFileSync(output, 'utf8')); + expect(receipt.numPassedTests).toBe(1); + expect(receipt.executionErrors).toBeGreaterThan(0); + expect(reconcileTestReports([receipt]).report.success).toBe(false); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); +}); diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 62830ccac..a472ce12e 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -4519,11 +4519,10 @@ void processRepoMap(std::map repoMap) { }); }); - describe('known limitations (documented skip tests)', () => { - it.skip('Ruby block parameter: users.each { |user| } — closure param inference, different feature', () => { - // Not a for-loop; .each { |user| } is a method call with a block. - // Requires closure parameter inference — a different feature category - // applicable to Ruby, Swift closures, Kotlin lambdas, and Java lambdas. + describe('unknown Ruby block parameter types', () => { + it('does not invent a type for a block parameter from an untyped receiver', () => { + // Neither the parameter name nor calling `save` proves users contains + // User instances. Inferring User here would introduce false call edges. const tree = parse( ` def process(users) @@ -4533,7 +4532,7 @@ end Ruby, ); const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); - expect(flatGet(typeEnv, 'user')).toBe('User'); + expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); }); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 2b1d43c38..73da0755f 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -8,6 +8,8 @@ export default defineConfig({ hookTimeout: 120000, pool: 'forks', globals: true, + // Stable test identity across the Linux/macOS/Windows execution receipts. + includeTaskLocation: true, teardownTimeout: 3000, // E2E harnesses pin a small NODE_OPTIONS heap so spawned CLI children // stay light; without this opt-out the #2649 auto-heap override would From 047354c3f0f828394f2b79081fa311bb46faed02 Mon Sep 17 00:00:00 2001 From: Younes Beriane Date: Tue, 6 Oct 2026 08:40:21 +0200 Subject: [PATCH 12/13] fix(community): run Leiden in a worker so its timeout can fire (#3478) --- gitnexus/scripts/cross-platform-tests.ts | 1 + .../src/core/ingestion/community-processor.ts | 115 ++++++++++++++---- gitnexus/test/helpers/leiden-timeout-child.ts | 38 ++++++ .../test/unit/community-processor.test.ts | 100 ++++++++++++++- 4 files changed, 225 insertions(+), 29 deletions(-) create mode 100644 gitnexus/test/helpers/leiden-timeout-child.ts diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 55bade8f0..74fb9623c 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -289,6 +289,7 @@ const SPAWN_CLI = [ // Worker threads tests — exercise real worker_threads which have // platform-specific behavior (thread spawning, IPC, exit handling) const WORKER_THREADS = [ + 'test/unit/community-processor.test.ts', 'test/integration/worker-pool.test.ts', 'test/integration/parse-impl-quarantine-cache-skip.test.ts', ]; diff --git a/gitnexus/src/core/ingestion/community-processor.ts b/gitnexus/src/core/ingestion/community-processor.ts index 233326e03..17b90d03a 100644 --- a/gitnexus/src/core/ingestion/community-processor.ts +++ b/gitnexus/src/core/ingestion/community-processor.ts @@ -29,12 +29,9 @@ const _require = createRequire(import.meta.url); /** Graphology Graph instance type (AbstractGraph from graphology-types avoids CJS/ESM interop namespace issue) */ type GraphInstance = AbstractGraph; -const leiden: LeidenModule = _require(leidenPath); - -/** Vendored Leiden algorithm module shape */ -interface LeidenModule { - detailed: (graph: GraphInstance, options: Record) => LeidenDetailedResult; -} +// The Leiden worker loads its own copy; this load keeps a missing vendor/ asset +// failing at import time and keeps it visible to dockerfile-runtime-asset-parity. +_require(leidenPath); /** Result returned by leiden.detailed() */ interface LeidenDetailedResult { @@ -110,15 +107,6 @@ interface IcebugWorkerFailure { * incremental-indexing equivalence test (incremental ≡ full rebuild). */ const LEIDEN_SEED = 0xc0de; -function createSeededRng(seed: number): () => number { - let s = seed >>> 0; - return () => { - s = (s + 0x6d2b79f5) >>> 0; - let t = Math.imul(s ^ (s >>> 15), 1 | s); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} const COMMUNITY_ENGINE_ENV = 'GITNEXUS_COMMUNITY_ENGINE'; /** @@ -474,28 +462,101 @@ const runCommunityEngine = async ( 35, ); const fallback = await runGraphologyLeiden(graph, projection.isLarge, engineRequested); - return { ...fallback, fallbackReason }; + return { + ...fallback, + fallbackReason: fallback.fallbackReason + ? `${fallbackReason}; ${fallback.fallbackReason}` + : fallbackReason, + }; } }; -const runGraphologyLeiden = async ( +/** + * Runs the vendored Leiden in a worker so LEIDEN_TIMEOUT_MS can actually fire. + * leiden.detailed is synchronous and, on some graphs, never returns + * (graphology/graphology#557); a timer on the main thread cannot interrupt it. + * The worker is pure JS, so terminate() is safe (unlike the icebug N-API worker). + */ +const GRAPHOLOGY_WORKER_SOURCE = ` +const { parentPort, workerData } = require('node:worker_threads'); +try { + const Graph = require(workerData.graphologyPath); + const leiden = require(workerData.leidenPath); + const graph = Graph.from(workerData.graph); + const rng = (() => { + let s = workerData.seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + })(); + const d = leiden.detailed(graph, { + resolution: workerData.resolution, + maxIterations: workerData.maxIterations, + rng, + }); + parentPort.postMessage({ + ok: true, + communities: d.communities, + count: d.count, + modularity: d.modularity, + }); +} catch (error) { + parentPort.postMessage({ ok: false, error: error instanceof Error ? error.message : String(error) }); +} +`; + +export const runGraphologyLeiden = async ( graph: GraphInstance, isLarge: boolean, engineRequested: CommunityDetectionEngine, + timeoutMs: number = LEIDEN_TIMEOUT_MS, ): Promise => { try { - const details = await Promise.race([ - Promise.resolve( - leiden.detailed(graph, { + const details = await new Promise((resolve, reject) => { + const worker = new Worker(GRAPHOLOGY_WORKER_SOURCE, { + eval: true, + workerData: { + graphologyPath: _require.resolve('graphology'), + leidenPath, + graph: graph.export(), + seed: LEIDEN_SEED, resolution: isLarge ? 2.0 : 1.0, maxIterations: isLarge ? 3 : 0, - rng: createSeededRng(LEIDEN_SEED), - }), - ), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Leiden timeout')), LEIDEN_TIMEOUT_MS), - ), - ]); + }, + }); + let settled = false; + const timeout = setTimeout(() => { + settled = true; + void worker.terminate(); + reject(new Error('Leiden timeout')); + }, timeoutMs); + worker.once('message', (message) => { + settled = true; + clearTimeout(timeout); + if (message.ok === true) { + resolve({ + communities: message.communities, + count: message.count, + modularity: message.modularity, + }); + } else { + reject(new Error(message.error)); + } + }); + worker.once('error', (error) => { + settled = true; + clearTimeout(timeout); + reject(error); + }); + worker.once('exit', (code) => { + if (settled) return; + clearTimeout(timeout); + reject(new Error(`Graphology Leiden worker exited with code ${code}`)); + }); + }); return { ...details, engine: 'graphology', engineRequested }; } catch (e: any) { if (e.message !== 'Leiden timeout') { diff --git a/gitnexus/test/helpers/leiden-timeout-child.ts b/gitnexus/test/helpers/leiden-timeout-child.ts new file mode 100644 index 000000000..eecebbf84 --- /dev/null +++ b/gitnexus/test/helpers/leiden-timeout-child.ts @@ -0,0 +1,38 @@ +import { + buildGraphologyGraph, + runGraphologyLeiden, +} from '../../src/core/ingestion/community-processor.js'; + +// 105-node / 128-edge graph from #3476 on which the vendored Leiden never +// terminates (graphology/graphology#557). Preserve the original insertion order: +// sorting either the nodes or edges changes the path through the algorithm. +const EDGES = + '39-26 65-66 72-52 10-38 98-68 37-10 26-81 37-38 30-66 18-66 30-97 84-81 50-96 36-76 ' + + '33-29 54-87 4-75 44-31 29-31 60-44 91-10 48-41 68-15 83-90 82-57 50-71 28-55 100-11 ' + + '42-19 28-42 74-92 55-5 7-65 96-41 61-27 60-85 89-67 97-34 94-64 89-6 61-51 12-13 ' + + '35-9 91-20 94-23 91-7 43-99 80-85 80-101 75-50 38-57 4-104 35-6 65-103 83-45 62-91 ' + + '16-37 94-33 103-17 67-66 39-73 68-2 26-31 48-19 43-88 88-47 54-42 26-66 100-65 7-30 ' + + '32-13 56-2 22-24 78-24 84-53 90-71 60-12 76-49 86-35 55-68 96-46 33-30 4-1 24-68 ' + + '23-14 93-3 58-56 64-27 85-87 61-101 36-37 37-85 60-63 37-26 41-74 103-87 78-36 69-97 ' + + '77-42 97-52 57-25 27-34 68-28 95-104 18-96 4-15 60-48 17-14 51-31 83-40 48-75 16-99 ' + + '79-65 70-14 3-27 0-62 21-101 44-59 4-24 34-52 65-8 102-66 67-24 11-26 39-78 6-103 ' + + '4-71 70-4'; + +const graph = buildGraphologyGraph({ + nodes: Array.from({ length: 105 }, (_, i) => ({ + id: String(i), + name: String(i), + filePath: `/src/${i}.ts`, + type: 'Function', + })), + edges: EDGES.split(/\s+/).map((edge) => { + const [source, target] = edge.split('-').map(Number); + return [source, target]; + }), + symbolCount: 105, + isLarge: false, +}); + +const result = await runGraphologyLeiden(graph, false, 'graphology', 500); +console.log(JSON.stringify(result)); +// No explicit exit: a leaked worker must keep this process alive for the parent to detect. diff --git a/gitnexus/test/unit/community-processor.test.ts b/gitnexus/test/unit/community-processor.test.ts index a2bb8ce6d..2e8acb9f8 100644 --- a/gitnexus/test/unit/community-processor.test.ts +++ b/gitnexus/test/unit/community-processor.test.ts @@ -1,9 +1,12 @@ +import { spawnSync } from 'node:child_process'; import { EventEmitter } from 'node:events'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { Worker } from 'node:worker_threads'; import { describe, it, expect, vi } from 'vitest'; +import { tsxLoaderUrl } from '../helpers/cli-entry.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; import { @@ -197,9 +200,19 @@ describe('community-processor', () => { vi.resetModules(); vi.doMock('node:worker_threads', () => { class MockWorker extends EventEmitter { - constructor() { + constructor(_source?: string, options?: { workerData?: { graphologyPath?: string } }) { super(); queueMicrotask(() => { + if (options?.workerData?.graphologyPath) { + // The Graphology Leiden worker: answer as the real one would. + this.emit('message', { + ok: true, + communities: { 'fn:a': 0, 'fn:b': 0 }, + count: 1, + modularity: 0, + }); + return; + } this.emit('message', { ok: true, partition: [0, 0], modularity: Number.NaN }); }); } @@ -232,7 +245,7 @@ describe('community-processor', () => { expect(result.stats.engineRequested).toBe('icebug'); expect(result.stats.engine).toBe('graphology'); - expect(result.stats.fallbackReason).toContain('modularity'); + expect(result.stats.fallbackReason).toBe('optional icebug modularity was not finite'); expect(progress.some((message) => message.includes('falling back to Graphology'))).toBe( true, ); @@ -246,6 +259,64 @@ describe('community-processor', () => { } }); + it.each(['icebug', 'auto'] as const)( + 'preserves both fallback reasons when %s fails and Graphology times out', + async (engine) => { + vi.resetModules(); + vi.useFakeTimers(); + const icebugError = 'optional icebug could not load'; + vi.doMock('node:worker_threads', () => { + class MockWorker extends EventEmitter { + constructor(_source?: string, options?: { workerData?: { graphologyPath?: string } }) { + super(); + if (!options?.workerData?.graphologyPath) { + queueMicrotask(() => this.emit('message', { ok: false, error: icebugError })); + } + // The Graphology worker stays silent until its real deadline fires. + } + + terminate(): Promise { + return Promise.resolve(0); + } + + unref(): void {} + } + + return { Worker: MockWorker }; + }); + + try { + const { processCommunities: processCommunitiesWithMockWorker } = + await import('../../src/core/ingestion/community-processor.js'); + const graph = createKnowledgeGraph(); + graph.addNode(makeNode('fn:a', 'a')); + graph.addNode(makeNode('fn:b', 'b')); + graph.addRelationship(makeRel('rel:ab', 'fn:a', 'fn:b')); + + const pending = processCommunitiesWithMockWorker(graph, undefined, { engine }); + await vi.advanceTimersByTimeAsync(60_000); + const result = await pending; + + expect(result.stats).toMatchObject({ + engineRequested: engine, + engine: 'graphology', + fallbackReason: `${icebugError}; Graphology Leiden timeout`, + totalCommunities: 1, + modularity: 0, + nodesProcessed: 2, + }); + expect(result.memberships).toEqual([ + { nodeId: 'fn:a', communityId: 'comm_0' }, + { nodeId: 'fn:b', communityId: 'comm_0' }, + ]); + } finally { + vi.useRealTimers(); + vi.doUnmock('node:worker_threads'); + vi.resetModules(); + } + }, + ); + it('falls back before icebug worker launch for nondeterministic options', async () => { const graph = createKnowledgeGraph(); graph.addNode(makeNode('fn:a', 'a', 'Function', '/src/group/a.ts')); @@ -372,6 +443,31 @@ module.exports = { }); }); + describe('graphology Leiden timeout', () => { + it('returns the fallback and exits naturally after terminating the hanging worker', () => { + const childPath = fileURLToPath( + new URL('../helpers/leiden-timeout-child.ts', import.meta.url), + ); + // A fallback alone is not enough: the child must exit without process.exit(). + // SIGKILL bounds cleanup if a regression leaves its worker running forever. + const child = spawnSync(process.execPath, ['--import', tsxLoaderUrl(), childPath], { + encoding: 'utf8', + timeout: 10_000, + killSignal: 'SIGKILL', + }); + + expect(child.error).toBeUndefined(); + expect(child.signal).toBeNull(); + expect(child.status, child.stderr).toBe(0); + const result = JSON.parse(child.stdout); + + expect(result.fallbackReason).toBe('Graphology Leiden timeout'); + expect(result.count).toBe(1); + expect(new Set(Object.values(result.communities))).toEqual(new Set([0])); + expect(Object.keys(result.communities)).toHaveLength(105); + }, 15_000); + }); + describe('vendored Leiden partitioning', () => { // Golden values for the seeded graph below, captured from the vendored // implementation. They pin the partition, not just its shape. From dd096e690c06e5d54ab3595c63b83f15e04b6191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Tue, 6 Oct 2026 10:23:35 +0300 Subject: [PATCH 13/13] chore(deps): consolidate open dependabot updates (#3496) --- .github/workflows/ci-tests.yml | 10 +- .gitignore | 1 + eval/uv.lock | 275 ++++---- gitnexus-web/package-lock.json | 6 +- gitnexus/package-lock.json | 599 ++++++++---------- gitnexus/package.json | 4 +- gitnexus/scripts/execution-reporter.ts | 31 +- .../analyze-staged-embedding-recovery.test.ts | 10 +- ...ug-pool-storage-version-lock-retry.test.ts | 9 +- gitnexus/test/unit/mcp-read-only.test.ts | 2 +- .../test/unit/mcp-repository-policy.test.ts | 2 +- .../test/unit/storage/shared-store.test.ts | 2 +- gitnexus/vitest.config.ts | 2 + 13 files changed, 462 insertions(+), 491 deletions(-) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 4862fc6ec..6f5e3fb32 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -80,8 +80,8 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-blob-${{ matrix.shard }} - path: gitnexus/.vitest-reports/ - # .vitest-reports is a dotdir; upload-artifact excludes hidden files by + path: gitnexus/.vitest/blob/ + # .vitest is a dotdir; upload-artifact excludes hidden files by # default, which would upload an empty artifact and break the merge. include-hidden-files: true retention-days: 5 @@ -110,11 +110,11 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: coverage-blob-* - path: gitnexus/.vitest-reports + path: gitnexus/.vitest/blob merge-multiple: true - name: Merge coverage + enforce thresholds run: >- - npx vitest --mergeReports + npx vitest --mergeReports=.vitest/blob --reporter=default --reporter=./scripts/execution-reporter.ts --outputFile=test-results.json @@ -122,7 +122,7 @@ jobs: --coverage.reporter=json-summary --coverage.reporter=json --coverage.reporter=text - --coverage.thresholdAutoUpdate=false + --coverage.thresholds.autoUpdate=false working-directory: gitnexus # gitnexus-shared already built by setup-gitnexus above - name: Install gitnexus-web dependencies diff --git a/.gitignore b/.gitignore index ffa32d659..176a20e61 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ npm-debug.log* # Testing coverage/ +.vitest/ .tmp-test/ gitnexus/.tmp-test/ diff --git a/eval/uv.lock b/eval/uv.lock index 07e2f172f..0f51f54a0 100644 --- a/eval/uv.lock +++ b/eval/uv.lock @@ -678,7 +678,7 @@ wheels = [ [[package]] name = "datasets" -version = "4.8.3" +version = "5.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dill" }, @@ -696,9 +696,9 @@ dependencies = [ { name = "tqdm" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/74/0c5f29109e02c4c33e8634e31d5cd7d90ecc3ed7c95e1b377ece08354068/datasets-4.8.3.tar.gz", hash = "sha256:882fb1bb514772bec17fbcad2e32985893954d0a0ecf42266e5091386be1f3b7", size = 604294, upload-time = "2026-03-19T17:43:59.32Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/5b/836516269d4f618efe621661cfb6f9acc57e6f95265db3efaee48a5ffe04/datasets-5.0.1.tar.gz", hash = "sha256:ce22bb851efd7494f08aad33b940803784434f6e77763d00679a0dc45fcf686a", size = 641498, upload-time = "2026-07-28T11:09:12.016Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/c6/b8298394a8926f8af21c059f4ec794b61f9cf64ade561184027b94110639/datasets-4.8.3-py3-none-any.whl", hash = "sha256:a66cb506097bfce8461b076fb9e86ea216e00fd622387ba19909d9c1689ff8f9", size = 526834, upload-time = "2026-03-19T17:43:57.337Z" }, + { url = "https://files.pythonhosted.org/packages/44/0b/98fc6eb83333508ca5f44c52b3e287ea8137a0ad582714e2cbc67a02154b/datasets-5.0.1-py3-none-any.whl", hash = "sha256:9fbf73688f8c18f7529b4fe592abd04015f81d1e58001e4bac73ffb2b39d7cc4", size = 559079, upload-time = "2026-07-28T11:09:10.266Z" }, ] [[package]] @@ -953,11 +953,11 @@ wheels = [ [[package]] name = "fsspec" -version = "2026.2.0" +version = "2026.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, ] [package.optional-dependencies] @@ -1776,119 +1776,160 @@ wheels = [ [[package]] name = "multidict" -version = "6.7.1" +version = "6.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, - { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, - { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, - { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, - { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, - { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, - { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/24/3823efc330630a1f132bcd0a9b182ddec3c53f452f551c8e8d3aaf5a2d20/multidict-6.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4", size = 96739, upload-time = "2026-09-21T17:54:42.815Z" }, + { url = "https://files.pythonhosted.org/packages/6a/69/36331fe1d3aeb0c525a9972d6cf82791075a395aa96f026f060009d691ae/multidict-6.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987", size = 58514, upload-time = "2026-09-21T17:54:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/5860651f782078ef13f2761c2593f4ba066d76f626b2f30f23a7f838b775/multidict-6.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c", size = 57303, upload-time = "2026-09-21T17:54:45.304Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9f/c56e2fa223cb5d182660e1a09eec38880f96b7cd22769582f74447c60418/multidict-6.9.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1", size = 320076, upload-time = "2026-09-21T17:54:46.826Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/eda4fe0cad51f96096363ed32d3e0f8df28dab00beff4d92adc9db517726/multidict-6.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07", size = 315489, upload-time = "2026-09-21T17:54:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/14/10b1ded0a4085a51c3054125f854da4f47ce4fec724a2d2503231b83f3db/multidict-6.9.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1", size = 296716, upload-time = "2026-09-21T17:54:49.749Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/04d155dd18528443cb9009f3a9f35f7417773797d072c95104ff17b8b9b7/multidict-6.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3", size = 330244, upload-time = "2026-09-21T17:54:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/8b/90/284d10b3a9e5b312a8ec5b7a175fc7d560eefdc99886320449891a197992/multidict-6.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80", size = 331288, upload-time = "2026-09-21T17:54:52.595Z" }, + { url = "https://files.pythonhosted.org/packages/27/50/f420de3683f9b047fc5588064d84043ef57a7152ef451b8373dfc7068d22/multidict-6.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387", size = 320663, upload-time = "2026-09-21T17:54:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/01/ab/0120a650d7ce4fe167299c13a13cc4ee2dc72458a9c0e9e9330b824b73c9/multidict-6.9.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345", size = 291139, upload-time = "2026-09-21T17:54:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/c66342f73ee5cd2c9b1dda6cfce89b5f348c48921b8c8b6e59aafbbfe31a/multidict-6.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd", size = 310044, upload-time = "2026-09-21T17:54:56.745Z" }, + { url = "https://files.pythonhosted.org/packages/05/0d/e44b90d9e77e44ec3473c4c7ac7ea95764d7e8fbf6d5a085ef9bff7ce0c2/multidict-6.9.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f", size = 303279, upload-time = "2026-09-21T17:54:58.178Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/7741f7c23c211fae31ab546f0ba41569dae7671cf7ca30d5afb59e9d92aa/multidict-6.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b", size = 326177, upload-time = "2026-09-21T17:54:59.825Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/0cdbfbbe36316b2ef4b8e011afaca2cf291a73fe4fabf34dd0b22e3ee48a/multidict-6.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82", size = 323526, upload-time = "2026-09-21T17:55:01.43Z" }, + { url = "https://files.pythonhosted.org/packages/98/05/0c86e9678e78f4596df0ed7f59d2aa755107da3f2a92f1b7fa150e94cbb8/multidict-6.9.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953", size = 290278, upload-time = "2026-09-21T17:55:02.993Z" }, + { url = "https://files.pythonhosted.org/packages/95/8e/71bd7f43c4883cba6c5f96db88bd2311d52e74ad7680be5915938490d56f/multidict-6.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a", size = 321142, upload-time = "2026-09-21T17:55:04.48Z" }, + { url = "https://files.pythonhosted.org/packages/2d/00/bf59d6bb22a4c152e4039574c29490caf3c65107715121807b4bd7cde244/multidict-6.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13", size = 319045, upload-time = "2026-09-21T17:55:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/f27f4f045198bf1c4bcd6eb3b6cf06be2dceaceb6e67222b903fce7a6749/multidict-6.9.1-cp311-cp311-win32.whl", hash = "sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f", size = 51309, upload-time = "2026-09-21T17:55:07.531Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/c2064aef64efc007bc89a571f843e5772f27946eb9bc018731bf8016e319/multidict-6.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e", size = 59148, upload-time = "2026-09-21T17:55:08.936Z" }, + { url = "https://files.pythonhosted.org/packages/45/a1/3bab1827edd813dfb90712af5cbb7fc1473ed4d9c871d103df4f4bb950c2/multidict-6.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d", size = 54258, upload-time = "2026-09-21T17:55:10.191Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" }, + { url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" }, + { url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" }, + { url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" }, + { url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" }, + { url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" }, + { url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" }, + { url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" }, + { url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" }, + { url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" }, + { url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" }, + { url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" }, + { url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" }, + { url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" }, + { url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" }, + { url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" }, + { url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" }, + { url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" }, + { url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" }, + { url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" }, + { url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" }, + { url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" }, ] [[package]] diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 4fc259a7b..08ab3b130 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -5338,9 +5338,9 @@ } }, "node_modules/joi": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.8.tgz", - "integrity": "sha512-G2TX62h58ZHuwqetJgP2F4ualakqAmZtBYe3jWen7gxQRw5xApX6crnFtuB91WC0c3ESBnva+kGSnb3+6pIQDQ==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.9.tgz", + "integrity": "sha512-2mD929bUVKUhOLQQEVhlf6EZ0Mlo0DeRb5MO7cViR9AXLtBauuccEtB1py9Ocxpo/P7ucnh442iY/iOwrh3IQw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 5cc0fcbce..72f3035c8 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -65,11 +65,11 @@ "@types/express": "^5.0.6", "@types/node": "^26.0.0", "@types/proxy-addr": "^2.0.3", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^5.0.0", "gitnexus-shared": "file:../gitnexus-shared", "tsx": "^4.0.0", "typescript": "^7.0.2", - "vitest": "^4.0.18" + "vitest": "^5.0.0" }, "engines": { "node": "^22.18.0 || >=24.11.0" @@ -814,9 +814,9 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.1.tgz", - "integrity": "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==", + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.31.0.tgz", + "integrity": "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", @@ -866,13 +866,14 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", - "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", "dev": true, "license": "MIT", + "peer": true, "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@pinojs/redact": { @@ -881,10 +882,28 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", - "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", "cpu": [ "arm64" ], @@ -894,14 +913,15 @@ "os": [ "android" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", "cpu": [ "arm64" ], @@ -911,14 +931,15 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", - "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", "cpu": [ "x64" ], @@ -928,14 +949,15 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", - "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", "cpu": [ "x64" ], @@ -945,14 +967,15 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", - "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", "cpu": [ "arm" ], @@ -962,14 +985,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", - "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", "cpu": [ "arm64" ], @@ -979,14 +1003,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", - "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", "cpu": [ "arm64" ], @@ -996,14 +1021,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", - "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", "cpu": [ "ppc64" ], @@ -1013,14 +1039,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", - "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", "cpu": [ "s390x" ], @@ -1030,14 +1057,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", - "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", "cpu": [ "x64" ], @@ -1047,14 +1075,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", - "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", "cpu": [ "x64" ], @@ -1064,14 +1093,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", - "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", "cpu": [ "arm64" ], @@ -1081,14 +1111,15 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", - "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", "cpu": [ "arm64" ], @@ -1098,14 +1129,15 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", - "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", "cpu": [ "x64" ], @@ -1115,6 +1147,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1124,7 +1157,8 @@ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@scarf/scarf": { "version": "1.4.0", @@ -1133,13 +1167,6 @@ "hasInstallScript": true, "license": "Apache-2.0" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -1277,9 +1304,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", - "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", "dev": true, "license": "MIT", "dependencies": { @@ -1672,29 +1699,27 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", - "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz", + "integrity": "sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.11", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" + "@vitest/istanbul-lib-coverage": "^1.0.0", + "@vitest/istanbul-lib-report": "^1.0.0", + "ast-v8-to-istanbul": "^1.0.5", + "magicast": "^0.5.4", + "obug": "^2.1.4", + "std-env": "^4.2.0", + "tinyrainbow": "^3.1.1" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.11", - "vitest": "4.1.11" + "@vitest/browser": "5.0.0", + "vitest": "5.0.0" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1702,34 +1727,40 @@ } } }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", + "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", + "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "@vitest/istanbul-lib-coverage": "1.0.2" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=22" } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.2.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1747,74 +1778,16 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", "dev": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1958,9 +1931,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", "dev": true, "license": "MIT", "dependencies": { @@ -2292,13 +2265,6 @@ "node": ">= 0.6" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -2419,6 +2385,7 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8" } @@ -2486,9 +2453,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -2611,9 +2578,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3065,13 +3032,6 @@ "node": ">=16.9.0" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3109,9 +3069,9 @@ } }, "node_modules/ignore": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", - "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "license": "MIT", "engines": { "node": ">= 4" @@ -3183,45 +3143,6 @@ "node": ">=20" } }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jose": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", @@ -3326,6 +3247,7 @@ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -3363,6 +3285,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3384,6 +3307,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3405,6 +3329,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3426,6 +3351,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3447,6 +3373,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3468,6 +3395,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3489,6 +3417,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3510,6 +3439,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3531,6 +3461,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3552,6 +3483,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3573,6 +3505,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3597,24 +3530,24 @@ } }, "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, @@ -3639,13 +3572,13 @@ } }, "node_modules/magicast/node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -3655,9 +3588,9 @@ } }, "node_modules/magicast/node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -3668,22 +3601,6 @@ "node": ">=6.9.0" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3800,9 +3717,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -3811,6 +3728,7 @@ } ], "license": "MIT", + "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -3881,15 +3799,18 @@ "license": "MIT" }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/on-exit-leak-free": { "version": "2.1.2", @@ -3994,24 +3915,18 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -4104,9 +4019,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -4123,8 +4038,9 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4277,13 +4193,14 @@ } }, "node_modules/rolldown": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", - "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@oxc-project/types": "=0.144.0", + "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4293,20 +4210,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.4", - "@rolldown/binding-darwin-arm64": "1.2.4", - "@rolldown/binding-darwin-x64": "1.2.4", - "@rolldown/binding-freebsd-x64": "1.2.4", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", - "@rolldown/binding-linux-arm64-gnu": "1.2.4", - "@rolldown/binding-linux-arm64-musl": "1.2.4", - "@rolldown/binding-linux-ppc64-gnu": "1.2.4", - "@rolldown/binding-linux-s390x-gnu": "1.2.4", - "@rolldown/binding-linux-x64-gnu": "1.2.4", - "@rolldown/binding-linux-x64-musl": "1.2.4", - "@rolldown/binding-openharmony-arm64": "1.2.4", - "@rolldown/binding-win32-arm64-msvc": "1.2.4", - "@rolldown/binding-win32-x64-msvc": "1.2.4" + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, "node_modules/router": { @@ -4586,9 +4504,9 @@ } }, "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -4704,16 +4622,19 @@ } }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { @@ -4738,9 +4659,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -5112,16 +5033,17 @@ } }, "node_modules/vite": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", - "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.25", - "rolldown": "~1.2.1", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "bin": { @@ -5138,7 +5060,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5190,38 +5112,31 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -5229,16 +5144,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { diff --git a/gitnexus/package.json b/gitnexus/package.json index afb4b5f37..c5ed70d0c 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -128,11 +128,11 @@ "@types/express": "^5.0.6", "@types/node": "^26.0.0", "@types/proxy-addr": "^2.0.3", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^5.0.0", "gitnexus-shared": "file:../gitnexus-shared", "tsx": "^4.0.0", "typescript": "^7.0.2", - "vitest": "^4.0.18" + "vitest": "^5.0.0" }, "overrides": { "adm-zip": ">=0.6.0", diff --git a/gitnexus/scripts/execution-reporter.ts b/gitnexus/scripts/execution-reporter.ts index 83a93a035..bfb048c96 100644 --- a/gitnexus/scripts/execution-reporter.ts +++ b/gitnexus/scripts/execution-reporter.ts @@ -1,10 +1,9 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; import { JsonReporter, type TestRunEndReason } from 'vitest/node'; /** Vitest's stock JSON omits unhandled errors, even when success is true. */ export default class ExecutionReporter extends JsonReporter { - private executionErrors = 0; - private executionReason: TestRunEndReason = 'interrupted'; - constructor() { super({}); } @@ -14,20 +13,26 @@ export default class ExecutionReporter extends JsonReporter { errors: readonly unknown[] = [], reason: TestRunEndReason = 'interrupted', ): Promise { - this.executionErrors = errors.length; - this.executionReason = reason; + const configuredOutput = this.ctx.config.outputFile; + const outputFile = resolve( + this.ctx.config.root, + (typeof configuredOutput === 'string' ? configuredOutput : configuredOutput?.json) ?? + '.vitest/json/output.json', + ); + // Vitest 5 writes JSON directly instead of calling writeReport. Use an + // explicit path so this reporter also works for the web suite on Vitest 4. + this.options.outputFile = outputFile; await super.onTestRunEnd(modules); - } - - override async writeReport(json: string): Promise { - const report = JSON.parse(json); - await super.writeReport( + const report = JSON.parse(await readFile(outputFile, 'utf8')); + await writeFile( + outputFile, JSON.stringify({ ...report, - success: report.success && this.executionErrors === 0 && this.executionReason === 'passed', - executionErrors: this.executionErrors, - executionReason: this.executionReason, + success: report.success && errors.length === 0 && reason === 'passed', + executionErrors: errors.length, + executionReason: reason, }), + 'utf8', ); } } diff --git a/gitnexus/test/integration/analyze-staged-embedding-recovery.test.ts b/gitnexus/test/integration/analyze-staged-embedding-recovery.test.ts index dbb5b893e..33db6b36b 100644 --- a/gitnexus/test/integration/analyze-staged-embedding-recovery.test.ts +++ b/gitnexus/test/integration/analyze-staged-embedding-recovery.test.ts @@ -320,9 +320,10 @@ afterAll(async () => { }); // Atomic publication is POSIX-specific; Windows uses the in-place path. -describe - .skipIf(process.platform === 'win32') - .sequential('interrupted staged embedding recovery (real CLI and native DB)', () => { +describe.skipIf(process.platform === 'win32')( + 'interrupted staged embedding recovery (real CLI and native DB)', + { concurrent: false }, + () => { it.each([ ['plain', []], ['forced', ['--force', '--embeddings']], @@ -446,4 +447,5 @@ describe }, DEADLINE, ); - }); + }, +); diff --git a/gitnexus/test/unit/lbug-pool-storage-version-lock-retry.test.ts b/gitnexus/test/unit/lbug-pool-storage-version-lock-retry.test.ts index aef8867de..7818e26ac 100644 --- a/gitnexus/test/unit/lbug-pool-storage-version-lock-retry.test.ts +++ b/gitnexus/test/unit/lbug-pool-storage-version-lock-retry.test.ts @@ -136,7 +136,10 @@ describe('pool-adapter storage-version fail-fast and lock-retry backoff', () => return goodDb(); }); - const lockedInit = initLbug(lockedId, lockedPath); + // Handle the expected rejection before advancing the retry timers. + const lockedRejection = expect(initLbug(lockedId, lockedPath)).rejects.toThrow( + /LadybugDB unavailable for locked/, + ); await vi.waitFor(() => { expect(createLbugDatabaseMock).toHaveBeenCalledTimes(1); }); @@ -151,7 +154,9 @@ describe('pool-adapter storage-version fail-fast and lock-retry backoff', () => expect(createLbugDatabaseMock).toHaveBeenCalledTimes(3); }); await vi.advanceTimersByTimeAsync(4000); - await expect(lockedInit).rejects.toThrow(/LadybugDB unavailable for locked/); + // Leave a turn for Node to detect any rejection without a handler. + await new Promise((resolve) => setImmediate(resolve)); + await lockedRejection; expect(createLbugDatabaseMock).toHaveBeenCalledTimes(4); }); }); diff --git a/gitnexus/test/unit/mcp-read-only.test.ts b/gitnexus/test/unit/mcp-read-only.test.ts index fd27d93ef..07d01a567 100644 --- a/gitnexus/test/unit/mcp-read-only.test.ts +++ b/gitnexus/test/unit/mcp-read-only.test.ts @@ -145,7 +145,7 @@ describe('MCP read-only mode', () => { it.each([ ['impact', { target: 'auth', direction: 'upstream', crossDepth: 5 }], ['impact', { target: 'auth', direction: 'upstream', subgroup: 'services' }], - ])('rejects group-only arguments before backend dispatch: %s %o', async (name, args) => { + ])('rejects group-only arguments before dispatch #%#: %s %o', async (name, args) => { enableReadOnly(); const session = await connect(); try { diff --git a/gitnexus/test/unit/mcp-repository-policy.test.ts b/gitnexus/test/unit/mcp-repository-policy.test.ts index 1e4322837..c1eb743ce 100644 --- a/gitnexus/test/unit/mcp-repository-policy.test.ts +++ b/gitnexus/test/unit/mcp-repository-policy.test.ts @@ -192,7 +192,7 @@ describe('MCP repository policy', () => { [{ GITNEXUS_MCP_ALLOWED_REPOS: 'Missing' }, 'invalid'], [{ GITNEXUS_MCP_ALLOWED_REPOS: 'Duplicate' }, 'ambiguous'], [{ GITNEXUS_MCP_DEFAULT_REPO: 'Duplicate' }, 'ambiguous'], - ])('fails startup with a sanitized %s configuration error', async (env, reason) => { + ])('fails startup with a sanitized %s configuration error (case %#)', async (env, reason) => { const backend = createBackend(); let message = ''; try { diff --git a/gitnexus/test/unit/storage/shared-store.test.ts b/gitnexus/test/unit/storage/shared-store.test.ts index 0ca91f9d6..9be52ef0a 100644 --- a/gitnexus/test/unit/storage/shared-store.test.ts +++ b/gitnexus/test/unit/storage/shared-store.test.ts @@ -133,7 +133,7 @@ describe('resolveSharedStoreKey', () => { [{ [SHARED_STORE_ENV]: '0' }], [{ [STORAGE_PATH_ENV]: '/tmp/explicit-index' }], [{ [STORAGE_ROOT_ENV]: '/tmp/index-root' }], - ])('returns null for every checkout when disabled by %o', async (env) => { + ])('returns null for every checkout when disabled by %o (case %#)', async (env) => { const { main, wts } = await makeRepo(['wt']); expect(isSharedStoreDisabled(env)).toBe(true); expect(resolveSharedStoreKey(main, env)).toBeNull(); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 73da0755f..77b457172 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -8,6 +8,8 @@ export default defineConfig({ hookTimeout: 120000, pool: 'forks', globals: true, + // Keep the Vitest 4 mock lifecycle while upgrading the runner to Vitest 5. + clearMocks: false, // Stable test identity across the Linux/macOS/Windows execution receipts. includeTaskLocation: true, teardownTimeout: 3000,