On this page
`test -L` vs `realpath` for symlink detection
A POSIX gotcha. `test -L child/leaf` returns false when a parent is the symlink, even when the resolution chain is healthy. Use `realpath` for source-of-truth chain validation.
A symlink audit script in my 3B repo almost ran rm <link> && ln -s against a
perfectly healthy mount. The check that flagged the “bug” was a single line — test -L ~/.claude/skills/<skill>/SKILL.md — and it returned false. The script
concluded “renameSync trap detected, recovering” and started preparing the
destructive rebuild path. Only a downstream sanity check (file content matched
the SoT) prevented the rm.
POSIX wasn’t wrong. The script was wrong about what POSIX promises.
The problem
test -L child/leaf returns false when child/ is itself a symlink, even when
the resolved chain points at exactly the SoT you expect. Treating that result as
“broken symlink” produces false-positive diagnoses in symlink audit scripts.
The fix is mechanical — swap to realpath and string-compare against the
expected target. The reasoning is what matters, because the same trap shows up
in [ -L X ] and ls -la X too. If you only fix one of them, the bug rotates.
Context
3B’s skill mounts symlink at the directory level:
~/.claude/skills/ # symlink → 3b/.claude/skills/
~/.claude/skills/<skill>/SKILL.md # NOT a symlink at the leaf — leaf
# is a regular file inside the SoT
# directory Audit script ran test -L ~/.claude/skills/<skill>/SKILL.md expecting “true =
healthy symlink chain”. Got false. Concluded “renameSync trap, symlink replaced
by regular file”. Wrong diagnosis.
Why this happens
POSIX test -L file walks the path resolving every parent component. By the
time it inspects the leaf, parent symlinks have been followed. The leaf is the resolved entry — usually a regular file, NOT the link that pointed at it. So test -L returns false even though the chain is healthy.
This is correct POSIX behavior — the bug is in the assumption that test -L detects “any symlink in this path’s resolution chain”. It detects only “this
exact name is a symlink”.
The solution
Use realpath (or readlink -f) and compare against the expected SoT path:
RESOLVED=$(realpath ~/.claude/skills/<skill>/SKILL.md)
EXPECTED=/Users/.../3b/.agents/skills/<skill>/SKILL.md
[ "$RESOLVED" = "$EXPECTED" ] && echo "OK" || echo "BROKEN" Python equivalent:
import os
resolved = os.path.realpath(mount)
expected = "/Users/.../3b/.agents/skills/<skill>/SKILL.md"
print("OK" if resolved == expected else "BROKEN") For Node helpers:
const fs = require("fs");
const realPath = fs.realpathSync(mount); Each form does the same thing — resolve the whole chain, then string-compare against what you expected the chain to land on.
When to use which
Different questions need different tools. Picking the wrong one is what produced the false positive in the first place:
| Goal | Tool |
|---|---|
| “Is THIS exact name a symlink?” | test -L path |
| “Does this path RESOLVE to the SoT I expect?” | realpath + string compare |
| “Walk up the path checking each component?” | iterate + test -L per parent |
test -L is fine for the narrow question. For symlink audits — anything where
you care about the chain landing in the right place — realpath is what you
want.
Difficulties encountered
- The false-positive masquerades as a real bug. The scripted check produced a
“renameSync trap detected, recovering” message that almost triggered a
destructive
rm <link> && ln -srecovery path. Verifying the audit’s recovery preconditions (file content matched SoT, parent dir was a symlink) prevented the destructive action. ls -la <leaf>shows the leaf as a regular file (nolprefix) when resolved through parent symlinks. Reinforces the wrong conclusion if you rely onlsoutput for symlink detection.
Key points
- Symlink resolution in POSIX walks the whole path, not just the leaf.
test -L,[ -L X ],ls -la Xall describe the leaf’s own type after parent resolution — not the chain’s symlink status.realpath(BSD + GNU + macOS coreutils) is the portable answer. Compare the resolved path against your expected SoT.- Symlink audit scripts that use
test -Lon leaf paths will produce false positives whenever a parent in the chain is the actual symlink. Always userealpathfor SoT chain validation.
Takeaway
If your audit script’s recovery path is destructive, the detection check has to
be the right question, not just a true/false answer. test -L answers “is this
exact name a symlink?” — which is rarely what an audit actually wants. The
audit wants “does this path resolve to the place I expect?” That question only
has one tool: resolve the chain, then compare strings.
The bigger lesson, for me, was that POSIX behavior felt counter-intuitive only
because I’d memorized -L as “symlink check” rather than “this exact name is a
symlink”. Reading the spec carefully would have saved the false positive — and
the near-miss rm.
See also
- POSIX
test(1)spec — explicitly says-Lchecks “the file” (singular) - 3B
.agents/skills/check-symlinks/scripts/check-symlinks.sh— usesrealpathfor this reason