JuliusBrussee/caveman · error

caveman build: package artifact symlink is not lockable

Error message

caveman build: package artifact symlink is not lockable: ${JSON.stringify(path)}

What it means

Thrown by collectPackageFiles when it encounters a symbolic link while enumerating a package's files. Because this build produces a locked/artifact-accurate file set, symlinks are considered non-lockable: the library refuses rather than following links that could duplicate or escape the package.

Solutions

  1. Replace the symlink inside the package with the real file or a copy.
  2. Reinstall with a package manager/linker that materializes real files (npm instead of pnpm symlinks for this package).
  3. Exclude or restructure symlinked directories before building.
  4. If it's your own package, stop shipping symlinks in published artifacts (use files field to publish real files).

Example fix

// before
ln -s ../shared/asset.js node_modules/foo/asset.js

// after
cp ../shared/asset.js node_modules/foo/asset.js
Defensive patterns

Strategy: validation

Validate before calling

import { readdirSync, lstatSync } from "node:fs";
import { join } from "node:path";
function hasSymlinks(dir) {
  for (const e of readdirSync(dir, { withFileTypes: true })) {
    const p = join(dir, e.name);
    if (e.isSymbolicLink() || lstatSync(p).isSymbolicLink()) return p;
    if (e.isDirectory() && hasSymlinks(p)) return hasSymlinks(p);
  }
  return null;
}

Type guard

null

Try / catch

try {
  await graph.expand();
} catch (e) {
  if (e instanceof Error && e.message.includes("symlink is not lockable")) {
    console.error("Replace the reported symlink with a real file before building.");
  }
  throw e;
}

Prevention

When it happens

Trigger: collectPackageClosure collects files for a package; readdir-withFileTypes yields an entry that is neither file, directory, nor regular file — specifically entry.isSymbolicLink() is true (e.g. node_modules/bin links or pnpm-style symlinks inside the package).

Common situations: pnpm/yarn PnP installs that symlink within packages; a package that ships symlinks (e.g. bin or vendored assets); developer-created symlinks inside node_modules pointing to a local checkout.

Understand the failure class

Background: UnsupportedOperationException and "is not supported" errors: when a library deliberately refuses a call — this error's family across 30 libraries.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/e0afd4fb6b8c3e73. Report an issue: GitHub.

Appendix: source

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

      if (!required.has(dependency)) continue;
      throw new Error(`caveman build: unresolved package dependency ${JSON.stringify(dependency)}`, {
        cause: error,
      });
    }
  }
}

async function collectPackageFiles(directory: string, files: Set<string>): Promise<void> {
  const entries = await opendir(directory);
  for await (const entry of entries) {
    if (entry.name === "node_modules" || entry.name === ".git") continue;
    const path = resolve(directory, entry.name);
    if (entry.isDirectory()) {
      await collectPackageFiles(path, files);
    } else if (entry.isFile()) {
      files.add(path);
    } else if (entry.isSymbolicLink()) {
      throw new Error(`caveman build: package artifact symlink is not lockable: ${JSON.stringify(path)}`);
    }
  }
}

function barePackageName(specifier: string): string {
  const parts = specifier.split("/");
  if (specifier.startsWith("@")) {
    if (parts.length < 2 || parts[1] === "") throw new Error("invalid scoped package");
    return `${parts[0]}/${parts[1]}`;
  }
  if (parts[0] === "") throw new Error("invalid package");
  return parts[0]!;
}

function resolvePackageExport(exportsValue: unknown, subpath: string): string | undefined {
  if (typeof exportsValue === "string" || Array.isArray(exportsValue)) {
    return subpath === "." ? resolveConditionalExport(exportsValue) : undefined;
  }

View on GitHub (pinned to 3ee70a1026)