JuliusBrussee/caveman · error

caveman build: source graph symlink escapes project root

Error message

caveman build: source graph symlink escapes project root

What it means

A relative import resolved lexically to a file inside the project root, but its realpath (after following symlinks) lands outside the canonical root. The source graph locks files by physical identity; a symlink that escapes the project makes the closure non-finite/unverifiable from inside, so the build rejects it explicitly rather than silently following it out.

Source

Thrown at packages/agent/src/source-graph.ts:96

          break;
        } catch (error) {
          if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
        }
      }
      if (!found) {
        throw new Error(`caveman build: unresolved relative source import ${JSON.stringify(specifier)}`);
      }
      if (bare) {
        await collectPackageClosure(packageRoot!, files, packageRoots);
        continue;
      }
      const lexicalFound = found;
      found = await realpath(found);
      const insideRoot = isPathWithin(canonicalRoot, found);
      if (!insideRoot && !bare && !external.has(path)) {
        const lexicalInsideRoot = isPathWithin(canonicalRoot, lexicalFound);
        if (lexicalInsideRoot) {
          throw new Error("caveman build: source graph symlink escapes project root");
        }
        throw new Error("caveman build: imported source escapes project root");
      }
      if (!insideRoot) external.add(found);
      if (!files.has(found)) {
        files.add(found);
        if (!traversalStopRoots.some((stop) => isPathWithin(stop, found))) {
          queue.push(found);
        }
      }
    }
  }
  return files;
}

function explicitImportCandidates(base: string, importer: string): string[] {
  const importerExtension = extname(importer);
  if (![".ts", ".tsx", ".mts", ".cts"].includes(importerExtension)) return [base];

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Remove the escaping symlink and either copy the file in or vendor it as a real dependency package installed through node_modules.
  2. Move the shared code inside the project root so the physical file is contained.
  3. If the target must stay outside, import it as a proper package dependency so it goes through the bare-specifier package closure path instead of a project-relative symlink.

Example fix

# before
ln -s ~/shared/util.ts src/shared-util.ts # then import "./shared-util"

# after
# place the real file inside the project, or depend on it as a package:
Defensive patterns

Strategy: validation

Validate before calling

import { realpath, lstat } from "node:fs/promises";
import { relative, isAbsolute, resolve, dirname } from "node:path";
async function noEscapingSymlinks(projectRoot: string): Promise<void> {
  const root = await realpath(projectRoot);
  for (const file of await listProjectFiles(projectRoot)) {
    const st = await lstat(file);
    if (st.isSymbolicLink()) {
      const real = await realpath(file);
      const rel = relative(root, real);
      if (isAbsolute(rel) || rel.startsWith("..")) throw new Error(`escaping symlink: ${file} -> ${real}`);
    }
  }
}

Try / catch

try {
  await buildSourceGraph(root);
} catch (error) {
  if (error instanceof Error && error.message === "caveman build: source graph symlink escapes project root") {
    // find the symlink (compare lexical vs realpath) and remove it or vendor the target
  } else throw error;
}

Prevention

When it happens

Trigger: A symlink inside the project directory points to a file outside the project (e.g. linking a shared module from ~/common or another checkout), and a project source imports through it.

Common situations: Developers symlinking shared utilities between repos; monorepo tooling that links packages into src; editor plugins or patch-package-style workflows creating symlinks inside the source tree.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15). Data as JSON: /api/errors/8c475ebf7b863f96. Report an issue: GitHub.