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
8 changes: 3 additions & 5 deletions @commitlint/load/src/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,9 @@ export default async function load(
const deduplicatedPlugins = [...new Set(extended.plugins)];
for (const plugin of deduplicatedPlugins) {
if (typeof plugin === "string") {
plugins = await loadPlugin(
plugins,
plugin,
process.env.DEBUG === "true",
);
plugins = await loadPlugin(plugins, plugin, {
debug: process.env.DEBUG === "true",
});
Comment thread
escapedcat marked this conversation as resolved.
} else {
plugins.local = plugin;
}
Expand Down
68 changes: 68 additions & 0 deletions @commitlint/load/src/utils/load-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { test, expect, vi } from "vitest";
import { AsyncRule, Plugin, Rule, SyncRule } from "@commitlint/types";
import path from "node:path";
import os from "node:os";

import loadPlugin from "./load-plugin.js";
import { resolveFromNpxCache } from "@commitlint/resolve-extends";

vi.mock("@commitlint/resolve-extends", () => ({
resolveFromNpxCache: vi.fn(() => undefined),
}));

vi.mock("commitlint-plugin-example", () => ({ example: true }));

Expand Down Expand Up @@ -114,3 +121,64 @@ test("should load a scoped plugin when referenced by long name, but should not g
const plugins = await loadPlugin({}, "@scope/commitlint-plugin-example");
expect(plugins["example"]).toBeUndefined();
});

test("should load plugin from npx cache when available", async () => {
vi.mocked(resolveFromNpxCache).mockReturnValueOnce(
path.join(
os.tmpdir(),
"npx-cache",
"node_modules",
"commitlint-plugin-example",
),
);

vi.mock("commitlint-plugin-example", () => ({ example: true }));

const plugins = await loadPlugin({}, "example");
expect(vi.mocked(resolveFromNpxCache)).toHaveBeenCalledWith(
"commitlint-plugin-example",
);
expect(plugins["example"]).toBeDefined();
Comment thread
escapedcat marked this conversation as resolved.
});

test("should accept boolean as third parameter for backward compatibility", async () => {
const plugins = await loadPlugin({}, "example", true);
expect(plugins["example"]).toBeDefined();
});

test("should throw when searchPath is not a string", async () => {
await expect(
loadPlugin({}, "example", { searchPaths: [123 as any] }),
).rejects.toThrow('Invalid searchPath "123": must be an absolute path');
});

test("should throw when searchPath is not absolute", async () => {
await expect(
loadPlugin({}, "example", { searchPaths: ["./relative/path"] }),
).rejects.toThrow(
'Invalid searchPath "./relative/path": must be an absolute path',
);
});

test("should throw when searchPath does not exist", async () => {
await expect(
loadPlugin({}, "example", { searchPaths: ["/nonexistent/path"] }),
).rejects.toThrow(
'Invalid searchPath "/nonexistent/path": directory does not exist',
);
});

test("should throw when searchPath is a file not a directory", async () => {
const tempFile = path.join(os.tmpdir(), "test-file.txt");
await import("node:fs/promises").then((fs) => fs.writeFile(tempFile, "test"));

try {
await expect(
loadPlugin({}, "example", { searchPaths: [tempFile] }),
).rejects.toThrow(
`Invalid searchPath "${tempFile}": must be a directory, not a file`,
);
} finally {
await import("node:fs/promises").then((fs) => fs.unlink(tempFile));
}
});
180 changes: 152 additions & 28 deletions @commitlint/load/src/utils/load-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createRequire } from "node:module";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

Expand All @@ -7,6 +8,7 @@ import pc from "picocolors";

import { normalizePackageName, getShorthandName } from "./plugin-naming.js";
import { WhitespacePluginError, MissingPluginError } from "./plugin-errors.js";
import { resolveFromNpxCache } from "@commitlint/resolve-extends";

const require = createRequire(import.meta.url);

Expand All @@ -19,14 +21,67 @@ const dynamicImport = async <T>(id: string): Promise<T> => {
return ("default" in imported && imported.default) || imported;
};

function sanitizeErrorMessage(message: string): string {
return message
.replace(/\/[^/]+\/node_modules/g, "...")
.replace(/\\[^\\]+\\node_modules/g, "...");
}

function findPackageJson(dir: string): string | null {
let current = dir;
const root = path.parse(dir).root;
while (current !== root) {
const pkgPath = path.join(current, "package.json");
if (fs.existsSync(pkgPath)) {
return pkgPath;
}
current = path.dirname(current);
}
return null;
}

export interface LoadPluginOptions {
debug?: boolean;
searchPaths?: string[];
}

function normalizeOptions(
options: LoadPluginOptions | boolean,
): LoadPluginOptions {
if (typeof options === "boolean") {
return { debug: options };
}
return options;
}

