mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
fix: pre-landing review round — 8 auto-fixes + 8 accepted findings hardened
The ship review army (4 specialists + red-team + checklist, 29 findings)
produced 8 mechanical auto-fixes and 11 decisions; the accepted set:
- win32 slug parity completed: lib/bin-context.ts gains the remote-first
outermost walk + degraded-cache self-heal the bash side got this wave —
the two implementations now agree on the stray-marker live-bug shape,
pinned by shared fixtures (multi-specialist 9/10 finding).
- probe honors the plan's bounded-read decision: 256KB prefix, extraction
semantics mirrored from parseTranscriptJsonl so probe/prepare can never
diverge on the same file (>1MB transcript test).
- policy normalize parity: bash normalize() now matches canonicalizeRemote
on .git/-trailing and uppercase-.GIT shapes (7-shape corpus pinned two
ways) — a deny for those shapes could previously slip the transcript gate.
- session-update reclaim is TOCTOU-safe (atomic mv-aside on both branches).
- settings-hook: unparseable settings.json errors instead of being replaced
with {}; ensure-event keys on (event, source) so matcher changes update
in place — never zero or two registrations.
- dot-only slug guard at both parse sites (hostile 'url = ..' can't escape
projects/); enqueue tmp-file janitor (1h TTL, inside the drain lock);
brain-sync .migrating never clobbered; drop-queue/status count .migrating;
snapshot -o warning correct + surfaced in diff mode; version-bump test
order-dependence removed; uninstall clears the advance stamp.
Deferred with record: slug heal-probe cost sentinel (P3 TODO), FF_OK
conflation (noted, misdiagnosis-only).
270 pass / 0 fail across the 10 touched suites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9fecf0f16f
commit
b7d44c45b4
@@ -276,6 +276,85 @@ describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
|
||||
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("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");
|
||||
|
||||
@@ -473,6 +473,32 @@ describe('gstack-brain-sync --discover-new', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 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
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// #2549 queue integrity: classified drops, privacy retention,
|
||||
// surgical rewrite, unpushed-commit detector
|
||||
|
||||
@@ -19,6 +19,7 @@ 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");
|
||||
@@ -150,3 +151,60 @@ describe("repoPolicyTierBatch (TypeScript client)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -938,6 +938,82 @@ describe("#2394: probe applies the same attribution gate as prepare", () => {
|
||||
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 ─────────────
|
||||
|
||||
@@ -219,6 +219,20 @@ describe('gstack-slug ↔ remote-slug parity', () => {
|
||||
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('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.
|
||||
|
||||
@@ -579,10 +579,21 @@ describe('path containment: pins and flags cannot escape the repo', () => {
|
||||
});
|
||||
|
||||
describe('#2600: repair must not write fabricated 0.0.0.0 when VERSION is missing', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-2600-'));
|
||||
afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } });
|
||||
// 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
|
||||
@@ -605,6 +616,7 @@ describe('#2600: repair must not write fabricated 0.0.0.0 when VERSION is missin
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -618,6 +630,7 @@ describe('#2600: repair must not write fabricated 0.0.0.0 when VERSION is missin
|
||||
});
|
||||
|
||||
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'), '');
|
||||
@@ -641,8 +654,7 @@ describe('#2600: repair must not write fabricated 0.0.0.0 when VERSION is missin
|
||||
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 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-2600-exact-'));
|
||||
afterAll(() => { try { fs.rmSync(rootDir, { recursive: true, force: true }); } catch { /* noop */ } });
|
||||
const rootDir = makeDir();
|
||||
|
||||
fs.mkdirSync(path.join(rootDir, 'app'), { recursive: true });
|
||||
fs.writeFileSync(path.join(rootDir, 'VERSION'), '0.2.0.0\n');
|
||||
@@ -666,21 +678,31 @@ describe('#2600: repair must not write fabricated 0.0.0.0 when VERSION is missin
|
||||
});
|
||||
|
||||
describe('#2600: classify must surface versionFileExists=false when VERSION is missing', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-2600-classify-'));
|
||||
afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } });
|
||||
// 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 */ } }
|
||||
});
|
||||
|
||||
// Set up a minimal git repo so classify can resolve base
|
||||
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');
|
||||
/** 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.)
|
||||
|
||||
@@ -693,7 +715,8 @@ describe('#2600: classify must surface versionFileExists=false when VERSION is m
|
||||
});
|
||||
|
||||
test('classify reports versionFileExists=true when VERSION is present', () => {
|
||||
// Now create VERSION AND sync package.json so pkgAgrees=true → ALREADY_BUMPED.
|
||||
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');
|
||||
|
||||
|
||||
@@ -234,6 +234,23 @@ describe('gstack-session-update lock identity + TTL (#2613)', () => {
|
||||
}
|
||||
}, 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.
|
||||
const bareRms = src.match(/rm -rf "\$LOCK_DIR"(?!\.)/g) || [];
|
||||
expect(bareRms.length).toBe(1);
|
||||
expect(src).toContain(`trap 'rm -rf "$LOCK_DIR" 2>/dev/null' EXIT`);
|
||||
});
|
||||
|
||||
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' });
|
||||
|
||||
@@ -343,6 +343,71 @@ describe('timeline-stop-hook wiring', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('corrupt settings.json: ensure-event refuses (exit 1) 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, exit 1, 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(1);
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user