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
- Replace the symlink inside the package with the real file or a copy.
- Reinstall with a package manager/linker that materializes real files (npm instead of pnpm symlinks for this package).
- Exclude or restructure symlinked directories before building.
- 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
- Don't create symlinks inside node_modules or published packages.
- Use a linker that materializes files (npm) for projects fed into this build.
- Scan packages for symlinks in CI before graph expansion.
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
- caveman build: source graph symlink escapes project root
- caveman build: imported source escapes project root
- failed to canonicalize CODEX_HOME
- MCP config path contains dangling symlink
- is a symlink; refusing transactional ownership mutation
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)