diff --git a/.changeset/curly-years-sparkle.md b/.changeset/curly-years-sparkle.md new file mode 100644 index 00000000..42822ee2 --- /dev/null +++ b/.changeset/curly-years-sparkle.md @@ -0,0 +1,5 @@ +--- +'@steiger/toolkit': minor +--- + +Separate Vitest-dependent utilities into a different entrypoint to make Vitest a truly optional peer dependency 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..80c1a6d9 --- /dev/null +++ b/examples/kitchen-sink-of-fsd-issues/package.json @@ -0,0 +1,8 @@ +{ + "name": "kitchen-sink-of-fsd-issues", + "private": true, + "devDependencies": { + "@feature-sliced/steiger-plugin": "workspace:*", + "steiger": "workspace:*" + } +} 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/discover-plugins.test.ts b/integration-tests/tests/discover-plugins.test.ts new file mode 100644 index 00000000..4a1b1e18 --- /dev/null +++ b/integration-tests/tests/discover-plugins.test.ts @@ -0,0 +1,156 @@ +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' +import { exec } from 'tinyexec' +import { getSteigerBinPath } from '../utils/get-bin-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', { timeout: 60_000 }, 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 } }) + + 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' }, + ]) +}) + +test('suggestion to install the FSD plugin', { timeout: 4 * 60_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)?", + ) + 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' }) + .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) { + 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': `file:${join(repoRoot, 'packages', '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) +} + +/** + * 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/integration-tests/tests/smoke.test.ts b/integration-tests/tests/smoke.test.ts index c077c623..6b2f937d 100644 --- a/integration-tests/tests/smoke.test.ts +++ b/integration-tests/tests/smoke.test.ts @@ -7,18 +7,35 @@ 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' +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') -const pathPlatform = os.platform() === 'win32' ? 'windows' : 'posix' -test('basic functionality in the kitchen sink example project', 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 }) + 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'], { + 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' } } }) - await expect(stderr).toMatchFileSnapshot(join('__snapshots__', `smoke-stderr-${pathPlatform}.txt`)) + await expect(stderr).toMatchFileSnapshot(getSnapshotPath('smoke-stderr')) + + await fs.rm(join(steigerPluginPath, steigerPluginTarball.trim())) }) 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-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 +} 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/packages/steiger-plugin-fsd/src/_lib/collect-related-ts-configs.ts b/packages/steiger-plugin-fsd/src/_lib/collect-related-ts-configs.ts index 25cba350..eb31c461 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 7c8c63dc..7830b2c6 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 4ba889cf..5c66e714 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 abb1479d..ea55e655 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 09c70aba..23b71c92 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 ce76d4ba..5f4e6d45 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) => { @@ -14,7 +14,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 1941fd02..dd8871e7 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 { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' import inconsistentNaming from './index.js' it('reports no errors on entity 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 47c2ecd4..9276bf19 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) => { @@ -15,7 +15,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 95a492e4..a58a717d 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 a742dce0..fb2c9bf3 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 27839e38..bcb26810 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 4ca5f9fb..08a6820e 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) => { @@ -14,7 +14,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 491cd4ed..3cf72972 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 4319bedf..47d0c7bb 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 d45f4b64..bbf4c358 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 f712f5ee..ee0021fb 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 659843a9..dba240a8 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 2e0d8a59..c7957ba2 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 b0a695c9..98d25fd3 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 37ec76d1..afc22fcb 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 111e2f56..a3d97aca 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/package.json b/packages/steiger/package.json index 1800cb10..d3767f09 100644 --- a/packages/steiger/package.json +++ b/packages/steiger/package.json @@ -51,9 +51,12 @@ "immer": "^10.1.1", "lodash-es": "^4.17.21", "micromatch": "^4.0.8", + "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.2", "yargs": "^17.7.2", "zod": "^3.24.1", "zod-validation-error": "^3.4.0" @@ -73,5 +76,12 @@ "tsx": "^4.19.2", "typescript": "^5.7.3", "vitest": "^3.0.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..2d870025 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) => { @@ -124,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) @@ -137,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) 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..3c47ca2b 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}`) @@ -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..f7959660 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 @@ -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/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..edc96e38 --- /dev/null +++ b/packages/steiger/src/features/discover-plugins/discover-plugins.ts @@ -0,0 +1,166 @@ +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' + +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'], +}) + +/** + * 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 pluginNames = await extractAllDependencies(packageJsonPath).then((deps) => deps.filter(isSteigerPlugin)) + + return Promise.all( + 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(pathToFileURL(pluginIndex.path).toString()) + 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/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..892f1936 --- /dev/null +++ b/packages/steiger/src/features/discover-plugins/is-steiger-plugin.ts @@ -0,0 +1,36 @@ +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}-`) + } +} + +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) + }) +} 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..8b14f771 --- /dev/null +++ b/packages/steiger/src/features/discover-plugins/suggest-fsd-plugin.ts @@ -0,0 +1,106 @@ +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', { + 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. + * + * 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', '-D', 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/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/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/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/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/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/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/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/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/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/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 3113b515..06982cdf 100644 --- a/packages/toolkit/tsup.config.ts +++ b/packages/toolkit/tsup.config.ts @@ -1,15 +1,12 @@ 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, 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}'], - }, -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f35ae6d..b8ee39e8 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: @@ -148,6 +160,9 @@ importers: micromatch: specifier: ^4.0.8 version: 4.0.8 + oxc-resolver: + specifier: ^3.0.3 + version: 3.0.3 patronum: specifier: ^2.3.0 version: 2.3.0(effector@23.2.3) @@ -157,6 +172,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.2 + version: 0.3.2 yargs: specifier: ^17.7.2 version: 17.7.2 @@ -416,6 +437,15 @@ packages: 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'} @@ -978,6 +1008,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'} @@ -990,6 +1023,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'} @@ -1213,6 +1301,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==} @@ -2277,6 +2368,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'} @@ -3195,6 +3289,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 @@ -3595,6 +3705,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 @@ -3607,6 +3724,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 @@ -3771,6 +3923,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 @@ -4920,6 +5077,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/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/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: '^_' }], }, }, ] 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']