Skip to content

🐛 --cache misses new entry files in previously unmatched directories #1897

Description

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:

export {};

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:

export {};

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:

knip --files

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

  1. Cache **/*.spec.ts with src/a/existing.spec.ts and a pre-existing src/b/component.ts.
  2. Add src/b/component.spec.ts.
  3. Repeat the cached glob.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions