Problem
The Unplugin project cache can omit a compiler input reached through a symlink or Windows junction inside the project root.
The project-tree walk intentionally skips symbolic links. Following them would permit cycles and traversal outside the project. isProjectWalkPath, however, classifies paths only by lexical root and ignored directory names. A graph-reported file such as <project>/linked/dependency.ts is therefore considered covered by the project walk even though the walk never visits it.
That file enters neither inputHashes nor externalInputHashes. The same predicate is used by Metro's supplementary snapshot, so the input is omitted there as well.
Evidence
The cache assembles its input universe in two parts:
collectProjectInputHashes hashes regular files returned by listProjectInputFiles.
collectExternalInputHashes hashes graph and plugin inputs for which isProjectWalkPath is false.
listProjectInputFiles accepts Dirent.isDirectory() and Dirent.isFile() entries. A symlink or junction satisfies neither branch and is skipped.
isProjectWalkPath checks the relative path segments but never calls lstat on those segments.
A Windows junction fixture with this shape reproduces the gap:
project/
tsconfig.json
linked/ -> external/
external/
dependency.ts
The observed classification was:
{
"hashKeys": ["tsconfig.json"],
"classifiedInsideWalk": true
}
The compiler graph spelling linked/dependency.ts is therefore suppressed from the external snapshot despite being absent from the project snapshot.
Consequence surface
- Bun, Turbopack, and other long-lived cache owners can replay a transform after a linked dependency changes.
- Metro can reuse a stale worker snapshot because its graph supplement applies the same classification.
- Standard bundler watch registration still receives the graph path, but the shared transform cache can answer before compiling again.
- File symlinks, directory symlinks, and Windows junctions have the same classification problem.
- Broken links and link cycles must fail closed without making project discovery recursive.
- Ordinary in-root files, ignored
node_modules inputs, out-of-root files, case-insensitive identity, and generated temporary configs must retain their current behavior.
Approach
Keep the tree walk non-following. Refine isProjectWalkPath so an existing relative path is considered walk-covered only when no component below the project root traverses a symbolic link or junction. A linked graph input then belongs to the external-input owner and receives an exact file hash.
The check must be platform-neutral and tolerate an absent candidate path. It must not resolve or follow the link during the recursive project walk.
Acceptance and verification
- A graph input reached through an in-root file symlink is hashed externally.
- A graph input reached through an in-root directory symlink or Windows junction is hashed externally.
- Changing that target invalidates the project cache.
- Metro's snapshot supplement includes the linked input.
- Ordinary in-root files remain project-walk inputs and are not duplicated externally.
- Ignored directories and genuinely out-of-root graph inputs retain coverage.
- Broken links and cycles do not hang or escape the project root.
- Run:
pnpm --filter @ttsc/unplugin build
pnpm --filter @ttsc/test-unplugin start -- --include=external
pnpm --filter @ttsc/test-metro start
pnpm --filter @ttsc/test-unplugin start
Coordination
Issue #252 changes how often a generation is validated, not which inputs it contains. This issue supplies the missing linked-input invariant and must land before performance evidence is used to claim cache freshness.
Problem
The Unplugin project cache can omit a compiler input reached through a symlink or Windows junction inside the project root.
The project-tree walk intentionally skips symbolic links. Following them would permit cycles and traversal outside the project.
isProjectWalkPath, however, classifies paths only by lexical root and ignored directory names. A graph-reported file such as<project>/linked/dependency.tsis therefore considered covered by the project walk even though the walk never visits it.That file enters neither
inputHashesnorexternalInputHashes. The same predicate is used by Metro's supplementary snapshot, so the input is omitted there as well.Evidence
The cache assembles its input universe in two parts:
collectProjectInputHasheshashes regular files returned bylistProjectInputFiles.collectExternalInputHasheshashes graph and plugin inputs for whichisProjectWalkPathis false.listProjectInputFilesacceptsDirent.isDirectory()andDirent.isFile()entries. A symlink or junction satisfies neither branch and is skipped.isProjectWalkPathchecks the relative path segments but never callslstaton those segments.A Windows junction fixture with this shape reproduces the gap:
The observed classification was:
{ "hashKeys": ["tsconfig.json"], "classifiedInsideWalk": true }The compiler graph spelling
linked/dependency.tsis therefore suppressed from the external snapshot despite being absent from the project snapshot.Consequence surface
node_modulesinputs, out-of-root files, case-insensitive identity, and generated temporary configs must retain their current behavior.Approach
Keep the tree walk non-following. Refine
isProjectWalkPathso an existing relative path is considered walk-covered only when no component below the project root traverses a symbolic link or junction. A linked graph input then belongs to the external-input owner and receives an exact file hash.The check must be platform-neutral and tolerate an absent candidate path. It must not resolve or follow the link during the recursive project walk.
Acceptance and verification
Coordination
Issue #252 changes how often a generation is validated, not which inputs it contains. This issue supplies the missing linked-input invariant and must land before performance evidence is used to claim cache freshness.