From 22f61f80c1de0a188468defcc690df2793174e60 Mon Sep 17 00:00:00 2001 From: Aadivya Raushan <96186374+aadivyaraushan@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:30:53 +0530 Subject: [PATCH 1/2] fix: reject short embeddings, stop guessing main, escape transcript tags (C1/C4/C6) Part C correctness from #145: fail closed on incomplete embedBatch results, resolve default branch via ls-remote or unknown, and neutralize early-close tags in working-memory prompts so pasted transcript content cannot break the evidence fence. Co-authored-by: Cursor --- apps/cli/repo-context.ts | 12 +- libs/hooks/worker.ts | 13 +- .../graph-context/context-builder.ts | 46 ++++- package.json | 2 +- scripts/check-embed-batch-length.js | 171 ++++++++++++++++++ scripts/check-repo-context.js | 27 ++- scripts/check-worker-transcript-escape.js | 41 +++++ 7 files changed, 300 insertions(+), 12 deletions(-) create mode 100644 scripts/check-embed-batch-length.js create mode 100644 scripts/check-worker-transcript-escape.js diff --git a/apps/cli/repo-context.ts b/apps/cli/repo-context.ts index c74716f..f282d71 100644 --- a/apps/cli/repo-context.ts +++ b/apps/cli/repo-context.ts @@ -39,7 +39,17 @@ function defaultBranch(repoRoot: string): string { const remoteHead = gitOptional(repoRoot, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]); if (remoteHead?.startsWith("origin/")) return remoteHead.slice("origin/".length); - return "main"; + const lsRemoteSymref = gitOptional(repoRoot, ["ls-remote", "--symref", "origin", "HEAD"]); + const fromLsRemote = parseLsRemoteSymrefHead(lsRemoteSymref); + if (fromLsRemote !== undefined) return fromLsRemote; + + return "unknown"; +} + +function parseLsRemoteSymrefHead(output: string | undefined): string | undefined { + if (output === undefined) return undefined; + const match = output.match(/^ref:\s+refs\/heads\/([^\s\t]+)/m); + return match?.[1]; } function repoName(remoteUrl: string, repoRoot: string): string { diff --git a/libs/hooks/worker.ts b/libs/hooks/worker.ts index 6b9d245..fe0a720 100644 --- a/libs/hooks/worker.ts +++ b/libs/hooks/worker.ts @@ -102,12 +102,13 @@ async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttempt): Pr } } -function updateWorkingMemoryPrompt( +export function updateWorkingMemoryPrompt( transcriptMarkdown: string, attempt: ClaimedMemoryUpdateAttempt, sessionRef: string, proposalPath: string, ): string { + const escapedTranscript = escapeFilteredSessionTranscript(transcriptMarkdown.trim()); return `Run the greplica-update-working-memory skill for a completed coding-agent session. If your runtime supports slash-command skills, invoke /greplica-update-working-memory for this task. Use the filtered session transcript below as the session context. It has been projected to Markdown with session metadata and human/agent text messages only. @@ -128,11 +129,19 @@ Session: - due_reason: ${attempt.reason} -${transcriptMarkdown.trim()} +${escapedTranscript} `; } +/** Neutralize literal closing tags so pasted transcript content cannot break the evidence fence. */ +function escapeFilteredSessionTranscript(transcriptMarkdown: string): string { + return transcriptMarkdown.replaceAll( + "", + "", + ); +} + function safePathSegment(value: string): string { return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "unknown"; } diff --git a/libs/knowledge-graph/graph-context/context-builder.ts b/libs/knowledge-graph/graph-context/context-builder.ts index 0a43b02..49aae38 100644 --- a/libs/knowledge-graph/graph-context/context-builder.ts +++ b/libs/knowledge-graph/graph-context/context-builder.ts @@ -25,6 +25,8 @@ export interface BuildGraphContextOptions { config?: GraphContextConfig; repoRoot?: string; resolveCodeAnchors?: boolean; + /** Optional embedder override (tests / hermetic callers). */ + embedder?: Embedder; } interface ExistingEmbedding { @@ -44,7 +46,7 @@ export class GraphContextBuilder { const flowDocuments = buildFlowDocuments(graph); const evidenceByClaim = buildEvidenceByClaim(graph); const documents = [...claimDocuments, ...componentDocuments, ...flowDocuments]; - const embedder = createEmbedder(config.embedding); + const embedder = options.embedder ?? createEmbedder(config.embedding); const embeddingStatus = await this.ensureEmbeddings(repoId, documents, embedder, config); if (options.warnOnCreatedEmbeddings && embeddingStatus.created > 0) { console.warn(`graph context created ${embeddingStatus.created} missing embedding(s); proposal apply should normally pre-create them.`); @@ -120,14 +122,23 @@ export class GraphContextBuilder { }; } - async ensureForGraph(repoId: string, graph: GraphReadResult, config: GraphContextConfig = graphContextConfig): Promise { + async ensureForGraph( + repoId: string, + graph: GraphReadResult, + config: GraphContextConfig = graphContextConfig, + embedder?: Embedder, + ): Promise { const documents = [ ...buildClaimDocuments(graph), ...buildComponentDocuments(graph), ...buildFlowDocuments(graph), ]; - const embedder = createEmbedder(config.embedding); - return this.ensureEmbeddings(repoId, documents, embedder, config); + return this.ensureEmbeddings( + repoId, + documents, + embedder ?? createEmbedder(config.embedding), + config, + ); } private async ensureEmbeddings( @@ -149,6 +160,7 @@ export class GraphContextBuilder { ); const missing = documents.filter((document) => !existing.has(document.key)); const vectors = await embedder.embedBatch(missing.map((document) => document.text)); + assertEmbedBatchComplete(vectors, missing.length, config.embedding.dimensions); repository.insertGraphObjectEmbeddings( missing.map((document, index) => ({ @@ -158,7 +170,7 @@ export class GraphContextBuilder { provider: config.embedding.provider, model: config.embedding.model, dimensions: config.embedding.dimensions, - embedding: float32ArrayToBuffer(vectors[index] ?? []), + embedding: float32ArrayToBuffer(vectors[index]), })), ); @@ -224,6 +236,30 @@ export class GraphContextBuilder { } } +function assertEmbedBatchComplete( + vectors: number[][], + expectedCount: number, + dimensions: number, +): void { + if (vectors.length !== expectedCount) { + throw new Error( + `embedBatch returned ${vectors.length} vector(s); expected ${expectedCount}.`, + ); + } + + for (let index = 0; index < vectors.length; index += 1) { + const vector = vectors[index]; + if (vector === undefined || vector.length === 0) { + throw new Error(`embedBatch returned an empty vector at index ${index}.`); + } + if (vector.length !== dimensions) { + throw new Error( + `embedBatch vector at index ${index} has length ${vector.length}; expected ${dimensions}.`, + ); + } + } +} + function buildEvidenceByClaim(graph: GraphReadResult): Map { const sources = new Map(graph.sources.map((source) => [source.id, source])); const evidenceByClaim = new Map(); diff --git a/package.json b/package.json index 4c46f31..c236e7d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs", "smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs", "smoke:cursor": "npm run build && node scripts/smoke-cursor-install.mjs", - "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js", + "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-embed-batch-length.js && node scripts/check-worker-transcript-escape.js", "test:repo-installations": "npm run build && node scripts/check-repo-installations.js", "test:transcript-bundle": "npm run build && node scripts/check-transcript-bundle.js", "test:repo-context": "npm run build && node scripts/check-repo-context.js", diff --git a/scripts/check-embed-batch-length.js b/scripts/check-embed-batch-length.js new file mode 100644 index 0000000..56eb21c --- /dev/null +++ b/scripts/check-embed-batch-length.js @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url); +const { openDatabase } = await import(new URL("dist/libs/storage/sqlite/db.js", root)); +const { SqliteRepository } = await import(new URL("dist/libs/storage/sqlite/repository.js", root)); +const { GraphContextBuilder } = await import( + new URL("dist/libs/knowledge-graph/graph-context/context-builder.js", root) +); + +const tmp = mkdtempSync(join(tmpdir(), "greplica-embed-batch-")); +const db = openDatabase(join(tmp, "graph.db")); +const repository = new SqliteRepository(db); +const builder = new GraphContextBuilder(repository); + +const repo = { + repo_root: tmp, + repo_name: "embed-batch-check", + default_branch: "main", +}; +const { id: repoId } = repository.upsertRepo(repo).repo; + +const dimensions = 8; +const config = { + version: "test-embed-batch", + embedding: { + provider: "local", + model: "test-model", + dimensions, + batchSize: 16, + }, + ranking: { + semanticThreshold: 0.1, + selectionThreshold: 0.8, + packetMinimumScore: 0.15, + packetAdditionalDirectScoreFloor: 0.15, + minimumSelectedClaims: 1, + weights: { semantic: 1, bm25: 0 }, + bm25: { k1: 1.5, b: 0.75 }, + claimSupport: { weight: 0, countBoost: 0 }, + directObject: { weight: 0 }, + graphBoost: { + claimAboutTarget: 0, + containsParentToChild: 0, + containsChildToParent: 0, + touchesComponentToFlow: 0, + maxSources: 1, + }, + packetHubPenalty: { + weight: 0, + graphScoreThreshold: 0, + claimSupportThreshold: 0, + bm25Threshold: 0, + semanticThreshold: 0, + coherenceThreshold: 0, + }, + coherence: { + weight: 0, + neighborThreshold: 0, + degreePenalty: 0, + aboutWeight: 0, + touchesWeight: 0, + containsWeight: 0, + maxSources: 1, + }, + }, + dedupe: { similarityThreshold: 0.75 }, +}; + +const graph = { + components: [], + flows: [], + claims: [ + { + id: "claim.one", + kind: "fact", + text: "first claim text for embedding", + truth: "source_verified", + intent: "intended", + }, + { + id: "claim.two", + kind: "fact", + text: "second claim text for embedding", + truth: "source_verified", + intent: "intended", + }, + ], + sources: [], + edges: [], +}; + +const shortBatchEmbedder = { + async embed() { + return Array.from({ length: dimensions }, () => 0.1); + }, + async embedBatch(texts) { + // Deliberately return fewer vectors than texts. + return texts.slice(0, Math.max(0, texts.length - 1)).map(() => Array.from({ length: dimensions }, () => 0.2)); + }, +}; + +await assert.rejects( + () => builder.ensureForGraph(repoId, graph, config, shortBatchEmbedder), + (error) => { + assert.ok(error instanceof Error); + assert.match(error.message, /embedBatch|vector/i); + return true; + }, +); + +const rowsAfterShortBatch = repository.listGraphObjectEmbeddings({ + repo_id: repoId, + provider: config.embedding.provider, + model: config.embedding.model, + dimensions: config.embedding.dimensions, +}); +assert.equal(rowsAfterShortBatch.length, 0, "short embedBatch must not insert embedding rows"); + +const emptyVectorEmbedder = { + async embed() { + return Array.from({ length: dimensions }, () => 0.1); + }, + async embedBatch(texts) { + return texts.map(() => []); + }, +}; + +await assert.rejects( + () => builder.ensureForGraph(repoId, graph, config, emptyVectorEmbedder), + (error) => { + assert.ok(error instanceof Error); + assert.match(error.message, /empty|length|dimension|vector/i); + return true; + }, +); + +const rowsAfterEmpty = repository.listGraphObjectEmbeddings({ + repo_id: repoId, + provider: config.embedding.provider, + model: config.embedding.model, + dimensions: config.embedding.dimensions, +}); +assert.equal(rowsAfterEmpty.length, 0, "empty vectors must not insert embedding rows"); + +const healthyEmbedder = { + async embed() { + return Array.from({ length: dimensions }, () => 0.1); + }, + async embedBatch(texts) { + return texts.map((_, index) => Array.from({ length: dimensions }, () => 0.01 * (index + 1))); + }, +}; + +const status = await builder.ensureForGraph(repoId, graph, config, healthyEmbedder); +assert.equal(status.created, 2); +const rowsHealthy = repository.listGraphObjectEmbeddings({ + repo_id: repoId, + provider: config.embedding.provider, + model: config.embedding.model, + dimensions: config.embedding.dimensions, +}); +assert.equal(rowsHealthy.length, 2); +for (const row of rowsHealthy) { + assert.ok(row.embedding.byteLength > 0, "healthy embeddings must be non-empty BLOBs"); +} + +db.close(); +console.log("Embed batch length checks passed."); diff --git a/scripts/check-repo-context.js b/scripts/check-repo-context.js index 410a9eb..482f7ab 100644 --- a/scripts/check-repo-context.js +++ b/scripts/check-repo-context.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, realpathSync } from "node:fs"; +import { mkdtempSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; @@ -37,9 +37,30 @@ const branchContext = detectRepoContext(branchRepo); assert.equal(branchContext.default_branch, "trunk"); const noRemoteHeadRepo = initRepo(join(tmp, "no-remote-head-repo")); -git(noRemoteHeadRepo, "remote", "add", "origin", "https://github.com/Autoloops/greplica.git"); +git(noRemoteHeadRepo, "remote", "add", "origin", `file://${join(tmp, "missing-remote.git")}`); const noRemoteHeadContext = detectRepoContext(noRemoteHeadRepo); -assert.equal(noRemoteHeadContext.default_branch, "main"); +assert.equal(noRemoteHeadContext.default_branch, "unknown"); + +const bareOrigin = join(tmp, "bare-origin.git"); +git(tmp, "init", "--bare", "--quiet", bareOrigin); +git(bareOrigin, "symbolic-ref", "HEAD", "refs/heads/develop"); +const commitRepo = initRepo(join(tmp, "commit-for-bare")); +writeFileSync(join(commitRepo, "README"), "develop tip\n"); +git(commitRepo, "add", "README"); +git(commitRepo, "-c", "user.name=Greplica Test", "-c", "user.email=test@example.com", "commit", "-m", "init"); +git(commitRepo, "branch", "-M", "develop"); +git(commitRepo, "remote", "add", "origin", bareOrigin); +git(commitRepo, "push", "--quiet", "origin", "develop"); +git(bareOrigin, "symbolic-ref", "HEAD", "refs/heads/develop"); + +const lsRemoteRepo = initRepo(join(tmp, "ls-remote-default-repo")); +git(lsRemoteRepo, "remote", "add", "origin", bareOrigin); +const lsRemoteContext = detectRepoContext(lsRemoteRepo); +assert.equal( + lsRemoteContext.default_branch, + "develop", + "when origin/HEAD is missing, ls-remote --symref should resolve the remote default branch", +); console.log("Repo context checks passed."); diff --git a/scripts/check-worker-transcript-escape.js b/scripts/check-worker-transcript-escape.js new file mode 100644 index 0000000..b42f783 --- /dev/null +++ b/scripts/check-worker-transcript-escape.js @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; + +const root = new URL("..", import.meta.url); +const { updateWorkingMemoryPrompt } = await import(new URL("dist/libs/hooks/worker.js", root)); + +const earlyClose = "before after hostile content"; +const attempt = { + reason: "test", + session: { + platform: "cursor", + session_id: "sess-escape-1", + }, +}; +const prompt = updateWorkingMemoryPrompt(earlyClose, attempt, "cursor:sess-escape-1", "/tmp/proposal.json"); + +assert.ok(prompt.includes(""), "opening tag must remain"); +assert.ok(prompt.includes(""), "real closing tag must remain"); + +const openTag = ""; +const closeTag = ""; +const openIndex = prompt.indexOf(openTag); +const closeIndex = prompt.lastIndexOf(closeTag); +assert.ok(openIndex >= 0); +assert.ok(closeIndex > openIndex); + +const inner = prompt.slice(openIndex + openTag.length, closeIndex); +assert.equal( + inner.includes(closeTag), + false, + "transcript body must not contain a raw early-close sequence", +); +assert.ok( + inner.includes("before") && inner.includes("after hostile content"), + "escaped transcript should still preserve surrounding text", +); + +// Only one structural close tag for the evidence fence (the real closing tag). +const closeMatches = prompt.match(/<\/filtered_session_transcript>/g) ?? []; +assert.equal(closeMatches.length, 1, "exactly one raw closing tag should remain in the prompt"); + +console.log("Worker transcript escape checks passed."); From d8d6ae5dd3f7311d1f801fc47b9a283d3733156d Mon Sep 17 00:00:00 2001 From: Aadivya Raushan <96186374+aadivyaraushan@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:30:18 +0530 Subject: [PATCH 2/2] fix: harden C4 timeout/scopes and cover wrong-dimension embeddings Add a 5s timeout on ls-remote, reuse/rename the existing main scope when default_branch changes, and reject wrong-length embedding vectors in tests. Co-authored-by: Cursor --- apps/cli/repo-context.ts | 12 ++++++-- libs/storage/sqlite/repository.ts | 20 ++++++++++++ package.json | 2 +- scripts/check-embed-batch-length.js | 26 ++++++++++++++++ scripts/check-main-scope-reuse.js | 47 +++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 scripts/check-main-scope-reuse.js diff --git a/apps/cli/repo-context.ts b/apps/cli/repo-context.ts index f282d71..d2ecf0e 100644 --- a/apps/cli/repo-context.ts +++ b/apps/cli/repo-context.ts @@ -39,7 +39,10 @@ function defaultBranch(repoRoot: string): string { const remoteHead = gitOptional(repoRoot, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]); if (remoteHead?.startsWith("origin/")) return remoteHead.slice("origin/".length); - const lsRemoteSymref = gitOptional(repoRoot, ["ls-remote", "--symref", "origin", "HEAD"]); + // Network call — keep a short timeout so install/doctor/graph cannot hang on a bad remote. + const lsRemoteSymref = gitOptional(repoRoot, ["ls-remote", "--symref", "origin", "HEAD"], { + timeoutMs: 5_000, + }); const fromLsRemote = parseLsRemoteSymrefHead(lsRemoteSymref); if (fromLsRemote !== undefined) return fromLsRemote; @@ -58,12 +61,17 @@ function repoName(remoteUrl: string, repoRoot: string): string { return lastPart ?? basename(repoRoot); } -function gitOptional(cwd: string, args: string[]): string | undefined { +function gitOptional( + cwd: string, + args: string[], + options: { timeoutMs?: number } = {}, +): string | undefined { try { const output = execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + ...(options.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), }).trim(); return output.length > 0 ? output : undefined; } catch { diff --git a/libs/storage/sqlite/repository.ts b/libs/storage/sqlite/repository.ts index 2b43f28..498c9f3 100644 --- a/libs/storage/sqlite/repository.ts +++ b/libs/storage/sqlite/repository.ts @@ -224,6 +224,26 @@ export class SqliteRepository implements GraphReadRepository { } ensureScope(input: CreateScopeInput): GraphScope { + // One main scope per repo: reuse and rename/ref-update if default_branch changes + // (e.g. fabricated "main" → "unknown" or ls-remote-resolved name) instead of + // creating orphan kind=main rows keyed by the new branch string. + if (input.kind === "main") { + const existingMain = this.db + .prepare( + "SELECT * FROM graph_scopes WHERE repo_id = ? AND kind = 'main' ORDER BY created_at LIMIT 1", + ) + .get(input.repo_id) as GraphScope | undefined; + if (existingMain) { + if (existingMain.name !== input.name || existingMain.ref !== input.ref) { + this.db + .prepare("UPDATE graph_scopes SET name = ?, ref = ? WHERE id = ?") + .run(input.name, input.ref, existingMain.id); + return { ...existingMain, name: input.name, ref: input.ref }; + } + return existingMain; + } + } + const existing = this.db .prepare("SELECT * FROM graph_scopes WHERE repo_id = ? AND kind = ? AND name = ?") .get(input.repo_id, input.kind, input.name) as GraphScope | undefined; diff --git a/package.json b/package.json index c236e7d..a117fcb 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs", "smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs", "smoke:cursor": "npm run build && node scripts/smoke-cursor-install.mjs", - "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-embed-batch-length.js && node scripts/check-worker-transcript-escape.js", + "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-embed-batch-length.js && node scripts/check-worker-transcript-escape.js && node scripts/check-main-scope-reuse.js", "test:repo-installations": "npm run build && node scripts/check-repo-installations.js", "test:transcript-bundle": "npm run build && node scripts/check-transcript-bundle.js", "test:repo-context": "npm run build && node scripts/check-repo-context.js", diff --git a/scripts/check-embed-batch-length.js b/scripts/check-embed-batch-length.js index 56eb21c..c9aa203 100644 --- a/scripts/check-embed-batch-length.js +++ b/scripts/check-embed-batch-length.js @@ -145,6 +145,32 @@ const rowsAfterEmpty = repository.listGraphObjectEmbeddings({ }); assert.equal(rowsAfterEmpty.length, 0, "empty vectors must not insert embedding rows"); +const wrongDimensionEmbedder = { + async embed() { + return Array.from({ length: dimensions }, () => 0.1); + }, + async embedBatch(texts) { + return texts.map(() => Array.from({ length: dimensions - 1 }, () => 0.3)); + }, +}; + +await assert.rejects( + () => builder.ensureForGraph(repoId, graph, config, wrongDimensionEmbedder), + (error) => { + assert.ok(error instanceof Error); + assert.match(error.message, /dimension|length|vector/i); + return true; + }, +); + +const rowsAfterWrongDim = repository.listGraphObjectEmbeddings({ + repo_id: repoId, + provider: config.embedding.provider, + model: config.embedding.model, + dimensions: config.embedding.dimensions, +}); +assert.equal(rowsAfterWrongDim.length, 0, "wrong-dimension vectors must not insert embedding rows"); + const healthyEmbedder = { async embed() { return Array.from({ length: dimensions }, () => 0.1); diff --git a/scripts/check-main-scope-reuse.js b/scripts/check-main-scope-reuse.js new file mode 100644 index 0000000..1911e31 --- /dev/null +++ b/scripts/check-main-scope-reuse.js @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Caller: package.json "test". Covers ensureScope main-scope reuse when default_branch changes. +// User: follow-up commits for code-review high/medium/low items. + +const root = new URL("..", import.meta.url); +const { openDatabase } = await import(new URL("dist/libs/storage/sqlite/db.js", root)); +const { SqliteRepository } = await import(new URL("dist/libs/storage/sqlite/repository.js", root)); + +const tmp = mkdtempSync(join(tmpdir(), "greplica-main-scope-reuse-")); +const db = openDatabase(join(tmp, "graph.db")); +const repository = new SqliteRepository(db); + +const { repo } = repository.upsertRepo({ + repo_root: tmp, + repo_name: "scope-reuse", + default_branch: "main", +}); + +const mainFirst = repository.ensureScope({ + repo_id: repo.id, + kind: "main", + name: "main", + ref: "main", +}); +assert.equal(mainFirst.name, "main"); + +const mainSecond = repository.ensureScope({ + repo_id: repo.id, + kind: "main", + name: "unknown", + ref: "unknown", +}); +assert.equal(mainSecond.id, mainFirst.id, "must reuse the same main scope id"); +assert.equal(mainSecond.name, "unknown"); +assert.equal(mainSecond.ref, "unknown"); + +const mainCount = db + .prepare("SELECT COUNT(*) AS c FROM graph_scopes WHERE repo_id = ? AND kind = 'main'") + .get(repo.id).c; +assert.equal(mainCount, 1, "re-init with a new default_branch must not create a second main scope"); + +db.close(); +console.log("Main scope reuse checks passed.");