/**
 * Consolidated server-related tests.
 *
 * Merged from:
 *   - tests/soft-fail.test.ts
 *   - tests/stream-cap.test.ts
 *   - tests/turndown.test.ts
 *   - tests/project-dir.test.ts
 *   - tests/subagent-budget.test.ts
 *
 * Run: npx vitest run tests/core/server.test.ts
 */

import { strict as assert } from "node:assert";
import { spawn, spawnSync, execSync, type ChildProcess } from "node:child_process";
import {
  chmodSync,
  writeFileSync,
  mkdtempSync,
  mkdirSync,
  rmSync,
  readFileSync,
  existsSync,
} from "node:fs";
import { join, dirname, resolve } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import { describe, test, expect, beforeAll, afterAll, afterEach } from "vitest";

import { classifyNonZeroExit } from "../../src/exit-classify.js";
import { PolyglotExecutor } from "../../src/executor.js";
import { detectRuntimes } from "../../src/runtime.js";
import { ContentStore } from "../../src/store.js";
import {
  clearStorageDirectoryCheckCacheForTests,
  describeStorageDirectorySource,
  ensureWritableStorageDir,
  formatStorageDirectoryError,
  resolveContentStorageDir,
  resolveDefaultSessionDir,
  resolveSessionStorageDir,
  resolveStatsStorageDir,
  StorageDirectoryError,
} from "../../src/session/db.js";
import { ROUTING_BLOCK } from "../../hooks/routing-block.mjs";

// ─── Shared setup ───────────────────────────────────────────────────────────
const runtimes = detectRuntimes();
const __dirname = dirname(fileURLToPath(import.meta.url));
const STORAGE_ENV_KEY = "QUIET_CONTEXT_DIR";
const savedStorageEnv = process.env[STORAGE_ENV_KEY];

afterEach(() => {
  if (savedStorageEnv === undefined) delete process.env[STORAGE_ENV_KEY];
  else process.env[STORAGE_ENV_KEY] = savedStorageEnv;
  clearStorageDirectoryCheckCacheForTests();
});

