/**
 * graph-store tests — ordered steps 2–3 of issue #1552 PR1.
 *
 * Cover:
 *   - upsertFileBatch: ingest counts (exact node + edge counts from hand-
 *     written fixture IR)
 *   - idempotency: re-ingesting the same IR yields identical state
 *   - dangling-edge handling: cross-file edges whose destination was
 *     deleted are DROPPED (per the PR1 policy documented in graph-schema)
 *     and surfaced via droppedDanglingEdges
 *   - node-id determinism: nodeIdFor is pure (sorted key material sha256)
 *   - write queue serialization (rule 40)
 *   - tagged failure shape on a closed store (rule 34)
 */
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";

import {
  openBetterSqlite3,
  type BetterSqlite3Database,
} from "@remnic/core/runtime/better-sqlite";
import { GraphStore, nodeIdFor } from "./graph-store.js";
import type { StoreFileIR, SymbolIR } from "./graph-store.js";

// ──────────────────────────────────────────────────────────────────────────
// Fixture IR — synthetic code only (public-repo policy).
// ──────────────────────────────────────────────────────────────────────────

function sym(
  qualifiedName: string,
  name: string,
  startByte: number,
  endByte: number,
  kind: SymbolIR["kind"] = "function",
): SymbolIR {
  return { qualifiedName, name, span: { startByte, endByte }, kind };
}

const fileA: StoreFileIR = {
  path: "src/a.ts",
  language: "typescript",
  contentHash: "h-a",
  symbols: [
    sym("a.greet", "greet", 0, 100),
    sym("a.farewell", "farewell", 100, 200),
  ],
  edges: [
    {
      srcQualifiedName: "a.greet",
      dstQualifiedName: "a.farewell",
      type: "CALLS",
      confidence: 0.95,
      provenance: "heuristic",
    },
  ],
};

const fileB: StoreFileIR = {
  path: "src/b.ts",
  language: "typescript",
  contentHash: "h-b",
  symbols: [
    sym("b.run", "run", 0, 50),
    sym("b.helper", "helper", 50, 150),
  ],
  edges: [
    {
      srcQualifiedName: "b.run",
      dstQualifiedName: "b.helper",
      type: "CALLS",
      confidence: 0.9,
      provenance: "heuristic",
    },
    {
      // Cross-file edge — destination lives in fileA.
      srcQualifiedName: "b.run",
      dstQualifiedName: "a.greet",
      type: "CALLS",
      confidence: 0.6,
      provenance: "heuristic",
    },
  ],
};

async function tempStore(): Promise<{ store: GraphStore; dir: string }> {
  const dir = await mkdtemp(path.join(tmpdir(), "coding-graph-store-"));
  const store = await GraphStore.open({
    dbPath: path.join(dir, "graph.sqlite"),
  });
  return { store, dir };
}

