mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-10 15:09:00 +02:00
Merge remote-tracking branch 'origin/main' into phantom-askuserquestion-hooks
# Conflicts: # CHANGELOG.md # TODOS.md # VERSION # bin/gstack-settings-hook # package.json # setup
This commit is contained in:
@@ -6,6 +6,7 @@ import * as path from "path";
|
||||
import {
|
||||
toMsysPath,
|
||||
slugFromEnvironment,
|
||||
outermostProjectRoot,
|
||||
resolveSlug,
|
||||
NEEDS_NATIVE_SLUG_ON_WINDOWS,
|
||||
} from "../lib/bin-context";
|
||||
@@ -14,8 +15,21 @@ const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8");
|
||||
|
||||
let tmp: string;
|
||||
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-slug-")); });
|
||||
afterEach(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} });
|
||||
let savedEnvSlug: string | undefined;
|
||||
beforeEach(() => {
|
||||
// realpathSync so the native cwd matches what the bash script's `pwd` reports
|
||||
// (macOS: /var/folders/... is a symlink to /private/var/folders/...).
|
||||
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "gstack-slug-")));
|
||||
// An ambient GSTACK_PROJECT_SLUG (leaked from an operator shell or a sibling
|
||||
// test in a shared-process shard) would override every derivation under test.
|
||||
savedEnvSlug = process.env.GSTACK_PROJECT_SLUG;
|
||||
delete process.env.GSTACK_PROJECT_SLUG;
|
||||
});
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
|
||||
if (savedEnvSlug === undefined) delete process.env.GSTACK_PROJECT_SLUG;
|
||||
else process.env.GSTACK_PROJECT_SLUG = savedEnvSlug;
|
||||
});
|
||||
|
||||
/**
|
||||
* Windows cannot exec bin/gstack-slug -- a `#!/usr/bin/env bash` script with no file
|
||||
@@ -111,3 +125,291 @@ describe("the fallback stays win32-gated", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Walk-up parity: the native fallback must resolve the same OUTERMOST project
|
||||
* root as bin/gstack-slug's `_outermost_project_root` (see
|
||||
* test/gstack-slug-cwd-walk-up.test.ts for the bash-side pins). Before this
|
||||
* port, the native path derived the slug from `git remote get-url origin` in
|
||||
* cwd — the INNERMOST repo — so a Windows session inside a nested/vendored
|
||||
* repo or an artifact-only subdir filed its state under a different slug than
|
||||
* every bash-side consumer.
|
||||
*
|
||||
* Each scenario is run through BOTH implementations on the same fixture
|
||||
* (separate GSTACK_HOMEs so neither reads the other's cache) and pinned to the
|
||||
* same expected slug. The bash leg is skipped on win32, where bash isn't
|
||||
* reliably spawnable — the native leg still pins the ported semantics there.
|
||||
*/
|
||||
describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
|
||||
const SCRIPT = path.join(ROOT, "bin", "gstack-slug");
|
||||
const HAS_BASH = process.platform !== "win32";
|
||||
|
||||
function bashSlug(cwd: string, extraEnv: Record<string, string> = {}): string {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
HOME: path.join(tmp, "bash-home"),
|
||||
GSTACK_HOME: path.join(tmp, "bash-home", ".gstack"),
|
||||
};
|
||||
delete env.GSTACK_PROJECT_SLUG; // only set when a scenario passes it explicitly
|
||||
Object.assign(env, extraEnv);
|
||||
const r = spawnSync("bash", [SCRIPT], { cwd, env, encoding: "utf-8", timeout: 10_000 });
|
||||
const m = (r.stdout || "").match(/^SLUG=([^\n]*)$/m);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
|
||||
const nativeHome = () => path.join(tmp, "native-home");
|
||||
|
||||
/** Assert native === expected, and bash === expected where bash is available. */
|
||||
function expectBoth(cwd: string, expected: string) {
|
||||
expect(slugFromEnvironment(nativeHome(), cwd)).toBe(expected);
|
||||
if (HAS_BASH) expect(bashSlug(cwd)).toBe(expected);
|
||||
}
|
||||
|
||||
test("AC-1: .git at root, artifact-only subdir — slug is the ROOT basename", () => {
|
||||
const projectRoot = path.join(tmp, "loadout");
|
||||
const siteSubdir = path.join(projectRoot, "site");
|
||||
fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true });
|
||||
fs.mkdirSync(path.join(siteSubdir, ".vercel"), { recursive: true });
|
||||
fs.writeFileSync(path.join(siteSubdir, ".vercel", "project.json"), "{}\n");
|
||||
expectBoth(siteSubdir, "loadout");
|
||||
});
|
||||
|
||||
test("AC-1 variant: package.json at root, node_modules-only subdir — ROOT basename", () => {
|
||||
const projectRoot = path.join(tmp, "monorepo");
|
||||
const subdir = path.join(projectRoot, "packages", "web");
|
||||
fs.mkdirSync(subdir, { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, "package.json"), "{}\n");
|
||||
fs.mkdirSync(path.join(subdir, "node_modules"), { recursive: true });
|
||||
expectBoth(subdir, "monorepo");
|
||||
});
|
||||
|
||||
test("AC-2: stale cache (old-bug shape) self-heals to the outermost-root slug", () => {
|
||||
const projectRoot = path.join(tmp, "loadout");
|
||||
const siteSubdir = path.join(projectRoot, "site");
|
||||
fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true });
|
||||
fs.mkdirSync(path.join(siteSubdir, ".vercel"), { recursive: true });
|
||||
// Pre-seed the native cache with the WRONG value (pre-walk-up poisoning:
|
||||
// cached == basename(pwd) while pwd is NOT the project root).
|
||||
const cacheDir = path.join(nativeHome(), "slug-cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, toMsysPath(siteSubdir).replace(/\//g, "_"));
|
||||
fs.writeFileSync(cacheFile, "site");
|
||||
|
||||
expect(slugFromEnvironment(nativeHome(), siteSubdir)).toBe("loadout");
|
||||
// The cache file itself must have been overwritten (self-healing).
|
||||
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("loadout");
|
||||
});
|
||||
|
||||
test("sticky cache (#2212): a cached identity that is NOT the old-bug shape survives", () => {
|
||||
const projectRoot = path.join(tmp, "renamed-project");
|
||||
fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true });
|
||||
const cacheDir = path.join(nativeHome(), "slug-cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, toMsysPath(projectRoot).replace(/\//g, "_"));
|
||||
fs.writeFileSync(cacheFile, "legacy-name");
|
||||
// cached != basename(pwd), so the sticky rule holds — no recompute.
|
||||
expect(slugFromEnvironment(nativeHome(), projectRoot)).toBe("legacy-name");
|
||||
});
|
||||
|
||||
test("AC-3: cwd IS the project root with .git — slug = basename", () => {
|
||||
const projectRoot = path.join(tmp, "myproject");
|
||||
fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true });
|
||||
expectBoth(projectRoot, "myproject");
|
||||
});
|
||||
|
||||
test("AC-4: no markers anywhere on the chain — slug = pwd basename (fallback)", () => {
|
||||
const deep = path.join(tmp, "just", "a", "plain", "folder");
|
||||
fs.mkdirSync(deep, { recursive: true });
|
||||
expectBoth(deep, "folder");
|
||||
});
|
||||
|
||||
test("AC-5: subdir of a repo with a remote — slug derived from the ROOT's remote", () => {
|
||||
// Pins the `git -C "$PROJECT_ROOT"` port: the subdir has no repo of its
|
||||
// own, so the old native path (git in cwd) also reached the parent repo —
|
||||
// but only the walk-up guarantees BOTH implementations root the remote
|
||||
// lookup at the same directory.
|
||||
const projectRoot = path.join(tmp, "realgit");
|
||||
const subdir = path.join(projectRoot, "src", "deep");
|
||||
fs.mkdirSync(subdir, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", projectRoot]);
|
||||
spawnSync("git", ["-C", projectRoot, "remote", "add", "origin", "https://github.com/foo/bar.git"]);
|
||||
expectBoth(subdir, "foo-bar");
|
||||
});
|
||||
|
||||
test("weak marker: README.md at root, artifact-only subdir — ROOT basename", () => {
|
||||
const projectRoot = path.join(tmp, "loadout");
|
||||
const siteSubdir = path.join(projectRoot, "site");
|
||||
fs.mkdirSync(siteSubdir, { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, "README.md"), "# loadout\n");
|
||||
fs.mkdirSync(path.join(siteSubdir, ".vercel"), { recursive: true });
|
||||
expectBoth(siteSubdir, "loadout");
|
||||
});
|
||||
|
||||
test("two-tier: vendored sub-repo with .git wins over parent README (strong > weak)", () => {
|
||||
const projectRoot = path.join(tmp, "loadout");
|
||||
const subRepo = path.join(projectRoot, "starter-pack");
|
||||
fs.mkdirSync(subRepo, { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, "README.md"), "# loadout\n");
|
||||
fs.mkdirSync(path.join(subRepo, ".git"), { recursive: true });
|
||||
expectBoth(subRepo, "starter-pack");
|
||||
});
|
||||
|
||||
test("two-tier: outermost weak wins when no strong marker exists on the chain", () => {
|
||||
const projectRoot = path.join(tmp, "loadout");
|
||||
const subdir = path.join(projectRoot, "docs");
|
||||
fs.mkdirSync(subdir, { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, "README.md"), "# loadout\n");
|
||||
fs.writeFileSync(path.join(subdir, "README.md"), "# docs\n");
|
||||
expectBoth(subdir, "loadout");
|
||||
});
|
||||
|
||||
test("nested repo: outermost .git wins — nested/vendored repos don't split stores", () => {
|
||||
// THE bug this port fixes: the old native path asked the INNERMOST repo's
|
||||
// remote. bin/gstack-slug resolves the OUTERMOST strong marker instead.
|
||||
const outer = path.join(tmp, "outer-project");
|
||||
const inner = path.join(outer, "vendor", "inner-lib");
|
||||
fs.mkdirSync(inner, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", outer]);
|
||||
spawnSync("git", ["-C", outer, "remote", "add", "origin", "git@github.com:acme/outer.git"]);
|
||||
spawnSync("git", ["init", "-q", inner]);
|
||||
spawnSync("git", ["-C", inner, "remote", "add", "origin", "git@github.com:vendor/inner.git"]);
|
||||
expectBoth(inner, "acme-outer");
|
||||
});
|
||||
|
||||
test("LIVE BUG SHAPE: a stray empty .git ancestor no longer degrades the slug (remote-first)", () => {
|
||||
// The exact 2026-08-17 reproduction: an ancestor dir with an empty .git
|
||||
// (not a valid repo, no origin) above a canonical-remote repo. The
|
||||
// pre-remote-first native path resolved PROJECT_ROOT to the stray marker
|
||||
// ancestor, found no origin THERE, and degraded to its basename — filing
|
||||
// every repo under it into one shared ~/.gstack/projects/<basename>/.
|
||||
const strayHome = path.join(tmp, "strayhome");
|
||||
fs.mkdirSync(path.join(strayHome, ".git"), { recursive: true }); // empty — invalid repo
|
||||
const repo = path.join(strayHome, "work", "repo");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", repo]);
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/garrytan/gstack"]);
|
||||
expectBoth(repo, "garrytan-gstack");
|
||||
expect(slugFromEnvironment(nativeHome(), repo)).not.toBe("strayhome");
|
||||
});
|
||||
|
||||
test("nested repos under a stray marker: a no-remote outer cannot shadow an inner remote", () => {
|
||||
// Outermost REMOTE-bearing repo wins — an outer repo whose origin does
|
||||
// not resolve is skipped by the remote walk, so the inner remote-bearing
|
||||
// repo carries identity (parity with bin/gstack-slug's _outermost_remote_repo).
|
||||
const outer = path.join(tmp, "outer-plain");
|
||||
const inner = path.join(outer, "vendor", "inner-lib");
|
||||
fs.mkdirSync(inner, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", outer]); // no origin — marker-only repo
|
||||
spawnSync("git", ["init", "-q", inner]);
|
||||
spawnSync("git", ["-C", inner, "remote", "add", "origin", "git@github.com:vendor/inner.git"]);
|
||||
expectBoth(inner, "vendor-inner");
|
||||
});
|
||||
|
||||
test("degraded-ancestor cache self-heals: the pre-remote-first cached value is rewritten", () => {
|
||||
// Pre-fix, the resolver cached basename(PROJECT_ROOT) for the stray
|
||||
// marker ancestor. cached == the marker root's basename while a
|
||||
// remote-bearing repo BELOW it exists → recompute + heal the cache.
|
||||
const strayHome = path.join(tmp, "strayhome");
|
||||
fs.mkdirSync(path.join(strayHome, ".git"), { recursive: true });
|
||||
const repo = path.join(strayHome, "git", "proj");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", repo]);
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/garrytan/gstack"]);
|
||||
|
||||
const cacheDir = path.join(nativeHome(), "slug-cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, toMsysPath(repo).replace(/\//g, "_"));
|
||||
fs.writeFileSync(cacheFile, "strayhome"); // the degraded value the old resolver cached
|
||||
|
||||
expect(slugFromEnvironment(nativeHome(), repo)).toBe("garrytan-gstack");
|
||||
// The cache file itself must have been overwritten (self-healing).
|
||||
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("garrytan-gstack");
|
||||
});
|
||||
|
||||
test("package.json wrapper root (no .git): sticky basename slug is PRESERVED — heal is stray-repo-shape only", () => {
|
||||
// Legit #2212 shape: a monorepo wrapper anchored by package.json used
|
||||
// gstack before an inner dir grew a remote-bearing repo. The degraded-
|
||||
// ancestor heal must NOT fire — it is restricted to marker roots anchored
|
||||
// by a .git entry whose origin does NOT resolve (the live-bug shape).
|
||||
const wrapper = path.join(tmp, "wrapperproj");
|
||||
const inner = path.join(wrapper, "apps", "web");
|
||||
fs.mkdirSync(inner, { recursive: true });
|
||||
fs.writeFileSync(path.join(wrapper, "package.json"), '{"name":"wrapper"}\n');
|
||||
spawnSync("git", ["init", "-q", inner]);
|
||||
spawnSync("git", ["-C", inner, "remote", "add", "origin", "https://github.com/acme/web.git"]);
|
||||
|
||||
const cacheDir = path.join(nativeHome(), "slug-cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, toMsysPath(inner).replace(/\//g, "_"));
|
||||
fs.writeFileSync(cacheFile, "wrapperproj"); // legit sticky identity
|
||||
|
||||
expect(slugFromEnvironment(nativeHome(), inner)).toBe("wrapperproj"); // NOT healed to acme-web
|
||||
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("wrapperproj");
|
||||
|
||||
// The bash implementation agrees on the same fixture (own home, seeded cache).
|
||||
if (HAS_BASH) {
|
||||
const bashCacheDir = path.join(tmp, "bash-home", ".gstack", "slug-cache");
|
||||
fs.mkdirSync(bashCacheDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(bashCacheDir, toMsysPath(inner).replace(/\//g, "_")), "wrapperproj");
|
||||
expect(bashSlug(inner)).toBe("wrapperproj");
|
||||
}
|
||||
});
|
||||
|
||||
test("sticky identity preserved (#2212): a remote adopted AT the marker root is NOT healed", () => {
|
||||
// Legit sticky shape: the repo that adopted the remote IS the marker root
|
||||
// (remote root == project root), so the degraded-ancestor heal must not
|
||||
// fire even though cached == basename(project root).
|
||||
const repo = path.join(tmp, "stickyproj");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", repo]);
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/x/y.git"]);
|
||||
|
||||
const cacheDir = path.join(nativeHome(), "slug-cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, toMsysPath(repo).replace(/\//g, "_"));
|
||||
fs.writeFileSync(cacheFile, "stickyproj"); // pre-origin basename identity
|
||||
|
||||
expect(slugFromEnvironment(nativeHome(), repo)).toBe("stickyproj");
|
||||
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("stickyproj");
|
||||
});
|
||||
|
||||
test('hostile origin `url = ..` never becomes a dot slug — basename fallback (dot-only guard)', () => {
|
||||
// git accepts `..` as a remote URL. Unchecked, the derived slug would be
|
||||
// ".." — path traversal one level above ~/.gstack/projects/. Both
|
||||
// implementations must reject it and fall through to the basename.
|
||||
const repo = path.join(tmp, "dotty");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["init", "-q", repo]);
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", ".."]);
|
||||
expectBoth(repo, "dotty");
|
||||
});
|
||||
|
||||
test("GSTACK_PROJECT_SLUG env override beats every other resolution path, never cached", () => {
|
||||
const projectRoot = path.join(tmp, "loadout");
|
||||
const siteSubdir = path.join(projectRoot, "site");
|
||||
fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true });
|
||||
fs.mkdirSync(siteSubdir, { recursive: true });
|
||||
|
||||
process.env.GSTACK_PROJECT_SLUG = "custom-override";
|
||||
try {
|
||||
expect(slugFromEnvironment(nativeHome(), siteSubdir)).toBe("custom-override");
|
||||
if (HAS_BASH) {
|
||||
expect(bashSlug(siteSubdir, { GSTACK_PROJECT_SLUG: "custom-override" })).toBe("custom-override");
|
||||
}
|
||||
} finally {
|
||||
delete process.env.GSTACK_PROJECT_SLUG;
|
||||
}
|
||||
// Per-invocation escape hatch, never a durable identity: no cache written.
|
||||
const cacheFile = path.join(nativeHome(), "slug-cache", toMsysPath(siteSubdir).replace(/\//g, "_"));
|
||||
expect(fs.existsSync(cacheFile)).toBe(false);
|
||||
});
|
||||
|
||||
test("outermostProjectRoot terminates on hostile path forms (dirname fixed points)", () => {
|
||||
// Mirrors the windows-free-tests regression on the bash side: mixed-form
|
||||
// paths must hit the dirname fixed point, not loop. A hang here would trip
|
||||
// the suite timeout; reaching the assertions IS the pass.
|
||||
for (const hostile of ["C:/Users/nobody/project", ".", "//server/share/dir"]) {
|
||||
expect(typeof outermostProjectRoot(hostile)).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,7 +166,12 @@ describe('brain-cache endpoint detection', () => {
|
||||
expect(outer).not.toBe('local');
|
||||
});
|
||||
|
||||
test('detectEndpointHash still prefers user scope over project scope (#2499)', async () => {
|
||||
test('detectEndpointHash prefers project-local scope over user scope (#2392 wave)', async () => {
|
||||
// Empirically verified against claude 2.1.233 with hermetic fixtures:
|
||||
// `claude mcp get gbrain` reports "Scope: Local config" when both scopes
|
||||
// define the server — project-local WINS. The old pin here encoded the
|
||||
// opposite (user-first) assumption, which mis-hashed endpoints whenever
|
||||
// the two scopes disagreed.
|
||||
const mod = await importCache();
|
||||
const cj = join(TMP_HOME, 'claude.json');
|
||||
writeFileSync(cj, JSON.stringify({
|
||||
@@ -175,14 +180,14 @@ describe('brain-cache endpoint detection', () => {
|
||||
'/w/repo': { mcpServers: { gbrain: { url: 'https://proj.example/mcp' } } },
|
||||
},
|
||||
}));
|
||||
const userScoped = mod.detectEndpointHash(cj, '/w/repo');
|
||||
// Same file minus the user-scope entry → different hash proves user scope won.
|
||||
const conflictHash = mod.detectEndpointHash(cj, '/w/repo');
|
||||
// Same file minus the USER entry → identical hash proves project scope won.
|
||||
writeFileSync(cj, JSON.stringify({
|
||||
projects: {
|
||||
'/w/repo': { mcpServers: { gbrain: { url: 'https://proj.example/mcp' } } },
|
||||
},
|
||||
}));
|
||||
expect(mod.detectEndpointHash(cj, '/w/repo')).not.toBe(userScoped);
|
||||
expect(mod.detectEndpointHash(cj, '/w/repo')).toBe(conflictHash);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
* Gate-tier, free, pure import + assertion. Runs in <100ms.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { describe, test, expect, afterAll } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
BRAIN_CACHE_ENTITIES,
|
||||
SKILL_DIGEST_SUBSETS,
|
||||
@@ -167,3 +170,59 @@ describe('brain-cache-spec internal consistency', () => {
|
||||
expect(getPreflightSkills().sort()).toEqual(expected.sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('brain-cache MCP scope precedence (C15 pin)', () => {
|
||||
// Claude Code resolves a same-name MCP conflict in favor of the
|
||||
// PROJECT-LOCAL entry (.projects[cwd].mcpServers) over the user-scope
|
||||
// entry (.mcpServers). Verified empirically against claude 2.1.233 with a
|
||||
// hermetic fake $HOME: `claude mcp get gbrain` reported "Scope: Local
|
||||
// config" and the project-local URL when both scopes defined gbrain.
|
||||
// detectEndpointHash must hash the endpoint the project actually talks
|
||||
// to, or a brain switch would never invalidate the cache.
|
||||
const TMP = mkdtempSync(join(tmpdir(), 'brain-cache-precedence-'));
|
||||
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
const cache = () => import('../bin/gstack-brain-cache');
|
||||
const writeFixture = (name: string, cfg: object): string => {
|
||||
const p = join(TMP, name);
|
||||
writeFileSync(p, JSON.stringify(cfg));
|
||||
return p;
|
||||
};
|
||||
const USER_URL = { type: 'http', url: 'https://user.example/mcp' };
|
||||
const PROJ_URL = { type: 'http', url: 'https://proj.example/mcp' };
|
||||
|
||||
test('project-local gbrain entry beats user scope for a cwd inside the project', async () => {
|
||||
const mod = await cache();
|
||||
const conflict = writeFixture('claude-conflict.json', {
|
||||
mcpServers: { gbrain: USER_URL },
|
||||
projects: { '/w/repo': { mcpServers: { gbrain: PROJ_URL } } },
|
||||
});
|
||||
const conflictHash = mod.detectEndpointHash(conflict, '/w/repo/src');
|
||||
// Same hash as the project entry alone → the project-local entry won.
|
||||
const projOnly = writeFixture('claude-proj-only.json', {
|
||||
projects: { '/w/repo': { mcpServers: { gbrain: PROJ_URL } } },
|
||||
});
|
||||
expect(conflictHash).toBe(mod.detectEndpointHash(projOnly, '/w/repo/src'));
|
||||
// And NOT the user entry's hash.
|
||||
const userOnly = writeFixture('claude-user-only.json', {
|
||||
mcpServers: { gbrain: USER_URL },
|
||||
});
|
||||
expect(conflictHash).not.toBe(mod.detectEndpointHash(userOnly, '/w/repo/src'));
|
||||
});
|
||||
|
||||
test('user scope still resolves when the cwd has no project-local entry', async () => {
|
||||
const mod = await cache();
|
||||
const cj = writeFixture('claude-user-fallback.json', {
|
||||
mcpServers: { gbrain: USER_URL },
|
||||
projects: { '/other/repo': { mcpServers: { gbrain: PROJ_URL } } },
|
||||
});
|
||||
const hash = mod.detectEndpointHash(cj, '/w/unrelated');
|
||||
expect(hash).toHaveLength(8);
|
||||
// Matches the user-only hash — the OTHER project's entry is invisible
|
||||
// outside its own tree.
|
||||
const userOnly = writeFixture('claude-user-only-2.json', {
|
||||
mcpServers: { gbrain: USER_URL },
|
||||
});
|
||||
expect(hash).toBe(mod.detectEndpointHash(userOnly, '/w/unrelated'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,11 +48,15 @@ describe('gstack-brain-sync — Windows path/exec invariants', () => {
|
||||
expect(SRC.indexOf(CR_STRIP)).toBeLessThan(SRC.indexOf('add -f -- "$p"'));
|
||||
});
|
||||
|
||||
test('inline enqueue appends one atomic record at a time (codex P2 #1)', () => {
|
||||
expect(SRC).toContain('os.O_APPEND');
|
||||
expect(SRC).toContain('os.write(fd');
|
||||
// No buffered batch write to the queue (the interleave-corruption shape).
|
||||
test('inline enqueue writes one atomic record at a time (codex P2 #1, spool form)', () => {
|
||||
// The invariant is per-record write atomicity (no interleave corruption).
|
||||
// Pre-spool this was O_APPEND on the shared queue file; the spool design
|
||||
// satisfies it more strongly: one FILE per record, tmp write + atomic
|
||||
// os.replace — nothing shared to interleave.
|
||||
expect(SRC).toContain('os.replace(tmp');
|
||||
// No shared-file append anywhere (the interleave-corruption shape).
|
||||
expect(SRC).not.toContain('open(queue_path, "a"');
|
||||
expect(SRC).not.toContain('os.O_APPEND');
|
||||
});
|
||||
|
||||
test('skip-list is normalized on BOTH discover and drain sides (codex P2 #2)', () => {
|
||||
|
||||
+318
-38
@@ -51,6 +51,28 @@ function git(args: string[], cwd?: string) {
|
||||
return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 };
|
||||
}
|
||||
|
||||
// ---- spool helpers (maildir-style queue: one FILE per record) ----
|
||||
// Writers create <epoch>-<pid>-<uniq>.json under .brain-queue.d/ via tmp +
|
||||
// atomic rename; the drain deletes exactly the files it snapshotted. The
|
||||
// legacy single-file .brain-queue.jsonl exists only as a migration source.
|
||||
const spoolDir = () => path.join(tmpHome, '.brain-queue.d');
|
||||
const spoolFiles = () =>
|
||||
fs.existsSync(spoolDir())
|
||||
? fs.readdirSync(spoolDir()).filter((f) => f.endsWith('.json')).sort()
|
||||
: [];
|
||||
const spoolText = () =>
|
||||
spoolFiles()
|
||||
.map((f) => fs.readFileSync(path.join(spoolDir(), f), 'utf-8'))
|
||||
.join('');
|
||||
let spoolSeq = 0;
|
||||
function seedSpool(record: string): string {
|
||||
fs.mkdirSync(spoolDir(), { recursive: true });
|
||||
spoolSeq += 1;
|
||||
const name = `${Math.floor(Date.now() / 1000)}-${process.pid}-t${spoolSeq}.json`;
|
||||
fs.writeFileSync(path.join(spoolDir(), name), record.endsWith('\n') ? record : record + '\n');
|
||||
return name;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-home-'));
|
||||
bareRemote = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-remote-'));
|
||||
@@ -130,6 +152,7 @@ describe('gstack-brain-enqueue', () => {
|
||||
test('no-op when feature not initialized', () => {
|
||||
const r = run(['gstack-brain-enqueue', 'projects/foo/learnings.jsonl']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(spoolDir())).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmpHome, '.brain-queue.jsonl'))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -137,18 +160,22 @@ describe('gstack-brain-enqueue', () => {
|
||||
fs.mkdirSync(path.join(tmpHome, '.git'), { recursive: true });
|
||||
const r = run(['gstack-brain-enqueue', 'projects/foo/learnings.jsonl']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(path.join(tmpHome, '.brain-queue.jsonl'))).toBe(false);
|
||||
expect(fs.existsSync(spoolDir())).toBe(false);
|
||||
});
|
||||
|
||||
test('enqueues when mode is full and .git exists', () => {
|
||||
test('enqueues one spool file when mode is full and .git exists', () => {
|
||||
fs.mkdirSync(path.join(tmpHome, '.git'), { recursive: true });
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||||
run(['gstack-brain-enqueue', 'projects/foo/learnings.jsonl']);
|
||||
const queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
|
||||
expect(queue).toContain('projects/foo/learnings.jsonl');
|
||||
const obj = JSON.parse(queue.trim());
|
||||
const files = spoolFiles();
|
||||
expect(files.length).toBe(1);
|
||||
// Sortable maildir name: <epoch>-<pid>-<uniq>.json.
|
||||
expect(files[0]).toMatch(/^\d+-\d+-\d+\.json$/);
|
||||
const obj = JSON.parse(fs.readFileSync(path.join(spoolDir(), files[0]), 'utf-8').trim());
|
||||
expect(obj.file).toBe('projects/foo/learnings.jsonl');
|
||||
expect(obj.ts).toBeTruthy();
|
||||
// No tmp-file droppings left behind.
|
||||
expect(fs.readdirSync(spoolDir()).filter((f) => f.startsWith('.tmp-')).length).toBe(0);
|
||||
});
|
||||
|
||||
test('skip list honored', () => {
|
||||
@@ -157,12 +184,11 @@ describe('gstack-brain-enqueue', () => {
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-skip.txt'), 'projects/foo/secret.jsonl\n');
|
||||
run(['gstack-brain-enqueue', 'projects/foo/secret.jsonl']);
|
||||
run(['gstack-brain-enqueue', 'projects/foo/ok.jsonl']);
|
||||
const queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
|
||||
expect(queue).not.toContain('secret.jsonl');
|
||||
expect(queue).toContain('ok.jsonl');
|
||||
expect(spoolText()).not.toContain('secret.jsonl');
|
||||
expect(spoolText()).toContain('ok.jsonl');
|
||||
});
|
||||
|
||||
test('concurrent enqueues all land (atomic append)', async () => {
|
||||
test('concurrent enqueues all land (one spool file per record)', async () => {
|
||||
fs.mkdirSync(path.join(tmpHome, '.git'), { recursive: true });
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||||
const procs = [];
|
||||
@@ -176,9 +202,10 @@ describe('gstack-brain-enqueue', () => {
|
||||
}));
|
||||
}
|
||||
await Promise.all(procs);
|
||||
const queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
|
||||
const lines = queue.trim().split('\n').filter(Boolean);
|
||||
expect(lines.length).toBe(10);
|
||||
expect(spoolFiles().length).toBe(10);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(spoolText()).toContain(`file-${i}.jsonl`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no args does not crash', () => {
|
||||
@@ -366,9 +393,8 @@ describe('gstack-brain-sync egress receipt gate', () => {
|
||||
expect(refused.stderr).toContain('EGRESS_RECEIPT_FAILED');
|
||||
expect(refused.stderr).toContain('Fix: chmod -R u+w');
|
||||
expect(refused.stderr).toContain('ATTEMPTS to send off-machine');
|
||||
// Queue intact (receipt is written BEFORE the commit consumes it).
|
||||
const queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
|
||||
expect(queue).toContain('projects/p/learnings.jsonl');
|
||||
// Spool intact (receipt is written BEFORE finalize consumes records).
|
||||
expect(spoolText()).toContain('projects/p/learnings.jsonl');
|
||||
// No local commit was created.
|
||||
expect(git(['rev-list', '--count', 'HEAD']).stdout.trim()).toBe(commitsBefore);
|
||||
// Nothing reached the remote.
|
||||
@@ -433,19 +459,43 @@ describe('gstack-brain-uninstall', () => {
|
||||
// --discover-new: cursor-based change detection
|
||||
// ---------------------------------------------------------------
|
||||
describe('gstack-brain-sync --discover-new', () => {
|
||||
test('enqueues new allowlisted files; idempotent on re-run', () => {
|
||||
test('enqueues new allowlisted files as spool records; idempotent on re-run', () => {
|
||||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||||
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
|
||||
run(['gstack-brain-sync', '--discover-new']);
|
||||
let queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
|
||||
expect(queue).toContain('retros/week-1.md');
|
||||
// Clear queue, run again — idempotent (no new entries).
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '');
|
||||
expect(spoolText()).toContain('retros/week-1.md');
|
||||
// Clear the spool, run again — idempotent (no new records).
|
||||
for (const f of spoolFiles()) fs.unlinkSync(path.join(spoolDir(), f));
|
||||
run(['gstack-brain-sync', '--discover-new']);
|
||||
queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
|
||||
expect(queue.trim()).toBe('');
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Enqueue tmp janitor: a writer killed between its tmp write and the
|
||||
// atomic rename orphans a .tmp-* file forever (it never becomes a
|
||||
// record, nothing else touches it). The drain reaps ones older than
|
||||
// 1 hour, inside its lock; fresh ones (in-flight enqueues) survive.
|
||||
// ---------------------------------------------------------------
|
||||
describe('enqueue tmp janitor', () => {
|
||||
test('an orphaned .tmp-* older than 1h is reaped on --once; a fresh one survives', () => {
|
||||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||||
fs.mkdirSync(spoolDir(), { recursive: true });
|
||||
|
||||
const oldTmp = path.join(spoolDir(), '.tmp-99999-x1');
|
||||
fs.writeFileSync(oldTmp, '{"file":"projects/p/learnings.jsonl"}\n');
|
||||
const past = new Date(Date.now() - 2 * 3600 * 1000);
|
||||
fs.utimesSync(oldTmp, past, past);
|
||||
|
||||
const freshTmp = path.join(spoolDir(), '.tmp-99999-x2');
|
||||
fs.writeFileSync(freshTmp, '{"file":"projects/p/learnings.jsonl"}\n');
|
||||
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
expect(fs.existsSync(oldTmp)).toBe(false); // orphan reaped
|
||||
expect(fs.existsSync(freshTmp)).toBe(true); // in-flight write untouched
|
||||
});
|
||||
});
|
||||
|
||||
@@ -458,7 +508,6 @@ describe('#2549 queue integrity', () => {
|
||||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', mode]);
|
||||
}
|
||||
const queueText = () => fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
|
||||
const statusJson = () => JSON.parse(fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8'));
|
||||
|
||||
test('privacy-held entries are RETAINED and classified, not wiped as "no allowlisted changes"', () => {
|
||||
@@ -470,9 +519,9 @@ describe('#2549 queue integrity', () => {
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
// The exact #2549 repro: the old code truncated the queue here and said
|
||||
// "no allowlisted changes in queue". The entry must survive, and the
|
||||
// "no allowlisted changes in queue". The record must survive, and the
|
||||
// status must attribute the hold honestly.
|
||||
expect(queueText()).toContain('projects/p/timeline.jsonl');
|
||||
expect(spoolText()).toContain('projects/p/timeline.jsonl');
|
||||
const s = statusJson();
|
||||
expect(s.status).toBe('idle');
|
||||
expect(s.message).toContain('privacy-held retained');
|
||||
@@ -484,13 +533,13 @@ describe('#2549 queue integrity', () => {
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
// Unmatched: no allowlist glob covers .txt scratch files.
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/scratch.txt'), 'x\n');
|
||||
fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/scratch.txt"}\n');
|
||||
seedSpool('{"file":"projects/p/scratch.txt"}');
|
||||
// Missing: allowlisted name that does not exist on disk.
|
||||
fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/learnings.jsonl"}\n');
|
||||
seedSpool('{"file":"projects/p/learnings.jsonl"}');
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(queueText()).not.toContain('scratch.txt');
|
||||
expect(queueText()).not.toContain('learnings.jsonl');
|
||||
expect(spoolText()).not.toContain('scratch.txt');
|
||||
expect(spoolText()).not.toContain('learnings.jsonl');
|
||||
const s = statusJson();
|
||||
expect(s.message).toContain('1 unmatched dropped');
|
||||
expect(s.message).toContain('1 missing dropped');
|
||||
@@ -504,17 +553,25 @@ describe('#2549 queue integrity', () => {
|
||||
expect(detail.dropped.missing).toContain('projects/p/learnings.jsonl');
|
||||
});
|
||||
|
||||
test('an unparseable queue line is preserved, never destroyed', () => {
|
||||
test('an unparseable legacy queue line migrates as-is and is quarantined, never destroyed', () => {
|
||||
// The line lands in the legacy single-file queue (pre-spool writer);
|
||||
// migration converts it verbatim to a spool record, and the drain moves
|
||||
// what it cannot parse into quarantine (never deletes it, and never
|
||||
// leaves it re-warning at every boundary).
|
||||
initWithMode('full');
|
||||
fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'not json at all\n');
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(queueText()).toContain('not json at all');
|
||||
const qDir = path.join(spoolDir(), 'quarantine');
|
||||
expect(fs.existsSync(qDir)).toBe(true);
|
||||
const qFiles = fs.readdirSync(qDir);
|
||||
expect(qFiles.length).toBe(1);
|
||||
expect(fs.readFileSync(path.join(qDir, qFiles[0]), 'utf-8')).toContain('not json at all');
|
||||
});
|
||||
|
||||
test('surgical rewrite: a synced entry leaves the queue while a held sibling survives the same drain', () => {
|
||||
// Proves the rewrite is a live filtered rewrite, not a truncation: two
|
||||
// entries drain in one --once, one stages+pushes, one is mode-held.
|
||||
test('finalize: a synced record leaves the spool while a held sibling survives the same drain', () => {
|
||||
// Proves finalize is a per-record delete, not a truncation: two records
|
||||
// drain in one --once, one stages+pushes, one is mode-held.
|
||||
initWithMode('artifacts-only');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","insight":"y","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
@@ -523,8 +580,8 @@ describe('#2549 queue integrity', () => {
|
||||
run(['gstack-brain-enqueue', 'projects/p/timeline.jsonl']);
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(queueText()).not.toContain('learnings.jsonl'); // synced, removed
|
||||
expect(queueText()).toContain('timeline.jsonl'); // held, retained
|
||||
expect(spoolText()).not.toContain('learnings.jsonl'); // synced, removed
|
||||
expect(spoolText()).toContain('timeline.jsonl'); // held, retained
|
||||
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' });
|
||||
expect(log.stdout).toMatch(/sync: 1 file/);
|
||||
});
|
||||
@@ -550,8 +607,8 @@ describe('#2549 queue integrity', () => {
|
||||
const s = statusJson();
|
||||
expect(s.status).toBe('push_failed');
|
||||
expect(s.message).toContain('commit retained locally');
|
||||
// Drained path left the queue — it lives in the local commit now.
|
||||
expect(queueText()).not.toContain('learnings.jsonl');
|
||||
// Drained record left the spool — it lives in the local commit now.
|
||||
expect(spoolText()).not.toContain('learnings.jsonl');
|
||||
// The commit exists locally, ahead of origin.
|
||||
const ahead = git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim();
|
||||
expect(Number(ahead)).toBeGreaterThan(0);
|
||||
@@ -680,3 +737,226 @@ describe('#2549 queue integrity', () => {
|
||||
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// C12 spool queue: per-record files kill the enqueue/drain race.
|
||||
// One FILE per record under .brain-queue.d/ — writer and drainer never
|
||||
// share an inode, so the lockless append-vs-rewrite race is structurally
|
||||
// gone. Crash semantics are at-least-once (unfinalized records re-drain).
|
||||
// ---------------------------------------------------------------
|
||||
describe('C12 spool queue', () => {
|
||||
function initWithMode(mode: string) {
|
||||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', mode]);
|
||||
}
|
||||
const remoteLog = () =>
|
||||
spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' }).stdout;
|
||||
|
||||
test('two rapid enqueues of different paths create two spool files; one drain syncs both', () => {
|
||||
initWithMode('full');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
|
||||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||
run(['gstack-brain-enqueue', 'retros/week-1.md']);
|
||||
expect(spoolFiles().length).toBe(2);
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
expect(remoteLog()).toMatch(/sync: 2 file/);
|
||||
});
|
||||
|
||||
test('a record created after a drain survives untouched and drains on the NEXT --once', () => {
|
||||
// Structural form of the concurrent-append test: finalize deletes only
|
||||
// snapshot-manifest files, so a record the drain never listed cannot be
|
||||
// touched — whether it lands mid-drain or after.
|
||||
initWithMode('full');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
// New record arrives (a writer that raced the previous drain).
|
||||
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
|
||||
run(['gstack-brain-enqueue', 'retros/week-1.md']);
|
||||
const [pending] = spoolFiles();
|
||||
expect(pending).toBeTruthy();
|
||||
const pendingContent = fs.readFileSync(path.join(spoolDir(), pending), 'utf-8');
|
||||
expect(pendingContent).toContain('retros/week-1.md');
|
||||
// Untouched by the completed drain; the NEXT drain delivers it.
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||||
});
|
||||
|
||||
test('at-least-once: a drain that fails before finalize leaves every spool file for the next run', () => {
|
||||
if (process.platform === 'win32' || process.getuid?.() === 0) return; // chmod advisory there
|
||||
initWithMode('full');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
|
||||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||
run(['gstack-brain-enqueue', 'retros/week-1.md']);
|
||||
const seeded = spoolFiles();
|
||||
expect(seeded.length).toBe(2);
|
||||
|
||||
// Break the egress-receipt ledger: the drain fails AFTER staging but
|
||||
// BEFORE any commit or finalize — simulating a crash mid-drain.
|
||||
fs.mkdirSync(path.join(tmpHome, 'security'), { recursive: true });
|
||||
fs.chmodSync(path.join(tmpHome, 'security'), 0o500);
|
||||
try {
|
||||
const refused = run(['gstack-brain-sync', '--once']);
|
||||
expect(refused.status).toBe(1);
|
||||
// The exact same spool files are still present — nothing consumed.
|
||||
expect(spoolFiles()).toEqual(seeded);
|
||||
} finally {
|
||||
fs.chmodSync(path.join(tmpHome, 'security'), 0o700);
|
||||
}
|
||||
|
||||
// Next run re-drains the surviving records.
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
expect(remoteLog()).toMatch(/sync: 2 file/);
|
||||
});
|
||||
|
||||
test('legacy migration: .brain-queue.jsonl lines convert to spool records, nothing lost', () => {
|
||||
// Pre-spool writers appended to the single-file queue. Three lines: two
|
||||
// stageable artifacts, one behavioral (mode-held under artifacts-only).
|
||||
initWithMode('artifacts-only');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/timeline.jsonl'), '{"skill":"x","event":"started"}\n');
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-queue.jsonl'),
|
||||
'{"file":"projects/p/learnings.jsonl","ts":"2026-01-01T00:00:00Z"}\n' +
|
||||
'{"file":"retros/week-1.md","ts":"2026-01-01T00:00:01Z"}\n' +
|
||||
'{"file":"projects/p/timeline.jsonl","ts":"2026-01-01T00:00:02Z"}\n');
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
// Legacy file consumed; no .migrating remnant.
|
||||
expect(fs.existsSync(path.join(tmpHome, '.brain-queue.jsonl'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmpHome, '.brain-queue.jsonl.migrating'))).toBe(false);
|
||||
// Both artifacts synced; the behavioral record survives as a spool file.
|
||||
expect(remoteLog()).toMatch(/sync: 2 file/);
|
||||
expect(spoolText()).toContain('projects/p/timeline.jsonl');
|
||||
expect(spoolText()).not.toContain('learnings.jsonl');
|
||||
});
|
||||
|
||||
test('an unparseable spool record is quarantined with a warning; the drain continues', () => {
|
||||
initWithMode('full');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||
const badFile = seedSpool('this is not json');
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('unparseable');
|
||||
// The good sibling synced; the unreadable record was never destroyed —
|
||||
// it moved to quarantine so it stops re-warning at every boundary.
|
||||
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||||
expect(spoolFiles()).toEqual([]);
|
||||
const qPath = path.join(spoolDir(), 'quarantine', badFile);
|
||||
expect(fs.existsSync(qPath)).toBe(true);
|
||||
expect(fs.readFileSync(qPath, 'utf-8')).toContain('this is not json');
|
||||
});
|
||||
|
||||
test('--status queue_depth counts spool records plus unmigrated legacy lines', () => {
|
||||
initWithMode('full');
|
||||
seedSpool('{"file":"projects/p/a.jsonl","ts":"t"}');
|
||||
seedSpool('{"file":"projects/p/b.jsonl","ts":"t"}');
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/c.jsonl","ts":"t"}\n');
|
||||
const r = run(['gstack-brain-sync', '--status']);
|
||||
expect(r.status).toBe(0);
|
||||
const supplemental = JSON.parse(r.stdout.trim().split('\n').pop()!);
|
||||
expect(supplemental.queue_depth).toBe(3);
|
||||
});
|
||||
|
||||
test('G1: a malformed pulled privacy map (["bad"]) holds the queue — warns, deletes NOTHING, next run re-drains', () => {
|
||||
// Remotely triggerable kill vector: the privacy map arrives via the
|
||||
// artifacts-repo pull. A non-dict entry used to raise mid-classification
|
||||
// AFTER the snapshot manifest was written, and the old finalize polarity
|
||||
// ("delete unless retained") then unlinked EVERY snapshotted record.
|
||||
initWithMode('full');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||
const seeded = spoolFiles();
|
||||
expect(seeded.length).toBe(1);
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '["bad"]');
|
||||
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('privacy map');
|
||||
// Zero records deleted; nothing pushed.
|
||||
expect(spoolFiles()).toEqual(seeded);
|
||||
expect(remoteLog()).not.toMatch(/sync:/);
|
||||
|
||||
// Fix the map: the surviving queue re-drains and syncs.
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '[]');
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||||
});
|
||||
|
||||
test('G1: a classifier that dies AFTER the snapshot write consumes nothing (call-site exit check + explicit-delete finalize)', () => {
|
||||
// A dict entry with a non-string pattern passes the shape filter but
|
||||
// raises inside fnmatch DURING classification — the post-manifest crash
|
||||
// window (same shape as ENOSPC/OOM mid-run). The call site must see the
|
||||
// nonzero exit, warn, skip finalize, and leave everything queued.
|
||||
initWithMode('full');
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||
const seeded = spoolFiles();
|
||||
expect(seeded.length).toBe(1);
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '[{"pattern": 123}]');
|
||||
|
||||
const r = run(['gstack-brain-sync', '--once']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('classification failed');
|
||||
expect(spoolFiles()).toEqual(seeded); // zero records deleted
|
||||
expect(remoteLog()).not.toMatch(/sync:/);
|
||||
const status = JSON.parse(fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8'));
|
||||
expect(status.status).toBe('error');
|
||||
expect(status.message).toContain('queue preserved');
|
||||
|
||||
// Fix the map: the surviving queue re-drains and syncs.
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '[]');
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||||
});
|
||||
|
||||
test('G1: finalize is explicit-delete-only and the fast path is .migrating-aware (static pins)', () => {
|
||||
const src = fs.readFileSync(path.join(BIN, 'gstack-brain-sync'), 'utf-8');
|
||||
// The compute call site checks the python exit status before finalizing.
|
||||
expect(src).toMatch(/if ! compute_paths_to_stage /);
|
||||
// finalize_queue takes the staged-paths file and deletes only staged ∪ dropped.
|
||||
expect(src).toContain('deletable = staged | dropped');
|
||||
expect(src).toContain('if p not in deletable:');
|
||||
// The empty fast path also treats a leftover .migrating file as non-idle.
|
||||
expect(src).toMatch(/spool_has_records && \[ ! -s "\$QUEUE" \] && \[ ! -s "\$QUEUE\.migrating" \]/);
|
||||
});
|
||||
|
||||
test('--drop-queue keeps the --yes gate and counts spool + legacy entries', () => {
|
||||
initWithMode('full');
|
||||
seedSpool('{"file":"projects/p/a.jsonl","ts":"t"}');
|
||||
seedSpool('{"file":"projects/p/b.jsonl","ts":"t"}');
|
||||
fs.writeFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/c.jsonl","ts":"t"}\n');
|
||||
const refused = run(['gstack-brain-sync', '--drop-queue']);
|
||||
expect(refused.status).toBe(1);
|
||||
expect(refused.stderr).toContain('--yes');
|
||||
expect(spoolFiles().length).toBe(2);
|
||||
const dropped = run(['gstack-brain-sync', '--drop-queue', '--yes']);
|
||||
expect(dropped.status).toBe(0);
|
||||
expect(dropped.stdout).toContain('dropped 3 queue entries');
|
||||
expect(spoolFiles().length).toBe(0);
|
||||
expect(fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8')).toBe('');
|
||||
const again = run(['gstack-brain-sync', '--drop-queue', '--yes']);
|
||||
expect(again.stdout).toContain('queue already empty');
|
||||
});
|
||||
});
|
||||
|
||||
+13
-3
@@ -515,7 +515,7 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
|
||||
_GBRAIN_MCP_MODE="none"
|
||||
_GBRAIN_MCP_ENTRY=""
|
||||
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/")) or ($cwd | startswith($k + "\\"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // .mcpServers.gbrain // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
|
||||
case "$_GBRAIN_MCP_TYPE" in
|
||||
url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
|
||||
@@ -555,7 +555,11 @@ if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
|
||||
echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})"
|
||||
elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
|
||||
_BRAIN_QUEUE_DEPTH=0
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ')
|
||||
# Spool-dir queue (one file per record); legacy .brain-queue.jsonl lines are
|
||||
# counted too until the drain migrates them.
|
||||
[ -d "$_GSTACK_HOME/.brain-queue.d" ] && _BRAIN_QUEUE_DEPTH=$(find "$_GSTACK_HOME/.brain-queue.d" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ') ))
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl.migrating" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl.migrating" | tr -d ' ') ))
|
||||
_BRAIN_LAST_PUSH="never"
|
||||
[ -f "$_GSTACK_HOME/.brain-last-push" ] && _BRAIN_LAST_PUSH=$(cat "$_GSTACK_HOME/.brain-last-push" 2>/dev/null || echo never)
|
||||
echo "ARTIFACTS_SYNC: mode=$_BRAIN_SYNC_MODE | last_push=$_BRAIN_LAST_PUSH | queue=$_BRAIN_QUEUE_DEPTH"
|
||||
@@ -773,7 +777,13 @@ Escalate after 3 failed attempts, uncertain security-sensitive changes, or scope
|
||||
|
||||
## Operational Self-Improvement
|
||||
|
||||
Before completing, if you discovered a durable project quirk or command fix that would save 5+ minutes next time, log it:
|
||||
Before completing, review the session for durable learnings and log each one —
|
||||
this step ALWAYS runs, it is not conditional on something feeling noteworthy
|
||||
(#2402: 43 of 44 learnings came from explicit /learn because "if you
|
||||
discovered" read as optional). A durable learning is a project quirk, command
|
||||
fix, pitfall, or pattern that would save 5+ minutes in a future session. If
|
||||
the review genuinely surfaces none, state "No durable learnings this session"
|
||||
in your completion summary — an explicit empty result, not a skipped step.
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"SKILL_NAME","type":"operational","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"observed"}'
|
||||
|
||||
+15
-5
@@ -501,7 +501,7 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
|
||||
_GBRAIN_MCP_MODE="none"
|
||||
_GBRAIN_MCP_ENTRY=""
|
||||
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/")) or ($cwd | startswith($k + "\\"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // .mcpServers.gbrain // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
|
||||
case "$_GBRAIN_MCP_TYPE" in
|
||||
url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
|
||||
@@ -541,7 +541,11 @@ if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
|
||||
echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})"
|
||||
elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
|
||||
_BRAIN_QUEUE_DEPTH=0
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ')
|
||||
# Spool-dir queue (one file per record); legacy .brain-queue.jsonl lines are
|
||||
# counted too until the drain migrates them.
|
||||
[ -d "$_GSTACK_HOME/.brain-queue.d" ] && _BRAIN_QUEUE_DEPTH=$(find "$_GSTACK_HOME/.brain-queue.d" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ') ))
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl.migrating" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl.migrating" | tr -d ' ') ))
|
||||
_BRAIN_LAST_PUSH="never"
|
||||
[ -f "$_GSTACK_HOME/.brain-last-push" ] && _BRAIN_LAST_PUSH=$(cat "$_GSTACK_HOME/.brain-last-push" 2>/dev/null || echo never)
|
||||
echo "ARTIFACTS_SYNC: mode=$_BRAIN_SYNC_MODE | last_push=$_BRAIN_LAST_PUSH | queue=$_BRAIN_QUEUE_DEPTH"
|
||||
@@ -781,7 +785,13 @@ Escalate after 3 failed attempts, uncertain security-sensitive changes, or scope
|
||||
|
||||
## Operational Self-Improvement
|
||||
|
||||
Before completing, if you discovered a durable project quirk or command fix that would save 5+ minutes next time, log it:
|
||||
Before completing, review the session for durable learnings and log each one —
|
||||
this step ALWAYS runs, it is not conditional on something feeling noteworthy
|
||||
(#2402: 43 of 44 learnings came from explicit /learn because "if you
|
||||
discovered" read as optional). A durable learning is a project quirk, command
|
||||
fix, pitfall, or pattern that would save 5+ minutes in a future session. If
|
||||
the review genuinely surfaces none, state "No durable learnings this session"
|
||||
in your completion summary — an explicit empty result, not a skipped step.
|
||||
|
||||
```bash
|
||||
$GSTACK_BIN/gstack-learnings-log '{"skill":"SKILL_NAME","type":"operational","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"observed"}'
|
||||
@@ -2024,7 +2034,7 @@ Before reviewing code quality, check: **did they build what was requested — no
|
||||
|
||||
Review the diff for structural issues that tests don't catch.
|
||||
|
||||
1. Read `.agents/skills/gstack/review/checklist.md`. If the file cannot be read, **STOP** and report the error.
|
||||
1. Read `$GSTACK_ROOT/review/checklist.md`. If the file cannot be read, **STOP** and report the error.
|
||||
|
||||
2. Run `git diff origin/<base>` to get the full diff (scoped to feature changes against the freshly-fetched base branch).
|
||||
|
||||
@@ -2206,7 +2216,7 @@ Save the review output — it goes into the PR body in Step 19.
|
||||
|
||||
**Subagent prompt:**
|
||||
|
||||
> You are classifying Greptile review comments for a /ship workflow. Read `.agents/skills/gstack/review/greptile-triage.md` and follow the fetch, filter, classify, and **escalation detection** steps. Do NOT fix code, do NOT reply to comments, do NOT commit — report only.
|
||||
> You are classifying Greptile review comments for a /ship workflow. Read `$GSTACK_ROOT/review/greptile-triage.md` and follow the fetch, filter, classify, and **escalation detection** steps. Do NOT fix code, do NOT reply to comments, do NOT commit — report only.
|
||||
>
|
||||
> For each comment, assign: `classification` (`valid_actionable`, `already_fixed`, `false_positive`, `suppressed`), `escalation_tier` (1 or 2), the file:line or [top-level] tag, body summary, and permalink URL.
|
||||
>
|
||||
|
||||
+15
-5
@@ -503,7 +503,7 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
|
||||
_GBRAIN_MCP_MODE="none"
|
||||
_GBRAIN_MCP_ENTRY=""
|
||||
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/")) or ($cwd | startswith($k + "\\"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // .mcpServers.gbrain // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
|
||||
case "$_GBRAIN_MCP_TYPE" in
|
||||
url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
|
||||
@@ -543,7 +543,11 @@ if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
|
||||
echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})"
|
||||
elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
|
||||
_BRAIN_QUEUE_DEPTH=0
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ')
|
||||
# Spool-dir queue (one file per record); legacy .brain-queue.jsonl lines are
|
||||
# counted too until the drain migrates them.
|
||||
[ -d "$_GSTACK_HOME/.brain-queue.d" ] && _BRAIN_QUEUE_DEPTH=$(find "$_GSTACK_HOME/.brain-queue.d" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ') ))
|
||||
[ -f "$_GSTACK_HOME/.brain-queue.jsonl.migrating" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl.migrating" | tr -d ' ') ))
|
||||
_BRAIN_LAST_PUSH="never"
|
||||
[ -f "$_GSTACK_HOME/.brain-last-push" ] && _BRAIN_LAST_PUSH=$(cat "$_GSTACK_HOME/.brain-last-push" 2>/dev/null || echo never)
|
||||
echo "ARTIFACTS_SYNC: mode=$_BRAIN_SYNC_MODE | last_push=$_BRAIN_LAST_PUSH | queue=$_BRAIN_QUEUE_DEPTH"
|
||||
@@ -761,7 +765,13 @@ Escalate after 3 failed attempts, uncertain security-sensitive changes, or scope
|
||||
|
||||
## Operational Self-Improvement
|
||||
|
||||
Before completing, if you discovered a durable project quirk or command fix that would save 5+ minutes next time, log it:
|
||||
Before completing, review the session for durable learnings and log each one —
|
||||
this step ALWAYS runs, it is not conditional on something feeling noteworthy
|
||||
(#2402: 43 of 44 learnings came from explicit /learn because "if you
|
||||
discovered" read as optional). A durable learning is a project quirk, command
|
||||
fix, pitfall, or pattern that would save 5+ minutes in a future session. If
|
||||
the review genuinely surfaces none, state "No durable learnings this session"
|
||||
in your completion summary — an explicit empty result, not a skipped step.
|
||||
|
||||
```bash
|
||||
$GSTACK_BIN/gstack-learnings-log '{"skill":"SKILL_NAME","type":"operational","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"observed"}'
|
||||
@@ -2031,7 +2041,7 @@ Before reviewing code quality, check: **did they build what was requested — no
|
||||
|
||||
Review the diff for structural issues that tests don't catch.
|
||||
|
||||
1. Read `.factory/skills/gstack/review/checklist.md`. If the file cannot be read, **STOP** and report the error.
|
||||
1. Read `$GSTACK_ROOT/review/checklist.md`. If the file cannot be read, **STOP** and report the error.
|
||||
|
||||
2. Run `git diff origin/<base>` to get the full diff (scoped to feature changes against the freshly-fetched base branch).
|
||||
|
||||
@@ -2438,7 +2448,7 @@ Save the review output — it goes into the PR body in Step 19.
|
||||
|
||||
**Subagent prompt:**
|
||||
|
||||
> You are classifying Greptile review comments for a /ship workflow. Read `.factory/skills/gstack/review/greptile-triage.md` and follow the fetch, filter, classify, and **escalation detection** steps. Do NOT fix code, do NOT reply to comments, do NOT commit — report only.
|
||||
> You are classifying Greptile review comments for a /ship workflow. Read `$GSTACK_ROOT/review/greptile-triage.md` and follow the fetch, filter, classify, and **escalation detection** steps. Do NOT fix code, do NOT reply to comments, do NOT commit — report only.
|
||||
>
|
||||
> For each comment, assign: `classification` (`valid_actionable`, `already_fixed`, `false_positive`, `suppressed`), `escalation_tier` (1 or 2), the file:line or [top-level] tag, body summary, and permalink URL.
|
||||
>
|
||||
|
||||
@@ -594,13 +594,16 @@ describe("lib/gbrain-local-status — bearer-token thin-client (#2520)", () => {
|
||||
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
|
||||
});
|
||||
|
||||
it("returns 'thin-client' when config.json is absent and the registration is PROJECT-scoped (#2499)", () => {
|
||||
it("returns 'thin-client' when config.json is absent and the registration is PROJECT-scoped for THIS cwd (#2499)", () => {
|
||||
// The project key must be the running process's cwd (or an ancestor):
|
||||
// per-project scoping (C15) means only registrations visible to this
|
||||
// cwd count.
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "ok",
|
||||
withConfig: false,
|
||||
claudeJson: {
|
||||
projects: { "/some/repo": { mcpServers: { "gbrain-remote": REMOTE_GBRAIN } } },
|
||||
projects: { [process.cwd()]: { mcpServers: { "gbrain-remote": REMOTE_GBRAIN } } },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
@@ -659,6 +662,117 @@ describe("lib/gbrain-local-status — bearer-token thin-client (#2520)", () => {
|
||||
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
|
||||
});
|
||||
|
||||
// ── C15: project scan is scoped to the cwd's nearest-ancestor project ──
|
||||
// Before the fix, hasRemoteOnlyGbrainMcp scanned EVERY project's
|
||||
// mcpServers, so one project's remote registration reclassified broken
|
||||
// local engines as thin-client machine-wide.
|
||||
|
||||
it("C15: an OTHER project's remote entry no longer flips thin-client for this cwd (no config)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "ok",
|
||||
withConfig: false,
|
||||
claudeJson: {
|
||||
projects: { "/some/other/repo": { mcpServers: { gbrain: REMOTE_GBRAIN } } },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
|
||||
});
|
||||
|
||||
it("C15: an OTHER project's remote entry no longer reclassifies a broken local engine", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "engine-locked",
|
||||
withConfig: true,
|
||||
claudeJson: {
|
||||
projects: { "/some/other/repo": { mcpServers: { gbrain: REMOTE_GBRAIN } } },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("engine-locked");
|
||||
});
|
||||
|
||||
it("C15: path boundary — a sibling-prefix project key is NOT this cwd's project", () => {
|
||||
// /path/to/repo2 must never match a scan from /path/to/repo (and vice
|
||||
// versa) — same boundary rule as the jq resolver and brain-cache.
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "ok",
|
||||
withConfig: false,
|
||||
claudeJson: {
|
||||
projects: { [`${process.cwd()}-sibling`]: { mcpServers: { gbrain: REMOTE_GBRAIN } } },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
|
||||
});
|
||||
|
||||
it("C15: an ANCESTOR project key of this cwd still counts (nearest-ancestor matching)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "ok",
|
||||
withConfig: false,
|
||||
claudeJson: {
|
||||
projects: { [dirname(process.cwd())]: { mcpServers: { gbrain: REMOTE_GBRAIN } } },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
|
||||
});
|
||||
|
||||
it("C15: a nearer project WITHOUT gbrain does not shadow an ancestor's registration (jq parity)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "ok",
|
||||
withConfig: false,
|
||||
claudeJson: {
|
||||
projects: {
|
||||
[dirname(process.cwd())]: { mcpServers: { gbrain: REMOTE_GBRAIN } },
|
||||
[process.cwd()]: { mcpServers: { "other-server": { type: "http", url: "https://x.example/mcp" } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
|
||||
});
|
||||
|
||||
// ── C15: adopted precedence — project-local beats user scope per name ──
|
||||
// Claude Code's own conflict resolution, verified empirically against
|
||||
// claude 2.1.233 with a hermetic fake $HOME (`claude mcp get gbrain`
|
||||
// reports "Scope: Local config" when both scopes define the name).
|
||||
|
||||
it("C15 precedence: THIS project's remote gbrain shadows a user-scope local-stdio gbrain → thin-client", () => {
|
||||
// Union semantics would see the user-scope stdio entry and keep
|
||||
// engine-locked; the adopted precedence says this project's queries go
|
||||
// remote, so thin-client is the truthful classification here.
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "engine-locked",
|
||||
withConfig: true,
|
||||
claudeJson: {
|
||||
mcpServers: { gbrain: LOCAL_GBRAIN },
|
||||
projects: { [process.cwd()]: { mcpServers: { gbrain: REMOTE_GBRAIN } } },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
|
||||
});
|
||||
|
||||
it("C15 precedence: THIS project's local-stdio gbrain shadows a user-scope remote gbrain → local statuses keep their meaning", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "engine-locked",
|
||||
withConfig: true,
|
||||
claudeJson: {
|
||||
mcpServers: { gbrain: REMOTE_GBRAIN },
|
||||
projects: { [process.cwd()]: { mcpServers: { gbrain: LOCAL_GBRAIN } } },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("engine-locked");
|
||||
});
|
||||
|
||||
it("--is-ok exits 0 on a bearer thin-client fixture (end-to-end gate)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* lib/gbrain-repo-policy-client — batch trust-tier lookup (#2392).
|
||||
*
|
||||
* Covers the `get --batch` verb of bin/gstack-gbrain-repo-policy (bash level:
|
||||
* multiple urls in, per-line verdicts out, input order preserved) and the
|
||||
* TypeScript client `repoPolicyTierBatch` (ONE spawn, dedup, fast paths, and
|
||||
* the whole-batch `unreadable` classification on a corrupt store).
|
||||
*
|
||||
* Each test uses a temp GSTACK_HOME so nothing leaks into the user's real
|
||||
* ~/.gstack. The client is exercised against the REAL bash script — the
|
||||
* script owns URL normalization, so stub stores are seeded through its own
|
||||
* `set` verb.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
import { repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client";
|
||||
import { canonicalizeRemote } from "../lib/gstack-memory-helpers";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const BIN = path.join(ROOT, "bin", "gstack-gbrain-repo-policy");
|
||||
|
||||
let tmpHome: string;
|
||||
|
||||
function env(): NodeJS.ProcessEnv {
|
||||
return { ...process.env, GSTACK_HOME: tmpHome };
|
||||
}
|
||||
|
||||
function run(args: string[], input?: string) {
|
||||
const res = spawnSync(BIN, args, { env: env(), encoding: "utf-8", input });
|
||||
return {
|
||||
stdout: res.stdout || "",
|
||||
stderr: res.stderr || "",
|
||||
status: res.status ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
function policyFile(): string {
|
||||
return path.join(tmpHome, "gbrain-repo-policy.json");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "gbrain-policy-client-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("bin/gstack-gbrain-repo-policy get --batch (bash level)", () => {
|
||||
test("multiple urls in, per-line verdicts out, input order preserved", () => {
|
||||
expect(run(["set", "https://github.com/foo/bar.git", "deny"]).status).toBe(0);
|
||||
expect(run(["set", "git@github.com:baz/qux.git", "read-only"]).status).toBe(0);
|
||||
expect(run(["set", "https://github.com/rw/repo", "read-write"]).status).toBe(0);
|
||||
|
||||
const r = run(
|
||||
["get", "--batch"],
|
||||
// Mixed URL forms — the script's normalize() collapses them to the
|
||||
// stored keys. `nope/never` has no entry → none.
|
||||
"git@github.com:foo/bar.git\nhttps://github.com/nope/never\nhttps://github.com/baz/qux\nhttps://github.com/rw/repo.git\n",
|
||||
);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe("deny\nnone\nread-only\nread-write\n");
|
||||
});
|
||||
|
||||
test("no store on disk: every line is none, and no file is created", () => {
|
||||
const r = run(["get", "--batch"], "https://github.com/a/a\nhttps://github.com/b/b\n");
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe("none\nnone\n");
|
||||
expect(fs.existsSync(policyFile())).toBe(false);
|
||||
});
|
||||
|
||||
test("corrupt store: hard error (exit 2), NOT quarantined, names recovery", () => {
|
||||
fs.writeFileSync(policyFile(), "not valid json{", { mode: 0o600 });
|
||||
const r = run(["get", "--batch"], "https://github.com/foo/bar\n");
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain("corrupt");
|
||||
expect(r.stderr).toContain("gstack-gbrain-repo-policy list");
|
||||
// Unlike interactive `get`, batch must never quarantine-and-proceed —
|
||||
// that would bypass a set deny policy on an unattended ingest run.
|
||||
expect(fs.readFileSync(policyFile(), "utf-8")).toBe("not valid json{");
|
||||
expect(
|
||||
fs.readdirSync(tmpHome).find((f) => f.includes(".corrupt-")),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("legacy allow entries migrate to read-write on batch read", () => {
|
||||
fs.writeFileSync(
|
||||
policyFile(),
|
||||
JSON.stringify({ "github.com/foo/bar": "allow" }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const r = run(["get", "--batch"], "https://github.com/foo/bar\n");
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe("read-write\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("repoPolicyTierBatch (TypeScript client)", () => {
|
||||
test("maps each input url to its verdict, dedup included", () => {
|
||||
expect(run(["set", "https://github.com/foo/bar", "deny"]).status).toBe(0);
|
||||
expect(run(["set", "https://github.com/baz/qux", "read-only"]).status).toBe(0);
|
||||
|
||||
const verdicts = repoPolicyTierBatch(
|
||||
[
|
||||
"github.com/foo/bar", // canonical form, as memory-ingest passes it
|
||||
"github.com/baz/qux",
|
||||
"github.com/nope/never",
|
||||
"github.com/foo/bar", // duplicate — dedup keeps ONE map entry
|
||||
],
|
||||
env(),
|
||||
);
|
||||
expect(verdicts.size).toBe(3);
|
||||
expect(verdicts.get("github.com/foo/bar")).toEqual({ tier: "deny" });
|
||||
expect(verdicts.get("github.com/baz/qux")).toEqual({ tier: "read-only" });
|
||||
expect(verdicts.get("github.com/nope/never")).toEqual({ tier: "none" });
|
||||
});
|
||||
|
||||
test("no store on disk: every url is tier none with no error (fast path)", () => {
|
||||
const verdicts = repoPolicyTierBatch(["github.com/a/a", "github.com/b/b"], env());
|
||||
expect(verdicts.get("github.com/a/a")).toEqual({ tier: "none" });
|
||||
expect(verdicts.get("github.com/b/b")).toEqual({ tier: "none" });
|
||||
expect(fs.existsSync(policyFile())).toBe(false);
|
||||
});
|
||||
|
||||
test("empty url list returns an empty map without spawning", () => {
|
||||
const verdicts = repoPolicyTierBatch([], env());
|
||||
expect(verdicts.size).toBe(0);
|
||||
});
|
||||
|
||||
test("corrupt store: EVERY url maps to { tier: none, error: unreadable }", () => {
|
||||
fs.writeFileSync(policyFile(), "not valid json{", { mode: 0o600 });
|
||||
const verdicts = repoPolicyTierBatch(["github.com/foo/bar", "github.com/baz/qux"], env());
|
||||
expect(verdicts.get("github.com/foo/bar")).toEqual({ tier: "none", error: "unreadable" });
|
||||
expect(verdicts.get("github.com/baz/qux")).toEqual({ tier: "none", error: "unreadable" });
|
||||
});
|
||||
|
||||
test("store unreadable on disk (chmod 000): whole batch classified unreadable", () => {
|
||||
if (process.platform === "win32" || process.getuid?.() === 0) return; // chmod semantics differ
|
||||
expect(run(["set", "https://github.com/foo/bar", "deny"]).status).toBe(0);
|
||||
fs.chmodSync(policyFile(), 0o000);
|
||||
try {
|
||||
const verdicts = repoPolicyTierBatch(["github.com/foo/bar"], env());
|
||||
expect(verdicts.get("github.com/foo/bar")).toEqual({ tier: "none", error: "unreadable" });
|
||||
} finally {
|
||||
fs.chmodSync(policyFile(), 0o600);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Normalize parity: bash normalize() ↔ lib canonicalizeRemote ─────────────
|
||||
//
|
||||
// bin/gstack-memory-ingest.ts produces page.git_remote via canonicalizeRemote
|
||||
// (lib/gstack-memory-helpers) and then looks the policy up through
|
||||
// repoPolicyTierBatch — whose bash side re-normalizes with normalize(). If
|
||||
// the two functions disagree on ANY URL shape, a policy the user set via the
|
||||
// script silently fails to apply to ingest (a deny that doesn't deny). The
|
||||
// contract pinned here: for every shape X, `set X <tier>` followed by a batch
|
||||
// lookup of canonicalizeRemote(X) returns <tier>. Bash owns normalization —
|
||||
// any divergence is fixed in the SCRIPT's normalize(), never by re-normalizing
|
||||
// in TypeScript.
|
||||
|
||||
describe("normalize parity: bash normalize() ↔ canonicalizeRemote (edge URL shapes)", () => {
|
||||
// One distinct repo per shape so tiers don't overwrite each other.
|
||||
const CORPUS: Array<{ shape: string; tier: "read-write" | "read-only" | "deny" }> = [
|
||||
{ shape: "https://github.com/acme/plain", tier: "deny" },
|
||||
{ shape: "https://github.com/acme/dotgit.git", tier: "read-only" },
|
||||
{ shape: "https://github.com/acme/slash/", tier: "read-write" },
|
||||
// .git + trailing slash: bash must strip the slash BEFORE the .git suffix
|
||||
// (slash-first order), as canonicalizeRemote does.
|
||||
{ shape: "https://github.com/acme/dotgitslash.git/", tier: "deny" },
|
||||
// Uppercase .GIT: canonicalizeRemote strips case-insensitively; bash must
|
||||
// lowercase before the suffix strip or the key keeps a ".git" tail.
|
||||
{ shape: "https://github.com/ACME/UpperGit.GIT", tier: "read-only" },
|
||||
{ shape: "git@github.com:acme/scp.git", tier: "deny" },
|
||||
{ shape: "ssh://git@github.com/acme/sshurl.git", tier: "read-write" },
|
||||
];
|
||||
|
||||
test("normalize <url> prints exactly canonicalizeRemote(url) for every corpus shape", () => {
|
||||
for (const { shape } of CORPUS) {
|
||||
const r = run(["normalize", shape]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout.trim()).toBe(canonicalizeRemote(shape));
|
||||
}
|
||||
});
|
||||
|
||||
test("a policy set via the script with shape X is found via canonicalizeRemote(X)", () => {
|
||||
for (const { shape, tier } of CORPUS) {
|
||||
expect(run(["set", shape, tier]).status).toBe(0);
|
||||
}
|
||||
const canon = CORPUS.map((c) => canonicalizeRemote(c.shape));
|
||||
const verdicts = repoPolicyTierBatch(canon, env());
|
||||
for (let i = 0; i < CORPUS.length; i++) {
|
||||
expect(verdicts.get(canon[i])).toEqual({ tier: CORPUS[i].tier });
|
||||
}
|
||||
});
|
||||
|
||||
test("cross-shape: set through one shape, looked up through another shape of the same repo", () => {
|
||||
// The store keys on the normalized form, so every spelling of the same
|
||||
// repo shares one entry — set through scp form, read through https form.
|
||||
expect(run(["set", "git@github.com:acme/xshape.git", "deny"]).status).toBe(0);
|
||||
const canon = canonicalizeRemote("https://github.com/ACME/XShape.GIT/");
|
||||
const verdicts = repoPolicyTierBatch([canon], env());
|
||||
expect(verdicts.get(canon)).toEqual({ tier: "deny" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* #2516: the brain worktree gbrain indexes must advance on the daily sync —
|
||||
* and the unattended advance path must be SAFE: refuse dirty worktrees,
|
||||
* refuse anything that is not a worktree of the artifacts repo, and never
|
||||
* force-remove. (Pre-fix, the worktree only moved when setup-gbrain /
|
||||
* sync-gbrain / brain-restore ran, so brains silently served stale pages.)
|
||||
*/
|
||||
import { describe, test as _test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
const test = (name: string, fn: any) => _test(name, fn, 30000);
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin');
|
||||
|
||||
let tmpHome: string;
|
||||
|
||||
function run(argv: string[], env: Record<string, string> = {}) {
|
||||
const full = path.join(BIN, argv[0]);
|
||||
const res = spawnSync(full, argv.slice(1), {
|
||||
env: { ...process.env, HOME: tmpHome, GSTACK_HOME: tmpHome, ...env },
|
||||
encoding: 'utf-8',
|
||||
cwd: ROOT,
|
||||
});
|
||||
return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 };
|
||||
}
|
||||
|
||||
function git(args: string[], cwd: string) {
|
||||
const res = spawnSync('git', args, { cwd, encoding: 'utf-8' });
|
||||
return { stdout: (res.stdout || '').trim(), status: res.status ?? -1 };
|
||||
}
|
||||
|
||||
function commit(cwd: string, msg: string): string {
|
||||
fs.appendFileSync(path.join(cwd, 'artifact.md'), `${msg}\n`);
|
||||
git(['add', 'artifact.md'], cwd);
|
||||
git(['commit', '-q', '-m', msg], cwd);
|
||||
return git(['rev-parse', 'HEAD'], cwd).stdout;
|
||||
}
|
||||
|
||||
const worktreePath = () => path.join(tmpHome, '.gstack-brain-worktree');
|
||||
|
||||
function makeArtifactsRepoWithWorktree(): { head: string } {
|
||||
git(['init', '-q', '-b', 'main'], tmpHome);
|
||||
git(['config', 'user.email', 't@t'], tmpHome);
|
||||
git(['config', 'user.name', 't'], tmpHome);
|
||||
const head = commit(tmpHome, 'seed');
|
||||
git(['worktree', 'add', '--detach', worktreePath(), head], tmpHome);
|
||||
return { head };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'wtree-adv-home-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('gstack-gbrain-source-wireup --advance-only (#2516)', () => {
|
||||
test('advances a clean, behind worktree to the parent HEAD', () => {
|
||||
makeArtifactsRepoWithWorktree();
|
||||
const newHead = commit(tmpHome, 'second');
|
||||
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain('advanced');
|
||||
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(newHead);
|
||||
});
|
||||
|
||||
test('up-to-date worktree is a no-op', () => {
|
||||
const { head } = makeArtifactsRepoWithWorktree();
|
||||
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain('up-to-date');
|
||||
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(head);
|
||||
});
|
||||
|
||||
test('REFUSES a dirty worktree — local changes are never advanced away', () => {
|
||||
const { head } = makeArtifactsRepoWithWorktree();
|
||||
commit(tmpHome, 'second');
|
||||
fs.writeFileSync(path.join(worktreePath(), 'artifact.md'), 'local edit\n');
|
||||
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
|
||||
expect(r.status).toBe(0); // benign skip, not a hard failure
|
||||
expect(r.stderr).toContain('local changes');
|
||||
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(head); // untouched
|
||||
expect(fs.readFileSync(path.join(worktreePath(), 'artifact.md'), 'utf-8')).toBe('local edit\n');
|
||||
});
|
||||
|
||||
test('REFUSES a path that is not a worktree of the artifacts repo', () => {
|
||||
makeArtifactsRepoWithWorktree();
|
||||
// A standalone user repo masquerading as the brain worktree.
|
||||
const userRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'wtree-adv-user-'));
|
||||
try {
|
||||
git(['init', '-q', '-b', 'main'], userRepo);
|
||||
git(['config', 'user.email', 't@t'], userRepo);
|
||||
git(['config', 'user.name', 't'], userRepo);
|
||||
const userHead = commit(userRepo, 'user work');
|
||||
const r = run(['gstack-gbrain-source-wireup', '--advance-only'], {
|
||||
GSTACK_BRAIN_WORKTREE: userRepo,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('not a worktree of');
|
||||
expect(git(['rev-parse', 'HEAD'], userRepo).stdout).toBe(userHead); // untouched
|
||||
} finally {
|
||||
fs.rmSync(userRepo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('missing worktree is a benign skip', () => {
|
||||
git(['init', '-q', '-b', 'main'], tmpHome);
|
||||
git(['config', 'user.email', 't@t'], tmpHome);
|
||||
git(['config', 'user.name', 't'], tmpHome);
|
||||
commit(tmpHome, 'seed');
|
||||
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('no managed worktree');
|
||||
});
|
||||
|
||||
test('never contains a force-remove on the advance-only path (static pin)', () => {
|
||||
// The unattended path must not be able to delete local worktree changes:
|
||||
// do_advance_only may not call safe_rm_worktree, `worktree remove`, or rm -rf.
|
||||
const src = fs.readFileSync(path.join(BIN, 'gstack-gbrain-source-wireup'), 'utf-8');
|
||||
const fn = src.slice(src.indexOf('do_advance_only()'), src.indexOf('do_uninstall()'));
|
||||
expect(fn.length).toBeGreaterThan(100);
|
||||
expect(fn).not.toContain('safe_rm_worktree');
|
||||
expect(fn).not.toContain('worktree remove');
|
||||
expect(fn).not.toContain('rm -rf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('brain-sync --once daily advance wiring (#2516)', () => {
|
||||
test('once advances the worktree behind a 24h attempt stamp', () => {
|
||||
makeArtifactsRepoWithWorktree();
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'artifacts-only']);
|
||||
const second = commit(tmpHome, 'second');
|
||||
|
||||
const r1 = run(['gstack-brain-sync', '--once']);
|
||||
expect(r1.status).toBe(0);
|
||||
const stamp = path.join(tmpHome, '.brain-worktree-last-advance');
|
||||
expect(fs.existsSync(stamp)).toBe(true);
|
||||
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(second);
|
||||
|
||||
// Within the 24h window: parent advances again, --once does NOT re-advance.
|
||||
const third = commit(tmpHome, 'third');
|
||||
const r2 = run(['gstack-brain-sync', '--once']);
|
||||
expect(r2.status).toBe(0);
|
||||
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(second);
|
||||
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).not.toBe(third);
|
||||
|
||||
// Expire the stamp → the next --once advances again.
|
||||
fs.writeFileSync(stamp, String(Math.floor(Date.now() / 1000) - 90000));
|
||||
const r3 = run(['gstack-brain-sync', '--once']);
|
||||
expect(r3.status).toBe(0);
|
||||
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(third);
|
||||
});
|
||||
});
|
||||
@@ -1958,12 +1958,14 @@ describe('Codex generation (--host codex)', () => {
|
||||
|
||||
// ─── Path rewriting regression tests ─────────────────────────
|
||||
|
||||
test('sidecar paths point to .agents/skills/gstack/review/ (not gstack-review/)', () => {
|
||||
// Regression: gen-skill-docs rewrote .claude/skills/review → .agents/skills/gstack-review
|
||||
// but setup puts sidecars under .agents/skills/gstack/review/. Must match setup layout.
|
||||
test('sidecar paths resolve through $GSTACK_ROOT (not gstack-review/)', () => {
|
||||
// #2518: templates now anchor sidecars at the installed skill root
|
||||
// (~/.claude/skills/gstack/review/...), which the codex path rewrite turns
|
||||
// into $GSTACK_ROOT/review/... — resolved by the preamble against the
|
||||
// repo-local .agents root or the global install. The old repo-relative
|
||||
// form (.claude/skills/review/) only resolved inside gstack's own checkout.
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
|
||||
// Correct: references to sidecar files use gstack/review/ path
|
||||
expect(content).toContain('.agents/skills/gstack/review/checklist.md');
|
||||
expect(content).toContain('$GSTACK_ROOT/review/checklist.md');
|
||||
// design-checklist.md is now referenced via Review Army specialist (Claude only, stripped for Codex)
|
||||
// Wrong: must NOT reference gstack-review/checklist.md (file doesn't exist there)
|
||||
expect(content).not.toContain('.agents/skills/gstack-review/checklist.md');
|
||||
@@ -1981,7 +1983,7 @@ describe('Codex generation (--host codex)', () => {
|
||||
test('greptile-triage sidecar path is correct', () => {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
|
||||
if (content.includes('greptile-triage')) {
|
||||
expect(content).toContain('.agents/skills/gstack/review/greptile-triage.md');
|
||||
expect(content).toContain('$GSTACK_ROOT/review/greptile-triage.md');
|
||||
expect(content).not.toContain('.agents/skills/gstack-review/greptile-triage');
|
||||
}
|
||||
});
|
||||
@@ -2023,10 +2025,12 @@ describe('Codex generation (--host codex)', () => {
|
||||
|
||||
// ─── Claude output regression guard ─────────────────────────
|
||||
|
||||
test('Claude output unchanged: review skill still uses .claude/skills/ paths', () => {
|
||||
// Codex changes must NOT affect Claude output
|
||||
test('Claude output uses installed-root review paths (#2518)', () => {
|
||||
// Codex changes must NOT affect Claude output; the Claude form is the
|
||||
// installed-root anchor, not the old repo-relative path that only
|
||||
// resolved inside gstack's own checkout.
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('.claude/skills/review/checklist.md');
|
||||
expect(content).toContain('~/.claude/skills/gstack/review/checklist.md');
|
||||
expect(content).toContain('~/.claude/skills/gstack');
|
||||
// Must NOT contain Codex HOST paths. `~/.codex/sessions/` is exempt: the
|
||||
// timeout-wrapper guidance documents the Codex CLI's own rollout-log
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* gstack-config default-table completeness (gate, free).
|
||||
*
|
||||
* Skill preambles read configuration with
|
||||
*
|
||||
* VAR=$(gstack-config get <key> 2>/dev/null || echo "<default>")
|
||||
*
|
||||
* and that fallback only fires on a NON-ZERO exit. `get` used to answer a key
|
||||
* it did not know with "" and exit 0, so VAR came back empty and the default
|
||||
* written right there in the preamble was unreachable. The skill then branched
|
||||
* on a value it never specified -- "skip entirely if QUESTION_TUNING is false"
|
||||
* reached with QUESTION_TUNING="".
|
||||
*
|
||||
* Four keys skills actually read had no entry in lookup_default and took that
|
||||
* path: question_tuning, repo_mode, team_mode, transcript_ingest_mode.
|
||||
*
|
||||
* Three invariants are pinned so the class cannot reopen:
|
||||
*
|
||||
* 1. every key read anywhere in the tree is matched by an arm of the DEFAULTS
|
||||
* table. Add a `gstack-config get some_new_key` to a preamble without
|
||||
* adding its default and this test fails. Checked by parsing the case arms
|
||||
* rather than shelling out per key, which keeps it fast and makes the
|
||||
* failure name the key.
|
||||
* 2. a genuinely unknown key exits non-zero, so the caller fallback fires.
|
||||
* 3. a known key whose default is intentionally empty still exits 0 --
|
||||
* cross_project_learnings ("unset triggers the first-time prompt") and
|
||||
* redact_repo_visibility ("empty falls through to gh/glab detection")
|
||||
* depend on receiving "" successfully.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const CONFIG_BIN = path.join(ROOT, 'bin', 'gstack-config');
|
||||
const SELF = 'gstack-config-defaults.test.ts';
|
||||
|
||||
// Isolated state dir, so a value the developer happens to have set in their own
|
||||
// ~/.gstack/config.yaml cannot mask a missing default.
|
||||
const STATE = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-config-test-'));
|
||||
|
||||
function get(key: string): { out: string; code: number } {
|
||||
const r = spawnSync('bash', [CONFIG_BIN, 'get', key], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, GSTACK_STATE_ROOT: STATE },
|
||||
});
|
||||
return { out: r.stdout ?? '', code: r.status ?? -1 };
|
||||
}
|
||||
|
||||
/** Case-arm patterns of lookup_default, in order, excluding the catch-all. */
|
||||
function defaultArms(): string[] {
|
||||
const src = fs.readFileSync(CONFIG_BIN, 'utf-8');
|
||||
const body = src.slice(src.indexOf('lookup_default()'));
|
||||
const end = body.indexOf('\n}');
|
||||
const arms: string[] = [];
|
||||
// e.g. ` proactive) echo "true" ;;` or ` user_slug_at_*) echo "" ;;`
|
||||
for (const m of body.slice(0, end).matchAll(/^\s{4}([a-zA-Z0-9_*]+)\)/gm)) {
|
||||
if (m[1] !== '*') arms.push(m[1]);
|
||||
}
|
||||
return arms;
|
||||
}
|
||||
|
||||
function isCovered(key: string, arms: string[]): boolean {
|
||||
return arms.some((a) =>
|
||||
a.endsWith('*') ? key.startsWith(a.slice(0, -1)) : key === a,
|
||||
);
|
||||
}
|
||||
|
||||
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next']);
|
||||
|
||||
/** Every `gstack-config get <key>` call site in the tree. */
|
||||
function keysReadInTree(): string[] {
|
||||
const keys = new Set<string>();
|
||||
// [ \t]+ rather than \s+: \s crosses newlines and would pair a trailing
|
||||
// "gstack-config get" with the first word of the next line.
|
||||
const re = /gstack-config["']?[ \t]+get[ \t]+([a-zA-Z0-9_]+)/g;
|
||||
const stack = [ROOT];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(cur, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of entries) {
|
||||
if (SKIP_DIRS.has(ent.name) || ent.isSymbolicLink()) continue;
|
||||
const full = path.join(cur, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
stack.push(full);
|
||||
continue;
|
||||
}
|
||||
// Skip this file: its own prose cites example keys.
|
||||
if (ent.name === SELF) continue;
|
||||
if (!/\.(md|ts|sh)$|^gstack-[a-z-]+$/.test(ent.name)) continue;
|
||||
let text: string;
|
||||
try {
|
||||
text = fs.readFileSync(full, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const m of text.matchAll(re)) keys.add(m[1]);
|
||||
}
|
||||
}
|
||||
return [...keys].sort();
|
||||
}
|
||||
|
||||
describe('gstack-config defaults (gate, free)', () => {
|
||||
test('every key read in the tree is covered by the DEFAULTS table', () => {
|
||||
const arms = defaultArms();
|
||||
expect(arms.length).toBeGreaterThan(10); // the parse actually found the table
|
||||
const uncovered = keysReadInTree().filter((k) => !isCovered(k, arms));
|
||||
expect(uncovered).toEqual([]);
|
||||
});
|
||||
|
||||
test('an unknown key exits non-zero, so the caller fallback fires', () => {
|
||||
const r = get('definitely_not_a_gstack_key_9f3a');
|
||||
expect(r.code).not.toBe(0);
|
||||
expect(r.out).toBe('');
|
||||
});
|
||||
|
||||
test('a known key whose default is intentionally empty still exits 0', () => {
|
||||
// repo_mode is in this class BY CONTRACT: gstack-repo-mode treats any
|
||||
// non-empty answer as a user override and skips classification, so a
|
||||
// synthesized "unknown" default would turn the classifier into dead code
|
||||
// (caught live by test/gstack-repo-mode.test.ts during the wave).
|
||||
for (const key of ['cross_project_learnings', 'salience_allowlist', 'redact_repo_visibility', 'repo_mode']) {
|
||||
expect({ key, ...get(key) }).toEqual({ key, out: '', code: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
test('the regressed keys resolve to the values their callers assume', () => {
|
||||
expect(get('question_tuning').out).toBe('false');
|
||||
expect(get('team_mode').out).toBe('false');
|
||||
expect(get('transcript_ingest_mode').out).toBe('off');
|
||||
});
|
||||
});
|
||||
@@ -93,7 +93,7 @@ describe("gstack-memory-ingest CLI", () => {
|
||||
const session = `{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${new Date().toISOString()}","cwd":"/tmp/x"}\n{"type":"assistant","message":{"role":"assistant","content":"hi"},"timestamp":"${new Date().toISOString()}"}\n`;
|
||||
writeClaudeCodeSession(home, "tmp-x", "abc123", session);
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 1");
|
||||
expect(r.stdout).toContain("transcript");
|
||||
@@ -109,7 +109,7 @@ describe("gstack-memory-ingest CLI", () => {
|
||||
const session = `{"type":"session_meta","payload":{"id":"sess-xyz","cwd":"/tmp/x","git":{"repository_url":"https://github.com/foo/bar"}},"timestamp":"${today.toISOString()}"}\n`;
|
||||
writeCodexSession(home, ymd, session);
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 1");
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
@@ -269,7 +269,7 @@ describe("internal: parseTranscriptJsonl + buildTranscriptPage shape", () => {
|
||||
mkdirSync(projDir, { recursive: true });
|
||||
writeFileSync(join(projDir, "abc123.jsonl"), content, "utf-8");
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: join(home, ".gstack") });
|
||||
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: join(home, ".gstack") });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 1");
|
||||
|
||||
@@ -288,7 +288,7 @@ describe("internal: parseTranscriptJsonl + buildTranscriptPage shape", () => {
|
||||
`{"type":"assistant","message":{"role":"assistant","content":"this is truncat`; // no closing brace + no newline
|
||||
writeFileSync(join(projDir, "trunc.jsonl"), content, "utf-8");
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: join(home, ".gstack") });
|
||||
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: join(home, ".gstack") });
|
||||
// Should not crash; should report 1 transcript
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 1");
|
||||
@@ -300,9 +300,16 @@ describe("internal: parseTranscriptJsonl + buildTranscriptPage shape", () => {
|
||||
|
||||
describe("gstack-memory-ingest --limit", () => {
|
||||
it("respects --limit by stopping after N writes (mocked via --probe shortcut)", () => {
|
||||
const r = runScript(["--probe", "--limit", "1"]);
|
||||
// Hermetic home: against the operator's real HOME this walked the whole
|
||||
// transcript corpus (and, post policy-parity, batch-checked its real
|
||||
// policy store), making a pure arg-parsing assertion slow and flaky.
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const r = runScript(["--probe", "--limit", "1"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
// --limit doesn't apply to probe but argument should parse without error
|
||||
expect(r.exitCode).toBe(0);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects --limit 0 with exit 1", () => {
|
||||
@@ -861,3 +868,443 @@ describe("#2105 codex response_item rollout shape", () => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── #2394: --probe counts post-attribution, matching what --bulk would write ─
|
||||
|
||||
describe("#2394: probe applies the same attribution gate as prepare", () => {
|
||||
function makeAttributableCwd(home: string): string {
|
||||
const repo = join(home, "work", "attributable-repo");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["-C", repo, "init", "-q"], { encoding: "utf-8" });
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/foo/bar.git"], { encoding: "utf-8" });
|
||||
return repo;
|
||||
}
|
||||
|
||||
function writeMixedCorpus(home: string): void {
|
||||
const attributableCwd = makeAttributableCwd(home);
|
||||
const ts = new Date().toISOString();
|
||||
writeClaudeCodeSession(
|
||||
home, "work-attributable", "attr1",
|
||||
`{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${ts}","cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"}\n`,
|
||||
);
|
||||
writeClaudeCodeSession(
|
||||
home, "tmp-nowhere", "unattr1",
|
||||
`{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${ts}","cwd":"${join(home, "not-a-repo").replace(/\\/g, "\\\\")}"}\n`,
|
||||
);
|
||||
mkdirSync(join(home, "not-a-repo"), { recursive: true });
|
||||
}
|
||||
|
||||
it("probe reports post-attribution counts and names what it skipped", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
writeMixedCorpus(home);
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
// Post-attribution: only the transcript whose cwd resolves to a remote.
|
||||
expect(r.stdout).toContain("Total files in window: 1");
|
||||
// The excluded remainder is visible, never silent.
|
||||
expect(r.stdout).toContain("Skipped (unattributed): 1");
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("--include-unattributed restores raw counts", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
writeMixedCorpus(home);
|
||||
|
||||
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 2");
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("parity: probe post-attribution count equals what prepare actually processes", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
writeMixedCorpus(home);
|
||||
|
||||
const probe = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(probe.exitCode).toBe(0);
|
||||
const probeNew = Number((probe.stdout.match(/New \(never ingested\):\s+(\d+)/) || [])[1]);
|
||||
expect(probeNew).toBe(1);
|
||||
|
||||
// Same stage on the ingest side: the transcripts that reach the import
|
||||
// step (written + failed) are exactly the ones that passed the shared
|
||||
// attribution gate in preparePages. No gbrain is configured in this
|
||||
// hermetic env, so the attributable transcript FAILS at import — that is
|
||||
// fine: parity is a prepare-stage invariant (probe post-attribution ==
|
||||
// prepare post-attribution), deliberately NOT == final written (#2394).
|
||||
const inc = runScript(["--incremental", "--quiet"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
const m = inc.stderr.match(/(\d+) written, (\d+) failed/) || inc.stdout.match(/(\d+) written, (\d+) failed/);
|
||||
expect(m).not.toBeNull();
|
||||
const reachedImport = Number(m![1]) + Number(m![2]);
|
||||
expect(reachedImport).toBe(probeNew);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("a multi-MB transcript is still classified correctly (bounded probe read)", () => {
|
||||
// The probe reads a BOUNDED 256KB prefix, never the whole file (plan C7).
|
||||
// The cwd sits on the first line; >1MB of filler follows. Classification
|
||||
// must come out attributable — and stay cheap on real multi-MB corpora.
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const attributableCwd = join(home, "work", "attributable-repo");
|
||||
mkdirSync(attributableCwd, { recursive: true });
|
||||
spawnSync("git", ["-C", attributableCwd, "init", "-q"], { encoding: "utf-8" });
|
||||
spawnSync("git", ["-C", attributableCwd, "remote", "add", "origin", "https://github.com/foo/bar.git"], { encoding: "utf-8" });
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const cwdLine = `{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${ts}","cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"}\n`;
|
||||
const filler = `{"type":"assistant","message":{"role":"assistant","content":"${"x".repeat(1000)}"}}\n`;
|
||||
const body = cwdLine + filler.repeat(1100); // > 1MB after the cwd line
|
||||
expect(body.length).toBeGreaterThan(1024 * 1024);
|
||||
writeClaudeCodeSession(home, "work-attributable", "big1", body);
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 1");
|
||||
expect(r.stdout).not.toContain("Skipped (unattributed)");
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("Codex format: session_meta cwd attributes the transcript in the probe", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const attributableCwd = makeAttributableCwd(home);
|
||||
const today = new Date();
|
||||
const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
const session = `{"type":"session_meta","payload":{"id":"sess-meta-cwd","cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"},"timestamp":"${today.toISOString()}"}\n`;
|
||||
writeCodexSession(home, ymd, session);
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 1");
|
||||
expect(r.stdout).not.toContain("Skipped (unattributed)");
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("parity: a Codex cwd appearing only on a LATER record is unattributed in probe AND prepare", () => {
|
||||
// parseTranscriptJsonl reads Codex cwd from the session_meta FIRST record
|
||||
// ONLY. The probe mirrors those exact rules — the pre-fix probe scanned
|
||||
// every line for any cwd and DIVERGED on this shape (probe said
|
||||
// attributable, prepare said not).
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const attributableCwd = makeAttributableCwd(home);
|
||||
const today = new Date();
|
||||
const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
const session =
|
||||
`{"type":"session_meta","payload":{"id":"sess-late-cwd"},"timestamp":"${today.toISOString()}"}\n` +
|
||||
`{"type":"response_item","payload":{"type":"message","role":"user","content":[{"text":"hi"}]},"cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"}\n`;
|
||||
writeCodexSession(home, ymd, session);
|
||||
|
||||
const probe = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(probe.exitCode).toBe(0);
|
||||
expect(probe.stdout).toContain("Total files in window: 0");
|
||||
expect(probe.stdout).toContain("Skipped (unattributed): 1");
|
||||
|
||||
// Prepare agrees: nothing reaches the import stage (written + failed = 0)
|
||||
// and the skip is attributed to the same gate.
|
||||
const inc = runScript(["--incremental"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(inc.exitCode).toBe(0);
|
||||
const written = Number((inc.stdout.match(/written:\s+(\d+)/) || [])[1]);
|
||||
const failed = Number((inc.stdout.match(/failed:\s+(\d+)/) || [])[1]);
|
||||
const unattrib = Number((inc.stdout.match(/skipped \(unattrib\):\s+(\d+)/) || [])[1]);
|
||||
expect(written + failed).toBe(0);
|
||||
expect(unattrib).toBe(1);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── #2392: transcript ingest honors the per-remote trust policy ─────────────
|
||||
//
|
||||
// The same store the code-import gate honors (bin/gstack-gbrain-sync.ts):
|
||||
// tier `deny` and `read-only` transcripts are skipped with their own counters;
|
||||
// a store that EXISTS but can't be read is a hard error before any writes
|
||||
// (never a silent bypass of a set policy); no store at all = zero policy work.
|
||||
// The policy store is seeded through the REAL bin/gstack-gbrain-repo-policy
|
||||
// script (its `set` verb owns the file schema + URL normalization).
|
||||
|
||||
describe("#2392: transcript ingest honors per-remote trust policy", () => {
|
||||
const POLICY_BIN = join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-policy");
|
||||
|
||||
/** Attributable temp git repo whose origin points at `remoteUrl`. */
|
||||
function makeRepoWithRemote(home: string, name: string, remoteUrl: string): string {
|
||||
const repo = join(home, "work", name);
|
||||
mkdirSync(repo, { recursive: true });
|
||||
spawnSync("git", ["-C", repo, "init", "-q"], { encoding: "utf-8" });
|
||||
spawnSync("git", ["-C", repo, "remote", "add", "origin", remoteUrl], { encoding: "utf-8" });
|
||||
return repo;
|
||||
}
|
||||
|
||||
function writeSessionForRepo(home: string, projectName: string, sessionId: string, cwd: string): void {
|
||||
const record = JSON.stringify({
|
||||
type: "user",
|
||||
message: { role: "user", content: `hello from ${sessionId}` },
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd,
|
||||
});
|
||||
writeClaudeCodeSession(home, projectName, sessionId, record + "\n");
|
||||
}
|
||||
|
||||
function setPolicy(gstackHome: string, url: string, tier: string): void {
|
||||
const r = spawnSync(POLICY_BIN, ["set", url, tier], {
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, GSTACK_HOME: gstackHome },
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
}
|
||||
|
||||
function stateSessions(gstackHome: string): string[] {
|
||||
const statePath = join(gstackHome, ".transcript-ingest-state.json");
|
||||
if (!existsSync(statePath)) return [];
|
||||
return Object.keys(JSON.parse(readFileSync(statePath, "utf-8")).sessions || {});
|
||||
}
|
||||
|
||||
it("(a) deny remote's transcript is skipped and counted as skipped_policy_deny", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const { binDir, logFile } = installFakeGbrain(home);
|
||||
|
||||
const denyCwd = makeRepoWithRemote(home, "denied", "https://github.com/denyme/denied.git");
|
||||
const okCwd = makeRepoWithRemote(home, "allowed", "https://github.com/okorg/okrepo.git");
|
||||
writeSessionForRepo(home, "work-denied", "denysess1", denyCwd);
|
||||
writeSessionForRepo(home, "work-allowed", "oksess1", okCwd);
|
||||
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
|
||||
|
||||
const r = runScript(["--bulk", "--quiet"], {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
});
|
||||
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toMatch(/written:\s+1/);
|
||||
expect(r.stdout).toMatch(/skipped \(policy deny\):\s+1/);
|
||||
expect(r.stdout).not.toMatch(/skipped \(policy read-only\)/);
|
||||
|
||||
// Only the allowed session was imported + state-recorded.
|
||||
expect(existsSync(logFile)).toBe(true);
|
||||
const sessions = stateSessions(gstackHome);
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0]).toContain("oksess1");
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("(b) read-only remote's transcript is skipped and counted as skipped_policy_readonly", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const { binDir } = installFakeGbrain(home);
|
||||
|
||||
const roCwd = makeRepoWithRemote(home, "readonly", "https://github.com/roorg/rorepo.git");
|
||||
const okCwd = makeRepoWithRemote(home, "allowed", "https://github.com/okorg/okrepo.git");
|
||||
writeSessionForRepo(home, "work-readonly", "rosess1", roCwd);
|
||||
writeSessionForRepo(home, "work-allowed", "oksess1", okCwd);
|
||||
setPolicy(gstackHome, "https://github.com/roorg/rorepo.git", "read-only");
|
||||
|
||||
const r = runScript(["--bulk", "--quiet"], {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
});
|
||||
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toMatch(/written:\s+1/);
|
||||
expect(r.stdout).toMatch(/skipped \(policy read-only\):\s+1/);
|
||||
const sessions = stateSessions(gstackHome);
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0]).toContain("oksess1");
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("(c) read-write remote's transcript is ingested (reaches gbrain import)", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const { binDir, logFile } = installFakeGbrain(home);
|
||||
|
||||
const rwCwd = makeRepoWithRemote(home, "readwrite", "https://github.com/rworg/rwrepo.git");
|
||||
writeSessionForRepo(home, "work-readwrite", "rwsess1", rwCwd);
|
||||
setPolicy(gstackHome, "https://github.com/rworg/rwrepo.git", "read-write");
|
||||
|
||||
const r = runScript(["--bulk", "--quiet"], {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
});
|
||||
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toMatch(/written:\s+1/);
|
||||
expect(r.stdout).not.toMatch(/skipped \(policy/);
|
||||
// gbrain import ran exactly once — the page reached the import stage.
|
||||
const calls = readFileSync(logFile, "utf-8").trim().split("\n").filter(Boolean);
|
||||
expect(calls.length).toBe(1);
|
||||
expect(stateSessions(gstackHome).length).toBe(1);
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("(d) corrupted store: hard error before any writes, message names recovery", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const { binDir, logFile } = installFakeGbrain(home);
|
||||
|
||||
const cwd = makeRepoWithRemote(home, "somerepo", "https://github.com/some/repo.git");
|
||||
writeSessionForRepo(home, "work-somerepo", "somesess1", cwd);
|
||||
// Corrupt store — the batch verb refuses (exit 2), the client classifies
|
||||
// `unreadable`, and ingest must abort rather than bypass a set policy.
|
||||
writeFileSync(join(gstackHome, "gbrain-repo-policy.json"), "not valid json{", "utf-8");
|
||||
|
||||
const r = runScript(["--bulk", "--quiet"], {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
});
|
||||
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toMatch(/\[memory-ingest\] ERR:.*repo policy store exists/);
|
||||
expect(r.stderr).toContain("gstack-gbrain-repo-policy list");
|
||||
expect(r.stderr).toContain("/setup-gbrain");
|
||||
// Nothing written: no gbrain import call, no state file, store untouched.
|
||||
expect(existsSync(logFile)).toBe(false);
|
||||
expect(stateSessions(gstackHome).length).toBe(0);
|
||||
expect(readFileSync(join(gstackHome, "gbrain-repo-policy.json"), "utf-8")).toBe("not valid json{");
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("(e) no store at all: no policy filtering, transcript ingests normally", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const { binDir } = installFakeGbrain(home);
|
||||
|
||||
const cwd = makeRepoWithRemote(home, "freerepo", "https://github.com/free/repo.git");
|
||||
writeSessionForRepo(home, "work-freerepo", "freesess1", cwd);
|
||||
|
||||
const r = runScript(["--bulk", "--quiet"], {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
});
|
||||
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toMatch(/written:\s+1/);
|
||||
expect(r.stdout).not.toMatch(/skipped \(policy/);
|
||||
expect(stateSessions(gstackHome).length).toBe(1);
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("(f) probe policy parity: a denied remote's transcript lands in skipped_policy_deny, not new_count", () => {
|
||||
// --probe used to count policy-denied transcripts as ingestible (it only
|
||||
// applied attribution), so its numbers overstated what --bulk would write.
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
|
||||
const denyCwd = makeRepoWithRemote(home, "denied", "https://github.com/denyme/denied.git");
|
||||
writeSessionForRepo(home, "work-denied", "denysess1", denyCwd);
|
||||
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 0");
|
||||
expect(r.stdout).toMatch(/New \(never ingested\):\s+0/);
|
||||
expect(r.stdout).toMatch(/Skipped \(policy deny\):\s+1/);
|
||||
expect(r.stdout).not.toMatch(/Skipped \(policy read-only\)/);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("(g) probe policy parity: a read-only remote's transcript lands in skipped_policy_readonly", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
|
||||
const roCwd = makeRepoWithRemote(home, "readonly", "https://github.com/roorg/rorepo.git");
|
||||
writeSessionForRepo(home, "work-readonly", "rosess1", roCwd);
|
||||
setPolicy(gstackHome, "https://github.com/roorg/rorepo.git", "read-only");
|
||||
|
||||
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("Total files in window: 0");
|
||||
expect(r.stdout).toMatch(/Skipped \(policy read-only\):\s+1/);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("(h) --limit counts policy-PERMITTED pages only: a denied-first corpus still writes the allowed page", () => {
|
||||
// Walk order is deterministic here: Claude Code projects are walked
|
||||
// BEFORE Codex sessions (walkAllSources), so the DENIED transcript is
|
||||
// prepared first. Pre-fix, --limit 1 was applied to the unfiltered
|
||||
// prepared array — the denied record consumed the limit and the permitted
|
||||
// one starved (written: 0).
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const { binDir } = installFakeGbrain(home);
|
||||
|
||||
const denyCwd = makeRepoWithRemote(home, "denied", "https://github.com/denyme/denied.git");
|
||||
writeSessionForRepo(home, "work-denied", "denysess1", denyCwd); // Claude Code: walked first
|
||||
const okCwd = makeRepoWithRemote(home, "allowed", "https://github.com/okorg/okrepo.git");
|
||||
const today = new Date();
|
||||
const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
writeCodexSession(
|
||||
home, ymd,
|
||||
`{"type":"session_meta","payload":{"id":"oksess-codex","cwd":"${okCwd.replace(/\\/g, "\\\\")}"},"timestamp":"${today.toISOString()}"}\n`,
|
||||
);
|
||||
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
|
||||
|
||||
const r = runScript(["--bulk", "--quiet", "--limit", "1"], {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
});
|
||||
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toMatch(/written:\s+1/);
|
||||
expect(r.stdout).toMatch(/skipped \(policy deny\):\s+1/);
|
||||
// The page that landed is the PERMITTED one (the Codex session), not
|
||||
// whichever record happened to be walked first.
|
||||
const sessions = stateSessions(gstackHome);
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0]).toContain("rollout-");
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("artifacts are never policy-filtered, even when their project's remote is denied", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(join(gstackHome, "projects", "denyme-denied"), { recursive: true });
|
||||
const { binDir } = installFakeGbrain(home);
|
||||
|
||||
// A learning artifact under a project slug matching a denied remote —
|
||||
// the policy is keyed by git remote, which artifacts don't have.
|
||||
writeFileSync(join(gstackHome, "projects", "denyme-denied", "learnings.jsonl"), '{"key":"a","insight":"b"}\n');
|
||||
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
|
||||
|
||||
const r = runScript(["--bulk", "--quiet"], {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
});
|
||||
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toMatch(/written:\s+1/);
|
||||
expect(r.stdout).not.toMatch(/skipped \(policy/);
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@@ -385,19 +385,54 @@ describe("offline output contract (what /ship branches on, #2545)", () => {
|
||||
// trustworthy when the PR queue is unreachable. That field is therefore
|
||||
// load-bearing prose-to-code coupling: if it silently stopped being emitted,
|
||||
// /ship would read undefined, treat the run as fully online, and lose the
|
||||
// "verify no sibling holds it" prompt. Asserted end-to-end with a stub `gh`
|
||||
// that always fails, which is what an expired token or an offline laptop
|
||||
// looks like from here.
|
||||
// "verify no sibling holds it" prompt.
|
||||
//
|
||||
// Both tests run the CLI in a LOCAL FIXTURE repo, never the checkout it
|
||||
// lives in: in the real checkout the git fallback does a live
|
||||
// `ls-remote origin` and reads the real branch census, which made this test
|
||||
// depend on the operator's network — and on a shallow CI clone it fetched
|
||||
// the remote's every branch (the shard-deadline hang fixed alongside this).
|
||||
const NEXTVER = join(import.meta.dir, "..", "bin", "gstack-next-version");
|
||||
|
||||
function fixtureRepo(): { root: string; work: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), "nextver-contract-"));
|
||||
// detectHost() sniffs "github.com" in the origin URL STRING before any
|
||||
// gh/glab auth probe — a bare origin at a path containing github.com
|
||||
// pins host:"github" identically on every machine (an auth-probe
|
||||
// fallthrough once made this test pass via glab locally and read
|
||||
// host:"unknown" on CI) while keeping ls-remote/fetch fully local.
|
||||
const bare = join(root, "github.com", "origin.git");
|
||||
mkdirSync(bare, { recursive: true });
|
||||
Bun.spawnSync(["git", "init", "-q", "--bare", "-b", "main", bare]);
|
||||
const work = join(root, "work");
|
||||
mkdirSync(work);
|
||||
const git = (...args: string[]) =>
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd: work });
|
||||
git("init", "-q", "-b", "main");
|
||||
writeFileSync(join(work, "VERSION"), "1.0.0.0\n");
|
||||
git("add", "-A");
|
||||
git("commit", "-qm", "v1.0.0.0 chore: base");
|
||||
git("remote", "add", "origin", bare);
|
||||
git("push", "-q", "origin", "main");
|
||||
return { root, work };
|
||||
}
|
||||
|
||||
test("emits fallback:'git' and still returns a version when gh fails", async () => {
|
||||
// Stub `gh` always fails — what an expired token or an offline laptop
|
||||
// looks like from here. No origin remote → ls-remote fails fast and the
|
||||
// allocation comes from local refs, never the network.
|
||||
const stubDir = mkdtempSync(join(tmpdir(), "nextver-stubgh-"));
|
||||
const { root, work } = fixtureRepo();
|
||||
writeFileSync(join(stubDir, "gh"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
const proc = Bun.spawnSync(
|
||||
["bun", "run", "./bin/gstack-next-version", "--base", "main",
|
||||
["bun", "run", NEXTVER, "--base", "main",
|
||||
"--bump", "patch", "--current-version", "1.0.0.0", "--workspace-root", "null"],
|
||||
{ env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
|
||||
{ cwd: work, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
|
||||
);
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
const out = JSON.parse(new TextDecoder().decode(proc.stdout));
|
||||
expect(out.host).toBe("github"); // pinned by the fixture's URL sniff, not auth probes
|
||||
expect(out.offline).toBe(true);
|
||||
expect(out.fallback).toBe("git");
|
||||
// The whole point: degraded queue view, NOT a degraded allocation.
|
||||
@@ -406,13 +441,28 @@ describe("offline output contract (what /ship branches on, #2545)", () => {
|
||||
}, 30000);
|
||||
|
||||
test("online runs leave fallback null", async () => {
|
||||
const proc = Bun.spawnSync(
|
||||
["bun", "run", "./bin/gstack-next-version", "--base", "main",
|
||||
"--bump", "patch", "--current-version", "1.0.0.0", "--workspace-root", "null"],
|
||||
// Stub `gh` SUCCEEDS (empty PR queue) — the online path asserted
|
||||
// deterministically instead of only when the operator happens to be
|
||||
// authed. Before this stub the test silently no-opped on CI.
|
||||
const stubDir = mkdtempSync(join(tmpdir(), "nextver-stubgh-ok-"));
|
||||
const { root, work } = fixtureRepo();
|
||||
writeFileSync(
|
||||
join(stubDir, "gh"),
|
||||
'#!/bin/sh\ncase "$1" in\n pr) echo "[]" ;;\n repo) echo "testowner" ;;\n *) exit 0 ;;\nesac\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const proc = Bun.spawnSync(
|
||||
["bun", "run", NEXTVER, "--base", "main",
|
||||
"--bump", "patch", "--current-version", "1.0.0.0", "--workspace-root", "null"],
|
||||
{ cwd: work, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
|
||||
);
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
const out = JSON.parse(new TextDecoder().decode(proc.stdout));
|
||||
if (out.offline) return; // no network / no gh auth on this machine: nothing to assert
|
||||
expect(out.host).toBe("github"); // pinned by the fixture's URL sniff, not auth probes
|
||||
expect(out.offline).toBe(false);
|
||||
expect(out.fallback).toBe(null);
|
||||
expect(out.version).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
@@ -542,6 +592,343 @@ describe("fetchGitClaimed (offline allocation — the anti-duplicate fallback, #
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitClaimed — non-mutating live remote query (ls-remote first)", () => {
|
||||
// The degraded git-fallback used to count EVERY remote-tracking ref on EVERY
|
||||
// remote: branches deleted on origin (stale local refs) and an unrelated
|
||||
// `upstream` remote's branches all inflated the claim set, pushing the
|
||||
// allocation past the real queue. `git ls-remote --heads origin` returns the
|
||||
// remote's LIVE branch list with zero local mutation — a path/file remote
|
||||
// answers it offline, which is exactly what these fixtures use.
|
||||
function git(cwd: string, ...args: string[]) {
|
||||
return Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd });
|
||||
}
|
||||
|
||||
// Local origin with: main (0.1.66.0), sibling (0.1.67.0, live claim), and
|
||||
// dead (0.1.98.0) — deleted on origin AFTER the clone, so the clone keeps a
|
||||
// stale refs/remotes/origin/dead. Plus a second remote's stale claim ref.
|
||||
function liveFixture(): { root: string; clone: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), "nextver-lsremote-"));
|
||||
const origin = join(root, "origin");
|
||||
mkdirSync(origin);
|
||||
git(origin, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(origin, "VERSION"), "0.1.66.0\n");
|
||||
git(origin, "add", "-A");
|
||||
git(origin, "commit", "-qm", "v0.1.66.0 chore: base");
|
||||
git(origin, "checkout", "-q", "-b", "sibling");
|
||||
writeFileSync(join(origin, "VERSION"), "0.1.67.0\n");
|
||||
git(origin, "add", "-A");
|
||||
git(origin, "commit", "-qm", "v0.1.67.0 feat: sibling claimed this");
|
||||
git(origin, "checkout", "-q", "-b", "dead");
|
||||
writeFileSync(join(origin, "VERSION"), "0.1.98.0\n");
|
||||
git(origin, "add", "-A");
|
||||
git(origin, "commit", "-qm", "v0.1.98.0 feat: deleted later");
|
||||
git(origin, "checkout", "-q", "main");
|
||||
const clone = join(root, "clone");
|
||||
git(root, "clone", "-q", origin, clone);
|
||||
// Deleted on the REMOTE after the clone — the stale local ref survives.
|
||||
git(origin, "branch", "-qD", "dead");
|
||||
// A second remote carrying a stale claim branch: must never be counted.
|
||||
git(clone, "checkout", "-q", "-b", "tmp-upstream");
|
||||
writeFileSync(join(clone, "VERSION"), "0.1.99.0\n");
|
||||
git(clone, "add", "-A");
|
||||
git(clone, "commit", "-qm", "v0.1.99.0 upstream stale claim");
|
||||
const upSha = new TextDecoder().decode(git(clone, "rev-parse", "HEAD").stdout).trim();
|
||||
git(clone, "checkout", "-q", "main");
|
||||
git(clone, "branch", "-qD", "tmp-upstream");
|
||||
git(clone, "update-ref", "refs/remotes/upstream/stale", upSha);
|
||||
return { root, clone };
|
||||
}
|
||||
|
||||
test("live path: only branches that exist on origin RIGHT NOW are claims", () => {
|
||||
const { root, clone } = liveFixture();
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
process.chdir(clone);
|
||||
const warnings: string[] = [];
|
||||
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||
const versions = claims.map((c) => c.version);
|
||||
expect(versions).toContain("0.1.67.0"); // live sibling claim
|
||||
expect(versions).not.toContain("0.1.98.0"); // deleted on origin — stale local ref ignored
|
||||
expect(versions).not.toContain("0.1.99.0"); // second remote's refs are not our queue
|
||||
// The live path emits no staleness warning.
|
||||
expect(warnings.join(" ")).not.toContain("ls-remote");
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("zero local mutation: the stale remote-tracking ref survives the query", () => {
|
||||
// ls-remote reads the remote without fetch/prune — an allocator run must
|
||||
// never rewrite local refs as a side effect.
|
||||
const { root, clone } = liveFixture();
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
process.chdir(clone);
|
||||
fetchGitClaimed("main", "VERSION", []);
|
||||
const ref = git(clone, "rev-parse", "--verify", "-q", "refs/remotes/origin/dead");
|
||||
expect(ref.exitCode).toBe(0);
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fallback: ls-remote failure uses LOCAL refs/remotes/origin only, with a staleness warning", () => {
|
||||
// No origin remote configured at all — ls-remote must fail, and the
|
||||
// fallback must scan refs/remotes/origin ONLY (never other remotes).
|
||||
const dir = mkdtempSync(join(tmpdir(), "nextver-lsfallback-"));
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
git(dir, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(dir, "VERSION"), "0.1.66.0\n");
|
||||
git(dir, "add", "-A");
|
||||
git(dir, "commit", "-qm", "v0.1.66.0 chore: base");
|
||||
git(dir, "checkout", "-q", "-b", "sibling");
|
||||
writeFileSync(join(dir, "VERSION"), "0.1.67.0\n");
|
||||
git(dir, "add", "-A");
|
||||
git(dir, "commit", "-qm", "v0.1.67.0 feat: sibling claimed this");
|
||||
const sibSha = new TextDecoder().decode(git(dir, "rev-parse", "HEAD").stdout).trim();
|
||||
git(dir, "checkout", "-q", "-b", "stale2");
|
||||
writeFileSync(join(dir, "VERSION"), "0.1.99.0\n");
|
||||
git(dir, "add", "-A");
|
||||
git(dir, "commit", "-qm", "v0.1.99.0 upstream stale claim");
|
||||
const upSha = new TextDecoder().decode(git(dir, "rev-parse", "HEAD").stdout).trim();
|
||||
git(dir, "checkout", "-q", "main");
|
||||
git(dir, "update-ref", "refs/remotes/origin/sibling", sibSha);
|
||||
git(dir, "update-ref", "refs/remotes/upstream/stale", upSha);
|
||||
|
||||
process.chdir(dir);
|
||||
const warnings: string[] = [];
|
||||
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||
const versions = claims.map((c) => c.version);
|
||||
expect(versions).toContain("0.1.67.0"); // origin's local snapshot still counts
|
||||
expect(versions).not.toContain("0.1.99.0"); // upstream remote is ignored
|
||||
expect(warnings.join(" ")).toContain("stale local refs/remotes/origin");
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitClaimed — unfetched live claims (G2: ls-remote advertises SHAs without objects)", () => {
|
||||
// `git ls-remote` lists a branch's tip sha without transferring objects, so
|
||||
// a branch pushed AFTER the last local fetch has no local object and both
|
||||
// VERSION reads fail. The old `continue` silently dropped that LIVE claim —
|
||||
// the exact duplicate-allocation this fallback exists to prevent.
|
||||
function git(cwd: string, ...args: string[]) {
|
||||
return Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd });
|
||||
}
|
||||
|
||||
function cloneFixture(): { root: string; origin: string; clone: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), "nextver-unfetched-"));
|
||||
const origin = join(root, "origin");
|
||||
mkdirSync(origin);
|
||||
git(origin, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(origin, "VERSION"), "0.1.66.0\n");
|
||||
git(origin, "add", "-A");
|
||||
git(origin, "commit", "-qm", "v0.1.66.0 chore: base");
|
||||
const clone = join(root, "clone");
|
||||
git(root, "clone", "-q", origin, clone);
|
||||
return { root, origin, clone };
|
||||
}
|
||||
|
||||
test("a claim branch pushed after the last local fetch is read via a targeted fetch", () => {
|
||||
const { root, origin, clone } = cloneFixture();
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
// The claim lands on origin AFTER the clone — its objects are absent
|
||||
// locally, so `git show <sha>:VERSION` and the remote-tracking read
|
||||
// both fail until the targeted fetch runs.
|
||||
git(origin, "checkout", "-q", "-b", "late-claim");
|
||||
writeFileSync(join(origin, "VERSION"), "0.1.70.0\n");
|
||||
git(origin, "add", "-A");
|
||||
git(origin, "commit", "-qm", "v0.1.70.0 feat: late claim");
|
||||
git(origin, "checkout", "-q", "main");
|
||||
|
||||
process.chdir(clone);
|
||||
const warnings: string[] = [];
|
||||
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||
expect(claims.map((c) => c.version)).toContain("0.1.70.0");
|
||||
expect(warnings.join(" ")).not.toContain("UNKNOWN claim");
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("MANY unfetched claim branches resolve with ONE batched fetch, not a per-branch crawl", () => {
|
||||
// The per-branch fetch loop this replaces ground a shallow CI clone
|
||||
// against a busy remote for minutes (dozens of sequential network
|
||||
// fetches). The network budget must stay one round trip no matter how
|
||||
// many branches are missing — pinned by counting `git fetch` spawns
|
||||
// through a PATH shim.
|
||||
const { root, origin, clone } = cloneFixture();
|
||||
const cwd = process.cwd();
|
||||
const oldPath = process.env.PATH;
|
||||
const shimDir = mkdtempSync(join(tmpdir(), "nextver-gitshim-"));
|
||||
try {
|
||||
for (const v of ["0.1.70.0", "0.1.71.0", "0.1.72.0"]) {
|
||||
git(origin, "checkout", "-q", "-b", `late-${v.replace(/\./g, "-")}`);
|
||||
writeFileSync(join(origin, "VERSION"), `${v}\n`);
|
||||
git(origin, "add", "-A");
|
||||
git(origin, "commit", "-qm", `v${v} feat: late claim`);
|
||||
git(origin, "checkout", "-q", "main");
|
||||
}
|
||||
|
||||
const realGit = Bun.which("git");
|
||||
const spawnLog = join(shimDir, "spawns.log");
|
||||
writeFileSync(
|
||||
join(shimDir, "git"),
|
||||
`#!/bin/sh\necho "$@" >> "${spawnLog}"\nexec "${realGit}" "$@"\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
process.chdir(clone);
|
||||
process.env.PATH = `${shimDir}:${oldPath}`;
|
||||
const warnings: string[] = [];
|
||||
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||
process.env.PATH = oldPath;
|
||||
|
||||
const versions = claims.map((c) => c.version);
|
||||
expect(versions).toContain("0.1.70.0");
|
||||
expect(versions).toContain("0.1.71.0");
|
||||
expect(versions).toContain("0.1.72.0");
|
||||
expect(warnings.join(" ")).not.toContain("UNKNOWN claim");
|
||||
const fetches = readFileSync(spawnLog, "utf-8")
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("fetch "));
|
||||
expect(fetches.length).toBe(1);
|
||||
for (const v of ["0-1-70-0", "0-1-71-0", "0-1-72-0"]) {
|
||||
expect(fetches[0]).toContain(`refs/heads/late-${v}`);
|
||||
}
|
||||
} finally {
|
||||
process.env.PATH = oldPath;
|
||||
process.chdir(cwd);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
rmSync(shimDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("one unservable ref does not poison the batch — live claims still resolve, the ghost warns", () => {
|
||||
// A dangling sha fails the WHOLE batched transfer, so the still-missing
|
||||
// refs get a bounded per-branch retry: the real claim must come through
|
||||
// and only the ghost surfaces as UNKNOWN.
|
||||
const { root, origin, clone } = cloneFixture();
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
git(origin, "checkout", "-q", "-b", "late-claim");
|
||||
writeFileSync(join(origin, "VERSION"), "0.1.70.0\n");
|
||||
git(origin, "add", "-A");
|
||||
git(origin, "commit", "-qm", "v0.1.70.0 feat: late claim");
|
||||
git(origin, "checkout", "-q", "main");
|
||||
writeFileSync(
|
||||
join(origin, ".git", "refs", "heads", "ghost"),
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
|
||||
);
|
||||
|
||||
process.chdir(clone);
|
||||
const warnings: string[] = [];
|
||||
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||
expect(claims.map((c) => c.version)).toContain("0.1.70.0");
|
||||
const joined = warnings.join(" ");
|
||||
expect(joined).toContain("origin/ghost");
|
||||
expect(joined).toContain("UNKNOWN claim");
|
||||
expect(joined).not.toContain("late-claim");
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a claim STILL unreadable after the fetch surfaces as an UNKNOWN-claim warning, never silence", () => {
|
||||
const { root, origin, clone } = cloneFixture();
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
// A ref origin advertises but cannot serve: dangling sha written
|
||||
// straight into refs/. ls-remote lists it; every local read fails, the
|
||||
// targeted fetch fails ("not our ref"), and the object never appears.
|
||||
writeFileSync(
|
||||
join(origin, ".git", "refs", "heads", "ghost"),
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
|
||||
);
|
||||
|
||||
process.chdir(clone);
|
||||
const warnings: string[] = [];
|
||||
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||
expect(claims.map((c) => c.branch)).not.toContain("origin/ghost");
|
||||
const joined = warnings.join(" ");
|
||||
expect(joined).toContain("origin/ghost");
|
||||
expect(joined).toContain("UNKNOWN claim");
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a live branch that simply carries no VERSION file is not a claim and not an UNKNOWN warning", () => {
|
||||
const { root, origin, clone } = cloneFixture();
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
// Branch exists BEFORE the clone (objects local), VERSION deleted on it:
|
||||
// the read fails because the PATH is absent, not the object. Old
|
||||
// semantics (skip quietly) must hold — no phantom UNKNOWN noise.
|
||||
git(origin, "checkout", "-q", "-b", "docs-only");
|
||||
git(origin, "rm", "-q", "VERSION");
|
||||
git(origin, "commit", "-qm", "docs: no version file");
|
||||
git(origin, "checkout", "-q", "main");
|
||||
git(clone, "fetch", "-q", "origin");
|
||||
|
||||
process.chdir(clone);
|
||||
const warnings: string[] = [];
|
||||
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||
expect(claims.map((c) => c.branch)).not.toContain("origin/docs-only");
|
||||
expect(warnings.join(" ")).not.toContain("docs-only");
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("width pinned on failed base read (3-digit repos)", () => {
|
||||
// readBaseVersion used to return a literal "0.0.0.0" when origin/<base> was
|
||||
// unreadable — a 4-digit string, which flipped versionWidth() to 4 and
|
||||
// handed a 3-digit repo a 4-digit slot its tooling can't read back (#2501's
|
||||
// width class, resurfacing through the failure path). The zero base is now
|
||||
// shaped by the LOCAL version file's width.
|
||||
const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-next-version");
|
||||
|
||||
test("a 3-digit repo keeps 3-digit allocation when origin/<base> is unreadable", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nextver-width3-"));
|
||||
const stubDir = mkdtempSync(join(tmpdir(), "nextver-width3-stub-"));
|
||||
try {
|
||||
// gh/glab stubs fail → host unknown → git fallback; no origin remote →
|
||||
// the base read fails too, which is the path under test.
|
||||
writeFileSync(join(stubDir, "gh"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
writeFileSync(join(stubDir, "glab"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd: dir });
|
||||
writeFileSync(join(dir, "VERSION"), "0.99.2\n");
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: dir });
|
||||
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "init"], { cwd: dir });
|
||||
|
||||
const proc = Bun.spawnSync(
|
||||
["bun", "run", SCRIPT, "--base", "main", "--bump", "patch", "--workspace-root", "null"],
|
||||
{ cwd: dir, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
|
||||
);
|
||||
const out = JSON.parse(new TextDecoder().decode(proc.stdout));
|
||||
// Zero base at the repo's OWN width — never "0.0.0.0" in a 3-digit repo.
|
||||
expect(out.base_version).toBe("0.0.0");
|
||||
expect(out.version).toBe("0.0.1"); // 3-digit allocation, not 0.0.1.0
|
||||
expect(out.warnings.join(" ")).not.toContain("0.0.0.0");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("integration (smoke)", () => {
|
||||
// Bumps timeout to 30s — the test spawns a real `bun run` subprocess that
|
||||
// does a `gh pr list` against the live GitHub API to inspect claimed slots.
|
||||
|
||||
@@ -53,6 +53,18 @@ function runWithStdin(input: string, ...args: string[]): { stdout: string; stder
|
||||
};
|
||||
}
|
||||
|
||||
/** Plant a pref by writing the file. Used for legacy / inert one-way prefs
|
||||
* that --write now refuses (#2488). --check and --stats still have to
|
||||
* handle files written before the reject landed. */
|
||||
function plantPref(id: string, pref: string) {
|
||||
run('--read');
|
||||
const projects = fs.readdirSync(path.join(tmpHome, 'projects'));
|
||||
const file = path.join(tmpHome, 'projects', projects[0], 'question-preferences.json');
|
||||
const prefs = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
prefs[id] = pref;
|
||||
fs.writeFileSync(file, JSON.stringify(prefs, null, 2));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// --check
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -92,7 +104,8 @@ describe('--check with preferences set', () => {
|
||||
});
|
||||
|
||||
test('one-way + never-ask → ASK_NORMALLY with safety note', () => {
|
||||
setPref('ship-test-failure-triage', 'never-ask');
|
||||
// Planted: --write now refuses never-ask on one-way ids (#2488).
|
||||
plantPref('ship-test-failure-triage', 'never-ask');
|
||||
const r = run('--check', 'ship-test-failure-triage');
|
||||
expect(r.stdout).toContain('ASK_NORMALLY');
|
||||
expect(r.stdout).toContain('one-way door overrides');
|
||||
@@ -111,7 +124,7 @@ describe('--check with preferences set', () => {
|
||||
});
|
||||
|
||||
test('one-way + ask-only-for-one-way → ASK_NORMALLY', () => {
|
||||
setPref('ship-test-failure-triage', 'ask-only-for-one-way');
|
||||
plantPref('ship-test-failure-triage', 'ask-only-for-one-way');
|
||||
const r = run('--check', 'ship-test-failure-triage');
|
||||
expect(r.stdout.trim()).toContain('ASK_NORMALLY');
|
||||
});
|
||||
@@ -389,6 +402,112 @@ describe('--write schema validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #2488: --write must refuse suppressing prefs on one-way ids. --check
|
||||
// already ignores them; storing them made --stats report a working NEVER_ASK.
|
||||
describe('--write one-way door reject (#2488)', () => {
|
||||
function prefsFile(): string {
|
||||
const projects = fs.readdirSync(path.join(tmpHome, 'projects'));
|
||||
return path.join(tmpHome, 'projects', projects[0], 'question-preferences.json');
|
||||
}
|
||||
|
||||
test('never-ask on one-way id is rejected and does not write', () => {
|
||||
const r = run(
|
||||
'--write',
|
||||
JSON.stringify({
|
||||
question_id: 'plan-eng-review-arch-finding',
|
||||
preference: 'never-ask',
|
||||
source: 'plan-tune',
|
||||
}),
|
||||
);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('cannot set never-ask');
|
||||
expect(r.stderr).toContain('plan-eng-review-arch-finding');
|
||||
expect(r.stderr).toContain('door_type: one-way');
|
||||
expect(JSON.parse(fs.readFileSync(prefsFile(), 'utf-8'))).toEqual({});
|
||||
});
|
||||
|
||||
test('ask-only-for-one-way on one-way id is rejected and does not write', () => {
|
||||
const r = run(
|
||||
'--write',
|
||||
JSON.stringify({
|
||||
question_id: 'ship-test-failure-triage',
|
||||
preference: 'ask-only-for-one-way',
|
||||
source: 'plan-tune',
|
||||
}),
|
||||
);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('cannot set ask-only-for-one-way');
|
||||
expect(r.stderr).toContain('door_type: one-way');
|
||||
expect(JSON.parse(fs.readFileSync(prefsFile(), 'utf-8'))).toEqual({});
|
||||
});
|
||||
|
||||
test('always-ask on one-way id is accepted (agrees with the safety override)', () => {
|
||||
const r = run(
|
||||
'--write',
|
||||
JSON.stringify({
|
||||
question_id: 'plan-eng-review-arch-finding',
|
||||
preference: 'always-ask',
|
||||
source: 'plan-tune',
|
||||
}),
|
||||
);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('OK');
|
||||
expect(JSON.parse(fs.readFileSync(prefsFile(), 'utf-8'))).toEqual({
|
||||
'plan-eng-review-arch-finding': 'always-ask',
|
||||
});
|
||||
});
|
||||
|
||||
test('never-ask on two-way id is still accepted', () => {
|
||||
const r = run(
|
||||
'--write',
|
||||
JSON.stringify({
|
||||
question_id: 'ship-changelog-voice-polish',
|
||||
preference: 'never-ask',
|
||||
source: 'plan-tune',
|
||||
}),
|
||||
);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('OK');
|
||||
});
|
||||
|
||||
test('rejected one-way write does not clobber an existing two-way pref', () => {
|
||||
run(
|
||||
'--write',
|
||||
JSON.stringify({
|
||||
question_id: 'ship-changelog-voice-polish',
|
||||
preference: 'never-ask',
|
||||
source: 'plan-tune',
|
||||
}),
|
||||
);
|
||||
const r = run(
|
||||
'--write',
|
||||
JSON.stringify({
|
||||
question_id: 'plan-eng-review-arch-finding',
|
||||
preference: 'never-ask',
|
||||
source: 'plan-tune',
|
||||
}),
|
||||
);
|
||||
expect(r.status).toBe(1);
|
||||
expect(JSON.parse(fs.readFileSync(prefsFile(), 'utf-8'))).toEqual({
|
||||
'ship-changelog-voice-polish': 'never-ask',
|
||||
});
|
||||
});
|
||||
|
||||
test('poisoning source on a one-way id still exits 2 (origin gate first)', () => {
|
||||
const r = run(
|
||||
'--write',
|
||||
JSON.stringify({
|
||||
question_id: 'plan-eng-review-arch-finding',
|
||||
preference: 'never-ask',
|
||||
source: 'inline-tool-output',
|
||||
}),
|
||||
);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('profile poisoning defense');
|
||||
expect(r.stderr).not.toContain('door_type');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// --read, --clear, --stats
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -448,5 +567,21 @@ describe('--stats', () => {
|
||||
expect(r.stdout).toContain('TOTAL: 3');
|
||||
expect(r.stdout).toContain('NEVER_ASK: 2');
|
||||
expect(r.stdout).toContain('ALWAYS_ASK: 1');
|
||||
expect(r.stdout).toContain('INERT_ONE_WAY: 0');
|
||||
});
|
||||
|
||||
test('planted one-way never-ask is INERT_ONE_WAY, not a working NEVER_ASK (#2488)', () => {
|
||||
plantPref('plan-eng-review-arch-finding', 'never-ask');
|
||||
const r = run('--stats');
|
||||
expect(r.stdout).toContain('TOTAL: 1');
|
||||
expect(r.stdout).toContain('NEVER_ASK: 0');
|
||||
expect(r.stdout).toContain('INERT_ONE_WAY: 1');
|
||||
});
|
||||
|
||||
test('planted one-way ask-only-for-one-way is INERT_ONE_WAY, not ASK_ONLY_ONE_WAY', () => {
|
||||
plantPref('ship-test-failure-triage', 'ask-only-for-one-way');
|
||||
const r = run('--stats');
|
||||
expect(r.stdout).toContain('ASK_ONLY_ONE_WAY: 0');
|
||||
expect(r.stdout).toContain('INERT_ONE_WAY: 1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,13 @@ import * as os from "os";
|
||||
|
||||
const BIN = path.resolve(import.meta.dir, "..", "bin", "gstack-redact");
|
||||
|
||||
// A synthetic AWS access key for feeding the scanner. Derived by
|
||||
// concatenation so the contiguous credential-shaped literal never appears in
|
||||
// this file's source — the CI quality gate scans every ADDED diff line with
|
||||
// this same engine, and a raw fixture literal here fails the gate it exists
|
||||
// to test (#2610 port fallout). The scanner still sees the assembled bytes.
|
||||
const FAKE_AWS_KEY = ["AKIA", "1234567890ABCDEF"].join("");
|
||||
|
||||
function run(
|
||||
args: string[],
|
||||
stdin: string,
|
||||
@@ -28,7 +35,7 @@ describe("gstack-redact exit codes", () => {
|
||||
expect(run([], "just some prose").code).toBe(0);
|
||||
});
|
||||
test("HIGH → 3", () => {
|
||||
expect(run([], "key AKIA1234567890ABCDEF").code).toBe(3);
|
||||
expect(run([], `key ${FAKE_AWS_KEY}`).code).toBe(3);
|
||||
});
|
||||
test("MEDIUM only → 2", () => {
|
||||
expect(run(["--repo-visibility", "public"], "mail bob@corp.io").code).toBe(2);
|
||||
@@ -37,7 +44,7 @@ describe("gstack-redact exit codes", () => {
|
||||
|
||||
describe("gstack-redact --json", () => {
|
||||
test("emits valid JSON with findings + counts", () => {
|
||||
const { stdout, code } = run(["--json"], "key AKIA1234567890ABCDEF");
|
||||
const { stdout, code } = run(["--json"], `key ${FAKE_AWS_KEY}`);
|
||||
expect(code).toBe(3);
|
||||
const parsed = JSON.parse(stdout);
|
||||
expect(parsed.findings[0].id).toBe("aws.access_key");
|
||||
@@ -59,8 +66,8 @@ describe("gstack-redact --allowlist", () => {
|
||||
test("allowlisted span is suppressed", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "redact-allow-"));
|
||||
const allow = path.join(dir, "allow.txt");
|
||||
fs.writeFileSync(allow, "AKIA1234567890ABCDEF\n");
|
||||
const { code } = run(["--allowlist", allow], "key AKIA1234567890ABCDEF");
|
||||
fs.writeFileSync(allow, FAKE_AWS_KEY + "\n");
|
||||
const { code } = run(["--allowlist", allow], `key ${FAKE_AWS_KEY}`);
|
||||
expect(code).toBe(0);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -95,3 +102,47 @@ describe("gstack-redact oversize fails closed", () => {
|
||||
expect(stdout).toContain("too large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("gstack-redact argv dispatch", () => {
|
||||
// The bug: main() recognised exactly two subcommands and let everything else
|
||||
// fall through to the stdin scan, which reports "no findings" and exits 0.
|
||||
// So `install-prepush-hooks` (plural typo) installed no hook and still looked
|
||||
// like success — the credential guard silently absent while the operator
|
||||
// believes it is armed. A guard that no-ops must never exit 0.
|
||||
test("a typo'd install subcommand fails loudly instead of exiting 0", () => {
|
||||
const { code, stderr } = run(["install-prepush-hooks"], "");
|
||||
expect(code).not.toBe(0);
|
||||
expect(stderr).toContain("unknown subcommand");
|
||||
});
|
||||
|
||||
test("an unknown positional never reports a clean scan", () => {
|
||||
const { code, stdout } = run(["totally-bogus"], "");
|
||||
expect(code).not.toBe(0);
|
||||
expect(stdout).not.toContain("HIGH=0");
|
||||
});
|
||||
|
||||
// Usage errors must not collide with the findings codes (2 = MEDIUM,
|
||||
// 3 = HIGH); a caller gating on those would read a typo as "findings".
|
||||
test("usage errors exit 1, not a findings code", () => {
|
||||
expect(run(["totally-bogus"], "").code).toBe(1);
|
||||
});
|
||||
|
||||
test("--help prints usage and exits 0 without scanning", () => {
|
||||
const { code, stdout } = run(["--help"], `key ${FAKE_AWS_KEY}`);
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain("STDIN");
|
||||
expect(stdout).not.toContain("HIGH=1");
|
||||
});
|
||||
|
||||
// "scan" is what the human output header ("gstack-redact scan — repo …")
|
||||
// invites people to type, so it stays an accepted alias for the default
|
||||
// filter mode. Rejecting it would break that muscle memory for no gain.
|
||||
test("the 'scan' alias still scans normally", () => {
|
||||
expect(run(["scan"], `key ${FAKE_AWS_KEY}`).code).toBe(3);
|
||||
expect(run(["scan"], "just prose").code).toBe(0);
|
||||
});
|
||||
|
||||
test("flags are still parsed, not mistaken for subcommands", () => {
|
||||
expect(run(["--json"], "just prose").code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -219,6 +219,77 @@ describe('add-event', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// ensure-event: duplicate (event, source) collapse
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
describe('ensure-event collapses duplicate (event, source) entries', () => {
|
||||
test('two same-source entries from the old matcher-keyed dedup collapse to ONE updated entry', () => {
|
||||
// Pre-existing installs can carry two entries with the same
|
||||
// (event, _gstack_source) — the old dedup keyed on the matcher too, so a
|
||||
// matcher change pushed a second registration. `.find()` updated only the
|
||||
// first and left the stale twin running forever.
|
||||
const { spawnSync } = require('child_process');
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcherA', hooks: [{ type: 'command', command: '/old-a', timeout: 5 }] },
|
||||
{ matcher: 'Bash', hooks: [{ type: 'command', command: '/user-own-hook' }] },
|
||||
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcherB', hooks: [{ type: 'command', command: '/old-b', timeout: 5 }] },
|
||||
],
|
||||
},
|
||||
}, null, 2));
|
||||
|
||||
const r = spawnSync('bash', [
|
||||
SETTINGS_HOOK, 'ensure-event',
|
||||
'--event', 'PostToolUse',
|
||||
'--matcher', 'NewMatcher',
|
||||
'--command', '/canonical',
|
||||
'--source', 'plan-tune-cathedral',
|
||||
'--timeout', '5',
|
||||
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
||||
|
||||
expect(r.status).toBe(0);
|
||||
// The collapse is reported on stderr, never silent.
|
||||
expect(r.stderr).toContain('collapsed 1 duplicate');
|
||||
const s = settings();
|
||||
const mine = s.hooks.PostToolUse.filter((e: any) => e._gstack_source === 'plan-tune-cathedral');
|
||||
expect(mine).toHaveLength(1); // ONE canonical entry — the stale twin is gone
|
||||
expect(mine[0].matcher).toBe('NewMatcher');
|
||||
expect(mine[0].hooks[0].command).toBe('/canonical');
|
||||
// Unrelated user hook untouched.
|
||||
const bash = s.hooks.PostToolUse.find((e: any) => e.matcher === 'Bash');
|
||||
expect(bash.hooks[0].command).toBe('/user-own-hook');
|
||||
expect(s.hooks.PostToolUse).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('no duplicates → no collapse message, single entry updated as before', () => {
|
||||
const { spawnSync } = require('child_process');
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcher', hooks: [{ type: 'command', command: '/old', timeout: 5 }] },
|
||||
],
|
||||
},
|
||||
}, null, 2));
|
||||
|
||||
const r = spawnSync('bash', [
|
||||
SETTINGS_HOOK, 'ensure-event',
|
||||
'--event', 'PostToolUse',
|
||||
'--matcher', 'NewMatcher',
|
||||
'--command', '/new',
|
||||
'--source', 'plan-tune-cathedral',
|
||||
'--timeout', '5',
|
||||
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
||||
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).not.toContain('collapsed');
|
||||
const s = settings();
|
||||
expect(s.hooks.PostToolUse).toHaveLength(1);
|
||||
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/new');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// remove-source
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* bin/gstack-slug ↔ browse/bin/remote-slug parity.
|
||||
*
|
||||
* The bug this pins (2026-08-17, observed live in a Conductor worktree of
|
||||
* garrytan/gstack): a stray marker-bearing ancestor above the repo — an empty
|
||||
* `~/.git` directory that is not even a valid git repo — captured
|
||||
* gstack-slug's "outermost strong marker" walk-up as the project root. That
|
||||
* ancestor has no `origin` remote, so the resolver silently degraded to
|
||||
* `basename($HOME)` and emitted `SLUG=garrytan`, while remote-slug (which
|
||||
* asks git for the containing repo's remote) correctly said
|
||||
* `garrytan-gstack`. Every store keyed on the slug (decisions, timeline,
|
||||
* ceo-plans, learnings) filed into ~/.gstack/projects/garrytan/ — one bucket
|
||||
* shared by every repo under $HOME.
|
||||
*
|
||||
* The fix makes the canonical remote authoritative: gstack-slug now walks the
|
||||
* ancestor chain for the OUTERMOST dir with a `.git` entry (dir for normal
|
||||
* clones, FILE for git-worktrees) whose `origin` remote resolves, and derives
|
||||
* `owner-repo` with the exact same parse remote-slug uses. Marker-only
|
||||
* ancestors that are not remote-bearing repos can still anchor the basename
|
||||
* FALLBACK, but they can no longer shadow a real remote.
|
||||
*
|
||||
* Contracts pinned here:
|
||||
* - Parity: for any repo (plain clone or git-worktree) whose slug derivation
|
||||
* reaches a canonical remote, gstack-slug's SLUG equals remote-slug's
|
||||
* output — including under a stray-marker home.
|
||||
* - Walk-up preserved: a nested inner repo under an outer canonical-remote
|
||||
* repo resolves to the OUTER repo's owner-repo (outermost wins), matching
|
||||
* remote-slug run at the outer root.
|
||||
* - Fallback preserved: a no-remote repo still resolves to its basename.
|
||||
* - Cache self-heal: a pre-fix degraded cache entry (== the bogus marker
|
||||
* root's basename) is rewritten to the canonical slug; legit #2212 sticky
|
||||
* identity (repo that adopted a remote after first use) is NOT healed.
|
||||
*
|
||||
* Test pattern mirrors test/gstack-slug-cwd-walk-up.test.ts: per-test
|
||||
* tmpHome, spawnSync against the real bash scripts, fixtures on disk.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { spawnSync, type SpawnSyncReturns } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SLUG_SCRIPT = path.join(ROOT, 'bin', 'gstack-slug');
|
||||
const REMOTE_SLUG_SCRIPT = path.join(ROOT, 'browse', 'bin', 'remote-slug');
|
||||
|
||||
function baseEnv(tmpHome: string): Record<string, string | undefined> {
|
||||
// Drop any ambient override: a sibling test leaking GSTACK_PROJECT_SLUG in
|
||||
// a shared-process shard would flip runs into override mode.
|
||||
const { GSTACK_PROJECT_SLUG: _drop, ...ambient } = process.env;
|
||||
return { ...ambient, HOME: tmpHome, GSTACK_HOME: path.join(tmpHome, '.gstack') };
|
||||
}
|
||||
|
||||
function runSlug(cwd: string, tmpHome: string): SpawnSyncReturns<string> {
|
||||
return spawnSync('bash', [SLUG_SCRIPT], {
|
||||
cwd,
|
||||
env: baseEnv(tmpHome),
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
function runRemoteSlug(cwd: string, tmpHome: string): SpawnSyncReturns<string> {
|
||||
return spawnSync('bash', [REMOTE_SLUG_SCRIPT], {
|
||||
cwd,
|
||||
env: baseEnv(tmpHome),
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
function slugOf(r: SpawnSyncReturns<string>): string {
|
||||
const m = r.stdout.match(/^SLUG=([^\n]*)$/m);
|
||||
return m ? m[1]! : '';
|
||||
}
|
||||
|
||||
function git(args: string[], opts: { cwd?: string } = {}): void {
|
||||
const r = spawnSync('git', args, { encoding: 'utf8', timeout: 10_000, ...opts });
|
||||
if (r.status !== 0) {
|
||||
throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** git init -b main + optional origin remote. Returns the repo path. */
|
||||
function makeRepo(dir: string, originUrl?: string): string {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
git(['init', '-q', '-b', 'main', dir]);
|
||||
if (originUrl) git(['-C', dir, 'remote', 'add', 'origin', originUrl]);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function encodedCacheKey(absPath: string): string {
|
||||
return absPath.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
/** Assert both scripts succeed in `cwd` and emit the same slug. */
|
||||
function expectParity(cwd: string, tmpHome: string, expected: string): void {
|
||||
const gstack = runSlug(cwd, tmpHome);
|
||||
const remote = runRemoteSlug(cwd, tmpHome);
|
||||
expect(gstack.status).toBe(0);
|
||||
expect(remote.status).toBe(0);
|
||||
const remoteOut = remote.stdout.trim();
|
||||
expect(slugOf(gstack)).toBe(expected);
|
||||
expect(remoteOut).toBe(expected);
|
||||
expect(slugOf(gstack)).toBe(remoteOut);
|
||||
}
|
||||
|
||||
describe('gstack-slug ↔ remote-slug parity', () => {
|
||||
let tmpHome: string;
|
||||
let fixtures: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// realpathSync: macOS tmpdir is a symlink (/var -> /private/var); the
|
||||
// scripts key their cache and walk on the resolved cwd.
|
||||
tmpHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'slug-parity-home-')));
|
||||
fixtures = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'slug-parity-fix-')));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch {}
|
||||
try { fs.rmSync(fixtures, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
|
||||
test('plain clone, https remote WITH .git suffix — identical owner-repo slug', () => {
|
||||
const repo = makeRepo(path.join(fixtures, 'proj'), 'https://github.com/acme/widgets.git');
|
||||
expectParity(repo, tmpHome, 'acme-widgets');
|
||||
});
|
||||
|
||||
test('plain clone, https remote WITHOUT .git suffix (live-bug URL shape) — identical slug', () => {
|
||||
const repo = makeRepo(path.join(fixtures, 'proj'), 'https://github.com/garrytan/gstack');
|
||||
expectParity(repo, tmpHome, 'garrytan-gstack');
|
||||
});
|
||||
|
||||
test('plain clone, scp-like ssh remote — identical owner-repo slug', () => {
|
||||
const repo = makeRepo(path.join(fixtures, 'proj'), 'git@github.com:acme/widgets.git');
|
||||
expectParity(repo, tmpHome, 'acme-widgets');
|
||||
});
|
||||
|
||||
test('git-worktree of a clone (.git FILE, the Conductor shape) — identical slug', () => {
|
||||
const main = makeRepo(path.join(fixtures, 'main-clone'), 'https://github.com/garrytan/gstack');
|
||||
git(['-C', main, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init']);
|
||||
const wt = path.join(fixtures, 'wt');
|
||||
git(['-C', main, 'worktree', 'add', '-q', wt, '-b', 'feature-branch']);
|
||||
// Sanity: worktree roots carry a .git FILE, not a directory.
|
||||
expect(fs.statSync(path.join(wt, '.git')).isFile()).toBe(true);
|
||||
expectParity(wt, tmpHome, 'garrytan-gstack');
|
||||
});
|
||||
|
||||
test('LIVE BUG SHAPE: stray empty .git on an ancestor "home" no longer degrades the slug', () => {
|
||||
// The exact 2026-08-17 reproduction: an ancestor dir with an empty .git
|
||||
// (not a valid repo, no origin) above a canonical-remote worktree.
|
||||
const strayHome = path.join(fixtures, 'strayhome');
|
||||
fs.mkdirSync(path.join(strayHome, '.git'), { recursive: true }); // empty — invalid repo
|
||||
const main = makeRepo(
|
||||
path.join(strayHome, 'conductor', 'workspaces', 'gstack', 'main-clone'),
|
||||
'https://github.com/garrytan/gstack',
|
||||
);
|
||||
git(['-C', main, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init']);
|
||||
const wt = path.join(strayHome, 'conductor', 'workspaces', 'gstack', 'beirut-v4');
|
||||
git(['-C', main, 'worktree', 'add', '-q', wt, '-b', 'gstack-fix-wave']);
|
||||
|
||||
// Both the plain clone and the worktree must resolve to owner-repo — the
|
||||
// pre-fix resolver emitted `strayhome` (the marker root's basename) here.
|
||||
expectParity(main, tmpHome, 'garrytan-gstack');
|
||||
expectParity(wt, tmpHome, 'garrytan-gstack');
|
||||
expect(slugOf(runSlug(wt, tmpHome))).not.toBe('strayhome');
|
||||
});
|
||||
|
||||
test('walk-up preserved: nested inner repo (no remote) resolves to the OUTER repo slug', () => {
|
||||
const outer = makeRepo(path.join(fixtures, 'outer'), 'git@github.com:acme/outer.git');
|
||||
const inner = makeRepo(path.join(outer, 'vendor', 'inner'));
|
||||
|
||||
const gstack = runSlug(inner, tmpHome);
|
||||
expect(gstack.status).toBe(0);
|
||||
// Outermost remote-bearing repo wins — same answer as remote-slug asked
|
||||
// at the outer root. (remote-slug asked from INSIDE the inner repo can't
|
||||
// see past the inner .git — its remote derivation does not succeed there,
|
||||
// so the parity clause doesn't apply; the walk-up contract does.)
|
||||
expect(slugOf(gstack)).toBe('acme-outer');
|
||||
expect(runRemoteSlug(outer, tmpHome).stdout.trim()).toBe('acme-outer');
|
||||
});
|
||||
|
||||
test('walk-up preserved: nested inner repo WITH its own remote still resolves to the OUTER repo slug', () => {
|
||||
const outer = makeRepo(path.join(fixtures, 'outer'), 'git@github.com:acme/outer.git');
|
||||
const inner = makeRepo(path.join(outer, 'vendor', 'inner'), 'git@github.com:acme/inner.git');
|
||||
|
||||
const gstack = runSlug(inner, tmpHome);
|
||||
expect(gstack.status).toBe(0);
|
||||
// Outermost wins — unchanged from the pre-fix walk-up semantics.
|
||||
expect(slugOf(gstack)).toBe('acme-outer');
|
||||
});
|
||||
|
||||
test('fallback unchanged: no-remote repo resolves to its basename (and remote-slug agrees)', () => {
|
||||
const repo = makeRepo(path.join(fixtures, 'lonely'));
|
||||
const gstack = runSlug(repo, tmpHome);
|
||||
expect(gstack.status).toBe(0);
|
||||
expect(slugOf(gstack)).toBe('lonely');
|
||||
// remote-slug's own no-remote fallback is basename(toplevel) — parity
|
||||
// holds incidentally on this shape too.
|
||||
expect(runRemoteSlug(repo, tmpHome).stdout.trim()).toBe('lonely');
|
||||
});
|
||||
|
||||
test('cache self-heal: a pre-fix degraded cache entry is rewritten to the canonical slug', () => {
|
||||
const strayHome = path.join(fixtures, 'strayhome');
|
||||
fs.mkdirSync(path.join(strayHome, '.git'), { recursive: true });
|
||||
const repo = makeRepo(path.join(strayHome, 'git', 'proj'), 'https://github.com/garrytan/gstack');
|
||||
|
||||
// Pre-seed the cache with the pre-fix degraded value: the bogus marker
|
||||
// root's basename (what the old resolver computed and cached).
|
||||
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, encodedCacheKey(repo));
|
||||
fs.writeFileSync(cacheFile, 'strayhome');
|
||||
|
||||
const gstack = runSlug(repo, tmpHome);
|
||||
expect(gstack.status).toBe(0);
|
||||
expect(slugOf(gstack)).toBe('garrytan-gstack');
|
||||
// The cache file itself must have been overwritten (self-healing).
|
||||
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('garrytan-gstack');
|
||||
});
|
||||
|
||||
test('hostile origin `url = ..` cannot become a ".." slug — basename fallback holds', () => {
|
||||
// git accepts `..` as a remote URL; the sed parse passes it through
|
||||
// unchanged, so unguarded it becomes SLUG=".." — filing state one level
|
||||
// ABOVE ~/.gstack/projects/ (confined to ~/.gstack, but still traversal).
|
||||
// The dot-only guard rejects it and the basename fallback anchors identity.
|
||||
const repo = makeRepo(path.join(fixtures, 'dotty'), '..');
|
||||
const r = runSlug(repo, tmpHome);
|
||||
expect(r.status).toBe(0);
|
||||
expect(slugOf(r)).toBe('dotty');
|
||||
// The cache must hold the healed value, never the dot slug.
|
||||
const cacheFile = path.join(tmpHome, '.gstack', 'slug-cache', encodedCacheKey(repo));
|
||||
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('dotty');
|
||||
});
|
||||
|
||||
test('package.json wrapper root (no .git): sticky basename slug is PRESERVED — heal is stray-repo-shape only', () => {
|
||||
// Legit #2212 shape: a monorepo wrapper anchored by package.json used
|
||||
// gstack before an inner dir grew a remote-bearing repo. The degraded-
|
||||
// ancestor heal must NOT fire here — it is restricted to marker roots
|
||||
// anchored by a .git entry whose origin does NOT resolve (the live-bug
|
||||
// stray-repo shape).
|
||||
const wrapper = path.join(fixtures, 'wrapperproj');
|
||||
fs.mkdirSync(wrapper, { recursive: true });
|
||||
fs.writeFileSync(path.join(wrapper, 'package.json'), '{"name":"wrapper"}\n');
|
||||
const inner = makeRepo(path.join(wrapper, 'apps', 'web'), 'https://github.com/acme/web.git');
|
||||
|
||||
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, encodedCacheKey(inner));
|
||||
fs.writeFileSync(cacheFile, 'wrapperproj');
|
||||
|
||||
const r = runSlug(inner, tmpHome);
|
||||
expect(r.status).toBe(0);
|
||||
expect(slugOf(r)).toBe('wrapperproj'); // NOT healed to acme-web
|
||||
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('wrapperproj');
|
||||
});
|
||||
|
||||
test('sticky identity preserved (#2212): repo that adopted a remote after first use is NOT healed', () => {
|
||||
// Legit sticky shape: the repo itself is the marker root (REMOTE_ROOT ==
|
||||
// PROJECT_ROOT) and its cached identity is its pre-origin basename slug.
|
||||
const repo = makeRepo(path.join(fixtures, 'stickyproj'), 'https://github.com/x/y.git');
|
||||
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, encodedCacheKey(repo));
|
||||
fs.writeFileSync(cacheFile, 'stickyproj');
|
||||
|
||||
const gstack = runSlug(repo, tmpHome);
|
||||
expect(gstack.status).toBe(0);
|
||||
expect(slugOf(gstack)).toBe('stickyproj');
|
||||
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('stickyproj');
|
||||
});
|
||||
});
|
||||
@@ -577,3 +577,182 @@ describe('path containment: pins and flags cannot escape the repo', () => {
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'frontend', 'package.json'), 'utf-8')).version).toBe('1.1.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2600: repair must not write fabricated 0.0.0.0 when VERSION is missing', () => {
|
||||
// Per-test dirs: the tests assert both "VERSION absent" and "VERSION
|
||||
// present" states, so a shared dir made them order-dependent (test 1's
|
||||
// absence assertion only held because test 2 hadn't run yet).
|
||||
const dirs: string[] = [];
|
||||
const makeDir = (): string => {
|
||||
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-2600-'));
|
||||
dirs.push(d);
|
||||
return d;
|
||||
};
|
||||
afterAll(() => {
|
||||
for (const d of dirs) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } }
|
||||
});
|
||||
|
||||
test('repair fails with exit 2 when VERSION file does not exist', () => {
|
||||
const dir = makeDir();
|
||||
// Set up: package.json exists with version 0.1.0.0, but no VERSION file
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.1.0.0' }, null, 2) + '\n');
|
||||
// VERSION file deliberately absent
|
||||
expect(fs.existsSync(path.join(dir, 'VERSION'))).toBe(false);
|
||||
|
||||
let code = 0;
|
||||
let stderr = '';
|
||||
try {
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe' });
|
||||
} catch (e: any) {
|
||||
code = e.status;
|
||||
stderr = (e.stderr || '').toString();
|
||||
}
|
||||
|
||||
// Should fail, not succeed
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('VERSION file not found');
|
||||
// package.json must NOT be modified
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.1.0.0');
|
||||
});
|
||||
|
||||
test('repair works normally when VERSION file exists', () => {
|
||||
const dir = makeDir();
|
||||
// Set up: both VERSION and package.json exist, with drift
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '2.0.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.9.0' }, null, 2) + '\n');
|
||||
|
||||
const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir }).toString();
|
||||
const result = JSON.parse(out);
|
||||
|
||||
expect(result.repaired).toBe('2.0.0.0');
|
||||
expect(result.packageJsonVersion).toBe('2.0.0');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('2.0.0');
|
||||
});
|
||||
|
||||
test('repair refuses to propagate a fabricated version when VERSION file is empty (#2600)', () => {
|
||||
const dir = makeDir();
|
||||
// VERSION exists but is empty — readVersionFile folds this into DEFAULT ("0.0.0.0").
|
||||
// Without the `current === DEFAULT` guard, this would write 0.0.0 into package.json.
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.5.0' }, null, 2) + '\n');
|
||||
|
||||
let code = 0;
|
||||
let stderr = '';
|
||||
try {
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe' });
|
||||
} catch (e: any) {
|
||||
code = e.status;
|
||||
stderr = (e.stderr || '').toString();
|
||||
}
|
||||
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('empty or contains no parsable version');
|
||||
// package.json must NOT be modified
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.5.0');
|
||||
});
|
||||
|
||||
test('repair proceeds when VERSION genuinely reads 0.0.0.0 (a real file, not the sentinel)', () => {
|
||||
// current === DEFAULT is ambiguous: it is BOTH the missing/unparseable
|
||||
// sentinel AND a legitimate literal "0.0.0.0" in a brand-new repo. The
|
||||
// guard now disambiguates on the raw bytes — a real 0.0.0.0 repairs
|
||||
// package.json to the npm-valid 0.0.0.
|
||||
const dir = makeDir();
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '0.0.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.5.0' }, null, 2) + '\n');
|
||||
|
||||
const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir }).toString();
|
||||
const result = JSON.parse(out);
|
||||
expect(result.repaired).toBe('0.0.0.0');
|
||||
expect(result.packageJsonVersion).toBe('0.0.0');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.0.0');
|
||||
});
|
||||
|
||||
test('repair still rejects whitespace-only VERSION content (sentinel path, not a real version)', () => {
|
||||
const dir = makeDir();
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), ' \n\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.5.0' }, null, 2) + '\n');
|
||||
|
||||
let code = 0;
|
||||
try { execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe' }); }
|
||||
catch (e: any) { code = e.status; }
|
||||
expect(code).toBe(2);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.5.0');
|
||||
});
|
||||
|
||||
test('repair reproduces the exact issue scenario: VERSION in root, package.json in app/ (#2600)', () => {
|
||||
// The exact layout from the issue: VERSION at repo root, package.json in app/
|
||||
// Running repair from app/ cwd with no VERSION there used to write 0.0.0.0 into app/package.json.
|
||||
const rootDir = makeDir();
|
||||
|
||||
fs.mkdirSync(path.join(rootDir, 'app'), { recursive: true });
|
||||
fs.writeFileSync(path.join(rootDir, 'VERSION'), '0.2.0.0\n');
|
||||
fs.writeFileSync(path.join(rootDir, 'app', 'package.json'), JSON.stringify({ name: 'x', version: '0.1.0.0' }, null, 2) + '\n');
|
||||
|
||||
// Run from app/ — no VERSION in cwd, readVersionFile would fold to 0.0.0.0
|
||||
let code = 0;
|
||||
let stderr = '';
|
||||
try {
|
||||
execFileSync('bun', [BIN, 'repair'], { cwd: path.join(rootDir, 'app'), stdio: 'pipe' });
|
||||
} catch (e: any) {
|
||||
code = e.status;
|
||||
stderr = (e.stderr || '').toString();
|
||||
}
|
||||
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('VERSION file not found');
|
||||
// app/package.json must NOT be modified
|
||||
expect(JSON.parse(fs.readFileSync(path.join(rootDir, 'app', 'package.json'), 'utf-8')).version).toBe('0.1.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2600: classify must surface versionFileExists=false when VERSION is missing', () => {
|
||||
// Per-test dirs: one test asserts VERSION absent, the other creates it — a
|
||||
// shared dir made them order-dependent. Each test builds its own repo.
|
||||
const dirs: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const d of dirs) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } }
|
||||
});
|
||||
|
||||
/** Minimal git repo (no VERSION committed) so classify can resolve base. */
|
||||
function makeRepoDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-2600-classify-'));
|
||||
dirs.push(dir);
|
||||
const git = (...a: string[]) => execFileSync('git', a, { cwd: dir, stdio: 'pipe' });
|
||||
git('init', '-q', '-b', 'main');
|
||||
git('config', 'user.email', 't@t'); git('config', 'user.name', 't');
|
||||
// Commit with no VERSION file
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), 'test\n');
|
||||
git('add', '-A'); git('commit', '-q', '-m', 'base');
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir }).toString().trim();
|
||||
fs.mkdirSync(path.join(dir, '.git', 'refs', 'remotes', 'origin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.git', 'refs', 'remotes', 'origin', 'main'), head + '\n');
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('classify reports versionFileExists=false when VERSION is absent', () => {
|
||||
const dir = makeRepoDir();
|
||||
// No package.json: pkgExists=false, pkgAgrees=true, current===base → FRESH.
|
||||
// (A package.json with a non-zero version would cause DRIFT_UNEXPECTED.)
|
||||
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString();
|
||||
const result = JSON.parse(out);
|
||||
|
||||
expect(result.versionFileExists).toBe(false);
|
||||
expect(result.currentVersion).toBe('0.0.0.0'); // fabricated default
|
||||
expect(result.state).toBe('FRESH'); // base also reads 0.0.0.0, no pkg drift
|
||||
});
|
||||
|
||||
test('classify reports versionFileExists=true when VERSION is present', () => {
|
||||
const dir = makeRepoDir();
|
||||
// Create VERSION AND sync package.json so pkgAgrees=true → ALREADY_BUMPED.
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '0.2.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.2.0.0' }, null, 2) + '\n');
|
||||
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString();
|
||||
const result = JSON.parse(out);
|
||||
|
||||
expect(result.versionFileExists).toBe(true);
|
||||
expect(result.currentVersion).toBe('0.2.0.0');
|
||||
expect(result.state).toBe('ALREADY_BUMPED'); // base is 0.0.0.0, current is 0.2.0.0, pkg in sync
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
},
|
||||
behavioral: 'external',
|
||||
externalTest: 'test/skill-e2e-ship-section-loading.test.ts',
|
||||
maxSkeletonBytes: 90_800, // v1.67 wave + v1.66.1's evidence-ledger prose (merged): measured 90,333
|
||||
maxSkeletonBytes: 91_600, // v1.68 fix wave: unconditional learnings capture (#2402, ~450B/skill); measured 91,061
|
||||
minUnionBytes: 120_000,
|
||||
mustContain: ['VERSION', 'CHANGELOG', 'review', 'merge', 'PR'],
|
||||
// v1.58.5.0: pre-push-guard install (#2077) stacks on the shared first-run-guidance preamble.
|
||||
@@ -157,7 +157,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
|
||||
// Fork port wave 2 (#703): the repo-doc-preference block in the design
|
||||
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
|
||||
maxSkeletonBytes: 93_000, // v1.67 fix wave: #2499 jq entry-resolution in the brain-sync preamble (~340B/skill) + wave doc additions; measured 92,531
|
||||
maxSkeletonBytes: 93_900, // v1.68 fix wave: #2402 learnings capture + spool queue-depth lines; measured 93,345
|
||||
minUnionBytes: 80_000,
|
||||
mustContain: ['SCOPE EXPANSION', 'SELECTIVE EXPANSION', 'HOLD SCOPE', 'SCOPE REDUCTION'],
|
||||
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
|
||||
@@ -183,7 +183,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
|
||||
// #2499 project-scope MCP jq in the brain-sync block grew every tier-2+
|
||||
// skeleton ~1.5KB (entry resolution emitted once per SKILL.md).
|
||||
maxSkeletonBytes: 70_500, // measured 70,318
|
||||
maxSkeletonBytes: 71_800, // v1.68 fix wave (#2402); measured 71,228
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['Architecture', 'Code Quality', 'Test', 'Performance'],
|
||||
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback + the
|
||||
@@ -216,7 +216,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// tier-2+ skeleton (measured 89,184). Main's v1.64.0.0 adds ~340 B more
|
||||
// (telemetry --error-message/--failed-step preamble prose, PR #769).
|
||||
// Budget covers the sum of both waves.
|
||||
maxSkeletonBytes: 91_000,
|
||||
maxSkeletonBytes: 91_700, // v1.68 fix wave (#2402); measured 91,176
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['design', 'visual'],
|
||||
maxSizeRatio: 1.12, // D1 1.104 + main's ~0.008
|
||||
@@ -240,7 +240,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
|
||||
// #2499 project-scope MCP jq in the brain-sync block grew every tier-2+
|
||||
// skeleton ~1.5KB (entry resolution emitted once per SKILL.md).
|
||||
maxSkeletonBytes: 82_500, // measured 82,031
|
||||
maxSkeletonBytes: 83_500, // v1.68 fix wave (#2402); measured 82,941
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['developer experience', 'Getting Started'],
|
||||
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
|
||||
@@ -270,7 +270,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// the #538 opt-out + D1 evidence directive — ratio 1.104 measured.
|
||||
// #2499 project-scope MCP jq in the brain-sync block grew every tier-2+
|
||||
// skeleton ~1.5KB (entry resolution emitted once per SKILL.md).
|
||||
maxSkeletonBytes: 101_500, // measured 101,314
|
||||
maxSkeletonBytes: 102_800, // v1.68 fix wave (#2402); measured 102,220
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['design doc', 'problem statement'],
|
||||
maxSizeRatio: 1.12,
|
||||
@@ -291,7 +291,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
|
||||
// always-loaded AskUserQuestion Format section.
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
|
||||
maxSkeletonBytes: 57_000, // v1.67 fix wave: #2499 preamble growth; measured 56,571
|
||||
maxSkeletonBytes: 57_900, // v1.68 fix wave (#2402); measured 57,385
|
||||
minUnionBytes: 55_000,
|
||||
mustContain: ['CHANGELOG', 'Diataxis', 'coverage'],
|
||||
// Two intentional additions stack on this small skill: the AUQ-failure prose
|
||||
@@ -322,7 +322,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
|
||||
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
|
||||
// the skeleton at 69,022 B; +~1 KB headroom.
|
||||
maxSkeletonBytes: 70_500, // v1.67 fix wave: #2499 preamble growth; measured 70,003
|
||||
maxSkeletonBytes: 71_400, // v1.68 fix wave (#2402); measured 70,815
|
||||
minUnionBytes: 72_000,
|
||||
mustContain: ['Typography', 'Color', 'Aesthetic Direction'],
|
||||
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback ~2KB +
|
||||
@@ -362,7 +362,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
|
||||
// always-loaded AskUserQuestion Format section.
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
|
||||
maxSkeletonBytes: 76_400, // v1.67 fix wave: #2499 preamble growth; measured 75,891
|
||||
maxSkeletonBytes: 77_300, // v1.68 fix wave (#2402); measured 76,705
|
||||
minUnionBytes: 72_000,
|
||||
mustContain: ['OWASP', 'STRIDE', 'daily', 'comprehensive', 'verif'],
|
||||
// cso keeps its mode-dispatch + FP-filtering phases always-loaded, so the
|
||||
|
||||
@@ -122,3 +122,181 @@ describe('gstack-session-update pull wedge (#2566)', () => {
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
// ── #2613: the lock pidfile must record the LIVE holder, not the exited parent ──
|
||||
//
|
||||
// `echo $$` inside the backgrounded subshell recorded the parent hook's PID.
|
||||
// The parent exits immediately, so every subsequent session judged the lock
|
||||
// stale and rm -rf'd a LIVE holder's lock — concurrent updaters, the exact
|
||||
// state the lock exists to prevent. Plus: a hard TTL (heartbeat-refreshed)
|
||||
// bounds PID-reuse wedges and the empty/missing-pidfile races.
|
||||
|
||||
describe('gstack-session-update lock identity + TTL (#2613)', () => {
|
||||
function makeSlowGitShim(base: string, sleepSecs: number): string {
|
||||
const shimDir = path.join(base, 'shim');
|
||||
fs.mkdirSync(shimDir, { recursive: true });
|
||||
const realGit = execFileSync('bash', ['-c', 'command -v git'], { encoding: 'utf8' }).trim();
|
||||
fs.writeFileSync(
|
||||
path.join(shimDir, 'git'),
|
||||
`#!/usr/bin/env bash\ncase "$*" in *pull*) sleep ${sleepSecs};; esac\nexec "${realGit}" "$@"\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
return shimDir;
|
||||
}
|
||||
|
||||
function runScriptWithPath(install: string, state: string, shimDir: string) {
|
||||
return spawnSync('bash', [SCRIPT], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GSTACK_DIR: install, GSTACK_STATE_DIR: state, PATH: `${shimDir}:${process.env.PATH}` },
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
function isAlive(pid: number): boolean {
|
||||
try { process.kill(pid, 0); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
test('recorded pid is the live holder subshell, not the exited parent', async () => {
|
||||
const { base, install, state } = makeFixture();
|
||||
const shimDir = makeSlowGitShim(base, 3);
|
||||
try {
|
||||
const r = runScriptWithPath(install, state, shimDir);
|
||||
expect(r.status).toBe(0); // parent hook has EXITED by now (spawnSync waited)
|
||||
// Poll for the pidfile the detached subshell writes.
|
||||
const pidPath = path.join(state, '.setup-lock', 'pid');
|
||||
const deadline = Date.now() + 5000;
|
||||
let pid = 0;
|
||||
while (Date.now() < deadline) {
|
||||
if (fs.existsSync(pidPath)) {
|
||||
pid = Number(fs.readFileSync(pidPath, 'utf8').trim());
|
||||
if (pid > 0) break;
|
||||
}
|
||||
await new Promise((res) => setTimeout(res, 50));
|
||||
}
|
||||
expect(pid).toBeGreaterThan(0);
|
||||
// The lock is held (slow pull) — its recorded PID must be ALIVE.
|
||||
// Pre-fix this held the dead parent's PID and the assertion fails.
|
||||
expect(fs.existsSync(path.join(state, '.setup-lock'))).toBe(true);
|
||||
expect(isAlive(pid)).toBe(true);
|
||||
await waitForLog(state, /UP_TO_DATE|UPDATING|PULL_FAILED/);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('a live lock with a live pid is respected and survives', async () => {
|
||||
const { base, install, state } = makeFixture();
|
||||
const holder = require('child_process').spawn('sleep', ['30'], { stdio: 'ignore' });
|
||||
try {
|
||||
const lockDir = path.join(state, '.setup-lock');
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(lockDir, 'pid'), String(holder.pid));
|
||||
const r = runScript(install, state);
|
||||
expect(r.status).toBe(0);
|
||||
const log = await waitForLog(state, /SKIP locked_by=/);
|
||||
expect(log).toContain(`SKIP locked_by=${holder.pid}`);
|
||||
expect(fs.existsSync(lockDir)).toBe(true); // NOT rm -rf'd (#2613)
|
||||
} finally {
|
||||
holder.kill();
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('a dead pid is reclaimed and the run proceeds', async () => {
|
||||
const { base, install, state } = makeFixture();
|
||||
try {
|
||||
const dead = spawnSync('true', { encoding: 'utf8' }); // reaped by the time spawnSync returns
|
||||
const lockDir = path.join(state, '.setup-lock');
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(lockDir, 'pid'), String(dead.pid));
|
||||
const r = runScript(install, state);
|
||||
expect(r.status).toBe(0);
|
||||
const log = await waitForLog(state, /UP_TO_DATE|UPDATING/);
|
||||
expect(log).toMatch(/UP_TO_DATE|UPDATING/);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('an empty pidfile inside the TTL window is NOT instantly reaped', async () => {
|
||||
const { base, install, state } = makeFixture();
|
||||
try {
|
||||
const lockDir = path.join(state, '.setup-lock');
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(lockDir, 'pid'), ''); // mkdir→echo race window
|
||||
const r = runScript(install, state);
|
||||
expect(r.status).toBe(0);
|
||||
const log = await waitForLog(state, /SKIP locked_by=/);
|
||||
expect(log).toContain('SKIP locked_by=');
|
||||
expect(fs.existsSync(lockDir)).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('reclaim is TOCTOU-safe: both reclaim branches mv the lock aside atomically (static pin)', () => {
|
||||
// `rm -rf "$LOCK_DIR"` then `mkdir` lets TWO contenders both judge the
|
||||
// lock stale and both win (one rm can land between the other's rm and
|
||||
// mkdir). The atomic mv-aside makes exactly one contender own the reap:
|
||||
// the loser's mv fails and it backs off with SKIP lock_contested. Pin
|
||||
// that BOTH reclaim branches (TTL-expired and dead-PID) use it, and that
|
||||
// no bare in-place `rm -rf "$LOCK_DIR"` survives outside the holder's
|
||||
// own EXIT trap.
|
||||
const src = fs.readFileSync(SCRIPT, 'utf8');
|
||||
const mvAside = src.match(/mv "\$LOCK_DIR" "\$LOCK_DIR\.reap\.\$\$" 2>\/dev\/null \|\| \{ log_entry "SKIP lock_contested"; exit 0; \}/g) || [];
|
||||
expect(mvAside.length).toBe(2); // TTL branch + dead-PID branch
|
||||
// The only rm -rf of the live lock dir is the holder's EXIT trap — and
|
||||
// even that one is ownership-checked (see the static pin below).
|
||||
const bareRms = src.match(/rm -rf "\$LOCK_DIR"(?!\.)/g) || [];
|
||||
expect(bareRms.length).toBe(1);
|
||||
expect(src).toContain(
|
||||
`trap 'kill "$HB_PID" 2>/dev/null; [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null' EXIT`,
|
||||
);
|
||||
});
|
||||
|
||||
test('EXIT trap is ownership-checked and a heartbeat runs during pull/setup (static pins)', () => {
|
||||
const src = fs.readFileSync(SCRIPT, 'utf8');
|
||||
// (a) After a TTL reclaim by another updater, $LOCK_DIR belongs to the
|
||||
// NEW holder — the old holder's trap must remove the lock ONLY while the
|
||||
// pidfile still contains ITS pid (MYPID captured at write time).
|
||||
const trapLine = src.split('\n').find((l) => l.includes("trap '") && l.includes('rm -rf "$LOCK_DIR"'));
|
||||
expect(trapLine).toBeDefined();
|
||||
expect(trapLine!).toContain('[ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR"');
|
||||
// MYPID is written to the pidfile (the identity the trap compares against).
|
||||
expect(src).toContain('MYPID="${BASHPID:-$(sh -c \'echo $PPID\')}"');
|
||||
expect(src).toContain('echo "$MYPID" > "$LOCK_DIR/pid"');
|
||||
// (b) In-flight heartbeat: the step-boundary touches only fire AFTER the
|
||||
// pull / setup return, so a legitimately-slow step past the 30-min TTL
|
||||
// got reclaimed while ALIVE. The loop re-checks ownership each beat and
|
||||
// exits instead of touching a reclaimed holder's pidfile.
|
||||
expect(src).toMatch(
|
||||
/while :; do sleep 300; \[ "\$\(cat "\$LOCK_DIR\/pid" 2>\/dev\/null\)" = "\$MYPID" \] \|\| exit 0; touch "\$LOCK_DIR\/pid" 2>\/dev\/null; done/,
|
||||
);
|
||||
expect(src).toContain('HB_PID=$!');
|
||||
// The trap stops the heartbeat so it can never outlive the holder.
|
||||
expect(trapLine!).toContain('kill "$HB_PID"');
|
||||
});
|
||||
|
||||
test('an expired-TTL lock is reclaimed even when its pid is alive (PID reuse)', async () => {
|
||||
const { base, install, state } = makeFixture();
|
||||
const holder = require('child_process').spawn('sleep', ['30'], { stdio: 'ignore' });
|
||||
try {
|
||||
const lockDir = path.join(state, '.setup-lock');
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
const pidPath = path.join(lockDir, 'pid');
|
||||
fs.writeFileSync(pidPath, String(holder.pid));
|
||||
// Age the heartbeat past the 30-min TTL: a recycled PID looks alive
|
||||
// forever, so liveness alone can never clear this wedge.
|
||||
const past = new Date(Date.now() - 40 * 60 * 1000);
|
||||
fs.utimesSync(pidPath, past, past);
|
||||
const r = runScript(install, state);
|
||||
expect(r.status).toBe(0);
|
||||
const log = await waitForLog(state, /RECLAIMED lock_ttl_expired/);
|
||||
expect(log).toContain('RECLAIMED lock_ttl_expired');
|
||||
await waitForLog(state, /UP_TO_DATE|UPDATING/);
|
||||
} finally {
|
||||
holder.kill();
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
@@ -207,12 +207,14 @@ describe('two-class referenced-paths (ENG-OV7)', () => {
|
||||
});
|
||||
|
||||
test('the referenced-path scan actually sees the review checklist refs (self-check)', () => {
|
||||
// Guard against the extraction regex silently rotting: the review skill is
|
||||
// KNOWN to carry alias-relative refs; if the scanner stops seeing them the
|
||||
// class-1 assertion is vacuous.
|
||||
const class1 = collectRefs().filter((r) => r.skillName !== 'gstack');
|
||||
expect(class1.length).toBeGreaterThan(0);
|
||||
expect(class1.some((r) => r.skillName === 'review' && r.rel === 'checklist.md')).toBe(true);
|
||||
// Guard against the extraction regex silently rotting: the review skill's
|
||||
// checklist refs are KNOWN to exist. Since #2518 they anchor at the
|
||||
// installed gstack root (class 2: gstack/review/checklist.md), not the
|
||||
// alias-relative form — if the scanner stops seeing them, the class-2
|
||||
// assertion is vacuous.
|
||||
const refs = collectRefs();
|
||||
expect(refs.length).toBeGreaterThan(0);
|
||||
expect(refs.some((r) => r.skillName === 'gstack' && r.rel === 'review/checklist.md')).toBe(true);
|
||||
});
|
||||
|
||||
test('KNOWN_BROKEN_CLASS2 entries are still actually broken (ratchet)', () => {
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* 1. Set up a fake $HOME with a Claude Code project + a Codex session +
|
||||
* ~/.gstack/ artifacts (eureka, learning, ceo-plan, design-doc, retro,
|
||||
* builder-profile)
|
||||
* 2. Run gstack-memory-ingest --probe → verify counts match disk
|
||||
* 2. Run gstack-memory-ingest --probe → verify stage counts match disk
|
||||
* (post-attribution headline + unattributed skip line, #2394)
|
||||
* 3. Run gstack-memory-ingest --bulk → verify state file gets written +
|
||||
* session_id dedup works on re-run (idempotency)
|
||||
* 4. Run gstack-gbrain-sync --dry-run → verify all 3 stages preview
|
||||
@@ -98,7 +99,7 @@ function runBun(script: string, args: string[], env: Record<string, string>): {
|
||||
// ── E2E pipeline ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("V1 memory ingest pipeline E2E", () => {
|
||||
it("--probe finds all 9 fixture files across all source types", () => {
|
||||
it("--probe accounts for all 9 fixture files: 7 attributable + 2 unattributed transcripts skipped (#2394)", () => {
|
||||
const home = makeFixtureHome();
|
||||
const { gstackHome, counts } = setupFixture(home);
|
||||
const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" };
|
||||
@@ -106,11 +107,15 @@ describe("V1 memory ingest pipeline E2E", () => {
|
||||
const r = runBun(INGEST, ["--probe"], env);
|
||||
expect(r.exitCode).toBe(0);
|
||||
|
||||
const totalExpected = Object.values(counts).reduce((s, n) => s + n, 0);
|
||||
expect(r.stdout).toContain(`Total files in window: ${totalExpected}`);
|
||||
// #2394: probe counts what --bulk would ingest. The fixture transcripts
|
||||
// carry no resolvable git remote, so the shared attribution gate skips
|
||||
// both; the gstack artifacts are store-local and always attributable.
|
||||
const transcripts = counts.transcript;
|
||||
const attributable = Object.values(counts).reduce((s, n) => s + n, 0) - transcripts;
|
||||
expect(r.stdout).toContain(`Total files in window: ${attributable}`);
|
||||
expect(r.stdout).toContain(`Skipped (unattributed): ${transcripts}`);
|
||||
|
||||
// Spot-check that each type appears with the right count
|
||||
expect(r.stdout).toMatch(/transcript\s+2/);
|
||||
// Spot-check that each artifact type appears with the right count
|
||||
expect(r.stdout).toMatch(/eureka\s+1/);
|
||||
expect(r.stdout).toMatch(/learning\s+1/);
|
||||
expect(r.stdout).toMatch(/ceo-plan\s+1/);
|
||||
@@ -118,6 +123,21 @@ describe("V1 memory ingest pipeline E2E", () => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("--probe --include-unattributed counts all 9 fixture files, transcripts included", () => {
|
||||
const home = makeFixtureHome();
|
||||
const { gstackHome, counts } = setupFixture(home);
|
||||
const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" };
|
||||
|
||||
const r = runBun(INGEST, ["--probe", "--include-unattributed"], env);
|
||||
expect(r.exitCode).toBe(0);
|
||||
|
||||
const totalExpected = Object.values(counts).reduce((s, n) => s + n, 0);
|
||||
expect(r.stdout).toContain(`Total files in window: ${totalExpected}`);
|
||||
expect(r.stdout).toMatch(/transcript\s+2/);
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("--incremental writes a state file with schema_version: 1 + last_writer", () => {
|
||||
const home = makeFixtureHome();
|
||||
const { gstackHome } = setupFixture(home);
|
||||
|
||||
@@ -225,6 +225,8 @@ describe('timeline-stop-hook (#2553, F5 fail-open)', () => {
|
||||
});
|
||||
|
||||
describe('timeline-stop-hook wiring', () => {
|
||||
const SETTINGS_HOOK = path.join(ROOT, 'bin', 'gstack-settings-hook');
|
||||
|
||||
test('setup registers the Stop hook with its own source tag and tears it down on --no-team', () => {
|
||||
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
expect(setup).toContain('--event Stop');
|
||||
@@ -235,6 +237,240 @@ describe('timeline-stop-hook wiring', () => {
|
||||
expect(teardown).toContain('remove-source --source gstack-timeline-stop');
|
||||
});
|
||||
|
||||
test('setup surfaces a settings-hook refusal instead of swallowing it', () => {
|
||||
// The hardened settings-hook refuses to rewrite a corrupt settings.json
|
||||
// (exit 1). Both setup call sites (ALREADY_INSTALLED plan-tune re-point,
|
||||
// timeline ensure-event) must stay non-fatal but PRINT the failure.
|
||||
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
const warnings = setup.match(/settings hook update failed/g) || [];
|
||||
expect(warnings.length).toBeGreaterThanOrEqual(2);
|
||||
// The old swallow patterns are gone (the --no-team remove-source teardown
|
||||
// legitimately keeps its 2>/dev/null; only the ensure-event registration
|
||||
// must surface stderr).
|
||||
expect(setup).not.toContain('_install_plan_tune_hooks >/dev/null 2>&1 || true');
|
||||
expect(setup).not.toMatch(/ensure-event[\s\S]{0,220}--source gstack-timeline-stop[\s\S]{0,40}2>\/dev\/null/);
|
||||
});
|
||||
|
||||
test('setup routes the Stop hook through ensure-event, not presence-only dedup', () => {
|
||||
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
// ensure-event registers when missing AND re-points a stale path in place.
|
||||
expect(setup).toMatch(/ensure-event[\s\S]{0,220}--source gstack-timeline-stop/);
|
||||
// The old guard skipped registration whenever the source tag was merely
|
||||
// PRESENT, so a stale absolute path (deleted dev worktree) was never
|
||||
// re-pointed on a setup re-run.
|
||||
expect(setup).not.toMatch(/list-sources 2>\/dev\/null \| grep -q "gstack-timeline-stop"/);
|
||||
});
|
||||
|
||||
test('hook path resolution is canonical-only: global install or skip, never the worktree', () => {
|
||||
// Drive setup's _hook_command_path directly: canonical install present →
|
||||
// that path (survives deleting the worktree setup ran from). Absent →
|
||||
// non-zero and NO output — registration is skipped with a log line; the
|
||||
// running tree's path is NEVER baked into settings.json (the SOURCE
|
||||
// fallback was the phantom-hooks defect and is deliberately gone).
|
||||
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
const fn = setup.match(/_hook_command_path\(\) \{[\s\S]*?\n\}/);
|
||||
expect(fn).not.toBeNull();
|
||||
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hookpath-'));
|
||||
try {
|
||||
const canonicalRoot = path.join(fakeHome, '.claude', 'skills', 'gstack');
|
||||
const globalHook = path.join(canonicalRoot, 'hosts', 'claude', 'hooks', 'timeline-stop-hook');
|
||||
fs.mkdirSync(path.dirname(globalHook), { recursive: true });
|
||||
fs.writeFileSync(globalHook, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: fakeHome,
|
||||
SOURCE_GSTACK_DIR: '/some/dev/worktree',
|
||||
CANONICAL_GSTACK_ROOT: canonicalRoot,
|
||||
};
|
||||
|
||||
const withGlobal = spawnSync(
|
||||
'bash',
|
||||
['-c', `${fn![0]}\n_hook_command_path hosts/claude/hooks/timeline-stop-hook`],
|
||||
{ env, encoding: 'utf-8', timeout: 10_000 },
|
||||
);
|
||||
expect(withGlobal.status).toBe(0);
|
||||
expect(withGlobal.stdout.trim()).toBe(globalHook);
|
||||
|
||||
// No canonical install → the resolver FAILS (caller logs a visible
|
||||
// skip); it never falls back to the setup-time tree.
|
||||
fs.rmSync(globalHook);
|
||||
const withoutGlobal = spawnSync(
|
||||
'bash',
|
||||
['-c', `${fn![0]}\n_hook_command_path hosts/claude/hooks/timeline-stop-hook`],
|
||||
{ env, encoding: 'utf-8', timeout: 10_000 },
|
||||
);
|
||||
expect(withoutGlobal.status).not.toBe(0);
|
||||
expect(withoutGlobal.stdout.trim()).toBe('');
|
||||
} finally {
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ensure-event re-points a stale absolute path and leaves exactly one registration', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-'));
|
||||
try {
|
||||
const settingsFile = path.join(dir, 'settings.json');
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
Stop: [{
|
||||
_gstack_source: 'gstack-timeline-stop',
|
||||
hooks: [{ type: 'command', command: '/deleted/worktree/hosts/claude/hooks/timeline-stop-hook', timeout: 5 }],
|
||||
}],
|
||||
},
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const r = spawnSync('bash', [
|
||||
SETTINGS_HOOK, 'ensure-event',
|
||||
'--event', 'Stop',
|
||||
'--command', HOOK,
|
||||
'--source', 'gstack-timeline-stop',
|
||||
'--timeout', '5',
|
||||
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
||||
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('re-pointed');
|
||||
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
|
||||
expect(s.hooks.Stop).toHaveLength(1); // replaced in place — never two
|
||||
expect(s.hooks.Stop[0].hooks[0].command).toBe(HOOK);
|
||||
expect(s.hooks.Stop[0]._gstack_source).toBe('gstack-timeline-stop');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ensure-event is a true no-op when the registration already matches (no write, no backup churn)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-noop-'));
|
||||
try {
|
||||
const settingsFile = path.join(dir, 'settings.json');
|
||||
const args = [
|
||||
SETTINGS_HOOK, 'ensure-event',
|
||||
'--event', 'Stop',
|
||||
'--command', HOOK,
|
||||
'--source', 'gstack-timeline-stop',
|
||||
'--timeout', '5',
|
||||
];
|
||||
const env = { ...process.env, GSTACK_SETTINGS_FILE: settingsFile };
|
||||
const first = spawnSync('bash', args, { env, encoding: 'utf-8', timeout: 15_000 });
|
||||
expect(first.status).toBe(0);
|
||||
const bytesAfterFirst = fs.readFileSync(settingsFile, 'utf-8');
|
||||
|
||||
const second = spawnSync('bash', args, { env, encoding: 'utf-8', timeout: 15_000 });
|
||||
expect(second.status).toBe(0);
|
||||
expect(second.stdout).toContain('unchanged');
|
||||
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(bytesAfterFirst);
|
||||
// Re-running ./setup must not accumulate settings.json.bak.<ts> files.
|
||||
const baks = fs.readdirSync(dir).filter((f) => f.includes('.bak'));
|
||||
expect(baks).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('corrupt settings.json: ensure-event refuses (exit 3) and never rewrites the file', () => {
|
||||
// The old catch{} folded an unparseable EXISTING settings.json into {}
|
||||
// and the atomic write replaced the user's permissions/env/other hooks
|
||||
// with just ours. Now: loud stderr error, fail-closed exit 3 (the
|
||||
// settings-hook parse-refusal code), file byte-identical.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-corrupt-'));
|
||||
try {
|
||||
const settingsFile = path.join(dir, 'settings.json');
|
||||
const corrupt = '{ "permissions": { "allow": ["Bash(npm:*)"] }, INVALID';
|
||||
fs.writeFileSync(settingsFile, corrupt);
|
||||
|
||||
const r = spawnSync('bash', [
|
||||
SETTINGS_HOOK, 'ensure-event',
|
||||
'--event', 'Stop',
|
||||
'--command', HOOK,
|
||||
'--source', 'gstack-timeline-stop',
|
||||
'--timeout', '5',
|
||||
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
||||
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('not valid JSON');
|
||||
// Never rewritten — the corrupt bytes (and whatever the user can still
|
||||
// salvage from them) survive verbatim.
|
||||
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(corrupt);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a matcher change updates the tagged entry in place — still exactly one registration', () => {
|
||||
// Identity key is (event, source): an existing gstack entry with a STALE
|
||||
// matcher must be updated, never joined by a second entry (the old key
|
||||
// included the matcher, so any future matcher change would duplicate).
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-matcher-'));
|
||||
try {
|
||||
const settingsFile = path.join(dir, 'settings.json');
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [{
|
||||
_gstack_source: 'gstack-plan-tune',
|
||||
matcher: 'OldMatcher',
|
||||
hooks: [{ type: 'command', command: '/old/path/hook', timeout: 5 }],
|
||||
}],
|
||||
},
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const r = spawnSync('bash', [
|
||||
SETTINGS_HOOK, 'ensure-event',
|
||||
'--event', 'PreToolUse',
|
||||
'--command', '/new/path/hook',
|
||||
'--source', 'gstack-plan-tune',
|
||||
'--matcher', 'NewMatcher',
|
||||
'--timeout', '5',
|
||||
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
||||
|
||||
expect(r.status).toBe(0);
|
||||
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
|
||||
expect(s.hooks.PreToolUse).toHaveLength(1); // updated in place — never two
|
||||
expect(s.hooks.PreToolUse[0].matcher).toBe('NewMatcher');
|
||||
expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/new/path/hook');
|
||||
expect(s.hooks.PreToolUse[0]._gstack_source).toBe('gstack-plan-tune');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed update leaves exactly one registration — never zero, never two', () => {
|
||||
// Root can write through 0o555 directories, so the failure injection
|
||||
// (read-only dir) does not bind there; the invariant is still covered by
|
||||
// the atomic tmp+rename pinned in the re-point test above.
|
||||
if (typeof process.getuid === 'function' && process.getuid() === 0) return;
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-fail-'));
|
||||
try {
|
||||
const settingsFile = path.join(dir, 'settings.json');
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
Stop: [{
|
||||
_gstack_source: 'gstack-timeline-stop',
|
||||
hooks: [{ type: 'command', command: '/stale/path/timeline-stop-hook', timeout: 5 }],
|
||||
}],
|
||||
},
|
||||
}, null, 2) + '\n');
|
||||
|
||||
fs.chmodSync(dir, 0o555); // every write path (backup, tmp, rename) fails
|
||||
const r = spawnSync('bash', [
|
||||
SETTINGS_HOOK, 'ensure-event',
|
||||
'--event', 'Stop',
|
||||
'--command', HOOK,
|
||||
'--source', 'gstack-timeline-stop',
|
||||
'--timeout', '5',
|
||||
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
||||
fs.chmodSync(dir, 0o755);
|
||||
|
||||
expect(r.status).not.toBe(0); // the failure is loud, not swallowed
|
||||
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
|
||||
expect(s.hooks.Stop).toHaveLength(1); // old registration intact
|
||||
expect(s.hooks.Stop[0].hooks[0].command).toBe('/stale/path/timeline-stop-hook');
|
||||
} finally {
|
||||
try { fs.chmodSync(dir, 0o755); } catch {}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('gstack-uninstall removes the Stop hook registration', () => {
|
||||
const uninstall = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-uninstall'), 'utf-8');
|
||||
expect(uninstall).toContain('remove-source --source gstack-timeline-stop');
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Static pins for the v1.68 wave's prose-tier behaviors — the coverage audit
|
||||
* flagged these as the only surfaces a future template edit could silently
|
||||
* revert without failing anything.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf-8');
|
||||
|
||||
describe('gstack-upgrade template: ff-only precedes the gated reset (#2517)', () => {
|
||||
const tmpl = read('gstack-upgrade/SKILL.md.tmpl');
|
||||
|
||||
test('git pull --ff-only runs before any reset --hard', () => {
|
||||
const ff = tmpl.indexOf('git pull --ff-only --autostash');
|
||||
const reset = tmpl.indexOf('git reset --hard origin/main');
|
||||
expect(ff).toBeGreaterThan(-1);
|
||||
expect(reset).toBeGreaterThan(-1);
|
||||
expect(ff).toBeLessThan(reset);
|
||||
});
|
||||
|
||||
test('the ff path carries the FF_OK success gate that skips the fallback', () => {
|
||||
expect(tmpl).toContain('FF_OK');
|
||||
expect(tmpl.indexOf('FF_OK')).toBeLessThan(tmpl.indexOf('git reset --hard origin/main'));
|
||||
});
|
||||
|
||||
test('the destructive fallback is gated on unpushed commits, not just a clean tree', () => {
|
||||
// A clean tree with unpushed local commits is NOT safe for reset --hard.
|
||||
expect(tmpl).toContain('git rev-list origin/main..HEAD');
|
||||
expect(tmpl.indexOf('git rev-list origin/main..HEAD')).toBeLessThan(
|
||||
tmpl.indexOf('git reset --hard origin/main'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('untrusted-content warning injection points (#2441)', () => {
|
||||
test('scrape and skillify templates carry the shared token', () => {
|
||||
// The wording lives in ONE exported const (resolvers/browse.ts); these
|
||||
// pins keep the injection POINTS from silently disappearing.
|
||||
expect(read('scrape/SKILL.md.tmpl')).toContain('{{UNTRUSTED_CONTENT_WARNING}}');
|
||||
expect(read('skillify/SKILL.md.tmpl')).toContain('{{UNTRUSTED_CONTENT_WARNING}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('brain-uninstall removes the spool queue', () => {
|
||||
test('uninstall cleans .brain-queue.d alongside the legacy queue file', () => {
|
||||
const src = read('bin/gstack-brain-uninstall');
|
||||
expect(src).toContain('.brain-queue.d');
|
||||
expect(src).toContain('.brain-queue.jsonl');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user