diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index 7435814cc..283fe89e0 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -117,6 +117,8 @@ jobs: browse/test/server-sanitize-surrogates.test.ts \ test/setup-windows-fallback.test.ts \ test/bin-windows-bun-import-paths.test.ts \ + test/bin-context-windows-slug.test.ts \ + test/artifacts-allowlist-decisions.test.ts \ test/build-script-shell-compat.test.ts \ test/docs-config-keys.test.ts \ test/brain-sync-windows-paths.test.ts \ diff --git a/bin/gstack-artifacts-init b/bin/gstack-artifacts-init index f99c96591..9691c226e 100755 --- a/bin/gstack-artifacts-init +++ b/bin/gstack-artifacts-init @@ -291,6 +291,14 @@ projects/*/*-design-*.md projects/*/*-test-plan-*.md projects/*/*-eng-review-test-plan-*.md projects/*/timeline.jsonl +# The decision store. gstack-decision-log enqueues projects//decisions.jsonl +# after EVERY write, but no glob above matched it, so compute_paths_to_stage rejected +# all of them at its "must match at least one allowlist glob" check -- a writer +# enqueueing a path the syncer is guaranteed to drop. Without these the durable +# decision ledger never leaves the machine, on any platform. +projects/*/decisions.jsonl +projects/*/decisions.active.json +projects/*/decisions.archive.jsonl retros/*.md developer-profile.json builder-journey.md @@ -318,6 +326,9 @@ cat > "$GSTACK_HOME/.brain-privacy-map.json" <<'EOF' {"pattern": "projects/*/*-design-*.md", "class": "artifact"}, {"pattern": "projects/*/*-test-plan-*.md", "class": "artifact"}, {"pattern": "projects/*/*-eng-review-test-plan-*.md", "class": "artifact"}, + {"pattern": "projects/*/decisions.jsonl", "class": "artifact"}, + {"pattern": "projects/*/decisions.active.json", "class": "artifact"}, + {"pattern": "projects/*/decisions.archive.jsonl", "class": "artifact"}, {"pattern": "retros/*.md", "class": "artifact"}, {"pattern": "builder-journey.md", "class": "artifact"}, {"pattern": "projects/*/timeline.jsonl", "class": "behavioral"}, diff --git a/lib/bin-context.ts b/lib/bin-context.ts index faa1c65a2..28021b56a 100644 --- a/lib/bin-context.ts +++ b/lib/bin-context.ts @@ -6,12 +6,102 @@ */ import { spawnSync } from "child_process"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { basename, join } from "path"; -/** Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`). */ +/** Keep the slug inside the [a-zA-Z0-9._-] alphabet gstack-slug promises (`tr -cd`). */ +function sanitizeSlug(s: string): string { + return s.replace(/[^a-zA-Z0-9._-]/g, ""); +} + +/** + * A Windows path in the MSYS form git-bash's `pwd` reports: + * `C:\Users\j\foo` → `/c/Users/j/foo`. gstack-slug keys its cache on THAT form + * (`tr '/' '_'`), so a native lookup must reproduce it exactly or it misses the very + * entry gstack-slug wrote and silently re-derives instead of staying consistent. + * Exported for the cache-key test; non-Windows paths pass through unchanged. + */ +export function toMsysPath(p: string): string { + const drive = p.match(/^([A-Za-z]):[\\/]/); + const body = (drive ? p.slice(2) : p).replace(/\\/g, "/"); + return drive ? `/${drive[1].toLowerCase()}${body}` : body; +} + +/** + * Native port of bin/gstack-slug's resolution order, used when that script cannot be + * spawned (see resolveSlug). Same three steps, same alphabet, same cache file — so + * this and the shell path always agree. They must: the bins WRITE using this, while + * the Context Recovery preamble READS using the script. + */ +export function slugFromEnvironment(gstackHome?: string, cwd: string = process.cwd()): string { + const home = gstackHome || process.env.GSTACK_HOME || join(homedir(), ".gstack"); + const cacheDir = join(home, "slug-cache"); + const cacheFile = join(cacheDir, toMsysPath(cwd).replace(/\//g, "_")); + + let slug = ""; + // 1. cached slug wins (guarantees consistency across sessions) + if (existsSync(cacheFile)) { + try { + slug = sanitizeSlug(readFileSync(cacheFile, "utf-8").trim()); + } catch { + slug = ""; + } + } + // 2. else derive from the git remote: [:/]/[.git] → owner-repo + if (!slug) { + const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8", cwd }); + const m = (r.stdout || "").trim().match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/); + if (m) slug = sanitizeSlug(m[1].replace(/\//g, "-")); + } + // 3. else the directory name + if (!slug) slug = sanitizeSlug(basename(cwd)); + if (!slug) return "unknown"; + + // 4. cache it, as gstack-slug does — atomic, and failures stay silent (`|| true`) + try { + mkdirSync(cacheDir, { recursive: true }); + const tmp = `${cacheFile}.tmp.${process.pid}`; + writeFileSync(tmp, slug, "utf-8"); + renameSync(tmp, cacheFile); + } catch { + // best-effort cache; a miss only costs a re-derive on the next call + } + return slug; +} + +/** Windows cannot exec an extensionless `#!/usr/bin/env bash` script (no shebang, no + * PATHEXT match for an explicit path), so gstack-slug spawns ENOENT there. */ +export const NEEDS_NATIVE_SLUG_ON_WINDOWS = process.platform === "win32"; + +/** + * Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`). + * + * On Windows that spawn fails ENOENT (see NEEDS_NATIVE_SLUG_ON_WINDOWS) and `r.stdout` + * is undefined — the same class of hazard as the gbrain shim spawns in lib/gbrain-exec.ts + * (#1731). Returning the literal "unknown" filed every decision under + * ~/.gstack/projects/unknown/ — one bucket shared by every project on the machine — + * while the bash-side Context Recovery preamble resolved the real slug, found no + * decisions.active.json there, and skipped through a bare `if [ -f … ]` with no else. + * + * Nothing failed, for ten days: BOTH decision bins (log and search) missed identically, + * so writes and searches stayed consistent with each other, and the only component that + * resolved correctly was silent by design. + * + * `shell: true` is NOT the fix here, unlike #1731: cmd.exe cannot run a bash script + * either. Nor is re-spawning through `bash` — on Windows that frequently resolves to + * WSL, whose $HOME and /mnt/c paths yield a different slug AND a different cache + * directory, trading one split store for another. + * + * POSIX behaviour is unchanged: the fallback is win32-only, where the previous result + * was unconditionally wrong and so has nothing to regress. + */ export function resolveSlug(slugBinPath: string): string { const r = spawnSync(slugBinPath, { encoding: "utf-8" }); const m = (r.stdout || "").match(/^SLUG=(.+)$/m); - return m ? m[1].trim() : "unknown"; + if (m) return m[1].trim(); + if (NEEDS_NATIVE_SLUG_ON_WINDOWS) return slugFromEnvironment(); + return "unknown"; } /** Current git branch, or undefined on detached HEAD / outside a repo. */ diff --git a/test/artifacts-allowlist-decisions.test.ts b/test/artifacts-allowlist-decisions.test.ts new file mode 100644 index 000000000..7908cd28d --- /dev/null +++ b/test/artifacts-allowlist-decisions.test.ts @@ -0,0 +1,66 @@ +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 INIT = fs.readFileSync(path.join(ROOT, "bin", "gstack-artifacts-init"), "utf-8"); + +/** Pull a quoted heredoc body out of gstack-artifacts-init by target filename. */ +function heredoc(target: string): string { + const re = new RegExp(`cat > "\\$GSTACK_HOME/${target}" <<'EOF'\\n([\\s\\S]*?)\\nEOF\\n`); + const m = INIT.match(re); + if (!m) throw new Error(`heredoc for ${target} not found in gstack-artifacts-init`); + return m[1]; +} + +/** fnmatch.fnmatchcase semantics, as compute_paths_to_stage applies them: + * `*` does not cross a path separator. */ +function globToRe(g: string): RegExp { + return new RegExp("^" + g.split("*").map((s) => s.replace(/[.]/g, "[.]")).join("[^/]*") + "$"); +} + +const DECISION_PATHS = [ + "projects/acme-widget/decisions.jsonl", + "projects/acme-widget/decisions.active.json", + "projects/acme-widget/decisions.archive.jsonl", +]; + +/** + * gstack-decision-log:40 enqueues projects//decisions.jsonl after EVERY write, + * but no managed glob matched it, so compute_paths_to_stage rejected all of them at + * its "must match at least one allowlist glob" check. The writer and the syncer + * disagreed silently: turning artifacts sync on backed up learnings, plans, designs + * and timelines -- everything EXCEPT the durable decision ledger -- and nothing + * anywhere reported a miss, because a dropped path prints exactly what a synced one + * does when the queue is otherwise empty. + * + * Source-level rather than end-to-end: gstack-artifacts-init.test.ts drives the real + * script through #!/bin/bash shims and a colon-separated PATH, so it cannot run on + * Windows -- which is the platform where this bug bit. + */ +describe("the artifacts allowlist covers the decision store", () => { + const globs = heredoc("\\.brain-allowlist") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")); + + test("every decisions.* path matches at least one allowlist glob", () => { + for (const p of DECISION_PATHS) { + expect({ p, matched: globs.some((g) => globToRe(g).test(p)) }).toEqual({ p, matched: true }); + } + }); + + test("decisions.* are class artifact, so they sync in artifacts-only mode too", () => { + const map = JSON.parse(heredoc("\\.brain-privacy-map\\.json")); + for (const p of DECISION_PATHS) { + const hit = map.find((e: { pattern: string; class: string }) => globToRe(e.pattern).test(p)); + expect({ p, cls: hit?.class }).toEqual({ p, cls: "artifact" }); + } + }); + + test("the allowlist still ends with the user-additions marker", () => { + // Additions below it survive re-init; a glob added above would be silently + // overwritten the next time gstack-artifacts-init runs. + expect(heredoc("\\.brain-allowlist").trimEnd()).toMatch(/# ---- USER ADDITIONS BELOW ----/); + }); +}); diff --git a/test/bin-context-windows-slug.test.ts b/test/bin-context-windows-slug.test.ts new file mode 100644 index 000000000..47f05b8da --- /dev/null +++ b/test/bin-context-windows-slug.test.ts @@ -0,0 +1,113 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { spawnSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + toMsysPath, + slugFromEnvironment, + resolveSlug, + NEEDS_NATIVE_SLUG_ON_WINDOWS, +} from "../lib/bin-context"; + +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 {} }); + +/** + * Windows cannot exec bin/gstack-slug -- a `#!/usr/bin/env bash` script with no file + * extension -- so spawnSync fails ENOENT and resolveSlug used to return the literal + * string "unknown". Every decision on the machine landed in one shared + * ~/.gstack/projects/unknown/ bucket, while the bash-side Context Recovery preamble + * resolved the real slug and silently found nothing there. + * + * These exercise the native fallback on EVERY platform (it is only the *gating* that + * is win32-specific), so macOS/Linux CI catches a regression that would otherwise + * only ever surface on a Windows user's disk. + */ +describe("native slug fallback mirrors bin/gstack-slug", () => { + test("toMsysPath reproduces the git-bash cache key", () => { + // gstack-slug does: CACHE_KEY=$(printf '%s' "$(pwd)" | tr '/' '_') + // and git-bash `pwd` reports C:\Users\j\foo as /c/Users/j/foo. + expect(toMsysPath("C:\\Users\\j\\foo")).toBe("/c/Users/j/foo"); + expect(toMsysPath("D:/Work/Repo")).toBe("/d/Work/Repo"); + expect(toMsysPath("/already/posix")).toBe("/already/posix"); + // The cache FILENAME is the real contract: + expect(toMsysPath("C:\\Users\\j\\foo").replace(/\//g, "_")).toBe("_c_Users_j_foo"); + }); + + test("step 1: a cached slug wins over everything else", () => { + const cwd = path.join(tmp, "proj"); + fs.mkdirSync(cwd); + const cacheDir = path.join(tmp, "home", "slug-cache"); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(path.join(cacheDir, toMsysPath(cwd).replace(/\//g, "_")), "cached-wins"); + expect(slugFromEnvironment(path.join(tmp, "home"), cwd)).toBe("cached-wins"); + }); + + test("step 2: derives owner-repo from the git remote, https and ssh alike", () => { + for (const [url, want] of [ + ["https://github.com/acme/Widget.git", "acme-Widget"], + ["git@github.com:acme/Widget.git", "acme-Widget"], + ["https://gitlab.com/acme/Widget", "acme-Widget"], + ] as const) { + const cwd = fs.mkdtempSync(path.join(tmp, "repo-")); + spawnSync("git", ["init", "-q"], { cwd }); + spawnSync("git", ["remote", "add", "origin", url], { cwd }); + expect(slugFromEnvironment(path.join(tmp, "home2"), cwd)).toBe(want); + } + }); + + test("step 3: falls back to the sanitized directory name", () => { + // `tr -cd 'a-zA-Z0-9._-'` DELETES disallowed characters rather than replacing them. + const cwd = path.join(tmp, "My Proj+v2"); + fs.mkdirSync(cwd); + expect(slugFromEnvironment(path.join(tmp, "home3"), cwd)).toBe("MyProjv2"); + }); + + test("the resolved slug is cached back, as the shell script does", () => { + const cwd = path.join(tmp, "cacheme"); + fs.mkdirSync(cwd); + const home = path.join(tmp, "home4"); + const slug = slugFromEnvironment(home, cwd); + const key = path.join(home, "slug-cache", toMsysPath(cwd).replace(/\//g, "_")); + expect(fs.existsSync(key)).toBe(true); + // no trailing newline: gstack-slug writes with printf '%s' + expect(fs.readFileSync(key, "utf-8")).toBe(slug); + }); + + test("never returns the empty string", () => { + expect(slugFromEnvironment(path.join(tmp, "h"), tmp).length).toBeGreaterThan(0); + }); +}); + +describe("the fallback stays win32-gated", () => { + // Static tripwire in the style of gbrain-spawn-windows-shell.test.ts: POSIX CI + // cannot observe the Windows branch at runtime, so pin the gate itself. Removing + // it would silently change macOS/Linux behaviour, which today is byte-identical. + test("NEEDS_NATIVE_SLUG_ON_WINDOWS is platform-gated", () => { + expect(read("lib/bin-context.ts")).toMatch( + /export const NEEDS_NATIVE_SLUG_ON_WINDOWS\s*=\s*process\.platform === "win32"/, + ); + expect(NEEDS_NATIVE_SLUG_ON_WINDOWS).toBe(process.platform === "win32"); + }); + + test("resolveSlug no longer returns a bare literal on a failed spawn", () => { + const src = read("lib/bin-context.ts"); + expect(src).not.toMatch(/return m \? m\[1\]\.trim\(\) : "unknown";/); + expect(src).toMatch(/if \(NEEDS_NATIVE_SLUG_ON_WINDOWS\) return slugFromEnvironment\(\);/); + }); + + test("a spawn that cannot run resolves to a real slug, not 'unknown'", () => { + // The exact production failure: the helper path does not exist / cannot exec. + const got = resolveSlug(path.join(tmp, "definitely-not-a-real-bin")); + if (NEEDS_NATIVE_SLUG_ON_WINDOWS) { + expect(got).not.toBe("unknown"); + } else { + expect(got).toBe("unknown"); // POSIX behaviour deliberately unchanged + } + }); +});