This issue was investigated and drafted with AI assistance; the reproduction and findings were verified locally.
Description
Knip can report a newly added Vitest spec as unused on a warm cache, while the same analysis without --cache passes.
The reproduction was executed with Knip 6.26.0. I also checked the 6.27.0 source, where captureDirMtimes retains the same implementation.
I searched the existing issues and reviewed the cache documentation and known issues. A public reproduction URL is not available, so the complete minimal reproduction is included below.
Minimal reproduction
Create this workspace:
.
├── knip.json
├── package.json
└── apps/app
├── package.json
├── scripts/entry.ts
└── src
├── covered/existing.spec.ts
└── target/source.ts
Root package.json:
{
"name": "knip-glob-cache-workspace",
"private": true,
"workspaces": ["apps/*"],
"devDependencies": {
"knip": "6.26.0"
}
}
knip.json:
{
"workspaces": {
"apps/app": {
"entry": ["scripts/*.*"]
}
}
}
apps/app/package.json:
{
"name": "app",
"private": true,
"devDependencies": {
"vitest": "4.1.10"
}
}
apps/app/scripts/entry.ts:
import { source } from '../src/target/source.ts';
export { source };
apps/app/src/covered/existing.spec.ts:
apps/app/src/target/source.ts:
export const source = true;
Populate the cache:
knip --cache --cache-location .cache --files
This exits successfully. Then add apps/app/src/target/new.spec.ts:
Run the same cached command:
knip --cache --cache-location .cache --files
Actual result:
Unused files (1)
apps/app/src/target/new.spec.ts
The uncached command exits successfully:
Root cause
packages/knip/src/util/glob-cache.ts derives dirMtimes only from the previous glob matches:
captureDirMtimes(paths, baseDir)
During the cold run, the Vitest entry glob matches src/covered/existing.spec.ts, but nothing under src/target. Therefore src/target is absent from that cache entry's dirMtimes.
Adding src/target/new.spec.ts changes only the mtime of the previously unmatched src/target directory. All directories recorded for the entry glob still have matching mtimes, so the stale cached paths are returned.
The broader project glob does discover new.spec.ts. Knip consequently sees it as a project file but not as an entry and reports it as unused.
In the original repository cache, this was directly observable as two entries:
- Vitest/plugin-entry glob: the new spec and its parent directory were absent.
- Broader project glob: the new spec and parent directory were present.
Expected behavior
A warm cached run should discover files newly matching an entry glob and produce the same result as an uncached run.
Suggested regression test
- Cache
**/*.spec.ts with src/a/existing.spec.ts and a pre-existing src/b/component.ts.
- Add
src/b/component.spec.ts.
- Repeat the cached glob.
- Assert both specs are returned.
src/b must exist before the cold run without containing a matching file.
Experimentally verified diff
The following proof-of-cause patch fixed both the warm addition and deletion cases locally:
-import { dirname } from './path.ts';
+import { dirname, join } from './path.ts';
const captureDirMtimes = (paths: string[], baseDir: string): Record<string, number> => {
const dirs = new Set<string>();
- dirs.add(baseDir);
- for (const p of paths) {
- let d = dirname(p);
- while (d.length >= baseDir.length) {
- if (dirs.has(d)) break;
- dirs.add(d);
- const parent = dirname(d);
- if (parent === d) break;
- d = parent;
+ const pendingDirs = [baseDir];
+ while (pendingDirs.length > 0) {
+ const dir = pendingDirs.pop();
+ if (!dir || dirs.has(dir)) continue;
+ dirs.add(dir);
+ try {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
+ pendingDirs.push(join(dir, entry.name));
+ }
+ }
+ } catch {
+ // Directory disappeared while collecting cache metadata.
}
}
This recursively scans directories and may undermine cache performance, so it is probably not the ideal production implementation. A more efficient fix may be for the glob scanner to return the directories it actually traversed and store those mtimes. Temporarily disabling the disk glob-result cache would also avoid incorrect results.
Description
Knip can report a newly added Vitest spec as unused on a warm cache, while the same analysis without
--cachepasses.The reproduction was executed with Knip 6.26.0. I also checked the 6.27.0 source, where
captureDirMtimesretains the same implementation.I searched the existing issues and reviewed the cache documentation and known issues. A public reproduction URL is not available, so the complete minimal reproduction is included below.
Minimal reproduction
Create this workspace:
Root
package.json:{ "name": "knip-glob-cache-workspace", "private": true, "workspaces": ["apps/*"], "devDependencies": { "knip": "6.26.0" } }knip.json:{ "workspaces": { "apps/app": { "entry": ["scripts/*.*"] } } }apps/app/package.json:{ "name": "app", "private": true, "devDependencies": { "vitest": "4.1.10" } }apps/app/scripts/entry.ts:apps/app/src/covered/existing.spec.ts:apps/app/src/target/source.ts:Populate the cache:
This exits successfully. Then add
apps/app/src/target/new.spec.ts:Run the same cached command:
Actual result:
The uncached command exits successfully:
Root cause
packages/knip/src/util/glob-cache.tsderivesdirMtimesonly from the previous glob matches:During the cold run, the Vitest entry glob matches
src/covered/existing.spec.ts, but nothing undersrc/target. Thereforesrc/targetis absent from that cache entry'sdirMtimes.Adding
src/target/new.spec.tschanges only the mtime of the previously unmatchedsrc/targetdirectory. All directories recorded for the entry glob still have matching mtimes, so the stale cached paths are returned.The broader project glob does discover
new.spec.ts. Knip consequently sees it as a project file but not as an entry and reports it as unused.In the original repository cache, this was directly observable as two entries:
Expected behavior
A warm cached run should discover files newly matching an entry glob and produce the same result as an uncached run.
Suggested regression test
**/*.spec.tswithsrc/a/existing.spec.tsand a pre-existingsrc/b/component.ts.src/b/component.spec.ts.src/bmust exist before the cold run without containing a matching file.Experimentally verified diff
The following proof-of-cause patch fixed both the warm addition and deletion cases locally:
This recursively scans directories and may undermine cache performance, so it is probably not the ideal production implementation. A more efficient fix may be for the glob scanner to return the directories it actually traversed and store those mtimes. Temporarily disabling the disk glob-result cache would also avoid incorrect results.