UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

2,251 lines • 90 kB
import { createRequire as __pilensCreateRequire } from "node:module"; const require = __pilensCreateRequire(import.meta.url);
import {
  stripAnsi
} from "./chunk-FC4RP4I2.js";
import {
  resolveGitCheckout
} from "./chunk-3I5QFT3N.js";
import {
  emitBounded
} from "./chunk-ARFCXG7S.js";
import {
  augmentPythonEnvironment,
  detectPythonEnvironment
} from "./chunk-DIULIWKU.js";
import {
  PathKeyedMap
} from "./chunk-Z3CCVDUB.js";
import {
  resolveToolCwd
} from "./chunk-RUOG5D2C.js";
import {
  findGlobalBinary
} from "./chunk-U3VGR4VU.js";
import {
  resolveLanguageRootForFile
} from "./chunk-22AVIGXS.js";
import {
  safeSpawn,
  safeSpawnAsync
} from "./chunk-VN2AXLEX.js";
import {
  detectFileKind
} from "./chunk-5ZEJHV35.js";
import {
  detectFileRole
} from "./chunk-DQ2NZ7C4.js";
import {
  findNearestDirWithAnyBasename
} from "./chunk-VNMT7C64.js";
import {
  LEDGER_FIELD_MAX,
  recordDegradationOnce
} from "./chunk-N3YQJI6O.js";
import {
  createSubsystemLogger
} from "./chunk-O6TQT6RI.js";
import {
  BoundedFifoMap
} from "./chunk-ABBOA7UD.js";
import {
  findNearestMarkerRootDetailed,
  isRealGitMarker,
  isUnderDir,
  minimatch,
  normalizeEphemeralMapKey,
  normalizeMapKey,
  pathsEqual,
  toPosix
} from "./chunk-Q5U6FMDI.js";

// dist/clients/test-runner-client.js
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";

// dist/clients/run-duration.js
function isMeasuredDuration(duration) {
  return typeof duration === "number" && Number.isFinite(duration) && duration >= 0;
}
function toMeasuredDurationMs(duration) {
  return isMeasuredDuration(duration) ? Math.round(duration) : void 0;
}
function formatRunDurationMs(duration) {
  return isMeasuredDuration(duration) ? `${Math.round(duration)}ms` : "unmeasured";
}