export default async function loadPlugin(
plugins: PluginRecords,
pluginName: string,
debug: boolean = false,
options: LoadPluginOptions | boolean = {},
): Promise<PluginRecords> {
const normalized = normalizeOptions(options);
const { debug = false, searchPaths = [] } = normalized;

for (const searchPath of searchPaths) {
if (typeof searchPath !== "string" || !path.isAbsolute(searchPath)) {
throw new Error(
`Invalid searchPath "${searchPath}": must be an absolute path`,
);
}
if (!fs.existsSync(searchPath)) {
throw new Error(
`Invalid searchPath "${searchPath}": directory does not exist`,
);
}
Comment thread
escapedcat marked this conversation as resolved.
if (!fs.statSync(searchPath).isDirectory()) {
throw new Error(
`Invalid searchPath "${searchPath}": must be a directory, not a file`,
);
}
}

const longName = normalizePackageName(pluginName);
const shortName = getShorthandName(longName);
let plugin: Plugin;

if (pluginName.match(/\s+/u)) {
throw new WhitespacePluginError(pluginName, {
Expand All @@ -37,51 +92,120 @@ export default async function loadPlugin(
const pluginKey = longName === pluginName ? shortName : pluginName;

if (!plugins[pluginKey]) {
try {
plugin = await dynamicImport<Plugin>(longName);
} catch (pluginLoadErr) {
let plugin: Plugin | undefined;
let resolvedPath: string | undefined;

Comment thread
escapedcat marked this conversation as resolved.
// Try to load from npx cache directories using require.resolve
const npxResolvedPath = resolveFromNpxCache(longName);
if (npxResolvedPath) {
try {
// Check whether the plugin exists
require.resolve(longName);
} catch (error: any) {
// If the plugin can't be resolved, display the missing plugin error (usually a config or install error)
console.error(pc.red(`Failed to load plugin ${longName}.`));

const message = error?.message || "Unknown error occurred";
throw new MissingPluginError(pluginName, message, {
pluginName: longName,
commitlintPath: path.resolve(__dirname, "../.."),
});
plugin = await dynamicImport<Plugin>(npxResolvedPath);
Comment thread
escapedcat marked this conversation as resolved.
resolvedPath = npxResolvedPath;
} catch (err) {
if (debug) {
console.debug(
`Failed to load plugin ${longName} from npx cache: ${(err as Error).message}`,
);
}
}
}

// Try to load from additional search paths (extended config's node_modules)
if (!plugin) {
for (const searchPath of searchPaths) {
try {
resolvedPath = require.resolve(longName, { paths: [searchPath] });
Comment thread
escapedcat marked this conversation as resolved.
plugin = await dynamicImport<Plugin>(resolvedPath);
break;
} catch (err) {
if (debug) {
console.debug(
`Failed to load plugin ${longName} from ${searchPath}: ${(err as Error).message}`,
);
}
}
}
}
Comment thread
escapedcat marked this conversation as resolved.

// Otherwise, the plugin exists and is throwing on module load for some reason, so print the stack trace.
throw pluginLoadErr;
// Try default resolution as last resort
if (!plugin) {
try {
plugin = await dynamicImport<Plugin>(longName);
// Try to resolve path for debug logging
try {
resolvedPath = require.resolve(longName);
} catch {
// Ignore - path not critical
}
} catch (err) {
let resolutionError: Error | undefined;
try {
resolvedPath = require.resolve(longName);
} catch (resolveErr) {
resolutionError = resolveErr as Error;
}

if (resolutionError) {
// Resolution failed - throw MissingPluginError
if (debug) {
console.debug(
`Failed to resolve plugin ${longName}: ${resolutionError.message}`,
);
}
throw new MissingPluginError(
pluginName,
sanitizeErrorMessage(resolutionError.message),
{
pluginName: longName,
commitlintPath: path.resolve(__dirname, "../.."),
},
);
}
Comment thread
escapedcat marked this conversation as resolved.

// Resolution succeeded but import failed - rethrow original error
throw err;
}
}

// This step is costly, so skip if debug is disabled
if (debug) {
const resolvedPath = require.resolve(longName);

let version = null;

try {
version = require(`${longName}/package.json`).version;
} catch (e) {
// Do nothing
let version: string | null = null;

if (resolvedPath) {
try {
const pkgPath = findPackageJson(path.dirname(resolvedPath));
if (pkgPath) {
version = require(pkgPath).version;
}
} catch {
// Do nothing
Comment thread
escapedcat marked this conversation as resolved.
}
}

const loadedPluginAndVersion = version
? `${longName}@${version}`
: `${longName}, version unknown`;

const fromPath = resolvedPath ? ` (from ${resolvedPath})` : "";
console.log(
pc.blue(
`Loaded plugin ${pluginName} (${loadedPluginAndVersion}) (from ${resolvedPath})`,
`Loaded plugin ${pluginName} (${loadedPluginAndVersion})${fromPath}`,
),
);
}

plugins[pluginKey] = plugin;
if (plugin) {
plugins[pluginKey] = plugin;
} else {
throw new MissingPluginError(
pluginName,
"Plugin loaded but is undefined",
{
pluginName: longName,
commitlintPath: path.resolve(__dirname, "../.."),
},
);
}
}

return plugins;
Expand Down
Loading