Files
gstack/test/regression-transcript-slug-collision.test.ts
Garry TanandClaude Fable 5 9eab3ca336 fix(memory-ingest): slug assignments persist across runs via the state consult
First-occurrence-keeps-bare was walk-order-dependent ACROSS runs: a source
that got the suffixed slug once could take the bare slug the next run (its
collider aged out or was skipped as unchanged), leaving gbrain holding the
same transcript under two slugs — and a NEW collider could claim a bare
slug that state shows belongs to an unchanged source, silently overwriting
that page. disambiguateSlugs now consults state.sessions: a recorded slug
stays owned by its source_path, re-ingested sources keep their slug
verbatim, fresh assignments never take another source's slug, and legacy
duplicate records (pre-#2724 overwrites) resolve first-owner-wins and
self-heal on the next state write. Stateless behavior is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 16:28:41 +00:00

158 lines
6.7 KiB
TypeScript

/**
* Regression: disambiguateSlugs resolves colliding staged slugs.
*
* Two source files can map to one path-derived transcript slug (a session
* resumed under the same id on one day, or two session ids sharing a 12-char
* prefix). writeStaged() names each file `${slug}.md`, so the second overwrote
* the first; gbrain then collected N-1 of N staged files and the
* staged-vs-collected reconciliation guard failed the whole batch every run
* ("accounted for N-1 of N staged ... Refusing to advance state").
*/
import { describe, it, expect } from "bun:test";
import { readFileSync } from "fs";
import { join } from "path";
import { disambiguateSlugs } from "../bin/gstack-memory-ingest";
const mk = (slug: string, source_path: string) => ({
slug,
source_path,
rendered_body: "---\ntitle: x\n---\n\nbody",
page_slug: slug,
partial: false,
type: "transcript" as const,
git_remote: undefined,
});
describe("regression: disambiguateSlugs resolves colliding staged slugs", () => {
it("keeps the first occurrence and suffixes later colliders deterministically", () => {
const slug = "transcripts/claude-code/repo/2026-08-25-abc123def456";
const run = () => {
const pages = [mk(slug, "/a.jsonl"), mk(slug, "/b.jsonl")];
disambiguateSlugs(pages);
return pages;
};
const pages = run();
// First keeps the clean slug; second is disambiguated.
expect(pages[0].slug).toBe(slug);
expect(pages[1].slug).not.toBe(slug);
expect(pages[1].slug.startsWith(slug + "-")).toBe(true);
// slug and page_slug move together (downstream consumers must agree).
expect(pages[1].page_slug).toBe(pages[1].slug);
// Deterministic across runs (same source path → same suffix).
expect(run()[1].slug).toBe(pages[1].slug);
});
it("gives every member of a 3-way collision a distinct slug", () => {
const slug = "transcripts/codex/repo/2026-08-25-deadbeefcafe";
const pages = [
mk(slug, "/one.jsonl"),
mk(slug, "/two.jsonl"),
mk(slug, "/three.jsonl"),
];
disambiguateSlugs(pages);
const slugs = new Set(pages.map((p) => p.slug));
expect(slugs.size).toBe(3);
expect(pages[0].slug).toBe(slug);
});
it("leaves non-colliding slugs untouched", () => {
const pages = [
mk("transcripts/a/repo/2026-08-25-1111", "/x.jsonl"),
mk("transcripts/b/repo/2026-08-25-2222", "/y.jsonl"),
];
const before = pages.map((p) => p.slug);
disambiguateSlugs(pages);
expect(pages.map((p) => p.slug)).toEqual(before);
});
it("state consult: a source keeps its recorded suffixed slug when its old collider is absent", () => {
// Run 1 assigned A the bare slug and B the suffix; run 2 sees only B
// (A unchanged or gone). Without the consult B would flip to bare and
// gbrain would hold the same transcript under two slugs.
const slug = "transcripts/claude-code/repo/2026-08-25-abc123def456";
const state = {
sessions: {
"/a.jsonl": { page_slug: slug },
"/b.jsonl": { page_slug: `${slug}-cafe0123` },
},
};
const pages = [mk(slug, "/b.jsonl")];
disambiguateSlugs(pages, state);
expect(pages[0].slug).toBe(`${slug}-cafe0123`);
expect(pages[0].page_slug).toBe(pages[0].slug);
});
it("state consult: a new source never takes a bare slug owned by an unchanged source", () => {
// A owns the bare slug from a prior run but is NOT restaged this run;
// new collider C must suffix, not silently overwrite A's page in gbrain.
const slug = "transcripts/claude-code/repo/2026-08-25-abc123def456";
const state = { sessions: { "/a.jsonl": { page_slug: slug } } };
const pages = [mk(slug, "/c.jsonl")];
disambiguateSlugs(pages, state);
expect(pages[0].slug).not.toBe(slug);
expect(pages[0].slug.startsWith(slug + "-")).toBe(true);
});
it("state consult: legacy duplicate records resolve first-owner-wins and self-heal", () => {
// Pre-#2724 states could record the SAME bare slug for two sources.
// The first owner in state order keeps it; the other gets a stable
// suffix — after this run the state records distinct slugs.
const slug = "transcripts/codex/repo/2026-08-25-deadbeefcafe";
const state = {
sessions: {
"/first.jsonl": { page_slug: slug },
"/second.jsonl": { page_slug: slug },
},
};
const pages = [mk(slug, "/first.jsonl"), mk(slug, "/second.jsonl")];
disambiguateSlugs(pages, state);
expect(pages[0].slug).toBe(slug);
expect(pages[1].slug).not.toBe(slug);
expect(pages[1].slug.startsWith(slug + "-")).toBe(true);
});
it("state consult: no state (or empty sessions) behaves exactly like the stateless algorithm", () => {
const slug = "transcripts/claude-code/repo/2026-08-25-abc123def456";
const a = [mk(slug, "/a.jsonl"), mk(slug, "/b.jsonl")];
const b = [mk(slug, "/a.jsonl"), mk(slug, "/b.jsonl")];
disambiguateSlugs(a);
disambiguateSlugs(b, { sessions: {} });
expect(b.map((p) => p.slug)).toEqual(a.map((p) => p.slug));
});
it("call-site wiring: the prepare/stage flow actually invokes disambiguateSlugs (source pin)", () => {
// The unit tests above prove the function works; nothing else proves the
// flow CALLS it — a refactor could drop the invocation and every test
// would stay green while #2724 regresses. Anchor narrowly: extract the
// preparePages function body (and, as an accepted alternate home, the
// main-flow stretch between the preparePages call and writeStaged) and
// require a disambiguateSlugs( invocation inside — cosmetic changes
// (argument rename, comment edits) don't trip this; moving the call out
// of the prepare→stage flow entirely does.
const src = readFileSync(
join(import.meta.dir, "..", "bin", "gstack-memory-ingest.ts"),
"utf-8",
);
// preparePages body: from its declaration to the next top-level function.
const defStart = src.indexOf("function preparePages(");
expect(defStart).toBeGreaterThan(-1);
const afterDef = src.slice(defStart + "function preparePages(".length);
const endRel = afterDef.search(/\n(?:export )?(?:async )?function /);
const prepareBody = endRel === -1 ? afterDef : afterDef.slice(0, endRel);
// Alternate home: the stage flow between the preparePages call site and
// the writeStaged call that consumes its output.
const callSite = src.indexOf("= preparePages(");
const stageSite = callSite === -1 ? -1 : src.indexOf("writeStaged(", callSite);
const stageFlow =
callSite !== -1 && stageSite !== -1 ? src.slice(callSite, stageSite) : "";
const invokes = (text: string) =>
// An invocation, not the `function disambiguateSlugs(` definition.
/(?<!function )\bdisambiguateSlugs\(/.test(text);
expect(invokes(prepareBody) || invokes(stageFlow)).toBe(true);
});
});