describe("storage path resolution", () => {
  test("uses adapter defaults when no storage override is set", () => {
    delete process.env[STORAGE_ENV_KEY];

    const defaultSessionsDir = join(tmpdir(), "context-mode-default", "sessions");
    const defaultRoot = dirname(defaultSessionsDir);
    const session = resolveSessionStorageDir(() => defaultSessionsDir);
    const content = resolveContentStorageDir(() => defaultSessionsDir);
    const stats = resolveStatsStorageDir(() => defaultSessionsDir);

    expect(session).toEqual({
      kind: "session",
      path: defaultSessionsDir,
      envVar: null,
      source: "default",
    });
    expect(content).toEqual({
      kind: "content",
      path: join(defaultRoot, "content"),
      envVar: null,
      source: "default",
    });
    expect(stats).toEqual({
      kind: "stats",
      path: defaultSessionsDir,
      envVar: null,
      source: "default",
    });
  });

  test("shared default session dir helper derives context-mode sessions root", () => {
    const configRoot = join(tmpdir(), "context-mode-config-root");

    expect(resolveDefaultSessionDir({ configDir: configRoot })).toBe(
      join(configRoot, "context-mode", "sessions"),
    );
  });

  test("shared default session dir helper honors config env override", () => {
    const configRoot = join(tmpdir(), "context-mode-env-config-root");

    expect(
      resolveDefaultSessionDir({
        configDir: ".ignored",
        configDirEnv: "QUIET_CONTEXT_TEST_CONFIG_DIR",
        env: { QUIET_CONTEXT_TEST_CONFIG_DIR: configRoot },
      }),
    ).toBe(join(configRoot, "context-mode", "sessions"));
  });

  test("legacy session dir wins only inside blank or unset storage override default callback", () => {
    const legacyDir = join(tmpdir(), "context-mode-legacy-sessions");
    const root = resolve(tmpdir(), "context-mode-storage-root");
    const defaultDir = () => resolveDefaultSessionDir({
      configDir: ".ignored",
      legacySessionDirEnv: "QUIET_CONTEXT_TEST_SESSION_DIR",
      env: { QUIET_CONTEXT_TEST_SESSION_DIR: legacyDir },
    });

    delete process.env[STORAGE_ENV_KEY];
    expect(resolveSessionStorageDir(defaultDir).path).toBe(legacyDir);

    process.env[STORAGE_ENV_KEY] = " \t ";
    expect(resolveSessionStorageDir(defaultDir).path).toBe(legacyDir);

    process.env[STORAGE_ENV_KEY] = root;
    expect(resolveSessionStorageDir(defaultDir).path).toBe(join(root, "sessions"));
  });

  test("uses QUIET_CONTEXT_DIR as the single root for sessions, content, and stats", () => {
    const root = resolve(tmpdir(), "context-mode-storage-root");
    process.env[STORAGE_ENV_KEY] = root;

    expect(resolveSessionStorageDir(() => "/ignored")).toEqual({
      kind: "session",
      path: join(root, "sessions"),
      envVar: STORAGE_ENV_KEY,
      source: "override",
    });
    expect(resolveContentStorageDir(() => "/ignored")).toEqual({
      kind: "content",
      path: join(root, "content"),
      envVar: STORAGE_ENV_KEY,
      source: "override",
    });
    expect(resolveStatsStorageDir(() => "/ignored")).toEqual({
      kind: "stats",
      path: join(root, "sessions"),
      envVar: STORAGE_ENV_KEY,
      source: "override",
    });
  });

  test("treats blank QUIET_CONTEXT_DIR as default and reports ignored metadata", () => {
    process.env[STORAGE_ENV_KEY] = " \t ";
    const defaultSessionsDir = join(tmpdir(), "context-mode-default", "sessions");

    const session = resolveSessionStorageDir(() => defaultSessionsDir);
    const content = resolveContentStorageDir(() => defaultSessionsDir);

    expect(session).toMatchObject({
      kind: "session",
      path: defaultSessionsDir,
      envVar: null,
      source: "default",
      ignoredEnvVar: STORAGE_ENV_KEY,
      ignoredReason: "empty",
    });
    expect(content).toMatchObject({
      kind: "content",
      path: join(dirname(defaultSessionsDir), "content"),
      ignoredEnvVar: STORAGE_ENV_KEY,
      ignoredReason: "empty",
    });
    expect(describeStorageDirectorySource(session)).toBe("default; ignored empty QUIET_CONTEXT_DIR");

    const err = new StorageDirectoryError("session", session.path, STORAGE_ENV_KEY, undefined, undefined, session);
    expect(formatStorageDirectoryError(err)).toContain("Ignored empty QUIET_CONTEXT_DIR; using adapter default.");
  });

  test("rejects a relative QUIET_CONTEXT_DIR", () => {
    process.env[STORAGE_ENV_KEY] = "relative/path";

    expect(() => resolveSessionStorageDir(() => "/ignored")).toThrow(StorageDirectoryError);
    expect(() => resolveSessionStorageDir(() => "/ignored")).toThrow(
      "QUIET_CONTEXT_DIR must be an absolute path.",
    );
  });

  test("memoizes successful writable directory checks", () => {
    const dir = {
      kind: "session" as const,
      path: mkdtempSync(join(tmpdir(), "ctx-storage-cache-")),
      envVar: null,
      source: "default" as const,
    };

    expect(ensureWritableStorageDir(dir)).toBe(dir.path);
    rmSync(dir.path, { recursive: true, force: true });
    expect(ensureWritableStorageDir(dir)).toBe(dir.path);
    expect(existsSync(dir.path)).toBe(false);
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// 1. Non-zero Exit Code Classification (soft-fail)
// ═══════════════════════════════════════════════════════════════════════════

describe("Non-zero Exit Code Classification", () => {
  // ── Soft-fail: shell + exit 1 + stdout present ──

  test("shell exit 1 with stdout → not an error (grep no-match pattern)", () => {
    const result = classifyNonZeroExit({
      language: "shell",
      exitCode: 1,
      stdout: "file1.ts:10: writeRouting\nfile2.ts:20: writeRouting",
      stderr: "",
    });
    expect(result.isError).toBe(false);
    expect(result.output).toBe("file1.ts:10: writeRouting\nfile2.ts:20: writeRouting");
  });

  test("shell exit 1 with empty stdout → real error", () => {
    const result = classifyNonZeroExit({
      language: "shell",
      exitCode: 1,
      stdout: "",
      stderr: "",
    });
    expect(result.isError).toBe(true);
  });

  test("shell exit 1 with whitespace-only stdout → real error", () => {
    const result = classifyNonZeroExit({
      language: "shell",
      exitCode: 1,
      stdout: "   \n  ",
      stderr: "",
    });
    expect(result.isError).toBe(true);
  });

  // ── Hard errors: exit code >= 2 ──

  test("shell exit 2 (grep bad regex) → always error", () => {
    const result = classifyNonZeroExit({
      language: "shell",
      exitCode: 2,
      stdout: "",
      stderr: "grep: Invalid regular expression",
    });
    expect(result.isError).toBe(true);
    expect(result.output).toContain("Exit code: 2");
    expect(result.output).toContain("grep: Invalid regular expression");
  });

  test("shell exit 127 (command not found) → always error", () => {
    const result = classifyNonZeroExit({
      language: "shell",
      exitCode: 127,
      stdout: "",
      stderr: "bash: nonexistent: command not found",
    });
    expect(result.isError).toBe(true);
    expect(result.output).toContain("Exit code: 127");
  });

  // ── Non-shell languages: always error ──

  test("javascript exit 1 with stdout → still an error (not shell)", () => {
    const result = classifyNonZeroExit({
      language: "javascript",
      exitCode: 1,
      stdout: "some output before crash",
      stderr: "TypeError: x is not a function",
    });
    expect(result.isError).toBe(true);
    expect(result.output).toContain("Exit code: 1");
  });

  test("python exit 1 with stdout → still an error (not shell)", () => {
    const result = classifyNonZeroExit({
      language: "python",
      exitCode: 1,
      stdout: "partial output",
      stderr: "Traceback (most recent call last):",
    });
    expect(result.isError).toBe(true);
  });

  test("typescript exit 1 with stdout → still an error (not shell)", () => {
    const result = classifyNonZeroExit({
      language: "typescript",
      exitCode: 1,
      stdout: "output",
      stderr: "",
    });
    expect(result.isError).toBe(true);
  });

  // ── Output format ──

  test("soft-fail output is clean stdout (no 'Exit code:' prefix)", () => {
    const result = classifyNonZeroExit({
      language: "shell",
      exitCode: 1,
      stdout: "matched line",
      stderr: "",
    });
    expect(result.output).not.toContain("Exit code:");
    expect(result.output).toBe("matched line");
  });

  test("hard error output includes exit code, stdout, and stderr", () => {
    const result = classifyNonZeroExit({
      language: "shell",
      exitCode: 2,
      stdout: "partial",
      stderr: "error msg",
    });
    expect(result.output).toContain("Exit code: 2");
    expect(result.output).toContain("partial");
    expect(result.output).toContain("error msg");
  });

  test("hard-fail with empty stdout still forwards stderr in output", () => {
    const result = classifyNonZeroExit({
      language: "shell",
      exitCode: 1,
      stdout: "",
      stderr: "command not found",
    });
    expect(result.isError).toBe(true);
    expect(result.output).toContain("Exit code: 1");
    expect(result.output).toContain("command not found");
  });

  test("hard-fail output has labeled 'stdout:' and 'stderr:' sections", () => {
    const result = classifyNonZeroExit({
      language: "node",
      exitCode: 137,
      stdout: "S",
      stderr: "E",
    });
    expect(result.output).toMatch(/stdout:\s*\nS/);
    expect(result.output).toMatch(/stderr:\s*\nE/);
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// 2. Stream Cap (stream-cap)
// ═══════════════════════════════════════════════════════════════════════════

describe("Stdout Cap", () => {
  test("stdout: process killed when output exceeds hard cap", async () => {
    const executor = new PolyglotExecutor({ hardCapBytes: 1024, runtimes });
    const r = await executor.execute({
      language: "javascript",
      code: 'for (let i = 0; i < 4000; i++) console.log("x".repeat(25));',
      timeout: 10_000,
    });
    assert.ok(r.stderr.includes("output capped"), "Expected cap message in stderr, got: " + r.stderr.slice(-200));
    assert.ok(r.stderr.includes("process killed"), "Expected 'process killed' in stderr");
  });
});

describe("Stderr Cap", () => {
  test("stderr: process killed when stderr exceeds hard cap", async () => {
    const executor = new PolyglotExecutor({ hardCapBytes: 1024, runtimes });
    const r = await executor.execute({
      language: "javascript",
      code: 'for (let i = 0; i < 4000; i++) console.error("e".repeat(25));',
      timeout: 10_000,
    });
    assert.ok(r.stderr.includes("output capped"), "Expected cap message in stderr for stderr-heavy output");
  });
});

describe("Combined Cap", () => {
  test("combined: cap triggers on total stdout+stderr bytes", async () => {
    const executor = new PolyglotExecutor({ hardCapBytes: 2048, runtimes });
    const r = await executor.execute({
      language: "javascript",
      code: 'for (let i = 0; i < 200; i++) process.stdout.write("o".repeat(10) + "\\n");\nfor (let i = 0; i < 200; i++) process.stderr.write("e".repeat(10) + "\\n");',
      timeout: 10_000,
    });
    assert.ok(r.stderr.includes("output capped"), "Combined output should have triggered the cap");
  });
});

describe("Normal Operation", () => {
  test("normal: small output below cap works correctly", async () => {
    const executor = new PolyglotExecutor({ hardCapBytes: 1024 * 1024, runtimes });
    const r = await executor.execute({
      language: "javascript",
      code: 'console.log("hello from capped executor");',
    });
    assert.equal(r.exitCode, 0);
    assert.ok(r.stdout.includes("hello from capped executor"));
    assert.ok(!r.stderr.includes("output capped"), "Should NOT contain cap message for small output");
  });

  test("normal: moderate output below cap preserves all content", async () => {
    const executor = new PolyglotExecutor({ hardCapBytes: 1024 * 1024, runtimes });
    const r = await executor.execute({
      language: "javascript",
      code: 'for (let i = 0; i < 50; i++) console.log("line-" + i);',
    });
    assert.equal(r.exitCode, 0);
    assert.ok(r.stdout.includes("line-0"), "Should contain first line");
    assert.ok(r.stdout.includes("line-49"), "Should contain last line");
    assert.ok(!r.stderr.includes("output capped"));
  });
});

describe("Memory Bounding", () => {
  test("memory: collected stdout bytes stay bounded near cap", async () => {
    const capBytes = 4096;
    const executor = new PolyglotExecutor({ hardCapBytes: capBytes, runtimes });
    const r = await executor.execute({
      language: "javascript",
      code: 'for (let i = 0; i < 20000; i++) console.log("x".repeat(25));',
      timeout: 10_000,
    });
    assert.ok(r.stderr.includes("output capped"), "Cap should have triggered");
    const stdoutBytes = Buffer.byteLength(r.stdout);
    const tolerance = 256 * 1024;
    assert.ok(stdoutBytes < capBytes + tolerance, "Collected " + stdoutBytes + " bytes stdout; expected bounded near " + capBytes);
  });
});

describe("Cap Message Format", () => {
  test("format: cap message reports correct MB value for 2MB cap", async () => {
    const twoMB = 2 * 1024 * 1024;
    const executor = new PolyglotExecutor({ hardCapBytes: twoMB, runtimes });
    const r = await executor.execute({
      language: "javascript",
      code: 'for (let i = 0; i < 100000; i++) process.stdout.write("x".repeat(49) + "\\n");',
      timeout: 10_000,
    });
    assert.ok(r.stderr.includes("2MB"), "Expected '2MB' in cap message: " + r.stderr.slice(-200));
    assert.ok(r.stderr.includes("process killed"));
  });

  test("format: cap message uses em dash and bracket format", async () => {
    const executor = new PolyglotExecutor({ hardCapBytes: 1024, runtimes });
    const r = await executor.execute({
      language: "javascript",
      code: 'for (let i = 0; i < 4000; i++) console.log("x".repeat(25));',
      timeout: 10_000,
    });
    assert.ok(r.stderr.includes("\u2014"), "Cap message should use em dash");
    assert.ok(r.stderr.includes("[output capped at"), "Cap message should start with '[output capped at'");
  });
});

describe("Timeout Independence", () => {
  test("timeout: still fires when output is slow and under cap", async () => {
    const executor = new PolyglotExecutor({ hardCapBytes: 100 * 1024 * 1024, runtimes });
    const r = await executor.execute({
      language: "javascript",
      code: "while(true) {}",
      timeout: 500,
    });
    assert.equal(r.timedOut, true);
    assert.ok(!r.stderr.includes("output capped"), "Should be timeout, not cap");
  });

  test("timeout: cap fires before timeout for fast-producing process", async () => {
    const executor = new PolyglotExecutor({ hardCapBytes: 1024, runtimes });
    const r = await executor.execute({
      language: "javascript",
      code: 'for (let i = 0; i < 100000; i++) console.log("x".repeat(50));',
      timeout: 10_000,
    });
    assert.ok(r.stderr.includes("output capped"), "Cap should fire before timeout");
    assert.equal(r.timedOut, false, "timedOut should be false when cap killed the process");
  });
});

describe("Default Cap", () => {
  test("default: executor works with default hardCapBytes (no option)", async () => {
    const executor = new PolyglotExecutor({ runtimes });
    const r = await executor.execute({
      language: "javascript",
      code: 'console.log("default cap works");',
    });
    assert.equal(r.exitCode, 0);
    assert.ok(r.stdout.includes("default cap works"));
  });
});

describe("hardCap still limits output", () => {
  test("hardCap kills process but stdout is NOT truncated", async () => {
    const executor = new PolyglotExecutor({ hardCapBytes: 50 * 1024, runtimes });
    const r = await executor.execute({
      language: "javascript",
      code: 'for (let i = 0; i < 4000; i++) console.log("x".repeat(25));',
      timeout: 10_000,
    });
    assert.ok(r.stderr.includes("output capped"), "Hard cap should trigger");
    assert.ok(!r.stdout.includes("truncated"), "stdout should NOT have truncation marker");
    assert.ok(!r.stdout.includes("showing first"), "stdout should NOT have head/tail marker");
  });
});

describe("Large Output Auto-Indexing", () => {
  test("large stdout is fully preserved by executor", async () => {
    const executor = new PolyglotExecutor({ runtimes });
    const r = await executor.execute({
      language: "javascript",
      code: 'for (let i = 0; i < 100; i++) console.log(`line ${i}: ${"x".repeat(20)}`);',
    });
    assert.ok(r.stdout.includes("line 0"), "Should contain first line");
    assert.ok(r.stdout.includes("line 50"), "Should contain middle line");
    assert.ok(r.stdout.includes("line 99"), "Should contain last line");
    assert.ok(!r.stdout.includes("truncated"), "Should NOT be truncated");
  });

  test("large stdout is indexed into FTS5 and searchable", async () => {
    const store = new ContentStore(":memory:");
    const lines: string[] = [];
    for (let i = 0; i < 5000; i++) lines.push(`line ${i}: data_value_${i}`);
    const largeOutput = lines.join("\n");

    const indexed = store.indexPlainText(largeOutput, "test:large-output");
    assert.ok(indexed.totalChunks > 1, "Should be chunked into multiple sections");

    const results = store.searchWithFallback("data_value_2500", 3, "test:large-output");
    assert.ok(results.length > 0, "Middle content should be searchable");
    assert.ok(results[0].content.includes("2500"), "Should find the middle line");

    store.close();
  });

  test("small stdout is returned inline as-is", async () => {
    const executor = new PolyglotExecutor({ runtimes });
    const r = await executor.execute({
      language: "javascript",
      code: 'console.log("hello world");',
    });
    assert.equal(r.exitCode, 0);
    assert.ok(r.stdout.includes("hello world"));
    assert.ok(!r.stdout.includes("Indexed"), "Small output should NOT be indexed pointer");
  });
});

describe("Cross-Language Cap", () => {
  test("shell: cap works with shell scripts", async () => {
    const executor = new PolyglotExecutor({ hardCapBytes: 2048, runtimes });
    const r = await executor.execute({
      language: "shell",
      code: 'yes "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" | head -c 100000',
      timeout: 10_000,
    });
    assert.ok(r.stderr.includes("output capped"), "Cap should trigger for shell output");
  });

  test.runIf(runtimes.python)("python: cap works with python scripts", async () => {
    const executor = new PolyglotExecutor({ hardCapBytes: 2048, runtimes });
    // Single large write (not a 10k-iter loop): keeps the test fast on slow
    // Windows CI VMs where per-syscall Python overhead can otherwise blow the timeout.
    const r = await executor.execute({
      language: "python",
      code: 'import sys\nsys.stdout.write("x" * 100000)',
      timeout: 10_000,
    });
    assert.ok(r.stderr.includes("output capped"), "Cap should trigger for python output");
  });
});

describe("Interleaved Output", () => {
  test("interleaved: rapid alternating stdout/stderr triggers cap", async () => {
    const executor = new PolyglotExecutor({ hardCapBytes: 4096, runtimes });
    const r = await executor.execute({
      language: "javascript",
      code: 'for (let i = 0; i < 5000; i++) { if (i % 2 === 0) process.stdout.write("out" + i + "\\n"); else process.stderr.write("err" + i + "\\n"); }',
      timeout: 10_000,
    });
    assert.ok(r.stderr.includes("output capped"), "Interleaved output should trigger cap");
  });
});

describe("executeFile Cap", () => {
  test("executeFile: cap applies to file execution too", async () => {
    const tmpDir = mkdtempSync(join(tmpdir(), "cap-test-"));
    const testFile = join(tmpDir, "data.txt");
    writeFileSync(testFile, "test content", "utf-8");

    try {
      const executor = new PolyglotExecutor({ hardCapBytes: 1024, runtimes });
      const r = await executor.executeFile({
        path: testFile,
        language: "javascript",
        code: 'for (let i = 0; i < 4000; i++) console.log("x".repeat(25));',
        timeout: 10_000,
      });
      assert.ok(r.stderr.includes("output capped"), "executeFile should also respect the hard cap");
    } finally {
      rmSync(tmpDir, { recursive: true, force: true });
    }
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// 3. Turndown HTML-to-markdown conversion
// ═══════════════════════════════════════════════════════════════════════════

// Resolve turndown path the same way server.ts will
const require = createRequire(import.meta.url);
const turndownPath = require.resolve("turndown");
const gfmPath = require.resolve("turndown-plugin-gfm");

const turndownExecutor = new PolyglotExecutor();

function buildConversionCode(html: string): string {
  return `
const TurndownService = require(${JSON.stringify(turndownPath)});
const { gfm } = require(${JSON.stringify(gfmPath)});
const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
td.use(gfm);
td.remove(['script', 'style', 'nav', 'header', 'footer', 'noscript']);
console.log(td.turndown(${JSON.stringify(html)}));
`;
}

describe("turndown HTML-to-markdown conversion tests", () => {
  test("converts headings", async () => {
    const result = await turndownExecutor.execute({
      language: "javascript",
      code: buildConversionCode("<h1>Title</h1><h2>Subtitle</h2><h3>Section</h3>"),
    });
    assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
    assert(result.stdout.includes("# Title"), `expected '# Title', got: ${result.stdout}`);
    assert(result.stdout.includes("## Subtitle"));
    assert(result.stdout.includes("### Section"));
  });

  test("converts links", async () => {
    const result = await turndownExecutor.execute({
      language: "javascript",
      code: buildConversionCode('<p>Visit <a href="https://example.com">Example</a></p>'),
    });
    assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
    assert(result.stdout.includes("[Example](https://example.com)"));
  });

  test("converts fenced code blocks", async () => {
    const result = await turndownExecutor.execute({
      language: "javascript",
      code: buildConversionCode('<pre><code class="language-js">const x = 1;</code></pre>'),
    });
    assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
    assert(result.stdout.includes("```"), `expected fenced code block, got: ${result.stdout}`);
    assert(result.stdout.includes("const x = 1;"));
  });

  test("strips script tags", async () => {
    const result = await turndownExecutor.execute({
      language: "javascript",
      code: buildConversionCode("<p>Hello</p><script>alert('xss')</script><p>World</p>"),
    });
    assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
    assert(!result.stdout.includes("alert"), `script content leaked: ${result.stdout}`);
    assert(result.stdout.includes("Hello"));
    assert(result.stdout.includes("World"));
  });

  test("strips style, nav, header, footer, noscript tags", async () => {
    const html = [
      "<style>body { color: red; }</style>",
      "<header><nav>Menu</nav></header>",
      "<main><p>Content</p></main>",
      "<footer>Footer</footer>",
      "<noscript>Enable JS</noscript>",
    ].join("");
    const result = await turndownExecutor.execute({
      language: "javascript",
      code: buildConversionCode(html),
    });
    assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
    assert(result.stdout.includes("Content"), `lost main content: ${result.stdout}`);
    assert(!result.stdout.includes("Menu"), `nav leaked: ${result.stdout}`);
    assert(!result.stdout.includes("Footer"), `footer leaked: ${result.stdout}`);
    assert(!result.stdout.includes("Enable JS"), `noscript leaked: ${result.stdout}`);
    assert(!result.stdout.includes("color: red"), `style leaked: ${result.stdout}`);
  });

  test("converts tables", async () => {
    const html = `
    <table>
      <thead><tr><th>Name</th><th>Age</th></tr></thead>
      <tbody><tr><td>Alice</td><td>30</td></tr></tbody>
    </table>`;
    const result = await turndownExecutor.execute({
      language: "javascript",
      code: buildConversionCode(html),
    });
    assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
    assert(result.stdout.includes("| Name"), `expected pipe table, got: ${result.stdout}`);
    assert(result.stdout.includes("| Alice"));
    assert(result.stdout.includes("| ---"), `expected table separator, got: ${result.stdout}`);
  });

  test("handles nested tags correctly", async () => {
    const html = '<div><p>Outer <strong>bold <em>and italic</em></strong> text</p></div>';
    const result = await turndownExecutor.execute({
      language: "javascript",
      code: buildConversionCode(html),
    });
    assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
    assert(result.stdout.includes("**bold"), `missing bold: ${result.stdout}`);
    assert(result.stdout.includes("italic"), `missing italic: ${result.stdout}`);
  });

  test("handles malformed HTML gracefully", async () => {
    const html = "<p>Unclosed paragraph<p>Another<div>Nested badly</p></div>";
    const result = await turndownExecutor.execute({
      language: "javascript",
      code: buildConversionCode(html),
    });
    assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
    assert(result.stdout.includes("Unclosed paragraph"), `lost content: ${result.stdout}`);
    assert(result.stdout.includes("Nested badly"), `lost nested content: ${result.stdout}`);
  });

  test("decodes HTML entities", async () => {
    const result = await turndownExecutor.execute({
      language: "javascript",
      code: buildConversionCode("<p>Tom &amp; Jerry &lt;3 &quot;cheese&quot;</p>"),
    });
    assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
    assert(result.stdout.includes('Tom & Jerry <3 "cheese"'), `entities not decoded: ${result.stdout}`);
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// 4. Project Directory Path Resolution (project-dir)
// ═══════════════════════════════════════════════════════════════════════════

// Set up two isolated directories to simulate the scenario:
// - pluginDir: where the plugin is installed (start.sh does cd here)
// - projectDir: where the user's project lives (the real cwd)
const projDirBaseDir = join(tmpdir(), "ctx-mode-projdir-test-" + Date.now());
const projectDir = join(projDirBaseDir, "user-project");
const pluginDir = join(projDirBaseDir, "plugin-install");
mkdirSync(projectDir, { recursive: true });
mkdirSync(pluginDir, { recursive: true });

// Create a test file in the user's project directory
const testFileName = "data.json";
const testData = { message: "hello from project dir", count: 42 };
writeFileSync(
  join(projectDir, testFileName),
  JSON.stringify(testData),
  "utf-8",
);

// Also create a different file with the same name in the plugin directory
// to prove we're reading from the right place
const pluginData = { message: "wrong directory", count: 0 };
writeFileSync(
  join(pluginDir, testFileName),
  JSON.stringify(pluginData),
  "utf-8",
);

afterAll(() => {
  rmSync(projDirBaseDir, { recursive: true, force: true });
});

describe("executeFile: projectRoot path resolution", () => {
  test("relative path resolves against projectRoot, not cwd", async () => {
    const executor = new PolyglotExecutor({
      runtimes,
      projectRoot: projectDir,
    });

    const r = await executor.executeFile({
      path: testFileName, // relative path — should resolve to projectDir/data.json
      language: "javascript",
      code: `
        const data = JSON.parse(FILE_CONTENT);
        console.log(data.message);
      `,
    });

    assert.equal(r.exitCode, 0, `Expected exit 0, got ${r.exitCode}: ${r.stderr}`);
    assert.ok(
      r.stdout.includes("hello from project dir"),
      `Should read from projectDir, got: ${r.stdout.trim()}`,
    );
  });

  test("relative path with subdirectory resolves against projectRoot", async () => {
    const subDir = join(projectDir, "nested", "deep");
    mkdirSync(subDir, { recursive: true });
    writeFileSync(join(subDir, "nested.txt"), "nested content here", "utf-8");

    const executor = new PolyglotExecutor({
      runtimes,
      projectRoot: projectDir,
    });

    const r = await executor.executeFile({
      path: "nested/deep/nested.txt",
      language: "javascript",
      code: `console.log(FILE_CONTENT.trim());`,
    });

    assert.equal(r.exitCode, 0, `Expected exit 0: ${r.stderr}`);
    assert.ok(r.stdout.includes("nested content here"));
  });

  test("absolute path ignores projectRoot", async () => {
    const absFile = join(projDirBaseDir, "absolute-test.txt");
    writeFileSync(absFile, "absolute path content", "utf-8");

    const executor = new PolyglotExecutor({
      runtimes,
      projectRoot: projectDir,
    });

    const r = await executor.executeFile({
      path: absFile, // absolute path — projectRoot should be ignored
      language: "javascript",
      code: `console.log(FILE_CONTENT.trim());`,
    });

    assert.equal(r.exitCode, 0, `Expected exit 0: ${r.stderr}`);
    assert.ok(r.stdout.includes("absolute path content"));
  });

  test("default projectRoot is process.cwd()", async () => {
    // Create a file in the actual cwd
    const cwdFile = join(process.cwd(), ".ctx-mode-test-cwd-" + Date.now() + ".tmp");
    writeFileSync(cwdFile, "cwd content", "utf-8");

    try {
      const executor = new PolyglotExecutor({ runtimes });

      const r = await executor.executeFile({
        path: cwdFile,
        language: "javascript",
        code: `console.log(FILE_CONTENT.trim());`,
      });

      assert.equal(r.exitCode, 0, `Expected exit 0: ${r.stderr}`);
      assert.ok(r.stdout.includes("cwd content"));
    } finally {
      rmSync(cwdFile, { force: true });
    }
  });
});

describe("CLAUDE_PROJECT_DIR env var integration", () => {
  test("PolyglotExecutor accepts projectRoot option", async () => {
    const executor = new PolyglotExecutor({
      runtimes,
      projectRoot: "/some/custom/path",
    });

    // Verify the executor was created without error
    // The projectRoot is private, so we verify it indirectly via executeFile
    assert.ok(executor, "Executor should be created with custom projectRoot");
  });

  test("executeFile fails gracefully for non-existent relative path", async () => {
    const executor = new PolyglotExecutor({
      runtimes,
      projectRoot: projectDir,
    });

    const r = await executor.executeFile({
      path: "does-not-exist.json",
      language: "javascript",
      code: `console.log(FILE_CONTENT);`,
    });

    assert.notEqual(r.exitCode, 0, "Should fail for non-existent file");
  });
});

describe("Multi-language relative path resolution", () => {
  if (runtimes.python) {
    test("Python: relative path resolves against projectRoot", async () => {
      const executor = new PolyglotExecutor({
        runtimes,
        projectRoot: projectDir,
      });

      const r = await executor.executeFile({
        path: testFileName,
        language: "python",
        code: `
import json
data = json.loads(FILE_CONTENT)
print(f"msg: {data['message']}")
print(f"count: {data['count']}")
        `,
      });

      assert.equal(r.exitCode, 0, `Expected exit 0: ${r.stderr}`);
      assert.ok(r.stdout.includes("msg: hello from project dir"));
      assert.ok(r.stdout.includes("count: 42"));
    });
  }

  test("Shell: relative path resolves against projectRoot", async () => {
    const executor = new PolyglotExecutor({
      runtimes,
      projectRoot: projectDir,
    });

    const r = await executor.executeFile({
      path: testFileName,
      language: "shell",
      code: `echo "content: $FILE_CONTENT"`,
    });

    assert.equal(r.exitCode, 0, `Expected exit 0: ${r.stderr}`);
    assert.ok(r.stdout.includes("hello from project dir"));
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// ctx_index: projectRoot path resolution (#365)
// ═══════════════════════════════════════════════════════════════════════════
//
// Mirrors the executeFile relative-path resolution tests (line ~598). Confirms
// that ctx_index resolves a relative `path` argument against the detected
// project directory (CLAUDE_PROJECT_DIR / *_PROJECT_DIR / QUIET_CONTEXT_PROJECT_DIR
// → cwd fallback) instead of the MCP server process cwd. End-to-end via
// JSON-RPC against a freshly spawned server with an injected project dir.

describe("ctx_index: projectRoot path resolution (#365)", () => {
  const ctxProjectDir = mkdtempSync(join(tmpdir(), "ctx-index-projroot-"));
  const ctxFileName = "ctx-index-projroot-target.md";
  const uniqueMarker = `ctx-index-marker-${process.pid}-${Date.now()}`;

  beforeAll(() => {
    writeFileSync(
      join(ctxProjectDir, ctxFileName),
      `# ctx_index relative path test\n\nUnique marker: ${uniqueMarker}\n`,
      "utf-8",
    );
  });

  afterAll(() => {
    rmSync(ctxProjectDir, { recursive: true, force: true });
  });

  function spawnServerWithProjectDir(projectDirEnv: string): ChildProcess {
    return spawn("node", [mcpEntry], {
      stdio: ["pipe", "pipe", "pipe"],
      env: {
        ...process.env,
        QUIET_CONTEXT_DISABLE_VERSION_CHECK: "1",
        CLAUDE_PROJECT_DIR: projectDirEnv,
      },
    });
  }

  // MCP server processes JSON-RPC requests concurrently — we have to wait for
  // each response before sending the next one in tests that depend on order
  // (e.g. index then search). The shared `collectRpcResponses` helper kills
  // the proc once all expected ids arrive, so we use it serially per-call.
  async function awaitRpc(
    proc: ChildProcess,
    id: number,
    request: Record<string, unknown>,
    timeoutMs = 15_000,
  ): Promise<DoctorJsonRpcResponse | undefined> {
    return new Promise((resolve) => {
      let buffer = "";
      const onData = (d: Buffer) => {
        buffer += d.toString();
        let idx: number;
        while ((idx = buffer.indexOf("\n")) >= 0) {
          const line = buffer.slice(0, idx).trim();
          buffer = buffer.slice(idx + 1);
          if (!line) continue;
          try {
            const parsed = JSON.parse(line) as DoctorJsonRpcResponse;
            if (parsed.id === id) {
              proc.stdout!.off("data", onData);
              clearTimeout(timer);
              resolve(parsed);
              return;
            }
          } catch { /* ignore */ }
        }
      };
      const timer = setTimeout(() => {
        proc.stdout!.off("data", onData);
        resolve(undefined);
      }, timeoutMs);
      proc.stdout!.on("data", onData);
      sendRpc(proc, request);
    });
  }

  test("relative path resolves against CLAUDE_PROJECT_DIR, not server cwd", async () => {
    const proc = spawnServerWithProjectDir(ctxProjectDir);
    try {
      await awaitRpc(proc, 1, {
        jsonrpc: "2.0", id: 1, method: "initialize",
        params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ctx-index-pr365", version: "1.0" } },
      });
      sendRpc(proc, { jsonrpc: "2.0", method: "notifications/initialized" });

      const indexResp = await awaitRpc(proc, 100, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: ctxFileName } },
      });

      expect(indexResp?.error).toBeUndefined();
      const indexText = indexResp?.result?.content?.[0]?.text ?? "";
      expect(indexText).toMatch(/Indexed \d+ section/);

      // Only send search AFTER index has completed — MCP server processes
      // requests concurrently, so a piggybacked search would race the index.
      const searchResp = await awaitRpc(proc, 101, {
        jsonrpc: "2.0", id: 101, method: "tools/call",
        params: { name: "ctx_search", arguments: { queries: [uniqueMarker] } },
      });

      expect(searchResp?.error).toBeUndefined();
      const searchText = searchResp?.result?.content?.[0]?.text ?? "";
      expect(searchText).toContain(uniqueMarker);
    } finally {
      try { proc.kill("SIGTERM"); } catch { /* best effort */ }
    }
  }, 30_000);

  test("absolute path bypasses project-dir resolution", async () => {
    const absFile = join(ctxProjectDir, ctxFileName);
    const proc = spawnServerWithProjectDir("/non-existent-dir-on-purpose");
    try {
      await awaitRpc(proc, 1, {
        jsonrpc: "2.0", id: 1, method: "initialize",
        params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ctx-index-pr365-abs", version: "1.0" } },
      });
      sendRpc(proc, { jsonrpc: "2.0", method: "notifications/initialized" });

      const indexResp = await awaitRpc(proc, 100, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: absFile } },
      });

      expect(indexResp?.error).toBeUndefined();
      const indexText = indexResp?.result?.content?.[0]?.text ?? "";
      expect(indexText).toMatch(/Indexed \d+ section/);
      // Strengthened (FIX 3/10 C): assert the stored source label equals the
      // absolute path verbatim. Server defaults source = path when caller does
      // not pass an explicit source; the response text reports `from: <label>`,
      // so finding the absolute path here proves the resolver passed it
      // through intact instead of e.g. silently rewriting under projectDir.
      expect(indexText).toContain(`from: ${absFile}`);

      // Cross-check the same label round-trips through the FTS5 store: a
      // ctx_search scoped to source = <abs path> must surface the file's
      // unique marker. If the new resolver code path were skipped, the
      // absolute path would not be a valid lookup key here.
      const searchResp = await awaitRpc(proc, 101, {
        jsonrpc: "2.0", id: 101, method: "tools/call",
        params: { name: "ctx_search", arguments: { queries: [uniqueMarker], source: absFile } },
      });
      expect(searchResp?.error).toBeUndefined();
      const searchText = searchResp?.result?.content?.[0]?.text ?? "";
      expect(searchText).toContain(uniqueMarker);
    } finally {
      try { proc.kill("SIGTERM"); } catch { /* best effort */ }
    }
  }, 30_000);

  // ─────────────────────────────────────────────────────────────────────────
  // FIX 3/10 — negative-path coverage for ctx_index path resolution.
  //
  // PR #365 added happy-path tests above. The two tests below pin the P0
  // negative behaviors that those tests miss:
  //   A. `../` path traversal → currently allowed (trust-boundary policy).
  //      Pinned so future security-jail PRs surface the policy change here.
  //   B. ALL `*_PROJECT_DIR` envs unset → resolver falls back to spawned
  //      server's process.cwd().
  //   C. (above) Strengthens the absolute-path test with a source-label
  //      round-trip.
  // ─────────────────────────────────────────────────────────────────────────

  test("relative `../` path traversal still resolves and reads (current trust-boundary policy)", async () => {
    // Layout: <baseDir>/escape.md  +  <baseDir>/sub1/sub2 (= projectDir)
    // From projectDir, "../../escape.md" climbs back to <baseDir>/escape.md.
    const baseDir = mkdtempSync(join(tmpdir(), "ctx-index-traversal-"));
    const traversalProjectDir = join(baseDir, "sub1", "sub2");
    mkdirSync(traversalProjectDir, { recursive: true });
    const traversalMarker = `ctx-index-traversal-marker-${process.pid}-${Date.now()}`;
    writeFileSync(
      join(baseDir, "escape.md"),
      `# Path traversal target\n\nUnique marker: ${traversalMarker}\n`,
      "utf-8",
    );

    const proc = spawnServerWithProjectDir(traversalProjectDir);
    try {
      await awaitRpc(proc, 1, {
        jsonrpc: "2.0", id: 1, method: "initialize",
        params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ctx-index-fix3-traversal", version: "1.0" } },
      });
      sendRpc(proc, { jsonrpc: "2.0", method: "notifications/initialized" });

      const indexResp = await awaitRpc(proc, 100, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: "../../escape.md" } },
      });

      // Current policy: ctx_index trusts the host IDE's project boundary and
      // does not jail relative paths. A `../` escape that points at a real
      // file is RESOLVED and READ. If a future security PR introduces a
      // jail, this assertion will fail and force an explicit policy update.
      expect(indexResp?.error).toBeUndefined();
      const indexText = indexResp?.result?.content?.[0]?.text ?? "";
      expect(indexText).toMatch(/Indexed \d+ section/);

      // Confirm the file's contents actually entered the store (not a
      // silent no-op masquerading as success).
      const searchResp = await awaitRpc(proc, 101, {
        jsonrpc: "2.0", id: 101, method: "tools/call",
        params: { name: "ctx_search", arguments: { queries: [traversalMarker] } },
      });
      expect(searchResp?.error).toBeUndefined();
      const searchText = searchResp?.result?.content?.[0]?.text ?? "";
      expect(searchText).toContain(traversalMarker);
    } finally {
      try { proc.kill("SIGTERM"); } catch { /* best effort */ }
      rmSync(baseDir, { recursive: true, force: true });
    }
  }, 30_000);

  test("no *_PROJECT_DIR env set → relative path falls back to spawned-server cwd", async () => {
    // Strip every project-dir env the resolver chain consults (see
    // server.ts getProjectDir) so resolution is forced down to process.cwd().
    // start.mjs would re-set QUIET_CONTEXT_PROJECT_DIR and CLAUDE_PROJECT_DIR
    // from originalCwd — that originalCwd is the `cwd` we hand to spawn(),
    // which is exactly what we want to assert on.
    const fallbackCwd = mkdtempSync(join(tmpdir(), "ctx-index-cwdfallback-"));
    const fallbackFile = "t.md";
    const fallbackMarker = `ctx-index-cwdfallback-marker-${process.pid}-${Date.now()}`;
    writeFileSync(
      join(fallbackCwd, fallbackFile),
      `# cwd fallback target\n\nUnique marker: ${fallbackMarker}\n`,
      "utf-8",
    );

    const strippedEnv: Record<string, string> = {};
    for (const [k, v] of Object.entries(process.env)) {
      if (typeof v !== "string") continue;
      if (/_PROJECT_DIR$/.test(k)) continue;
      if (k === "VSCODE_CWD") continue;
      strippedEnv[k] = v;
    }
    strippedEnv.QUIET_CONTEXT_DISABLE_VERSION_CHECK = "1";

    const proc = spawn("node", [mcpEntry], {
      stdio: ["pipe", "pipe", "pipe"],
      cwd: fallbackCwd,
      env: strippedEnv,
    });
    try {
      await awaitRpc(proc, 1, {
        jsonrpc: "2.0", id: 1, method: "initialize",
        params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ctx-index-fix3-cwd", version: "1.0" } },
      });
      sendRpc(proc, { jsonrpc: "2.0", method: "notifications/initialized" });

      const indexResp = await awaitRpc(proc, 100, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: fallbackFile } },
      });

      expect(indexResp?.error).toBeUndefined();
      const indexText = indexResp?.result?.content?.[0]?.text ?? "";
      expect(indexText).toMatch(/Indexed \d+ section/);

      const searchResp = await awaitRpc(proc, 101, {
        jsonrpc: "2.0", id: 101, method: "tools/call",
        params: { name: "ctx_search", arguments: { queries: [fallbackMarker] } },
      });
      expect(searchResp?.error).toBeUndefined();
      const searchText = searchResp?.result?.content?.[0]?.text ?? "";
      expect(searchText).toContain(fallbackMarker);
    } finally {
      try { proc.kill("SIGTERM"); } catch { /* best effort */ }
      rmSync(fallbackCwd, { recursive: true, force: true });
    }
  }, 30_000);

  test("relative path label canonicalizes to resolved absolute path", async () => {
    const proc = spawnServerWithProjectDir(ctxProjectDir);
    try {
      await awaitRpc(proc, 1, {
        jsonrpc: "2.0", id: 1, method: "initialize",
        params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ctx-index-pr365-label", version: "1.0" } },
      });
      sendRpc(proc, { jsonrpc: "2.0", method: "notifications/initialized" });

      const indexResp = await awaitRpc(proc, 100, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: ctxFileName } },
      });

      expect(indexResp?.error).toBeUndefined();
      const indexText = indexResp?.result?.content?.[0]?.text ?? "";
      // Label must canonicalize to the resolved absolute path so the same file
      // indexed via './foo.md', 'foo.md', and 'subdir/../foo.md' produces a
      // single FTS5 row (sources.label is the dedup key).
      const expectedAbs = join(ctxProjectDir, ctxFileName);
      expect(indexText).toContain(`from: ${expectedAbs}`);
    } finally {
      try { proc.kill("SIGTERM"); } catch { /* best effort */ }
    }
  }, 30_000);

  // ── JetBrains regression: IDEA_INITIAL_DIRECTORY must enter the cascade ──
  //
  // JetBrains adapter sets only IDEA_INITIAL_DIRECTORY (no CLAUDE_PROJECT_DIR,
  // no QUIET_CONTEXT_PROJECT_DIR). Before the fix, getProjectDir() ignored that
  // var and fell through to process.cwd(), which is the IDE bin dir on
  // JetBrains — making `ctx_index({ path: "rel/foo.md" })` resolve to a path
  // under the IDE installation and ENOENT.
  //
  // Spawn the compiled server directly (build/server.js) instead of start.mjs
  // so we never enter the start.mjs path that auto-populates CLAUDE_PROJECT_DIR
  // and QUIET_CONTEXT_PROJECT_DIR from cwd. This lets us isolate the cascade
  // and prove that IDEA_INITIAL_DIRECTORY alone is enough to resolve relative
  // paths under the JetBrains project root.
  test("relative path resolves against IDEA_INITIAL_DIRECTORY (JetBrains)", async () => {
    const buildEntry = resolve(__dirname, "..", "..", "build", "server.js");
    if (!existsSync(buildEntry)) {
      // Compile src → build/ on demand. Bundle is untouched (CI rebuilds it).
      execSync("npx tsc --silent", {
        cwd: resolve(__dirname, "..", ".."),
        stdio: "pipe",
        timeout: 60_000,
      });
    }

    // Simulate JetBrains: cwd is an IDE-bin-like dir (NOT the project),
    // env carries only IDEA_INITIAL_DIRECTORY pointing at the real project.
    const fakeIdeBin = mkdtempSync(join(tmpdir(), "ctx-jetbrains-bin-"));

    // Strip every PROJECT_DIR env var from the inherited env so the cascade
    // is forced to consult IDEA_INITIAL_DIRECTORY. Issue #545 (v1.0.124):
    // also strip the claude-code IDENTIFICATION vars so detectPlatform()
    // doesn't misclassify the spawned MCP child as claude-code (which would
    // then run strict-mode and ban IDEA_INITIAL_DIRECTORY as a foreign var).
    // The test process inherits CLAUDE_CODE_ENTRYPOINT / CLAUDE_PLUGIN_ROOT
    // from whatever Claude Code session launched the test runner.
    const cleanEnv = { ...process.env };
    delete cleanEnv.CLAUDE_PROJECT_DIR;
    delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
    delete cleanEnv.CLAUDE_PLUGIN_ROOT;
    delete cleanEnv.CLAUDE_SESSION_ID;
    delete cleanEnv.GEMINI_PROJECT_DIR;
    delete cleanEnv.VSCODE_CWD;
    delete cleanEnv.OPENCODE_PROJECT_DIR;
    delete cleanEnv.PI_PROJECT_DIR;
    delete cleanEnv.PI_WORKSPACE_DIR;
    delete cleanEnv.QUIET_CONTEXT_PROJECT_DIR;

    const proc = spawn("node", [buildEntry], {
      stdio: ["pipe", "pipe", "pipe"],
      cwd: fakeIdeBin,
      env: {
        ...cleanEnv,
        QUIET_CONTEXT_DISABLE_VERSION_CHECK: "1",
        IDEA_INITIAL_DIRECTORY: ctxProjectDir,
      },
    });

    try {
      await awaitRpc(proc, 1, {
        jsonrpc: "2.0", id: 1, method: "initialize",
        params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ctx-index-jetbrains", version: "1.0" } },
      });
      sendRpc(proc, { jsonrpc: "2.0", method: "notifications/initialized" });

      const indexResp = await awaitRpc(proc, 100, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: ctxFileName } },
      });

      expect(indexResp?.error).toBeUndefined();
      const indexText = indexResp?.result?.content?.[0]?.text ?? "";
      // Must succeed — proves the relative path resolved under
      // IDEA_INITIAL_DIRECTORY (not the fake IDE-bin cwd).
      expect(indexText).toMatch(/Indexed \d+ section/);
      expect(indexText).not.toMatch(/Index error/);

      // Round-trip via search using the unique marker only present in the
      // file under IDEA_INITIAL_DIRECTORY — proves the right file was read.
      const searchResp = await awaitRpc(proc, 101, {
        jsonrpc: "2.0", id: 101, method: "tools/call",
        params: { name: "ctx_search", arguments: { queries: [uniqueMarker] } },
      });
      expect(searchResp?.error).toBeUndefined();
      const searchText = searchResp?.result?.content?.[0]?.text ?? "";
      expect(searchText).toContain(uniqueMarker);
    } finally {
      try { proc.kill("SIGTERM"); } catch { /* best effort */ }
      try { rmSync(fakeIdeBin, { recursive: true, force: true }); } catch { /* best effort */ }
    }
  }, 60_000);

  // Source-label dedup regression: when no explicit `source` is supplied,
  // ctx_index must default the FTS5 label to the *resolved* absolute path so
  // that the same file indexed via './foo.md', 'foo.md', or 'subdir/../foo.md'
  // collapses into a single row (sources.label is the dedup key).
  //
  // This pins behavior at two layers:
  //   1. The store-level dedup contract: identical labels overwrite, distinct
  //      labels produce distinct rows.
  //   2. The ctx_index source-resolution decision lives in src/server.ts and
  //      must read `source ?? resolvedPath`, NOT `source ?? path` (which would
  //      preserve raw user-typed input and break dedup).
  test("source-label dedup: identical labels collapse, raw user-typed paths would not", () => {
    const store = new ContentStore(":memory:");
    const dir = mkdtempSync(join(tmpdir(), "source-label-dedup-"));
    const file = "foo.md";
    const abs = join(dir, file);
    const marker = `dedup-marker-${process.pid}-${Date.now()}`;
    writeFileSync(abs, `# foo\n\nUnique marker: ${marker}\n`, "utf-8");

    try {
      // Post-fix simulation: server canonicalizes both spellings to `abs`
      // before calling store.index → single label → single row.
      store.index({ content: readFileSync(abs, "utf-8"), path: abs, source: abs });
      store.index({ content: readFileSync(abs, "utf-8"), path: abs, source: abs });

      const dedupResults = store.search(marker, 10);
      expect(dedupResults.length).toBe(1);
      expect(dedupResults[0].source).toBe(abs);
    } finally {
      store.close();
      rmSync(dir, { recursive: true, force: true });
    }
  });

  // Server-side guard: the source-label fallback must canonicalize to
  // resolvedPath, not to the raw user-typed `path`. Validates the actual
  // src/server.ts decision so a regression to `source ?? path` fails CI even
  // before bundle rebuild and end-to-end spawn coverage.
  test("source-label canonicalization: src/server.ts uses `source ?? resolvedPath`", () => {
    const serverSrc = readFileSync(
      resolve(__dirname, "../../src/server.ts"),
      "utf-8",
    );
    // Locate the ctx_index store.index call site and assert canonical fallback.
    const indexCall = serverSrc.match(
      /store\.index\(\{[^}]*source:\s*source\s*\?\?\s*(\w+)/,
    );
    expect(indexCall).not.toBeNull();
    expect(indexCall![1]).toBe("resolvedPath");
    // Negative guard: no place in ctx_index falls back to raw `path`.
    expect(serverSrc).not.toMatch(/store\.index\(\{[^}]*source:\s*source\s*\?\?\s*path[\s,}]/);
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// ctx_index: Read deny-policy enforcement (#442)
// ═══════════════════════════════════════════════════════════════════════════
//
// Real-MCP integration test: ctx_execute_file calls checkFilePathDenyPolicy
// before reading the file, but ctx_index historically skipped the check —
// any file readable by the MCP server process could be indexed into FTS5
// and exfiltrated through ctx_search. This pins the fix end-to-end via
// JSON-RPC against a freshly spawned server with .claude/settings.json
// containing a Read deny pattern matching the target file.

describe("ctx_index: Read deny-policy enforcement (#442)", () => {
  // Per-test projectDir prevents FTS5 cross-pollution between tests and
  // removes order-coupling on the empty-store cross-check.
  function setupProject(
    denyRules: string[],
    files: Record<string, string>,
  ): string {
    const dir = mkdtempSync(join(tmpdir(), "ctx-index-deny-"));
    mkdirSync(join(dir, ".claude"), { recursive: true });
    writeFileSync(
      join(dir, ".claude", "settings.json"),
      JSON.stringify({ permissions: { deny: denyRules } }),
      "utf-8",
    );
    for (const [rel, content] of Object.entries(files)) {
      const full = join(dir, rel);
      mkdirSync(dirname(full), { recursive: true });
      writeFileSync(full, content, "utf-8");
    }
    return dir;
  }

  function spawnServerInProject(
    projectDir: string,
    extraEnv: Record<string, string> = {},
  ): ChildProcess {
    return spawn("node", [mcpEntry], {
      stdio: ["pipe", "pipe", "pipe"],
      env: {
        ...process.env,
        QUIET_CONTEXT_DISABLE_VERSION_CHECK: "1",
        CLAUDE_PROJECT_DIR: projectDir,
        ...extraEnv,
      },
    });
  }

  // Rejects on timeout (rather than resolving undefined) so silent
  // server-spawn / import failures surface as a failing test instead of
  // false-positive assertions on optional-chained undefined access.
  async function awaitRpc(
    proc: ChildProcess,
    request: Record<string, unknown> & { id: number },
    timeoutMs = 15_000,
  ): Promise<DoctorJsonRpcResponse> {
    const id = request.id;
    return new Promise((resolve, reject) => {
      let buffer = "";
      let stderr = "";
      const onStderr = (d: Buffer) => { stderr += d.toString(); };
      const onData = (d: Buffer) => {
        buffer += d.toString();
        let idx: number;
        while ((idx = buffer.indexOf("\n")) >= 0) {
          const line = buffer.slice(0, idx).trim();
          buffer = buffer.slice(idx + 1);
          if (!line) continue;
          try {
            const parsed = JSON.parse(line) as DoctorJsonRpcResponse;
            if (parsed.id === id) {
              proc.stdout!.off("data", onData);
              proc.stderr!.off("data", onStderr);
              clearTimeout(timer);
              resolve(parsed);
              return;
            }
          } catch { /* not JSON-RPC line */ }
        }
      };
      const timer = setTimeout(() => {
        proc.stdout!.off("data", onData);
        proc.stderr!.off("data", onStderr);
        reject(new Error(
          `awaitRpc timeout after ${timeoutMs}ms for id=${id} method=${request.method}\n` +
          `stderr: ${stderr.slice(-2000)}`,
        ));
      }, timeoutMs);
      proc.stdout!.on("data", onData);
      proc.stderr!.on("data", onStderr);
      sendRpc(proc, request);
    });
  }

  async function initServer(proc: ChildProcess, clientName: string): Promise<void> {
    await awaitRpc(proc, {
      jsonrpc: "2.0", id: 1, method: "initialize",
      params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: clientName, version: "1.0" } },
    });
    sendRpc(proc, { jsonrpc: "2.0", method: "notifications/initialized" });
  }

  function killProc(proc: ChildProcess): void {
    try { proc.kill("SIGTERM"); } catch { /* best effort */ }
  }

  test("ctx_index({ path: <denied> }) returns deny-policy error and never indexes", async () => {
    const secretMarker = `secret-marker-${process.pid}-${Date.now()}`;
    const projectDir = setupProject(
      ["Read(./secret.env)", "Read(secret.env)"],
      { "secret.env": `SECRET_TOKEN=${secretMarker}\n` },
    );
    const proc = spawnServerInProject(projectDir);
    try {
      await initServer(proc, "ctx-index-deny-442");

      const indexResp = await awaitRpc(proc, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: "secret.env" } },
      });

      expect(indexResp.error).toBeUndefined();
      expect(indexResp.result?.isError).toBe(true);
      const indexText = indexResp.result?.content?.[0]?.text ?? "";
      expect(indexText).toContain("blocked by security policy");
      expect(indexText).toContain("Read deny pattern");
      // Pin the matched pattern so a future bug firing the wrong rule
      // cannot pass on the generic substring alone.
      expect(indexText).toMatch(/Read deny pattern \.?\/?secret\.env/);
      expect(indexText).not.toMatch(/Indexed \d+ section/);

      // Per-test projectDir guarantees an empty FTS5 store, so the secret
      // marker absence is the load-bearing exfil-prevention pin.
      const searchResp = await awaitRpc(proc, {
        jsonrpc: "2.0", id: 101, method: "tools/call",
        params: { name: "ctx_search", arguments: { queries: [secretMarker] } },
      });
      expect(searchResp.error).toBeUndefined();
      const searchText = searchResp.result?.content?.[0]?.text ?? "";
      const searchedEmpty =
        searchText.includes("No results found") ||
        searchText.includes("After indexing");
      expect(searchedEmpty).toBe(true);
    } finally {
      killProc(proc);
      rmSync(projectDir, { recursive: true, force: true });
    }
  }, 30_000);

  test("ctx_index({ path: <denied via glob *.env> }) blocks", async () => {
    const projectDir = setupProject(
      ["Read(*.env)"],
      { "secret.env": `SECRET_TOKEN=glob-${process.pid}\n` },
    );
    const proc = spawnServerInProject(projectDir);
    try {
      await initServer(proc, "ctx-index-deny-glob-442");
      const indexResp = await awaitRpc(proc, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: "secret.env" } },
      });
      expect(indexResp.result?.isError).toBe(true);
      const text = indexResp.result?.content?.[0]?.text ?? "";
      expect(text).toContain("blocked by security policy");
      expect(text).toMatch(/Read deny pattern .*\*\.env/);
    } finally {
      killProc(proc);
      rmSync(projectDir, { recursive: true, force: true });
    }
  }, 30_000);

  test("ctx_index({ path: <absolute denied> }) blocks", async () => {
    const projectDir = setupProject(
      [], // populated below after we know absolute path
      { "secret.env": `SECRET_TOKEN=abs-${process.pid}\n` },
    );
    const absSecret = join(projectDir, "secret.env");
    // Rewrite settings.json with the absolute deny rule.
    writeFileSync(
      join(projectDir, ".claude", "settings.json"),
      JSON.stringify({ permissions: { deny: [`Read(${absSecret})`] } }),
      "utf-8",
    );
    const proc = spawnServerInProject(projectDir);
    try {
      await initServer(proc, "ctx-index-deny-abs-442");
      const indexResp = await awaitRpc(proc, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: absSecret } },
      });
      expect(indexResp.result?.isError).toBe(true);
      const text = indexResp.result?.content?.[0]?.text ?? "";
      expect(text).toContain("blocked by security policy");
    } finally {
      killProc(proc);
      rmSync(projectDir, { recursive: true, force: true });
    }
  }, 30_000);

  test("ctx_index({ path: <denied via ../traversal> }) blocks", async () => {
    // Use a glob deny rule that matches the canonical (lexical/realpath)
    // form — proves evaluateFilePath's canonicalization defeats the
    // `sub/../secret.env` traversal trick.
    const projectDir = setupProject(
      ["Read(**/secret.env)"],
      {
        "secret.env": `SECRET_TOKEN=trav-${process.pid}\n`,
        "sub/placeholder.md": "# placeholder\n",
      },
    );
    const proc = spawnServerInProject(projectDir);
    try {
      await initServer(proc, "ctx-index-deny-trav-442");
      const indexResp = await awaitRpc(proc, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: "sub/../secret.env" } },
      });
      expect(indexResp.result?.isError).toBe(true);
      const text = indexResp.result?.content?.[0]?.text ?? "";
      expect(text).toContain("blocked by security policy");
    } finally {
      killProc(proc);
      rmSync(projectDir, { recursive: true, force: true });
    }
  }, 30_000);

  test("ctx_index({ path: <allowed> }) succeeds and is searchable", async () => {
    const allowedMarker = `allowed-marker-${process.pid}-${Date.now()}`;
    const projectDir = setupProject(
      ["Read(./secret.env)", "Read(secret.env)"],
      { "public-doc.md": `# Public doc\n\n${allowedMarker}\n` },
    );
    const proc = spawnServerInProject(projectDir);
    try {
      await initServer(proc, "ctx-index-allow-442");

      const indexResp = await awaitRpc(proc, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: "public-doc.md" } },
      });
      expect(indexResp.error).toBeUndefined();
      expect(indexResp.result?.isError).toBeFalsy();
      const indexText = indexResp.result?.content?.[0]?.text ?? "";
      expect(indexText).toMatch(/Indexed \d+ section/);

      const searchResp = await awaitRpc(proc, {
        jsonrpc: "2.0", id: 101, method: "tools/call",
        params: { name: "ctx_search", arguments: { queries: [allowedMarker] } },
      });
      expect(searchResp.error).toBeUndefined();
      expect(searchResp.result?.content?.[0]?.text ?? "").toContain(allowedMarker);
    } finally {
      killProc(proc);
      rmSync(projectDir, { recursive: true, force: true });
    }
  }, 30_000);

  test("ctx_index({ content: ... }) bypass branch — gate truly tied to `path`", async () => {
    // Configure a deny rule that WOULD match the inline `source` value if
    // the gate naively ran on it. Success here proves the bypass is keyed
    // on `path` absence, not on `source`.
    const projectDir = setupProject(
      ["Read(test-inline)", "Read(./test-inline)"],
      {},
    );
    const proc = spawnServerInProject(projectDir);
    try {
      await initServer(proc, "ctx-index-content-442");
      const inlineMarker = `inline-marker-${process.pid}-${Date.now()}`;
      const indexResp = await awaitRpc(proc, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: {
          name: "ctx_index",
          arguments: {
            content: `# Inline\n\n${inlineMarker}\n`,
            source: "test-inline",
          },
        },
      });
      expect(indexResp.error).toBeUndefined();
      expect(indexResp.result?.isError).toBeFalsy();
      expect(indexResp.result?.content?.[0]?.text ?? "").toMatch(/Indexed \d+ section/);
    } finally {
      killProc(proc);
      rmSync(projectDir, { recursive: true, force: true });
    }
  }, 30_000);

  test("ctx_index returns an actionable storage error when QUIET_CONTEXT_DIR is unwritable", async () => {
    if (process.platform === "win32") return;

    const projectDir = setupProject([], {
      "public-doc.md": "storage failure contract",
    });
    const storageRoot = mkdtempSync(join(tmpdir(), "ctx-storage-deny-"));
    chmodSync(storageRoot, 0o500);
    const proc = spawnServerInProject(projectDir, {
      QUIET_CONTEXT_DIR: storageRoot,
    });

    try {
      await initServer(proc, "ctx-index-storage-error");

      const indexResp = await awaitRpc(proc, {
        jsonrpc: "2.0",
        id: 103,
        method: "tools/call",
        params: { name: "ctx_index", arguments: { path: "public-doc.md" } },
      });

      expect(indexResp.error).toBeUndefined();
      expect(indexResp.result?.isError).toBe(true);
      expect(indexResp.result?.content?.[0]?.text).toContain(
        "context-mode content directory is not writable:",
      );
      expect(indexResp.result?.content?.[0]?.text).toContain(
        "Set QUIET_CONTEXT_DIR to a writable absolute path.",
      );
    } finally {
      killProc(proc);
      chmodSync(storageRoot, 0o700);
      rmSync(storageRoot, { recursive: true, force: true });
      rmSync(projectDir, { recursive: true, force: true });
    }
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// ctx_insight: execFile migration + port schema hardening (#441)
// ═══════════════════════════════════════════════════════════════════════════
//
// Three layers of regression protection:
//
//   1. Coarse source guard — `src/server.ts` must not reintroduce the
//      `execSync(\`…${…}…\`)` template-string injection pattern anywhere.
//      Behavioral coverage of the helpers themselves lives below in this
//      same file (mocked spawnSync, asserts argv arrays + no shell:true +
//      Windows LISTENING anchoring + per-pid failure isolation).
//
//   2. Cross-reference — server.ts must define the structured helpers
//      (openBrowserSync / killProcessOnPort) inline so the handler can
//      surface auto-open / kill failures to the agent rather than silently
//      reporting success.
//
//   3. Real-MCP integration — spawn the server via stdio JSON-RPC and
//      verify the tightened port schema rejects out-of-range and
//      non-integer values BEFORE the handler runs.

describe("ctx_insight: execFile migration source guard (#441)", () => {
  const serverSrc = readFileSync(
    resolve(__dirname, "../../src/server.ts"),
    "utf-8",
  );

  test("server.ts contains no execSync template-string interpolation anywhere", () => {
    // Match: execSync(`...${...}...`) — the original injection pattern.
    // Scoped to the entire file (not just the ctx_insight handler) because
    // any reintroduction of the pattern in server.ts is a regression worth
    // catching.
    const templateInterpolation = /execSync\(`[^`]*\$\{/m;
    expect(serverSrc).not.toMatch(templateInterpolation);
  });

  test("server.ts defines structured helpers (openBrowserSync, killProcessOnPort)", () => {
    // The helpers must be the ones that return BrowserOpenResult / KillResult
    // so the ctx_insight handler can surface failures. They live inline in
    // server.ts (PR #452 folded the prior src/process-utils.ts back in).
    expect(serverSrc).toMatch(/export function openBrowserSync\b/);
    expect(serverSrc).toMatch(/export function killProcessOnPort\b/);
    expect(serverSrc).toMatch(/export type BrowserOpenResult\b/);
    expect(serverSrc).toMatch(/export type KillResult\b/);
  });

  test("port schema is bounded to a valid TCP port range", () => {
    const portDecl = serverSrc.match(/port:\s*z\.coerce\.number\(\)([^,\n]*)\.optional\(\)/);
    expect(portDecl).not.toBeNull();
    const constraints = portDecl![1];
    expect(constraints).toContain(".int()");
    expect(constraints).toContain(".min(1)");
    expect(constraints).toContain(".max(65535)");
  });
});

describe("ctx_insight: port schema rejects invalid values (#441)", () => {
  function spawnInsightServer(): ChildProcess {
    return spawn("node", [mcpEntry], {
      stdio: ["pipe", "pipe", "pipe"],
      env: { ...process.env, QUIET_CONTEXT_DISABLE_VERSION_CHECK: "1" },
    });
  }

  async function awaitRpc(
    proc: ChildProcess,
    id: number,
    request: Record<string, unknown>,
    timeoutMs = 10_000,
  ): Promise<DoctorJsonRpcResponse | undefined> {
    return new Promise((resolve) => {
      let buffer = "";
      const onData = (d: Buffer) => {
        buffer += d.toString();
        let idx: number;
        while ((idx = buffer.indexOf("\n")) >= 0) {
          const line = buffer.slice(0, idx).trim();
          buffer = buffer.slice(idx + 1);
          if (!line) continue;
          try {
            const parsed = JSON.parse(line) as DoctorJsonRpcResponse;
            if (parsed.id === id) {
              proc.stdout!.off("data", onData);
              clearTimeout(timer);
              resolve(parsed);
              return;
            }
          } catch { /* ignore */ }
        }
      };
      const timer = setTimeout(() => {
        proc.stdout!.off("data", onData);
        resolve(undefined);
      }, timeoutMs);
      proc.stdout!.on("data", onData);
      sendRpc(proc, request);
    });
  }

  // Each invalid value is exercised against a fresh server so a schema
  // failure on one input cannot mask a regression on another.
  const invalidPortCases: Array<{ label: string; port: unknown }> = [
    { label: "zero (below min)", port: 0 },
    { label: "negative (below min)", port: -1 },
    { label: "above 16-bit range", port: 65536 },
    { label: "non-integer", port: 3.14 },
    { label: "non-numeric string", port: "not-a-port" },
  ];

  for (const { label, port } of invalidPortCases) {
    test(`rejects port=${JSON.stringify(port)} (${label}) at schema layer`, async () => {
      const proc = spawnInsightServer();
      try {
        const init = await awaitRpc(proc, 1, {
          jsonrpc: "2.0", id: 1, method: "initialize",
          params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ctx-insight-441", version: "1.0" } },
        });
        // Init must succeed before tools/call — otherwise an unrelated startup
        // failure could masquerade as schema rejection.
        expect(init?.result).toBeDefined();
        sendRpc(proc, { jsonrpc: "2.0", method: "notifications/initialized" });

        const resp = await awaitRpc(proc, 100, {
          jsonrpc: "2.0", id: 100, method: "tools/call",
          params: { name: "ctx_insight", arguments: { port } },
        });

        // Schema rejection surfaces as either a JSON-RPC `error` envelope or
        // a tool result with `isError: true`. Both shapes count as "the
        // handler never executed" — the contract this test pins.
        const rejected =
          (resp?.error !== undefined) ||
          (resp?.result?.isError === true);
        expect(rejected).toBe(true);

        // Cross-check: no "Dashboard running" success text leaked through.
        const text = resp?.result?.content?.[0]?.text ?? "";
        expect(text).not.toMatch(/Dashboard running at/);
      } finally {
        try { proc.kill("SIGTERM"); } catch { /* best effort */ }
      }
    }, 20_000);
  }

  // Pin the schema layer specifically: at least one case must surface as a
  // JSON-RPC `error` with code -32602 (Invalid params), proving zod rejected
  // the input before the handler ran.  A regression that loosened the schema
  // back to `z.coerce.number().optional()` and let the handler crash on
  // `port=0` would still satisfy the lenient `isError === true` checks above
  // — but it would not produce a -32602 envelope here.
  test("schema layer rejects out-of-range port with JSON-RPC -32602", async () => {
    const proc = spawnInsightServer();
    try {
      const init = await awaitRpc(proc, 1, {
        jsonrpc: "2.0", id: 1, method: "initialize",
        params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ctx-insight-441-schema", version: "1.0" } },
      });
      expect(init?.result).toBeDefined();
      sendRpc(proc, { jsonrpc: "2.0", method: "notifications/initialized" });

      const resp = await awaitRpc(proc, 200, {
        jsonrpc: "2.0", id: 200, method: "tools/call",
        params: { name: "ctx_insight", arguments: { port: 70000 } },
      });

      // Either a top-level error with code -32602 or an isError result whose
      // text mentions schema/range-validation language. Both prove zod fired.
      const errCode = resp?.error?.code;
      const text = resp?.result?.content?.[0]?.text ?? "";
      const schemaSignal =
        errCode === -32602 ||
        /(less than or equal|65535|invalid|too_big|expected number)/i.test(text);
      expect(schemaSignal).toBe(true);
    } finally {
      try { proc.kill("SIGTERM"); } catch { /* best effort */ }
    }
  }, 20_000);
});

// ═══════════════════════════════════════════════════════════════════════════
// ctx_execute_file: projectRoot env cascade parity with ctx_index
// ═══════════════════════════════════════════════════════════════════════════
//
// Regression for PR #365 follow-up. ctx_index was routed through the
// `getProjectDir()` env cascade (CLAUDE_PROJECT_DIR → ... → QUIET_CONTEXT_PROJECT_DIR
// → cwd) but the PolyglotExecutor still captured CLAUDE_PROJECT_DIR ?? cwd
// at construction time. ctx_execute_file therefore resolved the same
// relative path differently from ctx_index whenever only
// QUIET_CONTEXT_PROJECT_DIR was set (e.g. Cursor / OpenClaw / Codex spawns).
// Fix: executor now resolves projectRoot lazily via the server's getProjectDir.

describe("ctx_execute_file: QUIET_CONTEXT_PROJECT_DIR env cascade", () => {
  const execProjectDir = mkdtempSync(join(tmpdir(), "ctx-exec-projroot-"));
  const execScriptDir = join(execProjectDir, "rel");
  const execScriptName = "script.js";
  const execMarker = `ctx-exec-marker-${process.pid}-${Date.now()}`;

  // Spawn build/server.js directly to bypass start.mjs's auto-set of
  // CLAUDE_PROJECT_DIR = process.cwd(). That auto-set would defeat the
  // test by injecting a CLAUDE_PROJECT_DIR before getProjectDir() can
  // fall through to QUIET_CONTEXT_PROJECT_DIR.
  const buildServerEntry = resolve(__dirname, "..", "..", "build", "server.js");

  beforeAll(() => {
    mkdirSync(execScriptDir, { recursive: true });
    writeFileSync(
      join(execScriptDir, execScriptName),
      `console.log(${JSON.stringify(execMarker)});\n`,
      "utf-8",
    );
  });

  afterAll(() => {
    rmSync(execProjectDir, { recursive: true, force: true });
  });

  function spawnServerCtxModeOnly(projectDirEnv: string): ChildProcess {
    // Strip every CLAUDE_*-style projectDir signal so the executor MUST
    // fall back through the env cascade to QUIET_CONTEXT_PROJECT_DIR.
    const env = { ...process.env, QUIET_CONTEXT_DISABLE_VERSION_CHECK: "1" };
    delete env.CLAUDE_PROJECT_DIR;
    delete env.GEMINI_PROJECT_DIR;
    delete env.VSCODE_CWD;
    delete env.OPENCODE_PROJECT_DIR;
    delete env.PI_PROJECT_DIR;
    delete env.IDEA_INITIAL_DIRECTORY;
    env.QUIET_CONTEXT_PROJECT_DIR = projectDirEnv;
    return spawn("node", [buildServerEntry], {
      stdio: ["pipe", "pipe", "pipe"],
      env,
      // cwd far from execProjectDir so a stale `process.cwd()` snapshot
      // would resolve `rel/script.js` to a non-existent path.
      cwd: tmpdir(),
    });
  }

  async function awaitRpc(
    proc: ChildProcess,
    id: number,
    request: Record<string, unknown>,
    timeoutMs = 15_000,
  ): Promise<DoctorJsonRpcResponse | undefined> {
    return new Promise((res) => {
      let buffer = "";
      const onData = (d: Buffer) => {
        buffer += d.toString();
        let idx: number;
        while ((idx = buffer.indexOf("\n")) >= 0) {
          const line = buffer.slice(0, idx).trim();
          buffer = buffer.slice(idx + 1);
          if (!line) continue;
          try {
            const parsed = JSON.parse(line) as DoctorJsonRpcResponse;
            if (parsed.id === id) {
              proc.stdout!.off("data", onData);
              clearTimeout(timer);
              res(parsed);
              return;
            }
          } catch { /* ignore */ }
        }
      };
      const timer = setTimeout(() => {
        proc.stdout!.off("data", onData);
        res(undefined);
      }, timeoutMs);
      proc.stdout!.on("data", onData);
      sendRpc(proc, request);
    });
  }

  test("relative path resolves against QUIET_CONTEXT_PROJECT_DIR when CLAUDE_PROJECT_DIR is unset", async () => {
    const proc = spawnServerCtxModeOnly(execProjectDir);
    try {
      await awaitRpc(proc, 1, {
        jsonrpc: "2.0", id: 1, method: "initialize",
        params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ctx-exec-cm-projdir", version: "1.0" } },
      });
      sendRpc(proc, { jsonrpc: "2.0", method: "notifications/initialized" });

      const execResp = await awaitRpc(proc, 200, {
        jsonrpc: "2.0", id: 200, method: "tools/call",
        params: {
          name: "ctx_execute_file",
          arguments: {
            path: `rel/${execScriptName}`,
            language: "javascript",
            code: "eval(FILE_CONTENT);",
          },
        },
      });

      expect(execResp?.error).toBeUndefined();
      expect(execResp?.result?.isError ?? false).toBe(false);
      const text = execResp?.result?.content?.[0]?.text ?? "";
      expect(text).toContain(execMarker);
    } finally {
      try { proc.kill("SIGTERM"); } catch { /* best effort */ }
    }
  }, 30_000);
});

// ═══════════════════════════════════════════════════════════════════════════
// 5. Subagent Output Budget (subagent-budget)
// ═══════════════════════════════════════════════════════════════════════════

const HOOK_PATH = join(__dirname, "..", "..", "hooks", "pretooluse.mjs");
const LIVE = process.argv.includes("--live");

/**
 * TypeScript mock of hooks/pretooluse.mjs routing logic.
 * Replicates Task branch behavior without bash/jq dependency.
 */
function runHook(input: Record<string, unknown>): string {
  const toolName = (input as any).tool_name ?? "";
  const toolInput = (input as any).tool_input ?? {};

  if (toolName === "Task") {
    const subagentType = toolInput.subagent_type ?? "";
    const prompt = toolInput.prompt ?? "";

    if (subagentType === "Bash") {
      return JSON.stringify({
        hookSpecificOutput: {
          hookEventName: "PreToolUse",
          updatedInput: {
            ...toolInput,
            prompt: prompt + ROUTING_BLOCK,
            subagent_type: "general-purpose",
          },
        },
      });
    }

    return JSON.stringify({
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        updatedInput: {
          ...toolInput,
          prompt: prompt + ROUTING_BLOCK,
        },
      },
    });
  }

  // Non-Task tools return empty (passthrough)
  return "";
}

describe("Hook Injection", () => {
  test("Task hook injects context_window_protection XML block", () => {
    const output = runHook({
      tool_name: "Task",
      tool_input: { prompt: "Research zod npm package", subagent_type: "general-purpose" },
    });
    const parsed = JSON.parse(output);
    const prompt = parsed.hookSpecificOutput.updatedInput.prompt;
    assert.ok(
      prompt.includes("<context_window_protection>"),
      "Should inject context_window_protection opening tag",
    );
    assert.ok(
      prompt.includes("</context_window_protection>"),
      "Should inject context_window_protection closing tag",
    );
  });

  test("Task hook injects output constraints and tool hierarchy", () => {
    const output = runHook({
      tool_name: "Task",
      tool_input: { prompt: "Research zod", subagent_type: "general-purpose" },
    });
    const parsed = JSON.parse(output);
    const prompt = parsed.hookSpecificOutput.updatedInput.prompt;
    assert.ok(prompt.includes("<output_constraints>"), "Should inject output_constraints");
    // Pillar 4 (caveman/Output Compression) retired in #482. Routing block
    // must NOT push a prose-style directive — assert the negative.
    assert.ok(
      !prompt.toLowerCase().includes("terse like caveman"),
      "Routing block must not contain caveman/terse style directive",
    );
    assert.ok(
      !prompt.includes("<communication_style>"),
      "Routing block must not contain communication_style block",
    );
    assert.ok(
      prompt.includes("<tool_selection_hierarchy>"),
      "Should inject tool_selection_hierarchy",
    );
    // PR #683 follow-up (ADR-0002 + ADR-0003 hook prompt-surface contract):
    // <forbidden_actions> renamed to <when_not_to_use> to drop the
    // Constitutional-AI-trigger container name (rubric #9). Semantic intent
    // — "Bash, Read, WebFetch, ctx_execute have wrong-tool selection cues
    // injected into every session" — is preserved and asserted here.
    assert.ok(
      prompt.includes("<when_not_to_use>"),
      "Should inject when_not_to_use (the affirmative successor to <forbidden_actions>, ADR-0002)",
    );
  });

  test("Task hook injects batch_execute as primary tool", () => {
    const output = runHook({
      tool_name: "Task",
      tool_input: { prompt: "Analyze repo", subagent_type: "Explore" },
    });
    const parsed = JSON.parse(output);
    const prompt = parsed.hookSpecificOutput.updatedInput.prompt;
    assert.ok(
      prompt.includes("batch_execute"),
      "Should mention batch_execute as primary tool",
    );
  });

  test("Task hook upgrades Bash subagent to general-purpose", () => {
    const output = runHook({
      tool_name: "Task",
      tool_input: { prompt: "Run git log", subagent_type: "Bash" },
    });
    const parsed = JSON.parse(output);
    const updated = parsed.hookSpecificOutput.updatedInput;
    assert.equal(
      updated.subagent_type,
      "general-purpose",
      "Bash should be upgraded to general-purpose",
    );
    assert.ok(
      updated.prompt.includes("<context_window_protection>"),
      "Upgraded subagent should also get context_window_protection",
    );
  });

  test("Task hook preserves original prompt content", () => {
    const original = "Research the architecture of Next.js App Router";
    const output = runHook({
      tool_name: "Task",
      tool_input: { prompt: original, subagent_type: "general-purpose" },
    });
    const parsed = JSON.parse(output);
    const prompt = parsed.hookSpecificOutput.updatedInput.prompt;
    assert.ok(
      prompt.startsWith(original),
      "Original prompt should be preserved at the start",
    );
  });

  test("Non-Task tools are not affected by output budget", () => {
    const output = runHook({
      tool_name: "Bash",
      tool_input: { command: "ls -la" },
    });
    // Bash hook returns empty or redirect, never OUTPUT FORMAT
    assert.ok(
      !output.includes("OUTPUT FORMAT"),
      "Bash tool should not get output format injection",
    );
  });
});

describe("Shared Knowledge Base (subagent -> main)", () => {
  test("subagent index() is visible to main agent search()", () => {
    // Same ContentStore instance = same as shared MCP server process
    const store = new ContentStore(":memory:");

    // Simulate subagent indexing its research
    store.index({
      content: [
        "# Zod Overview",
        "TypeScript-first schema validation library.",
        "Zero dependencies, 98M weekly downloads.",
        "",
        "# API Reference",
        "z.string(), z.number(), z.object() are the core primitives.",
        "Use .parse() for runtime validation with type inference.",
        "",
        "# Recent Changes",
        "v4.3.6: Performance improvements to object parsing.",
        "v4.3.5: Fixed discriminated union edge case.",
      ].join("\n"),
      source: "subagent:zod-research",
    });

    // Simulate main agent searching subagent's indexed content
    const results = store.search("weekly downloads", 1, "zod-research");
    assert.ok(results.length > 0, "Main should find subagent's indexed content");
    assert.ok(
      results[0].content.includes("98M"),
      "Should retrieve exact data from subagent's index",
    );

    const apiResults = store.search("parse validation", 1, "zod-research");
    assert.ok(apiResults.length > 0, "Main should find API details");
    assert.ok(apiResults[0].content.includes(".parse()"), "Should find .parse() reference");

    store.close();
  });

  test("multiple subagents index into same KB with distinct sources", () => {
    const store = new ContentStore(":memory:");

    // Subagent A indexes architecture research
    store.index({
      content: "# Architecture\nMonorepo with pnpm workspaces. 15 packages.",
      source: "subagent-A:architecture",
    });

    // Subagent B indexes API research
    store.index({
      content: "# API Endpoints\nREST + GraphQL. 47 endpoints total.",
      source: "subagent-B:api",
    });

    // Subagent C indexes contributor analysis
    store.index({
      content: "# Contributors\nTop: @alice (312 commits), @bob (198 commits).",
      source: "subagent-C:contributors",
    });

    // Main agent searches each subagent's findings by source
    const arch = store.search("monorepo", 1, "subagent-A");
    assert.ok(arch.length > 0 && arch[0].content.includes("pnpm"));

    const api = store.search("endpoints", 1, "subagent-B");
    assert.ok(api.length > 0 && api[0].content.includes("47"));

    const contrib = store.search("commits", 1, "subagent-C");
    assert.ok(contrib.length > 0 && contrib[0].content.includes("alice"));

    // Cross-search without source filter finds all (OR mode for cross-chunk terms)
    const all = store.search("monorepo endpoints commits", 5, undefined, "OR");
    assert.ok(all.length >= 2, "Global search should find results from multiple subagents");

    store.close();
  });

  test("main agent can search subagent KB after subagent is done", () => {
    const store = new ContentStore(":memory:");

    // Subagent lifecycle: index → close (subagent done)
    store.index({
      content: "# Security Audit\nNo critical vulnerabilities found. 3 medium severity issues in auth module.",
      source: "subagent:security-audit",
    });
    // Subagent returns summary: "Indexed findings as 'subagent:security-audit'"

    // Main agent picks up later and searches
    const results = store.search("vulnerabilities auth", 1, "security-audit");
    assert.ok(results.length > 0);
    assert.ok(results[0].content.includes("3 medium severity"));

    store.close();
  });
});

describe("Context Budget Measurement", () => {
  test("ideal subagent response is under 500 words / 2KB", () => {
    // This is what a compliant subagent response should look like
    const idealResponse = [
      "## Summary",
      "- Researched zod npm package using batch_execute (1 call, 5 commands)",
      "- Indexed detailed findings as 'subagent:zod-research' (3 sections)",
      "",
      "## Key Findings",
      "- TypeScript-first schema validation, zero dependencies",
      "- v4.3.6 latest, 98.5M weekly downloads",
      "- 541 contributors, Colin McDonnell primary maintainer",
      "- MIT license, used by 2.8M+ projects",
      "",
      "## Indexed Sources",
      "- `subagent:zod-research` — full API docs, version history, contributor list",
      "",
      "Use `search(source: 'subagent:zod-research')` for details.",
    ].join("\n");

    const words = idealResponse.split(/\s+/).filter((w) => w.length > 0).length;
    const bytes = Buffer.byteLength(idealResponse);

    assert.ok(words < 500, `Ideal response should be under 500 words, got ${words}`);
    assert.ok(bytes < 2048, `Ideal response should be under 2KB, got ${bytes}`);
  });

  test("non-compliant response exceeds budget", () => {
    // Simulate what happens WITHOUT the output budget — full inline dump
    const bloatedResponse = Array.from(
      { length: 50 },
      (_, i) => `Line ${i}: Detailed information about zod feature ${i} with examples and code snippets...`,
    ).join("\n");

    const words = bloatedResponse.split(/\s+/).filter((w) => w.length > 0).length;
    const bytes = Buffer.byteLength(bloatedResponse);

    assert.ok(words > 500, "Bloated response should exceed 500 words");
  });
});

// Live LLM test — only runs when --live flag is passed
if (LIVE) {
  describe("Live LLM Test (claude -p)", () => {
    test("real subagent respects output budget", async () => {
      const prompt = `Research the npm package "chalk" — what it does, latest version, weekly downloads. Keep it brief.`;

      // Use claude CLI in pipe mode with haiku for speed
      const result = spawnSync(
        "claude",
        ["-p", "--model", "haiku", prompt],
        {
          encoding: "utf-8",
          timeout: 60_000,
          env: { ...process.env },
        },
      );

      if (result.error || result.status !== 0) {
        console.log("    Skipped: claude CLI not available or errored");
        console.log("    stderr:", result.stderr?.slice(0, 200));
        return;
      }

      const response = result.stdout;
      const words = response.split(/\s+/).filter((w: string) => w.length > 0).length;
      const bytes = Buffer.byteLength(response);

      // Soft assertion — LLM may not always comply perfectly
      if (words > 500) {
        console.log(`    WARNING: Response exceeded 500 word budget (${words} words)`);
      }

      assert.ok(
        words < 1000,
        `Response should be reasonable length, got ${words} words`,
      );
    });
  });
}

// ═══════════════════════════════════════════════════════════════════════════
// ctx_upgrade: inline fallback for missing CLI files
// ═══════════════════════════════════════════════════════════════════════════

describe("ctx_upgrade tool: inline fallback for missing CLI", () => {
  const serverSrc = readFileSync(
    resolve(__dirname, "../../src/server.ts"),
    "utf-8",
  );
  const packageJson = JSON.parse(
    readFileSync(resolve(__dirname, "../../package.json"), "utf-8"),
  );

  test("tries cli.bundle.mjs first", () => {
    expect(serverSrc).toContain("cli.bundle.mjs");
    // The bundle path should be checked before fallback
    expect(serverSrc).toMatch(/existsSync\(bundlePath\)/);
  });

  test("tries build/cli.js second", () => {
    expect(serverSrc).toContain('resolve(pluginRoot, "build", "cli.js")');
  });

  test("contains inline fallback with git clone when neither CLI file exists", () => {
    // The fallback must generate an inline script with git clone via execFileSync
    expect(serverSrc).toMatch(/git.*clone.*--depth.*1/);
    // The inline script is written to a temp .mjs file
    expect(serverSrc).toMatch(/\.ctx-upgrade-inline\.mjs/);
  });

  test("inline fallback copies package files to plugin root", () => {
    // The inline script must copy the published package payload back, including
    // newly added files such as the statusline bin directory.
    expect(packageJson.files).toEqual(
      expect.arrayContaining(["server.bundle.mjs", "cli.bundle.mjs", "bin"]),
    );
    expect(serverSrc).toContain('readFileSync(join(T,"package.json"),"utf8")');
    expect(serverSrc).toContain("pkg.files");
    expect(serverSrc).toContain("Array.isArray(pkg.files)");
    expect(serverSrc).toContain("cpSync(from,to,{recursive:true,force:true})");
    expect(serverSrc).toMatch(/npm.*install/);
  });

  test("fallback only triggers when neither CLI file exists", () => {
    // There should be an else/fallback branch after checking both paths
    expect(serverSrc).toMatch(/existsSync\(fallbackPath\)/);
  });

  // ── #469 follow-up: insight-cache cleanup must route through the shared
  //    locale-independent helper, not the original inline `for /f`/`findstr`
  //    block (which was the exact bug PR #469 fixed for ctx_insight). The
  //    orphan call site at the top of the ctx_upgrade handler still carried
  //    the broken pattern. Lock that down here.
  describe("ctx_upgrade insight-cache cleanup uses killProcessOnPort (#469 follow-up)", () => {
    // Scope assertions to the ctx_upgrade tool registration body so we don't
    // accidentally match the shared killProcessOnPort helper definition or
    // its tests below in the same file.
    const upgradeMatch = serverSrc.match(
      /server\.registerTool\(\s*"ctx_upgrade"[\s\S]*?^\);/m,
    );
    const upgradeBody = upgradeMatch ? upgradeMatch[0] : "";

    test("ctx_upgrade tool block was located in source", () => {
      expect(upgradeMatch).not.toBeNull();
    });

    test("ctx_upgrade does NOT contain the broken inline 'for /f' netstat parser", () => {
      // The locale-broken Windows pattern PR #469 already removed from
      // killProcessOnPort. Reintroducing it anywhere in ctx_upgrade is a
      // regression on non-English Windows.
      expect(upgradeBody).not.toMatch(/for\s+\/f\s+"tokens=5"/);
      expect(upgradeBody).not.toMatch(/findstr\s+:4747/);
    });

    test("ctx_upgrade does NOT shell out to taskkill / lsof directly", () => {
      // All port-cleanup must go through the shared helper. The handler
      // itself must not hand-roll either Windows or POSIX kill commands.
      expect(upgradeBody).not.toMatch(/taskkill/);
      expect(upgradeBody).not.toMatch(/lsof\s+-ti:4747/);
    });

    test("ctx_upgrade routes insight-cache cleanup through killProcessOnPort(4747)", () => {
      // Positive assertion: the handler must call the shared helper.
      expect(upgradeBody).toMatch(/killProcessOnPort\(\s*4747\s*\)/);
    });

    test("ctx_upgrade preserves best-effort semantics (cleanup wrapped in try/catch)", () => {
      // Cleanup failure must not block the upgrade — the try/catch at the
      // top of the handler with the "best effort" comment must remain.
      expect(upgradeBody).toMatch(/best effort/i);
    });
  });
});

// ─── ctx_purge is the ONLY reset mechanism ──────────────────────────────────

describe("ctx_purge is the sole reset/wipe mechanism", () => {
  const serverSrc = readFileSync(
    resolve(__dirname, "../../src/server.ts"),
    "utf-8",
  );
  const routingBlockSrc = readFileSync(
    resolve(__dirname, "../../hooks/routing-block.mjs"),
    "utf-8",
  );

  // ── ctx_stats has NO reset capability ──
  test("ctx_stats does NOT accept a reset parameter", () => {
    // Extract only the ctx_stats tool registration
    const statsMatch = serverSrc.match(
      /server\.registerTool\(\s*"ctx_stats"[\s\S]*?^\);/m,
    );
    expect(statsMatch).not.toBeNull();
    const statsBody = statsMatch![0];
    expect(statsBody).not.toContain("reset");
    expect(statsBody).not.toContain("resetSessionStats");
  });

  // ── No .clear-stats flag mechanism ──
  test("server has no checkClearStatsFlag mechanism", () => {
    expect(serverSrc).not.toContain("checkClearStatsFlag");
    expect(serverSrc).not.toContain(".clear-stats");
  });

  // ── Routing block: no reset instructions for /clear or /compact ──
  test("routing block does not instruct any reset after /clear or /compact", () => {
    expect(routingBlockSrc).not.toContain("reset: true");
    expect(routingBlockSrc).not.toContain("ctx_stats(reset");
  });

  test("routing block informs user about ctx_purge availability", () => {
    expect(routingBlockSrc).toMatch(/ctx.purge/i);
  });

  // ── ctx_purge is the complete wipe tool ──
  test("ctx_purge gates on confirm parameter", () => {
    expect(serverSrc).toContain("Purge cancelled");
    expect(serverSrc).toMatch(/if \(!confirm\)/);
  });

  test("ctx_purge wipes KB, session DB, events, and stats", () => {
    const purgeMatch = serverSrc.match(
      /server\.registerTool\(\s*"ctx_purge"[\s\S]*?^\);/m,
    );
    expect(purgeMatch).not.toBeNull();
    const purgeBody = purgeMatch![0];
    // 1. Closes the FTS5 knowledge base BEFORE wiping (releases Windows lock)
    expect(purgeBody).toContain("_store.cleanup()");
    expect(purgeBody).toContain("_store = null");
    // 2. Delegates the on-disk wipe to the purgeSession deep module so all
    //    file-kind sweeps (session DB, events.md, cleanup flag, FTS5 store,
    //    legacy content) flow through ONE code path with uniform dual-hash.
    expect(purgeBody).toContain("purgeSession({");
    expect(purgeBody).toContain("projectDir: getProjectDir()");
    expect(purgeBody).toContain("sessionsDir: getSessionDir()");
    expect(purgeBody).toContain("storePath: storePathForPurge");
    // 3. Resets in-memory stats
    expect(purgeBody).toContain("sessionStats.calls = {}");
    expect(purgeBody).toContain("sessionStats.sessionStart = Date.now()");
    // 4. Confirms with list of deleted items
    expect(purgeBody).toContain("Purged:");
  });
});

// ─── Platform-aware session DB paths ─────────────────────────────────────────

describe("Platform-aware session paths via adapter", () => {
  const serverSrc = readFileSync(
    resolve(__dirname, "../../src/server.ts"),
    "utf-8",
  );

  // ── Adapter is stored at startup ──
  test("server stores detected adapter at startup", () => {
    expect(serverSrc).toContain("let _detectedAdapter");
    // main() must assign the adapter after detection
    expect(serverSrc).toMatch(/_detectedAdapter\s*=\s*await\s+getAdapter/);
  });

  // ── No hardcoded .claude in tool handlers ──
  test("ctx_purge has no hardcoded .claude path", () => {
    const purgeMatch = serverSrc.match(
      /server\.registerTool\(\s*"ctx_purge"[\s\S]*?^\);/m,
    );
    expect(purgeMatch).not.toBeNull();
    expect(purgeMatch![0]).not.toMatch(/["']\.claude["']/);
  });

  test("ctx_stats has no hardcoded .claude path", () => {
    const statsMatch = serverSrc.match(
      /server\.registerTool\(\s*"ctx_stats"[\s\S]*?^\);/m,
    );
    expect(statsMatch).not.toBeNull();
    expect(statsMatch![0]).not.toMatch(/["']\.claude["']/);
  });

  // ── Adapter methods used for session paths (post-C2 narrowing) ──
  test("session paths derived from adapter.getSessionDir + resolveSessionDbPath", () => {
    // C2 narrowing (2026-05): adapter no longer exposes getSessionDBPath /
    // getSessionEventsPath. server.ts must derive per-project DB paths via
    // resolveSessionDbPath while reading the adapter ONLY for the platform
    // sessionDir. Pin both calls so an accidental regression to a missing
    // helper or a deleted adapter method is caught at the test boundary.
    expect(serverSrc).toMatch(/getSessionDir\(/);
    expect(serverSrc).toMatch(/resolveSessionDbPath\(/);
  });

  // ── Comprehensive projectDir detection ──
  test("getProjectDir delegates to resolveProjectDir; chain lives in util/project-dir.ts", () => {
    const fn = serverSrc.match(/function getProjectDir[\s\S]*?^}/m);
    expect(fn).not.toBeNull();
    const body = fn![0];
    // Server.ts MUST delegate so the env-var chain + plugin-path rejection
    // is unified with start.mjs (see v1.0.113 hotfix).
    expect(body).toContain("resolveProjectDir");
    expect(body).toContain("process.env.PWD");
    // Env-var chain itself moved to the shared resolver — pin its contract there.
    const utilSrc = readFileSync(
      resolve(__dirname, "../../src/util/project-dir.ts"),
      "utf-8",
    );
    for (const v of [
      "CLAUDE_PROJECT_DIR",
      "GEMINI_PROJECT_DIR",
      "VSCODE_CWD",
      "OPENCODE_PROJECT_DIR",
      "PI_PROJECT_DIR",
      "IDEA_INITIAL_DIRECTORY",
      "CURSOR_CWD",
      "QUIET_CONTEXT_PROJECT_DIR",
    ]) {
      expect(utilSrc).toContain(v);
    }
    expect(utilSrc).toContain("isPluginInstallPath");
    // Must NOT contain semantically wrong env vars
    expect(utilSrc).not.toContain("OPENCLAW_HOME");
  });

  // Issue #521 Slice 2: transcriptsRoot is the Claude Code transcript dir
  // (`~/.claude/projects`). Passing it on non-Claude-Code platforms (Cursor,
  // OpenCode, Codex, ...) is wrong — the most-recently-modified jsonl could
  // belong to an unrelated Claude Code window, returning that project's cwd
  // to a Cursor MCP. getProjectDir() MUST gate transcriptsRoot on the active
  // platform via detectPlatform(); only "claude-code" gets the path.
  test("getProjectDir gates transcriptsRoot on detected platform (Claude Code only)", () => {
    const fn = serverSrc.match(/function getProjectDir[\s\S]*?^}/m);
    expect(fn).not.toBeNull();
    const body = fn![0];
    // Must reference detectPlatform so the gate is dynamic per-process.
    expect(body).toContain("detectPlatform");
    // Must check for the claude-code platform string (literal — pinning the
    // gate predicate so a typo or platform-id rename is caught here).
    expect(body).toContain("claude-code");
    // Still passes transcriptsRoot when the gate matches.
    expect(body).toContain("transcriptsRoot");
  });

  // ── Content DB is platform-isolated (not shared) ──
  test("getStorePath uses platform-specific dir, not shared ~/.context-mode/", () => {
    const fn = serverSrc.match(/function getStorePath[\s\S]*?^}/m);
    expect(fn).not.toBeNull();
    const body = fn![0];
    // Must NOT use the shared platform-agnostic directory
    expect(body).not.toContain('".context-mode"');
    // Must derive content dir from adapter/session dir (platform-specific)
    expect(body).toContain("resolveContentStorageDir(getDefaultSessionDir)");
  });
});

// ─── Hash consistency ────────────────────────────────────────────────────────

describe("Project dir hash consistency", () => {
  const serverSrc = readFileSync(
    resolve(__dirname, "../../src/server.ts"),
    "utf-8",
  );

  test("server.ts imports canonical hash + path resolvers from session/db.js", () => {
    // After the case-fold migration + content-store migration, all DB
    // path computation lives in src/session/db.ts. The server MUST import
    // resolveSessionDbPath + resolveContentStorePath rather than rolling
    // its own hash + join inline.
    expect(serverSrc).toMatch(
      /import\s*\{[^}]*resolveSessionDbPath[^}]*\}\s*from\s*"\.\/session\/db\.js"/,
    );
    expect(serverSrc).toMatch(
      /import\s*\{[^}]*resolveContentStorePath[^}]*\}\s*from\s*"\.\/session\/db\.js"/,
    );
    // The deleted local helpers MUST be gone — guards against accidental
    // re-introduction that would split the case-fold contract.
    expect(serverSrc).not.toMatch(/^function hashProjectDir\(/m);
    expect(serverSrc).not.toMatch(/^function normalizeProjectDirForHash\(/m);
  });

  test("getStorePath delegates to resolveContentStorePath (auto case-fold migration)", () => {
    const fn = serverSrc.match(/function getStorePath[\s\S]*?^}/m);
    expect(fn).not.toBeNull();
    expect(fn![0]).toContain("resolveContentStorePath");
    // Must NOT have its own inline createHash call.
    expect(fn![0]).not.toContain("createHash");
  });

  test("server storage paths are routed through runtime override resolver", () => {
    expect(serverSrc).toContain("resolveSessionStorageDir");
    expect(serverSrc).toContain("resolveContentStorageDir");
    expect(serverSrc).toContain("resolveStatsStorageDir");
    expect(serverSrc).toContain("ensureWritableStorageDir");
    expect(serverSrc).toContain("formatStorageDirectoryError");
  });

  test("ctx_stats uses hashProjectDir, not inline hashing", () => {
    const statsMatch = serverSrc.match(
      /server\.registerTool\(\s*"ctx_stats"[\s\S]*?^\);/m,
    );
    expect(statsMatch).not.toBeNull();
    expect(statsMatch![0]).toContain("hashProjectDir");
    expect(statsMatch![0]).not.toContain("createHash");
  });

  test("ctx_purge uses hashProjectDir, not inline hashing", () => {
    const purgeMatch = serverSrc.match(
      /server\.registerTool\(\s*"ctx_purge"[\s\S]*?^\);/m,
    );
    expect(purgeMatch).not.toBeNull();
    expect(purgeMatch![0]).toContain("hashProjectDir");
    expect(purgeMatch![0]).not.toContain("createHash");
  });

  // ── B3b Slice 3.1: ctx_stats must scope getLifetimeStats to the active
  //    adapter via getSessionDir(), not the hardcoded ~/.claude/ default.
  //    Bug evidence: src/server.ts:2602/2612/2620 currently call
  //    `getLifetimeStats()` with no args, so non-Claude platforms (Cursor,
  //    OpenCode, JetBrains, ...) silently aggregate from the wrong dir.
  //    Statusline at src/server.ts:540 already passes
  //    `{ sessionsDir: getSessionDir() }` — the three ctx_stats sites must
  //    mirror that contract exactly. Note: `getMultiAdapterLifetimeStats`
  //    is NOT covered by this test because it takes `{ home }` and
  //    intentionally defaults to homedir() to scan every adapter dir; the
  //    bare-call form is correct for that helper.
  test("ctx_stats scopes lifetime aggregation to the active adapter sessionsDir", () => {
    const statsMatch = serverSrc.match(
      /server\.registerTool\(\s*"ctx_stats"[\s\S]*?^\);/m,
    );
    expect(statsMatch).not.toBeNull();
    const body = statsMatch![0];
    // Every `getLifetimeStats(` invocation inside ctx_stats MUST be argumented
    // (sessionsDir-aware). A bare `()` call falls back to the hardcoded
    // ~/.claude/context-mode/sessions default and silently mis-attributes
    // lifetime counts on non-Claude platforms. Use a negative lookbehind to
    // exclude `getMultiAdapterLifetimeStats(` whose default is intentional.
    const bareCalls = body.match(
      /(?<!MultiAdapter)getLifetimeStats\(\s*\)/g,
    );
    expect(bareCalls, "ctx_stats must not call getLifetimeStats() with no args").toBeNull();
    // Should pass an object literal containing `sessionsDir` (mirrors the
    // statusline contract at src/server.ts:540).
    expect(body).toMatch(/getLifetimeStats\(\s*\{\s*sessionsDir:\s*getSessionDir\(\)/);
  });
});

// ─── Purge deleted array honesty ─────────────────────────────────────────────

describe("ctx_purge deleted array is honest", () => {
  const serverSrc = readFileSync(
    resolve(__dirname, "../../src/server.ts"),
    "utf-8",
  );

  test("every deleted.push in ctx_purge is guarded by a success check", () => {
    // After the purgeSession() deep-module extraction, the per-file-kind
    // success guards live in src/session/purge.ts. The handler itself only
    // ever pushes "session stats" — which is the always-truthful in-memory
    // reset and explicitly exempted from the guard rule. The deep module
    // is independently covered by tests/session/purge-session.test.ts which
    // proves each label appears only when at least one file was unlinked.
    const purgeBody = serverSrc.match(
      /server\.registerTool\(\s*"ctx_purge"[\s\S]*?^\);/m,
    )![0];
    const pushes = [...purgeBody.matchAll(/deleted\.push\("([^"]+)"\)/g)];
    for (const push of pushes) {
      expect(push[1]).toBe("session stats");
    }

    // Issue #520: scoped per-session purge must NOT push "session stats"
    // (stats are project-scoped). The push remains the only string-literal
    // push, but it must be gated on scope === "project".
    expect(purgeBody).toMatch(/scope\s*===\s*["']project["']/);

    const moduleSrc = readFileSync(
      resolve(__dirname, "../../src/session/purge.ts"),
      "utf-8",
    );
    // Each user-facing label inside purgeSession() must be conditionally
    // pushed (success check).  We grep every push and verify the 120 chars
    // before it contain a guard.
    const modulePushes = [...moduleSrc.matchAll(/deleted\.push\("([^"]+)"\)/g)];
    expect(modulePushes.length).toBeGreaterThanOrEqual(2);
    for (const push of modulePushes) {
      const idx = push.index!;
      const context = moduleSrc.slice(Math.max(0, idx - 160), idx);
      const isGuarded = /if\s*\(\s*\w*[Ff]ound/.test(context)
        || /if\s*\(\s*removed\s*\)/.test(context)
        || /if\s*\(\s*_store\s*\)/.test(context);
      expect(isGuarded, `"${push[1]}" in purge.ts must be guarded by a success check`).toBe(true);
    }
  });
});

// ─── Issue #520: scoped ctx_purge handler/schema contract ──────────────────
//
// The HANDLER (not the deep module) owns the schema, the stats reset, and
// the back-compat deprecation warning. The deep module stays pure and
// is covered by tests/session/purge-session.test.ts. These tests assert
// the additive contract WITHOUT booting the MCP server.

describe("ctx_purge scoped handler (issue #520)", () => {
  const serverSrc = readFileSync(
    resolve(__dirname, "../../src/server.ts"),
    "utf-8",
  );
  const purgeBody = serverSrc.match(
    /server\.registerTool\(\s*"ctx_purge"[\s\S]*?^\);/m,
  )![0];

  // Slice 4 — stats file & in-memory reset gated on scope === "project".
  // A scoped per-session purge must leave the persisted stats file alone
  // (other sessions in the project still own those bytes).
  test("slice 4: persisted stats file unlink is gated on scope === 'project'", () => {
    const statsBlockMatch = purgeBody.match(/getStatsFilePath\(\)[\s\S]{0,300}/);
    expect(statsBlockMatch, "stats unlink block must exist in handler").not.toBeNull();
    // The whole stats reset (in-memory + file unlink) must live under a
    // `if (... === "project")` branch — verified by ensuring the stats
    // unlink is NOT at the top level of the handler.
    const statsIdx = purgeBody.indexOf("getStatsFilePath()");
    const beforeStats = purgeBody.slice(0, statsIdx);
    expect(beforeStats).toMatch(/scope\s*===\s*["']project["']/);
  });

  // Slice 5 — back-compat: bare {confirm:true} keeps verbatim behavior.
  // The handler must map missing scope/sessionId → scope:"project" and
  // delegate to purgeSession exactly as before. The schema must accept
  // bare {confirm:true} as it always did.
  test("slice 5: bare {confirm:true} handler still calls purgeSession with project scope", () => {
    // Either explicit scope:"project" is passed, OR the deep module's
    // own default-resolution kicks in. The handler MUST NOT throw on
    // bare {confirm:true}.
    expect(purgeBody).toMatch(/purgeSession\(/);
    expect(purgeBody).not.toMatch(/throw new (?:Error|TypeError)\([^)]*sessionId/);
  });

  // Slice 6 — schema rejects {confirm, sessionId, scope:"project"} (ambiguous).
  // The MCP SDK's normalizeObjectSchema() requires a plain ZodObject so it
  // can read `.shape` when serializing inputSchema → JSON Schema for
  // tools/list. A `.refine()` wrapper produces a ZodEffects which has no
  // `.shape`, so the SDK falls back to `properties: {}` — and Claude Code's
  // strict-input-validation gate then rejects the tool call before the
  // handler ever runs. Issue #563.
  //
  // Therefore the cross-field check MUST live in the handler body, not on
  // the schema. Verify (a) the inputSchema is NOT wrapped in refine() and
  // (b) the handler still rejects the ambiguous combo at runtime.
  test("slice 6: inputSchema is plain z.object — no .refine/.transform/.superRefine wrapper (#563)", () => {
    // Locate the inputSchema literal (between `inputSchema:` and the next
    // top-level handler comma `},`). Anchor narrowly so we only inspect
    // the schema, not the handler body that legitimately contains checks.
    const schemaStart = purgeBody.indexOf("inputSchema:");
    expect(schemaStart).toBeGreaterThan(-1);
    // The schema literal ends at the matching close of registerTool's
    // options object — i.e. just before `},\n  async (`.
    const handlerStart = purgeBody.indexOf("async ({");
    expect(handlerStart).toBeGreaterThan(schemaStart);
    const schemaSlice = purgeBody.slice(schemaStart, handlerStart);
    expect(schemaSlice).not.toMatch(/\.refine\(/);
    expect(schemaSlice).not.toMatch(/\.superRefine\(/);
    expect(schemaSlice).not.toMatch(/\.transform\(/);
  });

  test("slice 6b: handler rejects ambiguous {sessionId + scope:'project'} at runtime (#563)", () => {
    // The cross-field ambiguity check moved out of the schema into the
    // handler body. Verify a guard exists that fires when sessionId is
    // present AND scope === "project", and that it returns isError:true
    // rather than throwing.
    const handlerSlice = purgeBody.slice(purgeBody.indexOf("async ({"));
    expect(handlerSlice).toMatch(
      /sessionId\s*&&\s*scope\s*===\s*["']project["']|scope\s*===\s*["']project["']\s*&&\s*sessionId/,
    );
    expect(handlerSlice).toMatch(/isError:\s*true/);
    // Human-readable message preserved (matches the original refine() text
    // so consumers see the same guidance).
    expect(handlerSlice).toMatch(/[Aa]mbiguous/);
  });

  // Slice 7 — schema accepts {confirm:true, sessionId:"<uuid>"}.
  test("slice 7: schema declares optional sessionId and scope", () => {
    expect(purgeBody).toMatch(/sessionId:\s*z\.string\(\)\.optional\(\)/);
    expect(purgeBody).toMatch(/scope:\s*z\.enum\(\[["']session["'],\s*["']project["']\]\)\.optional\(\)/);
  });

  // Slice 8 — bare {confirm:true} (no sessionId, no scope) emits a
  // deprecation warning to stderr exactly once. The warn lives in the
  // handler — not the deep module — to keep purge.ts pure.
  test("slice 8: handler emits deprecation warning when scope+sessionId both omitted", () => {
    expect(purgeBody).toMatch(/console\.warn\([^)]*deprecat/i);
  });

  // Slice 9 (#563 regression — class-wide guard) — NO registered MCP tool
  // may wrap its inputSchema in .refine(), .superRefine(), or .transform().
  // All three produce a ZodEffects, which the MCP SDK's
  // normalizeObjectSchema() does not recognize (it reads `.shape`), so the
  // serialized JSON Schema collapses to `properties: {}` — and Claude Code
  // (and any strict-input client) then refuses every call to that tool
  // with "input_schema does not support fields". Move cross-field checks
  // into the handler body. This test catches the entire class for ALL
  // registered tools, not just ctx_purge.
  test("slice 9: all registered MCP tools must have non-empty input schema (regression for #563)", () => {
    // Match every registerTool(...) block — same anchor pattern used by
    // the per-tool slices above. Greedy [\s\S]*? + line-anchored ^);
    // terminator = the body of one registerTool call.
    const blocks = [
      ...serverSrc.matchAll(
        /server\.registerTool\(\s*"([^"]+)"[\s\S]*?^\);/gm,
      ),
    ];
    expect(blocks.length).toBeGreaterThan(5);

    const violations: string[] = [];
    for (const m of blocks) {
      const name = m[1];
      const body = m[0];
      // Isolate just the inputSchema literal (between `inputSchema:` and
      // the start of the handler arrow `async (`). Tools without an
      // inputSchema (none currently) are skipped silently.
      const sIdx = body.indexOf("inputSchema:");
      if (sIdx < 0) continue;
      const hIdx = body.indexOf("async (", sIdx);
      const schemaSlice = hIdx > sIdx ? body.slice(sIdx, hIdx) : body.slice(sIdx);
      if (/\.refine\(/.test(schemaSlice)) violations.push(`${name}: .refine()`);
      if (/\.superRefine\(/.test(schemaSlice)) violations.push(`${name}: .superRefine()`);
      if (/\.transform\(/.test(schemaSlice)) violations.push(`${name}: .transform()`);
    }
    expect(
      violations,
      "ZodEffects on inputSchema breaks MCP SDK normalizeObjectSchema → JSON " +
        "Schema collapses to properties:{} → Claude Code rejects with " +
        "'input_schema does not support fields'. Move cross-field checks into " +
        "the handler body. See issue #563.",
    ).toEqual([]);
  });
});

// ─── KB purge behavioral (ContentStore) ─────────────────────────────────────

describe("ContentStore purge behavior", () => {
  test("cleanup() deletes DB files (including WAL and SHM)", () => {
    const tmpPath = join(tmpdir(), `ctx-purge-test-${Date.now()}.db`);
    const store = new ContentStore(tmpPath);

    store.index({ content: "test content for purge verification", source: "purge-test" });
    expect(store.getStats().chunks).toBeGreaterThan(0);

    store.cleanup();

    // All DB files should be gone
    expect(existsSync(tmpPath)).toBe(false);
    expect(existsSync(tmpPath + "-wal")).toBe(false);
    expect(existsSync(tmpPath + "-shm")).toBe(false);
  });

  test("index survives when cleanup is NOT called (--continue scenario)", () => {
    const tmpPath = join(tmpdir(), `ctx-preserve-test-${Date.now()}.db`);
    const store = new ContentStore(tmpPath);

    store.index({ content: "preserved content across sessions", source: "preserve-test" });
    store.close();

    // Simulate --continue: reopen same DB
    const store2 = new ContentStore(tmpPath);
    const stats = store2.getStats();
    expect(stats.chunks).toBeGreaterThan(0);

    const results = store2.search("preserved content", 5);
    expect(results.length).toBeGreaterThan(0);

    store2.cleanup();
  });

  test("store recovers after purge — new index works", () => {
    const tmpPath = join(tmpdir(), `ctx-recovery-test-${Date.now()}.db`);

    // Phase 1: index and purge
    const store1 = new ContentStore(tmpPath);
    store1.index({ content: "old content to be purged", source: "old" });
    store1.cleanup();
    expect(existsSync(tmpPath)).toBe(false);

    // Phase 2: create fresh store at same path, index new content
    const store2 = new ContentStore(tmpPath);
    store2.index({ content: "fresh content after purge", source: "new" });

    const results = store2.search("fresh content", 5);
    expect(results.length).toBeGreaterThan(0);

    // Old content should NOT be found
    const oldResults = store2.search("old content to be purged", 5);
    expect(oldResults.length).toBe(0);

    store2.cleanup();
  });

  test("double cleanup does not crash", () => {
    const tmpPath = join(tmpdir(), `ctx-double-purge-${Date.now()}.db`);
    const store = new ContentStore(tmpPath);
    store.index({ content: "some content", source: "test" });

    // First cleanup
    store.cleanup();
    expect(existsSync(tmpPath)).toBe(false);

    // Second cleanup — DB already gone, should not throw
    expect(() => store.cleanup()).not.toThrow();
  });

  test("cleanup on never-indexed store does not crash", () => {
    const tmpPath = join(tmpdir(), `ctx-empty-purge-${Date.now()}.db`);
    const store = new ContentStore(tmpPath);

    // No indexing done — purge should still work
    expect(() => store.cleanup()).not.toThrow();
    expect(existsSync(tmpPath)).toBe(false);
  });

  test("ctx_purge handler deletes DB file even when _store is null (--continue scenario)", () => {
    // After the purgeSession() extraction (src/session/purge.ts) the
    // _store-is-null branch lives there: the handler ALWAYS resolves
    // getStorePath() and passes it as `storePath`, regardless of whether
    // _store was open.  purgeSession unlinks the file unconditionally,
    // which is exactly the --continue scenario this test was created for.
    // Behavioral coverage: tests/session/purge-session.test.ts slice 5.
    const serverSrc = readFileSync(
      resolve(__dirname, "../../src/server.ts"),
      "utf-8",
    );
    const purgeBody = serverSrc.match(
      /server\.registerTool\(\s*"ctx_purge"[\s\S]*?^\);/m,
    )![0];

    // Handler resolves storePath BEFORE the optional _store.cleanup() so
    // the disk wipe runs whether _store was open or not.
    const storePathIdx = purgeBody.indexOf("getStorePath()");
    const storeCleanupIdx = purgeBody.indexOf("_store.cleanup()");
    expect(storePathIdx).toBeGreaterThan(-1);
    expect(storePathIdx).toBeLessThan(storeCleanupIdx === -1 ? Infinity : storeCleanupIdx);
    // Handler always passes storePath into the deep module — that is what
    // makes the wipe unconditional.
    expect(purgeBody).toContain("storePath: storePathForPurge");

    // Deep module is the one calling unlinkSync on the FTS5 store path.
    const moduleSrc = readFileSync(
      resolve(__dirname, "../../src/session/purge.ts"),
      "utf-8",
    );
    expect(moduleSrc).toContain("unlinkSync");
  });
});

// ─── Version outdated warning ────────────────────────────────────────────────

describe("Version outdated warning in trackResponse", () => {
  const serverSrc = readFileSync(
    resolve(__dirname, "../../src/server.ts"),
    "utf-8",
  );

  test("fetchLatestVersion function exists and uses npm registry", () => {
    expect(serverSrc).toContain("function fetchLatestVersion");
    expect(serverSrc).toContain("registry.npmjs.org/context-mode");
  });

  test("version check fires in main() after server.connect", () => {
    const mainFn = serverSrc.slice(serverSrc.indexOf("async function main"));
    expect(mainFn).toContain("fetchLatestVersion");
  });

  test("trackResponse prepends warning when outdated", () => {
    const trackFn = serverSrc.slice(
      serverSrc.indexOf("function trackResponse"),
      serverSrc.indexOf("function trackIndexed"),
    );
    expect(trackFn).toContain("_latestVersion");
    expect(trackFn).toContain("outdated");
  });

  test("warning uses burst cadence (3 calls then silent)", () => {
    expect(serverSrc).toContain("VERSION_BURST_SIZE");
    expect(serverSrc).toContain("VERSION_SILENT_MS");
    expect(serverSrc).toContain("_warningBurstCount");
  });

  test("getUpgradeHint returns platform-specific command", () => {
    expect(serverSrc).toContain("function getUpgradeHint");
    // Claude Code gets slash command
    expect(serverSrc).toMatch(/claude.code.*ctx.upgrade|ctx.upgrade.*claude.code/i);
    // npm platforms get npm update
    expect(serverSrc).toContain("npm update -g context-mode");
    // OpenClaw gets its own command
    expect(serverSrc).toContain("npm run install:openclaw");
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// FS read instrumentation (mirrors network interceptor pattern)
// ═══════════════════════════════════════════════════════════════════════════

describe("FS read instrumentation", () => {
  const serverSrc = readFileSync(
    resolve(__dirname, "../../src/server.ts"),
    "utf-8",
  );

  test("wrapper contains __CM_FS__ marker for stderr reporting", () => {
    expect(serverSrc).toContain("__CM_FS__:");
  });

  test("wrapper instruments readFileSync to count bytes", () => {
    expect(serverSrc).toContain("readFileSync");
    expect(serverSrc).toContain("__cm_fs+=");
  });

  test("wrapper instruments readFile (async) to count bytes", () => {
    expect(serverSrc).toMatch(/readFile/);
    expect(serverSrc).toContain("__cm_fs+=d.length");
  });

  test("parses __CM_FS__ from stderr and adds to bytesSandboxed", () => {
    expect(serverSrc).toContain("__CM_FS__:(\\d+)");
    expect(serverSrc).toContain("sessionStats.bytesSandboxed += parseInt(fsMatch[1])");
  });

  test("cleans __CM_FS__ marker from stderr output", () => {
    expect(serverSrc).toContain('result.stderr.replace(/\\n?__CM_FS__:\\d+\\n?/g, "")');
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// batch_execute FS read tracking via NODE_OPTIONS preload
// ═══════════════════════════════════════════════════════════════════════════

describe("batch_execute FS read tracking", () => {
  const serverSrc = readFileSync(
    resolve(__dirname, "../../src/server.ts"),
    "utf-8",
  );

  test("creates CM_FS_PRELOAD temp file with FS tracking script", () => {
    expect(serverSrc).toContain("CM_FS_PRELOAD");
    expect(serverSrc).toContain("cm-fs-preload-");
    // Preload script must write __CM_FS__ marker to stderr on exit
    expect(serverSrc).toMatch(/writeFileSync\(\s*CM_FS_PRELOAD/);
  });

  test("sets NODE_OPTIONS with --require for batch commands", () => {
    expect(serverSrc).toContain("buildBatchNodeOptionsPrefix");
    expect(serverSrc).toContain("nodeOptsPrefix");
  });

  test("parses __CM_FS__ from batch output and updates bytesSandboxed", () => {
    expect(serverSrc).toContain("/__CM_FS__:(\\d+)/g");
    // Handler wires the FS-bytes callback to sessionStats; the runner strips/parses.
    expect(serverSrc).toContain("sessionStats.bytesSandboxed += bytes");
    expect(serverSrc).toContain("onFsBytes?.(cmdFsBytes)");
  });

  test("strips __CM_FS__ markers from batch command output", () => {
    expect(serverSrc).toContain('output.replace(/__CM_FS__:\\d+\\n?/g, "")');
  });

  test("cleans up preload file on shutdown", () => {
    expect(serverSrc).toContain("unlinkSync(CM_FS_PRELOAD)");
  });

  test("handler accepts concurrency input field with min/max bounds", () => {
    expect(serverSrc).toContain("concurrency: z");
    expect(serverSrc).toMatch(/\.min\(1\)\s*\n?\s*\.max\(8\)/);
    expect(serverSrc).toContain(".default(1)");
  });

  test("tool description documents the concurrency field with positive guidance", () => {
    // PR #683 / ADR-0002: emoji bullets replaced with prose. The standalone
    // CONCURRENCY: section was folded into WHEN: / WHEN NOT: prose by the
    // PR #683 WS3 canonical-structure pass so descriptions only carry the
    // four canonical sections (WHEN / WHEN NOT / RETURNS / EXAMPLE) plus
    // approved per-tool carve-outs (e.g. ctx_purge SCOPES / CONTRACT).
    // The I/O-bound vs CPU-bound split and the 4-8 speedup window are still
    // named — the PR #683 second amendment then deepened the concurrency
    // guidance (CPU-bound stays at 1, GitHub API caps at 4 to respect rate
    // limits, I/O-bound uses 4-8). This test pins the load-bearing concepts
    // (I/O-bound parallelism, the 4-8 window, the keep-at-1 rule) not the
    // exact prose so future copy-edits don't break it.
    expect(serverSrc).toMatch(/parallelize I\/O-bound (work|calls|batches)/);
    expect(serverSrc).toMatch(/4-8\s+(for I\/O-bound|I\/O-bound batches)/);
    expect(serverSrc).toContain("CPU-bound or stateful");
    expect(serverSrc).toContain("keep concurrency at 1");
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// runBatchCommands — concurrency, ordering, timeout semantics
// ═══════════════════════════════════════════════════════════════════════════

import {
  buildBatchNodeOptionsPrefix,
  runBatchCommands,
  type BatchCommand,
} from "../../src/server.js";

interface MockResult { stdout: string; stderr?: string; timedOut?: boolean; }

function mkMockExecutor(
  handler: (code: string, timeout: number | undefined) => Promise<MockResult> | MockResult,
): { execute: (input: { language: "shell"; code: string; timeout: number | undefined }) => Promise<MockResult> } {
  return {
    execute: async ({ code, timeout }) => Promise.resolve(handler(code, timeout)),
  };
}

const NOOP_PREFIX = ""; // tests don't need NODE_OPTIONS prefix

describe("runBatchCommands serial path (concurrency=1)", () => {
  test("happy path: outputs in input order, no timeout cascade", async () => {
    const cmds: BatchCommand[] = [
      { label: "A", command: "echo a" },
      { label: "B", command: "echo b" },
      { label: "C", command: "echo c" },
    ];
    const exec = mkMockExecutor((code) => ({ stdout: code.includes("echo a") ? "a" : code.includes("echo b") ? "b" : "c" }));
    const { outputs, timedOut } = await runBatchCommands(cmds, { timeout: 5000, concurrency: 1, nodeOptsPrefix: NOOP_PREFIX }, exec);
    expect(timedOut).toBe(false);
    expect(outputs).toHaveLength(3);
    expect(outputs[0]).toContain("# A");
    expect(outputs[0]).toContain("a");
    expect(outputs[1]).toContain("# B");
    expect(outputs[2]).toContain("# C");
  });

  test("preserves heredoc commands and combines captured stderr", async () => {
    const heredoc = "node - <<'NODE'\nconsole.log('stdout')\nconsole.error('stderr')\nNODE";
    let seenCode = "";
    const exec = mkMockExecutor((code) => {
      seenCode = code;
      return { stdout: "stdout\n", stderr: "stderr\n" };
    });
    const { outputs, timedOut } = await runBatchCommands(
      [{ label: "heredoc", command: heredoc }],
      { timeout: 5000, concurrency: 1, nodeOptsPrefix: NOOP_PREFIX },
      exec,
    );

    expect(timedOut).toBe(false);
    expect(seenCode).toBe(heredoc);
    expect(seenCode).not.toContain("NODE 2>&1");
    expect(outputs[0]).toContain("stdout");
    expect(outputs[0]).toContain("stderr");
  });

  test("cascading skip: timeout in first cmd skips the rest", async () => {
    let callCount = 0;
    const exec = mkMockExecutor(() => {
      callCount++;
      return { stdout: "slow", timedOut: true };
    });
    const cmds: BatchCommand[] = [
      { label: "slow", command: "sleep 999" },
      { label: "next", command: "echo next" },
      { label: "after", command: "echo after" },
    ];
    const { outputs, timedOut } = await runBatchCommands(cmds, { timeout: 100, concurrency: 1, nodeOptsPrefix: NOOP_PREFIX }, exec);
    expect(callCount).toBe(1); // only slow command executed
    expect(timedOut).toBe(true);
    expect(outputs[0]).toContain("# slow");
    expect(outputs[1]).toContain("(skipped — batch timeout exceeded)");
    expect(outputs[2]).toContain("(skipped — batch timeout exceeded)");
  });

  test("shared timeout budget: subsequent commands skip when budget exhausted", async () => {
    let callCount = 0;
    const exec = mkMockExecutor(async () => {
      callCount++;
      await new Promise((r) => setTimeout(r, 60)); // each call burns 60ms
      return { stdout: "ok" };
    });
    const cmds: BatchCommand[] = [
      { label: "A", command: "x" },
      { label: "B", command: "x" },
      { label: "C", command: "x" }, // by here, elapsed > 100ms
    ];
    const { outputs, timedOut } = await runBatchCommands(cmds, { timeout: 100, concurrency: 1, nodeOptsPrefix: NOOP_PREFIX }, exec);
    expect(callCount).toBeLessThan(3);
    expect(timedOut).toBe(true);
    expect(outputs.some((o) => o.includes("(skipped — batch timeout exceeded)"))).toBe(true);
  });

  // Issue #406 — when timeout omitted, no shared budget, no skip cascade.
  test("no timeout: all commands run to completion, no skip", async () => {
    const exec = mkMockExecutor(async () => {
      // Each call burns 60ms. With timeout omitted, NONE should be skipped.
      await new Promise((r) => setTimeout(r, 60));
      return { stdout: "ok" };
    });
    const cmds: BatchCommand[] = [
      { label: "A", command: "x" },
      { label: "B", command: "x" },
      { label: "C", command: "x" },
    ];
    const { outputs, timedOut } = await runBatchCommands(
      cmds,
      { timeout: undefined, concurrency: 1, nodeOptsPrefix: NOOP_PREFIX },
      exec,
    );
    expect(timedOut).toBe(false);
    expect(outputs).toHaveLength(3);
    expect(outputs.every((o) => !o.includes("skipped"))).toBe(true);
  });

  test("no timeout: per-command timeout passed to executor is undefined", async () => {
    const seenTimeouts: Array<number | undefined> = [];
    const exec = {
      execute: async (input: { language: "shell"; code: string; timeout: number | undefined }) => {
        seenTimeouts.push(input.timeout);
        return { stdout: "ok" } as MockResult;
      },
    };
    const cmds: BatchCommand[] = [
      { label: "A", command: "x" },
      { label: "B", command: "y" },
    ];
    await runBatchCommands(
      cmds,
      { timeout: undefined, concurrency: 1, nodeOptsPrefix: NOOP_PREFIX },
      exec,
    );
    expect(seenTimeouts).toEqual([undefined, undefined]);
  });
});

describe("runBatchCommands parallel path (concurrency>1)", () => {
  test("happy path: 3 cmds at concurrency=3 finish in parallel", async () => {
    const exec = mkMockExecutor(async () => {
      await new Promise((r) => setTimeout(r, 100));
      return { stdout: "ok" };
    });
    const cmds: BatchCommand[] = [
      { label: "A", command: "x" },
      { label: "B", command: "y" },
      { label: "C", command: "z" },
    ];
    const start = Date.now();
    const { outputs, timedOut } = await runBatchCommands(cmds, { timeout: 5000, concurrency: 3, nodeOptsPrefix: NOOP_PREFIX }, exec);
    const elapsed = Date.now() - start;
    expect(timedOut).toBe(false);
    expect(outputs).toHaveLength(3);
    expect(elapsed).toBeLessThan(250); // 3x parallel ~100ms, with overhead room
  });

  test("parallel path preserves heredoc commands and combines captured stderr", async () => {
    const seenCodes: string[] = [];
    const exec = mkMockExecutor((code) => {
      seenCodes.push(code);
      return { stdout: `${code.includes("one") ? "one" : "two"} stdout\n`, stderr: `${code.includes("one") ? "one" : "two"} stderr\n` };
    });
    const cmds: BatchCommand[] = [
      { label: "ONE", command: "node - <<'NODE'\nconsole.log('one')\nNODE" },
      { label: "TWO", command: "python3 - <<'PY'\nprint('two')\nPY" },
    ];
    const { outputs, timedOut } = await runBatchCommands(cmds, { timeout: 5000, concurrency: 2, nodeOptsPrefix: NOOP_PREFIX }, exec);

    expect(timedOut).toBe(false);
    expect(seenCodes).toEqual(cmds.map((cmd) => cmd.command));
    expect(seenCodes.join("\n")).not.toContain("2>&1");
    expect(outputs[0]).toContain("one stdout");
    expect(outputs[0]).toContain("one stderr");
    expect(outputs[1]).toContain("two stdout");
    expect(outputs[1]).toContain("two stderr");
  });

  test("order preservation: outputs match input order, not completion order", async () => {
    const exec = mkMockExecutor(async (code) => {
      // Reverse-order delay: first cmd is slowest
      const delay = code.includes("first") ? 80 : code.includes("second") ? 40 : 10;
      await new Promise((r) => setTimeout(r, delay));
      return { stdout: code.replace("echo ", "") };
    });
    const cmds: BatchCommand[] = [
      { label: "FIRST", command: "echo first" },
      { label: "SECOND", command: "echo second" },
      { label: "THIRD", command: "echo third" },
    ];
    const { outputs } = await runBatchCommands(cmds, { timeout: 5000, concurrency: 3, nodeOptsPrefix: NOOP_PREFIX }, exec);
    expect(outputs[0]).toContain("# FIRST");
    expect(outputs[1]).toContain("# SECOND");
    expect(outputs[2]).toContain("# THIRD");
  });

  test("concurrency cap: 6 cmds at concurrency=2 never exceed 2 in flight", async () => {
    let inFlight = 0;
    let maxInFlight = 0;
    const exec = mkMockExecutor(async () => {
      inFlight++;
      maxInFlight = Math.max(maxInFlight, inFlight);
      await new Promise((r) => setTimeout(r, 30));
      inFlight--;
      return { stdout: "ok" };
    });
    const cmds: BatchCommand[] = Array.from({ length: 6 }, (_, i) => ({ label: `C${i}`, command: "x" }));
    await runBatchCommands(cmds, { timeout: 5000, concurrency: 2, nodeOptsPrefix: NOOP_PREFIX }, exec);
    expect(maxInFlight).toBeLessThanOrEqual(2);
    expect(maxInFlight).toBeGreaterThanOrEqual(2);
  });

  test("per-command timeout: one cmd times out, siblings continue", async () => {
    const exec = mkMockExecutor((code) => {
      if (code.includes("slow")) return { stdout: "", timedOut: true };
      return { stdout: "ok" };
    });
    const cmds: BatchCommand[] = [
      { label: "slow", command: "sleep slow" },
      { label: "fast", command: "echo fast" },
    ];
    const { outputs, timedOut } = await runBatchCommands(cmds, { timeout: 100, concurrency: 2, nodeOptsPrefix: NOOP_PREFIX }, exec);
    expect(timedOut).toBe(true);
    expect(outputs[0]).toContain("(timed out after 100ms)");
    expect(outputs[1]).toContain("ok");
  });

  test("concurrency exceeds cmd count: caps at cmd count, no spurious workers", async () => {
    let inFlight = 0;
    let maxInFlight = 0;
    const exec = mkMockExecutor(async () => {
      inFlight++;
      maxInFlight = Math.max(maxInFlight, inFlight);
      await new Promise((r) => setTimeout(r, 20));
      inFlight--;
      return { stdout: "ok" };
    });
    const cmds: BatchCommand[] = [{ label: "A", command: "x" }, { label: "B", command: "y" }];
    await runBatchCommands(cmds, { timeout: 5000, concurrency: 8, nodeOptsPrefix: NOOP_PREFIX }, exec);
    expect(maxInFlight).toBeLessThanOrEqual(2);
  });

  test("FS bytes callback fires per-command in parallel branch", async () => {
    const exec = mkMockExecutor((code) => ({
      stdout: code.includes("a") ? "out a\n__CM_FS__:100\n" : "out b\n__CM_FS__:200\n",
    }));
    const cmds: BatchCommand[] = [
      { label: "A", command: "echo a" },
      { label: "B", command: "echo b" },
    ];
    let totalBytes = 0;
    const { outputs } = await runBatchCommands(
      cmds,
      { timeout: 5000, concurrency: 2, nodeOptsPrefix: NOOP_PREFIX, onFsBytes: (b) => { totalBytes += b; } },
      exec,
    );
    expect(totalBytes).toBe(300);
    // markers stripped from output
    expect(outputs.join("")).not.toContain("__CM_FS__");
  });
});

describe("runBatchCommands edge cases", () => {
  test("empty commands array returns empty outputs", async () => {
    const exec = mkMockExecutor(() => ({ stdout: "" }));
    const { outputs, timedOut } = await runBatchCommands([], { timeout: 1000, concurrency: 1, nodeOptsPrefix: NOOP_PREFIX }, exec);
    expect(outputs).toHaveLength(0);
    expect(timedOut).toBe(false);
  });

  test("empty stdout becomes (no output) sentinel", async () => {
    const exec = mkMockExecutor(() => ({ stdout: "" }));
    const cmds: BatchCommand[] = [{ label: "A", command: "x" }];
    const { outputs } = await runBatchCommands(cmds, { timeout: 1000, concurrency: 1, nodeOptsPrefix: NOOP_PREFIX }, exec);
    expect(outputs[0]).toContain("(no output)");
  });

  test("nodeOptsPrefix is prepended to each command", async () => {
    const seen: string[] = [];
    const exec = mkMockExecutor((code) => {
      seen.push(code);
      return { stdout: "ok" };
    });
    const cmds: BatchCommand[] = [{ label: "A", command: "echo hi" }];
    await runBatchCommands(cmds, { timeout: 1000, concurrency: 1, nodeOptsPrefix: 'NODE_OPTIONS="--require /tmp/x" ' }, exec);
    expect(seen[0]).toBe('NODE_OPTIONS="--require /tmp/x" echo hi');
  });

  test("buildBatchNodeOptionsPrefix formats POSIX shell assignment", () => {
    const prefix = buildBatchNodeOptionsPrefix("bash", "/tmp/cm fs'preload.js");
    expect(prefix).toBe("NODE_OPTIONS='--require /tmp/cm fs'\\''preload.js' ");
  });

  test("buildBatchNodeOptionsPrefix formats PowerShell assignment", () => {
    const prefix = buildBatchNodeOptionsPrefix(
      "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
      "C:\\Temp\\cm ' fs.js",
    );
    expect(prefix).toBe("$env:NODE_OPTIONS='--require C:\\Temp\\cm '' fs.js'; ");
  });

  test("buildBatchNodeOptionsPrefix formats cmd assignment", () => {
    const prefix = buildBatchNodeOptionsPrefix(
      "C:\\Windows\\System32\\cmd.exe",
      "C:\\Temp\\cm-fs-preload.js",
    );
    expect(prefix).toBe('set "NODE_OPTIONS=--require C:\\Temp\\cm-fs-preload.js" && ');
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// runBatchCommands hardening — P0 fixes per PRD-concurrency-architectural §0
// ═══════════════════════════════════════════════════════════════════════════

describe("runBatchCommands P0 hardening", () => {
  test("finding A: executor throw is isolated, siblings complete", async () => {
    // One worker's executor.execute() throws (e.g. spawn EAGAIN under load).
    // Without try/catch, Promise.all would reject and strand sibling outputs
    // as `undefined`, surfacing as the literal "undefined" after .join("\n").
    const exec = mkMockExecutor((code) => {
      if (code.includes("boom")) throw new Error("spawn EAGAIN");
      return { stdout: code.includes("a") ? "alpha" : code.includes("b") ? "beta" : "gamma" };
    });
    const cmds: BatchCommand[] = [
      { label: "A", command: "echo a" },
      { label: "BOOM", command: "echo boom" },
      { label: "B", command: "echo b" },
      { label: "C", command: "echo c" },
    ];
    const { outputs } = await runBatchCommands(
      cmds,
      { timeout: 5000, concurrency: 4, nodeOptsPrefix: NOOP_PREFIX },
      exec,
    );
    expect(outputs).toHaveLength(4);
    expect(outputs[0]).toContain("# A");
    expect(outputs[0]).toContain("alpha");
    expect(outputs[1]).toContain("# BOOM");
    expect(outputs[1]).toContain("(executor error: spawn EAGAIN)");
    expect(outputs[2]).toContain("beta");
    expect(outputs[3]).toContain("gamma");
    // Critically: no `undefined` slots
    expect(outputs.every((o) => typeof o === "string" && o.length > 0)).toBe(true);
  });

  test("finding B: timed-out parallel command still strips __CM_FS__ markers + counts bytes", async () => {
    // Real subprocess timeouts often return partial stdout *with* the marker.
    // Pre-fix the parallel branch wrote the (timed out) sentinel directly,
    // bypassing formatCommandOutput → markers leaked into context, bytes uncounted.
    const exec = mkMockExecutor(() => ({
      stdout: "partial line 1\n__CM_FS__:512\npartial line 2\n",
      timedOut: true,
    }));
    let totalBytes = 0;
    const { outputs, timedOut } = await runBatchCommands(
      [{ label: "SLOW", command: "x" }],
      { timeout: 100, concurrency: 2, nodeOptsPrefix: NOOP_PREFIX, onFsBytes: (b) => { totalBytes += b; } },
      exec,
    );
    expect(timedOut).toBe(true);
    expect(totalBytes).toBe(512); // marker counted
    expect(outputs[0]).not.toContain("__CM_FS__"); // marker stripped
    expect(outputs[0]).toContain("partial line 1");
    expect(outputs[0]).toContain("partial line 2");
    expect(outputs[0]).toContain("(timed out after 100ms)"); // sentinel still appended
  });

  test("finding D: timing-regression — 5 cmds × 100ms at concurrency=5 finishes in <200ms", async () => {
    // Replaces the deleted bench (CONTRIBUTING.md L275 forbids new test files).
    // Asserts ≥3× speedup over serial. CI-checked.
    const exec = mkMockExecutor(async () => {
      await new Promise((r) => setTimeout(r, 100));
      return { stdout: "ok" };
    });
    const cmds: BatchCommand[] = Array.from({ length: 5 }, (_, i) => ({
      label: `C${i}`,
      command: "x",
    }));
    const start = Date.now();
    const { outputs, timedOut } = await runBatchCommands(
      cmds,
      { timeout: 5000, concurrency: 5, nodeOptsPrefix: NOOP_PREFIX },
      exec,
    );
    const elapsed = Date.now() - start;
    expect(timedOut).toBe(false);
    expect(outputs).toHaveLength(5);
    // Serial would be ~500ms (5×100). Parallel should be ~100ms + overhead.
    // Threshold 200ms gives generous CI room while still catching a regression to serial.
    expect(elapsed).toBeLessThan(200);
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// runPool — shared concurrency primitive (PRD finding G)
// ═══════════════════════════════════════════════════════════════════════════

import { runPool, type PoolJob } from "../../src/runPool.js";

describe("runPool primitive", () => {
  test("empty jobs returns empty settled array", async () => {
    const { settled, effectiveConcurrency, capped } = await runPool([], { concurrency: 4 });
    expect(settled).toHaveLength(0);
    expect(effectiveConcurrency).toBe(0);
    expect(capped).toBe(false);
  });

  test("happy path: order preserved, all fulfilled", async () => {
    const jobs: PoolJob<number>[] = [10, 20, 30, 40].map((v, i) => ({
      run: async () => {
        await new Promise((r) => setTimeout(r, (4 - i) * 10)); // reverse-order delay
        return v;
      },
    }));
    const { settled, effectiveConcurrency } = await runPool(jobs, { concurrency: 4 });
    expect(effectiveConcurrency).toBe(4);
    expect(settled.map((s) => s.status === "fulfilled" ? s.value : null)).toEqual([10, 20, 30, 40]);
  });

  test("throw isolation: one job rejects, siblings still fulfill", async () => {
    const jobs: PoolJob<string>[] = [
      { run: async () => "a" },
      { run: async () => { throw new Error("boom"); } },
      { run: async () => "c" },
    ];
    const { settled } = await runPool(jobs, { concurrency: 3 });
    expect(settled[0]).toEqual({ status: "fulfilled", value: "a" });
    expect(settled[1].status).toBe("rejected");
    expect((settled[1] as { reason: Error }).reason.message).toBe("boom");
    expect(settled[2]).toEqual({ status: "fulfilled", value: "c" });
  });

  test("in-flight cap: never exceeds concurrency", async () => {
    let inFlight = 0;
    let maxInFlight = 0;
    const jobs: PoolJob<void>[] = Array.from({ length: 10 }, () => ({
      run: async () => {
        inFlight++;
        maxInFlight = Math.max(maxInFlight, inFlight);
        await new Promise((r) => setTimeout(r, 20));
        inFlight--;
      },
    }));
    const { effectiveConcurrency, capped } = await runPool(jobs, { concurrency: 3 });
    expect(maxInFlight).toBeLessThanOrEqual(3);
    expect(maxInFlight).toBeGreaterThanOrEqual(2); // proves at least some parallelism
    expect(effectiveConcurrency).toBe(3);
    expect(capped).toBe(false);
  });

  test("auto-clamp to job count when concurrency > jobs.length", async () => {
    const jobs: PoolJob<number>[] = [{ run: async () => 1 }, { run: async () => 2 }];
    const { effectiveConcurrency, capped } = await runPool(jobs, { concurrency: 8 });
    expect(effectiveConcurrency).toBe(2);
    expect(capped).toBe(true);
  });

  test("capByCpuCount caps by os.cpus().length", async () => {
    // We can't predict the test runner's cpu count, so just assert the bounds.
    const jobs: PoolJob<number>[] = Array.from({ length: 32 }, (_, i) => ({ run: async () => i }));
    const { effectiveConcurrency, capped } = await runPool(jobs, { concurrency: 32, capByCpuCount: true });
    const cores = require("node:os").cpus().length;
    expect(effectiveConcurrency).toBeLessThanOrEqual(cores);
    expect(effectiveConcurrency).toBeLessThanOrEqual(32);
    expect(capped).toBe(effectiveConcurrency < 32);
  });

  test("onSettled callback fires per job in completion order", async () => {
    const events: number[] = [];
    const jobs: PoolJob<number>[] = [
      { run: async () => { await new Promise((r) => setTimeout(r, 30)); return 0; } },
      { run: async () => { await new Promise((r) => setTimeout(r, 10)); return 1; } },
      { run: async () => { await new Promise((r) => setTimeout(r, 20)); return 2; } },
    ];
    await runPool(jobs, { concurrency: 3, onSettled: (idx) => { events.push(idx); } });
    // Job 1 (10ms) completes first, then 2 (20ms), then 0 (30ms)
    expect(events).toEqual([1, 2, 0]);
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// ctx_fetch_and_index batch path — schema + handler-level checks
// (Full subprocess fetch tested in tests/mcp-integration.ts;
//  these tests verify schema acceptance + serial-index contract via source-level read.)
// ═══════════════════════════════════════════════════════════════════════════

describe("ctx_fetch_and_index batch refactor", () => {
  const fetchHandlerSrc = readFileSync(
    resolve(__dirname, "../../src/server.ts"),
    "utf-8",
  );

  test("schema accepts both legacy {url} and batch {requests}", () => {
    expect(fetchHandlerSrc).toContain('url: z.string().optional()');
    expect(fetchHandlerSrc).toContain('requests: z');
    // Zod array of {url, source?} wrapped with preprocess for native plugin coercion
    expect(fetchHandlerSrc).toContain('z.preprocess(');
    expect(fetchHandlerSrc).toContain('coerceJsonArray');
    expect(fetchHandlerSrc).toContain('url: z.string()');
    expect(fetchHandlerSrc).toContain('source: z.string().optional()');
  });

  test("handler exposes concurrency 1-8 with default 1", () => {
    // Find the fetch_and_index registerTool block, then assert concurrency schema near it.
    // Stop anchor: the next registerTool call (ctx_batch_execute).
    const fetchBlockMatch = fetchHandlerSrc.match(/registerTool\(\s*"ctx_fetch_and_index"[\s\S]+?registerTool\(\s*"ctx_batch_execute"/);
    expect(fetchBlockMatch).not.toBeNull();
    const block = fetchBlockMatch![0];
    expect(block).toContain("concurrency: z");
    expect(block).toMatch(/\.min\(1\)\s*\n?\s*\.max\(8\)/);
    expect(block).toContain(".default(1)");
  });

  test("handler exposes per-call ttl override for fetch cache freshness (#648)", () => {
    const fetchBlockMatch = fetchHandlerSrc.match(/registerTool\(\s*"ctx_fetch_and_index"[\s\S]+?registerTool\(\s*"ctx_batch_execute"/);
    expect(fetchBlockMatch).not.toBeNull();
    const block = fetchBlockMatch![0];
    expect(block).toContain("ttl: z");
    expect(block).toContain("Override the cache freshness window");
    expect(block).toContain("`ttl: 0` bypasses the cache like `force: true`");
    expect(block).toContain("async ({ url, source, requests, concurrency, force, ttl })");
    expect(block).toContain("fetchOneUrl(req.url, req.source, force, ttl)");
  });

  test("fetchOneUrl applies ttl override and treats ttl=0 as cache bypass (#648)", () => {
    const fetchOneSrc = fetchHandlerSrc.match(/async function fetchOneUrl\([\s\S]+?const outputPath =/m);
    expect(fetchOneSrc).not.toBeNull();
    const block = fetchOneSrc![0];
    expect(block).toContain("ttl: number | undefined");
    expect(block).toContain("if (!force && ttl !== 0)");
    expect(block).toContain("const cacheTtlMs = ttl ?? FETCH_TTL_MS");
    expect(block).toContain("ageMs < cacheTtlMs");
    expect(block).toContain("ttlStr: formatFetchTtl(cacheTtlMs)");
  });

  test("PARALLELIZE I/O guidance + locked requests:[] schema in description", () => {
    // PR #683 / ADR-0002: emoji bullets replaced with prose. PR #683 WS3
    // (canonical structure) folded the dedicated CONCURRENCY: section into
    // the WHEN: / RETURNS: prose so the description carries only the
    // canonical four sections (WHEN / WHEN NOT / RETURNS / EXAMPLE).
    // PR #683 second amendment then deepened the concurrency guidance
    // (gh API cap 4, single-writer mechanism explanation) and reframed the
    // FTS5 serialization in more technical terms ("write phase always runs
    // serially because SQLite is a single-writer store").
    // This test pins the load-bearing concepts (requests:[] batch shape,
    // 4-8 I/O window, FTS5 serial-write contract) using semantic regexes
    // so future copy-edits don't break it.
    expect(fetchHandlerSrc).toMatch(/requests(:\s*\[|`\s*array)/);
    expect(fetchHandlerSrc).toMatch(/4-8\s+(for|stable)/);
    expect(fetchHandlerSrc).toMatch(/FTS5[^.]*(serializes? writes?|write phase[^.]*serial|single-writer)/);
  });

  test("serial-write contract: index drain is a for-loop calling indexFetched serially", () => {
    // The handler must NOT spawn parallel store.index calls. The drain is a
    // for-loop over `settled` calling indexFetched serially. Anti-pattern check.
    expect(fetchHandlerSrc).toContain("Serial index drain");
    expect(fetchHandlerSrc).toContain("indexFetched(v)");
    // No `await Promise.all(... indexFetched ...)` pattern anywhere
    expect(fetchHandlerSrc).not.toMatch(/Promise\.all\([^)]*indexFetched/);
  });

  test("backward compat: legacy single-URL response wording preserved", () => {
    // Original handler returned "Cached: **${label}**" / "Fetched and indexed **N sections**"
    // The refactor must keep these EXACT strings for the legacy path so
    // tests/mcp-integration.ts and any user-side scripts grepping the response don't break.
    expect(fetchHandlerSrc).toContain("Cached: **${r.label}**");
    expect(fetchHandlerSrc).toContain("Fetched and indexed **${r.indexed.totalChunks} sections**");
    // The source escapes backticks inside a template literal — match the escaped form.
    expect(fetchHandlerSrc).toContain("To refresh: call ctx_fetch_and_index again with");
    expect(fetchHandlerSrc).toContain("force: true");
  });

  test("isLegacySingle gate prevents batch response wrapping for single-URL calls", () => {
    expect(fetchHandlerSrc).toContain("const isLegacySingle = !requests && batch.length === 1");
    expect(fetchHandlerSrc).toContain("if (isLegacySingle)");
  });

  test("capped-concurrency note appears only when capped", () => {
    expect(fetchHandlerSrc).toMatch(/cappedNote\s*=\s*capped\s*\?/);
    // Compact form `cap=N/Mcpu` (replaces verbose "capped from N to M; M cores available").
    expect(fetchHandlerSrc).toContain("cap=${effectiveConcurrency}/${cpus().length}cpu");
  });

  test("batch isError only when ALL URLs fail (errorCount === batch.length)", () => {
    expect(fetchHandlerSrc).toContain("isError: errorCount === batch.length");
  });

  test("batch preview is capped to prevent context flooding (review F2)", () => {
    // Per-URL preview in batch mode capped tightly so an 8-URL batch doesn't
    // dump ~24KB of context (8 × 3072 char single-URL preview cap).
    expect(fetchHandlerSrc).toContain("FETCH_BATCH_PREVIEW_LIMIT");
    // Cap value must be ≤500 chars (8 URLs × 500 = ~4KB max snippets total)
    const limitMatch = fetchHandlerSrc.match(/FETCH_BATCH_PREVIEW_LIMIT\s*=\s*(\d+)/);
    expect(limitMatch).not.toBeNull();
    expect(parseInt(limitMatch![1])).toBeLessThanOrEqual(500);
    // Must actually be applied to per-URL previews in the batch loop
    expect(fetchHandlerSrc).toMatch(/preview\.length\s*>\s*FETCH_BATCH_PREVIEW_LIMIT/);
  });

  test("batch header uses singular form for count=1 (review F5 plural fix)", () => {
    // Grammar correctness in the compact status line: "1 errors" → "1 error"
    // via the fmt() helper, so the line stays grammatical at any count.
    expect(fetchHandlerSrc).toContain('const fmt = (n: number, sing: string, plur: string)');
    expect(fetchHandlerSrc).toContain('n === 1 ? sing : plur');
  });

  test("batch header uses compact format (review F5)", () => {
    // Old: "Batch fetched N URLs at concurrency=X (capped from Y to X; Z cores available): a fetched, b cached, c errors. d new sections (eKB total)."
    // New: "fetched N c=X cap=X/Zcpu. ok=a cache=b err=c. d sections eKB."
    expect(fetchHandlerSrc).toContain("`fetched ${batch.length} c=${effectiveConcurrency}");
    expect(fetchHandlerSrc).toContain("ok=${fetchedCount} cache=${cachedCount} err=${errorCount}");
    expect(fetchHandlerSrc).not.toContain("Batch fetched"); // old verbose wording gone
  });

  test("fetchOneUrl is parallel-safe (no SQLite writes)", () => {
    // Verify by inspecting the helper source: it calls store.getSourceMeta (read)
    // but never store.index/indexJSON/indexPlainText (writes).
    const fetchOneSrc = fetchHandlerSrc.match(/async function fetchOneUrl\([\s\S]+?^}/m);
    expect(fetchOneSrc).not.toBeNull();
    const block = fetchOneSrc![0];
    expect(block).toContain("store.getSourceMeta"); // read OK
    expect(block).not.toContain("store.index"); // no writes
    expect(block).not.toContain("store.indexJSON");
    expect(block).not.toContain("store.indexPlainText");
  });

  test("indexFetched is serial-only (single FTS5 write per call)", () => {
    const indexFetchedSrc = fetchHandlerSrc.match(/function indexFetched\([\s\S]+?^}/m);
    expect(indexFetchedSrc).not.toBeNull();
    const block = indexFetchedSrc![0];
    // Has exactly one of: store.index / store.indexJSON / store.indexPlainText per branch
    expect(block).toContain("store.indexJSON");
    expect(block).toContain("store.indexPlainText");
    expect(block).toContain("store.index");
  });

  test("force and requests parameters coerce string types from in-process native plugins", () => {
    // OpenCode/Kilo in-process plugin bridge stringifies primitive types
    // (boolean → "false", array → "[]"). z.preprocess(coerceBoolean/coerceJsonArray)
    // defends against this in the Zod parse step. This test verifies those
    // preprocess wrappers are present (issue #627 follow-up).
    const fetchBlockMatch = fetchHandlerSrc.match(/registerTool\(\s*"ctx_fetch_and_index"[\s\S]+?registerTool\(\s*"ctx_batch_execute"/);
    expect(fetchBlockMatch).not.toBeNull();
    const block = fetchBlockMatch![0];
    // force must coerce "false"/"true" strings → boolean
    expect(block).toMatch(/force:\s*z\s*\n?\s*\.preprocess\(\s*coerceBoolean\s*,\s*z\.boolean\(\)\)/);
    // requests must coerce JSON-stringified arrays
    expect(block).toMatch(/requests:\s*z\s*\n?\s*\.preprocess\(\s*coerceJsonArray\s*,/);
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// SSRF guard — ctx_fetch_and_index URL/IP allowlist (PR #401 ops review)
// ═══════════════════════════════════════════════════════════════════════════

import { classifyIp } from "../../src/server.js";

describe("classifyIp — SSRF guard IP classifier", () => {
  test("hard-blocks IMDS / link-local IPv4 (169.254.0.0/16)", () => {
    // 169.254.169.254 = AWS/GCP/Azure cloud metadata endpoint — never legitimate
    expect(classifyIp("169.254.169.254")).toBe("block");
    expect(classifyIp("169.254.0.1")).toBe("block");
    expect(classifyIp("169.254.255.254")).toBe("block");
  });

  test("hard-blocks multicast / reserved IPv4 (224+ and 0.0.0.0/8)", () => {
    expect(classifyIp("224.0.0.1")).toBe("block");
    expect(classifyIp("239.255.255.255")).toBe("block");
    expect(classifyIp("255.255.255.255")).toBe("block");
    expect(classifyIp("0.0.0.0")).toBe("block");
    expect(classifyIp("0.1.2.3")).toBe("block");
  });

  test("hard-blocks malformed IPv4", () => {
    expect(classifyIp("999.999.999.999")).toBe("block");
    expect(classifyIp("not-an-ip")).toBe("block");
    expect(classifyIp("1.2.3")).toBe("block");
  });

  test("hard-blocks IPv6 link-local + multicast + unspecified", () => {
    expect(classifyIp("fe80::1")).toBe("block"); // link-local
    expect(classifyIp("ff00::1")).toBe("block"); // multicast
    expect(classifyIp("::")).toBe("block");      // unspecified
  });

  test("private (allow by default, block under strict mode): RFC1918 + loopback IPv4", () => {
    // Allowed by default — developer's local dev server / internal network
    expect(classifyIp("127.0.0.1")).toBe("private");
    expect(classifyIp("127.255.255.255")).toBe("private");
    expect(classifyIp("10.0.0.5")).toBe("private");
    expect(classifyIp("10.255.255.255")).toBe("private");
    expect(classifyIp("172.16.0.1")).toBe("private");
    expect(classifyIp("172.31.255.255")).toBe("private");
    expect(classifyIp("172.15.0.1")).toBe("public");  // outside RFC1918
    expect(classifyIp("172.32.0.1")).toBe("public");  // outside RFC1918
    expect(classifyIp("192.168.1.1")).toBe("private");
    expect(classifyIp("192.168.255.255")).toBe("private");
  });

  test("private: IPv6 loopback + ULA (fc00::/7)", () => {
    expect(classifyIp("::1")).toBe("private");
    expect(classifyIp("fc00::1")).toBe("private");
    expect(classifyIp("fd12:3456:789a::1")).toBe("private");
  });

  test("public: real internet IPs", () => {
    expect(classifyIp("8.8.8.8")).toBe("public");           // Google DNS
    expect(classifyIp("1.1.1.1")).toBe("public");           // Cloudflare DNS
    expect(classifyIp("140.82.121.4")).toBe("public");      // github.com
    expect(classifyIp("2001:4860:4860::8888")).toBe("public"); // Google DNS IPv6
  });

  test("IPv4-mapped IPv6 recurses through IPv4 classifier", () => {
    // ::ffff:127.0.0.1 is just 127.0.0.1 wrapped in IPv6 mapping
    expect(classifyIp("::ffff:127.0.0.1")).toBe("private");
    expect(classifyIp("::ffff:169.254.169.254")).toBe("block"); // IMDS via IPv4-mapped
    expect(classifyIp("::ffff:8.8.8.8")).toBe("public");
  });
});

describe("SSRF guard — ssrfGuard policy in src/server.ts", () => {
  const serverSrc = readFileSync(
    resolve(__dirname, "../../src/server.ts"),
    "utf-8",
  );

  test("allowlists only http: and https: schemes", () => {
    expect(serverSrc).toContain('parsed.protocol !== "http:"');
    expect(serverSrc).toContain('parsed.protocol !== "https:"');
    // file:// / gopher:// / javascript: implicitly rejected
  });

  test("blocks IPs classified as block (link-local/IMDS/multicast)", () => {
    expect(serverSrc).toContain('verdict === "block"');
    expect(serverSrc).toContain("link-local / IMDS / multicast / reserved");
  });

  test("strict mode opt-in via CTX_FETCH_STRICT=1", () => {
    expect(serverSrc).toContain('process.env.CTX_FETCH_STRICT === "1"');
    expect(serverSrc).toContain('verdict === "private" && strict');
  });

  test("ssrfGuard runs BEFORE cache lookup (poisoned cache defense)", () => {
    // fetchOneUrl must call ssrfGuard before getSourceMeta — otherwise a
    // previously-poisoned source label could serve attacker content from cache.
    const fetchOneSrc = serverSrc.match(/async function fetchOneUrl\([\s\S]+?^}/m);
    expect(fetchOneSrc).not.toBeNull();
    const block = fetchOneSrc![0];
    const guardIdx = block.indexOf("ssrfGuard");
    const cacheIdx = block.indexOf("getSourceMeta");
    expect(guardIdx).toBeGreaterThan(-1);
    expect(cacheIdx).toBeGreaterThan(-1);
    expect(guardIdx).toBeLessThan(cacheIdx);
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// buildFetchCode — embedded SSRF guard contract (#476 review follow-ups)
// ═══════════════════════════════════════════════════════════════════════════

import { buildFetchCode } from "../../src/server.js";

describe("buildFetchCode — embedded SSRF guard contract", () => {
  const generated = buildFetchCode("https://example.com/x", "/tmp/x");

  test("strips proxy env vars (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY)", () => {
    // A configured outbound proxy would route fetch through an arbitrary
    // target; DNS resolution would happen at the proxy and the in-subprocess
    // DNS guard would never see the rebound IP. The generated subprocess
    // source must delete every proxy env var before any fetch can run.
    expect(generated).toMatch(/delete process\.env\.HTTP_PROXY/);
    expect(generated).toMatch(/delete process\.env\.HTTPS_PROXY/);
    expect(generated).toMatch(/delete process\.env\.ALL_PROXY/);
    expect(generated).toMatch(/delete process\.env\.http_proxy/);
    expect(generated).toMatch(/delete process\.env\.https_proxy/);
    expect(generated).toMatch(/delete process\.env\.all_proxy/);
  });

  test("embedded SSRF classifier is callable as `classifyIp` even when bundler renames the export (#bug-v1.0.133)", () => {
    // REGRESSION: esbuild renames top-level `classifyIp` to a short name
    // (e.g. `_h`) in server.bundle.mjs. The previous implementation embedded
    // `classifyIp.toString()` directly, which yielded `function _h(t){...}`
    // — but the subprocess template invokes `classifyIp(...)` literally and
    // the function's own internal recursion uses the bundler-mangled name.
    // Result was 100% failure of ctx_fetch_and_index in the published build:
    //   ReferenceError: classifyIp is not defined
    //     at patchedPromisesLookup (.../script.js:71:19)
    // The fix must: (1) expose the canonical `classifyIp` identifier in the
    // embedded scope, AND (2) preserve recursion under whatever name the
    // bundler chose. Validate by evaluating the embedded source in an
    // isolated scope and confirming `classifyIp` resolves and works for
    // both direct calls AND the recursive IPv4-mapped-IPv6 path.
    expect(generated).toMatch(/var\s+classifyIp\s*=/);

    // Extract the self-contained classifier declaration block (var classifyIp
    // = function classifyIp(rawIp){...};) and evaluate it in a fresh function
    // scope where neither `classifyIp` nor any bundler alias exists in the
    // outer closure. The canonical name MUST resolve and behave.
    const classifyIpDeclMatch = generated.match(
      /var\s+\w+\s*=\s*function\s+\w+\s*\(\s*rawIp\s*\)[\s\S]+?\n\};(?:\s*var\s+classifyIp\s*=\s*\w+;)?/,
    );
    expect(
      classifyIpDeclMatch,
      "embedded classifyIp declaration block must be extractable from buildFetchCode output",
    ).not.toBeNull();
    const classifierBlock = classifyIpDeclMatch![0];

    // eslint-disable-next-line @typescript-eslint/no-implied-eval
    const probe = new Function(`
      ${classifierBlock}
      return {
        imds: classifyIp("169.254.169.254"),
        loopback: classifyIp("127.0.0.1"),
        publicIp: classifyIp("8.8.8.8"),
        mapped: classifyIp("::ffff:169.254.169.254"),
      };
    `);
    const result = probe() as Record<string, string>;
    expect(result.imds).toBe("block");
    expect(result.loopback).toBe("private");
    expect(result.publicIp).toBe("public");
    // IPv4-mapped IPv6 forces the internal recursive call path; if recursion
    // is broken (bundler-mangled self-reference unresolved), this throws.
    expect(result.mapped).toBe("block");
  });

  test("patches dns/promises lookup (separate function reference from dns.lookup)", () => {
    // Patching dns.lookup does NOT affect dnsPromises.lookup. Today undici
    // uses callback-form dns.lookup so default fetch is covered, but the
    // invariant is fragile — a future undici switch or any caller using
    // dnsPromises.lookup directly would bypass the guard.
    // Match either literal 'node:dns/promises' or split-string 'no'+'de:dns/promises'
    // The split form is required by the G3 bundle invariant — the literal would
    // false-positive scripts/assert-bundle.mjs's raw-bare-require-node-builtin check.
    expect(generated).toMatch(
      /const dnsPromises\s*=\s*require\([^)]*dns\/promises['"]\)/,
    );
    expect(generated).toMatch(/dnsPromises\.lookup\s*=\s*async\s+function/);
    expect(generated).toMatch(/SSRF blocked/);
  });

  test("patches dns.resolve4 and dns.resolve6 (libuv bypass path)", () => {
    // dns.resolve* uses a different code path (no getaddrinfo, no /etc/hosts)
    // than dns.lookup — they must be patched separately or the guard is
    // trivially bypassed by any caller using dns.resolve* directly.
    expect(generated).toMatch(/['"]resolve4['"]\s*,\s*['"]resolve6['"]/);
    expect(generated).toMatch(/dns\[name\]\s*=\s*function/);
  });

  describe("buildFetchCode — redirect chain rebinding", () => {
    // SSRF rebinding via HTTP redirect chain bypasses the parent's pre-flight
    // ssrfGuard: a 302 to http://attacker/ (or an IPv4-mapped IMDS literal)
    // sends the subprocess fetch to an alternate host the parent never
    // classified. The connect-time DNS guard catches some cases, but a
    // direct-IP redirect target may not trigger getaddrinfo at all. Mitigate
    // at the HTTP layer: emit `redirect: 'manual'` in the generated source,
    // re-validate every Location header against ssrfGuard's classifier, and
    // cap the redirect chain so an attacker cannot exhaust the loop.
    const generated = buildFetchCode("https://example.com/x", "/tmp/x");

    test("generated source uses redirect: 'manual' (no follow default)", () => {
      // The default `redirect: 'follow'` lets undici chase a 3xx Location
      // BEFORE the in-subprocess DNS guard sees the target hostname (and even
      // when it does, a direct IPv4-literal redirect skips getaddrinfo). The
      // generated subprocess source MUST opt out of automatic following so
      // every hop is re-validated by classifyIp before another fetch fires.
      expect(generated).toMatch(/redirect:\s*['"]manual['"]/);
    });

    test("manual redirect handler validates Location host via classifyIp", () => {
      // After receiving a 3xx, the subprocess must parse the Location header,
      // resolve its host, and run the same classifyIp policy as ssrfGuard
      // before issuing the next fetch. Without this re-check, an attacker can
      // redirect to http://169.254.169.254/ or any rebinding-friendly host.
      expect(generated).toMatch(/Location/);
      expect(generated).toMatch(/classifyIp\s*\(/);
      // The redirect handler specifically must invoke classifyIp on the
      // redirect target — not just the parent's pre-flight call site.
      expect(generated).toMatch(/redirect[\s\S]{0,400}classifyIp/);
    });

    test("redirect chain is capped (no unbounded follow)", () => {
      // An attacker controlling redirect responses could otherwise loop the
      // subprocess forever or chain enough hops to amortize a slow rebinding
      // attack. Cap at 5 — the standard browser limit — and abort cleanly.
      expect(generated).toMatch(/(maxRedirects|MAX_REDIRECTS|redirectCount\s*[<>]=?\s*5|<\s*5|<=\s*5)/);
    });

    test("non-3xx response path still emits content (preserves 200 semantics)", () => {
      // Manual redirect handling must not break the happy path: a 200 OK
      // still flows through emit() with the right content-type branch. Pin
      // the existing emit() call sites so a refactor that drops them fails.
      expect(generated).toMatch(/emit\(['"]json['"]/);
      expect(generated).toMatch(/emit\(['"]html['"]/);
      expect(generated).toMatch(/emit\(['"]text['"]/);
    });
  });

  test("classifyIp embeds without references to module scope", () => {
    // classifyIp is embedded into the subprocess via Function.prototype.toString().
    // If a future change ever has classifyIp close over a module-scope helper
    // (e.g. a regex constant declared above it), the embedded source will
    // ReferenceError at parse or call time — silently breaking the guard. Pin
    // the contract: classifyIp source must contain no identifiers that can
    // only resolve in module scope.
    const src = classifyIp.toString();
    const forbidden = [
      "require(",
      "process.",
      "globalThis.",
      "global.",
      "__dirname",
      "__filename",
    ];
    for (const ident of forbidden) {
      expect(src).not.toContain(ident);
    }
    // Roundtrip: eval the source in an empty vm context with no globals,
    // then invoke it. This is exactly what the subprocess does.
    const { runInNewContext } = require("node:vm");
    const ctx: Record<string, unknown> = {};
    runInNewContext(`${src}\n;globalThis.fn = classifyIp;`, ctx);
    const fn = ctx.fn as (ip: string) => "block" | "private" | "public";
    expect(fn("169.254.169.254")).toBe("block");
    expect(fn("8.8.8.8")).toBe("public");
    expect(fn("10.0.0.1")).toBe("private");
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// buildFetchCode — IPv6 zone-id + generic dns.resolve (#476 round-3)
// ═══════════════════════════════════════════════════════════════════════════

describe("buildFetchCode — IPv6 zone-id + generic dns.resolve", () => {
  test("classifyIp strips IPv6 zone-id before classification (link-local)", () => {
    // fe80::/10 link-local with %eth0 zone suffix must still be blocked.
    // Today the lowercase prefix check `fe8`/`fe9`/`fea`/`feb` accidentally
    // catches link-local even with a zone suffix — but only because the
    // suffix is appended *after* the prefix. Pin the behavior explicitly so
    // a future refactor cannot regress.
    expect(classifyIp("fe80::1%eth0")).toBe("block");
    expect(classifyIp("fe80::1%25eth0")).toBe("block"); // URL-encoded zone
  });

  test("classifyIp strips IPv6 zone-id before classification (non-link-local)", () => {
    // RFC 6874 permits zone identifiers on any IPv6 address (not just
    // link-local). Without zone stripping, a loopback `::1%eth0` would NOT
    // match the strict equality `lower === "::1"` and would leak through as
    // "public". Pin every class so the strip happens before classification.
    expect(classifyIp("::1%eth0")).toBe("private");        // loopback w/ zone
    expect(classifyIp("fc00::1%eth0")).toBe("private");    // ULA w/ zone
    expect(classifyIp("ff00::1%eth0")).toBe("block");      // multicast w/ zone
    expect(classifyIp("2001:db8::1%eth0")).toBe("public"); // doc range w/ zone
  });

  test("buildFetchCode patches generic dns.resolve (not just resolve4/resolve6)", () => {
    // dns.resolve is the polymorphic entrypoint that dispatches on rrtype.
    // Today only resolve4/resolve6 are patched; a caller using
    // `dns.resolve(host, 'A', cb)` or default rrtype goes through an
    // un-guarded code path. Patch the generic wrapper so every A/AAAA
    // record runs through classifyIp.
    const generated = buildFetchCode("https://example.com/x", "/tmp/x");
    expect(generated).toMatch(/dns\.resolve\s*=\s*function/);
    expect(generated).toMatch(/classifyIp/);
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// ctx_doctor resource cleanup regression (#247)
// ═══════════════════════════════════════════════════════════════════════════

const mcpEntry = resolve(__dirname, "..", "..", "start.mjs");

interface DoctorJsonRpcResponse {
  jsonrpc: "2.0";
  id: number;
  result?: {
    content?: Array<{ type: string; text: string }>;
    isError?: boolean;
    serverInfo?: { name: string; version: string };
  };
  error?: { code: number; message: string };
}

function startMcpServer(extraEnv: Record<string, string> = {}): ChildProcess {
  return spawn("node", [mcpEntry], {
    stdio: ["pipe", "pipe", "pipe"],
    env: { ...process.env, QUIET_CONTEXT_DISABLE_VERSION_CHECK: "1", ...extraEnv },
  });
}

function sendRpc(proc: ChildProcess, msg: Record<string, unknown>): void {
  proc.stdin!.write(JSON.stringify(msg) + "\n");
}

/**
 * Read RPC responses from the server stdout until all `expectedIds` have
 * arrived or `timeoutMs` elapses, whichever comes first. Early-exit keeps
 * the happy path at <1s and gives Windows CI its full timeout budget when
 * process spawn + native-module load runs slow.
 */
function collectRpcResponses(
  proc: ChildProcess,
  timeoutMs: number,
  expectedIds: number[],
): Promise<DoctorJsonRpcResponse[]> {
  return new Promise((res) => {
    const expected = new Set(expectedIds);
    const seen = new Map<number, DoctorJsonRpcResponse>();
    let buffer = "";
    let timer: ReturnType<typeof setTimeout>;

    const finish = () => {
      clearTimeout(timer);
      try { proc.kill("SIGTERM"); } catch { /* best effort */ }
      res(Array.from(seen.values()));
    };

    proc.stdout!.on("data", (d: Buffer) => {
      buffer += d.toString();
      // Drain whole lines from the buffer. Stdout is newline-delimited JSON-RPC.
      let idx: number;
      while ((idx = buffer.indexOf("\n")) >= 0) {
        const line = buffer.slice(0, idx).trim();
        buffer = buffer.slice(idx + 1);
        if (!line) continue;
        try {
          const parsed = JSON.parse(line) as DoctorJsonRpcResponse;
          if (typeof parsed.id === "number" && expected.has(parsed.id)) {
            seen.set(parsed.id, parsed);
            if (seen.size === expected.size) {
              finish();
              return;
            }
          }
        } catch { /* ignore malformed / partial lines */ }
      }
    });

    timer = setTimeout(finish, timeoutMs);
  });
}

async function initAndCallDoctor(
  proc: ChildProcess,
  invocations: number,
  windowMs = 15_000,
): Promise<DoctorJsonRpcResponse[]> {
  sendRpc(proc, {
    jsonrpc: "2.0", id: 1, method: "initialize",
    params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ctx-doctor-regression", version: "1.0" } },
  });
  sendRpc(proc, { jsonrpc: "2.0", method: "notifications/initialized" });
  const ids: number[] = [];
  for (let i = 0; i < invocations; i++) {
    const id = 100 + i;
    ids.push(id);
    sendRpc(proc, { jsonrpc: "2.0", id, method: "tools/call", params: { name: "ctx_doctor", arguments: {} } });
  }
  return collectRpcResponses(proc, windowMs, ids);
}

describe("ctx_doctor — resource cleanup regression (#247)", () => {
  test("single ctx_doctor call returns a status report", async () => {
    const proc = startMcpServer();
    const responses = await initAndCallDoctor(proc, 1);
    const call = responses.find((r) => r.id === 100);
    expect(call).toBeDefined();
    expect(call!.error).toBeUndefined();
    const text = call!.result?.content?.[0]?.text ?? "";
    expect(text).toContain("context-mode doctor");
    expect(text).toMatch(/Server test:/);
    expect(text).toMatch(/FTS5 \/ SQLite:/);
  }, 30_000);

  test("ctx_doctor reports storage roots and ignored empty override", async () => {
    const storageRoot = mkdtempSync(join(tmpdir(), "ctx-doctor-storage-"));
    const proc = startMcpServer({ QUIET_CONTEXT_DIR: " \t ", HOME: storageRoot, USERPROFILE: storageRoot });
    const responses = await initAndCallDoctor(proc, 1);
    const call = responses.find((r) => r.id === 100);

    expect(call).toBeDefined();
    expect(call!.error).toBeUndefined();
    const text = call!.result?.content?.[0]?.text ?? "";
    expect(text).toContain("Storage sessions:");
    expect(text).toContain("Storage content:");
    expect(text).toContain("Storage stats:");
    expect(text).toContain("(default; ignored empty QUIET_CONTEXT_DIR)");
  }, 30_000);

  test("ctx_doctor reports storage root override source", async () => {
    const storageRoot = mkdtempSync(join(tmpdir(), "ctx-doctor-storage-root-"));
    const proc = startMcpServer({ QUIET_CONTEXT_DIR: storageRoot });
    const responses = await initAndCallDoctor(proc, 1);
    const call = responses.find((r) => r.id === 100);

    expect(call).toBeDefined();
    expect(call!.error).toBeUndefined();
    const text = call!.result?.content?.[0]?.text ?? "";
    expect(text).toContain(`Storage sessions: ${join(storageRoot, "sessions")} (via QUIET_CONTEXT_DIR)`);
    expect(text).toContain(`Storage content: ${join(storageRoot, "content")} (via QUIET_CONTEXT_DIR)`);
    expect(text).toContain(`Storage stats: ${join(storageRoot, "sessions")} (via QUIET_CONTEXT_DIR)`);
  }, 30_000);

  test("three concurrent ctx_doctor calls all succeed without crashing the server", async () => {
    const proc = startMcpServer();
    const responses = await initAndCallDoctor(proc, 3, 20_000);
    const calls = [100, 101, 102].map((id) => responses.find((r) => r.id === id));
    for (const c of calls) {
      expect(c, "missing ctx_doctor response — server likely crashed").toBeDefined();
      expect(c!.error).toBeUndefined();
      expect(c!.result?.content?.[0]?.text).toContain("context-mode doctor");
    }
  }, 35_000);
});

// ═══════════════════════════════════════════════════════════════════════════
// ctx_doctor renderer-safe output (Mickey #3 — Z.ai GLM compatibility)
// ═══════════════════════════════════════════════════════════════════════════
//
// Z.ai's MCP renderer crashes with `ReferenceError: client is not defined`
// when it parses GitHub-flavored markdown task-list syntax (`- [x]`, `- [ ]`,
// `- [-]`). To stay safe across all MCP clients (including renderers that mount
// custom React components for task lists or h2 headers), ctx_doctor MUST emit
// plain-text status prefixes (`[OK]`, `[FAIL]`, `[WARN]`) and avoid `##`
// headings.
describe("ctx_doctor — renderer-safe output (Z.ai compat)", () => {
  test("output uses [OK]/[FAIL]/[WARN] prefixes and no markdown task-list syntax", async () => {
    const proc = startMcpServer();
    const responses = await initAndCallDoctor(proc, 1);
    const call = responses.find((r) => r.id === 100);
    expect(call).toBeDefined();
    expect(call!.error).toBeUndefined();
    const text: string = call!.result?.content?.[0]?.text ?? "";

    // Must NOT contain GFM task-list syntax (triggers Z.ai's broken renderer)
    expect(text).not.toMatch(/-\s+\[x\]/);
    expect(text).not.toMatch(/-\s+\[ \]/);
    expect(text).not.toMatch(/-\s+\[-\]/);

    // Must NOT contain `## ` h2 (some renderers mount custom h2 components)
    expect(text).not.toMatch(/^##\s/m);

    // Must use plain-text status prefixes
    expect(text).toMatch(/\[OK\]/);
    // Header is plain text, no markdown
    expect(text).toMatch(/^context-mode doctor/m);
  }, 30_000);
});

// ═══════════════════════════════════════════════════════════════════════════
// Pre-detection session dir (race-condition fix)
// ═══════════════════════════════════════════════════════════════════════════
//
// Before the MCP `initialize` handshake completes, `_detectedAdapter` is null.
// Tools called in that window must still resolve a platform-correct sessions
// dir instead of falling back to hardcoded `~/.claude/context-mode/sessions/`.
//
// `getSessionDirSegments` is a sync, env-free map from PlatformId → segments
// (no adapter instantiation). `getSessionDir` calls `detectPlatform()` (sync,
// env-var-based) and feeds the result into the map. Falls back to `.claude`
// only if the map returns null (defensive — covers "unknown" PlatformId).

describe("ctx_doctor hook script checks", () => {
  const serverSrc = readFileSync(
    resolve(__dirname, "../../src/server.ts"),
    "utf-8",
  );

  test("resolves relative hook script paths against pluginRoot", () => {
    const doctorSection = serverSrc.slice(serverSrc.indexOf("ctx_doctor"), serverSrc.indexOf("ctx_upgrade"));
    expect(doctorSection).toContain("for (const scriptPath of hookScriptPaths)");
    expect(doctorSection).toContain("const hookPath = resolve(pluginRoot, scriptPath)");
    expect(doctorSection).not.toContain("for (const hookPath of hookScriptPaths)");
  });
});

describe("getSessionDirSegments — sync platform → segments map", () => {
  test("returns correct segments for every supported platform", async () => {
    const { getSessionDirSegments } = await import("../../src/adapters/detect.js");
    expect(getSessionDirSegments("claude-code")).toEqual([".claude"]);
    expect(getSessionDirSegments("codex")).toEqual([".codex"]);
    expect(getSessionDirSegments("qwen-code")).toEqual([".qwen"]);
    expect(getSessionDirSegments("gemini-cli")).toEqual([".gemini"]);
    expect(getSessionDirSegments("kiro")).toEqual([".kiro"]);
    expect(getSessionDirSegments("cursor")).toEqual([".cursor"]);
    expect(getSessionDirSegments("openclaw")).toEqual([".openclaw"]);
    expect(getSessionDirSegments("vscode-copilot")).toEqual([".vscode"]);
    expect(getSessionDirSegments("antigravity")).toEqual([".gemini"]);
    expect(getSessionDirSegments("pi")).toEqual([".pi"]);
    expect(getSessionDirSegments("kilo")).toEqual([".config", "kilo"]);
    expect(getSessionDirSegments("opencode")).toEqual([".config", "opencode"]);
    expect(getSessionDirSegments("zed")).toEqual([".config", "zed"]);
    expect(getSessionDirSegments("jetbrains-copilot")).toEqual([".config", "JetBrains"]);
  });

  test("returns null for unknown platform", async () => {
    const { getSessionDirSegments } = await import("../../src/adapters/detect.js");
    expect(getSessionDirSegments("unknown")).toBeNull();
    expect(getSessionDirSegments("not-a-platform")).toBeNull();
  });
});

describe("getDefaultSessionDir uses pre-detection when adapter not yet detected", () => {
  const serverSrc = readFileSync(
    resolve(__dirname, "../../src/server.ts"),
    "utf-8",
  );

  test("getDefaultSessionDir invokes detectPlatform + getSessionDirSegments before fallback", () => {
    const fn = serverSrc.match(/function getDefaultSessionDir\(\)[\s\S]*?^}/m);
    expect(fn, "getDefaultSessionDir not found in server.ts").not.toBeNull();
    const body = fn![0];
    // Pre-detection path must consult detectPlatform() and the sync segments map
    expect(body).toContain("detectPlatform");
    expect(body).toContain("getSessionDirSegments");
  });

  test("getDefaultSessionDir falls back to .claude only as last resort", () => {
    const fn = serverSrc.match(/function getDefaultSessionDir\(\)[\s\S]*?^}/m);
    expect(fn).not.toBeNull();
    const body = fn![0];
    // The .claude literal must still appear (last-resort fallback) but only
    // after both pre-detection branches. Verify the ordering: detectPlatform
    // call comes before the literal.
    const detectIdx = body.indexOf("detectPlatform");
    const claudeIdx = body.indexOf('".claude"');
    expect(detectIdx).toBeGreaterThan(-1);
    expect(claudeIdx).toBeGreaterThan(-1);
    expect(detectIdx).toBeLessThan(claudeIdx);
  });

  test("getDefaultSessionDir honors CODEX_HOME in the Codex pre-detection branch", () => {
    const fn = serverSrc.match(/function getDefaultSessionDir\(\)[\s\S]*?^}/m);
    expect(fn).not.toBeNull();
    const body = fn![0];
    expect(body).toContain("getSessionDirSegments(signal.platform)");
    expect(body).toContain("configDirEnvForSessionSegments(segments)");
    expect(serverSrc).toContain(
      'if (segments.length === 1 && segments[0] === ".codex") return "CODEX_HOME";',
    );
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// ctx_fetch_and_index cache-key collision (Fix 6/10)
// ═══════════════════════════════════════════════════════════════════════════
//
// Bug: cache key was `source ?? url`, so two distinct URLs sharing a `source`
// label silently returned the cached first response instead of fetching the
// second. Fix composes the cache key from label+url for cache lookup.

describe("ctx_fetch_and_index cache key includes URL (Fix 6/10)", () => {
  test("composeFetchCacheKey: same label + different URLs produce different keys", async () => {
    const { composeFetchCacheKey } = await import("../../src/fetch-cache.js");
    const k1 = composeFetchCacheKey("Docs", "https://x.com/a");
    const k2 = composeFetchCacheKey("Docs", "https://y.com/b");
    expect(k1).not.toBe(k2);
  });

  test("composeFetchCacheKey: same label + same URL → same key (legitimate cache hit)", async () => {
    const { composeFetchCacheKey } = await import("../../src/fetch-cache.js");
    const k1 = composeFetchCacheKey("Docs", "https://x.com/a");
    const k2 = composeFetchCacheKey("Docs", "https://x.com/a");
    expect(k1).toBe(k2);
  });

  test("server.ts uses composeFetchCacheKey for cache lookup (no bare-label collision)", () => {
    const serverSrc = readFileSync(
      resolve(__dirname, "../../src/server.ts"),
      "utf-8",
    );
    // The cache lookup may live in the handler block OR in an extracted helper
    // (post-refactor: `fetchOneUrl` is the parallel-safe fetcher invoked by both
    // single-URL and batch paths). Either location must use composeFetchCacheKey,
    // not the bare label/url variable.

    // composeFetchCacheKey must be imported and referenced
    expect(serverSrc).toContain('from "./fetch-cache.js"');
    expect(serverSrc).toContain("composeFetchCacheKey");

    // Find ANY getSourceMeta call across the file
    const lookupCall = serverSrc.match(/getSourceMeta\(\s*([^)]+)\s*\)/);
    expect(lookupCall, "getSourceMeta call missing").not.toBeNull();
    const arg = lookupCall![1].trim();
    // Must NOT be the bare `label` variable (that was the bug).
    expect(arg).not.toBe("label");
    // Argument must be a key derived from composition (`cacheKey`, `storageLabel`,
    // or a direct `composeFetchCacheKey(...)` call). Reject any single-token
    // identifier that doesn't carry the composition contract.
    expect(arg).toMatch(/cacheKey|storageLabel|composeFetchCacheKey/);
  });

  test("ContentStore: per-(label,url) keys do not collide on getSourceMeta", () => {
    const store = new ContentStore(":memory:");
    // Simulate two distinct URLs sharing a user-supplied "source" label,
    // but stored under composed keys per the fix.
    const URL_A = "https://example.com/a";
    const URL_B = "https://example.com/b";
    const labelA = `Docs::${URL_A}`;
    const labelB = `Docs::${URL_B}`;

    store.index({ content: "# A\nContent A unique alpha", source: labelA });
    // Before fix: a second cache lookup with the bare "Docs" label would
    // hit A's meta and short-circuit. After fix: lookup uses labelB → miss.
    expect(store.getSourceMeta(labelB)).toBeNull();
    // Cache hit for the same (label,url) still works.
    expect(store.getSourceMeta(labelA)).not.toBeNull();

    // Now index B and verify both remain searchable independently.
    store.index({ content: "# B\nContent B unique bravo", source: labelB });
    const aResults = store.search("alpha", 5, labelA);
    const bResults = store.search("bravo", 5, labelB);
    expect(aResults.length).toBeGreaterThan(0);
    expect(bResults.length).toBeGreaterThan(0);
    store.close();
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// ctx_insight process helpers (formerly tests/core/process-utils.test.ts)
//
// Behavioral tests for browserOpenArgv / openBrowserSync / killProcessOnPort
// (canonical home: src/server.ts). PR #452 review flagged that source-grep
// tests pin implementation strings, not the actual security property. These
// tests mock spawnSync directly and assert:
//
//   - argv arrays only — never `shell: true`
//   - per-platform fallback semantics (xdg-open → sensible-browser)
//   - Windows netstat parser anchors on LISTENING + local-address column
//   - per-pid kill failures do not abort the remaining loop
//   - structured results surface failure to callers
// ═══════════════════════════════════════════════════════════════════════════

import { vi } from "vitest";
import {
  browserOpenArgv,
  openBrowserSync,
  killProcessOnPort,
  type SpawnSyncFn,
} from "../../src/server.js";

type Captured = { cmd: string; args: readonly string[]; opts: unknown };
type FakeReturn = { status: number | null; stdout?: string; error?: Error };

function makeRunner(
  responses: Array<FakeReturn | ((cmd: string, args: readonly string[]) => FakeReturn)>,
): { runner: SpawnSyncFn; calls: Captured[] } {
  const calls: Captured[] = [];
  let i = 0;
  const runner: SpawnSyncFn = (cmd, args, opts) => {
    calls.push({ cmd, args, opts });
    const next = responses[i++];
    const r = typeof next === "function" ? next(cmd, args) : next;
    return {
      pid: 0,
      output: [],
      stdout: r?.stdout ?? "",
      stderr: "",
      status: r?.status ?? 0,
      signal: null,
      error: r?.error,
    } as ReturnType<SpawnSyncFn>;
  };
  return { runner, calls };
}

describe("browserOpenArgv", () => {
  test("darwin → open url", () => {
    expect(browserOpenArgv("http://x", "darwin")).toEqual([
      { cmd: "open", args: ["http://x"] },
    ]);
  });

  test("win32 → cmd /c start with empty title", () => {
    // The empty-string title arg is the security-relevant detail: if it were
    // dropped, `start "http://attacker?evil=1"` would be parsed as a window
    // title rather than a URL.
    expect(browserOpenArgv("http://x", "win32")).toEqual([
      { cmd: "cmd", args: ["/c", "start", "", "http://x"] },
    ]);
  });

  test("linux → xdg-open then sensible-browser fallback", () => {
    expect(browserOpenArgv("http://x", "linux")).toEqual([
      { cmd: "xdg-open", args: ["http://x"] },
      { cmd: "sensible-browser", args: ["http://x"] },
    ]);
  });

  test("argv contains url as a single argument — no shell metachar expansion", () => {
    // A URL with shell metachars must appear verbatim as one argv entry on
    // every platform. If it were ever interpolated into a shell string, the
    // `; rm -rf /` would split into a separate command.
    const evil = "http://x; rm -rf /; #";
    for (const platform of ["darwin", "win32", "linux"] as const) {
      const attempts = browserOpenArgv(evil, platform);
      for (const { args } of attempts) {
        expect(args).toContain(evil);
      }
    }
  });
});

describe("openBrowserSync", () => {
  test("darwin: spawnSync('open', [url]) with no shell:true", () => {
    const { runner, calls } = makeRunner([{ status: 0 }]);
    const r = openBrowserSync("http://x", "darwin", runner);

    expect(r.ok).toBe(true);
    expect(calls).toHaveLength(1);
    expect(calls[0].cmd).toBe("open");
    expect(calls[0].args).toEqual(["http://x"]);
    expect(calls[0].opts).not.toHaveProperty("shell", true);
  });

  test("win32: cmd /c start '' url, no shell:true", () => {
    const { runner, calls } = makeRunner([{ status: 0 }]);
    openBrowserSync("http://x", "win32", runner);

    expect(calls[0].cmd).toBe("cmd");
    expect(calls[0].args).toEqual(["/c", "start", "", "http://x"]);
    expect(calls[0].opts).not.toHaveProperty("shell", true);
  });

  test("linux: xdg-open status=0 → sensible-browser is NOT called", () => {
    const { runner, calls } = makeRunner([{ status: 0 }]);
    const r = openBrowserSync("http://x", "linux", runner);

    expect(r.ok).toBe(true);
    if (r.ok) expect(r.method).toBe("xdg-open");
    expect(calls.map(c => c.cmd)).toEqual(["xdg-open"]);
  });

  test("linux: xdg-open status!=0 → sensible-browser fallback fires", () => {
    const { runner, calls } = makeRunner([
      { status: 3 },
      { status: 0 },
    ]);
    const r = openBrowserSync("http://x", "linux", runner);

    expect(r.ok).toBe(true);
    if (r.ok) expect(r.method).toBe("sensible-browser");
    expect(calls.map(c => c.cmd)).toEqual(["xdg-open", "sensible-browser"]);
  });

  test("linux: xdg-open killed by signal (status=null + error) → fallback fires", () => {
    // The pre-fix bug: status===null was treated as success. Verify both
    // signal-kill and ENOENT trigger the fallback.
    const { runner, calls } = makeRunner([
      { status: null, error: new Error("Killed by signal") },
      { status: 0 },
    ]);
    const r = openBrowserSync("http://x", "linux", runner);

    expect(r.ok).toBe(true);
    expect(calls.map(c => c.cmd)).toEqual(["xdg-open", "sensible-browser"]);
  });

  test("linux: both xdg-open and sensible-browser fail → ok=false with reason", () => {
    const { runner } = makeRunner([
      { status: 1, error: new Error("ENOENT xdg-open") },
      { status: 1, error: new Error("ENOENT sensible-browser") },
    ]);
    const r = openBrowserSync("http://x", "linux", runner);

    expect(r.ok).toBe(false);
    if (!r.ok) {
      expect(r.method).toBe("none");
      expect(r.reason).toContain("xdg-open");
      expect(r.reason).toContain("sensible-browser");
    }
  });

  test("runner throws synchronously → caught, surfaced in reason", () => {
    const runner: SpawnSyncFn = () => { throw new Error("EMFILE"); };
    const r = openBrowserSync("http://x", "darwin", runner);

    expect(r.ok).toBe(false);
    if (!r.ok) expect(r.reason).toContain("EMFILE");
  });
});

describe("killProcessOnPort — Linux/macOS (lsof)", () => {
  test("port free (lsof status=1, empty stdout) → no kill, no error", () => {
    const { runner, calls } = makeRunner([
      { status: 1, stdout: "" },
    ]);
    const r = killProcessOnPort(4747, "linux", runner);

    expect(r.killedPids).toEqual([]);
    expect(r.attemptedPids).toEqual([]);
    expect(r.errors).toEqual([]);
    expect(calls).toHaveLength(1);
    expect(calls[0].cmd).toBe("lsof");
    expect(calls[0].args).toEqual(["-ti", ":4747"]);
  });

  test("lsof ENOENT (binary missing) → surfaced as error, no kill attempt", () => {
    const { runner, calls } = makeRunner([
      { status: null, error: new Error("ENOENT") },
    ]);
    const r = killProcessOnPort(4747, "linux", runner);

    expect(r.attemptedPids).toEqual([]);
    expect(r.errors.join(" ")).toMatch(/lsof.*ENOENT/);
    expect(calls).toHaveLength(1);
  });

  test("two pids, both kill cleanly → both reported as killed", () => {
    const { runner, calls } = makeRunner([
      { status: 0, stdout: "1234\n5678\n" },
      { status: 0 },
      { status: 0 },
    ]);
    const r = killProcessOnPort(4747, "linux", runner);

    expect(r.killedPids).toEqual(["1234", "5678"]);
    expect(r.attemptedPids).toEqual(["1234", "5678"]);
    expect(r.errors).toEqual([]);

    // Argv check: each kill receives the pid as a single argv entry.
    expect(calls[1]).toEqual(expect.objectContaining({ cmd: "kill", args: ["1234"] }));
    expect(calls[2]).toEqual(expect.objectContaining({ cmd: "kill", args: ["5678"] }));
  });

  test("first pid kill fails → second pid still attempted (no abort)", () => {
    // Pre-fix: try/catch wrapped the entire for-loop, so a single pid
    // failure aborted the rest of the kills.
    const { runner } = makeRunner([
      { status: 0, stdout: "1111\n2222\n3333\n" },
      { status: 1, error: new Error("EPERM") },
      { status: 0 },
      { status: 0 },
    ]);
    const r = killProcessOnPort(4747, "linux", runner);

    expect(r.attemptedPids).toEqual(["1111", "2222", "3333"]);
    expect(r.killedPids).toEqual(["2222", "3333"]);
    expect(r.errors.join(" ")).toMatch(/kill 1111/);
  });

  test("runner throws on a kill → loop continues, error captured", () => {
    let calls = 0;
    const runner: SpawnSyncFn = (cmd, args) => {
      calls++;
      if (cmd === "lsof") {
        return { pid: 0, output: [], stdout: "1\n2\n", stderr: "", status: 0, signal: null } as ReturnType<SpawnSyncFn>;
      }
      if (cmd === "kill" && args[0] === "1") throw new Error("boom");
      return { pid: 0, output: [], stdout: "", stderr: "", status: 0, signal: null } as ReturnType<SpawnSyncFn>;
    };
    const r = killProcessOnPort(4747, "linux", runner);

    expect(calls).toBe(3);
    expect(r.attemptedPids).toEqual(["1", "2"]);
    expect(r.killedPids).toEqual(["2"]);
    expect(r.errors.join(" ")).toMatch(/boom/);
  });

  test("garbage in lsof stdout is filtered (only digit-PIDs accepted)", () => {
    const { runner, calls } = makeRunner([
      { status: 0, stdout: "1234\n\nNot-a-pid\n5678\n" },
      { status: 0 },
      { status: 0 },
    ]);
    const r = killProcessOnPort(4747, "linux", runner);

    expect(r.killedPids).toEqual(["1234", "5678"]);
    expect(calls).toHaveLength(3); // lsof + 2 kills
  });
});

describe("killProcessOnPort — Windows (netstat)", () => {
  // Sample netstat -ano output. The MUST-NOT-KILL row's REMOTE column
  // contains :4747 — pre-fix `line.includes(":4747")` matched it and killed
  // the unrelated PID 9876.
  const netstatOut = [
    "Active Connections",
    "",
    "  Proto  Local Address          Foreign Address        State           PID",
    "  TCP    0.0.0.0:4747           0.0.0.0:0              LISTENING       1234",
    "  TCP    192.168.1.5:54321      8.8.8.8:4747           ESTABLISHED     9876", // MUST NOT match
    "  UDP    0.0.0.0:4747           *:*                                    5555", // UDP, must not match
    "  TCP    [::]:4747              [::]:0                 LISTENING       1235",
    "",
  ].join("\r\n");

  test("only LISTENING TCP rows whose LOCAL column ends with :port are killed", () => {
    const { runner, calls } = makeRunner([
      { status: 0, stdout: netstatOut },
      { status: 0 }, // taskkill 1234
      { status: 0 }, // taskkill 1235
    ]);
    const r = killProcessOnPort(4747, "win32", runner);

    expect(r.attemptedPids).toEqual(expect.arrayContaining(["1234", "1235"]));
    expect(r.attemptedPids).not.toContain("9876"); // remote-port match — was the bug
    expect(r.attemptedPids).not.toContain("5555"); // UDP — must not be killed

    // taskkill argv: /F /PID <pid> with no shell:true
    const killCalls = calls.filter(c => c.cmd === "taskkill");
    for (const c of killCalls) {
      expect(c.args[0]).toBe("/F");
      expect(c.args[1]).toBe("/PID");
      expect(c.opts).not.toHaveProperty("shell", true);
    }
  });

  test("netstat ENOENT → surfaced as error", () => {
    const { runner } = makeRunner([
      { status: null, error: new Error("ENOENT") },
    ]);
    const r = killProcessOnPort(4747, "win32", runner);

    expect(r.errors.join(" ")).toMatch(/netstat.*ENOENT/);
    expect(r.attemptedPids).toEqual([]);
  });

  test("first taskkill fails → second pid still attempted", () => {
    const { runner } = makeRunner([
      { status: 0, stdout: netstatOut },
      { status: 1, error: new Error("Access denied") }, // taskkill 1234
      { status: 0 }, // taskkill 1235
    ]);
    const r = killProcessOnPort(4747, "win32", runner);

    expect(r.attemptedPids).toContain("1234");
    expect(r.attemptedPids).toContain("1235");
    expect(r.killedPids).toContain("1235");
    expect(r.killedPids).not.toContain("1234");
    expect(r.errors.join(" ")).toMatch(/taskkill 1234/);
  });
});

describe("killProcessOnPort — input validation", () => {
  test("rejects out-of-range port without spawning anything", () => {
    const spy = vi.fn();
    const r = killProcessOnPort(70000, "linux", spy as unknown as SpawnSyncFn);

    expect(spy).not.toHaveBeenCalled();
    expect(r.errors.join(" ")).toMatch(/invalid port/);
  });

  test("rejects non-integer port without spawning anything", () => {
    const spy = vi.fn();
    const r = killProcessOnPort(3.14 as number, "linux", spy as unknown as SpawnSyncFn);

    expect(spy).not.toHaveBeenCalled();
    expect(r.errors.join(" ")).toMatch(/invalid port/);
  });
});

// ─── ctx_insight helper follow-ups (#441 follow-up) ──────────────────────────
//
// 1. Hard timeout on every helper-internal spawnSync. A hung lsof/xdg-open/
//    taskkill would otherwise block the MCP tool indefinitely. We assert the
//    timeout option is propagated to the runner — the actual cap value is an
//    implementation detail, but its presence is the regression we lock.
//
// 2. Windows non-English locale support. The pre-followup parser keyed off
//    `state !== "LISTENING"` which is locale-translated (Windows-FR shows
//    `À l'écoute`, Windows-DE `ABHÖREN`, Windows-ES `ESCUCHANDO`, etc.), so
//    on non-EN Windows the helper would silently match zero rows and never
//    free a stuck dashboard port. The remote-address column is NOT
//    locale-translated; we now key off it instead.

describe("Helper spawnSync timeout (#441 follow-up)", () => {
  test("openBrowserSync passes a timeout to the runner", () => {
    const { runner, calls } = makeRunner([{ status: 0 }]);
    openBrowserSync("http://x", "darwin", runner);

    expect(calls).toHaveLength(1);
    expect(calls[0].opts).toBeDefined();
    expect(typeof (calls[0].opts as { timeout?: number }).timeout).toBe("number");
    expect((calls[0].opts as { timeout: number }).timeout).toBeGreaterThan(0);
  });

  test("killProcessOnPort (Linux) passes a timeout to lsof and kill", () => {
    const { runner, calls } = makeRunner([
      { status: 0, stdout: "1234\n" },
      { status: 0 }, // kill 1234
    ]);
    killProcessOnPort(4747, "linux", runner);

    expect(calls).toHaveLength(2);
    for (const c of calls) {
      expect(typeof (c.opts as { timeout?: number }).timeout).toBe("number");
      expect((c.opts as { timeout: number }).timeout).toBeGreaterThan(0);
    }
  });

  test("killProcessOnPort (Windows) passes a timeout to netstat and taskkill", () => {
    const winFixture = [
      "  Proto  Local Address          Foreign Address        State           PID",
      "  TCP    0.0.0.0:4747           0.0.0.0:0              LISTENING       1234",
      "",
    ].join("\r\n");
    const { runner, calls } = makeRunner([
      { status: 0, stdout: winFixture },
      { status: 0 }, // taskkill 1234
    ]);
    killProcessOnPort(4747, "win32", runner);

    expect(calls.map(c => c.cmd)).toEqual(["netstat", "taskkill"]);
    for (const c of calls) {
      expect(typeof (c.opts as { timeout?: number }).timeout).toBe("number");
      expect((c.opts as { timeout: number }).timeout).toBeGreaterThan(0);
    }
  });
});

describe("killProcessOnPort — Windows non-English locale (#441 follow-up)", () => {
  // Same regression fixture as the en-US Windows test, but with the STATE
  // column translated. Pre-followup, the helper required `state === "LISTENING"`
  // and so silently produced zero PIDs on non-EN Windows. The follow-up keys
  // off the remote-address column (locale-independent) so this fixture must
  // now match LISTENING rows 1234 + 1235 and skip the ESTABLISHED row 9876
  // and the UDP row 5555.
  const netstatFr = [
    "Connexions actives",
    "",
    "  Proto  Adresse locale         Adresse distante       État            PID",
    "  TCP    0.0.0.0:4747           0.0.0.0:0              À l'écoute      1234",
    "  TCP    192.168.1.5:54321      8.8.8.8:4747           ESTABLI         9876",
    "  UDP    0.0.0.0:4747           *:*                                    5555",
    "  TCP    [::]:4747              [::]:0                 À l'écoute      1235",
    "",
  ].join("\r\n");

  const netstatDe = [
    "Aktive Verbindungen",
    "",
    "  Proto  Lokale Adresse         Remoteadresse          Status          PID",
    "  TCP    0.0.0.0:4747           0.0.0.0:0              ABHÖREN         1234",
    "  TCP    192.168.1.5:54321      8.8.8.8:4747           HERGESTELLT     9876",
    "  TCP    [::]:4747              [::]:0                 ABHÖREN         1235",
    "",
  ].join("\r\n");

  test("French Windows netstat output: kills 1234 and 1235, ignores 9876 + 5555", () => {
    const { runner } = makeRunner([
      { status: 0, stdout: netstatFr },
      { status: 0 }, // taskkill 1234
      { status: 0 }, // taskkill 1235
    ]);
    const r = killProcessOnPort(4747, "win32", runner);

    expect(r.attemptedPids).toEqual(expect.arrayContaining(["1234", "1235"]));
    expect(r.attemptedPids).not.toContain("9876");
    expect(r.attemptedPids).not.toContain("5555");
    expect(r.killedPids).toEqual(expect.arrayContaining(["1234", "1235"]));
  });

  test("German Windows netstat output: kills 1234 and 1235, ignores 9876", () => {
    const { runner } = makeRunner([
      { status: 0, stdout: netstatDe },
      { status: 0 }, // taskkill 1234
      { status: 0 }, // taskkill 1235
    ]);
    const r = killProcessOnPort(4747, "win32", runner);

    expect(r.attemptedPids).toEqual(expect.arrayContaining(["1234", "1235"]));
    expect(r.attemptedPids).not.toContain("9876");
    expect(r.killedPids).toEqual(expect.arrayContaining(["1234", "1235"]));
  });

  test("a connected remote :port still does NOT match (remote-column anchor)", () => {
    // Sanity: the predicate must still reject the ESTABLISHED row whose
    // REMOTE is 8.8.8.8:4747. This was the original pre-#441 bug; the
    // follow-up must not regress it while changing the locale strategy.
    const onlyEstablished = [
      "  Proto  Local Address          Foreign Address        State           PID",
      "  TCP    192.168.1.5:54321      8.8.8.8:4747           ESTABLISHED     9876",
      "",
    ].join("\r\n");
    const { runner, calls } = makeRunner([{ status: 0, stdout: onlyEstablished }]);
    const r = killProcessOnPort(4747, "win32", runner);

    expect(r.attemptedPids).toEqual([]);
    expect(calls).toHaveLength(1); // netstat only — no taskkill issued
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// Prose-style policy (issue #482)
// ═══════════════════════════════════════════════════════════════════════════
// Decision: context-mode keeps raw data out of context but does not dictate
// how the model writes its final answer. Aggressive brevity prompts have been
// shown to degrade coding/reasoning benchmarks (Moonshot AI on kimi-k2.5).
// Any caveman/terse-style language in shipped artifacts is a regression.

describe("prose-style policy (#482)", () => {
  const serverSrc = readFileSync(
    resolve(__dirname, "../../src/server.ts"),
    "utf-8",
  );
  const routingBlock = readFileSync(
    resolve(__dirname, "../../hooks/routing-block.mjs"),
    "utf-8",
  );

  test("no caveman/terse directive lands in any MCP tool description", () => {
    expect(serverSrc).not.toMatch(/terse like caveman/i);
    expect(serverSrc).not.toMatch(/only fluff die/i);
  });

  test("routing-block has no <communication_style> or <response_format> blocks", () => {
    expect(routingBlock).not.toMatch(/<communication_style>/);
    expect(routingBlock).not.toMatch(/<response_format>/);
    expect(routingBlock).not.toMatch(/terse like caveman/i);
  });

  test("README does not advertise an Output Compression pillar", () => {
    const readme = readFileSync(
      resolve(__dirname, "../../README.md"),
      "utf-8",
    );
    expect(readme).not.toMatch(/\*\*Output Compression\*\*/);
    expect(readme).not.toMatch(/terse like caveman/i);
  });

  test("committed bundles (cli/server/hooks) carry no caveman strings", () => {
    // Defense-in-depth: src/* is stripped, but the npm-shipped bundles are
    // built artifacts that could lag if a future release rebuilds from a
    // dirty tree. Lock the deletion to the actually-published files too
    // (round-5 finding).
    const bundlePaths = [
      "../../server.bundle.mjs",
      "../../cli.bundle.mjs",
      "../../hooks/session-extract.bundle.mjs",
      "../../hooks/session-snapshot.bundle.mjs",
      "../../hooks/session-db.bundle.mjs",
    ];
    for (const rel of bundlePaths) {
      const p = resolve(__dirname, rel);
      try {
        const content = readFileSync(p, "utf-8");
        expect(content).not.toMatch(/terse like caveman/i);
        expect(content).not.toMatch(/only fluff die/i);
      } catch (err) {
        // Bundle missing on this checkout (e.g., fresh clone before
        // `npm run bundle`). That is fine — CI's Build step generates
        // them; this test only asserts negative when the file exists.
        if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
      }
    }
  });
});

// ─────────────────────────────────────────────────────────
// homedir() import smoke test (round-5 finding from cluster C+E+G).
// The merge at 43c63cb dropped this import; b8ca35e restored it. A future
// merge could lose it again silently. Pin the contract: enumerateAdapterDirs
// must work with a stubbed home and resolve to a path under that home.
// ─────────────────────────────────────────────────────────
describe("analytics homedir() import is alive (#43c63cb regression guard)", () => {
  test("enumerateAdapterDirs() resolves a sessionsDir under the host home", async () => {
    const { enumerateAdapterDirs } = await import("../../src/session/analytics.js");
    const { homedir } = await import("node:os");
    const dirs = enumerateAdapterDirs();
    expect(dirs.length).toBeGreaterThan(0);
    // At least one entry must mention the actual home directory — proves
    // homedir() resolution is wired all the way through.
    const home = homedir();
    expect(dirs.every((d) => d.sessionsDir.startsWith(home))).toBe(true);
  });
});

// ─────────────────────────────────────────────────────────
// Startup banner suppression in stdio transport mode.
// When the server runs as a child process (stdin is not a TTY), the banner
// must not appear on stderr — Pi and other hosts render stderr in their UI.
// ─────────────────────────────────────────────────────────
describe("startup banner suppressed in stdio transport mode", () => {
  test("no banner on stderr when stdin is not a TTY (child process)", async () => {
    const bundlePath = resolve(__dirname, "../../server.bundle.mjs");
    if (!existsSync(bundlePath)) return;

    const stderr = await new Promise<string>((res) => {
      const proc = spawn(process.execPath, [bundlePath], {
        stdio: ["pipe", "pipe", "pipe"],
        env: { ...process.env, QUIET_CONTEXT_SUPPRESS_SECURITY_WARNING: "1" },
      });
      let data = "";
      proc.stderr.on("data", (chunk: Buffer) => { data += chunk.toString(); });
      setTimeout(() => { proc.kill(); res(data); }, 300);
    });

    expect(stderr).not.toContain("Context Mode MCP server");
    expect(stderr).not.toContain("Detected runtimes:");
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// v1.0.134 SLICE A — cross-adapter session attribution
// CLAUDE_SESSION_ID env var is NOT propagated to MCP servers (only hooks).
// `resolveSessionIdFromSessionDB` must read the most-recent session_id from
// THIS project's session DB so chunk attribution works on every adapter
// (cursor, codex, gemini, kiro, opencode, etc.) without relying on env.
// ═══════════════════════════════════════════════════════════════════════════
describe("v1.0.134 SLICE A — cross-adapter currentAttribution session DB fallback", () => {
  test("currentAttribution falls back to session DB when CLAUDE_SESSION_ID env not set (cross-adapter)", async () => {
    const { resolveSessionIdFromSessionDB, currentAttribution } = await import(
      "../../src/server.js"
    );
    const { SessionDB, resolveSessionDbPath } = await import(
      "../../src/session/db.js"
    );

    const sessionsDir = mkdtempSync(join(tmpdir(), "slice-a-sessions-"));
    const projectDir = mkdtempSync(join(tmpdir(), "slice-a-proj-"));
    try {
      const dbPath = resolveSessionDbPath({ projectDir, sessionsDir });
      const expectedSid = "11111111-2222-3333-4444-555566667777";

      const sdb = new SessionDB({ dbPath });
      try {
        sdb.ensureSession(expectedSid, projectDir);
        sdb.insertEvent(
          expectedSid,
          {
            type: "tool_use",
            category: "file",
            priority: 1,
            data: "src/x.ts",
            project_dir: projectDir,
            attribution_source: "test",
            attribution_confidence: 1,
          },
          "test",
        );
      } finally {
        sdb.close();
      }

      // Ensure env path is NOT taken — both env vars unset.
      const prevSid = process.env.CLAUDE_SESSION_ID;
      const prevProjDir = process.env.CLAUDE_PROJECT_DIR;
      const prevCmProjDir = process.env.QUIET_CONTEXT_PROJECT_DIR;
      delete process.env.CLAUDE_SESSION_ID;
      delete process.env.CLAUDE_PROJECT_DIR;
      delete process.env.QUIET_CONTEXT_PROJECT_DIR;
      try {
        // bypassCache: this test runs after others may have populated the cache.
        const sid = resolveSessionIdFromSessionDB({
          projectDir,
          sessionsDir,
          bypassCache: true,
        });
        expect(sid).toBe(expectedSid);

        // currentAttribution wraps it the same way for prod callers — when the
        // env var is unset it must surface the DB-resolved sid via the
        // wrapper too. We re-set QUIET_CONTEXT_PROJECT_DIR so the wrapper's
        // own (cache-bypassed by 2s window starting fresh) call also resolves.
        process.env.QUIET_CONTEXT_PROJECT_DIR = projectDir;
        // Wait long enough that the previous lookup's 2s cache won't shadow
        // the wrapper's own resolveSessionIdFromSessionDB call (env-driven path).
        // Easier: bypass via a direct call shape — the wrapper just composes.
        const attr = currentAttribution();
        // Either the env-resolved path returned the same sid, or — if cache
        // beat us — at least it's not the empty/undefined case. The hard
        // assertion is the DB lookup above; here we only confirm the
        // wrapper's contract (returns { sessionId } when sid resolves).
        expect(attr?.sessionId).toBeTruthy();
      } finally {
        if (prevSid !== undefined) process.env.CLAUDE_SESSION_ID = prevSid;
        if (prevProjDir !== undefined) process.env.CLAUDE_PROJECT_DIR = prevProjDir;
        if (prevCmProjDir !== undefined) process.env.QUIET_CONTEXT_PROJECT_DIR = prevCmProjDir;
        else delete process.env.QUIET_CONTEXT_PROJECT_DIR;
      }
    } finally {
      try { rmSync(sessionsDir, { recursive: true, force: true }); } catch { /* ignore */ }
      try { rmSync(projectDir, { recursive: true, force: true }); } catch { /* ignore */ }
    }
  });
});

test("withProjectDirOverride carries native plugin session id into currentAttribution (#574)", async () => {
  const { withProjectDirOverride, currentAttribution } = await import("../../src/server.js");
  const projectDir = mkdtempSync(join(tmpdir(), "native-plugin-attr-proj-"));
  try {
    const attr = await withProjectDirOverride(
      { projectDir, sessionId: "opencode-session-override" },
      async () => currentAttribution(),
    );
    expect(attr).toEqual({ sessionId: "opencode-session-override" });
  } finally {
    rmSync(projectDir, { recursive: true, force: true });
  }
});

test("OpenCode/Kilo legacy MCP child suppresses ctx_* tool registration while embedded plugin import keeps it", async () => {
  const { shouldSuppressMcpToolsForNativePluginHost } = await import("../../src/server.js");
  const legacySettings = {
    plugin: ["context-mode"],
    mcp: { "context-mode": { type: "local", command: ["context-mode"] } },
  };
  expect(shouldSuppressMcpToolsForNativePluginHost({ platform: "opencode", settings: legacySettings })).toBe(true);
  expect(shouldSuppressMcpToolsForNativePluginHost({ platform: "kilo", settings: legacySettings })).toBe(true);
  expect(shouldSuppressMcpToolsForNativePluginHost({ platform: "opencode", settings: { plugin: ["context-mode"] } })).toBe(false);
  expect(shouldSuppressMcpToolsForNativePluginHost({ platform: "opencode", embedded: "1", settings: legacySettings })).toBe(false);
  expect(shouldSuppressMcpToolsForNativePluginHost({ platform: "claude-code", embedded: undefined })).toBe(false);
});

test("OpenCode legacy MCP suppression parses JSONC URLs without stripping // inside strings", async () => {
  const { shouldSuppressMcpToolsForNativePluginHost } = await import("../../src/server.js");
  const dir = mkdtempSync(join(tmpdir(), "opencode-jsonc-url-"));
  const cwd = process.cwd();
  try {
    writeFileSync(join(dir, "opencode.jsonc"), `{
      // Keep this URL intact; a naive /\\/\\/.*/ stripper corrupts it.
      "endpoint": "https://example.com/api",
      "plugin": ["context-mode"],
      "mcp": {
        "context-mode": { "type": "local", "command": ["context-mode"] },
        "other": { "type": "local", "command": ["other"] }
      }
    }\n`);
    process.chdir(dir);
    expect(shouldSuppressMcpToolsForNativePluginHost({ platform: "opencode" })).toBe(true);
  } finally {
    process.chdir(cwd);
    rmSync(dir, { recursive: true, force: true });
  }
});

// Issue #623: when ctx_* tool registration is suppressed for the legacy MCP
// child on OpenCode/Kilo, an MCP client inspecting tools/list sees an empty
// list with NO explanation. The plugin-native tools work, but a user who only
// observes the MCP child (or another MCP host that doesn't load the plugin)
// has no signal that ctx_* tools were intentionally hidden. Surface a stderr
// diagnostic frame at first suppressed registerTool() call so operators can
// tell "tools/list is empty BECAUSE the legacy mcp.context-mode block coexists
// with plugin: ['context-mode']" — not "the server is broken".
test("OpenCode/Kilo legacy MCP child emits stderr diagnostic when ctx_* suppression fires (#623)", async () => {
  const { emitSuppressionDiagnostic, __resetSuppressionDiagnosticForTests } = await import("../../src/server.js");
  __resetSuppressionDiagnosticForTests();
  const lines: string[] = [];
  emitSuppressionDiagnostic({ platform: "opencode", write: (c) => lines.push(c) });
  // Second call must NOT re-emit — diagnostic is one-shot per process.
  emitSuppressionDiagnostic({ platform: "opencode", write: (c) => lines.push(c) });
  const joined = lines.join("");
  expect(joined).toMatch(/context-mode/);
  expect(joined).toMatch(/#623|plugin-native|legacy.*mcp\.context-mode|mcp\.context-mode.*legacy/i);
  // One-shot: exactly one line containing the marker.
  const matches = joined.match(/\[context-mode\]/g) ?? [];
  expect(matches.length).toBe(1);
  __resetSuppressionDiagnosticForTests();
});

// Issue #637: an operator who inspects the suppressed legacy MCP child via
// `tools/list` (or whose MCP host probes it on connect) currently receives a
// JSON-RPC -32601 "Method not found" error — because no `registerTool()` call
// survives the suppression shim, the SDK's `setToolRequestHandlers()` never
// runs and `tools/list` is therefore unregistered. To an outside observer that
// looks identical to a broken server and they reasonably conclude "the plugin
// never registers any ctx_* tools" (#637's headline framing). The real story
// is "the MCP child was intentionally muted; the plugin path is serving the
// tools natively" — already conveyed via the #623 stderr diagnostic, but
// JSON-RPC consumers don't read stderr.
//
// Fix: register an explicit empty `tools/list` handler whenever suppression
// is active, so the wire response becomes `{tools: []}` (spec-compliant,
// matches what operators expect) paired with the existing stderr diagnostic.
// This eliminates the misleading -32601 that started #637.
test("registerEmptyToolsListHandler responds with {tools:[]} so operators don't see -32601 on suppressed MCP child (#637)", async () => {
  // The user-facing failure mode that drove issue #637: an operator inspecting
  // the suppressed legacy MCP child via `tools/list` (or whose MCP host probes
  // it during connect) receives JSON-RPC -32601 "Method not found" because the
  // SDK only registers tools/list when `registerTool()` actually goes through —
  // and the #623 suppression shim returns undefined for every registration.
  // The reporter reads -32601 as "the plugin never registers any ctx_* tools",
  // which is the headline framing of #637.
  //
  // Fix: an exported helper that installs an explicit empty tools/list handler
  // when suppression is active. The bundle entry point calls it at module-init
  // time (alongside the prompts/resources handlers at server.ts:259-261).
  //
  // We test the helper in isolation against a fresh McpServer wired through an
  // in-memory transport to a Client. This avoids the module-load-time pinning
  // of `suppressMcpToolsForNativePluginHost` and gives a deterministic loop
  // that does not depend on the build bundle being present.
  const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
  const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
  const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
  const { registerEmptyToolsListHandler } = await import("../../src/server.js");

  const mcp = new McpServer({ name: "issue-637-isolated", version: "0.0.0" });
  registerEmptyToolsListHandler(mcp);

  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  await Promise.all([
    mcp.server.connect(serverTransport),
    (async () => {
      const client = new Client({ name: "issue-637-probe", version: "0.0.0" }, { capabilities: {} });
      await client.connect(clientTransport);
      const listed = await client.listTools();
      // Pre-fix: client.listTools() throws -32601 Method not found.
      // Post-fix: returns { tools: [] }.
      expect(listed).toBeDefined();
      expect(Array.isArray(listed.tools)).toBe(true);
      expect(listed.tools.length).toBe(0);
      await client.close();
    })(),
  ]);
}, 15_000);

// ─────────────────────────────────────────────────────────────────────────────
// Tool description style contract (#683 ADR-0002)
// ─────────────────────────────────────────────────────────────────────────────
//
// Static contract test that scans every server.registerTool() block in
// src/server.ts and asserts the tool description meets the style policy
// codified in docs/adr/0002-tool-description-style.md.
//
// Motivation: PR #654 surfaced that a single hortatory word ("blocked") in a
// routing deny reason was misread by Opus 4.6 as a network restriction,
// causing capitulation to training data instead of routing. The same drift
// has accumulated organically in tool descriptions (MANDATORY:, PREFER X
// OVER Y, NEVER, Do NOT, ✅/❌). This test is the regression guard.
//
// Per CONTRIBUTING.md "Test file organization", we fold the contract into
// tests/core/server.test.ts rather than creating tests/server/*.test.ts.
//
// Exemptions: ctx_stats, ctx_doctor, ctx_insight have minimal one-line
// descriptions by design — they are GUI/diagnostic affordances, not routing
// targets, so the WHEN: structural requirement does not apply.
describe("tool description style contract (#683 ADR-0002)", () => {
  const serverTsPath = resolve(__dirname, "../../src/server.ts");
  const serverTs = readFileSync(serverTsPath, "utf-8");

  // Extract every registered tool with its description string.
  // Description is a template literal or "+"-concatenated string literal
  // sitting on the `description:` key inside the registerTool config object.
  function extractToolDescriptions(): Array<{ name: string; description: string; lineNo: number }> {
    const out: Array<{ name: string; description: string; lineNo: number }> = [];
    const lines = serverTs.split("\n");
    const RE_REGISTER = /server\.registerTool\(\s*$/;
    const RE_NAME = /^\s*"(ctx_[a-z_]+)"\s*,\s*$/;
    for (let i = 0; i < lines.length; i++) {
      if (!RE_REGISTER.test(lines[i])) continue;
      const nameMatch = lines[i + 1]?.match(RE_NAME);
      if (!nameMatch) continue;
      const name = nameMatch[1];
      // Description block starts at first `description:` after the name.
      // Capture until the next `inputSchema:` line at the same indentation.
      let descStart = -1;
      let descEnd = -1;
      for (let j = i + 2; j < Math.min(i + 80, lines.length); j++) {
        if (descStart < 0 && /^\s*description:/.test(lines[j])) {
          descStart = j;
        } else if (descStart >= 0 && /^\s*(inputSchema|outputSchema|annotations):/.test(lines[j])) {
          descEnd = j;
          break;
        }
      }
      if (descStart < 0 || descEnd < 0) continue;
      const block = lines.slice(descStart, descEnd).join("\n");
      // Strip the leading `description: ` and trailing comma.
      // The literal text the LLM sees is just the string content — for the
      // contract we work on the source-form block (template-literal/concat
      // syntax), which is sufficient to detect forbidden tokens like
      // `MANDATORY:` or `PREFER`. We do NOT execute the template here.
      out.push({ name, description: block, lineNo: descStart + 1 });
    }
    return out;
  }

  const tools = extractToolDescriptions();

  // Tools exempt from the WHEN: structural requirement, with documented
  // rationale per the audit (see TOOL-DESCRIPTIONS-AUDIT.md §3 table).
  //
  // - ctx_stats / ctx_doctor / ctx_insight: minimal one-line descriptions by
  //   design — diagnostic/GUI affordances, not routing targets. Audit row:
  //   "NIT — Clean, minimal, no change."
  // - ctx_upgrade: MUST is appropriate here (post-call obligation on the
  //   agent to run the returned shell command). Audit row: "LOW — MUST is
  //   appropriate here (post-call obligation), good use case. No change."
  const EXEMPT_FROM_WHEN = new Set([
    "ctx_stats",
    "ctx_doctor",
    "ctx_insight",
    "ctx_upgrade",
  ]);

  // ctx_purge carve-out — the rewrite (PR #683 WS2) preserves the
  // user-facing DESTRUCTIVE signal because Probe 4 empirically showed that
  // soft framing regresses parameter fidelity on Haiku (5/5 → 3/5). The
  // word "DESTRUCTIVE" is therefore an accurate-signaling carve-out,
  // distinct from cross-LLM-bias negative framing the rubric forbids.
  // ADR-0002 §Exemptions documents this; the WHEN/WHEN NOT/SCOPES/
  // CONTRACT/RETURNS/EXAMPLE structure of the rewritten description still
  // meets the canonical contract enforced below.
  const EXEMPT_FROM_FORBIDDEN_TOKENS = new Set<string>([]);

  test("at least 11 ctx_* tools are registered", () => {
    // Sanity check that the extractor found the corpus.
    expect(tools.length).toBeGreaterThanOrEqual(11);
  });

  // Forbidden tokens per ADR-0002. Each pattern is documented inline so
  // a future contributor reading a failure understands the rationale, not
  // just the regex.
  type ForbiddenRule = { name: string; pattern: RegExp; rationale: string };
  const FORBIDDEN: ForbiddenRule[] = [
    {
      name: "SESSION STATE clause",
      // Rubric #3 + GRILL-Q1-VERDICT: tool descriptions are selection cues,
      // not in-context prompts. Skill/role/decision persistence belongs in
      // routing-block.mjs (which already covers it more thoroughly).
      pattern: /\bSESSION STATE\b/,
      rationale: "Move SESSION STATE guidance to routing-block.mjs (it already lives there).",
    },
    {
      name: "BLOCKED",
      // ADR-0003: 'blocked' is reserved for CASE B (real policy restriction)
      // in routing.mjs deny reasons. It MUST NOT appear in any ctx_* tool
      // description, where there is no security restriction to express.
      pattern: /\bBLOCKED\b/,
      rationale: "Reserve 'blocked' for routing CASE B (security policy denial) per ADR-0003.",
    },
    {
      name: "MANDATORY: opener",
      // Rubric #7: MANDATORY in a tool description reads as a developer
      // policy note rather than a tool-selection cue. Replace with WHEN:.
      pattern: /\bMANDATORY:/,
      rationale: "Replace 'MANDATORY:' opener with role definition + WHEN: section.",
    },
    {
      name: "PREFER X OVER Y",
      // Rubric #7: 'PREFER' is the wrong strength and frames the choice as
      // a tradeoff. WHEN:/WHEN NOT: sections give the agent positive cues.
      pattern: /\bPREFER\s+THIS\s+OVER\b/,
      rationale: "Replace 'PREFER THIS OVER X' with positive WHEN: clauses.",
    },
    {
      name: "Do NOT (descriptive)",
      // Rubric #2 + #7: affirmative beats negative. 'Do NOT' inside the
      // description is voice-of-trainer; routing-block.mjs is the right
      // layer for prohibitions.
      pattern: /\bDo NOT\s+(?:read|use|pull|call)\b/,
      rationale: "Rewrite 'Do NOT read/use/pull' as positive WHEN: / WHEN NOT: clauses.",
    },
    {
      name: "Never use (capitalised imperative)",
      // Rubric #7: 'Never' as a soft imperative inside a description is
      // a forbidding voice; sibling-tool selection should be expressed
      // through WHEN NOT: structure instead.
      pattern: /\bNever\s+use\b/,
      rationale: "Rewrite 'Never use' as positive WHEN NOT: clause.",
    },
    {
      name: "checkmark emoji ✅",
      // Rubric #4 + Probe 3 evidence: emoji tokenize inconsistently across
      // LLM families (Llama, Gemini) and ❌ bullets are precisely the
      // negative-example leakage pattern.
      pattern: /✅/,
      rationale: "Replace ✅ bullets with prose 'USE concurrency 4-8 for ...' (ADR-0002).",
    },
    {
      name: "cross emoji ❌",
      pattern: /❌/,
      rationale: "Replace ❌ bullets with prose 'KEEP concurrency 1 for ...' (ADR-0002).",
    },
  ];

  // ── Canonical structure (ADR-0002 amendment, PR #683 WS3) ────────────
  //
  // Every non-exempt ctx_* tool description MUST follow the canonical
  // structure documented in ADR-0002:
  //
  //   <1-line headline>
  //   WHEN:        (mandatory — positive selection cues, bulleted with `- `)
  //   WHEN NOT:    (optional — sibling-tool disambiguation, bulleted)
  //   RETURNS:     (mandatory — what the agent gets back)
  //   EXAMPLE:     (mandatory — one canonical call)
  //
  // Rules:
  //   1. Section order MUST be WHEN -> WHEN NOT -> RETURNS -> EXAMPLE
  //      (positive cues precede negative disambiguation, per audit rubric #2).
  //   2. Bullets MUST use markdown `- ` only. `1.`, `1-`, `* `, and `•` are
  //      rejected because they tokenize inconsistently across LLM families
  //      and break the audit's bullet-uniformity contract.
  //   3. Section headers MUST be UPPERCASE + colon at the start of a line
  //      (after the `\n` escape in source form).
  //   4. ctx_purge has an audit-approved carve-out for DESTRUCTIVE / SCOPES /
  //      CONTRACT headers (accurate-signaling and parameter-fidelity
  //      requirements that Probe 4 empirically validated). All four headers
  //      coexist with the canonical WHEN/WHEN NOT/RETURNS/EXAMPLE.
  //
  // Helper: flatten the source-form description into the literal text the
  // LLM eventually sees (collapse `\n` escapes, join `"..." + "..."` concat,
  // strip template-literal backticks). This is the same shape the host LLM
  // receives at tool-selection time.
  function flattenDescription(d: string): string {
    return d
      .replace(/^\s*description:\s*/, "")
      .replace(/\\n/g, "\n")
      .replace(/\\"/g, '"')
      .replace(/"\s*\+\s*\n\s*"/g, "")
      .replace(/"\s*\+\s*"/g, "")
      .replace(/^"|"$/gm, "")
      .replace(/`/g, "");
  }

  // Per-tool carve-outs that allow non-canonical UPPERCASE: section headers.
  // Each entry is justified inline so a future contributor reading a failure
  // understands why the carve-out exists.
  const ALLOWED_EXTRA_SECTIONS: Record<string, string[]> = {
    // ctx_purge: heavy framing is empirically validated by Probe 4. The
    // DESTRUCTIVE prefix preserves accurate user-facing signaling and
    // SCOPES/CONTRACT preserve parameter-fidelity discipline on Haiku.
    ctx_purge: ["DESTRUCTIVE", "SCOPES", "CONTRACT"],
  };

  // Canonical mandatory + optional sections.
  const CANONICAL_ORDER = ["WHEN", "WHEN NOT", "RETURNS", "EXAMPLE"] as const;
  const MANDATORY = ["WHEN", "RETURNS", "EXAMPLE"] as const;

  // Bullet patterns the contract rejects in routing-target descriptions.
  const BAD_BULLETS = [
    { name: "numeric-dot bullet (e.g. '1.')", pattern: /^\s*\d+\.\s/m },
    { name: "numeric-dash bullet (e.g. '1-')", pattern: /^\s*\d+-\s/m },
    { name: "asterisk bullet (e.g. '* foo')", pattern: /^\s*\*\s/m },
    { name: "unicode bullet (e.g. '• foo')", pattern: /^\s*•\s/m },
  ];

  for (const tool of tools) {
    // Tools exempt from BOTH groups have no assertions to make — emit a
    // placeholder test so vitest doesn't error on empty describe blocks.
    const isFullyExempt =
      EXEMPT_FROM_FORBIDDEN_TOKENS.has(tool.name) && EXEMPT_FROM_WHEN.has(tool.name);
    describe(tool.name, () => {
      if (isFullyExempt) {
        test("deferred to follow-up PR (see EXEMPT_FROM_FORBIDDEN_TOKENS rationale)", () => {
          expect(EXEMPT_FROM_FORBIDDEN_TOKENS.has(tool.name)).toBe(true);
        });
        return;
      }
      if (!EXEMPT_FROM_FORBIDDEN_TOKENS.has(tool.name)) {
        for (const rule of FORBIDDEN) {
          test(`MUST NOT contain '${rule.name}'`, () => {
            const match = tool.description.match(rule.pattern);
            if (match) {
              throw new Error(
                `${tool.name} description (src/server.ts:${tool.lineNo}) contains forbidden token '${match[0]}' ` +
                `(rule: ${rule.name}). ${rule.rationale}`,
              );
            }
          });
        }
      }

      if (!EXEMPT_FROM_WHEN.has(tool.name)) {
        test("MUST contain a WHEN: section (or WHEN TO USE: legacy)", () => {
          // Per ADR-0002: every routing-target ctx_* tool MUST have a
          // positive selection cue. The legacy alias `WHEN TO USE:` is
          // accepted because ctx_index already uses it and rewriting that
          // header is out of scope for this PR (audit MEDIUM, separate work).
          //
          // We can't use `\bWHEN` because in source-form descriptions the
          // preceding token is often the literal two-char sequence `\n`
          // (from the JS escape) — `n` is a word character so `\b` fails.
          // Likewise, for `+ "WHEN:..."` concat style the preceding char is
          // `"`. Match any of those legitimate prefixes explicitly so both
          // template-literal and string-concat description shapes pass.
          const hasWhen = /(?:\\n|^|\s|")WHEN(?:\s+TO\s+USE)?:/.test(tool.description);
          expect(hasWhen, `${tool.name} description (src/server.ts:${tool.lineNo}) must contain a WHEN: section`).toBe(true);
        });

        // ── Canonical structure assertions (PR #683 WS3) ─────────────
        const flat = flattenDescription(tool.description);

        test("MUST contain RETURNS: and EXAMPLE: sections (canonical structure)", () => {
          for (const section of MANDATORY) {
            expect(
              flat.includes(section + ":"),
              `${tool.name} (src/server.ts:${tool.lineNo}) missing mandatory section '${section}:' per ADR-0002 canonical structure.`,
            ).toBe(true);
          }
        });

        test("section order MUST be WHEN -> WHEN NOT -> RETURNS -> EXAMPLE", () => {
          // For each canonical section present, its position in the flattened
          // description must be strictly greater than the previous canonical
          // section's position. Carve-out headers (DESTRUCTIVE, SCOPES,
          // CONTRACT for ctx_purge) are allowed between canonical sections
          // — only the relative order of the canonical four is enforced.
          let lastPos = -1;
          for (const section of CANONICAL_ORDER) {
            const pos = flat.indexOf(section + ":");
            if (pos < 0) continue;
            expect(
              pos,
              `${tool.name} (src/server.ts:${tool.lineNo}) section '${section}:' appears before a sibling that should follow it (positions: ${CANONICAL_ORDER.map(s => `${s}=${flat.indexOf(s + ":")}`).join(", ")}). Canonical order: WHEN -> WHEN NOT -> RETURNS -> EXAMPLE.`,
            ).toBeGreaterThan(lastPos);
            lastPos = pos;
          }
        });

        test("section headers MUST be UPPERCASE + colon (no off-spec UPPERCASE sections)", () => {
          // Extract every UPPERCASE-header occurrence (two or more uppercase
          // chars, optional space, then colon at line start). Reject any
          // that aren't in the canonical set or the per-tool carve-out.
          const headerMatches = [...flat.matchAll(/^([A-Z][A-Z _]+):/gm)].map(m => m[1]);
          const allowed = new Set<string>([...CANONICAL_ORDER, ...(ALLOWED_EXTRA_SECTIONS[tool.name] ?? [])]);
          const offSpec = [...new Set(headerMatches.filter(h => !allowed.has(h)))];
          expect(
            offSpec,
            `${tool.name} (src/server.ts:${tool.lineNo}) uses off-spec UPPERCASE sections [${offSpec.join(", ")}]. ` +
            `Allowed: [${[...allowed].join(", ")}]. Fold operational sub-guidance (CONCURRENCY, TIPS, ...) into WHEN: / RETURNS: prose.`,
          ).toEqual([]);
        });

        test("bullets MUST use '- ' markdown only (no '1.', '1-', '* ', or '•')", () => {
          // Scan the flattened description. The rubric requires uniform
          // markdown bullets so the host LLM tokenizes them consistently
          // across Claude / GPT / Gemini / Llama. Numbered ordering inside
          // a routing-target description is also discouraged because each
          // bullet should be independently true, not sequenced.
          const failures: string[] = [];
          for (const rule of BAD_BULLETS) {
            const m = flat.match(rule.pattern);
            if (m) {
              const lineNo = flat.slice(0, flat.indexOf(m[0])).split("\n").length;
              failures.push(`${rule.name} at description-line ${lineNo}: '${m[0].trim()}'`);
            }
          }
          expect(
            failures,
            `${tool.name} (src/server.ts:${tool.lineNo}) bullet uniformity violation: ${failures.join("; ")}. ` +
            `Use markdown '- ' bullets only (ADR-0002 §Canonical structure).`,
          ).toEqual([]);
        });

        // ── PR #683 second amendment (Mert flag): RETURNS form uniformity ──
        //
        // ADR-0002 L56-57 specifies RETURNS as a header on its own line with
        // the body on the next line indented (matching WHEN: / WHEN NOT:
        // shape). Three tools (ctx_execute, ctx_execute_file, ctx_purge)
        // historically used inline form ("RETURNS: only your printed output.")
        // while four tools (ctx_index, ctx_search, ctx_fetch_and_index,
        // ctx_batch_execute) used the canonical header+body form. Mert flagged
        // the visual inconsistency on review. This guard locks the canonical
        // form so the regression can't slip back.
        //
        // EXAMPLE: stays inline per ADR-0002 L59 ("EXAMPLE: <one canonical
        // call>"). The asymmetry is intentional — RETURNS prose is multi-line
        // capable, EXAMPLE values are one-call-per-line.
        test("RETURNS: header MUST be on its own line, body indented below (ADR-0002 L56-57)", () => {
          // Match RETURNS: followed by anything other than \n on the same
          // line (treating \\n source escape as the same boundary as a real
          // newline). Inline form like "RETURNS: only your printed output."
          // fails; header+body form like "RETURNS:\n  Only your printed
          // output." passes.
          //
          // Source descriptions live either as template literals with real
          // newlines OR as `+ "...\n"` concat strings with escape sequences.
          // Match both shapes via a non-newline-non-backslash assertion.
          const inlineRe = /RETURNS:[ \t]+[^\n\\]/;
          const m = tool.description.match(inlineRe);
          expect(
            m,
            m
              ? `${tool.name} (src/server.ts:${tool.lineNo}) uses inline RETURNS form: '${m[0]}…'. ` +
                `ADR-0002 L56-57 requires header on its own line with body indented below. ` +
                `Rewrite as 'RETURNS:\\n  <body>'.`
              : "RETURNS: header on own line.",
          ).toBeNull();
        });
      }
    });
  }
});

// ──────────────────────────────────────────────────────────────────────────
// Hook routing prompt-surface contract (#683 ADR-0002 + ADR-0003 extension)
//
// `src/server.ts` carries the MCP tool-description surface (read by host
// LLMs at tool-selection time). The hook routing layer carries TWO MORE
// agent-facing prompt surfaces that share the same cross-LLM safety bias
// concerns (rubric #9):
//
//   1. `hooks/routing-block.mjs` — system-prompt injection text shipped on
//      every SessionStart and every routing redirect. Lives in 100% of
//      sessions; higher blast radius than any single tool description.
//   2. `hooks/core/routing.mjs` — runtime deny-reason strings shown to the
//      agent when a Bash / Read / Grep / WebFetch call is intercepted.
//      ADR-0003 distinguishes:
//        - CASE A (routing redirect — alternative tool exists for
//          context-window or efficiency reasons) → MUST use "redirected"
//          opener, MUST NOT use bare "BLOCKED".
//        - CASE B (true security / policy restriction) → "blocked by
//          security policy" + matched pattern is correct and expected.
//
// This block extends the ADR-0002 forbidden-token contract above to both
// surfaces, plus enforces ADR-0003 CASE A wording requirements on every
// `action: "deny"` / redirect-emitting branch in `routing.mjs`.
//
// One contract test, three prompt surfaces. Single regression guard.
// ──────────────────────────────────────────────────────────────────────────
describe("hook routing prompt-surface contract (#683 ADR-0002 + ADR-0003)", () => {
  const routingMjsPath = resolve(__dirname, "../../hooks/core/routing.mjs");
  const routingBlockMjsPath = resolve(__dirname, "../../hooks/routing-block.mjs");
  const routingMjs = readFileSync(routingMjsPath, "utf-8");
  const routingBlockMjs = readFileSync(routingBlockMjsPath, "utf-8");

  // Forbidden tokens that apply to BOTH hook prompt surfaces. These are
  // a strict superset of the ADR-0002 ctx_* description rules above
  // because routing.mjs deny reasons and routing-block.mjs guidance run
  // as system-prompt injection — exactly the layer where Constitutional AI
  // safety priors fire most aggressively (rubric #9).
  //
  // Each pattern is documented inline so a future contributor reading a
  // failure understands the rationale, not just the regex.
  type SurfaceRule = { name: string; pattern: RegExp; rationale: string };
  const SURFACE_FORBIDDEN: SurfaceRule[] = [
    {
      name: "<forbidden_actions> XML container",
      // Rubric #9: the container name itself is a Constitutional AI
      // trigger. Anthropic-tier models are RLHF'd to handle anything
      // tagged "forbidden" with extra caution, which inverts the intent
      // (the agent should follow the directive, not refuse it).
      // Reframe positively as <when_not_to_use> or fold into the
      // affirmative hierarchy.
      pattern: /<forbidden_actions>/,
      rationale: "Rename to <when_not_to_use> or fold into positive hierarchy (rubric #2 + #9).",
    },
    {
      name: "NEVER (uppercase imperative)",
      // Rubric #2 + #7: smaller / cross-family models fixate on the
      // forbidden token (ironic process theory). Use ALWAYS / WHEN NOT
      // structure instead.
      pattern: /\bNEVER\b/,
      rationale: "Rewrite uppercase NEVER as ALWAYS / WHEN NOT / positive directive.",
    },
    {
      name: "FORBIDDEN (capital banner)",
      // Same rubric. Pure negative banner with no positive counterpart
      // is the highest-risk framing under Constitutional AI priors.
      pattern: /\bFORBIDDEN\b/,
      rationale: "Avoid 'FORBIDDEN' banner; express as positive WHEN: cues.",
    },
    {
      name: "NO X for Y bullet",
      // Rubric #2: bullet-list negatives anchor the agent's attention
      // on the prohibited action. Convert each bullet to "Use Y for X"
      // (affirmative redirect).
      pattern: /^\s*-\s*NO\s+(Bash|Read|Grep|WebFetch|ctx_)/m,
      rationale: "Convert 'NO X for Y' bullets to positive 'Use Y for X' redirects.",
    },
  ];

  test("hooks/routing-block.mjs MUST NOT contain forbidden tokens", () => {
    const failures: string[] = [];
    for (const rule of SURFACE_FORBIDDEN) {
      const m = routingBlockMjs.match(rule.pattern);
      if (m) {
        const lineNo = routingBlockMjs.slice(0, m.index ?? 0).split("\n").length;
        failures.push(
          `hooks/routing-block.mjs:${lineNo} contains forbidden token '${m[0]}' ` +
            `(rule: ${rule.name}). ${rule.rationale}`,
        );
      }
    }
    expect(failures).toEqual([]);
  });

  test("hooks/core/routing.mjs MUST NOT contain forbidden tokens", () => {
    const failures: string[] = [];
    for (const rule of SURFACE_FORBIDDEN) {
      const m = routingMjs.match(rule.pattern);
      if (m) {
        const lineNo = routingMjs.slice(0, m.index ?? 0).split("\n").length;
        failures.push(
          `hooks/core/routing.mjs:${lineNo} contains forbidden token '${m[0]}' ` +
            `(rule: ${rule.name}). ${rule.rationale}`,
        );
      }
    }
    expect(failures).toEqual([]);
  });

  // ── ADR-0003 enforcement on routing.mjs deny / redirect strings ──
  //
  // Mechanically: any string literal that the agent sees as a redirect
  // explanation (curl/wget, inline HTTP, build tools, WebFetch) is CASE A.
  // ADR-0003 CASE A REQUIRES:
  //   - Opens with the verb "redirected"
  //   - MUST NOT contain the bare uppercase token "BLOCKED" (reserved for
  //     CASE B — see `Blocked by security policy: …` form)
  //   - MUST name the alternative ctx_* tool the agent should use
  //
  // CASE B (security policy) strings live in the same file and use the
  // form `Blocked by security policy: matches deny pattern <pat>`. Those
  // are correct AS-IS — the test only enforces CASE A on CASE A strings.
  //
  // Detection heuristic: a string-literal segment that contains "redirected"
  // is CASE A. A string segment that contains "Blocked by security policy"
  // is CASE B (exempt). String segments are extracted naively from the
  // source by walking forward from each return statement and capturing the
  // template-literal payload up to the closing backtick — sufficient for
  // the four current call sites (L707, L738, L751, L804) and any future
  // ones a contributor adds.
  describe("ADR-0003 CASE A: routing.mjs redirect deny reasons", () => {
    type CaseAString = { lineNo: number; payload: string };

    function extractCaseAStrings(src: string): CaseAString[] {
      const lines = src.split("\n");
      const out: CaseAString[] = [];
      for (let i = 0; i < lines.length; i++) {
        const ln = lines[i];
        // Skip comments — both single-line // and JSDoc * lines that mention
        // 'redirected' as documentation of the routing case, not as a deny
        // payload. The real CASE A payloads live inside template literals
        // assigned to `command:` or `reason:` object keys.
        const trimmed = ln.replace(/^\s+/, "");
        if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
        // Require BOTH "redirected" AND a template-literal backtick
        // (current shape for L707/738/751/804). CASE B strings use the
        // `Blocked by security policy: …` form and are excluded.
        if (
          /redirected/i.test(ln) &&
          /`/.test(ln) &&
          !/Blocked by security policy/.test(ln)
        ) {
          out.push({ lineNo: i + 1, payload: ln });
        }
      }
      return out;
    }

    const caseAs = extractCaseAStrings(routingMjs);

    test("at least 4 CASE A redirect strings present (sanity check on extractor)", () => {
      // Current corpus: L707 curl/wget, L738 inline HTTP, L751 build tools,
      // L804 WebFetch. If a contributor removes one, the count drops and
      // this sanity check forces the test author to revisit the extractor.
      expect(caseAs.length).toBeGreaterThanOrEqual(4);
    });

    for (const cs of caseAs) {
      describe(`hooks/core/routing.mjs:${cs.lineNo}`, () => {
        test("MUST open with the verb 'redirected' (CASE A wording — ADR-0003)", () => {
          expect(cs.payload).toMatch(/redirected/i);
        });

        test("MUST NOT contain bare uppercase BLOCKED (reserved for CASE B)", () => {
          // ADR-0003: CASE A strings MUST NOT borrow CASE B vocabulary.
          // Lowercase `block` (e.g. `blockchain`) is fine; uppercase BLOCKED
          // is the Constitutional AI trigger.
          expect(cs.payload).not.toMatch(/\bBLOCKED\b/);
        });

        test("MUST name at least one ctx_* alternative tool", () => {
          // ADR-0003 §CASE A: "MUST specify the alternative tool to use."
          // The current four sites all name ctx_execute and/or
          // ctx_fetch_and_index — we just enforce that SOMETHING ctx_*
          // is mentioned so the agent has a concrete next call.
          expect(cs.payload).toMatch(/ctx_(execute|fetch_and_index|search|batch_execute)/);
        });

        // ── PR #683 follow-up (Mert flag): negation-pattern eradication ──
        //
        // The original PR #654 fix replaced the single word "blocked" with
        // "redirected", which removed the Constitutional-AI safety trigger but
        // kept a sibling rubric #2 violation in the very next clause:
        //
        //   "(context-window optimization, NOT a network restriction)"
        //
        // The audit (TOOL-DESCRIPTIONS-AUDIT.md §2 Probe 3) measured this
        // parenthetical regressing Haiku capitulation from 0/6 → 2/6 — the
        // bare-NOT construct primes the very frame it tries to deny (ironic
        // process theory). Per ADR-0002 rubric #2 (affirmative beats negative),
        // CASE A strings MUST avoid bare-NOT negations entirely. Reframe with
        // affirmative "X has full network access" + imperative retry hint.
        test("MUST NOT contain the 'NOT a network' negation (PR #683 follow-up)", () => {
          // Matches "NOT a network restriction", "NOT a network/security
          // restriction", "is NOT a network ...", etc. Affirmative-only voice.
          expect(cs.payload).not.toMatch(/\bNOT\s+a\s+network\b/i);
        });

        test("MUST NOT contain 'Do NOT retry' negation (PR #683 follow-up)", () => {
          // Same rubric: "Do NOT retry with curl/wget" anchors attention on
          // the disallowed action. Express as the positive next step — the
          // ctx_execute / ctx_fetch_and_index call IS the next step.
          expect(cs.payload).not.toMatch(/\bDo\s+NOT\s+retry\b/i);
        });

        // ── PR #683 second amendment (Mert flag): no org-rationale preface ──
        //
        // The first amendment replaced the bare-NOT parenthetical
        // "(context-window optimization, NOT a network restriction)" with the
        // affirmative-voice opening "redirected to <ctx_tool> for context-window
        // efficiency". Mert's second-pass flag: the "for X reason" preface is
        // org-rationale, not action input. The agent's job is to (a) make the
        // correct next call, (b) know it has the capability, (c) know when to
        // retry — not to audit policy. The capability affirmation
        // "<ctx_tool> has full network access" already carries the substantive
        // signal the rationale was double-encoding. ADR-0003 §Second amendment.
        //
        // Compare HTTP 301: the response carries "Location: <new-url>" and the
        // client uses it. The server never appends "for SEO efficiency".
        test("MUST NOT contain 'for context-window efficiency' org-rationale (PR #683 second amendment)", () => {
          expect(cs.payload).not.toMatch(/\bfor\s+context-window\s+(efficiency|optimization)\b/i);
        });
      });
    }
  });
});

// ──────────────────────────────────────────────────────────────────────────
// PR #683 follow-up (Mert flag, 2026-05-24): tool description source-form
// uniformity — embedded `\n\n` escapes in template literals
//
// ctx_execute was the lone ctx_* tool whose description used a template
// literal with embedded `\n\n` escape sequences inline (compact but
// escape-heavy and harder to scan). Every other multi-section ctx_*
// description used `"..." + "...\n\n"` concat-form. Both render identically
// to the host LLM — the bytes are the same `\n\n` separators — but the
// source form was inconsistent.
//
// Canonical decision (ADR-0002 follow-up): template literal with REAL
// newlines. Source mirrors the rendered prompt. Zero escape sequences.
// Markdown-friendly when read in an editor. The contract test below locks
// the decision so a future contributor can't slip back to either of the
// rejected forms (embedded `\n\n` escapes OR multi-line string concat).
// ──────────────────────────────────────────────────────────────────────────
describe("tool description source form contract (#683 PR follow-up)", () => {
  const serverTsPath = resolve(__dirname, "../../src/server.ts");
  const serverTs = readFileSync(serverTsPath, "utf-8");

  // Locate every `description: ...,` block under a server.registerTool() call
  // and capture its raw source text. Reuse the same anchor pattern as the
  // ADR-0002 contract test for consistency.
  function extractDescriptionBlocks(): Array<{ name: string; raw: string; lineNo: number }> {
    const out: Array<{ name: string; raw: string; lineNo: number }> = [];
    const lines = serverTs.split("\n");
    const RE_REGISTER = /server\.registerTool\(\s*$/;
    const RE_NAME = /^\s*"(ctx_[a-z_]+)"\s*,\s*$/;
    for (let i = 0; i < lines.length; i++) {
      if (!RE_REGISTER.test(lines[i])) continue;
      const nameMatch = lines[i + 1]?.match(RE_NAME);
      if (!nameMatch) continue;
      const name = nameMatch[1];
      let descStart = -1;
      let descEnd = -1;
      for (let j = i + 2; j < Math.min(i + 80, lines.length); j++) {
        if (descStart < 0 && /^\s*description:/.test(lines[j])) {
          descStart = j;
        } else if (descStart >= 0 && /^\s*(inputSchema|outputSchema|annotations):/.test(lines[j])) {
          descEnd = j;
          break;
        }
      }
      if (descStart < 0 || descEnd < 0) continue;
      out.push({ name, raw: lines.slice(descStart, descEnd).join("\n"), lineNo: descStart + 1 });
    }
    return out;
  }

  const blocks = extractDescriptionBlocks();

  // Tools whose descriptions are intentionally one-line concats with no
  // section structure (diagnostic / GUI affordances per ADR-0002
  // §Exemptions). They carry no `\n` separators in the source at all, so
  // the embedded-escape rule is trivially satisfied and the concat-form
  // exemption applies. Future contributors can promote these to template
  // literals at will; they just aren't forced to.
  const SHORT_DESCRIPTION_EXEMPT = new Set([
    "ctx_stats",
    "ctx_doctor",
    "ctx_upgrade",
    "ctx_insight",
  ]);

  test("at least 11 ctx_* tools surfaced for source-form contract", () => {
    expect(blocks.length).toBeGreaterThanOrEqual(11);
  });

  for (const block of blocks) {
    if (SHORT_DESCRIPTION_EXEMPT.has(block.name)) continue;

    describe(block.name, () => {
      test("source-form MUST NOT contain embedded '\\n\\n' escape (use real newlines in template literal)", () => {
        // The forbidden pattern is the literal four-character source-text
        // sequence: backslash, n, backslash, n. In source these appear inside
        // template literals (`...\n\n...`) or quoted strings ("...\n\n").
        // The canonical form is a template literal with REAL newlines so the
        // source mirrors the rendered prompt byte-for-byte without escapes.
        const hasEscapedDoubleNewline = /\\n\\n/.test(block.raw);
        expect(
          hasEscapedDoubleNewline,
          `${block.name} description (src/server.ts:${block.lineNo}) contains embedded '\\n\\n' escapes. ` +
            `Use a template literal with REAL newlines instead — source should mirror the rendered prompt. ` +
            `Diff: replace \`...\\n\\n...\` source spans with multi-line template literals.`,
        ).toBe(false);
      });

      test("source-form MUST NOT use multi-line string concat with '+' (use template literal)", () => {
        // Concat-form was the legacy alternative — `"...\n\n" + "WHEN:\n"`.
        // Once we ban `\n\n`, the only sensible multi-section shape is a
        // template literal. This second assertion makes that explicit so a
        // contributor doesn't switch one negative form for another (e.g.
        // splitting on a newline inside a `+ "\n"` chain).
        //
        // Heuristic: a `"\n" +` or `"+ \n"` chain inside the description
        // block. Single-line concats (e.g. `"a " + "b"`) are too loose to
        // ban without false positives, so we anchor on the embedded newline
        // form which is what multi-section descriptions actually used.
        const hasConcatNewline = /"[^"]*"\s*\+\s*\n\s*"/.test(block.raw) ||
          /\\n"\s*\+/.test(block.raw);
        expect(
          hasConcatNewline,
          `${block.name} description (src/server.ts:${block.lineNo}) uses multi-line string concat with '+'. ` +
            `Use a template literal with REAL newlines instead — single canonical source form for all multi-section descriptions.`,
        ).toBe(false);
      });
    });
  }
});

// ═══════════════════════════════════════════════════════════════════════════
// ctx_index: directory path support (#687)
// ═══════════════════════════════════════════════════════════════════════════
//
// Reported by @matiasduartee in #687: passing a directory path to ctx_index
// surfaces `refusing to index <path>: not a regular file` from the security
// gate at src/store.ts:845. The gate is correct and stays — directory support
// is added as a separate concern via `ContentStore.indexDirectory()` dispatched
// at the server handler, with each discovered file going through the existing
// per-file `openSync + fstatSync.isFile()` invariant.
//
// Cross-OS — repro spanned macOS + Windows 11 across 4 clients.

describe("ctx_index: directory path support (#687)", () => {
  const baseDir = mkdtempSync(join(tmpdir(), "ctx-index-dir-687-"));

  function makeProjectDir(name: string): string {
    const dir = join(baseDir, name);
    mkdirSync(dir, { recursive: true });
    return dir;
  }

  afterAll(() => {
    rmSync(baseDir, { recursive: true, force: true });
  });

  function spawnServerWithProjectDir(projectDirEnv: string): ChildProcess {
    return spawn("node", [mcpEntry], {
      stdio: ["pipe", "pipe", "pipe"],
      env: {
        ...process.env,
        QUIET_CONTEXT_DISABLE_VERSION_CHECK: "1",
        CLAUDE_PROJECT_DIR: projectDirEnv,
      },
    });
  }

  async function awaitRpc(
    proc: ChildProcess,
    id: number,
    request: Record<string, unknown>,
    timeoutMs = 15_000,
  ): Promise<DoctorJsonRpcResponse | undefined> {
    return new Promise((resolveProm) => {
      let buffer = "";
      const onData = (d: Buffer) => {
        buffer += d.toString();
        let idx: number;
        while ((idx = buffer.indexOf("\n")) >= 0) {
          const line = buffer.slice(0, idx).trim();
          buffer = buffer.slice(idx + 1);
          if (!line) continue;
          try {
            const parsed = JSON.parse(line) as DoctorJsonRpcResponse;
            if (parsed.id === id) {
              proc.stdout!.off("data", onData);
              clearTimeout(timer);
              resolveProm(parsed);
              return;
            }
          } catch { /* ignore */ }
        }
      };
      const timer = setTimeout(() => {
        proc.stdout!.off("data", onData);
        resolveProm(undefined);
      }, timeoutMs);
      proc.stdout!.on("data", onData);
      sendRpc(proc, request);
    });
  }

  async function initialize(proc: ChildProcess, suffix: string): Promise<void> {
    await awaitRpc(proc, 1, {
      jsonrpc: "2.0", id: 1, method: "initialize",
      params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: `ctx-index-dir-${suffix}`, version: "1.0" } },
    });
    sendRpc(proc, { jsonrpc: "2.0", method: "notifications/initialized" });
  }

  test("file path still indexes (backwards-compat control)", async () => {
    const projectDir = makeProjectDir("compat");
    writeFileSync(join(projectDir, "doc.md"), "# Doc\n\nbody\n");
    const proc = spawnServerWithProjectDir(projectDir);
    try {
      await initialize(proc, "compat");
      const resp = await awaitRpc(proc, 100, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: "doc.md" } },
      });
      expect(resp?.error).toBeUndefined();
      const text = resp?.result?.content?.[0]?.text ?? "";
      expect(text).toMatch(/Indexed \d+ section/);
    } finally {
      try { proc.kill("SIGTERM"); } catch { /* best effort */ }
    }
  }, 30_000);

  test("directory with 3 .md files indexes all 3", async () => {
    const projectDir = makeProjectDir("happy");
    const docs = join(projectDir, "docs");
    mkdirSync(docs);
    writeFileSync(join(docs, "a.md"), "# A\n\nalpha-687\n");
    writeFileSync(join(docs, "b.md"), "# B\n\nbeta-687\n");
    writeFileSync(join(docs, "c.md"), "# C\n\ngamma-687\n");
    const proc = spawnServerWithProjectDir(projectDir);
    try {
      await initialize(proc, "happy");
      const resp = await awaitRpc(proc, 100, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: "docs" } },
      });
      expect(resp?.error).toBeUndefined();
      const text = resp?.result?.content?.[0]?.text ?? "";
      // Response must report directory indexing — 3 files.
      expect(text).toMatch(/3 file/i);
      expect(text).not.toMatch(/not a regular file/);
    } finally {
      try { proc.kill("SIGTERM"); } catch { /* best effort */ }
    }
  }, 30_000);

  test("empty directory returns 0-files response, not error", async () => {
    const projectDir = makeProjectDir("empty");
    const empty = join(projectDir, "empty");
    mkdirSync(empty);
    const proc = spawnServerWithProjectDir(projectDir);
    try {
      await initialize(proc, "empty");
      const resp = await awaitRpc(proc, 100, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: "empty" } },
      });
      expect(resp?.error).toBeUndefined();
      const text = resp?.result?.content?.[0]?.text ?? "";
      expect(resp?.result?.isError).not.toBe(true);
      expect(text).toMatch(/0 file/i);
    } finally {
      try { proc.kill("SIGTERM"); } catch { /* best effort */ }
    }
  }, 30_000);

  test("nested directory respects maxDepth", async () => {
    const projectDir = makeProjectDir("depth");
    mkdirSync(join(projectDir, "lvl1", "lvl2", "lvl3"), { recursive: true });
    writeFileSync(join(projectDir, "root.md"), "# root\n");
    writeFileSync(join(projectDir, "lvl1", "one.md"), "# one\n");
    writeFileSync(join(projectDir, "lvl1", "lvl2", "two.md"), "# two\n");
    writeFileSync(join(projectDir, "lvl1", "lvl2", "lvl3", "three.md"), "# three\n");
    const proc = spawnServerWithProjectDir(projectDir);
    try {
      await initialize(proc, "depth");
      const resp = await awaitRpc(proc, 100, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: ".", maxDepth: 1 } },
      });
      expect(resp?.error).toBeUndefined();
      const text = resp?.result?.content?.[0]?.text ?? "";
      // maxDepth 1: root.md + one.md = 2 files (lvl2/* and lvl3/* excluded).
      expect(text).toMatch(/2 file/i);
    } finally {
      try { proc.kill("SIGTERM"); } catch { /* best effort */ }
    }
  }, 30_000);

  test("respects default exclude patterns (node_modules, .git skip)", async () => {
    const projectDir = makeProjectDir("excl");
    mkdirSync(join(projectDir, "node_modules", "pkg"), { recursive: true });
    mkdirSync(join(projectDir, ".git"), { recursive: true });
    writeFileSync(join(projectDir, "real.md"), "# real\n");
    writeFileSync(join(projectDir, "node_modules", "pkg", "junk.md"), "# junk\n");
    writeFileSync(join(projectDir, ".git", "config.md"), "# git\n");
    const proc = spawnServerWithProjectDir(projectDir);
    try {
      await initialize(proc, "excl");
      const resp = await awaitRpc(proc, 100, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: "." } },
      });
      expect(resp?.error).toBeUndefined();
      const text = resp?.result?.content?.[0]?.text ?? "";
      // Only real.md indexed.
      expect(text).toMatch(/1 file/i);
    } finally {
      try { proc.kill("SIGTERM"); } catch { /* best effort */ }
    }
  }, 30_000);

  test("respects maxFiles cap (50 fixtures, request 10)", async () => {
    const projectDir = makeProjectDir("cap");
    const fixtures = join(projectDir, "fx");
    mkdirSync(fixtures);
    for (let i = 0; i < 50; i++) {
      writeFileSync(join(fixtures, `f${i}.md`), `# file ${i}\n`);
    }
    const proc = spawnServerWithProjectDir(projectDir);
    try {
      await initialize(proc, "cap");
      const resp = await awaitRpc(proc, 100, {
        jsonrpc: "2.0", id: 100, method: "tools/call",
        params: { name: "ctx_index", arguments: { path: "fx", maxFiles: 10 } },
      });
      expect(resp?.error).toBeUndefined();
      const text = resp?.result?.content?.[0]?.text ?? "";
      // Cap notice in response (architect FTS5-blowup guard).
      expect(text).toMatch(/10 file/i);
      expect(text).toMatch(/cap|limit|max/i);
    } finally {
      try { proc.kill("SIGTERM"); } catch { /* best effort */ }
    }
  }, 30_000);
});
