fix(paths): a project at a Windows drive root indexes its files (#2931)

A project at the root of a drive, such as a mapped network drive Z:\, a
subst drive or a UNC share root, indexed nothing on Windows: every file
was refused as "Path traversal blocked", and code reads under the root
were refused too. The containment check appended a separator to the root
before comparing, and a root such as C:\ already ends with one, so the
prefix became C:\\ and no path under the root matched. The dead code
report's default file reader had its own copy of the same check, so it
treated every file of such a project as unreadable.

The check now adds a separator only when the root doesn't already end
with one. The dead code reader uses the shared lexical containment check
instead of its own copy.

Validation on Windows 11:
- subst R: onto a two-file fixture, then codegraph init -y --force R:\:
  main indexes 0 files ("all 2 files had errors", 2 "Path traversal
  blocked"), and validatePathWithinRoot(R:\, a.ts) is null. The fix
  indexes both files (6 nodes, 6 edges) and getCode returns both
  functions' source. The mapping was removed after each run.
- Dead code report against one R:\ index: main's reader drops the unused
  function as unreadable; the fix reports it.
- Tests: 3 new tests fail on main's source and pass with the fix: a file
  under the filesystem root (this drive's root here, / on POSIX), path
  math under Q:\ and \\server\share\, and read-only realpaths under C:\.

Reported in PR #415 by @Kamilslaoui93; this reimplements the fix
independently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-27 17:47:28 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0187366697
commit f93e89a82d
4 changed files with 56 additions and 17 deletions
+6 -4
View File
@@ -100,10 +100,10 @@
*/
import fs from 'fs';
import path from 'path';
import type CodeGraph from '../index';
import type { EdgeKind, Node, NodeKind } from '../types';
import { isTestFile } from '../search/query-utils';
import { lexicalPathWithinRoot } from '../utils';
// =============================================================================
// Caps and defaults
@@ -706,11 +706,13 @@ export function buildDeadCodeReport(cg: CodeGraph, query: DeadCodeQuery = {}): D
* on disk and this module should not be the thing that trusts it.
*/
function defaultSourceReader(cg: CodeGraph): (filePath: string) => string | null {
const root = path.resolve(cg.getProjectRoot());
const root = cg.getProjectRoot();
return (filePath: string): string | null => {
try {
const absolute = path.resolve(root, filePath);
if (absolute !== root && !absolute.startsWith(root + path.sep)) return null;
// The shared containment check, which also holds for a project at a
// drive root: appending a separator to `C:\` refused every file (#415).
const absolute = lexicalPathWithinRoot(root, filePath);
if (absolute === null) return null;
const stat = fs.statSync(absolute);
if (!stat.isFile() || stat.size > MAX_CORROBORATION_BYTES) return null;
return fs.readFileSync(absolute, 'utf8');
+14 -12
View File
@@ -82,19 +82,28 @@ export function isConfigLeafNode(node: { kind: string; language?: string }): boo
return node.kind === 'constant' && !!node.language && CONFIG_LEAF_LANGUAGES.has(node.language);
}
/**
* Whether `child` is `parent` or lies under it, compared exactly as given.
*
* A root (`C:\`, `\\server\share\`, `/`) already ends with its separator, so
* none is added to it. Adding one made `C:\\` the prefix, which no path under
* the root starts with, so a project at a drive root indexed nothing (#415).
*/
function isSameOrUnder(child: string, parent: string): boolean {
if (child === parent) return true;
return child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
}
/**
* Whether `child` is `parent` itself or sits underneath it. Case-insensitive on
* Windows — NTFS is case-insensitive, and realpathSync can hand back a different
* case than the lexical root, which would otherwise false-reject a valid file.
*/
function isWithinDir(child: string, parent: string): boolean {
let c = child;
let p = parent;
if (process.platform === 'win32') {
c = c.toLowerCase();
p = p.toLowerCase();
return isSameOrUnder(child.toLowerCase(), parent.toLowerCase());
}
return c === p || c.startsWith(p + path.sep);
return isSameOrUnder(child, parent);
}
/**
@@ -208,13 +217,6 @@ function foldPathCase(p: string): string {
return process.platform === 'win32' || process.platform === 'darwin' ? p.toLowerCase() : p;
}
/** Whether `child` is `parent` or lies under it. Both are already folded. */
function isSameOrUnder(child: string, parent: string): boolean {
if (child === parent) return true;
// A root (`C:\`, `/`) already ends with its separator.
return child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
}
/**
* The plain spelling of a Win32 namespace path: `\\?\C:\x` and `\\.\C:\x` are
* `C:\x`, and `\\?\UNC\host\share` is `\\host\share`. `path.resolve` keeps the