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
5 changes: 4 additions & 1 deletion packages/lint-staged-config/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ describe(defineConfig, () => {
});
findPackageManagerSyncMock.mockReturnValue({ packageManager: "npm" });

// "md" is appended because neither markdownlint CLI is installed in this fixture. The
// expectation used to read `eslintExtensions` directly and passed only because the
// implementation mutated that exported array in place.
expect(defineConfig()).toStrictEqual({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.any returns any
[`**/*.{${eslintExtensions.join(",")}}`]: expect.any(Function),
[`**/*.{${[...eslintExtensions, "md"].join(",")}}`]: expect.any(Function),
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { hasPackageJsonAnyDependency } from "@visulima/package";
import { ESLint } from "eslint";

import type { EslintConfig } from "../types";
import getNearestConfigPath from "../utils/get-nearest-config-path";
import getNearestConfigPath, { isAbsolutePath } from "../utils/get-nearest-config-path";
import groupFilePathsByDirectoryName from "./group-file-paths-by-directory-name";
import removeIgnoredFiles from "./remove-ignored-files";

Expand Down Expand Up @@ -109,11 +109,13 @@ const createEslintCommands = async (
}

try {
config = getNearestConfigPath(
configName,
// eslint-disable-next-line no-template-curly-in-string
filePaths[0] as "/${string}",
);
const [firstPath] = filePaths;

if (firstPath === undefined || !isAbsolutePath(firstPath)) {
return;
}

config = getNearestConfigPath(configName, firstPath);
} catch {
// Ignore
}
Expand Down
73 changes: 38 additions & 35 deletions packages/lint-staged-config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Configuration } from "lint-staged";

import type { EslintConfig } from "./types";
import concatFiles from "./utils/concat-files";
import getNearestConfigPath from "./utils/get-nearest-config-path";
import getNearestConfigPath, { isAbsolutePath } from "./utils/get-nearest-config-path";

interface StylesheetsConfig {
extensions?: (typeof stylesheetsExtensions)[number][];
Expand Down Expand Up @@ -34,18 +34,17 @@ export const defineConfig = (
} = {},
// eslint-disable-next-line sonarjs/cognitive-complexity
): Configuration => {
// Copies, not the exported tuples themselves: the ESLint branch below pushes "md" onto this
// array, which would otherwise mutate the shared constant for every subsequent call.
const defaultEslint: EslintConfig = { extensions: [...eslintExtensions] };
const defaultStylesheets: StylesheetsConfig = { extensions: [...stylesheetsExtensions] };
const defaultTypescript: TypescriptConfig = { exclude: [], extensions: [...typescriptExtensions] };

const config = {
debug: false,
eslint: {
extensions: eslintExtensions,
},
stylesheets: {
extensions: stylesheetsExtensions,
},
typescript: {
exclude: [],
extensions: typescriptExtensions,
},
eslint: defaultEslint,
stylesheets: defaultStylesheets,
typescript: defaultTypescript,
...options,
};
const cwd = config.cwd ?? process.cwd();
Expand All @@ -69,23 +68,29 @@ export const defineConfig = (

const hasPrettier = hasPackageJsonAnyDependency(packageJson, ["prettier"]);

// `Extract` here selects lint-staged's object task-map from its config union; it is a union
// filter rather than a dictionary contract, which is what the rule is guarding against.
// eslint-disable-next-line @typescript-eslint/no-restricted-types -- see comment above
let loadedPlugins: Extract<Configuration, Record<string, unknown>> = {};

if (config.eslint !== false && hasPackageJsonAnyDependency(packageJson, ["eslint"])) {
if (!Array.isArray((config.eslint as EslintConfig).extensions) || ((config.eslint as EslintConfig).extensions as string[]).length === 0) {
const eslintConfig: EslintConfig = config.eslint;
const { extensions } = eslintConfig;

if (!Array.isArray(extensions) || extensions.length === 0) {
throw new Error("The `extensions` option is required for the ESLint configuration.");
}

if (!hasMarkdownCli && !hasMarkdownCli2) {
((config.eslint as EslintConfig).extensions as string[]).push("md");
extensions.push("md");
}

loadedPlugins[`**/*.{${((config.eslint as EslintConfig).extensions as string[]).join(",")}}`] = async (filenames: ReadonlyArray<string>) => {
loadedPlugins[`**/*.{${extensions.join(",")}}`] = async (filenames: ReadonlyArray<string>) => {
const { default: createEslintCommands } = await import("./eslint/create-eslint-commands");

return [
...(hasPrettier ? [`${packageManager} exec prettier --write ${concatFiles(filenames)}`] : []),
...(await createEslintCommands(packageManager, packageJson, config.eslint as EslintConfig, filenames)),
...(await createEslintCommands(packageManager, packageJson, eslintConfig, filenames)),
];
};
}
Expand Down Expand Up @@ -113,47 +118,45 @@ export const defineConfig = (
}

if (config.stylesheets !== false && hasPackageJsonAnyDependency(packageJson, ["stylelint"])) {
if (
!Array.isArray((config.stylesheets as StylesheetsConfig).extensions) ||
((config.stylesheets as StylesheetsConfig).extensions as string[]).length === 0
) {
const stylesheetsConfig: StylesheetsConfig = config.stylesheets;
const { extensions } = stylesheetsConfig;

if (!Array.isArray(extensions) || extensions.length === 0) {
throw new Error("The `extensions` option is required for the Stylesheets configuration.");
}

loadedPlugins[`**/*.{${((config.stylesheets as StylesheetsConfig).extensions as string[]).join(",")}}`] = (filenames: ReadonlyArray<string>) => [
loadedPlugins[`**/*.{${extensions.join(",")}}`] = (filenames: ReadonlyArray<string>) => [
...(hasPrettier ? [`${packageManager} exec prettier --ignore-unknown --write ${concatFiles(filenames)}`] : []),
`${packageManager} exec stylelint --fix`,
];
}

if (config.typescript !== false && hasPackageJsonAnyDependency(packageJson, ["typescript"])) {
if (
!Array.isArray((config.typescript as TypescriptConfig).extensions) ||
((config.typescript as TypescriptConfig).extensions as string[]).length === 0
) {
const typescriptConfig: TypescriptConfig = config.typescript;
const { extensions } = typescriptConfig;

if (!Array.isArray(extensions) || extensions.length === 0) {
throw new Error("The `extensions` option is required for the TypeScript configuration.");
}

loadedPlugins[`**/*.{${((config.typescript as TypescriptConfig).extensions as string[]).join(",")}}`] = (
loadedPlugins[`**/*.{${extensions.join(",")}}`] = (
filenames: ReadonlyArray<string>,
): string[] => {
const commands = new Set<string>();

filenames.forEach((filePath) => {
if (typeof (config.typescript as TypescriptConfig).exclude === "object" && Array.isArray((config.typescript as TypescriptConfig).exclude)) {
const isExcluded = ((config.typescript as TypescriptConfig).exclude as string[]).some((value) => filePath.includes(value));
const { exclude } = typescriptConfig;

if (isExcluded) {
return;
}
if (Array.isArray(exclude) && exclude.some((value) => filePath.includes(value))) {
return;
}

if (!isAbsolutePath(filePath)) {
return;
}

try {
const tsconfigPath = getNearestConfigPath(
"tsconfig.json",
// eslint-disable-next-line no-template-curly-in-string
filePath as "/${string}",
) as string;
const tsconfigPath = getNearestConfigPath("tsconfig.json", filePath);

commands.add(`${packageManager} exec tsc --noEmit --project ${tsconfigPath}`);
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,21 @@ const packageDirectorySync = (cwd?: string) => {
return filePath && dirname(filePath);
};

/**
* Narrows a string to an absolute path.
*
* `startsWith` does not narrow a `string` to the `` `/${string}` `` template literal type on its
* own, so the check is expressed as a predicate rather than asserted away at each call site.
* @param value Any path-like string.
* @returns True when the path is absolute.
*/
const isAbsolutePath = (value: string): value is AbsolutePath => value.startsWith("/");

const getNearestPackageRootPath = (cwd?: string): AbsolutePath => {
const packageDirectoryPath = packageDirectorySync(cwd ?? process.cwd());

if (packageDirectoryPath?.startsWith("/")) {
return packageDirectoryPath as AbsolutePath;
if (packageDirectoryPath !== undefined && isAbsolutePath(packageDirectoryPath)) {
return packageDirectoryPath;
}

throw new Error(`Cannot determine the nearest root of the package for the file: ${cwd ?? "unknown"}!`);
Expand All @@ -24,7 +34,9 @@ const getNearestPackageRootPath = (cwd?: string): AbsolutePath => {
const joinPaths = <T extends ReadonlyArray<string>>(paths: T): Join<T, "/"> => {
const joined = join(...paths);

if (joined.startsWith("/")) {
if (isAbsolutePath(joined)) {
// `Join` collapses to a template literal type the checker cannot derive from `path.join`.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see comment above
return joined as Join<T, "/">;
}

Expand All @@ -33,6 +45,10 @@ const joinPaths = <T extends ReadonlyArray<string>>(paths: T): Join<T, "/"> => {

const getNearestConfigPath = <N extends string = string, A extends AbsolutePath = AbsolutePath>(fileName: N, cwd?: A): ConfigPath<A, N> => {
const packageRootPath = getNearestPackageRootPath(cwd);

// The package root is an ancestor of `cwd`, so it cannot be proven to be the same `A`. The
// generic overstates what this function knows; narrowing it would change the public signature.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see comment above
const configPath = joinPaths<[A, N]>([packageRootPath as A, fileName]);

if (isAccessibleSync(configPath)) {
Expand All @@ -42,4 +58,5 @@ const getNearestConfigPath = <N extends string = string, A extends AbsolutePath
throw new Error(`Cannot locate nearest "${fileName}" file!`);
};

export { isAbsolutePath };
export default getNearestConfigPath;
Loading
Loading