From 8ff3fa6303208bfed81073fe21972877b7f99e5b Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sun, 19 Jan 2025 02:08:01 +0100 Subject: [PATCH 01/21] Implement auto-discovery for plugins --- .../kitchen-sink-of-fsd-issues/package.json | 6 + packages/steiger/package.json | 13 +- packages/steiger/src/cli.ts | 87 +++++--- .../choose-root-folder/choose-from-guesses.ts | 2 +- .../choose-root-folder/choose-from-similar.ts | 2 +- .../choose-root-folder/exit-exception.ts | 1 - .../src/features/choose-root-folder/index.ts | 1 - .../discover-plugins/discover-plugins.ts | 60 ++++++ .../src/features/discover-plugins/index.ts | 2 + .../discover-plugins/is-steiger-plugin.ts | 24 +++ .../discover-plugins/parse-package.ts | 8 + .../parse-plugin-default-export.ts | 10 + .../discover-plugins/suggest-fsd-plugin.ts | 104 +++++++++ packages/steiger/src/models/config/index.ts | 3 + .../models/config/schemas/config-object.ts | 12 ++ .../models/config/schemas/global-ignore.ts | 7 + .../src/models/config/schemas/plugin.ts | 23 ++ .../src/models/config/validate-config.ts | 64 +----- packages/steiger/src/shared/exit-exception.ts | 18 ++ .../src/shared/package-manager/index.ts | 2 + .../package-manager/package-managers.ts | 1 + .../package-manager/which-lockfile-exists.ts | 19 ++ .../shared/package-manager/which-pm-runs.ts | 27 +++ pnpm-lock.yaml | 199 ++++++++++++++++-- tooling/eslint-config/eslint.config.mjs | 3 +- 25 files changed, 594 insertions(+), 104 deletions(-) create mode 100644 examples/kitchen-sink-of-fsd-issues/package.json delete mode 100644 packages/steiger/src/features/choose-root-folder/exit-exception.ts create mode 100644 packages/steiger/src/features/discover-plugins/discover-plugins.ts create mode 100644 packages/steiger/src/features/discover-plugins/index.ts create mode 100644 packages/steiger/src/features/discover-plugins/is-steiger-plugin.ts create mode 100644 packages/steiger/src/features/discover-plugins/parse-package.ts create mode 100644 packages/steiger/src/features/discover-plugins/parse-plugin-default-export.ts create mode 100644 packages/steiger/src/features/discover-plugins/suggest-fsd-plugin.ts create mode 100644 packages/steiger/src/models/config/schemas/config-object.ts create mode 100644 packages/steiger/src/models/config/schemas/global-ignore.ts create mode 100644 packages/steiger/src/models/config/schemas/plugin.ts create mode 100644 packages/steiger/src/shared/exit-exception.ts create mode 100644 packages/steiger/src/shared/package-manager/index.ts create mode 100644 packages/steiger/src/shared/package-manager/package-managers.ts create mode 100644 packages/steiger/src/shared/package-manager/which-lockfile-exists.ts create mode 100644 packages/steiger/src/shared/package-manager/which-pm-runs.ts diff --git a/examples/kitchen-sink-of-fsd-issues/package.json b/examples/kitchen-sink-of-fsd-issues/package.json new file mode 100644 index 00000000..0f331851 --- /dev/null +++ b/examples/kitchen-sink-of-fsd-issues/package.json @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "steiger": "file:../../packages/steiger/dist", + "@feature-sliced/steiger-plugin": "file:../../packages/steiger-plugin-fsd/dist" + } +} diff --git a/packages/steiger/package.json b/packages/steiger/package.json index b60de18d..95e1e43c 100644 --- a/packages/steiger/package.json +++ b/packages/steiger/package.json @@ -40,8 +40,7 @@ "README.md" ], "dependencies": { - "@clack/prompts": "^0.8.2", - "@feature-sliced/steiger-plugin": "workspace:*", + "@clack/prompts": "^0.9.0", "chokidar": "^4.0.1", "cosmiconfig": "^9.0.0", "effector": "^23.2.3", @@ -51,9 +50,12 @@ "immer": "^10.1.1", "lodash-es": "^4.17.21", "minimatch": "^10.0.1", + "oxc-resolver": "^3.0.3", "patronum": "^2.3.0", "picocolors": "^1.1.1", "prexit": "^2.3.0", + "terminal-link": "^3.0.0", + "tinyexec": "^0.3.1", "yargs": "^17.7.2", "zod": "^3.24.0", "zod-validation-error": "^3.4.0" @@ -72,5 +74,12 @@ "tsx": "^4.19.2", "typescript": "^5.7.2", "vitest": "^3.0.0-beta.2" + }, + "repository": { + "type": "git", + "url": "https://github.com/feature-sliced/steiger.git" + }, + "bugs": { + "url": "https://github.com/feature-sliced/steiger/issues" } } diff --git a/packages/steiger/src/cli.ts b/packages/steiger/src/cli.ts index dde53a07..a1b92d75 100755 --- a/packages/steiger/src/cli.ts +++ b/packages/steiger/src/cli.ts @@ -8,30 +8,65 @@ import { hideBin } from 'yargs/helpers' import { reportPretty } from '@steiger/pretty-reporter' import { fromError } from 'zod-validation-error' import { cosmiconfig } from 'cosmiconfig' +import type { Diagnostic } from '@steiger/types' import { linter } from './app' import { processConfiguration, $plugins } from './models/config' import { applyAutofixes } from './features/autofix' -import { chooseRootFolderFromGuesses, chooseRootFolderFromSimilar, ExitException } from './features/choose-root-folder' -import fsd from '@feature-sliced/steiger-plugin' -import type { Diagnostic } from '@steiger/types' +import { chooseRootFolderFromGuesses, chooseRootFolderFromSimilar } from './features/choose-root-folder' +import { discoverPlugins, suggestInstallingFsdPlugin } from './features/discover-plugins' +import { handleExitRequest } from './shared/exit-exception' import packageJson from '../package.json' import { existsAndIsFolder } from './shared/file-system' -const { config, filepath } = (await cosmiconfig('steiger').search()) ?? { config: null, filepath: undefined } -const defaultConfig = fsd.configs.recommended +const { config, filepath } = (await cosmiconfig('steiger').search()) ?? { config: null, filepath: null } -try { - const configLocationDirectory = filepath ? dirname(filepath) : null - // use FSD recommended config as a default - processConfiguration(config || defaultConfig, configLocationDirectory) -} catch (err) { - if (filepath !== undefined) { +if (config !== null && filepath !== null) { + const configLocationDirectory = dirname(filepath) + try { + processConfiguration(config, configLocationDirectory) + } catch (err) { console.error( fromError(err, { prefix: `Invalid configuration in ${relative(process.cwd(), filepath)}` }).toString(), ) process.exit(100) } +} else { + let installedPlugins = await discoverPlugins() + if (installedPlugins.length === 0) { + try { + await handleExitRequest(suggestInstallingFsdPlugin, { exitCode: 0 }) + } catch { + // In this case, the error message is already printed, we just need to exit + process.exit(102) + } + installedPlugins = await discoverPlugins() + + if (installedPlugins.length === 0) { + console.error( + "Sorry, I tried to add the FSD plugin, but it didn't work :(\n" + + `Please report this case to ${packageJson.bugs.url}`, + ) + process.exit(101) + } + } + + try { + processConfiguration( + installedPlugins + .map((plugin) => plugin.autoConfig) + .filter(Boolean) + .flat(), + null, + ) + } catch (err) { + console.error( + fromError(err, { + prefix: `Failed to auto-construct a configuration from plugins ${installedPlugins.map(({ plugin }) => `"${plugin.meta.name}"`).join(', ')}`, + }).toString(), + ) + process.exit(103) + } } const yargsProgram = yargs(hideBin(process.argv)) @@ -93,26 +128,20 @@ if (inputPaths.length > 0) { if (await existsAndIsFolder(inputPaths[0])) { targetPath = resolve(inputPaths[0]) } else { - try { - targetPath = resolve(await chooseRootFolderFromSimilar(inputPaths[0])) - } catch (e) { - if (e instanceof ExitException) { - process.exit(0) - } else { - throw e - } - } + await handleExitRequest( + async () => { + targetPath = resolve(await chooseRootFolderFromSimilar(inputPaths[0])) + }, + { exitCode: 0 }, + ) } } else { - try { - targetPath = resolve(await chooseRootFolderFromGuesses()) - } catch (e) { - if (e instanceof ExitException) { - process.exit(0) - } else { - throw e - } - } + await handleExitRequest( + async () => { + targetPath = resolve(await chooseRootFolderFromGuesses()) + }, + { exitCode: 0 }, + ) } const printDiagnostics = (diagnostics: Array) => { diff --git a/packages/steiger/src/features/choose-root-folder/choose-from-guesses.ts b/packages/steiger/src/features/choose-root-folder/choose-from-guesses.ts index 3bf73deb..145430bd 100644 --- a/packages/steiger/src/features/choose-root-folder/choose-from-guesses.ts +++ b/packages/steiger/src/features/choose-root-folder/choose-from-guesses.ts @@ -1,9 +1,9 @@ import { sep } from 'node:path' import { confirm, isCancel, outro, select } from '@clack/prompts' -import { ExitException } from './exit-exception' import { formatCommand } from './format-command' import { existsAndIsFolder } from '../../shared/file-system' +import { ExitException } from '../../shared/exit-exception' const commonRootFolders = ['src', 'app'].map((folder) => `.${sep}${folder}`) diff --git a/packages/steiger/src/features/choose-root-folder/choose-from-similar.ts b/packages/steiger/src/features/choose-root-folder/choose-from-similar.ts index db188595..6d911b89 100644 --- a/packages/steiger/src/features/choose-root-folder/choose-from-similar.ts +++ b/packages/steiger/src/features/choose-root-folder/choose-from-similar.ts @@ -7,7 +7,7 @@ import * as find from 'empathic/find' import { distance } from 'fastest-levenshtein' import { isCancel, outro, select, confirm } from '@clack/prompts' import { formatCommand } from './format-command' -import { ExitException } from './exit-exception' +import { ExitException } from '../../shared/exit-exception' /** The maximum Levenshtein distance between the input and the reference for the input to be considered a typo. */ const typoThreshold = 5 diff --git a/packages/steiger/src/features/choose-root-folder/exit-exception.ts b/packages/steiger/src/features/choose-root-folder/exit-exception.ts deleted file mode 100644 index 81007c92..00000000 --- a/packages/steiger/src/features/choose-root-folder/exit-exception.ts +++ /dev/null @@ -1 +0,0 @@ -export class ExitException extends Error {} diff --git a/packages/steiger/src/features/choose-root-folder/index.ts b/packages/steiger/src/features/choose-root-folder/index.ts index 74886775..5a8a40e2 100644 --- a/packages/steiger/src/features/choose-root-folder/index.ts +++ b/packages/steiger/src/features/choose-root-folder/index.ts @@ -1,3 +1,2 @@ export { chooseFromGuesses as chooseRootFolderFromGuesses } from './choose-from-guesses' export { chooseFromSimilar as chooseRootFolderFromSimilar } from './choose-from-similar' -export { ExitException } from './exit-exception' diff --git a/packages/steiger/src/features/discover-plugins/discover-plugins.ts b/packages/steiger/src/features/discover-plugins/discover-plugins.ts new file mode 100644 index 00000000..e2a52991 --- /dev/null +++ b/packages/steiger/src/features/discover-plugins/discover-plugins.ts @@ -0,0 +1,60 @@ +import { readFile } from 'node:fs/promises' +import process from 'node:process' +import * as pkg from 'empathic/package' +import { ResolverFactory } from 'oxc-resolver' +import type { Plugin, Config, Rule } from '@steiger/types' + +import { parsePackage } from './parse-package' +import { isSteigerPlugin } from './is-steiger-plugin' +import { parsePluginDefaultExport } from './parse-plugin-default-export' + +const resolve = new ResolverFactory({ + conditionNames: ['node', 'import'], +}) + +/** + * Locate the nearest package.json and search for packages whose names adhere to the naming convention for Steiger plugins. + * + * For each plugin, returns the plugin object and the optimal configuration, if any. + */ +export async function discoverPlugins(): Promise< + Array<{ plugin: Plugin; autoConfig: Config> | undefined }> +> { + const packageJsonPath = pkg.up() + + if (packageJsonPath === undefined) { + return [] + } + + try { + const packageJson = await readFile(packageJsonPath, { encoding: 'utf-8' }).then(JSON.parse).then(parsePackage) + + const pluginNames = Object.keys(packageJson.dependencies ?? {}) + .concat(Object.keys(packageJson.devDependencies ?? {})) + .filter(isSteigerPlugin) + + return Promise.all( + pluginNames.map(async (pluginName) => { + const pluginIndex = await resolve.async(process.cwd(), pluginName) + if (pluginIndex.path === undefined) { + throw new Error(`Could not resolve plugin ${pluginName}`) + } + const pluginExports = await import(pluginIndex.path) + const { plugin, configs } = await parsePluginDefaultExport(pluginExports.default) + let autoConfig: Config> | undefined + if ('recommended' in configs) { + autoConfig = configs.recommended + } else { + const configNames = Object.keys(configs) + if (configNames.length === 1) { + autoConfig = configs[configNames[0]] + } + } + + return { plugin, autoConfig } + }), + ) + } catch { + return [] + } +} diff --git a/packages/steiger/src/features/discover-plugins/index.ts b/packages/steiger/src/features/discover-plugins/index.ts new file mode 100644 index 00000000..af000341 --- /dev/null +++ b/packages/steiger/src/features/discover-plugins/index.ts @@ -0,0 +1,2 @@ +export { discoverPlugins } from './discover-plugins' +export { suggestInstallingFsdPlugin } from './suggest-fsd-plugin' diff --git a/packages/steiger/src/features/discover-plugins/is-steiger-plugin.ts b/packages/steiger/src/features/discover-plugins/is-steiger-plugin.ts new file mode 100644 index 00000000..8e3619c3 --- /dev/null +++ b/packages/steiger/src/features/discover-plugins/is-steiger-plugin.ts @@ -0,0 +1,24 @@ +export const pluginNamePrefix = 'steiger-plugin' + +/** + * Checks if a package name corresponds to Steiger plugin naming conventions. + * + * The conventions are identical to those of ESLint plugins: + * - If the package name is scoped, the name must be `@/steiger-plugin-` or simply `@/steiger-plugin`. + * - If the package name is not scoped, the name must be `steiger-plugin-`. + * + * @example + * isSteigerPlugin('@someone/steiger-plugin-foo') // true + * isSteigerPlugin('steiger-plugin-bar') // true + * isSteigerPlugin('@someone-else/steiger-plugin') // true + * isSteigerPlugin('plugin-foo') // false + * isSteigerPlugin('steiger-foo') // false + */ +export function isSteigerPlugin(packageName: string) { + if (packageName.includes('/')) { + const [_scope, name] = packageName.split('/') + return name.startsWith(pluginNamePrefix) + } else { + return packageName.startsWith(`${pluginNamePrefix}-`) + } +} diff --git a/packages/steiger/src/features/discover-plugins/parse-package.ts b/packages/steiger/src/features/discover-plugins/parse-package.ts new file mode 100644 index 00000000..1827c567 --- /dev/null +++ b/packages/steiger/src/features/discover-plugins/parse-package.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +const partialPackageJsonSchema = z.object({ + dependencies: z.optional(z.record(z.string())), + devDependencies: z.optional(z.record(z.string())), +}) + +export const parsePackage = partialPackageJsonSchema.parseAsync diff --git a/packages/steiger/src/features/discover-plugins/parse-plugin-default-export.ts b/packages/steiger/src/features/discover-plugins/parse-plugin-default-export.ts new file mode 100644 index 00000000..855190c8 --- /dev/null +++ b/packages/steiger/src/features/discover-plugins/parse-plugin-default-export.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +import { configObjectSchema, pluginSchema, globalIgnoreSchema } from '../../models/config' + +const pluginDefaultExportSchema = z.object({ + plugin: pluginSchema, + configs: z.record(z.array(z.union([globalIgnoreSchema, configObjectSchema(), pluginSchema]))), +}) + +export const parsePluginDefaultExport = pluginDefaultExportSchema.parseAsync diff --git a/packages/steiger/src/features/discover-plugins/suggest-fsd-plugin.ts b/packages/steiger/src/features/discover-plugins/suggest-fsd-plugin.ts new file mode 100644 index 00000000..dd47bd77 --- /dev/null +++ b/packages/steiger/src/features/discover-plugins/suggest-fsd-plugin.ts @@ -0,0 +1,104 @@ +import { basename, dirname, relative, resolve } from 'node:path' +import { access } from 'node:fs/promises' +import { confirm, isCancel, outro, log, tasks } from '@clack/prompts' +import * as pkg from 'empathic/package' +import pc from 'picocolors' +import { exec, type Output } from 'tinyexec' +import terminalLink from 'terminal-link' + +import { whichLockfileExists, whichPackageManagerRuns } from '../../shared/package-manager' +import { ExitException } from '../../shared/exit-exception' +import { pluginNamePrefix } from './is-steiger-plugin' + +const fsdPlugin = '@feature-sliced/steiger-plugin' +const fsdWebsiteLink = terminalLink('Feature-Sliced Design', 'https://feature-sliced.design') + +/** + * Ask if the user wants to run FSD checks and offer to install the FSD plugin. + * + * This runs when the auto-detection of plugins didn't find anything. + */ +export async function suggestInstallingFsdPlugin() { + const pm = whichPackageManagerRuns()?.name ?? whichLockfileExists() ?? 'npm' + const packageJsonPath = pkg.up() + const addCommand = [pm, 'add', fsdPlugin] + + const theyWantFsdChecks = await confirm({ + message: + (packageJsonPath === undefined + ? "Couldn't find a package.json file with Steiger plugins. " + : `Couldn't find any plugins in ${formatPath(relative(process.cwd(), packageJsonPath))}. `) + + `Are you trying to check this project's compliance to ${fsdWebsiteLink}?`, + }) + + if (theyWantFsdChecks === false || isCancel(theyWantFsdChecks)) { + explainHowToFindOtherPlugins(pm) + throw new ExitException() + } + + // pnpm will refuse to install the package to the workspace root without explicit confirmation + if (pm === 'pnpm') { + try { + // pnpm workspace roots must have a pnpm-workspace.yaml file + await access('pnpm-workspace.yaml') + addCommand.push('--workspace-root') + } catch {} + } + + const installCommandCwd = packageJsonPath && dirname(relative(process.cwd(), packageJsonPath)) + // ↓ Will look like "folder-name (path: ..)" + const formattedCwd = + installCommandCwd && `${basename(resolve(installCommandCwd))} (path: ${formatPath(installCommandCwd)})` + const theyWantUsToInstall = await confirm({ + message: `Okay! Would you like to run ${formatCommand(addCommand.join(' '))}${formattedCwd ? ` in ${formattedCwd}` : ''} to install the FSD plugin?`, + active: 'Yes, run it for me', + inactive: 'No, exit, I will do it myself', + }) + + if (theyWantUsToInstall === true) { + let output: Output | undefined + await tasks([ + { + title: `Installing the FSD plugin with ${pm}`, + task: async () => { + output = await exec(addCommand[0], addCommand.slice(1), { nodeOptions: { cwd: installCommandCwd } }) + if (output.exitCode !== 0) { + return 'Failed to install the FSD plugin, error message follows' + } + return `Installed the FSD plugin with ${pm}` + }, + }, + ]) + + if (output !== undefined) { + if (output.exitCode !== 0) { + log.error((output.stderr || output.stdout).trim()) + outro('Something went wrong :( Please try installing the plugin manually.') + throw new Error('The command to install the FSD plugin failed') + } else { + log.info(output.stdout.trim()) + outro("All done! Now let's run the FSD checks.") + } + } + } else { + outro("You got it, boss! Run that command whenever you're ready.") + throw new ExitException() + } +} + +function explainHowToFindOtherPlugins(pm: string) { + outro( + `Alright! In that case, find a Steiger plugin and run ${formatCommand(`${pm} add `)}.\n` + + pc.dim( + ` Hint: ${terminalLink(`search for "${pluginNamePrefix}" on npm`, `https://www.npmjs.com/search?q=${pluginNamePrefix}`)} to see what plugins are available`, + ), + ) +} + +function formatPath(path: string): string { + return pc.blue(path) +} + +function formatCommand(command: string): string { + return pc.green(`\`${command}\``) +} diff --git a/packages/steiger/src/models/config/index.ts b/packages/steiger/src/models/config/index.ts index 8a031f28..5c94fb82 100644 --- a/packages/steiger/src/models/config/index.ts +++ b/packages/steiger/src/models/config/index.ts @@ -10,6 +10,9 @@ import { transformGlobs } from './transform-globs' type RuleInstructionsPerRule = Record export type { GlobGroupWithSeverity } from './types' +export { pluginSchema } from './schemas/plugin' +export { configObjectSchema } from './schemas/config-object' +export { globalIgnoreSchema } from './schemas/global-ignore' const $ruleInstructions = createStore(null) const setRuleInstructions = createEvent() diff --git a/packages/steiger/src/models/config/schemas/config-object.ts b/packages/steiger/src/models/config/schemas/config-object.ts new file mode 100644 index 00000000..9dc75e6b --- /dev/null +++ b/packages/steiger/src/models/config/schemas/config-object.ts @@ -0,0 +1,12 @@ +import z from 'zod' + +// z.enum requires at least one element in the array, so we need "[string, ...string[]]" +export const configObjectSchema = (allRuleNames?: [string, ...string[]]) => + z.object({ + files: z.optional(z.array(z.string())), + ignores: z.optional(z.array(z.string())), + rules: z.record( + allRuleNames !== undefined ? z.enum(allRuleNames) : z.string(), + z.union([z.enum(['off', 'error', 'warn']), z.tuple([z.enum(['error', 'warn']), z.object({}).passthrough()])]), + ), + }) diff --git a/packages/steiger/src/models/config/schemas/global-ignore.ts b/packages/steiger/src/models/config/schemas/global-ignore.ts new file mode 100644 index 00000000..aad3990f --- /dev/null +++ b/packages/steiger/src/models/config/schemas/global-ignore.ts @@ -0,0 +1,7 @@ +import z from 'zod' + +export const globalIgnoreSchema = z + .object({ + ignores: z.array(z.string()), + }) + .passthrough() diff --git a/packages/steiger/src/models/config/schemas/plugin.ts b/packages/steiger/src/models/config/schemas/plugin.ts new file mode 100644 index 00000000..b85c4937 --- /dev/null +++ b/packages/steiger/src/models/config/schemas/plugin.ts @@ -0,0 +1,23 @@ +import z from 'zod' + +const ruleResultSchema = z.object({ + // Marked as "any" because return type is not useful for this validation + diagnostics: z.array(z.any()), +}) + +export const pluginSchema = z.object({ + meta: z.object({ + name: z.string(), + version: z.string(), + }), + getRuleDescriptionUrl: z.optional(z.function().args(z.string()).returns(z.any())), + ruleDefinitions: z.array( + z.object({ + name: z.string(), + check: z + .function() + .args() + .returns(z.union([z.promise(ruleResultSchema), ruleResultSchema])), + }), + ), +}) diff --git a/packages/steiger/src/models/config/validate-config.ts b/packages/steiger/src/models/config/validate-config.ts index 9bc2e1b8..8a68d759 100644 --- a/packages/steiger/src/models/config/validate-config.ts +++ b/packages/steiger/src/models/config/validate-config.ts @@ -1,9 +1,13 @@ import z from 'zod' +import type { Schema } from 'zod' import { BaseRuleOptions, Config, Plugin, Rule } from '@steiger/types' -import { getOptions, isConfigObject, isPlugin } from './raw-config' import { isEqual } from '../../shared/objects' +import { getOptions, isConfigObject, isPlugin } from './raw-config' +import { globalIgnoreSchema } from './schemas/global-ignore' +import { pluginSchema } from './schemas/plugin' +import { configObjectSchema } from './schemas/config-object' const OLD_CONFIG_ERROR_MESSAGE = 'Old configuration format detected. We are evolving!\nPlease follow this short guide to migrate to the new one:\nhttps://github.com/feature-sliced/steiger/blob/master/MIGRATION_GUIDE.md' @@ -17,17 +21,6 @@ function getAllRuleNames(plugins: Array) { return allRules.map((rule) => rule.name) } -function validateConfigObjectsNumber(value: Config>, ctx: z.RefinementCtx) { - const configObjects = value.filter(isConfigObject) - - if (configObjects.length === 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: NO_CONFIG_OBJECTS_ERROR_MESSAGE, - }) - } -} - function validateRuleUniqueness(value: Config>, ctx: z.RefinementCtx) { const allRuleNames = getAllRuleNames(value.filter(isPlugin)) const uniqueNames = new Set(allRuleNames) @@ -75,7 +68,7 @@ function validateRuleOptions(value: Config>, ctx: z.RefinementCtx) { /** * Dynamically build a validation scheme based on the rules provided by plugins. * */ -export function buildValidationScheme(rawConfig: Config>) { +export function buildValidationScheme(rawConfig: Config>): Schema>> { const allRuleNames = getAllRuleNames(rawConfig.filter(isPlugin)) // Make sure there's at least one rule registered by plugins @@ -84,50 +77,9 @@ export function buildValidationScheme(rawConfig: Config>) { throw new Error(NO_RULES_ERROR_MESSAGE) } - // Marked as "any" because return type is not useful for this validation - const ruleResultScheme = z.object({ - diagnostics: z.array(z.any()), - }) - return z - .array( - z.union([ - z - .object({ - ignores: z.array(z.string()), - }) - .passthrough(), - z.object({ - files: z.optional(z.array(z.string())), - ignores: z.optional(z.array(z.string())), - // zod.record requires at least one element in the array, so we need "as [string, ...string[]]" - rules: z.record( - z.enum(allRuleNames as [string, ...string[]]), - z.union([ - z.enum(['off', 'error', 'warn']), - z.tuple([z.enum(['error', 'warn']), z.object({}).passthrough()]), - ]), - ), - }), - z.object({ - meta: z.object({ - name: z.string(), - version: z.string(), - }), - getRuleDescriptionUrl: z.optional(z.function().args(z.string()).returns(z.any())), - ruleDefinitions: z.array( - z.object({ - name: z.string(), - check: z - .function() - .args() - .returns(z.union([z.promise(ruleResultScheme), ruleResultScheme])), - }), - ), - }), - ]), - ) - .superRefine(validateConfigObjectsNumber) + .array(z.union([globalIgnoreSchema, configObjectSchema(allRuleNames as [string, ...string[]]), pluginSchema])) + .refine((configArray) => configArray.some(isConfigObject), { message: NO_CONFIG_OBJECTS_ERROR_MESSAGE }) .superRefine(validateRuleOptions) .superRefine(validateRuleUniqueness) } diff --git a/packages/steiger/src/shared/exit-exception.ts b/packages/steiger/src/shared/exit-exception.ts new file mode 100644 index 00000000..0791932a --- /dev/null +++ b/packages/steiger/src/shared/exit-exception.ts @@ -0,0 +1,18 @@ +/** For cases when the person requests to exit the program in an interactive choice. */ +export class ExitException extends Error {} + +/** Run the callback that might throw an `ExitException` and exit the process if that happened. */ +export async function handleExitRequest( + callback: () => ReturnType | Promise, + { exitCode }: { exitCode: number }, +) { + try { + return await callback() + } catch (e) { + if (e instanceof ExitException) { + process.exit(exitCode) + } else { + throw e + } + } +} diff --git a/packages/steiger/src/shared/package-manager/index.ts b/packages/steiger/src/shared/package-manager/index.ts new file mode 100644 index 00000000..25c3249d --- /dev/null +++ b/packages/steiger/src/shared/package-manager/index.ts @@ -0,0 +1,2 @@ +export { whichPackageManagerRuns } from './which-pm-runs' +export { whichLockfileExists } from './which-lockfile-exists' diff --git a/packages/steiger/src/shared/package-manager/package-managers.ts b/packages/steiger/src/shared/package-manager/package-managers.ts new file mode 100644 index 00000000..c585d22f --- /dev/null +++ b/packages/steiger/src/shared/package-manager/package-managers.ts @@ -0,0 +1 @@ +export type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun' | 'cnpm' diff --git a/packages/steiger/src/shared/package-manager/which-lockfile-exists.ts b/packages/steiger/src/shared/package-manager/which-lockfile-exists.ts new file mode 100644 index 00000000..84e5803c --- /dev/null +++ b/packages/steiger/src/shared/package-manager/which-lockfile-exists.ts @@ -0,0 +1,19 @@ +import { basename } from 'node:path' +import * as find from 'empathic/find' + +import type { PackageManager } from './package-managers' + +const lockfiles: Record = { + 'package-lock.json': 'npm', + 'yarn.lock': 'yarn', + 'pnpm-lock.yaml': 'pnpm', + 'bun.lockb': 'bun', + 'bun.lock': 'bun', +} + +/** Returns the package manager whose lockfile exists somewhere up the tree. */ +export function whichLockfileExists(): PackageManager | undefined { + const lockfilePath = find.any(Object.keys(lockfiles)) + + return lockfilePath ? lockfiles[basename(lockfilePath)] : undefined +} diff --git a/packages/steiger/src/shared/package-manager/which-pm-runs.ts b/packages/steiger/src/shared/package-manager/which-pm-runs.ts new file mode 100644 index 00000000..5634b467 --- /dev/null +++ b/packages/steiger/src/shared/package-manager/which-pm-runs.ts @@ -0,0 +1,27 @@ +// Code adapted from the `which-pm-runs` package +// Source: https://github.com/zkochan/packages/tree/main/which-pm-runs, licensed under MIT + +import type { PackageManager } from './package-managers' + +/** Returns the package manager from the `npm_config_user_agent` env variable. */ +export function whichPackageManagerRuns(): { name: PackageManager; version: string } | undefined { + if (!process.env.npm_config_user_agent) { + return undefined + } + const parsed = pmFromUserAgent(process.env.npm_config_user_agent) + if (['npm', 'yarn', 'pnpm', 'bun', 'cnpm'].includes(parsed.name)) { + return { name: parsed.name as PackageManager, version: parsed.version } + } else { + return undefined + } +} + +function pmFromUserAgent(userAgent: string) { + const pmSpec = userAgent.split(' ')[0] + const separatorPos = pmSpec.lastIndexOf('/') + const name = pmSpec.substring(0, separatorPos) + return { + name: name === 'npminstall' ? 'cnpm' : name, + version: pmSpec.substring(separatorPos + 1), + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77015992..401c6edf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,15 @@ importers: specifier: ^2.3.3 version: 2.3.3 + examples/kitchen-sink-of-fsd-issues: + devDependencies: + '@feature-sliced/steiger-plugin': + specifier: file:../../packages/steiger-plugin-fsd/dist + version: dist@file:packages/steiger-plugin-fsd/dist + steiger: + specifier: file:../../packages/steiger/dist + version: dist@file:packages/steiger/dist + packages/pretty-reporter: dependencies: chalk: @@ -79,11 +88,8 @@ importers: packages/steiger: dependencies: '@clack/prompts': - specifier: ^0.8.2 - version: 0.8.2 - '@feature-sliced/steiger-plugin': - specifier: workspace:* - version: link:../steiger-plugin-fsd + specifier: ^0.9.0 + version: 0.9.1 chokidar: specifier: ^4.0.1 version: 4.0.1 @@ -111,6 +117,9 @@ importers: minimatch: specifier: ^10.0.1 version: 10.0.1 + oxc-resolver: + specifier: ^3.0.3 + version: 3.0.3 patronum: specifier: ^2.3.0 version: 2.3.0(effector@23.2.3) @@ -120,6 +129,12 @@ importers: prexit: specifier: ^2.3.0 version: 2.3.0 + terminal-link: + specifier: ^3.0.0 + version: 3.0.0 + tinyexec: + specifier: ^0.3.1 + version: 0.3.1 yargs: specifier: ^17.7.2 version: 17.7.2 @@ -366,16 +381,25 @@ packages: '@changesets/write@0.3.2': resolution: {integrity: sha512-kDxDrPNpUgsjDbWBvUo27PzKX4gqeKOlhibaOXDJA6kuBisGqNHv/HwGJrAu8U/dSf8ZEFIeHIPtvSlZI1kULw==} - '@clack/core@0.3.5': - resolution: {integrity: sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==} + '@clack/core@0.4.1': + resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==} - '@clack/prompts@0.8.2': - resolution: {integrity: sha512-6b9Ab2UiZwJYA9iMyboYyW9yJvAO9V753ZhS+DHKEjZRKAxPPOb7MXXu84lsPFG+vZt6FRFniZ8rXi+zCIw4yQ==} + '@clack/prompts@0.9.1': + resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==} '@dependents/detective-less@5.0.0': resolution: {integrity: sha512-D/9dozteKcutI5OdxJd8rU+fL6XgaaRg60sPPJWkT33OCiRfkCu5wO5B/yXTaaL2e6EB0lcCBGe5E0XscZCvvQ==} engines: {node: '>=18'} + '@emnapi/core@1.3.1': + resolution: {integrity: sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==} + + '@emnapi/runtime@1.3.1': + resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==} + + '@emnapi/wasi-threads@1.0.1': + resolution: {integrity: sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -944,6 +968,9 @@ packages: '@microsoft/tsdoc@0.15.1': resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + '@napi-rs/wasm-runtime@0.2.6': + resolution: {integrity: sha512-z8YVS3XszxFTO73iwvFDNpQIzdMmSDTP/mB3E/ucR37V3Sx57hSExcXyMoNwaucWxnsWf4xfbZv0iZ30jr0M4Q==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -956,6 +983,61 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@oxc-resolver/binding-darwin-arm64@3.0.3': + resolution: {integrity: sha512-cCSzv2VNSKrQUy43enMt6cN+TlijYUJ3qVOx52ioq7qOKtZ6sy3kcfzSOy3f27cFOCaPotIqC35eb3LMrdsPCA==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@3.0.3': + resolution: {integrity: sha512-IgB18vUIG33pYfkKL7yi0NaudGdRWiTbTfxMqb4XRx1US7ZjhhwEEljf8dDVEGS607qvDbFrU04APYiPOEQRRw==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@3.0.3': + resolution: {integrity: sha512-psalblUDjksTdVGYP8XZuWxzog0k5T6qtCHq6S8+VQtpBEE+rjKI6aCtO656fOgdZuTgd4+GmtxFr2UVmOcNxg==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@3.0.3': + resolution: {integrity: sha512-kHhjtBPeQE/1lTsN3j0kZqQwY2BNe7jNYkZ10K4F5i2RRyaL5ImgzbfRtuAk1Fuf1JM/hPoWNEH9DR+6k6ROww==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@3.0.3': + resolution: {integrity: sha512-eEwxY+0Cf76HnQwr1+Qy48qwf4dAebTHaKhzEgxLqLK6szbglnK6SThjY95YHrYWwsH1GujWiFoX51jwZNYfSw==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-musl@3.0.3': + resolution: {integrity: sha512-LdxbLv8qVkzro4/ZoP9MuytIL6NOVsbhoZ5Wl1KXOa/2DSxBiksrAPMSChCTyeLy6P3ebSHxQSb52ku18t1LBA==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-x64-gnu@3.0.3': + resolution: {integrity: sha512-bN8elR9AV/DZZPdcteOWWElkz8KyxLtOvmfVl7Dnehcs6f9e+fWYKyqiKvva1jsxG4znGKCPT1gfMhpYW8QuKg==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-linux-x64-musl@3.0.3': + resolution: {integrity: sha512-Zy1U49BjriwbAds2ho6CGjZIk2KVn0+lrc/G5bvhQg7UJYxEkAueMGBuA5rULIhx9xVtIPsT9Q+J5Xhb4ffVNw==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-wasm32-wasi@3.0.3': + resolution: {integrity: sha512-7rteQnn7i5f9nkFZs1VRdBqFhvOx3zWavyKkWjXYVxc9vsSLTg0moh2MRZw5dw5m/bEi1u/p3YAKJ9gdHyBhNQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@3.0.3': + resolution: {integrity: sha512-G6u48LSbF5IIEy9vKl8EXpmUCtCr/wZkARRQjw1H4YMFrpa0nBZT3XRzcYjNIzmhb535rM28xFNEauvTuWQA1Q==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@3.0.3': + resolution: {integrity: sha512-miYkngimV69GpmaLclBZHE+PP7jebmqKsUJB7er8/eQfDyH1up52xauNJU+KgI/GHDx+JvMbSakdcyF7zM1/DQ==} + cpu: [x64] + os: [win32] + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1179,6 +1261,9 @@ packages: '@tsconfig/node18@18.2.4': resolution: {integrity: sha512-5xxU8vVs9/FNcvm3gE07fPbn9tl6tqGGWA9tSlwsUEkBxtRnTsNmwrV8gasZ9F/EobaSv9+nu8AxUKccw77JpQ==} + '@tybys/wasm-util@0.9.0': + resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + '@types/argparse@1.0.38': resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} @@ -1618,6 +1703,12 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dist@file:packages/steiger-plugin-fsd/dist: + resolution: {directory: packages/steiger-plugin-fsd/dist, type: directory} + + dist@file:packages/steiger/dist: + resolution: {directory: packages/steiger/dist, type: directory} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -2258,6 +2349,9 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + oxc-resolver@3.0.3: + resolution: {integrity: sha512-fU5lhDCb9fCv/CP2YJiBEcuC+ZhTdOBzyacoUvPlZxA4NpF6JPVbgeYD9rthQIjfWlAwi5qfxQj2dyqxLoJ9HA==} + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -3154,14 +3248,14 @@ snapshots: human-id: 1.0.2 prettier: 2.8.8 - '@clack/core@0.3.5': + '@clack/core@0.4.1': dependencies: picocolors: 1.1.1 sisteransi: 1.0.5 - '@clack/prompts@0.8.2': + '@clack/prompts@0.9.1': dependencies: - '@clack/core': 0.3.5 + '@clack/core': 0.4.1 picocolors: 1.1.1 sisteransi: 1.0.5 @@ -3170,6 +3264,22 @@ snapshots: gonzales-pe: 4.3.0 node-source-walk: 7.0.0 + '@emnapi/core@1.3.1': + dependencies: + '@emnapi/wasi-threads': 1.0.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.3.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.0.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -3574,6 +3684,13 @@ snapshots: '@microsoft/tsdoc@0.15.1': optional: true + '@napi-rs/wasm-runtime@0.2.6': + dependencies: + '@emnapi/core': 1.3.1 + '@emnapi/runtime': 1.3.1 + '@tybys/wasm-util': 0.9.0 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3586,6 +3703,41 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 + '@oxc-resolver/binding-darwin-arm64@3.0.3': + optional: true + + '@oxc-resolver/binding-darwin-x64@3.0.3': + optional: true + + '@oxc-resolver/binding-freebsd-x64@3.0.3': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@3.0.3': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@3.0.3': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@3.0.3': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@3.0.3': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@3.0.3': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@3.0.3': + dependencies: + '@napi-rs/wasm-runtime': 0.2.6 + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@3.0.3': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@3.0.3': + optional: true + '@pkgjs/parseargs@0.11.0': optional: true @@ -3750,6 +3902,11 @@ snapshots: '@tsconfig/node18@18.2.4': {} + '@tybys/wasm-util@0.9.0': + dependencies: + tslib: 2.8.1 + optional: true + '@types/argparse@1.0.38': optional: true @@ -4225,6 +4382,10 @@ snapshots: dependencies: path-type: 4.0.0 + dist@file:packages/steiger-plugin-fsd/dist: {} + + dist@file:packages/steiger/dist: {} + eastasianwidth@0.2.0: {} effector@23.2.3: {} @@ -4896,6 +5057,20 @@ snapshots: outdent@0.5.0: {} + oxc-resolver@3.0.3: + optionalDependencies: + '@oxc-resolver/binding-darwin-arm64': 3.0.3 + '@oxc-resolver/binding-darwin-x64': 3.0.3 + '@oxc-resolver/binding-freebsd-x64': 3.0.3 + '@oxc-resolver/binding-linux-arm-gnueabihf': 3.0.3 + '@oxc-resolver/binding-linux-arm64-gnu': 3.0.3 + '@oxc-resolver/binding-linux-arm64-musl': 3.0.3 + '@oxc-resolver/binding-linux-x64-gnu': 3.0.3 + '@oxc-resolver/binding-linux-x64-musl': 3.0.3 + '@oxc-resolver/binding-wasm32-wasi': 3.0.3 + '@oxc-resolver/binding-win32-arm64-msvc': 3.0.3 + '@oxc-resolver/binding-win32-x64-msvc': 3.0.3 + p-filter@2.1.0: dependencies: p-map: 2.1.0 diff --git a/tooling/eslint-config/eslint.config.mjs b/tooling/eslint-config/eslint.config.mjs index a0ef3b62..9558bacf 100644 --- a/tooling/eslint-config/eslint.config.mjs +++ b/tooling/eslint-config/eslint.config.mjs @@ -11,7 +11,8 @@ export default [ { ignores: ['**/node_modules', '**/dist'] }, { rules: { - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'no-empty': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], }, }, ] From 8a8aadb4b983554d7d1f991840531d5a60f071b4 Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sun, 2 Feb 2025 20:46:05 +0100 Subject: [PATCH 02/21] Separate Vitest-dependent utils into a different entrypoint --- .changeset/curly-years-sparkle.md | 5 +++++ .../src/_lib/collect-related-ts-configs.ts | 2 +- .../steiger-plugin-fsd/src/_lib/index-source-files.ts | 3 ++- .../src/ambiguous-slice-names/index.spec.ts | 2 +- .../src/excessive-slicing/index.spec.ts | 2 +- .../src/forbidden-imports/index.spec.ts | 4 ++-- .../src/import-locality/index.spec.ts | 4 ++-- .../src/inconsistent-naming/index.spec.ts | 2 +- .../src/insignificant-slice/index.spec.ts | 4 ++-- .../src/no-file-segments/index.spec.ts | 2 +- .../src/no-layer-public-api/index.spec.ts | 2 +- .../steiger-plugin-fsd/src/no-processes/index.spec.ts | 2 +- .../src/no-public-api-sidestep/index.spec.ts | 4 ++-- .../src/no-reserved-folder-names/index.spec.ts | 2 +- .../src/no-segmentless-slices/index.spec.ts | 2 +- .../src/no-segments-on-sliced-layers/index.spec.ts | 2 +- .../steiger-plugin-fsd/src/no-ui-in-app/index.spec.ts | 2 +- .../steiger-plugin-fsd/src/public-api/index.spec.ts | 2 +- .../src/repetitive-naming/index.spec.ts | 2 +- .../src/segments-by-purpose/index.spec.ts | 2 +- .../src/shared-lib-grouping/index.spec.ts | 2 +- .../src/typo-in-layer-name/index.spec.ts | 2 +- .../calculate-final-severity.spec.ts | 2 +- .../features/choose-root-folder/choose-from-guesses.ts | 2 +- .../features/choose-root-folder/choose-from-similar.ts | 2 +- .../remove-global-ignores-from-vfs.spec.ts | 2 +- .../features/run-rule/prepare-vfs-for-rule-run.spec.ts | 2 +- .../steiger/src/shared/globs/apply-exclusion.spec.ts | 2 +- packages/toolkit/package.json | 10 ++++++++-- packages/toolkit/src/index.ts | 2 -- packages/toolkit/src/test.ts | 1 + packages/toolkit/tsup.config.ts | 4 ++-- 32 files changed, 48 insertions(+), 37 deletions(-) create mode 100644 .changeset/curly-years-sparkle.md create mode 100644 packages/toolkit/src/test.ts diff --git a/.changeset/curly-years-sparkle.md b/.changeset/curly-years-sparkle.md new file mode 100644 index 00000000..b7733c49 --- /dev/null +++ b/.changeset/curly-years-sparkle.md @@ -0,0 +1,5 @@ +--- +'@steiger/toolkit': patch +--- + +Separate Vitest-dependent utilities into a different entrypoint to make Vitest a truly optional peer dependency diff --git a/packages/steiger-plugin-fsd/src/_lib/collect-related-ts-configs.ts b/packages/steiger-plugin-fsd/src/_lib/collect-related-ts-configs.ts index 5e42269a..6d8f9631 100644 --- a/packages/steiger-plugin-fsd/src/_lib/collect-related-ts-configs.ts +++ b/packages/steiger-plugin-fsd/src/_lib/collect-related-ts-configs.ts @@ -1,6 +1,6 @@ import { TSConfckParseResult } from 'tsconfck' import { dirname, resolve } from 'node:path' -import { joinFromRoot } from '@steiger/toolkit' +import { joinFromRoot } from '@steiger/toolkit/test' export type CollectRelatedTsConfigsPayload = { tsconfig: TSConfckParseResult['tsconfig'] diff --git a/packages/steiger-plugin-fsd/src/_lib/index-source-files.ts b/packages/steiger-plugin-fsd/src/_lib/index-source-files.ts index c112c6da..076b9b5f 100644 --- a/packages/steiger-plugin-fsd/src/_lib/index-source-files.ts +++ b/packages/steiger-plugin-fsd/src/_lib/index-source-files.ts @@ -1,5 +1,6 @@ import { getIndex, getLayers, getSegments, getSlices, isSliced, type LayerName } from '@feature-sliced/filesystem' -import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot, type File, type Folder } from '@steiger/toolkit' +import type { File, Folder } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' type SourceFile = { file: File diff --git a/packages/steiger-plugin-fsd/src/ambiguous-slice-names/index.spec.ts b/packages/steiger-plugin-fsd/src/ambiguous-slice-names/index.spec.ts index d2ceba67..6fbca266 100644 --- a/packages/steiger-plugin-fsd/src/ambiguous-slice-names/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/ambiguous-slice-names/index.spec.ts @@ -2,7 +2,7 @@ import { join } from 'node:path' import { expect, it } from 'vitest' import ambiguousSliceNames from './index.js' -import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' it('reports no errors on a project without slice names that match some segment name in Shared', () => { const root = parseIntoFsdRoot(` diff --git a/packages/steiger-plugin-fsd/src/excessive-slicing/index.spec.ts b/packages/steiger-plugin-fsd/src/excessive-slicing/index.spec.ts index 5e0ae1b3..a3065b46 100644 --- a/packages/steiger-plugin-fsd/src/excessive-slicing/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/excessive-slicing/index.spec.ts @@ -1,6 +1,6 @@ import { expect, it } from 'vitest' -import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' import excessiveSlicing from './index.js' it('reports no errors on projects with moderate slicing', () => { diff --git a/packages/steiger-plugin-fsd/src/forbidden-imports/index.spec.ts b/packages/steiger-plugin-fsd/src/forbidden-imports/index.spec.ts index dbedd802..08da7185 100644 --- a/packages/steiger-plugin-fsd/src/forbidden-imports/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/forbidden-imports/index.spec.ts @@ -1,6 +1,6 @@ import { expect, it, vi } from 'vitest' -import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' import forbiddenImports from './index.js' vi.mock('tsconfck', async (importOriginal) => { @@ -23,7 +23,7 @@ vi.mock('tsconfck', async (importOriginal) => { vi.mock('node:fs', async (importOriginal) => { const originalFs = await importOriginal() - const { createFsMocks } = await import('@steiger/toolkit') + const { createFsMocks } = await import('@steiger/toolkit/test') return createFsMocks( { diff --git a/packages/steiger-plugin-fsd/src/import-locality/index.spec.ts b/packages/steiger-plugin-fsd/src/import-locality/index.spec.ts index 8a25fa80..2e9159ad 100644 --- a/packages/steiger-plugin-fsd/src/import-locality/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/import-locality/index.spec.ts @@ -1,6 +1,6 @@ import { expect, it, vi } from 'vitest' -import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' import importLocality from './index.js' vi.mock('tsconfck', async (importOriginal) => { @@ -12,7 +12,7 @@ vi.mock('tsconfck', async (importOriginal) => { vi.mock('node:fs', async (importOriginal) => { const originalFs = await importOriginal() - const { createFsMocks } = await import('@steiger/toolkit') + const { createFsMocks } = await import('@steiger/toolkit/test') return createFsMocks( { diff --git a/packages/steiger-plugin-fsd/src/inconsistent-naming/index.spec.ts b/packages/steiger-plugin-fsd/src/inconsistent-naming/index.spec.ts index 60aec51c..8087f30c 100644 --- a/packages/steiger-plugin-fsd/src/inconsistent-naming/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/inconsistent-naming/index.spec.ts @@ -1,6 +1,6 @@ import { expect, it } from 'vitest' -import { compareMessages, joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { compareMessages, joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' import inconsistentNaming from './index.js' it('reports no errors on slice names that are pluralized consistently', () => { diff --git a/packages/steiger-plugin-fsd/src/insignificant-slice/index.spec.ts b/packages/steiger-plugin-fsd/src/insignificant-slice/index.spec.ts index 1c52423a..2426cc2d 100644 --- a/packages/steiger-plugin-fsd/src/insignificant-slice/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/insignificant-slice/index.spec.ts @@ -1,7 +1,7 @@ import { expect, it, vi } from 'vitest' import { join } from 'node:path' -import { compareMessages, joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { compareMessages, joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' import insignificantSlice from './index.js' vi.mock('tsconfck', async (importOriginal) => { @@ -13,7 +13,7 @@ vi.mock('tsconfck', async (importOriginal) => { vi.mock('node:fs', async (importOriginal) => { const originalFs = await importOriginal() - const { createFsMocks } = await import('@steiger/toolkit') + const { createFsMocks } = await import('@steiger/toolkit/test') return createFsMocks( { diff --git a/packages/steiger-plugin-fsd/src/no-file-segments/index.spec.ts b/packages/steiger-plugin-fsd/src/no-file-segments/index.spec.ts index 925b26bb..ff2c31f8 100644 --- a/packages/steiger-plugin-fsd/src/no-file-segments/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/no-file-segments/index.spec.ts @@ -1,6 +1,6 @@ import { expect, it } from 'vitest' -import { compareMessages, joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { compareMessages, joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' import noFileSegments from './index.js' it('reports no errors on a project with only folder segments', async () => { diff --git a/packages/steiger-plugin-fsd/src/no-layer-public-api/index.spec.ts b/packages/steiger-plugin-fsd/src/no-layer-public-api/index.spec.ts index 617b5065..09290b5f 100644 --- a/packages/steiger-plugin-fsd/src/no-layer-public-api/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/no-layer-public-api/index.spec.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest' import noLayerPublicApi from './index.js' -import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' it('reports no errors on a project without index files on layer level', () => { const root = parseIntoFsdRoot(` diff --git a/packages/steiger-plugin-fsd/src/no-processes/index.spec.ts b/packages/steiger-plugin-fsd/src/no-processes/index.spec.ts index b3a00907..755e01a8 100644 --- a/packages/steiger-plugin-fsd/src/no-processes/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/no-processes/index.spec.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest' import noProcesses from './index.js' -import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' it('reports no errors on a project without the Processes layer', () => { const root = parseIntoFsdRoot(` diff --git a/packages/steiger-plugin-fsd/src/no-public-api-sidestep/index.spec.ts b/packages/steiger-plugin-fsd/src/no-public-api-sidestep/index.spec.ts index 2fd0668c..ef0ede2c 100644 --- a/packages/steiger-plugin-fsd/src/no-public-api-sidestep/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/no-public-api-sidestep/index.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' import noPublicApiSidestep from './index.js' vi.mock('tsconfck', async (importOriginal) => { @@ -12,7 +12,7 @@ vi.mock('tsconfck', async (importOriginal) => { vi.mock('node:fs', async (importOriginal) => { const originalFs = await importOriginal() - const { createFsMocks } = await import('@steiger/toolkit') + const { createFsMocks } = await import('@steiger/toolkit/test') return createFsMocks( { diff --git a/packages/steiger-plugin-fsd/src/no-reserved-folder-names/index.spec.ts b/packages/steiger-plugin-fsd/src/no-reserved-folder-names/index.spec.ts index 5551f451..2d5220c5 100644 --- a/packages/steiger-plugin-fsd/src/no-reserved-folder-names/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/no-reserved-folder-names/index.spec.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest' import noReservedFolderNames from './index.js' -import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' it('reports no errors on a project without subfolders in segments that use reserved names', () => { const root = parseIntoFsdRoot(` diff --git a/packages/steiger-plugin-fsd/src/no-segmentless-slices/index.spec.ts b/packages/steiger-plugin-fsd/src/no-segmentless-slices/index.spec.ts index a12f7550..e07bfb7c 100644 --- a/packages/steiger-plugin-fsd/src/no-segmentless-slices/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/no-segmentless-slices/index.spec.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest' import noSegmentlessSlices from './index.js' -import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' it('reports no errors on a project where every slice has at least one segment', () => { const root = parseIntoFsdRoot(` diff --git a/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.spec.ts b/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.spec.ts index 00b911c9..ac356ec7 100644 --- a/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import noSegmentsOnSlicedLayers from './index.js' -import { compareMessages, joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { compareMessages, joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' describe('no-segments-on-sliced-layers rule', () => { it('reports no errors on a project where the sliced layers has no segments in direct children', () => { diff --git a/packages/steiger-plugin-fsd/src/no-ui-in-app/index.spec.ts b/packages/steiger-plugin-fsd/src/no-ui-in-app/index.spec.ts index fd193138..1ce5abe2 100644 --- a/packages/steiger-plugin-fsd/src/no-ui-in-app/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/no-ui-in-app/index.spec.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest' -import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' import noUiInApp from './index.js' diff --git a/packages/steiger-plugin-fsd/src/public-api/index.spec.ts b/packages/steiger-plugin-fsd/src/public-api/index.spec.ts index e69e682c..d767375b 100644 --- a/packages/steiger-plugin-fsd/src/public-api/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/public-api/index.spec.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest' import publicApi from './index.js' -import { compareMessages, joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { compareMessages, joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' it('reports no errors on a project with all the required public APIs', () => { const root = parseIntoFsdRoot(` diff --git a/packages/steiger-plugin-fsd/src/repetitive-naming/index.spec.ts b/packages/steiger-plugin-fsd/src/repetitive-naming/index.spec.ts index 94bd700c..141e194d 100644 --- a/packages/steiger-plugin-fsd/src/repetitive-naming/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/repetitive-naming/index.spec.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest' import repetitiveNaming from './index.js' -import { compareMessages, joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { compareMessages, joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' it('reports no errors on a project with no repetitive words in slices', () => { const root = parseIntoFsdRoot(` diff --git a/packages/steiger-plugin-fsd/src/segments-by-purpose/index.spec.ts b/packages/steiger-plugin-fsd/src/segments-by-purpose/index.spec.ts index a76a5b61..79c013e0 100644 --- a/packages/steiger-plugin-fsd/src/segments-by-purpose/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/segments-by-purpose/index.spec.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest' import segmentsByPurpose from './index.js' -import { compareMessages, joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { compareMessages, joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' it('reports no errors on a project with good segments', () => { const root = parseIntoFsdRoot(` diff --git a/packages/steiger-plugin-fsd/src/shared-lib-grouping/index.spec.ts b/packages/steiger-plugin-fsd/src/shared-lib-grouping/index.spec.ts index 791d2b68..b8f1266d 100644 --- a/packages/steiger-plugin-fsd/src/shared-lib-grouping/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/shared-lib-grouping/index.spec.ts @@ -1,6 +1,6 @@ import { expect, it } from 'vitest' -import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' import excessiveSlicing from './index.js' it('reports no errors on projects with no shared/lib', () => { diff --git a/packages/steiger-plugin-fsd/src/typo-in-layer-name/index.spec.ts b/packages/steiger-plugin-fsd/src/typo-in-layer-name/index.spec.ts index 181e07a1..d875acb5 100644 --- a/packages/steiger-plugin-fsd/src/typo-in-layer-name/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/typo-in-layer-name/index.spec.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest' -import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' import typoInLayerName from './index.js' diff --git a/packages/steiger/src/features/calculate-diagnostic-severities/calculate-final-severity.spec.ts b/packages/steiger/src/features/calculate-diagnostic-severities/calculate-final-severity.spec.ts index 1985d7ad..d93838d1 100644 --- a/packages/steiger/src/features/calculate-diagnostic-severities/calculate-final-severity.spec.ts +++ b/packages/steiger/src/features/calculate-diagnostic-severities/calculate-final-severity.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { joinFromRoot, parseIntoFolder } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder } from '@steiger/toolkit/test' import calculateFinalSeverities from './calculate-final-severity' import { GlobGroupWithSeverity } from '../../models/config' diff --git a/packages/steiger/src/features/choose-root-folder/choose-from-guesses.ts b/packages/steiger/src/features/choose-root-folder/choose-from-guesses.ts index 3bf73deb..01b3718c 100644 --- a/packages/steiger/src/features/choose-root-folder/choose-from-guesses.ts +++ b/packages/steiger/src/features/choose-root-folder/choose-from-guesses.ts @@ -58,7 +58,7 @@ async function findRootFolderCandidates(): Promise> { if (import.meta.vitest) { const { describe, test, expect, vi, beforeEach } = import.meta.vitest const { vol } = await import('memfs') - const { joinFromRoot } = await import('@steiger/toolkit') + const { joinFromRoot } = await import('@steiger/toolkit/test') vi.mock('node:fs/promises', () => import('memfs').then((memfs) => memfs.fs.promises)) diff --git a/packages/steiger/src/features/choose-root-folder/choose-from-similar.ts b/packages/steiger/src/features/choose-root-folder/choose-from-similar.ts index db188595..22e4c834 100644 --- a/packages/steiger/src/features/choose-root-folder/choose-from-similar.ts +++ b/packages/steiger/src/features/choose-root-folder/choose-from-similar.ts @@ -99,7 +99,7 @@ async function resolveWithCorrections(path: string) { if (import.meta.vitest) { const { test, expect, vi } = import.meta.vitest const { vol } = await import('memfs') - const { joinFromRoot } = await import('@steiger/toolkit') + const { joinFromRoot } = await import('@steiger/toolkit/test') vi.mock('node:fs/promises', () => import('memfs').then((memfs) => memfs.fs.promises)) diff --git a/packages/steiger/src/features/remove-global-ignores-from-vfs/remove-global-ignores-from-vfs.spec.ts b/packages/steiger/src/features/remove-global-ignores-from-vfs/remove-global-ignores-from-vfs.spec.ts index c6342800..5ddc1890 100644 --- a/packages/steiger/src/features/remove-global-ignores-from-vfs/remove-global-ignores-from-vfs.spec.ts +++ b/packages/steiger/src/features/remove-global-ignores-from-vfs/remove-global-ignores-from-vfs.spec.ts @@ -1,5 +1,5 @@ import { expect, it, describe } from 'vitest' -import { joinFromRoot, parseIntoFolder } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder } from '@steiger/toolkit/test' import removeGlobalIgnoresFromVfs from './remove-global-ignores-from-vfs' diff --git a/packages/steiger/src/features/run-rule/prepare-vfs-for-rule-run.spec.ts b/packages/steiger/src/features/run-rule/prepare-vfs-for-rule-run.spec.ts index 612600ee..2e9011ea 100644 --- a/packages/steiger/src/features/run-rule/prepare-vfs-for-rule-run.spec.ts +++ b/packages/steiger/src/features/run-rule/prepare-vfs-for-rule-run.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { joinFromRoot, parseIntoFolder } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder } from '@steiger/toolkit/test' import { prepareVfsForRuleRun } from './prepare-vfs-for-rule-run' import { GlobGroupWithSeverity } from '../../models/config' diff --git a/packages/steiger/src/shared/globs/apply-exclusion.spec.ts b/packages/steiger/src/shared/globs/apply-exclusion.spec.ts index 7f36a88d..2520eaf6 100644 --- a/packages/steiger/src/shared/globs/apply-exclusion.spec.ts +++ b/packages/steiger/src/shared/globs/apply-exclusion.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { joinFromRoot, parseIntoFolder } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder } from '@steiger/toolkit/test' import { applyExclusion } from './apply-exclusion' import { not } from './not' diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index 6b6de862..2e0fa724 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -12,8 +12,14 @@ "typecheck": "tsc --noEmit" }, "exports": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./test": { + "types": "./dist/test.d.ts", + "import": "./dist/test.js" + } }, "type": "module", "license": "MIT", diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index 7ff1a6b4..242fffea 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -4,5 +4,3 @@ export { findAllRecursively } from './find-all-recursively.js' export { enableAllRules, createConfigs } from './create-configs.js' export { createPlugin } from './create-plugin.js' export type { ConfigObjectOf } from './config-object-of.js' - -export { compareMessages, createFsMocks, joinFromRoot, parseIntoFolder } from './prepare-test.js' diff --git a/packages/toolkit/src/test.ts b/packages/toolkit/src/test.ts new file mode 100644 index 00000000..6fa2d625 --- /dev/null +++ b/packages/toolkit/src/test.ts @@ -0,0 +1 @@ +export { compareMessages, createFsMocks, joinFromRoot, parseIntoFolder } from './prepare-test.js' diff --git a/packages/toolkit/tsup.config.ts b/packages/toolkit/tsup.config.ts index 3113b515..e54edc96 100644 --- a/packages/toolkit/tsup.config.ts +++ b/packages/toolkit/tsup.config.ts @@ -1,10 +1,10 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/test.ts'], format: ['esm'], dts: { - entry: 'src/index.ts', + entry: ['src/index.ts', 'src/test.ts'], resolve: true, }, treeshake: true, From 5e9e18ab66651e5f165fa0079c5d52fe9b22e220 Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sun, 2 Feb 2025 20:51:01 +0100 Subject: [PATCH 03/21] Make the changeset a minor one --- .changeset/curly-years-sparkle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/curly-years-sparkle.md b/.changeset/curly-years-sparkle.md index b7733c49..42822ee2 100644 --- a/.changeset/curly-years-sparkle.md +++ b/.changeset/curly-years-sparkle.md @@ -1,5 +1,5 @@ --- -'@steiger/toolkit': patch +'@steiger/toolkit': minor --- Separate Vitest-dependent utilities into a different entrypoint to make Vitest a truly optional peer dependency From 1d017a57cf006cd0ae329e49013f173d7f78cff7 Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Mon, 17 Feb 2025 18:52:04 +0100 Subject: [PATCH 04/21] WIP: tests for plugin auto discovery --- .../kitchen-sink-of-fsd-issues/package.json | 5 +- .../auto-discovery-stderr-posix.txt | 19 +++++ .../__snapshots__/smoke-stderr-posix.txt | 11 ++- .../__snapshots__/smoke-stderr-windows.txt | 11 ++- .../tests/plugin-auto-discovery.test.ts | 80 +++++++++++++++++++ integration-tests/tests/smoke.test.ts | 4 +- .../utils/create-vite-project.ts | 31 +++++++ integration-tests/utils/get-snapshot-path.ts | 8 ++ packages/pretty-reporter/package.json | 3 +- pnpm-lock.yaml | 12 +++ pnpm-workspace.yaml | 1 + vitest.workspace.ts | 1 + 12 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 integration-tests/tests/__snapshots__/auto-discovery-stderr-posix.txt create mode 100644 integration-tests/tests/plugin-auto-discovery.test.ts create mode 100644 integration-tests/utils/create-vite-project.ts create mode 100644 integration-tests/utils/get-snapshot-path.ts create mode 100644 vitest.workspace.ts diff --git a/examples/kitchen-sink-of-fsd-issues/package.json b/examples/kitchen-sink-of-fsd-issues/package.json index 0f331851..c19c953d 100644 --- a/examples/kitchen-sink-of-fsd-issues/package.json +++ b/examples/kitchen-sink-of-fsd-issues/package.json @@ -1,6 +1,7 @@ { + "private": true, "devDependencies": { - "steiger": "file:../../packages/steiger/dist", - "@feature-sliced/steiger-plugin": "file:../../packages/steiger-plugin-fsd/dist" + "steiger": "workspace:*", + "@feature-sliced/steiger-plugin": "workspace:*" } } diff --git a/integration-tests/tests/__snapshots__/auto-discovery-stderr-posix.txt b/integration-tests/tests/__snapshots__/auto-discovery-stderr-posix.txt new file mode 100644 index 00000000..cf319867 --- /dev/null +++ b/integration-tests/tests/__snapshots__/auto-discovery-stderr-posix.txt @@ -0,0 +1,19 @@ +node:internal/modules/run_main:104 + triggerUncaughtException( + ^ + +Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'vitest' imported from /private/var/folders/0w/kjy1_yhx2kn54tm162jr4xrm0000gn/T/custom-steiger-plugin/node_modules/@steiger/toolkit/dist/index.js +Did you mean to import "vitest/index.cjs"? + at Object.getPackageJSONURL (node:internal/modules/package_json_reader:267:9) + at packageResolve (node:internal/modules/esm/resolve:768:81) + at moduleResolve (node:internal/modules/esm/resolve:854:18) + at defaultResolve (node:internal/modules/esm/resolve:984:11) + at ModuleLoader.defaultResolve (node:internal/modules/esm/loader:716:12) + at #cachedDefaultResolve (node:internal/modules/esm/loader:640:25) + at ModuleLoader.resolve (node:internal/modules/esm/loader:623:38) + at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:276:38) + at ModuleJob._link (node:internal/modules/esm/module_job:136:49) { + code: 'ERR_MODULE_NOT_FOUND' +} + +Node.js v23.6.1 diff --git a/integration-tests/tests/__snapshots__/smoke-stderr-posix.txt b/integration-tests/tests/__snapshots__/smoke-stderr-posix.txt index 2205085d..81c72dbd 100644 --- a/integration-tests/tests/__snapshots__/smoke-stderr-posix.txt +++ b/integration-tests/tests/__snapshots__/smoke-stderr-posix.txt @@ -4,9 +4,8 @@ │ └ fsd/forbidden-imports: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/forbidden-imports -┌ src/entities -✘ Inconsistent pluralization of slice names. Prefer all plural names -✔ Auto-fixable +┌ src/entities/user +✘ Avoid having both "user" and "users" entities. │ └ fsd/inconsistent-naming: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/inconsistent-naming @@ -40,6 +39,6 @@ │ └ fsd/no-processes: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/no-processes -──────────────────────────────────────────────────────── - Found 8 errors (1 can be fixed automatically with --fix) - +──────────────────────────────────────────────── + Found 8 errors (none can be fixed automatically) + diff --git a/integration-tests/tests/__snapshots__/smoke-stderr-windows.txt b/integration-tests/tests/__snapshots__/smoke-stderr-windows.txt index 5dea1273..acbe3930 100644 --- a/integration-tests/tests/__snapshots__/smoke-stderr-windows.txt +++ b/integration-tests/tests/__snapshots__/smoke-stderr-windows.txt @@ -4,9 +4,8 @@ │ └ fsd/forbidden-imports: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/forbidden-imports -┌ src\entities -× Inconsistent pluralization of slice names. Prefer all plural names -√ Auto-fixable +┌ src\entities\user +× Avoid having both "user" and "users" entities. │ └ fsd/inconsistent-naming: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/inconsistent-naming @@ -40,6 +39,6 @@ │ └ fsd/no-processes: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/no-processes -──────────────────────────────────────────────────────── - Found 8 errors (1 can be fixed automatically with --fix) - +──────────────────────────────────────────────── + Found 8 errors (none can be fixed automatically) + diff --git a/integration-tests/tests/plugin-auto-discovery.test.ts b/integration-tests/tests/plugin-auto-discovery.test.ts new file mode 100644 index 00000000..3b8a2a8b --- /dev/null +++ b/integration-tests/tests/plugin-auto-discovery.test.ts @@ -0,0 +1,80 @@ +import * as fs from 'node:fs/promises' +import os from 'node:os' +import { join } from 'node:path' + +import { expect, test } from 'vitest' +import { createViteProject } from '../utils/create-vite-project.js' +import { exec } from 'tinyexec' +import { getSteigerBinPath } from '../utils/get-bin-path.js' +import { getSnapshotPath } from '../utils/get-snapshot-path.js' + +const temporaryDirectory = await fs.realpath(os.tmpdir()) +const steiger = await getSteigerBinPath() + +test( + 'auto plugin discovery works', + async () => { + const project = join(temporaryDirectory, 'auto-discovery') + await createViteProject(project) + + const plugin = join(temporaryDirectory, 'custom-steiger-plugin') + await createDummySteigerPlugin(plugin) + + await exec('npm', ['install'], { nodeOptions: { cwd: plugin } }) + await exec('npm', ['add', `steiger-plugin-dummy@file:${plugin}`], { nodeOptions: { cwd: project } }) + + const { stderr } = await exec(steiger, ['-v'], { nodeOptions: { cwd: project, env: { NO_COLOR: '1' } } }) + await expect(stderr).toMatchFileSnapshot(getSnapshotPath('auto-discovery-stderr')) + }, + { timeout: 15_000 }, +) + +async function createDummySteigerPlugin(location: string) { + await fs.rm(location, { recursive: true, force: true }) + await fs.mkdir(location, { recursive: true }) + const packageJsonContents = JSON.stringify( + { + name: 'steiger-plugin-dummy', + version: '1.0.0-alpha.0', + type: 'module', + exports: { + import: './index.mjs', + }, + dependencies: { + '@steiger/toolkit': '*', + }, + }, + null, + 2, + ) + await fs.writeFile(join(location, 'package.json'), packageJsonContents) + + const indexMjsContents = ` + import { enableAllRules, createPlugin, createConfigs } from '@steiger/toolkit'; + + const plugin = createPlugin({ + meta: { + name: 'steiger-plugin-dummy', + version: '1.0.0-alpha.0', + }, + ruleDefinitions: [ + { + name: 'dummy/rule1', + check(root) { + return { diagnostics: [{ message: 'Root detected', location: { path: root.path } }] }; + }, + }, + ], + }); + + const configs = createConfigs({ + recommended: enableAllRules(plugin), + }); + + export default { + plugin, + configs, + }; + ` + await fs.writeFile(join(location, 'index.mjs'), indexMjsContents) +} diff --git a/integration-tests/tests/smoke.test.ts b/integration-tests/tests/smoke.test.ts index c077c623..10a00a61 100644 --- a/integration-tests/tests/smoke.test.ts +++ b/integration-tests/tests/smoke.test.ts @@ -7,11 +7,11 @@ import { exec } from 'tinyexec' import { expect, test } from 'vitest' import { getSteigerBinPath } from '../utils/get-bin-path.js' +import { getSnapshotPath } from '../utils/get-snapshot-path.js' const temporaryDirectory = await fs.realpath(os.tmpdir()) const steiger = await getSteigerBinPath() const kitchenSinkExample = join(dirname(fileURLToPath(import.meta.url)), '../../examples/kitchen-sink-of-fsd-issues') -const pathPlatform = os.platform() === 'win32' ? 'windows' : 'posix' test('basic functionality in the kitchen sink example project', async () => { const project = join(temporaryDirectory, 'smoke') @@ -20,5 +20,5 @@ test('basic functionality in the kitchen sink example project', async () => { const { stderr } = await exec('node', [steiger, 'src'], { nodeOptions: { cwd: project, env: { NO_COLOR: '1' } } }) - await expect(stderr).toMatchFileSnapshot(join('__snapshots__', `smoke-stderr-${pathPlatform}.txt`)) + await expect(stderr).toMatchFileSnapshot(getSnapshotPath('smoke-stderr')) }) diff --git a/integration-tests/utils/create-vite-project.ts b/integration-tests/utils/create-vite-project.ts new file mode 100644 index 00000000..8419d768 --- /dev/null +++ b/integration-tests/utils/create-vite-project.ts @@ -0,0 +1,31 @@ +import * as fs from 'node:fs/promises' + +import { exec } from 'tinyexec' + +/** Run `npm create vite` in a given location using the Vanilla TS template (or a template of choice). */ +export async function createViteProject( + location: string, + { template = 'vanilla-ts' }: { template?: ViteTemplate } = {}, +) { + await fs.rm(location, { recursive: true, force: true }) + await fs.mkdir(location, { recursive: true }) + return exec('npm', ['create', 'vite', '-y', '--', '.', '--template', template], { nodeOptions: { cwd: location } }) +} + +type ViteTemplate = + | 'vanilla' + | 'vanilla-ts' + | 'vue' + | 'vue-ts' + | 'react' + | 'react-ts' + | 'preact' + | 'preact-ts' + | 'lit' + | 'lit-ts' + | 'svelte' + | 'svelte-ts' + | 'solid' + | 'solid-ts' + | 'qwik' + | 'qwik-ts' diff --git a/integration-tests/utils/get-snapshot-path.ts b/integration-tests/utils/get-snapshot-path.ts new file mode 100644 index 00000000..93be63e6 --- /dev/null +++ b/integration-tests/utils/get-snapshot-path.ts @@ -0,0 +1,8 @@ +import os from 'node:os' +import { join } from 'node:path' + +const pathPlatform = os.platform() === 'win32' ? 'windows' : 'posix' + +export function getSnapshotPath(snapshotName: string) { + return join('__snapshots__', `${snapshotName}-${pathPlatform}.txt`) +} diff --git a/packages/pretty-reporter/package.json b/packages/pretty-reporter/package.json index 69e3bae3..01a7df41 100644 --- a/packages/pretty-reporter/package.json +++ b/packages/pretty-reporter/package.json @@ -37,7 +37,8 @@ "eslint": "^9.18.0", "prettier": "^3.4.2", "tsx": "^4.19.2", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^3.0.2" }, "dependencies": { "figures": "^6.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdf61cc3..4af11d2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,15 @@ importers: specifier: ^2.3.3 version: 2.3.3 + examples/kitchen-sink-of-fsd-issues: + devDependencies: + '@feature-sliced/steiger-plugin': + specifier: workspace:* + version: link:../../packages/steiger-plugin-fsd + steiger: + specifier: workspace:* + version: link:../../packages/steiger + integration-tests: dependencies: steiger: @@ -112,6 +121,9 @@ importers: typescript: specifier: ^5.7.3 version: 5.7.3 + vitest: + specifier: ^3.0.2 + version: 3.0.4(@types/node@18.19.74) packages/steiger: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ca6439c9..287a99f4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - 'packages/*' - 'tooling/*' - 'integration-tests' + - 'examples/*' diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 00000000..ed749969 --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1 @@ +export default ['./packages/*', './integration-tests'] From 710355dc966421cdca169ee9b6c0684c47814e79 Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sat, 10 May 2025 15:55:42 +0200 Subject: [PATCH 05/21] Move the toolkit's internal tests out of the source Otherwise it messes with the bundler and lets `vitest` seep into the non-Vitest bundle --- packages/toolkit/src/find-all-recursively.ts | 41 ----- packages/toolkit/src/prepare-test.ts | 163 ------------------ .../src/tests/find-all-recursively.test.ts | 39 +++++ .../toolkit/src/tests/prepare-test.test.ts | 161 +++++++++++++++++ packages/toolkit/tsup.config.ts | 3 - packages/toolkit/vitest.config.ts | 7 - 6 files changed, 200 insertions(+), 214 deletions(-) create mode 100644 packages/toolkit/src/tests/find-all-recursively.test.ts create mode 100644 packages/toolkit/src/tests/prepare-test.test.ts delete mode 100644 packages/toolkit/vitest.config.ts diff --git a/packages/toolkit/src/find-all-recursively.ts b/packages/toolkit/src/find-all-recursively.ts index 4517c57d..6723f940 100644 --- a/packages/toolkit/src/find-all-recursively.ts +++ b/packages/toolkit/src/find-all-recursively.ts @@ -1,8 +1,5 @@ -import { basename } from 'node:path' import type { Folder, File } from '@steiger/types' -import { joinFromRoot, parseIntoFolder } from './prepare-test.js' - /** Recursively walk through a folder and return all entries that satisfy the predicate in a flat array. */ export function findAllRecursively(folder: Folder, predicate: (entry: Folder | File) => boolean): Array { const result: Array = [] @@ -22,41 +19,3 @@ export function findAllRecursively(folder: Folder, predicate: (entry: Folder | F walk(folder) return result } - -if (import.meta.vitest) { - const { test, expect } = import.meta.vitest - - test('findAllRecursively', () => { - const root = parseIntoFolder(` - 📂 folder1 - 📂 directory1 - 📄 styles.ts - 📄 Button.tsx - 📄 TextField.tsx - 📄 index.ts - 📂 folder2 - 📂 folder3 - 📂 directory2 - 📄 CommentCard.tsx - 📄 index.ts - 📂 directory3 - 📂 folder4 - 📂 folder5 - 📄 styles.ts - 📄 EditorPage.tsx - 📄 Editor.tsx - 📄 index.ts - `) - - const result = findAllRecursively( - root, - (entry) => entry.type === 'folder' && basename(entry.path).includes('directory'), - ) - - expect(result.map((entry) => entry.path)).toEqual([ - joinFromRoot('folder1', 'directory1'), - joinFromRoot('folder2', 'folder3', 'directory2'), - joinFromRoot('directory3'), - ]) - }) -} diff --git a/packages/toolkit/src/prepare-test.ts b/packages/toolkit/src/prepare-test.ts index d4b85a00..42d9a9b4 100644 --- a/packages/toolkit/src/prepare-test.ts +++ b/packages/toolkit/src/prepare-test.ts @@ -70,166 +70,3 @@ export function createFsMocks(mockedFiles: Record, original: typ }) as typeof existsSync), } as typeof import('fs') } - -if (import.meta.vitest) { - const { test, expect } = import.meta.vitest - - test('parseIntoFolder', () => { - const root = parseIntoFolder(` - 📂 entities - 📂 users - 📂 ui - 📄 index.ts - 📂 posts - 📂 ui - 📄 index.ts - 📂 shared - 📂 ui - 📄 index.ts - 📄 Button.tsx - `) - - expect(root).toEqual({ - type: 'folder', - path: joinFromRoot(), - children: [ - { - type: 'folder', - path: joinFromRoot('entities'), - children: [ - { - type: 'folder', - path: joinFromRoot('entities', 'users'), - children: [ - { - type: 'folder', - path: joinFromRoot('entities', 'users', 'ui'), - children: [], - }, - { - type: 'file', - path: joinFromRoot('entities', 'users', 'index.ts'), - }, - ], - }, - { - type: 'folder', - path: joinFromRoot('entities', 'posts'), - children: [ - { - type: 'folder', - path: joinFromRoot('entities', 'posts', 'ui'), - children: [], - }, - { - type: 'file', - path: joinFromRoot('entities', 'posts', 'index.ts'), - }, - ], - }, - ], - }, - { - type: 'folder', - path: joinFromRoot('shared'), - children: [ - { - type: 'folder', - path: joinFromRoot('shared', 'ui'), - children: [ - { - type: 'file', - path: joinFromRoot('shared', 'ui', 'index.ts'), - }, - { - type: 'file', - path: joinFromRoot('shared', 'ui', 'Button.tsx'), - }, - ], - }, - ], - }, - ], - }) - }) - - test('it should return a nested root folder when the optional rootPath argument is passed', () => { - const markup = ` - 📂 entities - 📂 users - 📂 ui - 📄 index.ts - 📂 posts - 📂 ui - 📄 index.ts - 📂 shared - 📂 ui - 📄 index.ts - 📄 Button.tsx - ` - const root = parseIntoFolder(markup, joinFromRoot('src')) - - expect(root).toEqual({ - type: 'folder', - path: joinFromRoot('src'), - children: [ - { - type: 'folder', - path: joinFromRoot('src', 'entities'), - children: [ - { - type: 'folder', - path: joinFromRoot('src', 'entities', 'users'), - children: [ - { - type: 'folder', - path: joinFromRoot('src', 'entities', 'users', 'ui'), - children: [], - }, - { - type: 'file', - path: joinFromRoot('src', 'entities', 'users', 'index.ts'), - }, - ], - }, - { - type: 'folder', - path: joinFromRoot('src', 'entities', 'posts'), - children: [ - { - type: 'folder', - path: joinFromRoot('src', 'entities', 'posts', 'ui'), - children: [], - }, - { - type: 'file', - path: joinFromRoot('src', 'entities', 'posts', 'index.ts'), - }, - ], - }, - ], - }, - { - type: 'folder', - path: joinFromRoot('src', 'shared'), - children: [ - { - type: 'folder', - path: joinFromRoot('src', 'shared', 'ui'), - children: [ - { - type: 'file', - path: joinFromRoot('src', 'shared', 'ui', 'index.ts'), - }, - { - type: 'file', - path: joinFromRoot('src', 'shared', 'ui', 'Button.tsx'), - }, - ], - }, - ], - }, - ], - }) - }) -} diff --git a/packages/toolkit/src/tests/find-all-recursively.test.ts b/packages/toolkit/src/tests/find-all-recursively.test.ts new file mode 100644 index 00000000..67d1ff3b --- /dev/null +++ b/packages/toolkit/src/tests/find-all-recursively.test.ts @@ -0,0 +1,39 @@ +import { basename } from 'node:path' +import { test, expect } from 'vitest' + +import { joinFromRoot, parseIntoFolder } from '../prepare-test.js' +import { findAllRecursively } from '../find-all-recursively.js' + +test('findAllRecursively', () => { + const root = parseIntoFolder(` + 📂 folder1 + 📂 directory1 + 📄 styles.ts + 📄 Button.tsx + 📄 TextField.tsx + 📄 index.ts + 📂 folder2 + 📂 folder3 + 📂 directory2 + 📄 CommentCard.tsx + 📄 index.ts + 📂 directory3 + 📂 folder4 + 📂 folder5 + 📄 styles.ts + 📄 EditorPage.tsx + 📄 Editor.tsx + 📄 index.ts + `) + + const result = findAllRecursively( + root, + (entry) => entry.type === 'folder' && basename(entry.path).includes('directory'), + ) + + expect(result.map((entry) => entry.path)).toEqual([ + joinFromRoot('folder1', 'directory1'), + joinFromRoot('folder2', 'folder3', 'directory2'), + joinFromRoot('directory3'), + ]) +}) diff --git a/packages/toolkit/src/tests/prepare-test.test.ts b/packages/toolkit/src/tests/prepare-test.test.ts new file mode 100644 index 00000000..598737f6 --- /dev/null +++ b/packages/toolkit/src/tests/prepare-test.test.ts @@ -0,0 +1,161 @@ +import { test, expect } from 'vitest' +import { joinFromRoot, parseIntoFolder } from '../prepare-test.js' + +test('parseIntoFolder', () => { + const root = parseIntoFolder(` + 📂 entities + 📂 users + 📂 ui + 📄 index.ts + 📂 posts + 📂 ui + 📄 index.ts + 📂 shared + 📂 ui + 📄 index.ts + 📄 Button.tsx + `) + + expect(root).toEqual({ + type: 'folder', + path: joinFromRoot(), + children: [ + { + type: 'folder', + path: joinFromRoot('entities'), + children: [ + { + type: 'folder', + path: joinFromRoot('entities', 'users'), + children: [ + { + type: 'folder', + path: joinFromRoot('entities', 'users', 'ui'), + children: [], + }, + { + type: 'file', + path: joinFromRoot('entities', 'users', 'index.ts'), + }, + ], + }, + { + type: 'folder', + path: joinFromRoot('entities', 'posts'), + children: [ + { + type: 'folder', + path: joinFromRoot('entities', 'posts', 'ui'), + children: [], + }, + { + type: 'file', + path: joinFromRoot('entities', 'posts', 'index.ts'), + }, + ], + }, + ], + }, + { + type: 'folder', + path: joinFromRoot('shared'), + children: [ + { + type: 'folder', + path: joinFromRoot('shared', 'ui'), + children: [ + { + type: 'file', + path: joinFromRoot('shared', 'ui', 'index.ts'), + }, + { + type: 'file', + path: joinFromRoot('shared', 'ui', 'Button.tsx'), + }, + ], + }, + ], + }, + ], + }) +}) + +test('it should return a nested root folder when the optional rootPath argument is passed', () => { + const markup = ` + 📂 entities + 📂 users + 📂 ui + 📄 index.ts + 📂 posts + 📂 ui + 📄 index.ts + 📂 shared + 📂 ui + 📄 index.ts + 📄 Button.tsx + ` + const root = parseIntoFolder(markup, joinFromRoot('src')) + + expect(root).toEqual({ + type: 'folder', + path: joinFromRoot('src'), + children: [ + { + type: 'folder', + path: joinFromRoot('src', 'entities'), + children: [ + { + type: 'folder', + path: joinFromRoot('src', 'entities', 'users'), + children: [ + { + type: 'folder', + path: joinFromRoot('src', 'entities', 'users', 'ui'), + children: [], + }, + { + type: 'file', + path: joinFromRoot('src', 'entities', 'users', 'index.ts'), + }, + ], + }, + { + type: 'folder', + path: joinFromRoot('src', 'entities', 'posts'), + children: [ + { + type: 'folder', + path: joinFromRoot('src', 'entities', 'posts', 'ui'), + children: [], + }, + { + type: 'file', + path: joinFromRoot('src', 'entities', 'posts', 'index.ts'), + }, + ], + }, + ], + }, + { + type: 'folder', + path: joinFromRoot('src', 'shared'), + children: [ + { + type: 'folder', + path: joinFromRoot('src', 'shared', 'ui'), + children: [ + { + type: 'file', + path: joinFromRoot('src', 'shared', 'ui', 'index.ts'), + }, + { + type: 'file', + path: joinFromRoot('src', 'shared', 'ui', 'Button.tsx'), + }, + ], + }, + ], + }, + ], + }) +}) diff --git a/packages/toolkit/tsup.config.ts b/packages/toolkit/tsup.config.ts index e54edc96..06982cdf 100644 --- a/packages/toolkit/tsup.config.ts +++ b/packages/toolkit/tsup.config.ts @@ -9,7 +9,4 @@ export default defineConfig({ }, treeshake: true, clean: true, - esbuildOptions(options) { - options.define = { 'import.meta.vitest': 'undefined' } - }, }) diff --git a/packages/toolkit/vitest.config.ts b/packages/toolkit/vitest.config.ts deleted file mode 100644 index a93210ad..00000000 --- a/packages/toolkit/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - includeSource: ['src/**/*.{js,ts}'], - }, -}) From ad7f49fd5f8e5ffade6643a0effb03264da06b86 Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sat, 10 May 2025 15:56:03 +0200 Subject: [PATCH 06/21] Finish the basic test for plugin discovery --- .../auto-discovery-stderr-posix.txt | 19 ------- .../tests/plugin-auto-discovery.test.ts | 51 +++++++++++++------ integration-tests/utils/get-bin-path.ts | 6 +-- integration-tests/utils/get-repo-root-path.ts | 9 ++++ 4 files changed, 47 insertions(+), 38 deletions(-) delete mode 100644 integration-tests/tests/__snapshots__/auto-discovery-stderr-posix.txt create mode 100644 integration-tests/utils/get-repo-root-path.ts diff --git a/integration-tests/tests/__snapshots__/auto-discovery-stderr-posix.txt b/integration-tests/tests/__snapshots__/auto-discovery-stderr-posix.txt deleted file mode 100644 index cf319867..00000000 --- a/integration-tests/tests/__snapshots__/auto-discovery-stderr-posix.txt +++ /dev/null @@ -1,19 +0,0 @@ -node:internal/modules/run_main:104 - triggerUncaughtException( - ^ - -Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'vitest' imported from /private/var/folders/0w/kjy1_yhx2kn54tm162jr4xrm0000gn/T/custom-steiger-plugin/node_modules/@steiger/toolkit/dist/index.js -Did you mean to import "vitest/index.cjs"? - at Object.getPackageJSONURL (node:internal/modules/package_json_reader:267:9) - at packageResolve (node:internal/modules/esm/resolve:768:81) - at moduleResolve (node:internal/modules/esm/resolve:854:18) - at defaultResolve (node:internal/modules/esm/resolve:984:11) - at ModuleLoader.defaultResolve (node:internal/modules/esm/loader:716:12) - at #cachedDefaultResolve (node:internal/modules/esm/loader:640:25) - at ModuleLoader.resolve (node:internal/modules/esm/loader:623:38) - at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:276:38) - at ModuleJob._link (node:internal/modules/esm/module_job:136:49) { - code: 'ERR_MODULE_NOT_FOUND' -} - -Node.js v23.6.1 diff --git a/integration-tests/tests/plugin-auto-discovery.test.ts b/integration-tests/tests/plugin-auto-discovery.test.ts index 3b8a2a8b..db095128 100644 --- a/integration-tests/tests/plugin-auto-discovery.test.ts +++ b/integration-tests/tests/plugin-auto-discovery.test.ts @@ -6,30 +6,49 @@ import { expect, test } from 'vitest' import { createViteProject } from '../utils/create-vite-project.js' import { exec } from 'tinyexec' import { getSteigerBinPath } from '../utils/get-bin-path.js' -import { getSnapshotPath } from '../utils/get-snapshot-path.js' +import { getRepoRootPath } from '../utils/get-repo-root-path.js' const temporaryDirectory = await fs.realpath(os.tmpdir()) +const repoRoot = getRepoRootPath() const steiger = await getSteigerBinPath() -test( - 'auto plugin discovery works', - async () => { - const project = join(temporaryDirectory, 'auto-discovery') - await createViteProject(project) +test('auto plugin discovery works', { timeout: 15_000 }, async () => { + const project = join(temporaryDirectory, 'auto-discovery') + await createViteProject(project) - const plugin = join(temporaryDirectory, 'custom-steiger-plugin') - await createDummySteigerPlugin(plugin) + const plugin = join(temporaryDirectory, 'custom-steiger-plugin') + await createDummySteigerPlugin(plugin) - await exec('npm', ['install'], { nodeOptions: { cwd: plugin } }) - await exec('npm', ['add', `steiger-plugin-dummy@file:${plugin}`], { nodeOptions: { cwd: project } }) + await exec('npm', ['install'], { nodeOptions: { cwd: plugin } }) + await exec('npm', ['add', `steiger-plugin-dummy@file:${plugin}`], { nodeOptions: { cwd: project } }) - const { stderr } = await exec(steiger, ['-v'], { nodeOptions: { cwd: project, env: { NO_COLOR: '1' } } }) - await expect(stderr).toMatchFileSnapshot(getSnapshotPath('auto-discovery-stderr')) - }, - { timeout: 15_000 }, -) + function getDetectedPlugins(versionOutput: string) { + const [_steigerVersion, plugins] = versionOutput.trim().split('\n\n', 2) + return plugins.split('\n').map((line) => ({ name: line.split('\t')[0], version: line.split('\t')[1] })) + } + + const resultWithOnlyDummy = await exec(steiger, ['-v'], { nodeOptions: { cwd: project, env: { NO_COLOR: '1' } } }) + expect(resultWithOnlyDummy.stderr).toEqual('') + expect(getDetectedPlugins(resultWithOnlyDummy.stdout)).toEqual([ + { name: 'steiger-plugin-dummy', version: '1.0.0-alpha.0' }, + ]) + + await exec( + 'npm', + ['add', `@feature-sliced/steiger-plugin@file:${join(repoRoot, 'packages', 'steiger-plugin-fsd')}`], + { nodeOptions: { cwd: project } }, + ) + + const resultWithDummyAndFsd = await exec(steiger, ['-v'], { nodeOptions: { cwd: project, env: { NO_COLOR: '1' } } }) + expect(resultWithDummyAndFsd.stderr).toEqual('') + expect(getDetectedPlugins(resultWithDummyAndFsd.stdout)).toEqual([ + { name: '@feature-sliced/steiger-plugin', version: expect.any(String) }, + { name: 'steiger-plugin-dummy', version: '1.0.0-alpha.0' }, + ]) +}) async function createDummySteigerPlugin(location: string) { + console.log('Creating dummy plugin at', location) await fs.rm(location, { recursive: true, force: true }) await fs.mkdir(location, { recursive: true }) const packageJsonContents = JSON.stringify( @@ -41,7 +60,7 @@ async function createDummySteigerPlugin(location: string) { import: './index.mjs', }, dependencies: { - '@steiger/toolkit': '*', + '@steiger/toolkit': `file:${join(repoRoot, 'packages', 'toolkit')}`, }, }, null, diff --git a/integration-tests/utils/get-bin-path.ts b/integration-tests/utils/get-bin-path.ts index 7bc8a84d..e68fa3ef 100644 --- a/integration-tests/utils/get-bin-path.ts +++ b/integration-tests/utils/get-bin-path.ts @@ -1,8 +1,8 @@ import { promises as fs } from 'node:fs' import * as process from 'node:process' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' +import { join } from 'node:path' import { getBinPath } from 'get-bin-path' +import { getRepoRootPath } from './get-repo-root-path.js' /** * Resolve the full path to the built JS file of Steiger. @@ -10,7 +10,7 @@ import { getBinPath } from 'get-bin-path' * Rejects if the file doesn't exist. */ export async function getSteigerBinPath() { - const steiger = (await getBinPath({ cwd: join(dirname(fileURLToPath(import.meta.url)), '../../packages/steiger') }))! + const steiger = (await getBinPath({ cwd: join(getRepoRootPath(), './packages/steiger') }))! try { await fs.stat(steiger) } catch { diff --git a/integration-tests/utils/get-repo-root-path.ts b/integration-tests/utils/get-repo-root-path.ts new file mode 100644 index 00000000..9fe80e24 --- /dev/null +++ b/integration-tests/utils/get-repo-root-path.ts @@ -0,0 +1,9 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** Return the absolute path to the root of this repository. */ +export function getRepoRootPath() { + const __dirname = dirname(fileURLToPath(import.meta.url)) + const repoRootPath = join(__dirname, '..', '..') + return repoRootPath +} From ae540342c388fe6fc7848024ea2320138e8077ad Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sat, 10 May 2025 16:35:23 +0200 Subject: [PATCH 07/21] Add some more unit tests --- .../discover-plugins/discover-plugins.ts | 153 +++++++++++++++--- .../discover-plugins/is-steiger-plugin.ts | 12 ++ 2 files changed, 141 insertions(+), 24 deletions(-) diff --git a/packages/steiger/src/features/discover-plugins/discover-plugins.ts b/packages/steiger/src/features/discover-plugins/discover-plugins.ts index e2a52991..875e311d 100644 --- a/packages/steiger/src/features/discover-plugins/discover-plugins.ts +++ b/packages/steiger/src/features/discover-plugins/discover-plugins.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises' import process from 'node:process' +import { join } from 'node:path' import * as pkg from 'empathic/package' import { ResolverFactory } from 'oxc-resolver' import type { Plugin, Config, Rule } from '@steiger/types' @@ -8,6 +9,8 @@ import { parsePackage } from './parse-package' import { isSteigerPlugin } from './is-steiger-plugin' import { parsePluginDefaultExport } from './parse-plugin-default-export' +type Configs = Record>> + const resolve = new ResolverFactory({ conditionNames: ['node', 'import'], }) @@ -27,34 +30,136 @@ export async function discoverPlugins(): Promise< } try { - const packageJson = await readFile(packageJsonPath, { encoding: 'utf-8' }).then(JSON.parse).then(parsePackage) - - const pluginNames = Object.keys(packageJson.dependencies ?? {}) - .concat(Object.keys(packageJson.devDependencies ?? {})) - .filter(isSteigerPlugin) + const pluginNames = await extractAllDependencies(packageJsonPath).then((deps) => deps.filter(isSteigerPlugin)) return Promise.all( - pluginNames.map(async (pluginName) => { - const pluginIndex = await resolve.async(process.cwd(), pluginName) - if (pluginIndex.path === undefined) { - throw new Error(`Could not resolve plugin ${pluginName}`) - } - const pluginExports = await import(pluginIndex.path) - const { plugin, configs } = await parsePluginDefaultExport(pluginExports.default) - let autoConfig: Config> | undefined - if ('recommended' in configs) { - autoConfig = configs.recommended - } else { - const configNames = Object.keys(configs) - if (configNames.length === 1) { - autoConfig = configs[configNames[0]] - } - } - - return { plugin, autoConfig } - }), + pluginNames.map((pluginName) => + loadSteigerPlugin(pluginName).then(({ plugin, configs }) => ({ + plugin, + autoConfig: findOptimalAutoConfig(configs), + })), + ), ) } catch { return [] } } + +/** Extract the names of dependencies and dev dependencies from a package.json file given its path. */ +async function extractAllDependencies(packageJsonPath: string): Promise> { + const packageJson = await readFile(packageJsonPath, 'utf-8').then(JSON.parse).then(parsePackage) + const dependencies = Object.keys(packageJson.dependencies || {}) + const devDependencies = Object.keys(packageJson.devDependencies || {}) + return [...dependencies, ...devDependencies] +} + +/** + * Load a Steiger plugin by package name. + * + * This assumes that the plugin can be resolved from the current working directory, + * i.e. the code in the current working directory would be able to import it. + */ +async function loadSteigerPlugin(pluginName: string): Promise<{ plugin: Plugin; configs: Configs }> { + const pluginIndex = await resolve.async(process.cwd(), pluginName) + if (pluginIndex.path === undefined) { + throw new Error(`Could not resolve plugin ${pluginName}`) + } + const pluginExports = await import(pluginIndex.path) + return parsePluginDefaultExport(pluginExports.default) +} + +/** + * Finds the most optimal configuration to load without explicit instruction from the user. + * + * 1. If the plugin has a `recommended` configuration, it will be returned. + * 2. If it doesn't, but there is only one configuration available, that one will be returned. + * 3. If there are multiple configurations, `undefined` will be returned as there is no clear choice. + */ +function findOptimalAutoConfig(pluginConfigs: Configs): Config> | undefined { + if ('recommended' in pluginConfigs) { + return pluginConfigs.recommended + } + + const configNames = Object.keys(pluginConfigs) + if (configNames.length === 1) { + return pluginConfigs[configNames[0]] + } + + return undefined +} + +if (import.meta.vitest) { + const { test, expect, describe, vi, beforeEach } = import.meta.vitest + const { vol } = await import('memfs') + const { joinFromRoot } = await import('@steiger/toolkit/test') + + vi.mock('node:fs/promises', () => import('memfs').then((memfs) => memfs.fs.promises)) + + describe('extractAllDependencies', () => { + const root = joinFromRoot('home', 'project') + const packageJsonPath = join(root, 'package.json') + + beforeEach(() => { + vol.reset() + vi.spyOn(process, 'cwd').mockReturnValue(root) + }) + + test('returns an empty array when package.json is empty', async () => { + vol.fromNestedJSON( + { + 'package.json': '{}', + }, + root, + ) + + await expect(extractAllDependencies(packageJsonPath)).resolves.toEqual([]) + }) + + test('returns an empty array when package.json has no dependencies', async () => { + vol.fromNestedJSON( + { + 'package.json': JSON.stringify({ dependencies: {}, devDependencies: {} }), + }, + root, + ) + + await expect(extractAllDependencies(packageJsonPath)).resolves.toEqual([]) + }) + + test('returns both dependencies and devDependencies', async () => { + vol.fromNestedJSON( + { + 'package.json': JSON.stringify({ + dependencies: { react: '^17.0.2' }, + devDependencies: { jest: '^26.6.0' }, + }), + }, + root, + ) + + await expect(extractAllDependencies(packageJsonPath)).resolves.toEqual(['react', 'jest']) + }) + }) + + describe('findOptimalAutoConfig', async () => { + const rightConfig: Config> = [] + const wrongConfig: Config> = [] + + test('returns nothing when there are no configs', () => { + expect(findOptimalAutoConfig({})).toBe(undefined) + }) + + test('returns the recommended config when it exists', () => { + expect(findOptimalAutoConfig({ recommended: rightConfig })).toBe(rightConfig) + expect(findOptimalAutoConfig({ recommended: rightConfig, other: wrongConfig })).toBe(rightConfig) + }) + + test('returns the only config when there is no recommended config', () => { + expect(findOptimalAutoConfig({ other: rightConfig })).toBe(rightConfig) + }) + + test('returns nothing when there are multiple configs', () => { + expect(findOptimalAutoConfig({ other: wrongConfig, other2: wrongConfig })).toBe(undefined) + }) + }) +} diff --git a/packages/steiger/src/features/discover-plugins/is-steiger-plugin.ts b/packages/steiger/src/features/discover-plugins/is-steiger-plugin.ts index 8e3619c3..892f1936 100644 --- a/packages/steiger/src/features/discover-plugins/is-steiger-plugin.ts +++ b/packages/steiger/src/features/discover-plugins/is-steiger-plugin.ts @@ -22,3 +22,15 @@ export function isSteigerPlugin(packageName: string) { return packageName.startsWith(`${pluginNamePrefix}-`) } } + +if (import.meta.vitest) { + const { test, expect } = import.meta.vitest + + test('isSteigerPlugin', () => { + expect(isSteigerPlugin('@someone/steiger-plugin-foo')).toBe(true) + expect(isSteigerPlugin('steiger-plugin-bar')).toBe(true) + expect(isSteigerPlugin('@someone-else/steiger-plugin')).toBe(true) + expect(isSteigerPlugin('plugin-foo')).toBe(false) + expect(isSteigerPlugin('steiger-foo')).toBe(false) + }) +} From 4952eb10a2bc43bf7e1b398a7a06d96756be7ea1 Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sat, 10 May 2025 16:36:20 +0200 Subject: [PATCH 08/21] Rename the integration test to fit the feature name being tested --- .../{plugin-auto-discovery.test.ts => discover-plugins.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename integration-tests/tests/{plugin-auto-discovery.test.ts => discover-plugins.test.ts} (100%) diff --git a/integration-tests/tests/plugin-auto-discovery.test.ts b/integration-tests/tests/discover-plugins.test.ts similarity index 100% rename from integration-tests/tests/plugin-auto-discovery.test.ts rename to integration-tests/tests/discover-plugins.test.ts From 8adac5604b1101a851e7fa255e9bc52c357dec47 Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sun, 11 May 2025 00:30:09 +0200 Subject: [PATCH 09/21] Add a test for suggesting and installing the FSD plugin --- .../tests/discover-plugins.test.ts | 58 ++++++++++++++++++- .../discover-plugins/suggest-fsd-plugin.ts | 6 +- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/integration-tests/tests/discover-plugins.test.ts b/integration-tests/tests/discover-plugins.test.ts index db095128..55b21807 100644 --- a/integration-tests/tests/discover-plugins.test.ts +++ b/integration-tests/tests/discover-plugins.test.ts @@ -1,6 +1,7 @@ import * as fs from 'node:fs/promises' import os from 'node:os' import { join } from 'node:path' +import type { ChildProcess } from 'node:child_process' import { expect, test } from 'vitest' import { createViteProject } from '../utils/create-vite-project.js' @@ -12,7 +13,7 @@ const temporaryDirectory = await fs.realpath(os.tmpdir()) const repoRoot = getRepoRootPath() const steiger = await getSteigerBinPath() -test('auto plugin discovery works', { timeout: 15_000 }, async () => { +test('auto plugin discovery', { timeout: 15_000 }, async () => { const project = join(temporaryDirectory, 'auto-discovery') await createViteProject(project) @@ -47,8 +48,39 @@ test('auto plugin discovery works', { timeout: 15_000 }, async () => { ]) }) +test('suggestion to install the FSD plugin', { timeout: 30_000 }, async () => { + const project = join(temporaryDirectory, 'suggest-fsd-plugin') + await createViteProject(project) + + const execResult = exec(steiger, ['./src'], { + nodeOptions: { stdio: 'pipe', cwd: project, env: { NO_COLOR: '1', npm_config_user_agent: undefined } }, + }) + const steigerProcess = execResult.process! + + await expect(getNewProcessOutput(steigerProcess, { until: '○ No' })).resolves.toContain( + "Couldn't find any plugins in package.json. Are you trying to check this project's compliance to Feature-Sliced Design (https://feature-sliced.design)?", + ) + steigerProcess.stdin?.write('y') + + await expect(getNewProcessOutput(steigerProcess, { until: '○ No' })).resolves.toContain( + 'Okay! Would you like to run `npm add -D @feature-sliced/steiger-plugin` in suggest-fsd-plugin (path: .) to install the FSD plugin?', + ) + steigerProcess.stdin?.write('y') + + await expect(getNewProcessOutput(steigerProcess, { until: 'All done!' })).resolves.toContain( + "All done! Now let's run the FSD checks.", + ) + + const packageJson = (await fs + .readFile(join(project, 'package.json'), { encoding: 'utf-8' }) + .then(JSON.parse)) as Record> + expect(packageJson.devDependencies['@feature-sliced/steiger-plugin']).not.toBeUndefined() + await expect(getNewProcessOutput(execResult.process!, { stream: 'stderr' })).resolves.toContain('No problems found!') + await execResult + expect(execResult.exitCode).toEqual(0) +}) + async function createDummySteigerPlugin(location: string) { - console.log('Creating dummy plugin at', location) await fs.rm(location, { recursive: true, force: true }) await fs.mkdir(location, { recursive: true }) const packageJsonContents = JSON.stringify( @@ -97,3 +129,25 @@ async function createDummySteigerPlugin(location: string) { ` await fs.writeFile(join(location, 'index.mjs'), indexMjsContents) } + +/** + * Read the stdout/stderr stream of the process until the specified string is found. + * + * If no string is specified, it will return the first chunk of output. + */ +function getNewProcessOutput( + process: ChildProcess, + { until, stream = 'stdout' }: { until?: string; stream?: 'stdout' | 'stderr' } = {}, +): Promise { + return new Promise((resolve) => { + let output = '' + function onData(data: string) { + output += data + if (until === undefined || output.includes(until)) { + process[stream]?.off('data', onData) + resolve(output) + } + } + process[stream]?.on('data', onData) + }) +} diff --git a/packages/steiger/src/features/discover-plugins/suggest-fsd-plugin.ts b/packages/steiger/src/features/discover-plugins/suggest-fsd-plugin.ts index dd47bd77..8b14f771 100644 --- a/packages/steiger/src/features/discover-plugins/suggest-fsd-plugin.ts +++ b/packages/steiger/src/features/discover-plugins/suggest-fsd-plugin.ts @@ -11,7 +11,9 @@ import { ExitException } from '../../shared/exit-exception' import { pluginNamePrefix } from './is-steiger-plugin' const fsdPlugin = '@feature-sliced/steiger-plugin' -const fsdWebsiteLink = terminalLink('Feature-Sliced Design', 'https://feature-sliced.design') +const fsdWebsiteLink = terminalLink('Feature-Sliced Design', 'https://feature-sliced.design', { + fallback: (text, url) => `${pc.reset(text)} (${pc.blue(url)})`, +}) /** * Ask if the user wants to run FSD checks and offer to install the FSD plugin. @@ -21,7 +23,7 @@ const fsdWebsiteLink = terminalLink('Feature-Sliced Design', 'https://feature-sl export async function suggestInstallingFsdPlugin() { const pm = whichPackageManagerRuns()?.name ?? whichLockfileExists() ?? 'npm' const packageJsonPath = pkg.up() - const addCommand = [pm, 'add', fsdPlugin] + const addCommand = [pm, 'add', '-D', fsdPlugin] const theyWantFsdChecks = await confirm({ message: From 9ed971537a1d279e84b557d1f4cb6c5ca0b6159f Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sun, 11 May 2025 11:51:18 +0200 Subject: [PATCH 10/21] Tell TypeScript that `targetPath` cannot be undefined --- packages/steiger/src/cli.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/steiger/src/cli.ts b/packages/steiger/src/cli.ts index a1b92d75..2d870025 100755 --- a/packages/steiger/src/cli.ts +++ b/packages/steiger/src/cli.ts @@ -153,7 +153,7 @@ const printDiagnostics = (diagnostics: Array) => { } if (consoleArgs.watch) { - const [diagnosticsChanged, stopWatching] = await linter.watch(targetPath) + const [diagnosticsChanged, stopWatching] = await linter.watch(targetPath!) const unsubscribe = diagnosticsChanged.watch((state) => { console.clear() printDiagnostics(state) @@ -166,7 +166,7 @@ if (consoleArgs.watch) { unsubscribe() }) } else { - const diagnostics = await linter.run(targetPath) + const diagnostics = await linter.run(targetPath!) let stillRelevantDiagnostics = diagnostics printDiagnostics(diagnostics) From e31c98854d38ae7aa9337dd39f5e43a78f5c774a Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sun, 11 May 2025 11:52:23 +0200 Subject: [PATCH 11/21] Double the integration test timeouts --- integration-tests/tests/discover-plugins.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration-tests/tests/discover-plugins.test.ts b/integration-tests/tests/discover-plugins.test.ts index 55b21807..05228c3d 100644 --- a/integration-tests/tests/discover-plugins.test.ts +++ b/integration-tests/tests/discover-plugins.test.ts @@ -13,7 +13,7 @@ const temporaryDirectory = await fs.realpath(os.tmpdir()) const repoRoot = getRepoRootPath() const steiger = await getSteigerBinPath() -test('auto plugin discovery', { timeout: 15_000 }, async () => { +test('auto plugin discovery', { timeout: 30_000 }, async () => { const project = join(temporaryDirectory, 'auto-discovery') await createViteProject(project) @@ -48,7 +48,7 @@ test('auto plugin discovery', { timeout: 15_000 }, async () => { ]) }) -test('suggestion to install the FSD plugin', { timeout: 30_000 }, async () => { +test('suggestion to install the FSD plugin', { timeout: 60_000 }, async () => { const project = join(temporaryDirectory, 'suggest-fsd-plugin') await createViteProject(project) From e577534609826777546d14e768b8e13a4cad2afa Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sun, 11 May 2025 11:54:41 +0200 Subject: [PATCH 12/21] Fix monorepo issues --- examples/kitchen-sink-of-fsd-issues/package.json | 5 +++-- packages/steiger/package.json | 2 +- pnpm-lock.yaml | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/examples/kitchen-sink-of-fsd-issues/package.json b/examples/kitchen-sink-of-fsd-issues/package.json index c19c953d..80c1a6d9 100644 --- a/examples/kitchen-sink-of-fsd-issues/package.json +++ b/examples/kitchen-sink-of-fsd-issues/package.json @@ -1,7 +1,8 @@ { + "name": "kitchen-sink-of-fsd-issues", "private": true, "devDependencies": { - "steiger": "workspace:*", - "@feature-sliced/steiger-plugin": "workspace:*" + "@feature-sliced/steiger-plugin": "workspace:*", + "steiger": "workspace:*" } } diff --git a/packages/steiger/package.json b/packages/steiger/package.json index 38098927..d3767f09 100644 --- a/packages/steiger/package.json +++ b/packages/steiger/package.json @@ -56,7 +56,7 @@ "picocolors": "^1.1.1", "prexit": "^2.3.0", "terminal-link": "^3.0.0", - "tinyexec": "^0.3.1", + "tinyexec": "^0.3.2", "yargs": "^17.7.2", "zod": "^3.24.1", "zod-validation-error": "^3.4.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4af11d2e..b8ee39e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,8 +176,8 @@ importers: specifier: ^3.0.0 version: 3.0.0 tinyexec: - specifier: ^0.3.1 - version: 0.3.1 + specifier: ^0.3.2 + version: 0.3.2 yargs: specifier: ^17.7.2 version: 17.7.2 From a0abd9af8733880b925448abf570ac3b3e53c138 Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sun, 11 May 2025 15:17:52 +0200 Subject: [PATCH 13/21] Use file URLs for Windows compatibility --- .../steiger/src/features/discover-plugins/discover-plugins.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/steiger/src/features/discover-plugins/discover-plugins.ts b/packages/steiger/src/features/discover-plugins/discover-plugins.ts index 875e311d..edc96e38 100644 --- a/packages/steiger/src/features/discover-plugins/discover-plugins.ts +++ b/packages/steiger/src/features/discover-plugins/discover-plugins.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises' import process from 'node:process' import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import * as pkg from 'empathic/package' import { ResolverFactory } from 'oxc-resolver' import type { Plugin, Config, Rule } from '@steiger/types' @@ -64,7 +65,7 @@ async function loadSteigerPlugin(pluginName: string): Promise<{ plugin: Plugin; if (pluginIndex.path === undefined) { throw new Error(`Could not resolve plugin ${pluginName}`) } - const pluginExports = await import(pluginIndex.path) + const pluginExports = await import(pathToFileURL(pluginIndex.path).toString()) return parsePluginDefaultExport(pluginExports.default) } From 89a5b3919e86bb2a02e778f5a63377cdc2edece4 Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sun, 11 May 2025 15:29:07 +0200 Subject: [PATCH 14/21] Install the Steiger plugin through a tarball during testing --- integration-tests/tests/smoke.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/integration-tests/tests/smoke.test.ts b/integration-tests/tests/smoke.test.ts index 10a00a61..4dfc958e 100644 --- a/integration-tests/tests/smoke.test.ts +++ b/integration-tests/tests/smoke.test.ts @@ -8,8 +8,10 @@ import { expect, test } from 'vitest' import { getSteigerBinPath } from '../utils/get-bin-path.js' import { getSnapshotPath } from '../utils/get-snapshot-path.js' +import { getRepoRootPath } from '../utils/get-repo-root-path.js' const temporaryDirectory = await fs.realpath(os.tmpdir()) +const repoRoot = getRepoRootPath() const steiger = await getSteigerBinPath() const kitchenSinkExample = join(dirname(fileURLToPath(import.meta.url)), '../../examples/kitchen-sink-of-fsd-issues') @@ -18,7 +20,17 @@ test('basic functionality in the kitchen sink example project', async () => { await fs.rm(project, { recursive: true, force: true }) await fs.cp(kitchenSinkExample, project, { recursive: true }) + const steigerPluginPath = join(repoRoot, 'packages', 'steiger-plugin-fsd') + const { stdout: steigerPluginTarball } = await exec('npm', ['pack'], { + nodeOptions: { cwd: steigerPluginPath }, + }) + await exec('npm', ['install', join(steigerPluginPath, steigerPluginTarball.trim())], { + nodeOptions: { cwd: project }, + }) + const { stderr } = await exec('node', [steiger, 'src'], { nodeOptions: { cwd: project, env: { NO_COLOR: '1' } } }) await expect(stderr).toMatchFileSnapshot(getSnapshotPath('smoke-stderr')) + + await fs.rm(join(steigerPluginPath, steigerPluginTarball.trim())) }) From b161df664abc07daf9a416ca82bf7f95bf805ac4 Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sun, 11 May 2025 15:31:08 +0200 Subject: [PATCH 15/21] Increase timeouts for the smoke test --- integration-tests/tests/smoke.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/tests/smoke.test.ts b/integration-tests/tests/smoke.test.ts index 4dfc958e..68923c0a 100644 --- a/integration-tests/tests/smoke.test.ts +++ b/integration-tests/tests/smoke.test.ts @@ -15,7 +15,7 @@ const repoRoot = getRepoRootPath() const steiger = await getSteigerBinPath() const kitchenSinkExample = join(dirname(fileURLToPath(import.meta.url)), '../../examples/kitchen-sink-of-fsd-issues') -test('basic functionality in the kitchen sink example project', async () => { +test('basic functionality in the kitchen sink example project', { timeout: 15_000 }, async () => { const project = join(temporaryDirectory, 'smoke') await fs.rm(project, { recursive: true, force: true }) await fs.cp(kitchenSinkExample, project, { recursive: true }) From a6db528d2f57974b60fc92d8860187861dca0b5c Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sun, 11 May 2025 15:35:10 +0200 Subject: [PATCH 16/21] Increase timeouts yet again --- integration-tests/tests/smoke.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/tests/smoke.test.ts b/integration-tests/tests/smoke.test.ts index 68923c0a..98152692 100644 --- a/integration-tests/tests/smoke.test.ts +++ b/integration-tests/tests/smoke.test.ts @@ -15,7 +15,7 @@ const repoRoot = getRepoRootPath() const steiger = await getSteigerBinPath() const kitchenSinkExample = join(dirname(fileURLToPath(import.meta.url)), '../../examples/kitchen-sink-of-fsd-issues') -test('basic functionality in the kitchen sink example project', { timeout: 15_000 }, async () => { +test('basic functionality in the kitchen sink example project', { timeout: 30_000 }, async () => { const project = join(temporaryDirectory, 'smoke') await fs.rm(project, { recursive: true, force: true }) await fs.cp(kitchenSinkExample, project, { recursive: true }) From 731b58196fa6226e75ed0e526dd2e28a8acf4232 Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sun, 11 May 2025 15:40:28 +0200 Subject: [PATCH 17/21] Increase timeouts for the other test too --- integration-tests/tests/discover-plugins.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration-tests/tests/discover-plugins.test.ts b/integration-tests/tests/discover-plugins.test.ts index 05228c3d..da4c0fd9 100644 --- a/integration-tests/tests/discover-plugins.test.ts +++ b/integration-tests/tests/discover-plugins.test.ts @@ -13,7 +13,7 @@ const temporaryDirectory = await fs.realpath(os.tmpdir()) const repoRoot = getRepoRootPath() const steiger = await getSteigerBinPath() -test('auto plugin discovery', { timeout: 30_000 }, async () => { +test('auto plugin discovery', { timeout: 60_000 }, async () => { const project = join(temporaryDirectory, 'auto-discovery') await createViteProject(project) @@ -48,7 +48,7 @@ test('auto plugin discovery', { timeout: 30_000 }, async () => { ]) }) -test('suggestion to install the FSD plugin', { timeout: 60_000 }, async () => { +test('suggestion to install the FSD plugin', { timeout: 2 * 60_000 }, async () => { const project = join(temporaryDirectory, 'suggest-fsd-plugin') await createViteProject(project) From e644f7330ce0cbc70a2c928f96f97c07982e60be Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Sun, 11 May 2025 15:52:57 +0200 Subject: [PATCH 18/21] Really increase timeouts this time --- integration-tests/tests/discover-plugins.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/tests/discover-plugins.test.ts b/integration-tests/tests/discover-plugins.test.ts index da4c0fd9..0a1baded 100644 --- a/integration-tests/tests/discover-plugins.test.ts +++ b/integration-tests/tests/discover-plugins.test.ts @@ -48,7 +48,7 @@ test('auto plugin discovery', { timeout: 60_000 }, async () => { ]) }) -test('suggestion to install the FSD plugin', { timeout: 2 * 60_000 }, async () => { +test('suggestion to install the FSD plugin', { timeout: 4 * 60_000 }, async () => { const project = join(temporaryDirectory, 'suggest-fsd-plugin') await createViteProject(project) From 6952b0c6fbffeacd142a3b64360e5282d566ea1f Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Tue, 13 May 2025 06:45:17 +0200 Subject: [PATCH 19/21] Clean up workspace node_modules in the test --- integration-tests/tests/smoke.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/integration-tests/tests/smoke.test.ts b/integration-tests/tests/smoke.test.ts index 98152692..0f27cc6d 100644 --- a/integration-tests/tests/smoke.test.ts +++ b/integration-tests/tests/smoke.test.ts @@ -19,6 +19,8 @@ test('basic functionality in the kitchen sink example project', { timeout: 30_00 const project = join(temporaryDirectory, 'smoke') await fs.rm(project, { recursive: true, force: true }) await fs.cp(kitchenSinkExample, project, { recursive: true }) + await fs.rm(join(project, 'node_modules'), { recursive: true, force: true }) + await fs.rm(join(project, 'package.json'), { force: true }) const steigerPluginPath = join(repoRoot, 'packages', 'steiger-plugin-fsd') const { stdout: steigerPluginTarball } = await exec('npm', ['pack'], { From 6fa570a5afd71a0b09000c2ed18910fb0e7c4b92 Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Tue, 13 May 2025 06:57:38 +0200 Subject: [PATCH 20/21] Copy test files selectively --- integration-tests/tests/smoke.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/integration-tests/tests/smoke.test.ts b/integration-tests/tests/smoke.test.ts index 0f27cc6d..bb2e644c 100644 --- a/integration-tests/tests/smoke.test.ts +++ b/integration-tests/tests/smoke.test.ts @@ -18,9 +18,10 @@ const kitchenSinkExample = join(dirname(fileURLToPath(import.meta.url)), '../../ test('basic functionality in the kitchen sink example project', { timeout: 30_000 }, async () => { const project = join(temporaryDirectory, 'smoke') await fs.rm(project, { recursive: true, force: true }) - await fs.cp(kitchenSinkExample, project, { recursive: true }) - await fs.rm(join(project, 'node_modules'), { recursive: true, force: true }) - await fs.rm(join(project, 'package.json'), { force: true }) + await fs.mkdir(join(project, 'src'), { recursive: true }) + await fs.cp(join(kitchenSinkExample, 'src'), join(project, 'src'), { recursive: true }) + await fs.cp(join(kitchenSinkExample, 'tsconfig.app.json'), join(project, 'tsconfig.app.json')) + await fs.cp(join(kitchenSinkExample, 'tsconfig.json'), join(project, 'tsconfig.json')) const steigerPluginPath = join(repoRoot, 'packages', 'steiger-plugin-fsd') const { stdout: steigerPluginTarball } = await exec('npm', ['pack'], { From f61536f74543ffd6e2b434dc55e7080fdb5b24e4 Mon Sep 17 00:00:00 2001 From: Lev Chelyadinov Date: Tue, 13 May 2025 07:13:13 +0200 Subject: [PATCH 21/21] debug --- integration-tests/tests/discover-plugins.test.ts | 3 +++ integration-tests/tests/smoke.test.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/integration-tests/tests/discover-plugins.test.ts b/integration-tests/tests/discover-plugins.test.ts index 0a1baded..4a1b1e18 100644 --- a/integration-tests/tests/discover-plugins.test.ts +++ b/integration-tests/tests/discover-plugins.test.ts @@ -60,16 +60,19 @@ test('suggestion to install the FSD plugin', { timeout: 4 * 60_000 }, async () = await expect(getNewProcessOutput(steigerProcess, { until: '○ No' })).resolves.toContain( "Couldn't find any plugins in package.json. Are you trying to check this project's compliance to Feature-Sliced Design (https://feature-sliced.design)?", ) + console.log('got first batch of output') steigerProcess.stdin?.write('y') await expect(getNewProcessOutput(steigerProcess, { until: '○ No' })).resolves.toContain( 'Okay! Would you like to run `npm add -D @feature-sliced/steiger-plugin` in suggest-fsd-plugin (path: .) to install the FSD plugin?', ) + console.log('got second batch of output') steigerProcess.stdin?.write('y') await expect(getNewProcessOutput(steigerProcess, { until: 'All done!' })).resolves.toContain( "All done! Now let's run the FSD checks.", ) + console.log('got third batch of output') const packageJson = (await fs .readFile(join(project, 'package.json'), { encoding: 'utf-8' }) diff --git a/integration-tests/tests/smoke.test.ts b/integration-tests/tests/smoke.test.ts index bb2e644c..6b2f937d 100644 --- a/integration-tests/tests/smoke.test.ts +++ b/integration-tests/tests/smoke.test.ts @@ -26,9 +26,11 @@ test('basic functionality in the kitchen sink example project', { timeout: 30_00 const steigerPluginPath = join(repoRoot, 'packages', 'steiger-plugin-fsd') const { stdout: steigerPluginTarball } = await exec('npm', ['pack'], { nodeOptions: { cwd: steigerPluginPath }, + throwOnError: true, }) await exec('npm', ['install', join(steigerPluginPath, steigerPluginTarball.trim())], { nodeOptions: { cwd: project }, + throwOnError: true, }) const { stderr } = await exec('node', [steiger, 'src'], { nodeOptions: { cwd: project, env: { NO_COLOR: '1' } } })