mirror of
https://github.com/mksglu/context-mode.git
synced 2026-09-19 03:27:16 +08:00
da6b117a6f
Strategy decision (after build-pipeline audit):
Picked Strategy A-refined — bind hooks/session-helpers.mjs to the
already-existing esbuild output hooks/session-db.bundle.mjs (which
is generated from src/session/db.ts by the existing 'npm run bundle'
step in package.json). Zero new build steps, zero new shipped files,
zero new bundle pipeline complexity.
Strategy B (codegen) rejected: would add a custom build script with
brittle TS-text extraction; no precedent in repo.
Strategy C (shared .mjs source) rejected: src/ is not in package.json
'files' array, so a new src/session/path-helpers.mjs would not ship
to npm consumers; adding src/ to files would broaden the published
surface unnecessarily.
What changed:
- src/session/db.ts: new export resolveSessionPath({projectDir,
sessionsDir, ext, suffix?}) generalises resolveSessionDbPath over
arbitrary file extension (.db, -events.md, .cleanup). Existing
resolveSessionDbPath now delegates to it (contract preserved).
Optional suffix? lets the hook layer inject its marker-cached
suffix without re-invoking git.
- hooks/session-helpers.mjs: deleted inline hashCanonical, hashLegacy,
migrateAndJoin, normalizeWorktreePath, gitOutput,
getCurrentWorktreeRoot, getMainWorktreeRoot, and the case-fold
suffix computation. Now imports them from session-db.bundle.mjs
via pathToFileURL dynamic import (same pattern as
session-loaders.mjs — Windows-safe, see d2be2f7). Marker-file
cache wrapper around getWorktreeSuffix retained because it amortises
the git subprocess cost across hook forks (no equivalent need on
the long-lived TS server).
- hooks/session-db.bundle.mjs / cli.bundle.mjs / server.bundle.mjs:
regenerated by 'npm run build' (gitignored but committed per
project convention since prepublishOnly = npm run build).
- tests/session/resolve-session-path.test.ts: 7 new tests pinning
the new export's contract (compose, migrate, data-loss safety,
Linux no-op, worktree separation, explicit suffix override,
consistency with resolveSessionDbPath).
Lines of duplicated correctness logic deleted from hooks: ~60.
Architect self-review:
Risk 1 (top-level await blocking): hooks already await the dynamic
import of session-helpers.mjs, so no new latency.
Risk 2 (bundle absent in marketplace install): cloned the bundle-first
/ build-fallback pattern from session-loaders.mjs verbatim.
Risk 3 (Windows ESM dynamic import): used pathToFileURL(...).href —
same fix d2be2f7 applied to statusline (cross-OS reference).
Risk 4 (worktree-marker perf regression): preserved via the optional
suffix? param on resolveSessionPath.
Risk 5 (contract break): resolveSessionDbPath signature unchanged,
all existing tests/session/db-path-canonical-migration.test.ts
pass (10/10).
Verdict: ship.
Future-proofing: when round-6 case-fix lands, it edits ONE file —
src/session/db.ts. The bundle regenerates, hooks inherit it. The
'kept in sync manually since hooks run as plain .mjs' comment is
gone for good.
Tests: 17 passed (10 existing + 7 new) on the path/migration suite.
Hook+statusline+session subset: 880 passed (matches baseline; 1
remaining flake is the pre-existing kiro/integration timeout
unrelated to this change).
183 lines
7.1 KiB
TypeScript
183 lines
7.1 KiB
TypeScript
/**
|
|
* resolveSessionPath — generalized variant of resolveSessionDbPath that
|
|
* works for arbitrary file extensions (.db, -events.md, .cleanup, etc).
|
|
*
|
|
* Same case-fold + one-shot legacy migration semantics; the helper is the
|
|
* single source of truth that both the TS server (db.ts internals) and the
|
|
* .mjs hooks (hooks/session-helpers.mjs) consume — eliminating the
|
|
* parallel JS implementation that drifted across rounds 5 and 6.
|
|
*/
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { execFileSync } from "node:child_process";
|
|
import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
_resetWorktreeSuffixCacheForTests,
|
|
hashProjectDirCanonical,
|
|
hashProjectDirLegacy,
|
|
resolveSessionPath,
|
|
} from "../../src/session/db.js";
|
|
|
|
const cleanup: string[] = [];
|
|
afterEach(() => {
|
|
_resetWorktreeSuffixCacheForTests();
|
|
while (cleanup.length) {
|
|
const p = cleanup.pop();
|
|
if (p && existsSync(p)) rmSync(p, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function makeRepo(prefix: string): string {
|
|
const repo = mkdtempSync(join(tmpdir(), `ctx-${prefix}-`));
|
|
cleanup.push(repo);
|
|
execFileSync("git", ["init"], { cwd: repo, stdio: "ignore" });
|
|
execFileSync("git", ["config", "user.email", "t@t.com"], { cwd: repo, stdio: "ignore" });
|
|
execFileSync("git", ["config", "user.name", "T"], { cwd: repo, stdio: "ignore" });
|
|
writeFileSync(join(repo, "README.md"), "hi\n");
|
|
execFileSync("git", ["add", "README.md"], { cwd: repo, stdio: "ignore" });
|
|
execFileSync("git", ["commit", "-m", "initial"], { cwd: repo, stdio: "ignore" });
|
|
return repo;
|
|
}
|
|
|
|
function makeSessionsDir(): string {
|
|
const d = mkdtempSync(join(tmpdir(), "ctx-sess-"));
|
|
cleanup.push(d);
|
|
return d;
|
|
}
|
|
|
|
describe("resolveSessionPath (generalized)", () => {
|
|
it("composes <canonicalHash><suffix><ext> when no migration needed", () => {
|
|
const repo = makeRepo("compose");
|
|
const sessionsDir = makeSessionsDir();
|
|
const dbPath = resolveSessionPath({ projectDir: repo, sessionsDir, ext: ".db" });
|
|
const eventsPath = resolveSessionPath({
|
|
projectDir: repo,
|
|
sessionsDir,
|
|
ext: "-events.md",
|
|
});
|
|
const cleanupPath = resolveSessionPath({
|
|
projectDir: repo,
|
|
sessionsDir,
|
|
ext: ".cleanup",
|
|
});
|
|
expect(dbPath.startsWith(sessionsDir)).toBe(true);
|
|
expect(dbPath.endsWith(".db")).toBe(true);
|
|
expect(eventsPath.endsWith("-events.md")).toBe(true);
|
|
expect(cleanupPath.endsWith(".cleanup")).toBe(true);
|
|
// All three share the same hash prefix (only ext differs).
|
|
const trimmed = (p: string, e: string) => p.slice(0, p.length - e.length);
|
|
expect(trimmed(dbPath, ".db")).toBe(trimmed(eventsPath, "-events.md"));
|
|
expect(trimmed(dbPath, ".db")).toBe(trimmed(cleanupPath, ".cleanup"));
|
|
});
|
|
|
|
it.skipIf(process.platform === "linux")(
|
|
"migrates legacy raw-casing FILE to canonical name for arbitrary ext",
|
|
() => {
|
|
const repo = makeRepo("migrate-events");
|
|
const sessionsDir = makeSessionsDir();
|
|
const legacyHash = hashProjectDirLegacy(repo);
|
|
const canonicalHash = hashProjectDirCanonical(repo);
|
|
if (legacyHash === canonicalHash) return;
|
|
|
|
// Pre-create a legacy events.md
|
|
const legacyEvents = join(sessionsDir, `${legacyHash}-events.md`);
|
|
writeFileSync(legacyEvents, "# legacy events\nhello");
|
|
|
|
const resolved = resolveSessionPath({
|
|
projectDir: repo,
|
|
sessionsDir,
|
|
ext: "-events.md",
|
|
});
|
|
|
|
expect(resolved).toBe(join(sessionsDir, `${canonicalHash}-events.md`));
|
|
expect(existsSync(resolved)).toBe(true);
|
|
expect(readFileSync(resolved, "utf8")).toBe("# legacy events\nhello");
|
|
expect(existsSync(legacyEvents)).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "linux")(
|
|
"data-loss safety: leaves legacy alone when canonical also exists",
|
|
() => {
|
|
const repo = makeRepo("both-cleanup");
|
|
const sessionsDir = makeSessionsDir();
|
|
const legacyHash = hashProjectDirLegacy(repo);
|
|
const canonicalHash = hashProjectDirCanonical(repo);
|
|
if (legacyHash === canonicalHash) return;
|
|
|
|
const legacyCleanup = join(sessionsDir, `${legacyHash}.cleanup`);
|
|
const canonicalCleanup = join(sessionsDir, `${canonicalHash}.cleanup`);
|
|
writeFileSync(legacyCleanup, "LEGACY-FLAG");
|
|
writeFileSync(canonicalCleanup, "CANONICAL-FLAG");
|
|
|
|
const resolved = resolveSessionPath({
|
|
projectDir: repo,
|
|
sessionsDir,
|
|
ext: ".cleanup",
|
|
});
|
|
expect(resolved).toBe(canonicalCleanup);
|
|
expect(readFileSync(canonicalCleanup, "utf8")).toBe("CANONICAL-FLAG");
|
|
expect(existsSync(legacyCleanup)).toBe(true);
|
|
expect(readFileSync(legacyCleanup, "utf8")).toBe("LEGACY-FLAG");
|
|
},
|
|
);
|
|
|
|
it("Linux: legacy === canonical so no migration ever runs", () => {
|
|
if (process.platform !== "linux") return;
|
|
const repo = makeRepo("linux-events");
|
|
const sessionsDir = makeSessionsDir();
|
|
const hash = hashProjectDirLegacy(repo);
|
|
expect(hash).toBe(hashProjectDirCanonical(repo));
|
|
const file = join(sessionsDir, `${hash}-events.md`);
|
|
writeFileSync(file, "# linux noop\n");
|
|
const resolved = resolveSessionPath({
|
|
projectDir: repo,
|
|
sessionsDir,
|
|
ext: "-events.md",
|
|
});
|
|
expect(resolved).toBe(file);
|
|
expect(readFileSync(file, "utf8")).toBe("# linux noop\n");
|
|
});
|
|
|
|
it("worktree suffix flows through (different worktrees → different paths)", () => {
|
|
const wt1 = makeRepo("wt-a");
|
|
const wt2 = makeRepo("wt-b");
|
|
const sessionsDir = makeSessionsDir();
|
|
const p1 = resolveSessionPath({ projectDir: wt1, sessionsDir, ext: ".cleanup" });
|
|
const p2 = resolveSessionPath({ projectDir: wt2, sessionsDir, ext: ".cleanup" });
|
|
expect(p1).not.toBe(p2);
|
|
});
|
|
|
|
it("explicit `suffix` opt-in: hook layer can pass its own marker-cached suffix", () => {
|
|
// The hook layer caches the worktree suffix in a tmpdir marker file
|
|
// (so subsequent hook forks skip the git subprocess). It needs to be
|
|
// able to inject that cached value without re-invoking git here.
|
|
const repo = makeRepo("explicit-suffix");
|
|
const sessionsDir = makeSessionsDir();
|
|
const fakeSuffix = "__abc12345";
|
|
const resolved = resolveSessionPath({
|
|
projectDir: repo,
|
|
sessionsDir,
|
|
ext: ".db",
|
|
suffix: fakeSuffix,
|
|
});
|
|
const canonicalHash = hashProjectDirCanonical(repo);
|
|
expect(resolved).toBe(join(sessionsDir, `${canonicalHash}${fakeSuffix}.db`));
|
|
});
|
|
|
|
it("resolveSessionDbPath stays consistent with resolveSessionPath(ext: .db)", () => {
|
|
// Lock the contract: the existing exported entrypoint must equal the
|
|
// generalized helper for ext: ".db" so behavior cannot drift between them.
|
|
const repo = makeRepo("contract");
|
|
const sessionsDir = makeSessionsDir();
|
|
// Re-import the canonical export to compare.
|
|
return import("../../src/session/db.js").then(({ resolveSessionDbPath }) => {
|
|
const a = resolveSessionDbPath({ projectDir: repo, sessionsDir });
|
|
_resetWorktreeSuffixCacheForTests();
|
|
const b = resolveSessionPath({ projectDir: repo, sessionsDir, ext: ".db" });
|
|
expect(a).toBe(b);
|
|
});
|
|
});
|
|
});
|