test("upsertFileBatch: ingest two-file fixture yields exact node + edge counts", async () => {
  const { store, dir } = await tempStore();
  try {
    assert.equal(store.schemaVersion(), 1);

    const result = await store.upsertFileBatch([fileA, fileB]);
    assert.equal(result.ok, true);
    if (!result.ok) throw new Error("expected ok result");
    assert.equal(result.results.length, 2);

    const a = result.results.find((r) => r.path === "src/a.ts");
    const b = result.results.find((r) => r.path === "src/b.ts");
    assert.ok(a, "expected result for fileA");
    assert.ok(b, "expected result for fileB");
    assert.equal(a?.nodeCount, 2, "fileA: 2 symbols");
    assert.equal(a?.edgeCount, 1, "fileA: 1 intra-file edge");
    assert.equal(b?.nodeCount, 2, "fileB: 2 symbols");
    assert.equal(b?.edgeCount, 2, "fileB: 1 intra + 1 cross-file edge");
    assert.equal(a?.droppedDanglingEdges, 0, "no dangling edges on first ingest");
    assert.equal(b?.droppedDanglingEdges, 0);

    // Re-open and verify the rows persisted.
    await store.close();
    const reopened = await GraphStore.open({
      dbPath: path.join(dir, "graph.sqlite"),
    });
    assert.equal(reopened.schemaVersion(), 1);
    await reopened.close();
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test("idempotency: re-ingesting the same IR is a no-op (counts and ids unchanged)", async () => {
  const { store, dir } = await tempStore();
  try {
    const first = await store.upsertFileBatch([fileA, fileB]);
    assert.equal(first.ok, true);
    if (!first.ok) throw new Error("expected ok result");

    // Compute the expected ids deterministically.
    const expectedA1 = nodeIdFor({
      qualifiedName: "a.greet",
      filePath: "src/a.ts",
      label: "function",
    });
    const expectedB1 = nodeIdFor({
      qualifiedName: "b.run",
      filePath: "src/b.ts",
      label: "function",
    });

    // Second ingest — same IR — must not change row counts nor ids.
    const second = await store.upsertFileBatch([fileA, fileB]);
    assert.equal(second.ok, true);
    if (!second.ok) throw new Error("expected ok result");
    for (const r of second.results) {
      assert.equal(r.nodeCount, 0, "no new nodes when ids collide on PK");
      assert.equal(r.edgeCount, 0, "no new edges when (src,dst,type) collides");
      assert.equal(r.droppedDanglingEdges, 0);
    }

    // The rows still hash to the same deterministic ids.
    assert.equal(expectedA1.length, 64, "sha256 hex digest length");
    assert.equal(expectedB1.length, 64);
    assert.notEqual(expectedA1, expectedB1, "different symbols → different ids");
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("dangling-edge policy: cross-file edges whose dst is dropped are counted, not kept", async () => {
  const { store, dir } = await tempStore();
  try {
    // Initial batch — cross-file edge b.run → a.greet is fine.
    const r1 = await store.upsertFileBatch([fileA, fileB]);
    assert.equal(r1.ok, true);
    if (!r1.ok) throw new Error("expected ok result");

    // Re-ingest fileA only with a stripped symbol set: a.greet is gone, so
    // the cross-file edge from b.run → a.greet becomes dangling.
    const fileAReduced: StoreFileIR = {
      path: "src/a.ts",
      language: "typescript",
      contentHash: "h-a-2",
      symbols: [sym("a.farewell", "farewell", 100, 200)],
      // No edges — keeps the test focused on the dangling count.
      edges: [],
    };
    const r2 = await store.upsertFileBatch([fileAReduced]);
    assert.equal(r2.ok, true);
    if (!r2.ok) throw new Error("expected ok result");
    assert.equal(r2.results.length, 1);
    const a = r2.results[0]!;
    assert.equal(a.path, "src/a.ts");
    assert.equal(a.droppedDanglingEdges, 1, "one cross-file edge pointed at a.greet");
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("dangling-edge count is batch-order-independent when both ends are pruned (chatgpt-codex-connector P2: 'Count dangling edges against the whole batch')", async () => {
  // Two files share one cross-file edge x.src → y.dst. A re-ingest
  // batch that prunes BOTH ends must report 0 dangling edges (the
  // edge is cascade-deleted, not dangling) regardless of which file
  // the loop visits first. The pre-fix per-file src exclusion made
  // this 1 in one order and 0 in the other.
  const fileXfull: StoreFileIR = {
    path: "src/x.ts",
    language: "typescript",
    contentHash: "h-x",
    symbols: [sym("x.src", "src", 0, 50)],
    edges: [
      {
        srcQualifiedName: "x.src",
        dstQualifiedName: "y.dst",
        type: "CALLS",
        confidence: 0.9,
        provenance: "heuristic",
      },
    ],
  };
  const fileYfull: StoreFileIR = {
    path: "src/y.ts",
    language: "typescript",
    contentHash: "h-y",
    symbols: [sym("y.dst", "dst", 0, 50)],
  };
  const fileXempty: StoreFileIR = {
    path: "src/x.ts",
    language: "typescript",
    contentHash: "h-x-2",
    symbols: [],
    edges: [],
  };
  const fileYempty: StoreFileIR = {
    path: "src/y.ts",
    language: "typescript",
    contentHash: "h-y-2",
    symbols: [],
  };

  async function droppedTotal(order: StoreFileIR[]): Promise<number> {
    const dir = await mkdtemp(path.join(tmpdir(), "cg-order-"));
    const store = await GraphStore.open({ dbPath: path.join(dir, "store.db") });
    try {
      await store.upsertFileBatch([fileXfull, fileYfull]);
      const pruned = await store.upsertFileBatch(order);
      assert.equal(pruned.ok, true);
      if (!pruned.ok) throw new Error("expected ok result");
      return pruned.results.reduce((sum, r) => sum + r.droppedDanglingEdges, 0);
    } finally {
      await store.close();
      await rm(dir, { recursive: true, force: true });
    }
  }

  // Both ends pruned in one batch — order must not change the total,
  // and the total must be 0 (the edge is cascade-deleted, not dangling).
  const totalXfirst = await droppedTotal([fileXempty, fileYempty]);
  const totalYfirst = await droppedTotal([fileYempty, fileXempty]);
  assert.equal(
    totalXfirst,
    totalYfirst,
    "droppedDanglingEdges total must be identical regardless of file order in the batch",
  );
  assert.equal(
    totalXfirst,
    0,
    "an edge whose both ends are pruned in the batch is cascade-deleted, not dangling",
  );

  // Control: pruning only the dst (src survives in a separate batch)
  // is a genuine dangling edge and must still be reported as 1.
  const dirCtl = await mkdtemp(path.join(tmpdir(), "cg-order-ctl-"));
  const storeCtl = await GraphStore.open({ dbPath: path.join(dirCtl, "store.db") });
  try {
    await storeCtl.upsertFileBatch([fileXfull, fileYfull]);
    const onlyDstPruned = await storeCtl.upsertFileBatch([fileYempty]);
    assert.equal(onlyDstPruned.ok, true);
    if (!onlyDstPruned.ok) throw new Error("expected ok result");
    assert.equal(
      onlyDstPruned.results.reduce((s, r) => s + r.droppedDanglingEdges, 0),
      1,
      "a one-ended prune (src survives) is a genuine dangling edge and is still counted",
    );
  } finally {
    await storeCtl.close();
    await rm(dirCtl, { recursive: true, force: true });
  }
});

test("fts_index: maps FTS rowid → node id so PR2 can JOIN hits back to nodes", async () => {
  const { store, dir } = await tempStore();
  let peekDb: BetterSqlite3Database | undefined;
  try {
    const r = await store.upsertFileBatch([fileA]);
    assert.equal(r.ok, true);
    if (!r.ok) throw new Error("expected ok");

    // Re-open the same file with a raw connection (after the store
    // closed its transaction) to peek at the fts_index mapping.
    await store.close();
    peekDb = openBetterSqlite3(path.join(dir, "graph.sqlite"));

    // Hit nodes_fts with the symbol name and JOIN to fts_index to
    // recover the node id — exactly the read path PR2 will use.
    type FtsHit = { node_id: string };
    const hits: FtsHit[] = peekDb
      .prepare(
        `SELECT fts_index.node_id AS node_id
           FROM nodes_fts
           JOIN fts_index ON fts_index.fts_rowid = nodes_fts.rowid
          WHERE nodes_fts MATCH ?`,
      )
      .all("greet") as FtsHit[];
    assert.equal(hits.length, 1, "one MATCH hit for 'greet'");
    const expected = nodeIdFor({
      qualifiedName: "a.greet",
      filePath: "src/a.ts",
      label: "function",
    });
    assert.equal(hits[0]?.node_id, expected);

    // The forward mapping (node id → fts_rowid) is the deterministic
    // hash slice used by the write path. Verify it round-trips.
    type IndexRow = { fts_rowid: number };
    const fwd: IndexRow[] = peekDb
      .prepare(
        "SELECT fts_rowid FROM fts_index WHERE node_id = ?",
      )
      .all(expected) as IndexRow[];
    assert.equal(fwd.length, 1);
  } finally {
    peekDb?.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("fts_index: prunes the mapping when a node is removed by re-ingest", async () => {
  const { store, dir } = await tempStore();
  let peekDb: BetterSqlite3Database | undefined;
  try {
    const r1 = await store.upsertFileBatch([fileA]);
    assert.equal(r1.ok, true);
    if (!r1.ok) throw new Error("expected ok");

    // Re-ingest fileA with an empty symbol set — every node is
    // pruned, so every fts_index row must follow.
    const empty: StoreFileIR = {
      path: "src/a.ts",
      language: "typescript",
      contentHash: "h-a-empty",
      symbols: [],
      edges: [],
    };
    const r2 = await store.upsertFileBatch([empty]);
    assert.equal(r2.ok, true);
    if (!r2.ok) throw new Error("expected ok");

    await store.close();
    peekDb = openBetterSqlite3(path.join(dir, "graph.sqlite"));

    type CountRow = { c: number };
    const ftsCount: CountRow[] = peekDb
      .prepare("SELECT COUNT(*) AS c FROM nodes_fts")
      .all() as CountRow[];
    const idxCount: CountRow[] = peekDb
      .prepare("SELECT COUNT(*) AS c FROM fts_index")
      .all() as CountRow[];
    assert.equal(ftsCount[0]?.c, 0, "no FTS rows survive the prune");
    assert.equal(idxCount[0]?.c, 0, "no fts_index rows survive the prune");
  } finally {
    peekDb?.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("nodeIdFor is pure and order-stable (rule 23/38)", () => {
  const a1 = nodeIdFor({
    qualifiedName: "mod.fn",
    filePath: "x.ts",
    label: "function",
  });
  const a2 = nodeIdFor({
    qualifiedName: "mod.fn",
    filePath: "x.ts",
    label: "function",
  });
  assert.equal(a1, a2, "same input → same id");

  // Different label → different id.
  const b = nodeIdFor({
    qualifiedName: "mod.fn",
    filePath: "x.ts",
    label: "method",
  });
  assert.notEqual(a1, b);

  // Different qualified name → different id.
  const c = nodeIdFor({
    qualifiedName: "mod.other",
    filePath: "x.ts",
    label: "function",
  });
  assert.notEqual(a1, c);

  // Different file path → different id.
  const d = nodeIdFor({
    qualifiedName: "mod.fn",
    filePath: "y.ts",
    label: "function",
  });
  assert.notEqual(a1, d);
});

test("nodeIdFor uses sha256 (64 hex chars)", () => {
  const id = nodeIdFor({
    qualifiedName: "x",
    filePath: "y",
    label: "z",
  });
  assert.equal(id.length, 64);
  assert.match(id, /^[0-9a-f]{64}$/);
});

test("write queue serializes concurrent upserts (rule 40)", async () => {
  const { store, dir } = await tempStore();
  try {
    // Fire N batches in parallel — the queue must execute them one after
    // the other without raising and without losing rows.
    const N = 8;
    const irs: StoreFileIR[] = Array.from({ length: N }, (_, i) => ({
      path: `src/file-${i}.ts`,
      language: "typescript",
      contentHash: `h-${i}`,
      symbols: [sym(`f-${i}.run`, "run", 0, 10)],
      edges: [],
    }));
    const settled = await Promise.all(
      irs.map((ir) => store.upsertFileBatch([ir])),
    );
    for (const r of settled) {
      assert.equal(r.ok, true, "every concurrent upsert must succeed");
      if (r.ok) {
        assert.equal(r.results[0]?.nodeCount, 1);
      }
    }

    // drain() resolves cleanly after all queued writes settle.
    await store.drain();
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("closed store returns tagged failure, never throws (rule 34)", async () => {
  const { store, dir } = await tempStore();
  await store.close();
  try {
    const result = await store.upsertFileBatch([fileA]);
    assert.equal(result.ok, false);
    if (result.ok) throw new Error("expected failure");
    assert.equal(
      result.code,
      "store_closed",
      `closed store should return "store_closed" lifecycle failure; got ${result.code}`,
    );
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

// ──────────────────────────────────────────────────────────────────────────
// Rule 22 — read primitives distinguish backend failure from empty.
// Before the fix, readMeta / readFileHashes / readCoChanges caught every
// error and returned the empty value (null / new Map() / []), so a
// SQLITE_BUSY or a closed store was indistinguishable from "key absent" /
// "empty index" / "no co-change edges". The reindex executor's prune +
// head-advance decisions depend on these reads, so conflating error with
// empty could skip pruning while advancing head, or prune against a
// falsely-empty set (cursor Bugbot HIGH: 'readFileHashes conflates error
// with empty'; 'readCoChanges swallows store errors'; 'readMeta conflates
// absent key with db failure').
// ──────────────────────────────────────────────────────────────────────────

test("readMeta: closed store returns tagged failure, not null (rule 22)", async () => {
  const { store, dir } = await tempStore();
  await store.close();
  try {
    const r = store.readMeta("any_key");
    assert.equal(r.ok, false);
    if (r.ok) throw new Error("expected failure");
    assert.equal(r.code, "store_closed");
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test("readMeta: absent key returns { ok: true; value: null } — distinct from failure (rule 22)", async () => {
  const { store, dir } = await tempStore();
  try {
    const r = store.readMeta("absent_key");
    assert.equal(r.ok, true);
    if (!r.ok) throw new Error("expected ok");
    assert.equal(r.value, null);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test("readFileHashes: closed store returns tagged failure, not an empty Map (rule 22)", async () => {
  const { store, dir } = await tempStore();
  await store.close();
  try {
    const r = store.readFileHashes();
    assert.equal(r.ok, false);
    if (r.ok) throw new Error("expected failure");
    assert.equal(r.code, "store_closed");
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test("readFileHashes: empty index returns { ok: true; hashes: <empty> } — distinct from failure (rule 22)", async () => {
  const { store, dir } = await tempStore();
  try {
    const r = store.readFileHashes();
    assert.equal(r.ok, true);
    if (!r.ok) throw new Error("expected ok");
    assert.equal(r.hashes.size, 0);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test("readCoChanges: closed store returns tagged failure, not [] (rule 22)", async () => {
  const { store, dir } = await tempStore();
  await store.close();
  try {
    const r = store.readCoChanges("src/a.ts");
    assert.equal(r.ok, false);
    if (r.ok) throw new Error("expected failure");
    assert.equal(r.code, "store_closed");
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test("readCoChanges: no edges returns { ok: true; edges: [] } — distinct from failure (rule 22)", async () => {
  const { store, dir } = await tempStore();
  try {
    const r = store.readCoChanges("src/a.ts");
    assert.equal(r.ok, true);
    if (!r.ok) throw new Error("expected ok");
    assert.equal(r.edges.length, 0);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test("node-id ingestion: file-level delete cascades prior nodes + owned edges", async () => {
  const { store, dir } = await tempStore();
  try {
    const r1 = await store.upsertFileBatch([fileA]);
    assert.equal(r1.ok, true);
    if (!r1.ok) throw new Error("expected ok");

    // Re-ingest fileA with empty symbols — deletes both nodes.
    const fileAEmpty: StoreFileIR = {
      path: "src/a.ts",
      language: "typescript",
      contentHash: "h-a-empty",
      symbols: [],
      edges: [],
    };
    const r2 = await store.upsertFileBatch([fileAEmpty]);
    assert.equal(r2.ok, true);
    if (!r2.ok) throw new Error("expected ok");
    assert.equal(r2.results[0]?.nodeCount, 0);
    assert.equal(r2.results[0]?.edgeCount, 0);

    // Re-ingesting fileB should now see a cross-file edge b.run → a.greet
    // as a fresh insert (the prior dangling drop is gone since a.greet no
    // longer exists to receive the edge; the edge is skipped at insert time
    // because the dst cannot be resolved).
    const r3 = await store.upsertFileBatch([fileB]);
    assert.equal(r3.ok, true);
    if (!r3.ok) throw new Error("expected ok");
    // fileB has 2 symbols + 2 edges declared, but the cross-file edge's
    // dst (a.greet) is missing → 1 edge kept (intra-file).
    assert.equal(r3.results[0]?.nodeCount, 2);
    assert.equal(r3.results[0]?.edgeCount, 1);
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("absolute dbPath is required", async () => {
  // Path.resolve would also produce absolute; we need a deliberately
  // relative path so the validator throws — the previous version of this
  // test composed off mkdtemp, which always returned absolute, so the
  // assertion could never fire.
  const relative = `relative-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`;
  await assert.rejects(
    () =>
      GraphStore.open({
        dbPath: relative,
      }),
    /dbPath must be absolute/,
  );
});

test("edge src validation: cross-file edge src is rejected, not cross-owned (chatgpt-codex-connector P2)", async () => {
  const { store, dir } = await tempStore();
  try {
    // Seed fileB first so b.run exists in the DB.
    const r1 = await store.upsertFileBatch([fileB]);
    assert.equal(r1.ok, true);
    if (!r1.ok) throw new Error("expected ok");

    // Ingest fileA with an edge whose srcQualifiedName is "b.run" —
    // a symbol that lives in fileB, NOT fileA. The edge must be
    // DROPPED: a FileIR may only assert edges whose src is a symbol
    // in the same file. Without this guard the edge would be silently
    // cross-owned by fileB and survive fileA re-ingests.
    const fileAMalformedEdge: StoreFileIR = {
      path: "src/a.ts",
      language: "typescript",
      contentHash: "h-a-malformed",
      symbols: [sym("a.greet", "greet", 0, 100)],
      edges: [
        {
          srcQualifiedName: "b.run",
          dstQualifiedName: "a.greet",
          type: "CALLS",
          confidence: 0.5,
          provenance: "heuristic",
        },
      ],
    };
    const r2 = await store.upsertFileBatch([fileAMalformedEdge]);
    assert.equal(r2.ok, true);
    if (!r2.ok) throw new Error("expected ok");
    assert.equal(
      r2.results[0]?.edgeCount,
      0,
      "cross-file edge src must be rejected — no edges inserted",
    );

    // Verify no edge exists in the DB at all for this assertion.
    // The store exposes no public query API yet, so we go through
    // the re-ingest path: re-ingest fileA with no edges and confirm
    // no cross-owned edge was left behind.
    const fileAClean: StoreFileIR = {
      path: "src/a.ts",
      language: "typescript",
      contentHash: "h-a-clean",
      symbols: [sym("a.greet", "greet", 0, 100)],
      edges: [],
    };
    const r3 = await store.upsertFileBatch([fileAClean]);
    assert.equal(r3.ok, true);
    if (!r3.ok) throw new Error("expected ok");
    // If the cross-owned edge had been created, re-ingesting fileA
    // (which only deletes edges whose src is in fileA) would leave
    // it behind. But since the edge was never created, there is
    // nothing to clean up — edgeCount is 0.
    assert.equal(r3.results[0]?.edgeCount, 0);
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("duplicate paths in one batch throw (cursor Bugbot: 'Duplicate paths corrupt edge pass')", async () => {
  const dir = await mkdtemp(path.join(tmpdir(), "cg-dup-"));
  const store = await GraphStore.open({
    dbPath: path.join(dir, "store.db"),
  });
  try {
    const fileA1: StoreFileIR = {
      path: "src/a.ts",
      language: "typescript",
      contentHash: "h-a-1",
      symbols: [sym("a.greet", "greet", 0, 100)],
    };
    const fileA2: StoreFileIR = {
      path: "src/a.ts",
      language: "typescript",
      contentHash: "h-a-2",
      symbols: [sym("a.farewell", "farewell", 0, 100)],
    };
    await assert.rejects(
      store.upsertFileBatch([fileA1, fileA2]),
      /duplicate path 'src\/a\.ts' in batch/,
      "duplicate paths in one batch must throw, not silently corrupt the edge pass",
    );
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("null/missing symbols is rejected as a contract violation, not silently pruned (chatgpt-codex-connector P2: 'Reject missing symbols instead of pruning the file')", async () => {
  const dir = await mkdtemp(path.join(tmpdir(), "cg-nullsym-"));
  const store = await GraphStore.open({
    dbPath: path.join(dir, "store.db"),
  });
  try {
    // Seed the file with valid symbols so there is real state to wipe.
    const seeded = await store.upsertFileBatch([fileA]);
    assert.equal(seeded.ok, true, "seed ingest succeeds");
    assert.equal(
      seeded.ok && seeded.results[0]!.nodeCount,
      2,
      "fileA seeded with 2 symbol nodes",
    );

    // Deliberately violate the FileIR contract (symbols is required,
    // non-optional). The type system forbids null; this cast seeds
    // exactly the malformed payload a JSON-deserializing or buggy
    // caller would send, to prove the runtime guard catches it.
    const nullSymbolsIr = { ...fileA, symbols: null } as unknown as StoreFileIR;

    // The guard throws BEFORE the transaction opens, so no prune can
    // run and the existing nodes survive. The pre-fix `?? []` made
    // this return ok and delete both seeded nodes.
    await assert.rejects(
      store.upsertFileBatch([nullSymbolsIr]),
      /symbols must be an array .* received null/,
      "a null symbols field must be rejected, not treated as an empty assertion set",
    );

    // The guard throws before the transaction opens, so the seeded
    // nodes must still be present. nodeCount counts NEW inserts, so a
    // no-op re-ingest (nodeCount 0) is the proof: had the null-symbols
    // call wiped them (the pre-fix `?? []` bug), this re-ingest would
    // re-insert both and report 2.
    const reingested = await store.upsertFileBatch([fileA]);
    assert.equal(reingested.ok, true, "store accepts a valid ingest after the rejection");
    assert.equal(
      reingested.ok && reingested.results[0]!.nodeCount,
      0,
      "fileA re-ingest is a no-op — seeded nodes survived the rejected null-symbols attempt (no wipe)",
    );
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("duplicate (src,dst,type) edges keep first metadata, do not inflate edgeCount (cursor Bugbot #28876d4c)", async () => {
  const dir = await mkdtemp(path.join(tmpdir(), "cg-dupedge-"));
  const store = await GraphStore.open({
    dbPath: path.join(dir, "store.db"),
  });
  try {
    const fileDup: StoreFileIR = {
      path: "src/dup.ts",
      language: "typescript",
      contentHash: "h-dup",
      symbols: [sym("dup.caller", "caller", 0, 50), sym("dup.callee", "callee", 50, 100)],
      edges: [
        {
          srcQualifiedName: "dup.caller",
          dstQualifiedName: "dup.callee",
          type: "CALLS",
          confidence: 0.95,
          provenance: "heuristic",
        },
        {
          // Same (src, dst, type) — differing metadata. First edge wins;
          // this duplicate must NOT inflate edgeCount to 2.
          srcQualifiedName: "dup.caller",
          dstQualifiedName: "dup.callee",
          type: "CALLS",
          confidence: 0.4,
          provenance: "lsp",
        },
      ],
    };
    const r = await store.upsertFileBatch([fileDup]);
    assert.equal(r.ok, true);
    if (!r.ok) throw new Error("expected ok");
    assert.equal(
      r.results[0]?.edgeCount,
      1,
      "duplicate edge triples must dedupe to a single insert — edgeCount must not be inflated",
    );

    // Re-ingest the same file to prove the persisted edge kept the
    // FIRST duplicate's metadata (confidence 0.95): a no-op re-upsert
    // (changes=0) means the stored row matches the first edge exactly.
    const r2 = await store.upsertFileBatch([fileDup]);
    assert.equal(r2.ok, true);
    if (!r2.ok) throw new Error("expected ok");
    assert.equal(
      r2.results[0]?.edgeCount,
      0,
      "re-ingest with identical first-edge metadata must be a no-op (changes=0)",
    );
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("ambiguous local qualified_name drops edges, does not silently pick one node (chatgpt-codex-connector P2)", async () => {
  const dir = await mkdtemp(path.join(tmpdir(), "cg-ambig-"));
  const store = await GraphStore.open({
    dbPath: path.join(dir, "store.db"),
  });
  try {
    // Two symbols share qualifiedName "ambig.Foo" but have different
    // kinds (type vs function) → different node ids, same qualified_name.
    // Node identity is (qualifiedName, filePath, label=kind).
    const fileAmbig: StoreFileIR = {
      path: "src/ambig.ts",
      language: "typescript",
      contentHash: "h-ambig",
      symbols: [
        sym("ambig.Foo", "Foo", 0, 100, "type"),
        sym("ambig.Foo", "Foo", 100, 200, "function"),
        sym("ambig.bar", "bar", 200, 300, "function"),
      ],
      edges: [
        {
          // src "ambig.Foo" is ambiguous (two nodes) — must be dropped,
          // NOT silently attached to whichever the unordered query left last.
          srcQualifiedName: "ambig.Foo",
          dstQualifiedName: "ambig.bar",
          type: "CALLS",
          confidence: 0.9,
          provenance: "heuristic",
        },
      ],
    };
    const r = await store.upsertFileBatch([fileAmbig]);
    assert.equal(r.ok, true);
    if (!r.ok) throw new Error("expected ok");
    assert.equal(
      r.results[0]?.edgeCount,
      0,
      "edge whose src qualified_name is ambiguous (two nodes share it) must be dropped, not silently resolved",
    );
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("out-of-range edge confidence is rejected at the storage boundary (chatgpt-codex-connector P2)", async () => {
  const dir = await mkdtemp(path.join(tmpdir(), "cg-conf-"));
  const store = await GraphStore.open({
    dbPath: path.join(dir, "store.db"),
  });
  try {
    for (const badConfidence of [-0.1, 1.5, -1, 2, Number.NaN, Number.POSITIVE_INFINITY]) {
      const file: StoreFileIR = {
        path: "src/conf.ts",
        language: "typescript",
        contentHash: "h-conf",
        symbols: [sym("conf.caller", "caller", 0, 50), sym("conf.callee", "callee", 50, 100)],
        edges: [
          {
            srcQualifiedName: "conf.caller",
            dstQualifiedName: "conf.callee",
            type: "CALLS",
            confidence: badConfidence,
            provenance: "heuristic",
          },
        ],
      };
      await assert.rejects(
        store.upsertFileBatch([file]),
        /out of range \[0, 1\]/,
        `confidence ${badConfidence} must be rejected at the storage boundary`,
      );
    }
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("omitted edges field preserves prior edges, explicit [] wipes them (cursor Bugbot: 'Omitted edges field wipes stored edges')", async () => {
  const dir = await mkdtemp(path.join(tmpdir(), "cg-noedges-"));
  const store = await GraphStore.open({
    dbPath: path.join(dir, "store.db"),
  });
  try {
    // Initial ingest with edges.
    const r1 = await store.upsertFileBatch([fileA]);
    assert.equal(r1.ok, true);
    if (!r1.ok) throw new Error("expected ok");
    assert.equal(r1.results[0]?.edgeCount, 1, "initial ingest stores 1 edge");

    // Re-upsert the same file WITHOUT an edges field — a bare core
    // ParseResult.ir has no edges. Prior edges MUST be preserved.
    const noEdges: StoreFileIR = {
      path: "src/a.ts",
      language: "typescript",
      contentHash: "h-a-noedges",
      symbols: [sym("a.greet", "greet", 0, 100), sym("a.farewell", "farewell", 100, 200)],
    };
    const r2 = await store.upsertFileBatch([noEdges]);
    assert.equal(r2.ok, true);
    if (!r2.ok) throw new Error("expected ok");
    assert.equal(
      r2.results[0]?.edgeCount,
      0,
      "re-upsert without edges field must not insert or delete edges",
    );

    // Re-ingest fileB (which has a cross-file edge to a.greet). If the
    // edge from fileA was preserved, fileB's re-ingest sees it still
    // there (no dangling drop). If it was wiped, fileB's edge target
    // is gone but the edge re-inserts fine since a.greet still exists.
    // The definitive proof: re-upsert fileA with explicit [] edges —
    // this MUST wipe the prior edge.
    const emptyEdges: StoreFileIR = {
      path: "src/a.ts",
      language: "typescript",
      contentHash: "h-a-empty-edges",
      symbols: [sym("a.greet", "greet", 0, 100), sym("a.farewell", "farewell", 100, 200)],
      edges: [],
    };
    const r3 = await store.upsertFileBatch([emptyEdges]);
    assert.equal(r3.ok, true);
    if (!r3.ok) throw new Error("expected ok");
    // The prior edge (a.greet → a.farewell) is now stale and deleted.
    assert.equal(
      r3.results[0]?.droppedDanglingEdges,
      0,
      "explicit [] edges does not produce dangling edges (edges are src-owned)",
    );
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("close blocks new writes before draining so a concurrent upsert is rejected (chatgpt-codex-connector P2)", async () => {
  const dir = await mkdtemp(path.join(tmpdir(), "cg-closing-"));
  const store = await GraphStore.open({
    dbPath: path.join(dir, "store.db"),
  });
  try {
    // Schedule a write (pending on the queue).
    const firstWrite = store.upsertFileBatch([fileA]);
    // Start close() WITHOUT awaiting firstWrite. close() sets the
    // closing flag synchronously before awaiting drain, so a write
    // issued while drain is pending must be rejected, not scheduled
    // onto a queue whose tail drain() already snapshotted.
    const closeP = store.close();
    const rejected = await store.upsertFileBatch([fileB]);
    assert.equal(rejected.ok, false);
    if (rejected.ok) throw new Error("expected rejected");
    assert.equal(
      rejected.code,
      "store_closed",
      "upsert during close-drain must return store_closed, not run against a closing DB",
    );
    // Let the pending write + close finish.
    await firstWrite;
    await closeP;
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test("a second close() during an in-progress drain awaits it, not resolves early (chatgpt-codex-connector P2: 'Wait for an in-progress close')", async () => {
  const dir = await mkdtemp(path.join(tmpdir(), "cg-close2-"));
  const store = await GraphStore.open({
    dbPath: path.join(dir, "store.db"),
  });
  try {
    // Schedule a write but do NOT await it, so close()'s drain has a
    // pending write to wait on.
    const firstWrite = store.upsertFileBatch([fileA]);
    // closeA runs first: it sets `closing` synchronously then awaits
    // the drain. closeB is issued on the SAME synchronous tick, while
    // closeA is still mid-drain (no microtasks have flushed yet).
    const closeA = store.close();
    const closeB = store.close();

    // Discriminator: the pre-fix early `return` resolved closeB
    // synchronously (it returned a settled promise), so a .then
    // callback fires on the very next microtask. The fix returns the
    // shared pending drain promise, so closeB stays pending until the
    // drain finishes. `await Promise.resolve()` advances exactly one
    // microtask — enough for closeB's .then to fire if it was already
    // settled, not enough if it is still waiting on the drain.
    let earlyResolved = false;
    closeB.then(() => {
      earlyResolved = true;
    });
    await Promise.resolve();
    assert.equal(
      earlyResolved,
      false,
      "a second close() issued while the first is draining must await the shared drain promise, not resolve before it",
    );

    // Both closes now settle together once the drain (and firstWrite)
    // complete.
    await closeA;
    await closeB;
    await firstWrite;
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test("invalid symbol spans are rejected before storing nodes (chatgpt-codex-connector P2: 'Reject invalid symbol spans before storing nodes')", async () => {
  const dir = await mkdtemp(path.join(tmpdir(), "cg-badspan-"));
  const store = await GraphStore.open({
    dbPath: path.join(dir, "store.db"),
  });
  try {
    // Seed valid state so we can prove the guard throws BEFORE the
    // transaction opens — a wipe would clear these nodes.
    const seeded = await store.upsertFileBatch([fileA]);
    assert.equal(seeded.ok, true, "seed ingest succeeds");
    assert.equal(
      seeded.ok && seeded.results[0]!.nodeCount,
      2,
      "fileA seeded with 2 symbol nodes",
    );

    // Each case targets a distinct branch of assertValidSymbolSpan.
    // The `sym()` helper builds a well-formed symbol; the non-integer
    // and missing-span cases bypass it to seed the malformed payload a
    // JSON-deserializing or buggy caller would emit.
    const cases: Array<{ label: string; sym: unknown; re: RegExp }> = [
      { label: "startByte > endByte", sym: sym("a.bad", "bad", 100, 10), re: /startByte > endByte/ },
      { label: "negative startByte", sym: sym("a.bad", "bad", -1, 10), re: /negative span/ },
      { label: "negative endByte", sym: sym("a.bad", "bad", 0, -5), re: /negative span/ },
      { label: "non-integer startByte", sym: { qualifiedName: "a.bad", name: "bad", kind: "function", span: { startByte: 1.5, endByte: 10 } }, re: /non-integer span/ },
      { label: "NaN endByte", sym: { qualifiedName: "a.bad", name: "bad", kind: "function", span: { startByte: 0, endByte: Number.NaN } }, re: /non-integer span/ },
      { label: "missing span object", sym: { qualifiedName: "a.bad", name: "bad", kind: "function" }, re: /no span/ },
    ];
    for (const { label, sym: badSym, re } of cases) {
      const ir = { ...fileA, symbols: [badSym] } as unknown as StoreFileIR;
      await assert.rejects(
        store.upsertFileBatch([ir]),
        re,
        `${label}: invalid span must be rejected at the store boundary`,
      );
    }

    // Guard throws before the transaction opens, so every rejected
    // attempt left the seeded nodes intact — a re-ingest is a no-op
    // (nodeCount 0). The pre-fix path would have bound the bad span and
    // reported nodeCount 1.
    const reingested = await store.upsertFileBatch([fileA]);
    assert.equal(reingested.ok, true, "store accepts a valid ingest after the rejections");
    assert.equal(
      reingested.ok && reingested.results[0]!.nodeCount,
      0,
      "fileA re-ingest is a no-op — seeded nodes survived every rejected bad-span attempt",
    );

    // Boundary check: a zero-length half-open span [5, 5) is VALID
    // (startByte <= endByte), so it must be accepted, not rejected.
    const zeroSpan = await store.upsertFileBatch([
      { ...fileA, symbols: [sym("a.zero", "zero", 5, 5)] },
    ]);
    assert.equal(zeroSpan.ok, true, "a zero-length span [5, 5) is a legal half-open span");
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("non-canonical file paths are rejected before persisting (chatgpt-codex-connector P2: 'Reject non-canonical file paths before persisting')", async () => {
  const dir = await mkdtemp(path.join(tmpdir(), "cg-badpath-"));
  const store = await GraphStore.open({
    dbPath: path.join(dir, "store.db"),
  });
  try {
    const seeded = await store.upsertFileBatch([fileA]);
    assert.equal(seeded.ok, true, "seed ingest succeeds");

    // Each case is a distinct aliasing hazard: the same repo file
    // rendered two ways would hash to two files rows + node ids and
    // leave duplicates the canonical ingest cannot match or prune.
    const cases: Array<{ label: string; path: string; re: RegExp }> = [
      { label: "backslash separator", path: "src\\a.ts", re: /forward slashes/ },
      { label: "leading ./", path: "./src/a.ts", re: /must be canonical/ },
      { label: "leading ../", path: "../src/a.ts", re: /must be canonical/ },
      { label: "absolute posix", path: "/src/a.ts", re: /must be repo-relative/ },
      { label: ".. segment", path: "src/../a.ts", re: /must be canonical/ },
      { label: "empty string", path: "", re: /non-empty string/ },
    ];
    for (const { label, path: badPath, re } of cases) {
      const ir = { ...fileA, path: badPath } as StoreFileIR;
      await assert.rejects(
        store.upsertFileBatch([ir]),
        re,
        `${label}: non-canonical path must be rejected at the store boundary`,
      );
    }

    // Canonical re-ingest is a no-op — the seeded file survived every
    // rejected non-canonical attempt (the guard throws before the
    // files row is touched).
    const reingested = await store.upsertFileBatch([fileA]);
    assert.equal(reingested.ok, true, "store accepts a canonical ingest after the rejections");
    assert.equal(
      reingested.ok && reingested.results[0]!.nodeCount,
      0,
      "fileA re-ingest is a no-op — the canonical seeded row survived every rejected non-canonical attempt",
    );
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});


// ──────────────────────────────────────────────────────────────────────────
// upsertEdges (issue #1554 codegraph ingest_traces persistence path)
// ──────────────────────────────────────────────────────────────────────────

test("upsertEdges: persists a standalone HTTP_CALLS edge between indexed nodes (issue #1554 P1 persistence)", async () => {
  const { store, dir } = await tempStore();
  try {
    // Seed two files so the nodes the trace edge references exist.
    const seeded = await store.upsertFileBatch([fileA, fileB]);
    assert.equal(seeded.ok, true);

    // Persist a runtime HTTP call observation as a standalone edge.
    const result = await store.upsertEdges([
      {
        srcQualifiedName: "a.greet",
        dstQualifiedName: "b.run",
        type: "HTTP_CALLS",
        confidence: 0.7,
        provenance: "trace",
      },
    ]);
    assert.equal(result.ok, true);
    if (!result.ok) throw new Error("expected ok");
    assert.equal(result.persisted, 1, "one edge persisted");
    assert.equal(result.skipped, 0, "no skipped edges");

    // Re-open the store and verify the edge survived on disk (real
    // persistence, not an in-memory stub — P1 acceptance criterion).
    await store.close();
    const reopened = await GraphStore.open({ dbPath: path.join(dir, "graph.sqlite") });
    const stats = reopened.schemaStats();
    assert.equal(stats.ok, true);
    if (!stats.ok) throw new Error("expected stats ok");
    assert.equal(
      (stats.stats.edgesByType["HTTP_CALLS"] ?? 0),
      1,
      "HTTP_CALLS edge persisted across reopen",
    );
    await reopened.close();
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test("upsertEdges: skips edges whose endpoint does not resolve to an indexed node (dangling policy)", async () => {
  const { store, dir } = await tempStore();
  try {
    await store.upsertFileBatch([fileA]);

    // 'b.run' is not indexed (only fileA was ingested) → edge is skipped.
    const result = await store.upsertEdges([
      {
        srcQualifiedName: "a.greet",
        dstQualifiedName: "b.run",
        type: "HTTP_CALLS",
        confidence: 0.7,
        provenance: "trace",
      },
    ]);
    assert.equal(result.ok, true);
    if (!result.ok) throw new Error("expected ok");
    assert.equal(result.persisted, 0, "dangling dst → not persisted");
    assert.equal(result.skipped, 1, "dangling edge counted as skipped");
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("upsertEdges: upgrades confidence on an existing (src,dst,type) edge (idempotent upsert)", async () => {
  const { store, dir } = await tempStore();
  try {
    await store.upsertFileBatch([fileA, fileB]);

    // First trace: confidence 0.5.
    const first = await store.upsertEdges([
      { srcQualifiedName: "a.greet", dstQualifiedName: "b.run", type: "HTTP_CALLS", confidence: 0.5, provenance: "trace" },
    ]);
    assert.equal(first.ok && first.persisted, 1);

    // Second trace, same endpoints: confidence upgraded to 0.9.
    const second = await store.upsertEdges([
      { srcQualifiedName: "a.greet", dstQualifiedName: "b.run", type: "HTTP_CALLS", confidence: 0.9, provenance: "trace" },
    ]);
    assert.equal(second.ok && second.persisted, 1, "upsert updates the existing edge row");

    // Reopen + verify there is exactly ONE HTTP_CALLS edge (not two).
    await store.close();
    const reopened = await GraphStore.open({ dbPath: path.join(dir, "graph.sqlite") });
    const stats = reopened.schemaStats();
    assert.equal(stats.ok && (stats.stats.edgesByType["HTTP_CALLS"] ?? 0), 1, "no duplicate edge rows");
    await reopened.close();
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test("upsertEdges: rejects invalid provenance / out-of-range confidence at the boundary (rule 51)", async () => {
  const { store, dir } = await tempStore();
  try {
    await store.upsertFileBatch([fileA]);

    // Validation errors are fail-loud (matching upsertFileBatch's policy:
    // a bad edge is a contract violation, not a tagged failure).
    await assert.rejects(
      store.upsertEdges([
        { srcQualifiedName: "a.greet", dstQualifiedName: "a.farewell", type: "HTTP_CALLS", confidence: 0.5, provenance: "bogus" as never },
      ]),
      /provenance/,
    );
    await assert.rejects(
      store.upsertEdges([
        { srcQualifiedName: "a.greet", dstQualifiedName: "a.farewell", type: "HTTP_CALLS", confidence: 1.5, provenance: "trace" },
      ]),
      /out of range/,
    );
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});


// ──────────────────────────────────────────────────────────────────────────
// upsertEdges: node-id-keyed endpoints (issue #1677 — duplicate qualified-name
// support). A SIMILAR_TO edge between two symbols that share a qualified name
// across two files is persisted by node id, NOT dropped as ambiguous.
// ──────────────────────────────────────────────────────────────────────────

test("upsertEdges: srcNodeId/dstNodeId persist an edge between same-qualified-name nodes (issue #1677)", async () => {
  const { store, dir } = await tempStore();
  try {
    // Two files, each declaring a symbol with the SAME qualified name
    // "dup.Foo" but a different file path → distinct deterministic node ids
    // (identity is (qualifiedName, filePath, label)). The qualified-name
    // fallback would be AMBIGUOUS here and drop the edge.
    const fileOne: StoreFileIR = {
      path: "src/one.ts",
      language: "typescript",
      contentHash: "h-one",
      symbols: [sym("dup.Foo", "Foo", 0, 100, "function")],
    };
    const fileTwo: StoreFileIR = {
      path: "src/two.ts",
      language: "typescript",
      contentHash: "h-two",
      symbols: [sym("dup.Foo", "Foo", 0, 100, "function")],
    };
    await store.upsertFileBatch([fileOne, fileTwo]);

    const idOne = nodeIdFor({ qualifiedName: "dup.Foo", filePath: "src/one.ts", label: "function" });
    const idTwo = nodeIdFor({ qualifiedName: "dup.Foo", filePath: "src/two.ts", label: "function" });
    assert.notEqual(idOne, idTwo, "two same-qname symbols in different files have distinct node ids");

    // Edge keyed by node ids — the SIMILAR_TO pipeline shape.
    const result = await store.upsertEdges([
      {
        srcQualifiedName: "dup.Foo",
        dstQualifiedName: "dup.Foo",
        srcNodeId: idOne,
        dstNodeId: idTwo,
        type: "SIMILAR_TO",
        confidence: 0.93,
        provenance: "semantic",
      },
    ]);
    assert.equal(result.ok, true);
    if (!result.ok) throw new Error("expected ok");
    assert.equal(result.persisted, 1, "node-id-keyed edge persisted despite duplicate qualified name");
    assert.equal(result.skipped, 0, "edge must NOT be dropped as ambiguous");

    // The edge is traversable from either node by id.
    const t = store.traverse({ start: idOne, edgeTypes: ["SIMILAR_TO"], maxDepth: 1 });
    assert.equal(t.ok, true);
    if (t.ok) {
      assert.ok(
        t.hits.some((h) => h.nodeId === idTwo),
        "SIMILAR_TO edge reaches the exact same-qname node by id",
      );
    }
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("upsertEdges: a node id that does not reference an indexed node is skipped (issue #1677)", async () => {
  const { store, dir } = await tempStore();
  try {
    await store.upsertFileBatch([fileA]);

    // srcNodeId resolves (a.greet exists) but dstNodeId points at a node
    // that was never ingested → dangling, counted as skipped (consistent
    // with the qname dangling-edge policy).
    const bogusDst = nodeIdFor({ qualifiedName: "ghost", filePath: "src/ghost.ts", label: "function" });
    const result = await store.upsertEdges([
      {
        srcQualifiedName: "a.greet",
        dstQualifiedName: "ghost",
        srcNodeId: nodeIdFor({ qualifiedName: "a.greet", filePath: "src/a.ts", label: "function" }),
        dstNodeId: bogusDst,
        type: "SIMILAR_TO",
        confidence: 0.9,
        provenance: "semantic",
      },
    ]);
    assert.equal(result.ok, true);
    if (!result.ok) throw new Error("expected ok");
    assert.equal(result.persisted, 0, "dangling dst node id → not persisted");
    assert.equal(result.skipped, 1, "dangling node id counted as skipped");
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("upsertEdges: a node id whose qualified_name does not match is skipped (issue #1677, chatgpt-codex-connector P2)", async () => {
  const { store, dir } = await tempStore();
  try {
    // Two files, same qname "dup.Foo" → two distinct node ids.
    const fileOne: StoreFileIR = {
      path: "src/one.ts",
      language: "typescript",
      contentHash: "h-one",
      symbols: [sym("dup.Foo", "Foo", 0, 100, "function")],
    };
    const fileTwo: StoreFileIR = {
      path: "src/two.ts",
      language: "typescript",
      contentHash: "h-two",
      symbols: [sym("dup.Foo", "Foo", 0, 100, "function")],
    };
    await store.upsertFileBatch([fileOne, fileTwo]);
    const idOne = nodeIdFor({ qualifiedName: "dup.Foo", filePath: "src/one.ts", label: "function" });
    const idTwo = nodeIdFor({ qualifiedName: "dup.Foo", filePath: "src/two.ts", label: "function" });

    // idOne exists and resolves "dup.Foo" — this edge is fine.
    const ok = await store.upsertEdges([
      {
        srcQualifiedName: "dup.Foo",
        dstQualifiedName: "dup.Foo",
        srcNodeId: idOne,
        dstNodeId: idTwo,
        type: "SIMILAR_TO",
        confidence: 0.9,
        provenance: "semantic",
      },
    ]);
    assert.equal(ok.ok && ok.persisted, 1, "correctly-paired id+qname persists");

    // idOne is a REAL node id, but the edge claims its qname is "wrong.qname"
    // — the id/qname pair is inconsistent and MUST be skipped rather than
    // silently writing an edge from node idOne under a misleading name
    // (chatgpt-codex-connector P2: id/qname consistency at the boundary).
    const bad = await store.upsertEdges([
      {
        srcQualifiedName: "wrong.qname",
        dstQualifiedName: "dup.Foo",
        srcNodeId: idOne,
        dstNodeId: idTwo,
        type: "SIMILAR_TO",
        confidence: 0.9,
        provenance: "semantic",
      },
    ]);
    assert.equal(bad.ok, true);
    if (!bad.ok) throw new Error("expected ok");
    assert.equal(bad.persisted, 0, "mismatched id+qname → not persisted");
    assert.equal(bad.skipped, 1, "mismatched id+qname counted as skipped");
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});

test("upsertEdges: edges without node ids still use the qname path (issue #1677 back-compat)", async () => {
  const { store, dir } = await tempStore();
  try {
    // The existing trace/HTTP_CALLS path passes no node ids — it MUST keep
    // resolving by qualified name exactly as before.
    await store.upsertFileBatch([fileA]);
    const result = await store.upsertEdges([
      {
        srcQualifiedName: "a.greet",
        dstQualifiedName: "a.farewell",
        type: "CALLS",
        confidence: 0.8,
        provenance: "heuristic",
      },
    ]);
    assert.equal(result.ok, true);
    if (!result.ok) throw new Error("expected ok");
    assert.equal(result.persisted, 1, "qname-only edge still persists via the fallback path");
    assert.equal(result.skipped, 0);
  } finally {
    await store.close();
    await rm(dir, { recursive: true, force: true });
  }
});