// dist/clients/test-runner-client.js
function isRunnerErrorResult(result) {
  return result.failed === 0 && !!result.error;
}
var SOURCE_TO_TEST_PATTERNS = [
  {
    ext: ".ts",
    testExts: [".test.ts", ".spec.ts"],
    dirs: ["__tests__", "tests", ".", "__tests__"]
  },
  {
    ext: ".tsx",
    testExts: [".test.tsx", ".spec.tsx"],
    dirs: ["__tests__", "tests", ".", "__tests__"]
  },
  {
    ext: ".js",
    testExts: [".test.js", ".spec.js"],
    dirs: ["__tests__", "tests", ".", "__tests__"]
  },
  {
    ext: ".jsx",
    testExts: [".test.jsx", ".spec.jsx"],
    dirs: ["__tests__", "tests", ".", "__tests__"]
  },
  {
    ext: ".py",
    testExts: ["test_*.py", "*_test.py"],
    dirs: ["tests", "test", ".", "."]
  },
  { ext: ".go", testExts: ["_test.go"], dirs: [".", ".", ".", "."] },
  // Go tests are co-located
  { ext: ".rs", testExts: [".rs"], dirs: ["tests", "tests", "src", "."] },
  // Rust: tests/ or #[test] in src
  // PHPUnit convention: tests/ mirrors src/ with ClassNameTest.php naming
  // (e.g. src/Foo/Bar.php -> tests/Foo/BarTest.php). Basename is already the
  // class name (PHP files are named after their class), so no case transform
  // is needed — the mirrored-directory search below handles the tests/ root.
  { ext: ".php", testExts: ["Test.php"], dirs: ["tests"] },
  // ExUnit convention: test/ mirrors lib/ with a _test.exs suffix on the same
  // basename (e.g. lib/accounts/user.ex -> test/accounts/user_test.exs).
  { ext: ".ex", testExts: ["_test.exs"], dirs: ["test"] }
];
function findHoistedNodeModulesPackage(cwd, packageName) {
  return findNearestDirWithAnyBasename(path.resolve(cwd), [
    `node_modules/${packageName}`
  ]);
}
var MAX_PYTEST_RECURSE_DEPTH = 3;
var TURN_END_EXCLUDED_TEST_GLOBS = [
  "**/integration/**",
  "**/e2e/**",
  "**/*.integration.*",
  "**/*.e2e.*"
];
function recordCheckoutIdentityGap(metadata) {
  recordDegradationOnce({
    kind: "test-checkout-identity-unavailable",
    subject: createHash("sha256").update(JSON.stringify(metadata)).digest("hex"),
    reason: "cannot establish automatic test checkout identity",
    metadata
  });
}
function foreignGitRoot(testFilePath, cwd) {
  let realCwd;
  let realTarget;
  try {
    realCwd = fs.realpathSync.native(path.resolve(cwd));
    realTarget = fs.realpathSync.native(path.resolve(cwd, toPosix(testFilePath)));
  } catch (error_) {
    const errorCode = filesystemErrorCode(error_);
    if (errorCode === "ENOENT" || errorCode === "ENOTDIR")
      return null;
    recordCheckoutIdentityGap({
      cwd,
      candidate: testFilePath,
      lookup: "filesystem",
      detail: "realpath",
      errorCode
    });
    return null;
  }
  const targetWalk = findNearestMarkerRootDetailed(path.dirname(realTarget), [".git"], {
    markerPredicate: (marker) => isRealGitMarker(marker, true)
  });
  if (targetWalk.kind !== "found") {
    if (targetWalk.kind === "unavailable") {
      recordCheckoutIdentityGap({
        cwd,
        candidate: testFilePath,
        lookup: "target-root",
        detail: "marker-error",
        markerPath: targetWalk.markerPath,
        errorCode: filesystemErrorCode(targetWalk.cause)
      });
    } else if (targetWalk.reason === "depth-limit") {
      recordCheckoutIdentityGap({
        cwd,
        candidate: testFilePath,
        lookup: "target-root",
        detail: targetWalk.reason
      });
    }
    return null;
  }
  const root = targetWalk.root;
  if (!isUnderDir(realCwd, root))
    return root;
  const dispatchWalk = findNearestMarkerRootDetailed(realCwd, [".git"], {
    markerPredicate: (marker) => isRealGitMarker(marker, true)
  });
  if (dispatchWalk.kind !== "found") {
    recordCheckoutIdentityGap({
      cwd,
      candidate: testFilePath,
      lookup: "dispatch-root",
      ...dispatchWalk.kind === "unavailable" ? {
        detail: "marker-error",
        markerPath: dispatchWalk.markerPath,
        errorCode: filesystemErrorCode(dispatchWalk.cause)
      } : { detail: dispatchWalk.reason }
    });
    return null;
  }
  return !pathsEqual(root, dispatchWalk.root) ? root : null;
}
function sharesCommonDir(cwd, checkoutRoot) {
  const dispatch = resolveGitCheckout(cwd);
  const excluded = resolveGitCheckout(checkoutRoot);
  return dispatch !== null && excluded !== null && pathsEqual(dispatch.commonDir, excluded.commonDir);
}
function isExcludedTestTarget(testFilePath, cwd) {
  const absolute = path.resolve(cwd, toPosix(testFilePath));
  let rel = toPosix(path.relative(cwd, absolute));
  if (rel === "" || rel === ".." || rel.startsWith("../") || path.isAbsolute(rel)) {
    const realTarget = canonicalFailedPath(absolute);
    const realCwd = canonicalFailedPath(cwd);
    if (pathsEqual(realTarget, realCwd) || !isUnderDir(realTarget, realCwd))
      return true;
    rel = toPosix(path.relative(realCwd, realTarget));
  }
  const checkoutRoot = foreignGitRoot(testFilePath, cwd);
  if (checkoutRoot) {
    recordDegradationOnce({
      kind: "test-target-foreign-checkout",
      subject: createHash("sha256").update(JSON.stringify([cwd, testFilePath, checkoutRoot])).digest("hex"),
      reason: "automatic test target belongs to another Git checkout",
      metadata: {
        cwd,
        candidate: testFilePath,
        checkoutRoot,
        sameCommonDir: sharesCommonDir(cwd, checkoutRoot)
      }
    });
    return true;
  }
  return TURN_END_EXCLUDED_TEST_GLOBS.some((glob) => minimatch(rel, glob, { dot: true, nocase: true }));
}
var RUNNERS = {
  vitest: {
    kinds: ["jsts"],
    configFiles: ["vitest.config.ts", "vitest.config.js", "vitest.config.mjs"],
    command: "npx",
    binName: "vitest",
    args: (testFile, _cwd) => [
      "vitest",
      "run",
      testFile,
      "--reporter=json",
      "--passWithNoTests"
    ],
    parseJson: true
  },
  jest: {
    kinds: ["jsts"],
    configFiles: [
      "jest.config.ts",
      "jest.config.js",
      "jest.config.json",
      ".jestrc.js"
    ],
    command: "npx",
    binName: "jest",
    args: (testFile, _cwd) => [
      "jest",
      testFile,
      "--json",
      "--passWithNoTests",
      "--forceExit"
    ],
    parseJson: true
  },
  pytest: {
    kinds: ["python"],
    configFiles: ["pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"],
    // #2879 review round 3, F7: `pyproject.toml` is CONTENT-conditional in
    // detection — the Priority-1 loop accepts it only when it carries
    // `[tool.pytest.ini_options]` — but the cwd seam walks basenames, so
    // handing `configFiles` over verbatim anchored the child on the very
    // file detection had refused. Omitting it here makes the spawn walk
    // stop only on evidence the detector itself would accept; a project
    // whose ONLY pytest config is a real `[tool.pytest.ini_options]`
    // section therefore falls back to the dispatch root, which is exactly
    // where master ran it. (The proper fix is to carry detection's
    // accepted evidence path into the spawn resolution instead of
    // re-deriving it from a basename table — filed as a follow-up.)
    spawnCwdMarkers: ["pytest.ini", "tox.ini", "setup.cfg"],
    command: "python",
    args: (testFile, _cwd) => ["-m", "pytest", testFile, "--tb=short", "-q"],
    parseJson: false
    // pytest JSON requires plugin, use text parsing
  },
  go: {
    kinds: ["go"],
    configFiles: ["go.mod"],
    command: "go",
    args: (testFile, cwd) => {
      const relPath = path.relative(cwd, testFile);
      const pkgDir = path.dirname(relPath);
      return ["test", `-run`, ".", `./${pkgDir === "." ? "." : pkgDir}`];
    },
    parseJson: false
    // Go test output is text-based
  },
  cargo: {
    kinds: ["rust"],
    configFiles: ["Cargo.toml"],
    command: "cargo",
    args: (_testFile, _cwd) => ["test", "--no-fail-fast"],
    parseJson: false
    // cargo test output is text-based
  },
  dotnet: {
    kinds: ["csharp", "fsharp"],
    configFiles: ["*.csproj", "*.sln"],
    command: "dotnet",
    args: (_testFile, _cwd) => ["test", "--no-build"],
    parseJson: false
  },
  gradle: {
    kinds: ["java", "kotlin"],
    // #2870: `settings.gradle.kts` alongside its Groovy sibling — a
    // Kotlin-DSL build whose root carries only the settings script was
    // invisible to detection, the same one-spelling gap the java root
    // markers had.
    configFiles: [
      "build.gradle",
      "build.gradle.kts",
      "settings.gradle",
      "settings.gradle.kts"
    ],
    // #3956: a cwd-relative launcher must carry a separator, or the Windows
    // resolver treats it as a PATH-only name and never looks in the child cwd
    // where the wrapper actually lives. Forward slash is the platform-neutral
    // spelling the resolver accepts on win32 (same #2870 lesson as ./gradlew).
    command: process.platform === "win32" ? "./gradlew.bat" : "./gradlew",
    // The child's cwd must be a directory the wrapper actually lives in.
    spawnCwdMarkers: ["gradlew", "gradlew.bat"],
    args: (_testFile, _cwd) => ["test", "--no-daemon"],
    parseJson: false
  },
  maven: {
    kinds: ["java", "kotlin"],
    configFiles: ["pom.xml"],
    command: "mvn",
    args: (_testFile, _cwd) => ["test", "-q"],
    parseJson: false
  },
  rspec: {
    kinds: ["ruby"],
    configFiles: [".rspec", "spec/spec_helper.rb"],
    command: "bundle",
    // The real binary is "bundle" (the command runs `bundle exec rspec
    // <file>`), NOT "rspec" — without this, binName defaulted to the
    // runner key "rspec" and local/global resolution looked for the wrong
    // binary name (#1098).
    binName: "bundle",
    args: (testFile, _cwd) => ["exec", "rspec", testFile],
    parseJson: false
  },
  minitest: {
    kinds: ["ruby"],
    configFiles: ["Gemfile"],
    command: "ruby",
    args: (testFile, _cwd) => ["-Itest", testFile],
    parseJson: false
  },
  phpunit: {
    kinds: ["php"],
    // phpunit.xml(.dist) is the strong signal; composer.json is checked for
    // a require-dev dependency on phpunit/phpunit (see the special case in
    // detectRunner's Priority-1 loop, mirroring the pytest/pyproject.toml
    // handling above).
    configFiles: ["phpunit.xml", "phpunit.xml.dist", "composer.json"],
    // #2879 review round 3, F7: same shape as pytest's `pyproject.toml`,
    // and this one is load-bearing — phpunit reads `phpunit.xml` from its
    // CWD only, so a child launched in a directory whose `composer.json`
    // carries no `phpunit/phpunit` dependency runs with no bootstrap and
    // no autoloader, and the resulting fatal error reaches the agent as a
    // test failure (measured end to end with a fake phpunit recording its
    // own cwd). `composer.json` is an anchor, never phpunit evidence.
    spawnCwdMarkers: ["phpunit.xml", "phpunit.xml.dist"],
    command: "phpunit",
    args: (testFile, _cwd) => [testFile],
    parseJson: false
    // PHPUnit's default CLI output is text-based
  },
  mix: {
    kinds: ["elixir"],
    configFiles: ["mix.exs"],
    command: "mix",
    args: (testFile, _cwd) => ["test", testFile],
    parseJson: false
    // mix test's default output is text-based
  }
};
function stripWrapperArgs(binName, args) {
  if (args[0] === binName)
    return args.slice(1);
  if (args[0] === "-m" && args[1] === binName)
    return args.slice(2);
  return args;
}
var MAX_FAILED_TARGETS_PER_RUNNER = 32;
var MAX_FAILED_TARGET_CHECKS_PER_SELECTION = 8;
var FAILED_TARGET_DETAIL_CAP_PER_TURN = 8;
function canonicalFailedPath(filePath) {
  const absolute = path.resolve(filePath);
  try {
    return normalizeMapKey(fs.realpathSync.native(absolute));
  } catch {
    return normalizeMapKey(absolute);
  }
}
function canonicalProjectRoot(cwd) {
  const absolute = path.resolve(cwd);
  try {
    return {
      key: normalizeEphemeralMapKey(fs.realpathSync.native(absolute)),
      resolved: true
    };
  } catch {
    return { key: normalizeEphemeralMapKey(absolute), resolved: false };
  }
}
var MAX_CANONICAL_ROOT_MEMO_ENTRIES = 512;
function filesystemErrorCode(error) {
  if (error !== null && typeof error === "object" && "code" in error && typeof error.code === "string") {
    return error.code;
  }
  return void 0;
}
var PYTEST_SUMMARY_OUTCOME = "(?:passed|failed|skipped|reruns?|errors?|warnings?|deselected|xfailed|xpassed)";
var PYTEST_SUMMARY_LINE = new RegExp(`^\\s*(?:=+\\s*)?\\d+\\s+${PYTEST_SUMMARY_OUTCOME}(?:\\s*,\\s*\\d+\\s+${PYTEST_SUMMARY_OUTCOME})*\\s+in\\s+[\\d.]+s(?:\\s*=+)?\\s*$`, "i");
function parsePytestSummary(output) {
  const normalizedOutput = stripAnsi(output);
  const summaryLine = normalizedOutput.split(/\r?\n/).reverse().find((line) => PYTEST_SUMMARY_LINE.test(line));
  let passed = 0;
  let failed = 0;
  let skipped = 0;
  if (!summaryLine)
    return { passed, failed, skipped };
  const summaryBody = summaryLine.replace(/^=+\s*|\s*=+\s*$/g, "");
  for (const field of summaryBody.split(",")) {
    const [countText, outcome] = field.trim().split(/\s+/, 2);
    const count = Number.parseInt(countText, 10);
    if (!Number.isFinite(count))
      continue;
    switch (outcome) {
      case "passed":
        passed = count;
        break;
      case "failed":
        failed = count;
        break;
      case "skipped":
        skipped = count;
        break;
    }
  }
  const durationMatch = /in\s+([\d.]+)s/.exec(summaryLine);
  const duration = durationMatch ? Math.round(Number.parseFloat(durationMatch[1]) * 1e3) : void 0;
  return { passed, failed, skipped, duration };
}
var TestRunnerClient = class {
  log;
  // This is an instance-lifetime memo of RESOLVED spellings only, which leaves
  // two temporal edges. A symlink retargeted mid-session keeps its old
  // resolution until a new client instance — acceptable because round 2's
  // evidence re-validation already handles verdict-level staleness (positive
  // verdicts re-stat their config file). A spelling that did NOT resolve is
  // never memoized (#2077): the fallback key is a guess about a path that does
  // not exist yet, so memoizing it would pin an alias probed before its
  // symlink was created to a stale verdict for the instance's life. Re-probing
  // costs one failing realpath per call, and only for a cwd that does not
  // resolve — a state where `detectRunner` already walks node_modules on every
  // call. Keep the memo bounded so pathological spelling churn cannot grow it
  // without limit.
  canonicalRootMemo = new BoundedFifoMap(MAX_CANONICAL_ROOT_MEMO_ENTRIES);
  availableRunners = new PathKeyedMap(normalizeEphemeralMapKey);
  failedTestsByRunner = /* @__PURE__ */ new Map();
  failedTargetSequence = 0;
  statFailedTarget;
  // Best-effort vitest config `test.include`/`test.exclude` globs, scraped as
  // plain text (never executed) and cached per cwd so the config file is
  // only read/parsed once, not on every edit. `null` means "no config found
  // or it couldn't be parsed in the simple shape we look for" — callers
  // treat that as "no additional signal" and fall back to naming-convention
  // detection only.
  vitestTestGlobsCache = new PathKeyedMap(normalizeEphemeralMapKey);
  constructor(verbose = false, options = {}) {
    this.log = verbose ? createSubsystemLogger("test-runner") : () => {
    };
    this.statFailedTarget = options.statFailedTarget ?? ((filePath) => void fs.statSync(filePath));
  }
  getCanonicalProjectRoot(cwd) {
    const cached = this.canonicalRootMemo.get(cwd);
    if (cached !== void 0)
      return cached;
    const { key, resolved } = canonicalProjectRoot(cwd);
    if (!resolved)
      return key;
    this.canonicalRootMemo.set(cwd, key);
    return key;
  }
  /** #2442 test-only: exercise canonicalRootMemo's bounded eviction directly. */
  _getCanonicalProjectRootForTests(cwd) {
    return this.getCanonicalProjectRoot(cwd);
  }
  getRunnerAvailability(byRunner, runner) {
    const cached = byRunner?.get(runner);
    if (!cached)
      return void 0;
    if (cached.available && cached.evidencePath !== void 0 && !fs.existsSync(cached.evidencePath)) {
      byRunner?.delete(runner);
      return void 0;
    }
    return cached.available;
  }
  /**
   * #2252: only a POSITIVE verdict is memoized. A negative one has no
   * `evidencePath` to re-stat, so `getRunnerAvailability` had no way to tell
   * "still absent" from "a config file just appeared" and served the first
   * miss for the client's whole process lifetime — measured live: probe an
   * empty directory, add `vitest.config.ts`, and the SAME client kept
   * answering "no runner". Same precedent as #2242's alias-canonicalization
   * fix (`clients/test-runner-client.ts`'s `getCanonicalProjectRoot`): drop
   * the memo write on the failure branch rather than adding a TTL or a
   * re-arm signal. The cost is bounded and already paid today — a cache miss
   * re-runs the exact same `configFiles.some(fs.existsSync)` walk this
   * method's caller already does on every FIRST probe of a runner.
   */
  setRunnerAvailability(byRunner, runner, available, evidencePath) {
    if (!available)
      return;
    byRunner.set(runner, { available, evidencePath });
  }
  /**
   * Which runners may be handed `sourceFilePath`, and the directory their
   * config files are probed in (#2870).
   *
   * `eligible === null` means "no file was named" — the session-level
   * caller (`runtime-session.ts`'s summary) asks "what runner does this
   * project have" and has no file to scope to, so every runner stays a
   * candidate and the probe root is the dispatch root, exactly as before.
   *
   * A named file resolves TWO independent facts, and the order matters:
   *  - its KIND decides which runners may claim it at all (the gate), and
   *  - `resolveLanguageRootForFile` decides WHERE that kind's config files
   *    are looked for (the anchor) — the same per-kind, cached,
   *    workspace-clamped walk the dispatch runners already resolve their
   *    roots with, not a private walker.
   *
   * Returning `null` means no runner can own this file (a kind no entry
   * claims: markdown, yaml, an unknown extension), so no directory is
   * probed at all.
   */
  resolveDetectionScope(cwd, sourceFilePath) {
    if (sourceFilePath === void 0)
      return { root: cwd, eligible: null };
    const absoluteFile = path.resolve(sourceFilePath);
    if (!isUnderDir(absoluteFile, path.resolve(cwd))) {
      return { root: cwd, eligible: null };
    }
    const kind = detectFileKind(absoluteFile);
    if (!kind)
      return null;
    const eligible = new Set(Object.entries(RUNNERS).filter(([, config]) => config.kinds.includes(kind)).map(([name]) => name));
    if (eligible.size === 0)
      return null;
    return {
      root: resolveLanguageRootForFile(absoluteFile, cwd),
      eligible
    };
  }
  /**
   * Check if a test runner is available in the project
   * Detection order:
   * 1. Config files (vitest.config.ts, jest.config.js, etc.)
   * 2. package.json dependencies
   * 3. node_modules presence
   *
   * #2870: when `sourceFilePath` is given, the answer is scoped to that
   * file's kind and resolved language root — see `resolveDetectionScope`.
   */
  detectRunner(dispatchRoot, sourceFilePath) {
    const scope = this.resolveDetectionScope(dispatchRoot, sourceFilePath);
    if (!scope)
      return null;
    const { root, eligible } = scope;
    const anchored = this.probeRunnersAt(root, eligible);
    if (anchored)
      return anchored;
    const dispatch = path.resolve(dispatchRoot);
    if (path.resolve(root) !== dispatch) {
      const atDispatch = this.probeRunnersAt(dispatch, eligible);
      if (atDispatch)
        return atDispatch;
    }
    const isPythonSource = typeof sourceFilePath === "string" && sourceFilePath.endsWith(".py");
    if (!isPythonSource)
      return null;
    try {
      const whichCmd = process.platform === "win32" ? "where" : "which";
      const result = safeSpawn(whichCmd, ["pytest"], {
        timeout: 2e3
      });
      if (result.status === 0) {
        this.log("Detected pytest globally");
        return { runner: "pytest", config: RUNNERS.pytest };
      }
    } catch (err) {
      void err;
    }
    return null;
  }
  /**
   * Priorities 1-4 of the detection ladder, against ONE directory: config
   * files, `package.json` dependencies, a hoisted `node_modules`, and the
   * glob-capable whole-project runners. `eligible` is the kind gate
   * (#2870); `null` means no file was named and every runner is a
   * candidate. Returns `null` when this directory configures no eligible
   * runner, which is what lets `detectRunner` try the dispatch root next.
   */
  probeRunnersAt(cwd, eligible) {
    const rootKey = this.getCanonicalProjectRoot(cwd);
    let byRunner = this.availableRunners.get(rootKey);
    if (!byRunner) {
      byRunner = /* @__PURE__ */ new Map();
      this.availableRunners.set(rootKey, byRunner);
    }
    for (const [name, config] of Object.entries(RUNNERS)) {
      if (eligible && !eligible.has(name))
        continue;
      const cached = this.getRunnerAvailability(byRunner, name);
      if (cached !== void 0) {
        if (cached) {
          return { runner: name, config };
        }
        continue;
      }
      let configEvidencePath;
      const found = config.configFiles.some((cf) => {
        if (name === "pytest" && cf === "pyproject.toml") {
          const pyprojectPath = path.join(cwd, cf);
          if (!fs.existsSync(pyprojectPath))
            return false;
          try {
            const pyproject = fs.readFileSync(pyprojectPath, "utf-8");
            const matches2 = pyproject.includes("[tool.pytest.ini_options]");
            if (matches2)
              configEvidencePath = pyprojectPath;
            return matches2;
          } catch {
            return false;
          }
        }
        if (name === "phpunit" && cf === "composer.json") {
          const composerPath = path.join(cwd, cf);
          if (!fs.existsSync(composerPath))
            return false;
          try {
            const composer = JSON.parse(fs.readFileSync(composerPath, "utf-8"));
            const allDeps = {
              ...composer.require,
              ...composer["require-dev"]
            };
            const matches2 = Boolean(allDeps["phpunit/phpunit"]);
            if (matches2)
              configEvidencePath = composerPath;
            return matches2;
          } catch {
            return false;
          }
        }
        const candidate = path.join(cwd, cf);
        const matches = fs.existsSync(candidate);
        if (matches)
          configEvidencePath = candidate;
        return matches;
      });
      this.setRunnerAvailability(byRunner, name, found, configEvidencePath);
      if (found) {
        this.log(`Detected runner via config: ${name}`);
        return { runner: name, config };
      }
    }
    const packageJsonPath = path.join(cwd, "package.json");
    try {
      const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
      const allDeps = {
        ...pkg.dependencies,
        ...pkg.devDependencies
      };
      if (allDeps.vitest && (!eligible || eligible.has("vitest"))) {
        this.log("Detected vitest in package.json");
        this.setRunnerAvailability(byRunner, "vitest", true, packageJsonPath);
        return { runner: "vitest", config: RUNNERS.vitest };
      }
      if (allDeps.jest && (!eligible || eligible.has("jest"))) {
        this.log("Detected jest in package.json");
        this.setRunnerAvailability(byRunner, "jest", true, packageJsonPath);
        return { runner: "jest", config: RUNNERS.jest };
      }
      if ((allDeps.pytest || allDeps["pytest-cov"]) && (!eligible || eligible.has("pytest"))) {
        this.log("Detected pytest in package.json (unusual)");
        this.setRunnerAvailability(byRunner, "pytest", true, packageJsonPath);
        return { runner: "pytest", config: RUNNERS.pytest };
      }
    } catch (err) {
      void err;
    }
    for (const name of ["vitest", "jest"]) {
      if (eligible && !eligible.has(name))
        continue;
      const hoisted = findHoistedNodeModulesPackage(cwd, name);
      if (hoisted) {
        this.log(`Detected ${name} in node_modules (${hoisted})`);
        return { runner: name, config: RUNNERS[name] };
      }
    }
    for (const name of ["go", "cargo", "dotnet", "gradle", "maven"]) {
      if (eligible && !eligible.has(name))
        continue;
      const config = RUNNERS[name];
      const found = config.configFiles.some((cf) => {
        if (cf.includes("*")) {
          try {
            const files = fs.readdirSync(cwd);
            return files.some((f) => new RegExp(cf.replace(/\*/g, ".*")).test(f));
          } catch {
            return false;
          }
        }
        return fs.existsSync(path.join(cwd, cf));
      });
      if (found) {
        this.log(`Detected ${name} from config file`);
        return { runner: name, config };
      }
    }
    return null;
  }
  /**
   * Depth-bounded breadth-first search under `rootDir` for a pytest-style
   * test file matching `pattern` (exact, e.g. `test_foo.py`) or the
   * looser `test_*<basename>*.py` convention. Used as a last-resort
   * fallback when a Python test suite groups tests by kind
   * (`tests/unit/`, `tests/integration/`) instead of mirroring the
   * source directory layout, so the exact-mirror candidates in
   * `findTestFile` don't match. Bounded by `maxDepth` levels below
   * `rootDir` and skips hidden directories and `__pycache__` — never an
   * unbounded walk of the whole tests tree.
   */
  findPytestMatchRecursive(rootDir, pattern, basename2, maxDepth, acceptCandidate) {
    const queue = [
      { dir: rootDir, depth: 0 }
    ];
    while (queue.length > 0) {
      const next = queue.shift();
      if (!next)
        break;
      const { dir, depth } = next;
      let entries;
      try {
        entries = fs.readdirSync(dir, { withFileTypes: true });
      } catch {
        continue;
      }
      for (const entry of entries) {
        const fullPath = path.join(dir, entry.name);
        if (entry.isFile()) {
          if ((entry.name === pattern || entry.name.startsWith("test_") && entry.name.endsWith(".py") && entry.name.includes(basename2)) && acceptCandidate(fullPath)) {
            return fullPath;
          }
        } else if (entry.isDirectory() && depth < maxDepth) {
          if (entry.name === "__pycache__" || entry.name.startsWith("."))
            continue;
          queue.push({ dir: fullPath, depth: depth + 1 });
        }
      }
    }
    return null;
  }
  /**
   * Path of `dir` relative to `cwd`, using forward slashes, or null if `dir`
   * is not inside `cwd` (e.g. resolves to `..` or an absolute path).
   * Used to compute a mirrored test-tree subdirectory (e.g. `clients` for
   * `clients/knip-client.ts`, so `tests/clients/knip-client.test.ts` is
   * checked alongside the flat `tests/knip-client.test.ts` candidate).
   */
  relativeSourceDir(sourceFilePath, cwd) {
    const dir = path.dirname(sourceFilePath);
    const relDir = path.relative(cwd, path.resolve(cwd, dir));
    if (!relDir || relDir === "." || relDir.startsWith("..") || path.isAbsolute(relDir)) {
      return null;
    }
    return relDir;
  }
  /**
   * Best-effort, text-only scrape of a vitest config's `test.include` /
   * `test.exclude` arrays. This deliberately does NOT execute the config
   * file (that would mean loading arbitrary ESM/TS via Vite's config
   * loader — too heavy for a per-edit hot path). It just looks for a
   * simple `include: [ ... ]` / `exclude: [ ... ]` shape with string
   * literals inside and pulls those out with a regex.
   *
   * Returns `null` (never throws) when there's no vitest config file, it
   * can't be read, or the include/exclude shape isn't a plain array of
   * string literals (e.g. it's built from a function call, spread, or
   * template expression) — anything more dynamic than that is out of
   * scope for this heuristic.
   *
   * Cached per `cwd`, including a `null` result — #2252 F2: a project with
   * no vitest config, or one this heuristic can't scrape, is a COMMON shape
   * (every non-vitest project pays this on every edit otherwise; measured
   * ~1500x — 0.4µs cached vs. 598.7µs re-reading the candidate list every
   * call). The cache entry is revalidated on every read, the same shape
   * `getRunnerAvailability` uses for a positive verdict: bounded
   * `fs.existsSync` checks against the config file the result was derived
   * from (or, when none existed, against the candidate list itself), never
   * a full re-read/re-parse on a cache hit. So a config file appearing (or
   * a broken one being fixed) still converges — only the FULL parse is
   * paid once, not the existence check.
   */
  parseVitestTestGlobs(cwd) {
    const rootKey = this.getCanonicalProjectRoot(cwd);
    const candidates = [...RUNNERS.vitest.configFiles, "vitest.config.mts"];
    const cached = this.vitestTestGlobsCache.get(rootKey);
    if (cached !== void 0) {
      const stillValid = cached.evidencePath !== void 0 ? fs.existsSync(cached.evidencePath) : !candidates.some((cf) => fs.existsSync(path.join(cwd, cf)));
      if (stillValid)
        return cached.result;
      this.vitestTestGlobsCache.delete(rootKey);
    }
    let content = null;
    let foundPath;
    for (const cf of candidates) {
      const candidatePath = path.join(cwd, cf);
      try {
        content = fs.readFileSync(candidatePath, "utf-8");
        foundPath = candidatePath;
        break;
      } catch {
        continue;
      }
    }
    let result = null;
    if (content !== null) {
      const include = this.extractGlobArrayLiteral(content, "include");
      const exclude = this.extractGlobArrayLiteral(content, "exclude");
      if (include || exclude) {
        result = {};
        if (include)
          result.include = include;
        if (exclude)
          result.exclude = exclude;
      }
    }
    this.vitestTestGlobsCache.set(rootKey, { result, evidencePath: foundPath });
    return result;
  }
  /**
   * Extract `<key>: [ 'a', "b", `c` ]` as a plain string array from raw
   * config text. Returns undefined if the key isn't present, or if the
   * array body contains anything besides string literals and commas/
   * whitespace (a function call, spread, variable reference, etc.) —
   * that's a sign the value is dynamic and this best-effort scrape can't
   * safely interpret it.
   */
  extractGlobArrayLiteral(content, key) {
    const arrayMatch = content.match(new RegExp(`\\b${key}\\s*:\\s*\\[([^\\]]*)\\]`));
    if (!arrayMatch)
      return void 0;
    const body = arrayMatch[1];
    const literalPattern = /'([^'\\]*)'|"([^"\\]*)"|`([^`\\]*)`/g;
    const literals = [];
    let lastEnd = 0;
    let match;
    while ((match = literalPattern.exec(body)) !== null) {
      const between = body.slice(lastEnd, match.index).trim();
      if (between !== "" && !/^,$/.test(between))
        return void 0;
      literals.push(match[1] ?? match[2] ?? match[3] ?? "");
      lastEnd = literalPattern.lastIndex;
    }
    const trailing = body.slice(lastEnd).trim();
    if (trailing !== "" && trailing !== ",")
      return void 0;
    return literals.length > 0 ? literals : void 0;
  }
  /**
   * Whether `sourceFilePath` is itself a test file (as opposed to a source
   * file whose *related* test file needs to be discovered).
   *
   * Primary signal: `detectFileRole` (naming convention: `.test.`/`.spec.`
   * basenames, `_test.`/`_spec.` suffix infixes, `*Test(s).<ext>` CamelCase
   * suffixes, `test_`/`spec_` prefixes, `__tests__/`/`tests/`/`spec/`
   * directories — shared with the rest of the codebase, not a second
   * parallel detector).
   *
   * Secondary signal (vitest only): the project's own `test.include` /
   * `test.exclude` globs, best-effort scraped by `parseVitestTestGlobs`.
   * This can correct the naming-convention answer in both directions —
   * an `exclude` glob can rule out a path that looks like a test by name,
   * and an `include` glob can catch a project that puts tests somewhere
   * unconventional. When no config is found or it can't be parsed, this
   * is a no-op and behavior is unchanged.
   *
   * #628: a positive `include` override is only trusted when the glob is a
   * *narrow* test signal (see `isNarrowTestGlob`) — a bare "any file with
   * this extension" include (e.g. `src/**\/*.ts`) is common in real vitest
   * configs and matches ordinary source files, so treating any match as
   * "this is a test" produced vacuous `0p/0f` self-runs on plain source
   * files (background-review.ts, index.ts, …). The `exclude` direction is
   * left as a plain match: over-excluding only causes discovery to run on a
   * file that's actually a test (falls back to `findTestFile`, not a false
   * "self" positive), which is the safe failure mode.
   */
  isTestFile(sourceFilePath, cwd, runner) {
    let result = detectFileRole(sourceFilePath) === "test";
    if (runner === "vitest") {
      const globs = this.parseVitestTestGlobs(cwd);
      if (globs) {
        const rel = path.relative(cwd, path.resolve(cwd, sourceFilePath)).replace(/\\/g, "/");
        const matches = (globs_, filter) => !!globs_?.some((g) => (!filter || filter(g)) && minimatch(rel, g, { dot: true }));
        if (matches(globs.exclude)) {
          result = false;
        } else if (!result && matches(globs.include, (g) => this.isNarrowTestGlob(g))) {
          result = true;
        }
      }
    }
    return result;
  }
  /**
   * Whether an `include` glob is a specific enough signal to override a
   * plain "this is source, not a test" naming-convention verdict (#628).
   *
   * Trusted when either:
   *  - a literal (non-wildcard) path segment before the first wildcard
   *    names a conventional test location (`tests/`, `test/`, `spec/`,
   *    `specs/`, `__tests__/`) — the real case this override exists for:
   *    a project whose test files live in such a directory without a
   *    `.test.`/`.spec.` name (e.g. `tests/**\/*.ts`).
   *  - the static suffix after the last wildcard encodes more than the
   *    bare language extension (e.g. `.check.ts`, `.flow.ts`) — an explicit
   *    project-specific naming convention, not "any file with this
   *    extension" (e.g. `**\/*.check.ts`).
   *
   * Rejected for a bare extension glob with no test-ish directory (e.g.
   * `src/**\/*.ts`, `**\/*.ts`) — that shape matches every source file in
   * the tree and is exactly what produced vacuous self-runs in practice.
   */
  isNarrowTestGlob(glob) {
    const testDirPattern = /^(tests?|specs?|__tests__)$/i;
    for (const segment of glob.split("/")) {
      if (segment.includes("*") || segment.includes("?"))
        break;
      if (testDirPattern.test(segment))
        return true;
    }
    const lastWildcard = Math.max(glob.lastIndexOf("*"), glob.lastIndexOf("?"));
    const suffix = lastWildcard >= 0 ? glob.slice(lastWildcard + 1) : glob;
    const dotSegments = suffix.split(".").filter(Boolean);
    return dotSegments.length >= 2;
  }
  /**
   * Find test file for a given source file
   * Returns the test file path if it exists, null otherwise.
   * Automatic selection supplies eligibility before each first-match return;
   * ordinary discovery, existence checks, and suggestions remain unfiltered.
   */
  findTestFile(sourceFilePath, cwd, runnerOverride, acceptCandidate = () => true) {
    const ext = path.extname(sourceFilePath);
    const basename2 = path.basename(sourceFilePath, ext);
    const dir = path.dirname(sourceFilePath);
    const patterns = SOURCE_TO_TEST_PATTERNS.find((p) => p.ext === ext);
    if (!patterns)
      return null;
    const detected = runnerOverride ? { runner: runnerOverride, config: RUNNERS[runnerOverride] } : this.detectRunner(cwd, sourceFilePath);
    if (!detected)
      return null;
    const relDir = this.relativeSourceDir(sourceFilePath, cwd);
    for (let i = 0; i < patterns.testExts.length; i++) {
      const testExt = patterns.testExts[i];
      const testDir = patterns.dirs[i];
      if (testExt.includes("*")) {
        const pattern = testExt.replace(/\*/g, basename2);
        const searchDirs = testDir === "." ? [dir] : relDir ? [path.join(cwd, testDir, relDir), path.join(cwd, testDir)] : [path.join(cwd, testDir)];
        for (const searchDir of searchDirs) {
          let files;
          try {
            files = fs.readdirSync(searchDir);
          } catch (err) {
            void err;
            continue;
          }
          const match = files.find((f) => (f === pattern || f.startsWith("test_") && f.endsWith(".py") && f.includes(basename2)) && acceptCandidate(path.join(searchDir, f)));
          if (match) {
            const testPath = path.join(searchDir, match);
            this.log(`Found test file: ${testPath}`);
            return { testFile: testPath, runner: detected.runner };
          }
        }
        if (testDir !== ".") {
          const recursiveMatch = this.findPytestMatchRecursive(path.join(cwd, testDir), pattern, basename2, MAX_PYTEST_RECURSE_DEPTH, acceptCandidate);
          if (recursiveMatch) {
            this.log(`Found test file (recursive): ${recursiveMatch}`);
            return { testFile: recursiveMatch, runner: detected.runner };
          }
        }
      } else {
        const testFilename = basename2 + testExt;
        const searchPaths = [
          path.join(dir, testFilename),
          // same directory
          path.join(dir, "__tests__", testFilename),
          // __tests__ subdirectory
          ...relDir ? [
            path.join(cwd, "tests", relDir, testFilename),
            // mirrored tests/<subdir>/
            path.join(cwd, "__tests__", relDir, testFilename)
            // mirrored __tests__/<subdir>/
          ] : [],
          path.join(cwd, "tests", testFilename),
          // top-level tests/
          path.join(cwd, "__tests__", testFilename),
          // top-level __tests__/
          // PHP/Elixir-style source-root mirroring (e.g. src/Foo/Bar.php ->
          // tests/Foo/BarTest.php, lib/accounts/user.ex ->
          // test/accounts/user_test.exs): strips a conventional source-root
          // segment and mirrors under this pattern's OWN configured test
          // root (testDir), not the hardcoded "tests"/"__tests__" above —
          // ExUnit's root is "test" (singular), which those don't cover.
          ...this.sourceRootMirroredCandidates(dir, cwd, testDir, testFilename)
        ];
        for (const testPath of searchPaths) {
          if (fs.existsSync(testPath) && acceptCandidate(testPath)) {
            this.log(`Found test file: ${testPath}`);
            return { testFile: testPath, runner: detected.runner };
          }
        }
      }
    }
    const importMatch = this.findTestFileByImport(sourceFilePath, cwd, acceptCandidate);
    if (importMatch) {
      return { testFile: importMatch, runner: detected.runner };
    }
    return null;
  }
  /**
   * Select the most useful test target for this edit.
   *
   * Strategy:
   * 1) If there are known failing tests, rerun those first (fast feedback loop).
   * 2) Otherwise run related tests for the edited file.
   */
  getTestRunTarget(sourceFilePath, cwd, turnIndex) {
    const detected = this.detectRunner(cwd, sourceFilePath);
    if (!detected)
      return null;
    const failedSet = this.getFailedTargets(cwd, detected.runner);
    const selfIsTest = this.isTestFile(sourceFilePath, cwd, detected.runner);
    const related = selfIsTest ? null : this.findTestFile(sourceFilePath, cwd, detected.runner, (candidate) => {
      const checkoutRoot = foreignGitRoot(candidate, cwd);
      if (!checkoutRoot)
        return true;
      recordDegradationOnce({
        kind: "test-discovery-foreign-checkout",
        subject: createHash("sha256").update(JSON.stringify([cwd, candidate, checkoutRoot])).digest("hex"),
        reason: "automatic test candidate belongs to another Git checkout",
        metadata: { cwd, candidate, checkoutRoot, runner: detected.runner }
      });
      return false;
    });
    if (failedSet && failedSet.size > 0) {
      const failedFirst = this.retireIneligibleFailedTargets({
        cwd,
        runner: detected.runner,
        failedTargets: failedSet,
        relatedAbs: related ? path.resolve(related.testFile) : void 0,
        selfAbs: selfIsTest ? path.resolve(sourceFilePath) : void 0,
        turnIndex
      });
      if (failedFirst) {
        return {
          testFile: failedFirst,
          runner: detected.runner,
          config: detected.config,
          strategy: "failed-first"
        };
      }
    }
    if (selfIsTest) {
      return {
        testFile: path.resolve(sourceFilePath),
        runner: detected.runner,
        config: detected.config,
        strategy: "self"
      };
    }
    if (!related)
      return null;
    return {
      testFile: path.resolve(related.testFile),
      runner: detected.runner,
      config: detected.config,
      strategy: "related"
    };
  }
  async runTestFileAsync(testFile, cwd, runnerOrRequest, legacyConfig) {
    const absoluteTestFile = path.resolve(testFile);
    let request;
    if (typeof runnerOrRequest === "string") {
      if (!legacyConfig) {
        return this.emptyResult(absoluteTestFile, "", runnerOrRequest, "Runner configuration missing");
      }
      request = { runner: runnerOrRequest, config: legacyConfig };
    } else {
      request = runnerOrRequest;
    }
    const { runner, config, turnIndex, signal, displayRoot, requireOwnInstall } = request;
    if (!fs.existsSync(absoluteTestFile)) {
      return this.emptyResult(absoluteTestFile, "", runner, "Test file not found");
    }
    try {
      const spawnCwd = resolveToolCwd("runner", runner, absoluteTestFile, {
        cwd: path.resolve(cwd),
        rootMarkers: config.spawnCwdMarkers ?? config.configFiles
      }).cwd;
      const { command, args, env, ownInstall } = await this.resolveExec(runner, config, absoluteTestFile, cwd, spawnCwd, requireOwnInstall);
      if (requireOwnInstall && !ownInstall) {
        return {
          ...this.emptyResult(absoluteTestFile, "", runner),
          notRun: "no-runner-install"
        };
      }
      this.log(`Running (async): ${command} ${args.join(" ")} (cwd ${spawnCwd})`);
      const result = await safeSpawnAsync(command, args, {
        cwd: spawnCwd,
        timeout: 6e4,
        env,
        // #2522 R2 F1. `safeSpawnAsync` resolves `options.signal ?? ambient`,
        // so an absent batch signal keeps the pre-#2522 ambient behaviour.
        signal
      });
      const stdout = result.stdout || "";
      const stderr = result.stderr || "";
      if (result.error) {
        this.log(`Runner error: ${result.error.message}`);
        return this.emptyResult(absoluteTestFile, "", runner, `Runner error: ${result.error.message}`);
      }
      let parsed;
      switch (runner) {
        case "vitest":
          parsed = this.parseVitestOutput(stdout, stderr, absoluteTestFile, displayRoot ?? cwd, runner);
          break;
        case "jest":
          parsed = this.parseJestOutput(stdout, stderr, absoluteTestFile, displayRoot ?? cwd, runner);
          break;
        case "pytest":
          parsed = this.parsePytestOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, cwd, runner, displayRoot ?? cwd, spawnCwd);
          break;
        case "phpunit":
          parsed = this.parsePhpunitOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner, displayRoot ?? cwd, cwd, spawnCwd);
          break;
        case "mix":
          parsed = this.parseMixTestOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner, displayRoot ?? cwd, cwd, spawnCwd);
          break;
        default:
          parsed = this.parseGenericRunnerOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner, displayRoot ?? cwd, cwd, spawnCwd);
          break;
      }
      this.recordResult({
        cwd,
        runner,
        testFile: absoluteTestFile,
        result: parsed,
        turnIndex
      });
      return parsed;
    } catch (err) {
      this.log(`Run error: ${err.message}`);
      return this.emptyResult(absoluteTestFile, "", runner, err.message);
    }
  }
  getFailedTargets(cwd, runner, create = false) {
    let roots = this.failedTestsByRunner.get(runner);
    if (!roots && create) {
      roots = new PathKeyedMap(canonicalFailedPath);
      this.failedTestsByRunner.set(runner, roots);
    }
    if (!roots)
      return void 0;
    const root = canonicalFailedPath(cwd);
    let targets = roots.get(root);
    if (!targets && create) {
      targets = new PathKeyedMap(canonicalFailedPath);
      roots.set(root, targets);
    }
    return targets;
  }
  deleteFailedRoot(cwd, runner) {
    const roots = this.failedTestsByRunner.get(runner);
    if (!roots)
      return;
    roots.delete(canonicalFailedPath(cwd));
    if (roots.size === 0)
      this.failedTestsByRunner.delete(runner);
  }
  failedTargetCount(runner) {
    const roots = this.failedTestsByRunner.get(runner);
    if (!roots)
      return 0;
    let count = 0;
    for (const targets of roots.values())
      count += targets.size;
    return count;
  }
  evictOldestFailedTarget(runner) {
    const roots = this.failedTestsByRunner.get(runner);
    if (!roots)
      return void 0;
    let oldest;
    for (const [root, targets2] of roots) {
      for (const [identity, entry] of targets2) {
        if (!oldest || entry.sequence < oldest.entry.sequence) {
          oldest = { root, identity, entry };
        }
      }
    }
    if (!oldest)
      return void 0;
    const targets = roots.get(oldest.root);
    targets?.delete(oldest.identity);
    if (targets?.size === 0)
      roots.delete(oldest.root);
    if (roots.size === 0)
      this.failedTestsByRunner.delete(runner);
    return oldest.entry.displayPath;
  }
  classifyFailedTarget(candidate) {
    try {
      this.statFailedTarget(candidate);
      return { status: "present" };
    } catch (error) {
      const errorCode = filesystemErrorCode(error);
      if (errorCode === "ENOENT" || errorCode === "ENOTDIR") {
        return { status: "missing", errorCode };
      }
      return { status: "indeterminate", errorCode };
    }
  }
  recordFailedTargetState(record) {
    const { outcome, runner, candidate, errorCode, turnIndex } = record;
    const boundedTarget = candidate.length <= LEDGER_FIELD_MAX ? candidate : `\u2026${candidate.slice(1 - LEDGER_FIELD_MAX)}`;
    const targetIdentity = createHash("sha256").update(runner).update("\0").update(candidate).digest("hex");
    const identity = `${outcome}:${targetIdentity}`;
    const payload = {
      durationMs: 0,
      filePath: boundedTarget,
      metadata: {
        outcome,
        runner,
        errorCode: errorCode ?? "unknown"
      }
    };
    const options = {
      ledgerKind: "test-runner-failed-target-state",
      risingEdgePer: "identity",
      reason: `${outcome}: ${boundedTarget}`
    };
    emitBounded("test_runner_failed_target_state", identity, payload, turnIndex === void 0 ? options : {
      ...options,
      capPerTurn: {
        limit: FAILED_TARGET_DETAIL_CAP_PER_TURN,
        turnIndex
      }
    });
  }
  /**
   * Retire confirmed-missing or foreign-checkout failed-first entries, then
   * return one usable target. A bounded prefix is checked; remaining candidates
   * carry over to the next selection instead of adding unbounded synchronous
   * filesystem work to turn_end. Related/self targets keep priority.
   */
  retireIneligibleFailedTargets(selection) {
    const { cwd, runner, failedTargets, relatedAbs, selfAbs, turnIndex } = selection;
    let checked = 0;
    const inspected = /* @__PURE__ */ new Set();
    const inspect = (identity, entry) => {
      if (checked >= MAX_FAILED_TARGET_CHECKS_PER_SELECTION)
        return void 0;
      checked += 1;
      inspected.add(identity);
      const candidate = entry.displayPath;
      if (foreignGitRoot(candidate, cwd)) {
        failedTargets.delete(identity);
        this.recordFailedTargetState({
          outcome: "retired-nested-checkout",
          runner,
          candidate,
          turnIndex
        });
        return void 0;
      }
      const verdict = this.classifyFailedTarget(candidate);
      if (verdict.status === "missing") {
        failedTargets.delete(identity);
        this.recordFailedTargetState({
          outcome: "retired-missing",
          runner,
          candidate,
          errorCode: verdict.errorCode,
          turnIndex
        });
        return void 0;
      }
      if (verdict.status === "indeterminate") {
        this.recordFailedTargetState({
          outcome: "retained-indeterminate",
          runner,
          candidate,
          errorCode: verdict.errorCode,
          turnIndex
        });
      }
      return candidate;
    };
    for (const preferred of [relatedAbs, selfAbs]) {
      if (!preferred)
        continue;
      const identity = canonicalFailedPath(preferred);
      const entry = failedTargets.get(identity);
      if (!entry)
        continue;
      const selected = inspect(identity, entry);
      if (selected)
        return selected;
    }
    for (const [identity, entry] of failedTargets) {
      if (inspected.has(identity))
        continue;
      if (checked >= MAX_FAILED_TARGET_CHECKS_PER_SELECTION)
        break;
      const selected = inspect(identity, entry);
      if (selected)
        return selected;
    }
    if (failedTargets.size === 0)
      this.deleteFailedRoot(cwd, runner);
    return void 0;
  }
  /**
   * Check if a source file has corresponding tests (without running them)
   */
  hasTestFile(sourceFilePath, cwd) {
    return this.findTestFile(sourceFilePath, cwd) !== null;
  }
  /**
   * Suggest test files for a list of source files.
   * Returns deduplicated test file paths with their corresponding source file.
   */
  suggestTestFiles(sourceFiles, cwd) {
    const seen = /* @__PURE__ */ new Set();
    const results = [];
    for (const sourceFile of sourceFiles) {
      const found = this.findTestFile(sourceFile, cwd);
      if (!found)
        continue;
      const abs = path.resolve(found.testFile);
      if (seen.has(abs))
        continue;
      seen.add(abs);
      results.push({ testFile: abs, sourceFile, runner: found.runner });
    }
    return results;
  }
  // --- Shared JSON test output parser (Vitest + Jest share the same structure) ---
  parseJsonTestOutput(stdout, stderr, testFile, cwd, runner) {
    try {
      const json = JSON.parse(stdout);
      const failures = [];
      for (const suite of json.testResults || []) {
        if (suite.status === "failed" && suite.assertionResults) {
          for (const test of suite.assertionResults) {
            if (test.status === "failed") {
              failures.push({
                name: test.title,
                message: test.failureMessages?.[0] || suite.message || "Test failed",
                location: test.location ? `${path.relative(cwd, testFile)}:${test.location.line}` : void 0,
                stack: this.truncateStack(test.failureMessages?.join("\n"))
              });
            }
          }
        }
      }
      return {
        file: testFile,
        sourceFile: "",
        runner,
        passed: json.numPassedTests || 0,
        failed: json.numFailedTests || 0,
        // #1452: `numSkippedTests` is absent from both reporters' JSON, so
        // this read was always 0. `numPendingTests` is where a `test.skip`
        // actually lands; `numTodoTests` is counted with it because the
        // text parsers (pytest `N skipped`, mix `N excluded` + `N skipped`)
        // also fold every not-run test into one `skipped` figure.
        // `??` would accept a present 0, so a reporter that emits
        // `numSkippedTests: 0` beside a real `numPendingTests` would
        // reproduce the very defect this removes. Take the larger reading.
        skipped: Math.max(json.numSkippedTests ?? 0, (json.numPendingTests || 0) + (json.numTodoTests || 0)),
        failures,
        duration: this.jsonRunDurationMs(json.testResults)
      };
    } catch (err) {
      void err;
      const failed = stdout.includes("FAIL") || stderr.includes("FAIL");
      return this.emptyResult(testFile, "", runner, failed ? "Tests failed (could not parse output)" : void 0);
    }
  }
  /**
   * #1452: real run duration in ms from a vitest/jest `--json` payload.
   *
   * NOT `testResults[].perfStats`. That field exists on jest's INTERNAL
   * `TestResult`, but the JSON reporter's `formatTestResults` projects it to
   * per-suite `startTime`/`endTime` and drops it — measured absent from both
   * vitest 4.1.10 and jest 30.4.2 output, so reading it would have left this
   * at 0. The per-suite epoch pair is what both reporters actually emit.
   *
   * Wall-clock SPAN across suites (max end - min start), not a sum: suites in
   * one payload may have run in parallel workers, and summing would report
   * more elapsed time than the run took. With the single suite pi-lens
   * actually produces (one test file per invocation) the two agree.
   *
   * The span excludes the runner's own startup: the top-level `startTime` is
   * ~330ms earlier than the first suite's on this repo. What the per-suite
   * pair then measures is NOT the same quantity across runners. On vitest it
   * tracks test time closely (135ms span against 134ms of summed assertions),
   * but jest stamps a suite's `startTime` before transform and module load,
   * so the same fields give 5595ms against 128ms of assertions. Both are
   * honest suite wall clock; neither is comparable to the other, and only the
   * vitest figure is close to what pytest's `in 0.05s` or ExUnit's
   * `Finished in 0.05 seconds` report.
   *
   * Falls back to the summed per-assertion `duration` when a reporter omits
   * the suite pair. Never returns a negative or non-finite value — a garbled
   * payload must degrade to "unmeasured", not to a wrong number.
   *
   * #1479: that degradation is now literal. This used to return 0 for a
   * payload it could not read, which is the figure a sub-millisecond suite
   * also produces, so the caller could not tell them apart. It returns
   * `undefined` instead. A readable pair whose span is 0 still returns 0,
   * because that is a measurement.
   */
  jsonRunDurationMs(suites) {
    let minStart = Number.POSITIVE_INFINITY;
    let maxEnd = Number.NEGATIVE_INFINITY;
    let assertionTotal = 0;
    for (const suite of suites || []) {
      if (typeof suite.startTime === "number" && Number.isFinite(suite.startTime) && typeof suite.endTime === "number" && Number.isFinite(suite.endTime)) {
        minStart = Math.min(minStart, suite.startTime);
        maxEnd = Math.max(maxEnd, suite.endTime);
      }
      for (const assertion of suite.assertionResults || []) {
        if (typeof assertion.duration === "number" && Number.isFinite(assertion.duration) && assertion.duration > 0) {
          assertionTotal += assertion.duration;
        }
      }
    }
    const span = maxEnd - minStart;
    if (Number.isFinite(span) && span > 0)
      return Math.round(span);
    if (assertionTotal > 0)
      return Math.round(assertionTotal);
    if (Number.isFinite(span) && span === 0)
      return 0;
    return void 0;
  }
  // --- Vitest Parser ---
  parseVitestOutput(stdout, stderr, testFile, cwd, runner) {
    return this.parseJsonTestOutput(stdout, stderr, testFile, cwd, runner);
  }
  // --- Jest Parser ---
  parseJestOutput(stdout, stderr, testFile, cwd, runner) {
    return this.parseJsonTestOutput(stdout, stderr, testFile, cwd, runner);
  }
  // --- Pytest Parser (text-based, no JSON dependency) ---
  /**
   * #3871: a text runner prints a path relative to the directory it ran in
   * (`spawnCwd`, first in `bases`) or to the dispatch root. The first base
   * that holds the file wins; a path that resolves nowhere stays as printed,
   * and an absent capture is no location.
   */
  renderTextLocation(displayRoot, bases, file, line) {
    if (file === void 0 || line === void 0)
      return void 0;
    for (const base of bases) {
      const resolved = path.resolve(base, file);
      if (fs.existsSync(resolved)) {
        return `${toPosix(path.relative(displayRoot, resolved))}:${line}`;
      }
    }
    return `${file}:${line}`;
  }
  parsePytestOutput(stdout, stderr, exitCode, testFile, cwd, runner, displayRoot = cwd, spawnCwd = cwd) {
    const failures = [];
    const output = `${stdout}
${stderr}`;
    const { passed, failed, skipped, duration } = parsePytestSummary(output);
    const failureRegex = /FAILED\s+(\S+::\S+)\s*-\s*(.+?)(?:\n|$)/g;
    let match;
    while ((match = failureRegex.exec(output)) !== null) {
      const [file, ...testParts] = match[1].split("::");
      const location = this.renderTextLocation(displayRoot, [spawnCwd, cwd], file, testParts.join("::"));
      failures.push({
        name: match[1],
        message: match[2].trim().slice(0, 500),
        ...location === void 0 ? {} : { location }
      });
    }
    const tracebackRegex = /_{10,}\s*\n\s*(\w+Error:\s*.+?)(?:\n|$)/gs;
    while ((match = tracebackRegex.exec(output)) !== null) {
      if (failures.length > 0 && !failures[failures.length - 1].stack) {
        failures[failures.length - 1].stack = match[1].trim().slice(0, 1e3);
      }
    }
    return {
      file: testFile,
      sourceFile: "",
      runner,
      passed,
      failed,
      skipped,
      failures,
      duration,
      error: exitCode === 4 ? "Pytest configuration error" : exitCode === 2 ? "Pytest interrupted" : void 0
    };
  }
  // --- PHPUnit Parser (text-based, default CLI output) ---
  parsePhpunitOutput(stdout, stderr, exitCode, testFile, runner, displayRoot = path.dirname(testFile), cwd = path.dirname(testFile), spawnCwd = cwd) {
    const output = `${stdout}
${stderr}`;
    let passed = 0;
    let failed = 0;
    let skipped = 0;
    const okMatch = output.match(/OK\s*\((\d+)\s+tests?,\s*\d+\s+assertions?\)/i);
    if (okMatch) {
      passed = Number.parseInt(okMatch[1], 10);
    } else {
      const testsMatch = output.match(/Tests:\s*(\d+)/i);
      const failuresMatch = output.match(/Failures:\s*(\d+)/i);
      const errorsMatch = output.match(/Errors:\s*(\d+)/i);
      const skippedMatch = output.match(/Skipped:\s*(\d+)/i);
      const total = testsMatch ? Number.parseInt(testsMatch[1], 10) : 0;
      const failures2 = failuresMatch ? Number.parseInt(failuresMatch[1], 10) : 0;
      const errors = errorsMatch ? Number.parseInt(errorsMatch[1], 10) : 0;
      skipped = skippedMatch ? Number.parseInt(skippedMatch[1], 10) : 0;
      failed = failures2 + errors;
      passed = Math.max(0, total - failed - skipped);
    }
    const failures = [];
    const failureRegex = /^\d+\)\s+(\S+)/gm;
    let match;
    while ((match = failureRegex.exec(output)) !== null) {
      const afterFailure = output.slice(match.index + match[0].length);
      const nextFailure = afterFailure.search(/(?:^|\n)[^\S\n]*\d+\)\s/);
      const failureBlock = nextFailure === -1 ? afterFailure : afterFailure.slice(0, nextFailure);
      const locationMatch = /(?:^|\n)[^\S\n]*([^\s:]+\.php):(\d+)/.exec(failureBlock);
      const location = this.renderTextLocation(displayRoot, [spawnCwd, cwd], locationMatch?.[1], locationMatch?.[2]);
      failures.push({
        name: match[1],
        message: match[1],
        ...location === void 0 ? {} : { location }
      });
    }
    let duration;
    const clockMatch = output.match(/^Time:\s*(?:(\d+):)?(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?/im);
    if (clockMatch) {
      const hours = clockMatch[1] ? Number.parseInt(clockMatch[1], 10) : 0;
      const minutes = Number.parseInt(clockMatch[2], 10);
      const seconds = Number.parseInt(clockMatch[3], 10);
      const millis = clockMatch[4] ? Number.parseInt(clockMatch[4].padEnd(3, "0"), 10) : 0;
      duration = ((hours * 60 + minutes) * 60 + seconds) * 1e3 + millis;
    } else {
      const legacyMatch = output.match(/^Time:\s*([\d.]+)\s*(seconds?|s|ms|milliseconds?|minutes?)\b/im);
      if (legacyMatch) {
        const value = Number.parseFloat(legacyMatch[1]);
        const unit = legacyMatch[2].toLowerCase();
        const scale = unit.startsWith("ms") || unit.startsWith("milli") ? 1 : unit.startsWith("min") ? 6e4 : 1e3;
        if (Number.isFinite(value) && value > 0) {
          duration = Math.round(value * scale);
        }
      }
    }
    return {
      file: testFile,
      sourceFile: "",
      runner,
      passed,
      failed,
      skipped,
      failures,
      duration,
      error: exitCode !== 0 && passed === 0 && failed === 0 ? "PHPUnit runner error" : void 0
    };
  }
  // --- mix test Parser (ExUnit, text-based, default CLI output) ---
  parseMixTestOutput(stdout, stderr, exitCode, testFile, runner, displayRoot = path.dirname(testFile), cwd = path.dirname(testFile), spawnCwd = cwd) {
    const output = `${stdout}
${stderr}`;
    let passed = 0;
    let failed = 0;
    let skipped = 0;
    let duration;
    const summaryMatch = output.match(/(\d+)\s+tests?,\s*(\d+)\s+failures?(?:,\s*(\d+)\s+excluded)?(?:,\s*(\d+)\s+skipped)?/i);
    if (summaryMatch) {
      const total = Number.parseInt(summaryMatch[1], 10);
      failed = Number.parseInt(summaryMatch[2], 10);
      const excluded = summaryMatch[3] ? Number.parseInt(summaryMatch[3], 10) : 0;
      const skippedCount = summaryMatch[4] ? Number.parseInt(summaryMatch[4], 10) : 0;
      skipped = excluded + skippedCount;
      passed = Math.max(0, total - failed - skipped);
    }
    const durationMatch = output.match(/Finished in\s+([\d.]+)\s+seconds?/i);
    if (durationMatch) {
      duration = Math.round(Number.parseFloat(durationMatch[1]) * 1e3);
    }
    const failures = [];
    const failureRegex = /^[^\S\n]*\d+\)\s+(.+?)\s*\(([^)]+)\)\s*$/gm;
    let match;
    while ((match = failureRegex.exec(output)) !== null) {
      const afterFailure = output.slice(match.index + match[0].length);
      const nextFailure = afterFailure.search(/(?:^|\n)[^\S\n]*\d+\)\s/);
      const failureBlock = nextFailure === -1 ? afterFailure : afterFailure.slice(0, nextFailure);
      const locationMatch = /(?:^|\n)[^\S\n]*([^\s:]+\.exs):(\d+)/.exec(failureBlock);
      failures.push({
        name: match[1].trim(),
        message: match[1].trim(),
        location: this.renderTextLocation(displayRoot, [spawnCwd, cwd], locationMatch?.[1], locationMatch?.[2]) ?? match[2].trim()
      });
    }
    return {
      file: testFile,
      sourceFile: "",
      runner,
      passed,
      failed,
      skipped,
      failures,
      duration,
      error: exitCode !== 0 && passed === 0 && failed === 0 ? "mix test runner error" : void 0
    };
  }
  // --- Generic text parser for non-JSON runners ---
  /**
   * #1480: elapsed time for the runners `parseGenericRunnerOutput` handles.
   *
   * Before this, only go's `ok  pkg  0.25s` was read and every other runner
   * reported a hardcoded 0. #1479 made the log tell "measured" from
   * "unmeasured", but this parser is the `default:` arm behind cargo, dotnet,
   * maven, gradle, rspec, minitest and every unrecognised runner, so all of
   * them still reported a number nobody measured. Each runner below prints
   * its elapsed time in the same summary block this parser already regexes
   * for pass/fail counts.
   *
   * Absent, not 0, is the answer when nothing is found — see
   * `TestResult.duration` and `run-duration.ts`. A probe that returned 0 here
   * would be claiming a measurement.
   *
   * One parser serves all runners, so the probe is selected BY RUNNER NAME.
   * Running every probe over every runner's output was the original shape of
   * this code, and it let gradle borrow a number: `BUILD SUCCESSFUL in 3s`
   * plus a preceding `... ok` line satisfied go's `ok <pkg> <n>s` probe, so
   * the whole-build wall clock got reported as test time — the exact wrong
   * number this function refuses to print. Gating on the runner makes that
   * structurally impossible rather than merely unlikely, and it matters most
   * for the `default:` arm of the switch, which is where an unrecognised or
   * custom runner's arbitrary output lands.
   *
   * Within a runner the patterns are still anchored where an anchor helps,
   * for the same reason #1452's PHPUnit `Time:` pattern is anchored: an
   * unanchored /m match takes the FIRST hit over stdout+stderr, and a failure
   * diff quoting "Finished in ..." would beat the real summary. Note what the
   * `^` in `^Finished in` does and does not buy. It rejects a decoy that is
   * INDENTED, which is what a quoted expectation or an assertion diff is; it
   * does NOT rank two column-0 matches, so an unindented decoy printed by the
   * suite itself would still win. It is a cheap filter for the common shape,
   * not a proof of uniqueness. And it is not an anchor to the counts line for
   * rspec or minitest: both print their elapsed time on a `Finished in ...`
   * line and their counts (`3 examples, 0 failures`, `1 runs, 1 assertions,
   * ...`) on a different line.
   *
   * KNOWN LIMIT — first summary only. cargo across multiple crates, `dotnet
   * test` across multiple assemblies, and `go test ./...` across multiple
   * packages each print one summary per unit, and these probes take the
   * first. A multi-unit run therefore UNDER-REPORTS its duration. That is
   * left as-is deliberately: the count parsers below have the same first-match
   * shape for those runners, so duration and counts describe the same scope.
   * Fixing one without the other would trade an under-report for an
   * inconsistency. Pinned by test so it stays a known limit, not an accident.
   *
   * Formats and how each was verified:
   *
   * - go — `ok  example.com/pkg  0.253s`. Pre-existing pattern, unchanged
   *   apart from the shared finite/non-negative guard.
   *
   * - cargo — `test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured;
   *   0 filtered out; finished in 0.253s`. NOT VERIFIED AGAINST A LIVE CARGO
   *   RUN — this box has no MSVC linker, so `cargo test` cannot link. Format
   *   read out of the libtest printer shipped with the local rustc 1.94.1:
   *   `library/test/src/formatters/pretty.rs` builds `"; finished in
   *   {exec_time}"` and `library/test/src/time.rs` renders `TestSuiteExecTime`
   *   as `{:.2}s`. Older rustc omits the suffix entirely; that degrades to
   *   unmeasured.
   *
   * - dotnet/vstest — `Failed: 1, Passed: 2, Skipped: 0, Total: 3, Duration:
   *   1 m 30 s - t.dll (net8.0)`. NOT VERIFIED AGAINST A LIVE `dotnet test` —
   *   NuGet restore has no network here. Format read out of the
   *   vstest.console.dll shipped with the local .NET SDK 8.0.423, which holds
   *   the literal `{0} - Failed: {1}, Passed: {2}, Skipped: {3}, Total: {4},
   *   Duration: {5}` next to the unit literals `" h"`, `" m"`, `" s"`,
   *   `" ms"`, `"< 1 ms"`. The duration is a space-joined token list, so it
   *   is summed rather than read as one number.
   *
   * - maven/surefire — `Tests run: 4, Failures: 0, Errors: 0, Skipped: 0,
   *   Time elapsed: 0.05 s -- in com.example.AppTest`. NOT VERIFIED AGAINST A
   *   LIVE MAVEN — no mvn on this box. Summed across the per-class lines,
   *   because surefire prints `Time elapsed` per test class and its final
   *   `Results:` total carries no time. `[INFO] Total time: 3.4 s` is
   *   deliberately NOT used: that is whole-build wall clock including compile,
   *   which would report a wrong number rather than none. Surefire 2.x wrote
   *   `sec` where 3.x writes `s`; both are accepted.
   *
   *   EXPECT THIS TO BE ABSENT IN PRACTICE. pi-lens invokes `mvn test -q`
   *   (see RUNNERS.maven above), and surefire logs its per-class `Tests run:
   *   ..., Time elapsed: ...` lines at INFO, which `-q` suppresses. Only the
   *   ERROR-level lines of a FAILING class survive, so a green maven run
   *   typically reports unmeasured and a red one reports the failing classes'
   *   time alone. REASONED, NOT RUN — there is no mvn on this box to confirm
   *   it. Left in rather than dropped: it costs nothing, it is correct when
   *   the output does carry the lines (a repo that sets `-Dsurefire.useFile`
   *   or drops `-q` via `.mvn/maven.config`), and `unmeasured` is an honest
   *   report of the quiet case.
   *
   * - rspec — `Finished in 0.32394 seconds (files took 0.49427 seconds to
   *   load)`. VERIFIED against a live rspec-core 3.13.6 run on ruby 3.4.10.
   *   The minutes form (`Finished in 2 minutes 15.14 seconds`) comes from
   *   `RSpec::Core::Formatters::Helpers.format_duration` in the same
   *   installed gem; rspec never prints hours. Load time trails the run time
   *   on the same line and must not be read instead of it.
   *
   * - minitest — `Finished in 0.254594s, 7.8557 runs/s, 7.8557 assertions/s.`
   *   VERIFIED against a live minitest 5.25.4 run on ruby 3.4.10. The format
   *   string is `"Finished in %.6fs, ..."` in minitest.rb, always seconds.
   *
   * - gradle — deliberately left unmeasured, and now UNREACHABLE by any other
   *   runner's probe rather than merely unmatched by it. Gradle's console
   *   summary (`4 tests completed, 1 failed`) carries no elapsed time, and
   *   `BUILD SUCCESSFUL in 3s` is whole-build wall clock including compile
   *   and dependency resolution. Reporting that as test time would be a wrong
   *   number; #1479 makes the absence legible in the log instead.
   */
  parseGenericRunnerDuration(output, runner) {
    switch (runner) {
      case "go":
        return this.parseGoDuration(output);
      case "cargo":
        return this.parseCargoDuration(output);
      case "dotnet":
        return this.parseDotnetDuration(output);
      case "maven":
        return this.parseMavenDuration(output);
      case "rspec":
        return this.parseRspecDuration(output);
      case "minitest":
        return this.parseMinitestDuration(output);
      default:
        return void 0;
    }
  }
  /** go: `ok  	example.com/pkg	0.253s`. First package summary only. */
  parseGoDuration(output) {
    const goSummary = output.match(/ok\s+\S+\s+([\d.]+)s/m);
    if (!goSummary)
      return void 0;
    return toMeasuredDurationMs(Number.parseFloat(goSummary[1]) * 1e3);
  }
  /** cargo: `...; 0 filtered out; finished in 0.25s`. First crate only. */
  parseCargoDuration(output) {
    const cargoTime = output.match(/^test result:.*?;\s*finished in\s+([\d.]+)\s*s\b/im);
    if (!cargoTime)
      return void 0;
    return toMeasuredDurationMs(Number.parseFloat(cargoTime[1]) * 1e3);
  }
  /**
   * dotnet/vstest: `..., Total: 3, Duration: 1 m 30 s - t.dll (net8.0)`.
   *
   * Anchored to the counts line, and the tail stops at the ` - <dll>`
   * separator: without that stop an assembly name is scanned for unit tokens,
   * and a name like `Timeouts.30s.Tests.dll` adds 30 seconds of nothing.
   * First assembly only.
   */
  parseDotnetDuration(output) {
    const dotnetTime = output.match(/Failed:\s*\d+,\s*Passed:\s*\d+,\s*Skipped:\s*\d+,\s*Total:\s*\d+,\s*Duration:\s*([^\r\n-]+)/i);
    if (!dotnetTime)
      return void 0;
    if (/^\s*</.test(dotnetTime[1]))
      return 0;
    let total = 0;
    let tokens = 0;
    const units = {
      ms: 1,
      s: 1e3,
      m: 6e4,
      h: 36e5
    };
    for (const token of dotnetTime[1].matchAll(/([\d.]+)\s*(ms|h|m|s)\b/gi)) {
      total += Number.parseFloat(token[1]) * units[token[2].toLowerCase()];
      tokens++;
    }
    if (tokens === 0)
      return void 0;
    return toMeasuredDurationMs(total);
  }
  /**
   * maven/surefire: summed across per-class `Time elapsed` lines.
   *
   * The guard is "did any line match", NOT "is the sum positive". Surefire
   * prints `Time elapsed: 0.00 s` for a trivial test class, and that is a
   * measurement of zero, not a failure to measure.
   */
  parseMavenDuration(output) {
    let surefireTotal = 0;
    let matched = false;
    for (const line of output.matchAll(/^.*Tests run:\s*\d+,.*?Time elapsed:\s*([\d.]+)\s*(?:s|sec|secs|seconds)\b.*$/gim)) {
      const seconds = Number.parseFloat(line[1]);
      if (!Number.isFinite(seconds) || seconds < 0)
        continue;
      surefireTotal += seconds;
      matched = true;
    }
    if (!matched)
      return void 0;
    return toMeasuredDurationMs(surefireTotal * 1e3);
  }
  /** rspec: `Finished in 2 minutes 15.14 seconds (files took 0.5 ...)`. */
  parseRspecDuration(output) {
    const rspecTime = output.match(/^Finished in\s+(?:([\d.]+)\s+minutes?\s+)?([\d.]+)\s+seconds?/im);
    if (!rspecTime)
      return void 0;
    const minutes = rspecTime[1] ? Number.parseFloat(rspecTime[1]) : 0;
    return toMeasuredDurationMs(minutes * 6e4 + Number.parseFloat(rspecTime[2]) * 1e3);
  }
  /**
   * minitest: `Finished in 0.254594s, 7.8557 runs/s, ...`.
   *
   * The trailing `,` is load-bearing, not decoration: it is what separates
   * minitest's own line from a bare `Finished in 99s` the suite under test
   * printed at column 0, which the `^` alone does not rank.
   */
  parseMinitestDuration(output) {
    const minitestTime = output.match(/^Finished in\s+([\d.]+)s\s*,/im);
    if (!minitestTime)
      return void 0;
    return toMeasuredDurationMs(Number.parseFloat(minitestTime[1]) * 1e3);
  }
  parseGenericRunnerOutput(stdout, stderr, exitCode, testFile, runner, displayRoot = path.dirname(testFile), cwd = path.dirname(testFile), spawnCwd = cwd) {
    const output = `${stdout}
${stderr}`;
    const lower = output.toLowerCase();
    let passed = 0;
    let failed = 0;
    let skipped = 0;
    let matched = false;
    const duration = this.parseGenericRunnerDuration(output, runner);
    const cargoSummary = output.match(/test result:\s+\w+\.\s+(\d+)\s+passed;\s+(\d+)\s+failed;\s+(\d+)\s+ignored;/i);
    if (cargoSummary) {
      passed = Number.parseInt(cargoSummary[1], 10);
      failed = Number.parseInt(cargoSummary[2], 10);
      skipped = Number.parseInt(cargoSummary[3], 10);
      matched = true;
    }
    const dotnetSummary = output.match(/Failed:\s*(\d+),\s*Passed:\s*(\d+),\s*Skipped:\s*(\d+)/i);
    if (dotnetSummary) {
      failed = Number.parseInt(dotnetSummary[1], 10);
      passed = Number.parseInt(dotnetSummary[2], 10);
      skipped = Number.parseInt(dotnetSummary[3], 10);
      matched = true;
    }
    const mavenLines = [
      ...output.matchAll(/^.*?Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+).*$/gim)
    ];
    const mavenAggregates = mavenLines.filter((line) => !/Time elapsed:/i.test(line[0]));
    const mavenScored = mavenAggregates.length > 0 ? mavenAggregates : mavenLines;
    if (mavenScored.length > 0) {
      let total = 0;
      let mavenFailed = 0;
      let mavenSkipped = 0;
      for (const line of mavenScored) {
        total += Number.parseInt(line[1], 10);
        mavenFailed += Number.parseInt(line[2], 10) + Number.parseInt(line[3], 10);
        mavenSkipped += Number.parseInt(line[4], 10);
      }
      failed = mavenFailed;
      skipped = mavenSkipped;
      passed = Math.max(0, total - failed - skipped);
      matched = true;
    }
    const rspecSummary = output.match(/(\d+)\s+examples?,\s+(\d+)\s+failures?/i);
    if (rspecSummary) {
      const total = Number.parseInt(rspecSummary[1], 10);
      failed = Number.parseInt(rspecSummary[2], 10);
      passed = Math.max(0, total - failed);
      matched = true;
    }
    const minitestSummary = output.match(/(\d+)\s+runs?,\s+\d+\s+assertions?,\s+(\d+)\s+failures?,\s+(\d+)\s+errors?/i);
    if (minitestSummary) {
      const total = Number.parseInt(minitestSummary[1], 10);
      const failures2 = Number.parseInt(minitestSummary[2], 10);
      const errors = Number.parseInt(minitestSummary[3], 10);
      failed = failures2 + errors;
      passed = Math.max(0, total - failed);
      matched = true;
    }
    const gradleSummary = output.match(/(\d+)\s+tests? completed,\s+(\d+)\s+failed/i);
    if (gradleSummary) {
      const total = Number.parseInt(gradleSummary[1], 10);
      failed = Number.parseInt(gradleSummary[2], 10);
      passed = Math.max(0, total - failed);
      matched = true;
    }
    const goFailNames = [...output.matchAll(/--- FAIL:[^\S\n]+([^\s(]+)/g)];
    const goFailNameSet = new Set(goFailNames.map((m) => m[1].trim()));
    const goTopLevelFailCount = goFailNames.filter((m) => {
      const name = m[1].trim();
      const slash = name.indexOf("/");
      return slash === -1 || !goFailNameSet.has(name.slice(0, slash));
    }).length;
    let goInfraVerdict = false;
    if (runner === "go") {
      goInfraVerdict = /^FAIL[^\S\n]+\S+[^\S\n]+\[setup failed\]/m.test(output);
      const goFailPackage = /^FAIL(?![^\n]*\[(?:build|setup) failed\])[^\S\n]+\S+/m.test(output);
      const goOkPackages = [
        ...output.matchAll(/^ok[^\S\n]+\S+[^\S\n]+[\d.]+s/gm)
      ];
      if (goFailNames.length > 0 || goFailPackage) {
        failed = Math.max(failed, goTopLevelFailCount || 1);
        matched = true;
      }
      if (goOkPackages.length > 0) {
        passed = Math.max(passed, goOkPackages.length);
        matched = true;
      }
    }
    const failures = [];
    for (const m of goFailNames.slice(0, 5)) {
      failures.push({ name: m[1].trim(), message: m[1].trim() });
    }
    const otherNames = [
      ...output.matchAll(/^[^\S\n]*FAILED[^\S\n]+(\S.*)$/gm),
      ...output.matchAll(/^[^\S\n]*Failure:[^\S\n]+(\S.*)$/gm)
    ];
    for (const m of otherNames) {
      if (failures.length >= 5)
        break;
      const name = m[1].trim();
      const locationMatch = /(?<!\S)([^\s:]+\.(?:go|rs|rb|java|kt|cs|fs|py|php|exs)):(\d+)/.exec(name);
      const location = this.renderTextLocation(displayRoot, [spawnCwd, cwd], locationMatch?.[1], locationMatch?.[2]);
      failures.push({
        name,
        message: name,
        ...location === void 0 ? {} : { location }
      });
    }
    const runnerError = exitCode !== 0 && !matched && goFailNames.length === 0 && (lower.includes("error") || goInfraVerdict) ? `Runner ${runner} exited with ${exitCode}` : void 0;
    if (runnerError) {
      failures.length = 0;
    }
    if (exitCode !== 0 && failed === 0 && !runnerError) {
      failed = 1;
    }
    if (passed === 0 && failed === 0 && skipped === 0 && exitCode === 0) {
      passed = 1;
    }
    if (failures.length === 0 && failed > 0) {
      const firstLine = output.split("\n").find((l) => /fail|error|exception/i.test(l))?.trim().slice(0, 300) || `Tests failed for runner ${runner}`;
      failures.push({ name: `${runner} failure`, message: firstLine });
    }
    this.log(runnerError ? `Generic runner ${runner}: never started (${runnerError})` : `Generic runner ${runner}: ran (matched=${matched}, passed=${passed}, failed=${failed}, failures=${failures.length})`);
    return {
      file: testFile,
      sourceFile: "",
      runner,
      passed,
      failed,
      skipped,
      failures,
      duration,
      error: runnerError
    };
  }
  // --- Formatting ---
  /**
   * Format test result for LLM consumption
   */
  formatResult(result) {
    if (isRunnerErrorResult(result)) {
      return result.passed > 0 ? `[Tests] \u26A0 Could not complete tests: ${result.error} (${result.passed} passed before)` : `[Tests] \u26A0 Could not run tests: ${result.error}`;
    }
    const total = result.passed + result.failed + result.skipped;
    if (total === 0) {
      return "";
    }
    const durationStr = isMeasuredDuration(result.duration) && result.duration > 0 ? ` (${(result.duration / 1e3).toFixed(2)}s)` : "";
    if (result.failed === 0) {
      return `[Tests] \u2713 ${result.passed}/${total} passed${durationStr} \u2014 ${result.runner}`;
    }
    let output = `[Tests] \u2717 ${result.failed}/${total} failed, ${result.passed} passed${durationStr} \u2014 ${result.runner}
`;
    for (const failure of result.failures.slice(0, 5)) {
      output += `  \u2717 ${failure.name}
`;
      const msg = failure.message.split("\n")[0].slice(0, 200);
      output += `    ${msg}
`;
      if (failure.location) {
        output += `    at ${failure.location}
`;
      }
    }
    if (result.failures.length > 5) {
      output += `  ... and ${result.failures.length - 5} more failure(s)
`;
    }
    output += `  \u2192 Fix failing tests before proceeding
`;
    return output.trimEnd();
  }
  // --- Helpers ---
  /**
   * Additional mirrored-directory candidate for source trees whose test
   * tree mirrors the source tree under a *different*, conventional
   * source-root segment rather than the source file's full relative
   * directory — e.g. PHPUnit's `src/Foo/Bar.php` -> `tests/Foo/BarTest.php`
   * (strips `src`) or ExUnit's `lib/accounts/user.ex` ->
   * `test/accounts/user_test.exs` (strips `lib`).
   *
   * Unlike the `relDir`-based candidates above (which mirror under the
   * hardcoded "tests"/"__tests__" roots), this uses `testDir` — the
   * pattern's own configured test root from `SOURCE_TO_TEST_PATTERNS`
   * (e.g. "tests" for PHP, "test" for Elixir) — since ExUnit's root is
   * singular and wouldn't otherwise be checked.
   *
   * Returns an empty array when the source directory doesn't start with a
   * known source-root segment (src/lib/app) followed by at least one more
   * path segment — i.e. this is a no-op for languages/layouts that don't
   * use this convention.
   */
  sourceRootMirroredCandidates(dir, cwd, testDir, testFilename) {
    const knownSourceRoots = /* @__PURE__ */ new Set(["src", "lib", "app"]);
    const relDir = path.relative(cwd, dir);
    const segments = relDir.split(path.sep).filter(Boolean);
    if (segments.length > 1 && knownSourceRoots.has(segments[0])) {
      return [path.join(cwd, testDir, ...segments.slice(1), testFilename)];
    }
    return [];
  }
  /**
   * Fallback discovery: scan known test directories for a file that imports
   * the source module. Catches cases where the test file name doesn't match
   * the source basename (e.g. cline.test.ts testing cline-auth.ts).
   *
   * Checks for the basename appearing in a quoted import/require path:
   *   from "../providers/cline/cline-auth"   → /cline-auth"  ✓
   *   from "./cline-auth.js"                 → /cline-auth.  ✓
   *   import("cline-auth")                   → "cline-auth"  ✓
   */
  findTestFileByImport(sourceFilePath, cwd, acceptCandidate) {
    const ext = path.extname(sourceFilePath);
    const basename2 = path.basename(sourceFilePath, ext);
    const testPattern = /\.(test|spec)\.(ts|tsx|js|jsx|mjs)$/;
    const searchDirs = [
      path.join(cwd, "tests"),
      path.join(cwd, "__tests__"),
      path.dirname(sourceFilePath)
    ];
    for (const dir of searchDirs) {
      let entries;
      try {
        entries = fs.readdirSync(dir);
      } catch {
        continue;
      }
      for (const entry of entries) {
        const testPath = path.join(dir, entry);
        if (!testPattern.test(entry) || !acceptCandidate(testPath))
          continue;
        let content;
        try {
          content = fs.readFileSync(testPath, "utf-8");
        } catch {
          continue;
        }
        if (content.includes(`/${basename2}"`) || content.includes(`/${basename2}'`) || content.includes(`/${basename2}.`) || content.includes(`"${basename2}"`) || content.includes(`'${basename2}'`)) {
          this.log(`Found test file via import scan: ${testPath}`);
          return testPath;
        }
      }
    }
    return null;
  }
  /**
   * Resolve the executable and args for a runner, preferring a local
   * node_modules/.bin binary over npx to avoid the ~150ms npx startup cost.
   *
   * When a resolved binary becomes the command itself, `stripWrapperArgs`
   * drops ONLY the leading arg(s) that named the wrapped binary — never a
   * real subcommand (#1098: `cargo test --no-fail-fast` unconditionally lost
   * `test` here because the old code assumed every runner's args() started
   * with an npx-style runner-name arg, which only holds for wrapper-style
   * runners like vitest/jest/pytest).
   */
  async resolveExec(runner, config, testFile, cwd, spawnCwd = cwd, requireOwnInstall = false) {
    const needsInstall = config.command === "npx" || runner === "pytest" || runner === "phpunit";
    if (runner === "pytest") {
      const pythonEnvironment = await detectPythonEnvironment(cwd, void 0, {
        allowAmbient: !requireOwnInstall
      });
      if (pythonEnvironment) {
        return {
          command: pythonEnvironment.pythonPath,
          args: config.args(testFile, spawnCwd),
          env: augmentPythonEnvironment(process.env, pythonEnvironment),
          ownInstall: true
        };
      }
    }
    if (runner === "phpunit") {
      const suffix2 = process.platform === "win32" ? ".bat" : "";
      const vendorBin = path.join(cwd, "vendor", "bin", `phpunit${suffix2}`);
      if (fs.existsSync(vendorBin)) {
        return {
          command: vendorBin,
          args: config.args(testFile, spawnCwd),
          ownInstall: true
        };
      }
      return {
        command: "phpunit",
        args: config.args(testFile, spawnCwd),
        ownInstall: false
      };
    }
    const binName = config.binName ?? runner;
    const suffix = process.platform === "win32" ? ".cmd" : "";
    const localBin = path.join(cwd, "node_modules", ".bin", binName + suffix);
    if (fs.existsSync(localBin)) {
      return {
        command: localBin,
        args: stripWrapperArgs(binName, config.args(testFile, spawnCwd)),
        ownInstall: true
      };
    }
    const globalBin = await findGlobalBinary(binName);
    if (globalBin) {
      return {
        command: globalBin,
        args: stripWrapperArgs(binName, config.args(testFile, spawnCwd)),
        ownInstall: !needsInstall
      };
    }
    return {
      command: config.command,
      args: config.args(testFile, spawnCwd),
      ownInstall: !needsInstall
    };
  }
  emptyResult(testFile, sourceFile, runner, error) {
    return {
      file: testFile,
      sourceFile,
      runner,
      passed: 0,
      failed: 0,
      skipped: 0,
      failures: [],
      // #1479: no duration key at all. Nothing ran, so there is nothing
      // to report — this used to say 0, which reads as "ran, instantly".
      error
    };
  }
  truncateStack(stack) {
    if (!stack)
      return void 0;
    const lines = stack.split("\n").slice(0, 3);
    return lines.join("\n").slice(0, 500);
  }
  recordResult(record) {
    const { cwd, runner, testFile, result, turnIndex } = record;
    const targetPath = path.resolve(testFile);
    if (result.failed > 0 && foreignGitRoot(targetPath, cwd)) {
      this.recordFailedTargetState({
        outcome: "rejected-nested-checkout",
        runner,
        candidate: targetPath,
        turnIndex
      });
      return;
    }
    const target = canonicalFailedPath(targetPath);
    let failedTargets = this.getFailedTargets(cwd, runner, result.failed > 0);
    if (!failedTargets)
      return;
    if (result.failed > 0) {
      const alreadyRecorded = failedTargets.has(target);
      if (!alreadyRecorded && this.failedTargetCount(runner) >= MAX_FAILED_TARGETS_PER_RUNNER) {
        const evicted = this.evictOldestFailedTarget(runner);
        if (evicted) {
          this.recordFailedTargetState({
            outcome: "capacity-evicted",
            runner,
            candidate: evicted,
            turnIndex
          });
        }
        failedTargets = this.getFailedTargets(cwd, runner, true);
        if (!failedTargets)
          return;
      }
      failedTargets.set(target, {
        displayPath: targetPath,
        sequence: ++this.failedTargetSequence
      });
      return;
    }
    if (failedTargets.delete(target) && failedTargets.size === 0) {
      this.deleteFailedRoot(cwd, runner);
    }
  }
};

export {
  formatRunDurationMs,
  isRunnerErrorResult,
  SOURCE_TO_TEST_PATTERNS,
  TURN_END_EXCLUDED_TEST_GLOBS,
  isExcludedTestTarget,
  RUNNERS,
  stripWrapperArgs,
  MAX_CANONICAL_ROOT_MEMO_ENTRIES,
  TestRunnerClient
};