Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 99 additions & 3 deletions src/drift/checkers/path.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { globSync } from "glob";
import YAML from "yaml";
import type { Claim, DriftIssue } from "../../types.js";

const PLACEHOLDER_WORDS = /(?:^|[/_-])(?:new|example|your|sample|my|foo|bar|placeholder|template)(?:[/_.-]|$)/i;

/** Scoped package pattern: @scope/name or @scope/name/sub/path */
const SCOPED_PACKAGE = /^@([\w-]+)\/([\w-]+)(\/.*)?$/;

/** URLs are not filesystem paths */
const URL_PATTERN = /^(?:https?|ftp|file):\/\/|^\/\//;

/** Check that all claimed paths exist on disk */
export function checkPaths(
claims: Claim[],
Expand All @@ -16,8 +24,14 @@ export function checkPaths(
(c) => c.kind === "path" && !c.negated
);

// Collect workspace package names once for all claims
const workspaceNames = collectWorkspaceNames(projectRoot);

for (const claim of pathClaims) {
if (pathExists(claim.value, projectRoot, scaffoldRoot)) continue;
// URLs are never filesystem paths
if (URL_PATTERN.test(claim.value)) continue;

if (pathExists(claim.value, projectRoot, scaffoldRoot, workspaceNames)) continue;

// Downgrade to warning if: from a pattern file or path contains placeholder words.
// Bare filenames that aren't found even after recursive search are genuinely missing.
Expand All @@ -38,7 +52,70 @@ export function checkPaths(
return issues;
}

function pathExists(value: string, projectRoot: string, scaffoldRoot: string): boolean {
/**
* Collect the `name` field from each workspace's package.json.
* Reads the root `workspaces` field (npm, yarn, bun) or falls back to
* `pnpm-workspace.yaml` when that field is absent (pnpm monorepos).
*/
function collectWorkspaceNames(projectRoot: string): Set<string> {
const names = new Set<string>();
const patterns = collectWorkspacePatterns(projectRoot);

for (const pattern of patterns) {
const dirs = globSync(pattern, {
cwd: projectRoot,
ignore: ["node_modules/**"],
});
for (const dir of dirs) {
const pkgPath = resolve(projectRoot, dir, "package.json");
if (!existsSync(pkgPath)) continue;
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
if (pkg.name) names.add(pkg.name);
} catch {
// Skip malformed package.json
}
}
}

return names;
}

function collectWorkspacePatterns(projectRoot: string): string[] {
const rootPkgPath = resolve(projectRoot, "package.json");
if (existsSync(rootPkgPath)) {
try {
const rootPkg: { workspaces?: string[] | { packages?: string[] } } = JSON.parse(
readFileSync(rootPkgPath, "utf-8")
);
const patterns = Array.isArray(rootPkg.workspaces)
? rootPkg.workspaces
: rootPkg.workspaces?.packages ?? [];
if (patterns.length > 0) return patterns;
} catch {
// Fall through to pnpm-workspace.yaml
}
}

const pnpmWorkspacePath = resolve(projectRoot, "pnpm-workspace.yaml");
if (!existsSync(pnpmWorkspacePath)) return [];

try {
const doc = YAML.parse(readFileSync(pnpmWorkspacePath, "utf-8")) as {
packages?: string[];
} | null;
return Array.isArray(doc?.packages) ? doc.packages : [];
} catch {
return [];
}
}

function pathExists(
value: string,
projectRoot: string,
scaffoldRoot: string,
workspaceNames: Set<string>
): boolean {
// Try project root first (e.g. src/index.ts)
if (existsSync(resolve(projectRoot, value))) return true;

Expand All @@ -54,6 +131,25 @@ function pathExists(value: string, projectRoot: string, scaffoldRoot: string): b
if (existsSync(resolve(projectRoot, withoutPrefix))) return true;
}

// Resolve scoped package references (e.g. @acme/ui, @acme/shared/utils)
const scopedMatch = value.match(SCOPED_PACKAGE);
if (scopedMatch) {
const pkgName = `@${scopedMatch[1]}/${scopedMatch[2]}`;

// Try Node's module resolution first (works for installed npm packages)
try {
const req = createRequire(resolve(projectRoot, "noop.js"));
req.resolve(`${pkgName}/package.json`);
return true;
} catch {
// Fall through to workspace check
}

// Check workspace names (handles package managers that don't symlink
// all workspaces into node_modules, e.g. bun)
if (workspaceNames.has(pkgName)) return true;
}

// Bare filenames: search recursively — the file may exist in a subdirectory
if (!value.includes("/")) {
const matches = globSync(`**/${value}`, {
Expand Down
61 changes: 61 additions & 0 deletions test/checkers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,67 @@ describe("checkPaths", () => {
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe("error");
});

it("skips URL values instead of reporting missing paths", () => {
const claims = [
claim({ kind: "path", value: "https://example.com/docs" }),
claim({ kind: "path", value: "http://localhost:3000/api" }),
claim({ kind: "path", value: "ftp://files.example.com/readme.txt" }),
claim({ kind: "path", value: "file:///etc/hosts" }),
claim({ kind: "path", value: "//cdn.example.com/assets/app.js" }),
];
const issues = checkPaths(claims, tmpDir, tmpDir);
expect(issues).toHaveLength(0);
});

it("resolves workspace package aliases from package.json workspaces", () => {
mkdirSync(join(tmpDir, "packages/ui"), { recursive: true });
writeFileSync(
join(tmpDir, "package.json"),
JSON.stringify({ workspaces: ["packages/*"] })
);
writeFileSync(
join(tmpDir, "packages/ui/package.json"),
JSON.stringify({ name: "@acme/ui" })
);
const claims = [
claim({ kind: "path", value: "@acme/ui/button" }),
claim({ kind: "path", value: "@acme/ui" }),
];
const issues = checkPaths(claims, tmpDir, tmpDir);
expect(issues).toHaveLength(0);
});

it("resolves workspace package aliases from pnpm-workspace.yaml", () => {
mkdirSync(join(tmpDir, "packages/shared"), { recursive: true });
writeFileSync(join(tmpDir, "package.json"), JSON.stringify({ name: "root" }));
writeFileSync(
join(tmpDir, "pnpm-workspace.yaml"),
"packages:\n - 'packages/*'\n"
);
writeFileSync(
join(tmpDir, "packages/shared/package.json"),
JSON.stringify({ name: "@acme/shared" })
);
const claims = [claim({ kind: "path", value: "@acme/shared/types" })];
const issues = checkPaths(claims, tmpDir, tmpDir);
expect(issues).toHaveLength(0);
});

it("resolves installed scoped packages via require.resolve", () => {
const pkgDir = join(tmpDir, "node_modules/@scope/pkg");
mkdirSync(pkgDir, { recursive: true });
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({ name: "@scope/pkg", main: "index.js" })
);
writeFileSync(join(pkgDir, "index.js"), "module.exports = {};\n");
writeFileSync(join(tmpDir, "package.json"), JSON.stringify({ name: "test-root" }));

const claims = [claim({ kind: "path", value: "@scope/pkg/lib" })];
const issues = checkPaths(claims, tmpDir, tmpDir);
expect(issues).toHaveLength(0);
});
});

// ── Edges Checker ──
Expand Down
Loading