diff --git a/.changeset/stale-brooms-sparkle.md b/.changeset/stale-brooms-sparkle.md new file mode 100644 index 0000000..15a875f --- /dev/null +++ b/.changeset/stale-brooms-sparkle.md @@ -0,0 +1,8 @@ +--- +'@feature-sliced/steiger-plugin': minor +'@steiger/pretty-reporter': minor +'steiger': minor +'@steiger/types': minor +--- + +Complete configuration parsing and application logic diff --git a/packages/pretty-reporter/src/format-single-diagnostic.ts b/packages/pretty-reporter/src/format-single-diagnostic.ts index 0c79ebe..f6f81db 100644 --- a/packages/pretty-reporter/src/format-single-diagnostic.ts +++ b/packages/pretty-reporter/src/format-single-diagnostic.ts @@ -3,9 +3,9 @@ import figures from 'figures' import terminalLink from 'terminal-link' import chalk from 'chalk' -import type { AugmentedDiagnostic } from './types.js' +import type { Diagnostic } from '@steiger/types' -export function formatSingleDiagnostic(d: AugmentedDiagnostic, cwd: string): string { +export function formatSingleDiagnostic(d: Diagnostic, cwd: string): string { const x = d.severity === 'error' ? chalk.red(figures.cross) : chalk.yellow(figures.warning) const s = chalk.reset(figures.lineDownRight) const bar = chalk.reset(figures.lineVertical) @@ -23,7 +23,7 @@ ${e} ${ruleName} `.trim() } -function formatLocation(location: AugmentedDiagnostic['location'], cwd: string) { +function formatLocation(location: Diagnostic['location'], cwd: string) { let path = relative(cwd, location.path) if (location.line !== undefined) { path += `:${location.line}` diff --git a/packages/pretty-reporter/src/index.ts b/packages/pretty-reporter/src/index.ts index ed3a909..c73f875 100644 --- a/packages/pretty-reporter/src/index.ts +++ b/packages/pretty-reporter/src/index.ts @@ -1,11 +1,11 @@ import chalk from 'chalk' import figures from 'figures' -import type { AugmentedDiagnostic } from './types.js' +import type { Diagnostic } from '@steiger/types' import { formatSingleDiagnostic } from './format-single-diagnostic.js' import { s } from './pluralization.js' -export function formatPretty(diagnostics: Array, cwd: string) { +export function formatPretty(diagnostics: Array, cwd: string) { if (diagnostics.length === 0) { return chalk.green(`${figures.tick} No problems found!`) } @@ -43,8 +43,8 @@ export function formatPretty(diagnostics: Array, cwd: strin ) } -export function reportPretty(diagnostics: Array, cwd: string) { +export function reportPretty(diagnostics: Array, cwd: string) { console.error(formatPretty(diagnostics, cwd)) } -export type { AugmentedDiagnostic } +export type { Diagnostic } diff --git a/packages/pretty-reporter/src/types.ts b/packages/pretty-reporter/src/types.ts deleted file mode 100644 index 53297bf..0000000 --- a/packages/pretty-reporter/src/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Diagnostic, Severity } from '@steiger/types' - -export interface AugmentedDiagnostic extends Diagnostic { - ruleName: string - severity: Exclude - getRuleDescriptionUrl(ruleName: string): URL -} diff --git a/packages/steiger-plugin-fsd/src/_lib/prepare-test.ts b/packages/steiger-plugin-fsd/src/_lib/prepare-test.ts index 3d6f98a..70cbe7f 100644 --- a/packages/steiger-plugin-fsd/src/_lib/prepare-test.ts +++ b/packages/steiger-plugin-fsd/src/_lib/prepare-test.ts @@ -1,7 +1,7 @@ import { join, sep } from 'node:path' import type { readFileSync, existsSync } from 'node:fs' import type { FsdRoot } from '@feature-sliced/filesystem' -import type { Folder, File, Diagnostic } from '@steiger/types' +import type { Folder, File, PartialDiagnostic } from '@steiger/types' import { vi } from 'vitest' /** Parse a multi-line indented string with emojis for files and folders into an FSD root. @@ -39,7 +39,7 @@ export function parseIntoFsdRoot(fsMarkup: string, mountTo?: string): FsdRoot { return parseFolder(lines, mountTo ?? joinFromRoot()) } -export function compareMessages(a: Diagnostic, b: Diagnostic): number { +export function compareMessages(a: PartialDiagnostic, b: PartialDiagnostic): number { return a.message.localeCompare(b.message) || a.location.path.localeCompare(b.location.path) } diff --git a/packages/steiger-plugin-fsd/src/ambiguous-slice-names/index.ts b/packages/steiger-plugin-fsd/src/ambiguous-slice-names/index.ts index 123fe72..cfd54ce 100644 --- a/packages/steiger-plugin-fsd/src/ambiguous-slice-names/index.ts +++ b/packages/steiger-plugin-fsd/src/ambiguous-slice-names/index.ts @@ -1,13 +1,13 @@ import { basename, sep } from 'node:path' import { getAllSlices, getLayers, getSegments, type LayerName } from '@feature-sliced/filesystem' -import type { Diagnostic, Folder, Rule } from '@steiger/types' +import type { PartialDiagnostic, Folder, Rule } from '@steiger/types' import { NAMESPACE } from '../constants.js' /** Forbid slice names that match some segment’s name in shared (e.g., theme, i18n) */ const ambiguousSliceNames = { name: `${NAMESPACE}/ambiguous-slice-names`, check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] const layers = getLayers(root) const sharedLayer = layers.shared diff --git a/packages/steiger-plugin-fsd/src/excessive-slicing/index.ts b/packages/steiger-plugin-fsd/src/excessive-slicing/index.ts index ad55a2e..18d838e 100644 --- a/packages/steiger-plugin-fsd/src/excessive-slicing/index.ts +++ b/packages/steiger-plugin-fsd/src/excessive-slicing/index.ts @@ -1,6 +1,6 @@ import { join } from 'node:path' import { getLayers, getSlices, isSliced } from '@feature-sliced/filesystem' -import type { Diagnostic, Rule } from '@steiger/types' +import type { PartialDiagnostic, Rule } from '@steiger/types' import { groupSlices } from '../_lib/group-slices.js' import { NAMESPACE } from '../constants.js' @@ -16,7 +16,7 @@ const THRESHOLDS = { const excessiveSlicing = { name: `${NAMESPACE}/excessive-slicing`, check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] for (const [layerName, layer] of Object.entries(getLayers(root))) { if (!isSliced(layer) || !(layerName in THRESHOLDS)) { diff --git a/packages/steiger-plugin-fsd/src/forbidden-imports/index.ts b/packages/steiger-plugin-fsd/src/forbidden-imports/index.ts index 88201b9..8f7828d 100644 --- a/packages/steiger-plugin-fsd/src/forbidden-imports/index.ts +++ b/packages/steiger-plugin-fsd/src/forbidden-imports/index.ts @@ -3,7 +3,7 @@ import { layerSequence, resolveImport } from '@feature-sliced/filesystem' import precinct from 'precinct' const { paperwork } = precinct import { parse as parseNearestTsConfig } from 'tsconfck' -import type { Diagnostic, Rule } from '@steiger/types' +import type { PartialDiagnostic, Rule } from '@steiger/types' import { indexSourceFiles } from '../_lib/index-source-files.js' import { NAMESPACE } from '../constants.js' @@ -11,7 +11,7 @@ import { NAMESPACE } from '../constants.js' const forbiddenImports = { name: `${NAMESPACE}/forbidden-imports`, async check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] const { tsconfig } = await parseNearestTsConfig(root.path) const sourceFileIndex = indexSourceFiles(root) diff --git a/packages/steiger-plugin-fsd/src/import-locality/index.ts b/packages/steiger-plugin-fsd/src/import-locality/index.ts index 4d97f09..9d663f5 100644 --- a/packages/steiger-plugin-fsd/src/import-locality/index.ts +++ b/packages/steiger-plugin-fsd/src/import-locality/index.ts @@ -3,7 +3,7 @@ import { resolveImport } from '@feature-sliced/filesystem' import precinct from 'precinct' const { paperwork } = precinct import { parse as parseNearestTsConfig } from 'tsconfck' -import type { Diagnostic, Rule } from '@steiger/types' +import type { PartialDiagnostic, Rule } from '@steiger/types' import { indexSourceFiles } from '../_lib/index-source-files.js' import { NAMESPACE } from '../constants.js' @@ -11,7 +11,7 @@ import { NAMESPACE } from '../constants.js' const importLocality = { name: `${NAMESPACE}/import-locality`, async check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] const { tsconfig } = await parseNearestTsConfig(root.path) const sourceFileIndex = indexSourceFiles(root) diff --git a/packages/steiger-plugin-fsd/src/inconsistent-naming/index.ts b/packages/steiger-plugin-fsd/src/inconsistent-naming/index.ts index 470c16b..7b6aa8e 100644 --- a/packages/steiger-plugin-fsd/src/inconsistent-naming/index.ts +++ b/packages/steiger-plugin-fsd/src/inconsistent-naming/index.ts @@ -3,7 +3,7 @@ import { partition } from 'lodash-es' import pluralize from 'pluralize' const { isPlural, plural, singular } = pluralize import { getLayers, getSlices } from '@feature-sliced/filesystem' -import type { Diagnostic, Rule } from '@steiger/types' +import type { PartialDiagnostic, Rule } from '@steiger/types' import { groupSlices } from '../_lib/group-slices.js' import { NAMESPACE } from '../constants.js' @@ -12,7 +12,7 @@ import { NAMESPACE } from '../constants.js' const inconsistentNaming = { name: `${NAMESPACE}/inconsistent-naming`, check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] const { entities } = getLayers(root) if (entities === undefined) { diff --git a/packages/steiger-plugin-fsd/src/insignificant-slice/index.ts b/packages/steiger-plugin-fsd/src/insignificant-slice/index.ts index 40cafa9..9636184 100644 --- a/packages/steiger-plugin-fsd/src/insignificant-slice/index.ts +++ b/packages/steiger-plugin-fsd/src/insignificant-slice/index.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs' import { sep, join } from 'node:path' import { parse as parseNearestTsConfig } from 'tsconfck' import { isSliced, resolveImport, unslicedLayers, type LayerName } from '@feature-sliced/filesystem' -import type { Folder, Diagnostic, Rule } from '@steiger/types' +import type { Folder, PartialDiagnostic, Rule } from '@steiger/types' import precinct from 'precinct' const { paperwork } = precinct @@ -12,7 +12,7 @@ import { NAMESPACE } from '../constants.js' const insignificantSlice = { name: `${NAMESPACE}/insignificant-slice`, async check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] const references = await traceSliceReferences(root) diff --git a/packages/steiger-plugin-fsd/src/no-file-segments/index.ts b/packages/steiger-plugin-fsd/src/no-file-segments/index.ts index f7d0c68..1543149 100644 --- a/packages/steiger-plugin-fsd/src/no-file-segments/index.ts +++ b/packages/steiger-plugin-fsd/src/no-file-segments/index.ts @@ -1,12 +1,12 @@ import { basename } from 'node:path' import { getLayers, getSlices, isSliced } from '@feature-sliced/filesystem' -import type { Diagnostic, Rule } from '@steiger/types' +import type { PartialDiagnostic, Rule } from '@steiger/types' import { NAMESPACE } from '../constants.js' const noFileSegments = { name: `${NAMESPACE}/no-file-segments`, check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] for (const layer of Object.values(getLayers(root))) { if (!isSliced(layer)) { diff --git a/packages/steiger-plugin-fsd/src/no-layer-public-api/index.ts b/packages/steiger-plugin-fsd/src/no-layer-public-api/index.ts index 80206dd..ac6ad5c 100644 --- a/packages/steiger-plugin-fsd/src/no-layer-public-api/index.ts +++ b/packages/steiger-plugin-fsd/src/no-layer-public-api/index.ts @@ -1,5 +1,5 @@ import { getIndex, getLayers } from '@feature-sliced/filesystem' -import type { Diagnostic, Rule } from '@steiger/types' +import type { PartialDiagnostic, Rule } from '@steiger/types' import { NAMESPACE } from '../constants.js' /** Layers that are allowed to have an index file. */ @@ -9,7 +9,7 @@ const exceptionLayers = ['app'] const noLayerPublicApi = { name: `${NAMESPACE}/no-layer-public-api`, check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] for (const [layerName, layer] of Object.entries(getLayers(root))) { const index = getIndex(layer) diff --git a/packages/steiger-plugin-fsd/src/no-processes/index.ts b/packages/steiger-plugin-fsd/src/no-processes/index.ts index f33dda4..95c9f9e 100644 --- a/packages/steiger-plugin-fsd/src/no-processes/index.ts +++ b/packages/steiger-plugin-fsd/src/no-processes/index.ts @@ -1,11 +1,11 @@ import { basename } from 'node:path' -import type { Diagnostic, Rule } from '@steiger/types' +import type { PartialDiagnostic, Rule } from '@steiger/types' import { NAMESPACE } from '../constants.js' const noProcesses = { name: `${NAMESPACE}/no-processes`, check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] const processesLayer = root.children.find( (child) => child.type === 'folder' && basename(child.path) === 'processes', diff --git a/packages/steiger-plugin-fsd/src/no-public-api-sidestep/index.ts b/packages/steiger-plugin-fsd/src/no-public-api-sidestep/index.ts index 75a9d34..0312304 100644 --- a/packages/steiger-plugin-fsd/src/no-public-api-sidestep/index.ts +++ b/packages/steiger-plugin-fsd/src/no-public-api-sidestep/index.ts @@ -3,7 +3,7 @@ import precinct from 'precinct' const { paperwork } = precinct import { parse as parseNearestTsConfig } from 'tsconfck' import { getIndex, getLayers, getSegments, isSliced, resolveImport } from '@feature-sliced/filesystem' -import type { Folder, File, Diagnostic, Rule } from '@steiger/types' +import type { Folder, File, PartialDiagnostic, Rule } from '@steiger/types' import { indexSourceFiles } from '../_lib/index-source-files.js' import { NAMESPACE } from '../constants.js' @@ -12,7 +12,7 @@ import { NAMESPACE } from '../constants.js' const noPublicApiSidestep = { name: `${NAMESPACE}/no-public-api-sidestep`, async check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] const { tsconfig } = await parseNearestTsConfig(root.path) const sourceFileIndex = indexSourceFiles(root) diff --git a/packages/steiger-plugin-fsd/src/no-reserved-folder-names/index.ts b/packages/steiger-plugin-fsd/src/no-reserved-folder-names/index.ts index 61ce349..b5db9cd 100644 --- a/packages/steiger-plugin-fsd/src/no-reserved-folder-names/index.ts +++ b/packages/steiger-plugin-fsd/src/no-reserved-folder-names/index.ts @@ -1,6 +1,6 @@ import { basename } from 'node:path' import { getAllSegments, conventionalSegmentNames } from '@feature-sliced/filesystem' -import type { Diagnostic, Rule } from '@steiger/types' +import type { PartialDiagnostic, Rule } from '@steiger/types' import { findAllRecursively } from '../_lib/find-all-recursively.js' import { NAMESPACE } from '../constants.js' @@ -9,7 +9,7 @@ import { NAMESPACE } from '../constants.js' const noReservedFolderNames = { name: `${NAMESPACE}/no-reserved-folder-names`, check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] for (const { segment } of getAllSegments(root)) { if (segment.type === 'file') { diff --git a/packages/steiger-plugin-fsd/src/no-segmentless-slices/index.ts b/packages/steiger-plugin-fsd/src/no-segmentless-slices/index.ts index e18f75c..f516f4d 100644 --- a/packages/steiger-plugin-fsd/src/no-segmentless-slices/index.ts +++ b/packages/steiger-plugin-fsd/src/no-segmentless-slices/index.ts @@ -1,11 +1,11 @@ import { getLayers, isSlice, isSliced } from '@feature-sliced/filesystem' -import type { Folder, Diagnostic, Rule } from '@steiger/types' +import type { Folder, PartialDiagnostic, Rule } from '@steiger/types' import { NAMESPACE } from '../constants.js' const noSegmentlessSlices = { name: `${NAMESPACE}/no-segmentless-slices`, check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] for (const layer of Object.values(getLayers(root))) { if (!isSliced(layer)) { diff --git a/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.ts b/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.ts index 3754d20..9eac787 100644 --- a/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.ts +++ b/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.ts @@ -1,13 +1,13 @@ import { basename } from 'node:path' import { getLayers, isSliced, conventionalSegmentNames } from '@feature-sliced/filesystem' -import type { Diagnostic, Rule } from '@steiger/types' +import type { PartialDiagnostic, Rule } from '@steiger/types' import { NAMESPACE } from '../constants.js' const noSegmentsOnSlicedLayers = { name: `${NAMESPACE}/no-segments-on-sliced-layers`, check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] const layers = Object.values(getLayers(root)) for (const layer of layers) { diff --git a/packages/steiger-plugin-fsd/src/no-ui-in-app/index.ts b/packages/steiger-plugin-fsd/src/no-ui-in-app/index.ts index 4a88283..44310ef 100644 --- a/packages/steiger-plugin-fsd/src/no-ui-in-app/index.ts +++ b/packages/steiger-plugin-fsd/src/no-ui-in-app/index.ts @@ -1,11 +1,11 @@ -import type { Diagnostic, Rule } from '@steiger/types' +import type { PartialDiagnostic, Rule } from '@steiger/types' import { NAMESPACE } from '../constants.js' import { getLayers, getSegments } from '@feature-sliced/filesystem' const noUiInApp = { name: `${NAMESPACE}/no-ui-in-app`, check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] const layers = getLayers(root) diff --git a/packages/steiger-plugin-fsd/src/public-api/index.ts b/packages/steiger-plugin-fsd/src/public-api/index.ts index 558aa37..f3c9d67 100644 --- a/packages/steiger-plugin-fsd/src/public-api/index.ts +++ b/packages/steiger-plugin-fsd/src/public-api/index.ts @@ -1,13 +1,13 @@ import { join } from 'node:path' import { getLayers, getSegments, isSliced, getIndex, getSlices } from '@feature-sliced/filesystem' -import type { Diagnostic, Rule } from '@steiger/types' +import type { PartialDiagnostic, Rule } from '@steiger/types' import { NAMESPACE } from '../constants.js' /** Require slices (or segments on sliceless layers) to have a public API. */ const publicApi = { name: `${NAMESPACE}/public-api`, check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] for (const [layerName, layer] of Object.entries(getLayers(root))) { if (!isSliced(layer)) { diff --git a/packages/steiger-plugin-fsd/src/repetitive-naming/index.ts b/packages/steiger-plugin-fsd/src/repetitive-naming/index.ts index 4c3dd0e..cc440fe 100644 --- a/packages/steiger-plugin-fsd/src/repetitive-naming/index.ts +++ b/packages/steiger-plugin-fsd/src/repetitive-naming/index.ts @@ -1,5 +1,5 @@ import { getLayers, getSlices, isSliced } from '@feature-sliced/filesystem' -import type { Diagnostic, Rule } from '@steiger/types' +import type { PartialDiagnostic, Rule } from '@steiger/types' import { NAMESPACE } from '../constants.js' /** @@ -18,7 +18,7 @@ const wordPattern = /(?:[A-Z]+|[a-z]+)[a-z]*/g const repetitiveNaming = { name: `${NAMESPACE}/repetitive-naming`, check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] for (const layer of Object.values(getLayers(root))) { if (!isSliced(layer)) { diff --git a/packages/steiger-plugin-fsd/src/segments-by-purpose/index.ts b/packages/steiger-plugin-fsd/src/segments-by-purpose/index.ts index 2baa0d4..98f956d 100644 --- a/packages/steiger-plugin-fsd/src/segments-by-purpose/index.ts +++ b/packages/steiger-plugin-fsd/src/segments-by-purpose/index.ts @@ -1,5 +1,5 @@ import { getLayers, getSegments, getSlices, isSliced } from '@feature-sliced/filesystem' -import type { Diagnostic, Rule } from '@steiger/types' +import type { PartialDiagnostic, Rule } from '@steiger/types' import { NAMESPACE } from '../constants.js' const BAD_NAMES = ['components', 'hooks', 'helpers', 'utils', 'modals', 'types', 'constants', 'consts', 'const'] @@ -8,7 +8,7 @@ const BAD_NAMES = ['components', 'hooks', 'helpers', 'utils', 'modals', 'types', const segmentsByPurpose = { name: `${NAMESPACE}/segments-by-purpose`, check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] for (const layer of Object.values(getLayers(root))) { if (layer === null) { diff --git a/packages/steiger-plugin-fsd/src/shared-lib-grouping/index.ts b/packages/steiger-plugin-fsd/src/shared-lib-grouping/index.ts index cfe706b..2fd35e9 100644 --- a/packages/steiger-plugin-fsd/src/shared-lib-grouping/index.ts +++ b/packages/steiger-plugin-fsd/src/shared-lib-grouping/index.ts @@ -1,5 +1,5 @@ import { getLayers, getSegments } from '@feature-sliced/filesystem' -import type { Diagnostic, Rule } from '@steiger/types' +import type { PartialDiagnostic, Rule } from '@steiger/types' import { NAMESPACE } from '../constants.js' const THRESHOLD = 15 @@ -8,7 +8,7 @@ const THRESHOLD = 15 const sharedLibGrouping = { name: `${NAMESPACE}/shared-lib-grouping`, check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] const { shared } = getLayers(root) if (!shared) { diff --git a/packages/steiger-plugin-fsd/src/typo-in-layer-name/index.ts b/packages/steiger-plugin-fsd/src/typo-in-layer-name/index.ts index 80deb81..75d35a1 100644 --- a/packages/steiger-plugin-fsd/src/typo-in-layer-name/index.ts +++ b/packages/steiger-plugin-fsd/src/typo-in-layer-name/index.ts @@ -1,4 +1,4 @@ -import type { Diagnostic, Rule } from '@steiger/types' +import type { PartialDiagnostic, Rule } from '@steiger/types' import { NAMESPACE } from '../constants.js' import { LayerName, layerSequence } from '@feature-sliced/filesystem' import { distance } from 'fastest-levenshtein' @@ -10,7 +10,7 @@ const LEVENSHTEIN_DISTANCE_UPPER_BOUND = 3 const typoInLayerName = { name: `${NAMESPACE}/typo-in-layer-name`, check(root) { - const diagnostics: Array = [] + const diagnostics: Array = [] // construct list of suggestions, like [{ input: 'shraed', suggestion: 'shared', distance: 2 }, ...], // limit Levenshtein distance upper bound to 3, diff --git a/packages/steiger/package.json b/packages/steiger/package.json index 7d49ae8..4a64fec 100644 --- a/packages/steiger/package.json +++ b/packages/steiger/package.json @@ -46,6 +46,7 @@ "effector": "^23.2.1", "globby": "^14.0.1", "immer": "^10.1.1", + "lodash-es": "^4.17.21", "minimatch": "^10.0.1", "patronum": "^2.2.0", "prexit": "^2.2.0", @@ -59,6 +60,7 @@ "@steiger/tsconfig": "workspace:*", "@steiger/types": "workspace:*", "@total-typescript/ts-reset": "^0.5.1", + "@types/lodash-es": "^4.17.12", "@types/yargs": "^17.0.32", "tsup": "^8.0.2", "typescript": "^5.5.3", diff --git a/packages/steiger/src/_lib/prepare-test.ts b/packages/steiger/src/_lib/prepare-test.ts index 3d6f98a..70cbe7f 100644 --- a/packages/steiger/src/_lib/prepare-test.ts +++ b/packages/steiger/src/_lib/prepare-test.ts @@ -1,7 +1,7 @@ import { join, sep } from 'node:path' import type { readFileSync, existsSync } from 'node:fs' import type { FsdRoot } from '@feature-sliced/filesystem' -import type { Folder, File, Diagnostic } from '@steiger/types' +import type { Folder, File, PartialDiagnostic } from '@steiger/types' import { vi } from 'vitest' /** Parse a multi-line indented string with emojis for files and folders into an FSD root. @@ -39,7 +39,7 @@ export function parseIntoFsdRoot(fsMarkup: string, mountTo?: string): FsdRoot { return parseFolder(lines, mountTo ?? joinFromRoot()) } -export function compareMessages(a: Diagnostic, b: Diagnostic): number { +export function compareMessages(a: PartialDiagnostic, b: PartialDiagnostic): number { return a.message.localeCompare(b.message) || a.location.path.localeCompare(b.location.path) } diff --git a/packages/steiger/src/app.ts b/packages/steiger/src/app.ts index 3a2148b..b7bdcf6 100644 --- a/packages/steiger/src/app.ts +++ b/packages/steiger/src/app.ts @@ -1,78 +1,52 @@ -import { createEffect, sample, combine } from 'effector' +import { createEffect, sample } from 'effector' import { debounce, not } from 'patronum' -import { Rule, Folder, Severity } from '@steiger/types' -import type { AugmentedDiagnostic } from '@steiger/pretty-reporter' +import { Config, Folder, Rule } from '@steiger/types' import { scan, createWatcher } from './features/transfer-fs-to-vfs' import { defer } from './shared/defer' -import { $config, $rules } from './models/config' +import { $enabledRules, getEnabledRules, getGlobalIgnores } from './models/config' +import { runRule } from './features/run-rule' +import { removeGlobalIgnoreFromVfs } from './features/remove-global-ignores-from-vfs' +import { calculateFinalSeverities } from './features/calculate-diagnostic-severities' function getRuleDescriptionUrl(ruleName: string) { return new URL(`https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/${ruleName}`) } -type Config = typeof $config -type SeverityMap = Record> - -function getSeverity(value: Severity | [Severity, Record]): Severity { - return Array.isArray(value) ? value[0] : value -} - -function isEnabled([, value]: [string, Severity | [Severity, Record]]): boolean { - return getSeverity(value) !== 'off' -} - -const $enabledRules = combine($config, $rules, (config, rules) => { - const ruleConfigs = config?.rules - - if (ruleConfigs === undefined) { - return rules - } - - return rules.filter( - (rule) => !(rule.name in ruleConfigs) || ruleConfigs[rule.name as keyof typeof ruleConfigs] !== 'off', - ) -}) - -const $severities = $config.map( - (config) => - Object.fromEntries( - Object.entries(config?.rules ?? {}) - .filter(isEnabled) - .map(([ruleName, severityOrTuple]) => [ruleName, getSeverity(severityOrTuple)]), - ) as SeverityMap, -) - -const $ruleOptions = $config.map( - (config) => - Object.fromEntries( - Object.entries(config?.rules ?? {}) - .filter(isEnabled) - .map(([ruleName, severityOrTuple]) => [ruleName, Array.isArray(severityOrTuple) ? severityOrTuple[1] : {}]), - ) as Record>, -) - -async function runRules({ vfs, rules, severities }: { vfs: Folder; rules: Array; severities: SeverityMap }) { - const ruleResults = await Promise.all( - rules.map((rule) => { - const optionsForCurrentRule = $ruleOptions.getState()[rule.name] - - return Promise.resolve(rule.check(vfs, optionsForCurrentRule)).then(({ diagnostics }) => - diagnostics.map((d) => ({ - ...d, - ruleName: rule.name, - getRuleDescriptionUrl, - severity: severities[rule.name], - })), - ) - }), - ) - return ruleResults.flat() +async function runRules({ vfs, rules }: { vfs: Folder; rules: Array }) { + const vfsWithoutGlobalIgnores = removeGlobalIgnoreFromVfs(vfs, getGlobalIgnores()) + + const ruleResults = await Promise.all(rules.map((rule) => runRule(vfsWithoutGlobalIgnores, rule))) + return ruleResults.flatMap((r, ruleResultsIndex) => { + const { diagnostics } = r + if (diagnostics.length === 0) { + return [] + } + + const ruleName = rules[ruleResultsIndex].name + const severities = calculateFinalSeverities( + vfsWithoutGlobalIgnores, + ruleName, + diagnostics.map((d) => d.location.path), + ) + + return diagnostics.map((d, index) => ({ + ...d, + ruleName, + getRuleDescriptionUrl, + severity: severities[index], + })) + }) } export const linter = { run: (path: string) => - scan(path).then((vfs) => runRules({ vfs, rules: $enabledRules.getState(), severities: $severities.getState() })), + scan(path).then((vfs) => + runRules({ + vfs, + rules: getEnabledRules(), + }), + ), watch: async (path: string) => { const { vfs, watcher } = await createWatcher(path) @@ -81,7 +55,10 @@ export const linter = { sample({ clock: defer({ clock: [treeChanged, $enabledRules], until: not(runRulesFx.pending) }), - source: { vfs: vfs.$tree, rules: $enabledRules, severities: $severities }, + source: { + vfs: vfs.$tree, + rules: $enabledRules, + }, target: runRulesFx, }) @@ -92,5 +69,3 @@ export const linter = { export function defineConfig(config: Config) { return config } - -export type { Config } diff --git a/packages/steiger/src/cli.ts b/packages/steiger/src/cli.ts index 549b8f8..61cdc52 100755 --- a/packages/steiger/src/cli.ts +++ b/packages/steiger/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { resolve, relative } from 'node:path' +import { resolve, relative, dirname } from 'node:path' import * as process from 'node:process' import yargs from 'yargs' import prexit from 'prexit' @@ -56,8 +56,9 @@ const { config, filepath } = (await cosmiconfig('steiger').search()) ?? { config const defaultConfig = fsd.configs.recommended try { + const configLocationDirectory = filepath ? dirname(filepath) : null // use FSD recommended config as a default - processConfiguration(config || defaultConfig) + processConfiguration(config || defaultConfig, configLocationDirectory) } catch (err) { if (filepath !== undefined) { console.error( diff --git a/packages/steiger/src/features/apply-globs.test.ts b/packages/steiger/src/features/apply-globs.test.ts deleted file mode 100644 index b7ea27b..0000000 --- a/packages/steiger/src/features/apply-globs.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { expect, it, describe } from 'vitest' - -import applyGlobs from './apply-globs' -import { joinFromRoot, parseIntoFsdRoot } from '../_lib/prepare-test' - -describe('applyGlobs', () => { - it('should return the passed folder if no globs are provided', () => { - const root = parseIntoFsdRoot( - ` - πŸ“‚ shared - πŸ“‚ ui - πŸ“„ styles.css - πŸ“„ Button.tsx - πŸ“„ TextField.tsx - πŸ“„ index.ts - `, - joinFromRoot('src'), - ) - - expect(applyGlobs(root, {})).toEqual(root) - expect( - applyGlobs(root, { - inclusions: [], - exclusions: [], - }), - ).toEqual(root) - }) - - it('should return the picked folder if a specific folder is passed to inclusion patterns', () => { - const root = parseIntoFsdRoot( - ` - πŸ“‚ shared - πŸ“‚ ui - πŸ“„ styles.css - πŸ“„ Button.tsx - πŸ“„ TextField.tsx - πŸ“„ index.ts - πŸ“‚ entities - πŸ“‚ user - πŸ“„ UserAvatar.tsx - πŸ“‚ product - πŸ“„ ProductCard.tsx - `, - joinFromRoot('src'), - ) - - const expected = parseIntoFsdRoot( - ` - πŸ“‚ shared - πŸ“‚ ui - πŸ“„ styles.css - πŸ“„ Button.tsx - πŸ“„ TextField.tsx - πŸ“„ index.ts - `, - joinFromRoot('src'), - ) - - const actual = applyGlobs(root, { - inclusions: ['/src/shared/**'], - exclusions: [], - }) - - expect(actual).toEqual(expected) - }) - - it('should return all __mock__ folders if the inclusion pattern says to include them all', () => { - const root = parseIntoFsdRoot( - ` - πŸ“‚ shared - πŸ“‚ ui - πŸ“‚ __mocks__ - πŸ“„ Button.tsx - πŸ“„ styles.css - πŸ“„ Button.tsx - πŸ“„ TextField.tsx - πŸ“„ index.ts - πŸ“‚ entities - πŸ“‚ user - πŸ“‚ __mocks__ - πŸ“„ UserAvatar.tsx - πŸ“„ UserAvatar.tsx - πŸ“‚ product - πŸ“„ ProductCard.tsx - `, - joinFromRoot('src'), - ) - - const expected = parseIntoFsdRoot( - ` - πŸ“‚ shared - πŸ“‚ ui - πŸ“‚ __mocks__ - πŸ“„ Button.tsx - πŸ“‚ entities - πŸ“‚ user - πŸ“‚ __mocks__ - πŸ“„ UserAvatar.tsx - `, - joinFromRoot('src'), - ) - - const actual = applyGlobs(root, { - inclusions: ['**/__mocks__/**'], - exclusions: [], - }) - - expect(actual).toEqual(expected) - }) - - it('should return files picked by extension if the inclusion patterns pick by extension', () => { - const root = parseIntoFsdRoot( - ` - πŸ“‚ shared - πŸ“‚ ui - πŸ“„ styles.css - πŸ“„ Button.tsx - πŸ“„ TextField.tsx - πŸ“„ index.ts - πŸ“‚ entities - πŸ“‚ user - πŸ“„ styles.css - πŸ“„ UserAvatar.tsx - πŸ“‚ product - πŸ“„ ProductCard.tsx - `, - joinFromRoot('src'), - ) - - const expected = parseIntoFsdRoot( - ` - πŸ“‚ shared - πŸ“‚ ui - πŸ“„ styles.css - πŸ“‚ entities - πŸ“‚ user - πŸ“„ styles.css - `, - joinFromRoot('src'), - ) - - const actual = applyGlobs(root, { - inclusions: ['**/*.css'], - exclusions: [], - }) - - expect(actual).toEqual(expected) - }) - - it('should return a specific single file if the inclusion pattern pick that only file', () => { - const root = parseIntoFsdRoot( - ` - πŸ“‚ shared - πŸ“‚ ui - πŸ“„ styles.css - πŸ“„ Button.tsx - πŸ“„ TextField.tsx - πŸ“„ index.ts - πŸ“‚ entities - πŸ“‚ user - πŸ“„ styles.css - πŸ“„ UserAvatar.tsx - πŸ“‚ product - πŸ“„ ProductCard.tsx - `, - joinFromRoot('src'), - ) - - const expected = parseIntoFsdRoot( - ` - πŸ“‚ shared - πŸ“‚ ui - πŸ“„ TextField.tsx - `, - joinFromRoot('src'), - ) - - const actual = applyGlobs(root, { - inclusions: ['/src/shared/ui/TextField.tsx'], - exclusions: [], - }) - - expect(actual).toEqual(expected) - }) - - it('should correctly handle negations in exclusion patterns', () => { - const root = parseIntoFsdRoot( - ` - πŸ“‚ shared - πŸ“‚ ui - πŸ“„ styles.css - πŸ“„ Button.tsx - πŸ“„ TextField.tsx - πŸ“„ index.ts - πŸ“‚ config - πŸ“„ eslint.config.js - πŸ“„ styling.config.js - πŸ“‚ entities - πŸ“‚ user - πŸ“„ styles.css - πŸ“„ UserAvatar.tsx - πŸ“‚ product - πŸ“„ ProductCard.tsx - `, - joinFromRoot('src'), - ) - - const expected = parseIntoFsdRoot( - ` - πŸ“‚ shared - πŸ“‚ ui - πŸ“„ styles.css - πŸ“„ Button.tsx - πŸ“„ TextField.tsx - πŸ“„ index.ts - πŸ“‚ config - πŸ“„ eslint.config.js - πŸ“‚ entities - πŸ“‚ user - πŸ“„ styles.css - πŸ“„ UserAvatar.tsx - πŸ“‚ product - πŸ“„ ProductCard.tsx - `, - joinFromRoot('src'), - ) - - const actual = applyGlobs(root, { - exclusions: ['**/*.config.js', '!**/eslint.config.js'], - }) - - expect(actual).toEqual(expected) - }) - - it('should return the fs tree without all __mock__ folders if the exclusion pattern says to ignore them all', () => { - const root = parseIntoFsdRoot( - ` - πŸ“‚ shared - πŸ“‚ ui - πŸ“‚ __mocks__ - πŸ“„ Button.tsx - πŸ“„ styles.css - πŸ“„ Button.tsx - πŸ“„ TextField.tsx - πŸ“„ index.ts - πŸ“‚ entities - πŸ“‚ user - πŸ“‚ __mocks__ - πŸ“„ UserAvatar.tsx - πŸ“„ UserAvatar.tsx - πŸ“‚ product - πŸ“„ ProductCard.tsx - `, - joinFromRoot('src'), - ) - - const expected = parseIntoFsdRoot( - ` - πŸ“‚ shared - πŸ“‚ ui - πŸ“„ styles.css - πŸ“„ Button.tsx - πŸ“„ TextField.tsx - πŸ“„ index.ts - πŸ“‚ entities - πŸ“‚ user - πŸ“„ UserAvatar.tsx - πŸ“‚ product - πŸ“„ ProductCard.tsx - `, - joinFromRoot('src'), - ) - - const actual = applyGlobs(root, { - inclusions: [], - exclusions: ['**/__mocks__/**'], - }) - - expect(actual).toEqual(expected) - }) - - it('should exclude files with specific extension if exclusion pattern is set up accordingly', () => { - const root = parseIntoFsdRoot( - ` - πŸ“‚ shared - πŸ“‚ lib - πŸ“„ get-query-params.ts - πŸ“„ get-query-params.test.ts - πŸ“‚ ui - πŸ“„ styles.css - πŸ“„ Button.tsx - πŸ“„ Button.test.tsx - πŸ“„ TextField.tsx - πŸ“„ TextField.test.tsx - πŸ“„ index.ts - πŸ“‚ entities - πŸ“‚ user - πŸ“„ styles.css - πŸ“„ UserAvatar.tsx - πŸ“„ UserAvatar.test.tsx - πŸ“‚ product - πŸ“„ ProductCard.tsx - `, - joinFromRoot('src'), - ) - - const expected = parseIntoFsdRoot( - ` - πŸ“‚ shared - πŸ“‚ lib - πŸ“„ get-query-params.ts - πŸ“‚ ui - πŸ“„ styles.css - πŸ“„ Button.tsx - πŸ“„ TextField.tsx - πŸ“„ index.ts - `, - joinFromRoot('src'), - ) - - const actual = applyGlobs(root, { - inclusions: ['/src/shared/**'], - exclusions: ['**/*.test.{tsx,ts}'], - }) - - expect(actual).toEqual(expected) - }) -}) diff --git a/packages/steiger/src/features/apply-globs.ts b/packages/steiger/src/features/apply-globs.ts deleted file mode 100644 index fe87e15..0000000 --- a/packages/steiger/src/features/apply-globs.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { sep } from 'node:path' - -import { minimatch } from 'minimatch' -import { File, Folder } from '@steiger/types' - -import { isNegationPattern } from '../shared/globs' -import { flattenFolder, copyFsEntity } from '../shared/file-system' - -interface ApplyGlobsOptions { - inclusions?: string[] - exclusions?: string[] -} - -type RequiredApplyGlobsOptions = Required - -/** - * Turns flat array of files and folders into a tree structure based on the paths. - * */ -function recomposeTree(folder: Folder, nodes: Array) { - function getEntityBackToTree(folder: Folder, nested: Folder | File) { - const pathDiff = nested.path.slice(folder.path.length + 1) - const pathParts = pathDiff.split(sep).filter(Boolean) - let currentFolder = folder - - for (let i = 0; i < pathParts.length; i++) { - const pathPart = pathParts[i] - const isLastPart = i === pathParts.length - 1 - const nextPath = `${currentFolder.path}${sep}${pathPart}` - const existingFolder = currentFolder.children.find( - (child) => child.type === 'folder' && child.path === nextPath, - ) as Folder | undefined - - if (isLastPart && nested.type === 'file') { - currentFolder.children.push(nested) - return - } - - if (existingFolder) { - currentFolder = existingFolder - } else { - const newFolder: Folder = { - type: 'folder', - path: nextPath, - children: [], - } - currentFolder.children.push(newFolder) - currentFolder = newFolder - } - } - } - - nodes.forEach((node) => { - getEntityBackToTree(folder, node) - }) -} - -function createFilterAccordingToGlobs({ inclusions, exclusions }: RequiredApplyGlobsOptions) { - const thereAreInclusions = inclusions.length > 0 - const thereAreExclusions = exclusions.length > 0 - - function filterAccordingToGlobs(entity: File | Folder) { - const matchesInclusionPatterns = - !thereAreInclusions || inclusions.some((pattern) => minimatch(entity.path, pattern)) - let isIgnored = false - - if (matchesInclusionPatterns && thereAreExclusions) { - isIgnored = exclusions - .filter((pattern) => !isNegationPattern(pattern)) - .some((pattern) => minimatch(entity.path, pattern)) - - // If the path is ignored, check for any negated patterns that would include it back - if (isIgnored) { - const isNegated = exclusions.some( - (ignorePattern) => isNegationPattern(ignorePattern) && minimatch(entity.path, ignorePattern.slice(1)), - ) - - isIgnored = !isNegated - } - } - - return matchesInclusionPatterns && !isIgnored - } - - return filterAccordingToGlobs -} - -// ! Don't use platform specific path separators in the glob patterns for globby/minimatch -// as it only works with forward slashes! - -/** - * Apply glob patterns to a folder and return a new folder with only the matched files and folders. - * */ -export default function applyGlobs(folder: Folder, { inclusions = [], exclusions = [] }: ApplyGlobsOptions) { - // if there's nothing to match then return the folder as is - if (!inclusions?.length && !exclusions?.length) { - return folder - } - - const accordingToGlobs = createFilterAccordingToGlobs({ inclusions, exclusions }) - const flatFilteredFsNodes = flattenFolder(folder) - .map((entity) => copyFsEntity(entity)) - .filter(accordingToGlobs) - const finalFolder = copyFsEntity(folder) - - recomposeTree(finalFolder, flatFilteredFsNodes) - - return finalFolder -} diff --git a/packages/steiger/src/features/autofix.ts b/packages/steiger/src/features/autofix.ts index 0570149..4e0f8a0 100644 --- a/packages/steiger/src/features/autofix.ts +++ b/packages/steiger/src/features/autofix.ts @@ -1,8 +1,8 @@ import { dirname, join } from 'node:path' import { rename, open, mkdir, rm } from 'node:fs/promises' -import type { Diagnostic } from '@steiger/types' +import type { PartialDiagnostic } from '@steiger/types' -export async function applyAutofixes(diagnostics: Array): Promise { +export async function applyAutofixes(diagnostics: Array): Promise { const stillRelevantDiagnostics = [] const fixableDiagnostics = [] @@ -31,7 +31,7 @@ export async function applyAutofixes(diagnostics: Array return stillRelevantDiagnostics } -async function tryToApplyFixes(diagnostic: Diagnostic) { +async function tryToApplyFixes(diagnostic: PartialDiagnostic) { const fixes = diagnostic.fixes ?? [] return Promise.all( 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 new file mode 100644 index 0000000..fa7fd54 --- /dev/null +++ b/packages/steiger/src/features/calculate-diagnostic-severities/calculate-final-severity.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' + +import { joinFromRoot, parseIntoFsdRoot } from '../../_lib/prepare-test' +import calculateFinalSeverities from './calculate-final-severity' +import { GlobGroupWithSeverity } from '../../models/config' + +vi.mock('../../models/config', async () => { + const ruleToGlobs: Record> = { + rule1: [{ severity: 'warn' }, { files: ['/src/shared/ui/Button.ts'], severity: 'error' }], + } + + return { + getGlobsForRule: vi.fn((ruleName: string) => ruleToGlobs[ruleName]), + } +}) + +describe('calculateFinalSeverity', () => { + it('should return severities for paths', () => { + const vfs = parseIntoFsdRoot( + ` + πŸ“‚ shared + πŸ“‚ ui + πŸ“„ Button.ts + πŸ“„ Button.spec.ts + πŸ“„ Input.ts + πŸ“„ Input.spec.ts + πŸ“„ index.ts + πŸ“‚ lib + πŸ“„ get-query-params.ts + πŸ“„ get-query-params.spec.ts + πŸ“„ device-detection.ts + πŸ“„ device-detection.spec.ts + πŸ“„ index.ts + πŸ“‚ entities + πŸ“‚ user + πŸ“‚ ui + πŸ“„ UserAvatar.ts + πŸ“„ UserAvatar.spec.ts + πŸ“„ Input.ts + πŸ“‚ pages + πŸ“‚ profile + πŸ“„ index.ts + πŸ“‚ main + πŸ“„ index.ts + `, + joinFromRoot('src'), + ) + + const severities = calculateFinalSeverities(vfs, 'rule1', [ + joinFromRoot('src', 'shared', 'ui', 'Button.ts'), + joinFromRoot('src', 'shared', 'ui'), + joinFromRoot('src', 'shared'), + joinFromRoot('src', 'lib'), + ]) + + expect(severities).toEqual(['error', 'error', 'error', 'warn']) + }) +}) diff --git a/packages/steiger/src/features/calculate-diagnostic-severities/calculate-final-severity.ts b/packages/steiger/src/features/calculate-diagnostic-severities/calculate-final-severity.ts new file mode 100644 index 0000000..bb72892 --- /dev/null +++ b/packages/steiger/src/features/calculate-diagnostic-severities/calculate-final-severity.ts @@ -0,0 +1,18 @@ +import { Folder, Severity } from '@steiger/types' + +import { getGlobsForRule } from '../../models/config' +import { applyExclusion, not } from '../../shared/globs' +import { isPathInTree } from '../../shared/file-system' + +export default function calculateFinalSeverities( + vfs: Folder, + ruleName: string, + paths: Array, +): Array> { + const globGroups = getGlobsForRule(ruleName) + + const errorGlobs = globGroups.map(({ severity, ...rest }) => (severity === 'error' ? { ...rest } : not({ ...rest }))) + const errorVfs = applyExclusion(vfs, errorGlobs) + + return (>isPathInTree(errorVfs, paths)).map((isError) => (isError ? 'error' : 'warn')) +} diff --git a/packages/steiger/src/features/calculate-diagnostic-severities/index.ts b/packages/steiger/src/features/calculate-diagnostic-severities/index.ts new file mode 100644 index 0000000..79642c9 --- /dev/null +++ b/packages/steiger/src/features/calculate-diagnostic-severities/index.ts @@ -0,0 +1 @@ +export { default as calculateFinalSeverities } from './calculate-final-severity' diff --git a/packages/steiger/src/features/remove-global-ignores-from-vfs/index.ts b/packages/steiger/src/features/remove-global-ignores-from-vfs/index.ts new file mode 100644 index 0000000..fdf4f99 --- /dev/null +++ b/packages/steiger/src/features/remove-global-ignores-from-vfs/index.ts @@ -0,0 +1 @@ +export { default as removeGlobalIgnoreFromVfs } from './remove-global-ignores-from-vfs' 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 new file mode 100644 index 0000000..64dd8e8 --- /dev/null +++ b/packages/steiger/src/features/remove-global-ignores-from-vfs/remove-global-ignores-from-vfs.spec.ts @@ -0,0 +1,43 @@ +import { expect, it, describe } from 'vitest' + +import removeGlobalIgnoresFromVfs from './remove-global-ignores-from-vfs' +import { joinFromRoot, parseIntoFsdRoot } from '../../_lib/prepare-test' + +describe('removeGlobalIgnoresFromVfs', () => { + it('should remove nodes that match global ignores from VFS', () => { + const vfs = parseIntoFsdRoot( + ` + πŸ“‚ entities + πŸ“‚ user + πŸ“‚ model + πŸ“‚ __mocks__ + πŸ“„ store.ts + πŸ“„ store.ts + πŸ“„ store.test.ts + πŸ“‚ ui + πŸ“„ UserAvatar.tsx + πŸ“„ UserAvatar.test.tsx + πŸ“„ index.ts + `, + joinFromRoot('src'), + ) + const expectedVfs = parseIntoFsdRoot( + ` + πŸ“‚ entities + πŸ“‚ user + πŸ“‚ model + πŸ“„ store.ts + πŸ“‚ ui + πŸ“„ UserAvatar.tsx + πŸ“„ index.ts + `, + joinFromRoot('src'), + ) + + const globalIgnores = [{ ignores: ['**/__mocks__/**'] }, { ignores: ['**/*.test.{tsx,ts}'] }] + + const result = removeGlobalIgnoresFromVfs(vfs, globalIgnores) + + expect(result).toEqual(expectedVfs) + }) +}) diff --git a/packages/steiger/src/features/remove-global-ignores-from-vfs/remove-global-ignores-from-vfs.ts b/packages/steiger/src/features/remove-global-ignores-from-vfs/remove-global-ignores-from-vfs.ts new file mode 100644 index 0000000..4eeb6c5 --- /dev/null +++ b/packages/steiger/src/features/remove-global-ignores-from-vfs/remove-global-ignores-from-vfs.ts @@ -0,0 +1,13 @@ +import { Folder, GlobalIgnore } from '@steiger/types' +import { applyExclusion } from '../../shared/globs' +import { mergeGlobGroups } from '../../shared/globs/merge-glob-groups' + +export default function removeGlobalIgnoreFromVfs(vfs: Folder, globalIgnores: Array): Folder { + if (globalIgnores.length === 0) { + return vfs + } + + const globalIgnoresAsGlobGroup = mergeGlobGroups(globalIgnores) + + return applyExclusion(vfs, [globalIgnoresAsGlobGroup]) +} diff --git a/packages/steiger/src/features/run-rule/index.ts b/packages/steiger/src/features/run-rule/index.ts new file mode 100644 index 0000000..52b0b45 --- /dev/null +++ b/packages/steiger/src/features/run-rule/index.ts @@ -0,0 +1 @@ +export { runRule } from './run-rule' 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 new file mode 100644 index 0000000..4518d21 --- /dev/null +++ b/packages/steiger/src/features/run-rule/prepare-vfs-for-rule-run.spec.ts @@ -0,0 +1,80 @@ +import { prepareVfsForRuleRun } from './prepare-vfs-for-rule-run' + +import { describe, it, expect } from 'vitest' +import { joinFromRoot, parseIntoFsdRoot } from '../../_lib/prepare-test' +import { GlobGroupWithSeverity } from '../../models/config' + +describe('prepareVfsForRuleRun', () => { + it('should return vfs without off nodes', () => { + const vfs = parseIntoFsdRoot( + ` + πŸ“‚ shared + πŸ“‚ ui + πŸ“„ Button.ts + πŸ“„ Button.spec.ts + πŸ“„ Input.ts + πŸ“„ Input.spec.ts + πŸ“„ index.ts + πŸ“‚ lib + πŸ“„ get-query-params.ts + πŸ“„ get-query-params.spec.ts + πŸ“„ device-detection.ts + πŸ“„ device-detection.spec.ts + πŸ“„ index.ts + πŸ“‚ entities + πŸ“‚ user + πŸ“‚ ui + πŸ“„ UserAvatar.ts + πŸ“„ UserAvatar.spec.ts + πŸ“„ Input.ts + πŸ“‚ pages + πŸ“‚ profile + πŸ“„ index.ts + πŸ“‚ main + πŸ“„ index.ts + `, + joinFromRoot('src'), + ) + + const expectedVfs = parseIntoFsdRoot( + ` + πŸ“‚ shared + πŸ“‚ ui + πŸ“„ Button.ts + πŸ“„ Button.spec.ts + πŸ“„ Input.ts + πŸ“„ Input.spec.ts + πŸ“„ index.ts + πŸ“‚ lib + πŸ“„ get-query-params.ts + πŸ“„ get-query-params.spec.ts + πŸ“„ device-detection.ts + πŸ“„ device-detection.spec.ts + πŸ“„ index.ts + πŸ“‚ entities + πŸ“‚ user + πŸ“‚ ui + πŸ“„ UserAvatar.ts + πŸ“„ UserAvatar.spec.ts + πŸ“„ Input.ts + `, + joinFromRoot('src'), + ) + + const globGroups = [ + { + severity: 'error', + }, + { + files: ['/src/pages/**'], + severity: 'off', + }, + { + files: ['**/ui/**'], + severity: 'warn', + }, + ] as Array + + expect(prepareVfsForRuleRun(vfs, globGroups)).toEqual(expectedVfs) + }) +}) diff --git a/packages/steiger/src/features/run-rule/prepare-vfs-for-rule-run.ts b/packages/steiger/src/features/run-rule/prepare-vfs-for-rule-run.ts new file mode 100644 index 0000000..8228784 --- /dev/null +++ b/packages/steiger/src/features/run-rule/prepare-vfs-for-rule-run.ts @@ -0,0 +1,12 @@ +import { Folder } from '@steiger/types' + +import { GlobGroupWithSeverity } from '../../models/config' +import { applyExclusion, not } from '../../shared/globs' + +export function prepareVfsForRuleRun(vfs: Folder, globGroups: Array) { + const globs = globGroups.map(({ files, ignores, severity }) => + severity === 'off' ? not({ files, ignores }) : { files, ignores }, + ) + + return applyExclusion(vfs, globs) +} diff --git a/packages/steiger/src/features/run-rule/run-rule.ts b/packages/steiger/src/features/run-rule/run-rule.ts new file mode 100644 index 0000000..514efbd --- /dev/null +++ b/packages/steiger/src/features/run-rule/run-rule.ts @@ -0,0 +1,16 @@ +import { Folder, Rule } from '@steiger/types' + +import { getGlobsForRule, getRuleOptions } from '../../models/config' +import { prepareVfsForRuleRun } from './prepare-vfs-for-rule-run' + +export async function runRule(vfs: Folder, rule: Rule) { + const globsForRule = getGlobsForRule(rule.name) + + const finalVfs = prepareVfsForRuleRun(vfs, globsForRule) + + if (!finalVfs) { + return Promise.resolve({ diagnostics: [] }) + } + + return Promise.resolve(rule.check(finalVfs, getRuleOptions(rule.name) || undefined)) +} diff --git a/packages/steiger/src/models/config.test.ts b/packages/steiger/src/models/config.test.ts deleted file mode 100644 index 8d18276..0000000 --- a/packages/steiger/src/models/config.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { buildValidationScheme } from './config' -import { describe, expect, it } from 'vitest' - -const dummyRules = [ - { - name: 'rule1', - check: () => ({ diagnostics: [] }), - }, - { - name: 'rule2', - check: () => ({ diagnostics: [] }), - }, -] - -describe('buildValidationScheme', () => { - it('should successfully validate config with plain severities', () => { - const scheme = buildValidationScheme(dummyRules) - - const config = { - rules: { - rule1: 'off', - rule2: 'warn', - }, - } - - expect(scheme.parse(config)).toEqual(config) - }) - - it('should successfully validate config with a tuple of severity and rule options', () => { - const scheme = buildValidationScheme(dummyRules) - - const config = { - rules: { - rule1: ['warn', {}], - rule2: ['error', {}], - }, - } - - expect(scheme.parse(config)).toEqual(config) - }) - - it('should fail to validate config with no rules enabled', () => { - const scheme = buildValidationScheme(dummyRules) - - const config = { - rules: { - rule1: 'off', - rule2: 'off', - }, - } - - expect(() => scheme.parse(config)).toThrow('At least one rule must be enabled') - }) - - it('should fail to validate config with no rules provided', () => { - const scheme = buildValidationScheme(dummyRules) - - const config = { - rules: {}, - } - - expect(() => scheme.parse(config)).toThrow('At least one rule must be enabled') - }) -}) diff --git a/packages/steiger/src/models/config.ts b/packages/steiger/src/models/config.ts deleted file mode 100644 index aebb59c..0000000 --- a/packages/steiger/src/models/config.ts +++ /dev/null @@ -1,86 +0,0 @@ -import z from 'zod' -import { createEvent, createStore } from 'effector' -import { Config, ConfigObject, Plugin, Rule } from '@steiger/types' - -export const $config = createStore(null) -const setConfig = createEvent() -$config.on(setConfig, (_state, payload) => payload) - -export const $rules = createStore>([]) -const setRules = createEvent>() -$rules.on(setRules, (_state, payload) => payload) - -function processPlugins(config: Config) { - const plugins = config.filter((item) => 'ruleDefinitions' in item) as Array - const allRules = plugins.flatMap((plugin) => plugin.ruleDefinitions) - const ruleNames = allRules.map((rule) => rule.name) - const uniqueNames = new Set(ruleNames) - - // Check conflicts in rule names - if (uniqueNames.size !== allRules.length) { - const duplicates = ruleNames.filter((name, index) => ruleNames.indexOf(name) !== index) - throw new Error( - `Conflicting rule definitions found: ${duplicates.join(', ')}. Rules must be unique! Please check your plugins.`, - ) - } - - return allRules -} - -function mergeConfigObjects(config: Config) { - // TODO: temporary simplified implementation. - // Implement handling the "files" and "ignores" globs in further updates. - return config.reduce((acc: ConfigObject, item) => { - if ('rules' in item) { - return { ...acc, rules: { ...acc.rules, ...item.rules } } - } - - return acc - }, {}) -} - -/** - * Dynamically build a validation scheme based on the rules provided by plugins. - * */ -export function buildValidationScheme(rules: Array) { - const ruleNames = rules.map((rule) => rule.name) - - // Ensure the array has at least one element - if (ruleNames.length === 0) { - throw new Error('At least one rule must be provided by plugins!') - } - - return z.object({ - // zod.record requires at least one element in the array, so we need "as [string, ...string[]]" - rules: z - .record( - z.enum(ruleNames as [string, ...string[]]), - z.union([z.enum(['off', 'error', 'warn']), z.tuple([z.enum(['error', 'warn']), z.object({}).passthrough()])]), - ) - .refine( - (value) => { - const ruleNames = Object.keys(value) - const offRules = ruleNames.filter((name) => value[name] === 'off') - - if (offRules.length === ruleNames.length || ruleNames.length === 0) { - return false - } - - return true - }, - { message: 'At least one rule must be enabled' }, - ), - }) -} - -export function processConfiguration(config: Config) { - const allRules = processPlugins(config) - const validationScheme = buildValidationScheme(allRules) - const mergedConfig = mergeConfigObjects(config) - const validatedConfig = validationScheme.parse(mergedConfig) as ConfigObject - - setRules(allRules) - setConfig(validatedConfig) - - return validatedConfig -} diff --git a/packages/steiger/src/models/config/README.md b/packages/steiger/src/models/config/README.md new file mode 100644 index 0000000..501b314 --- /dev/null +++ b/packages/steiger/src/models/config/README.md @@ -0,0 +1,15 @@ +# Config + +The goal of this module is to parse the raw configuration, validate it and transform it to provide a way to access it in a form convenient for the application. + +## Glossary + +Configurations: + +- **Raw configuration**: configuration obtained from a user-written configuration file. +- **Final configuration** (rule instructions): transformed configuration that is presented in a way to be used by the application. + +There are 2 concepts of objects in the raw config. + +- **Registration objects**: Plugin. Exists to register a plugin that contains rule implementations for the application to run. +- **Configuration objects**: ConfigObject, GlobalIgnore. Exists to configure the registered rules (what files to analyze, ignore, what options to pass to the rules). diff --git a/packages/steiger/src/models/config/build-validation-scheme.spec.ts b/packages/steiger/src/models/config/build-validation-scheme.spec.ts new file mode 100644 index 0000000..cdbe44f --- /dev/null +++ b/packages/steiger/src/models/config/build-validation-scheme.spec.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from 'vitest' +import { Config, Plugin } from '@steiger/types' + +import buildValidationScheme from './build-validation-scheme' +import { isPlugin } from './raw-config' + +// The function maps a plugin object to a new object with the same properties as the original object, +// but with the check method expected to be just any function. +// The reason why it's needed is that the original check method's .toString method returns [Function: check], +// but after the validation it changes to [Function: anonymous] and fails the test. +function expectCheckToBeAnyFunction(config: Config) { + return config.map((configObject) => { + if (isPlugin(configObject)) { + return { + ...configObject, + ruleDefinitions: configObject.ruleDefinitions.map((ruleDefinition) => ({ + ...ruleDefinition, + check: expect.any(Function), + })), + } + } + return configObject + }) +} + +const dummyPlugin: Plugin = { + meta: { + name: 'dummy', + version: '1.0.0', + }, + ruleDefinitions: [ + { + name: 'rule1', + check() { + return { diagnostics: [] } + }, + }, + { + name: 'rule2', + check() { + return { diagnostics: [] } + }, + }, + ], +} + +describe('buildValidationScheme', () => { + it('should successfully validate config with plain severities', () => { + const config = [ + dummyPlugin, + { + rules: { + rule1: 'off', + rule2: 'warn', + }, + }, + ] as Config + const scheme = buildValidationScheme(config) + + expect(scheme.parse(config)).toEqual(expectCheckToBeAnyFunction(config)) + }) + + it('should successfully validate config with a tuple of severity and rule options', () => { + const config = [ + dummyPlugin, + { + rules: { + rule1: ['warn', {}], + rule2: ['error', {}], + }, + }, + ] as Config + const scheme = buildValidationScheme(config) + + expect(scheme.parse(config)).toEqual(expectCheckToBeAnyFunction(config)) + }) + + it('should successfully validate a config with several objects', () => { + const config = [ + dummyPlugin, + { + rules: { + rule1: 'off', + }, + }, + { + rules: { + rule2: 'warn', + }, + }, + ] as Config + + const scheme = buildValidationScheme(config) + + expect(scheme.parse(config)).toEqual(expectCheckToBeAnyFunction(config)) + }) + + it('should successfully validate a config with ignores', () => { + const config = [ + dummyPlugin, + { + ignores: ['/shared'], + rules: { + rule1: 'off', + }, + }, + { + ignores: ['/shared'], + rules: { + rule2: 'warn', + }, + }, + ] as Config + const scheme = buildValidationScheme(config) + + expect(scheme.parse(config)).toEqual(expectCheckToBeAnyFunction(config)) + }) + + it('should successfully validate a config with files', () => { + const config = [ + dummyPlugin, + + { + files: ['/shared'], + rules: { + rule1: 'off', + }, + }, + { + files: ['/shared'], + rules: { + rule2: 'warn', + }, + }, + ] as Config + const scheme = buildValidationScheme(config) + + expect(scheme.parse(config)).toEqual(expectCheckToBeAnyFunction(config)) + }) + + it('should successfully validate a config with files and ignores', () => { + const config = [ + dummyPlugin, + { + files: ['/shared'], + ignores: ['**/*.test.ts'], + rules: { + rule1: 'off', + }, + }, + { + files: ['/shared'], + ignores: ['**/*.test.ts'], + rules: { + rule2: 'warn', + }, + }, + ] as Config + const scheme = buildValidationScheme(config) + + expect(scheme.parse(config)).toEqual(expectCheckToBeAnyFunction(config)) + }) + + it('should throw an error if no plugins are provided', () => { + const config = [ + { + rules: { + rule1: 'off', + }, + }, + ] as Config + expect(() => buildValidationScheme(config)).toThrow('At least one rule must be provided by plugins!') + }) + + it('should throw an error if no config objects are provided', () => { + const config = [dummyPlugin] as Config + const scheme = buildValidationScheme(config) + + expect(() => scheme.parse(config)).toThrow('At least one config object must be provided!') + }) + + it('should throw an error if a config object without rules is provided', () => { + const config = [dummyPlugin, {}] as Config + const config1 = [dummyPlugin, { files: ['/shared'] }] as Config + const config2 = [dummyPlugin, { ignores: ['**/*.test.ts'] }] as Config + + const scheme = buildValidationScheme(config) + const scheme1 = buildValidationScheme(config1) + const scheme2 = buildValidationScheme(config2) + + expect(() => scheme.parse(config)).toThrow() + expect(() => scheme1.parse(config1)).toThrow() + expect(() => scheme2.parse(config2)).toThrow() + }) + + it('should throw an error if a non-existent rule is provided', () => { + const config = [ + dummyPlugin, + { + rules: { + rule1: 'off', + rule3: 'warn', + }, + }, + ] as Config + const scheme = buildValidationScheme(config) + + expect(() => scheme.parse(config)).toThrow() + }) + + it('should correctly validate a config with global ignores', () => { + const config = [ + dummyPlugin, + { + ignores: ['/src/shared/**'], + }, + { + ignores: ['/src/entities/**'], + rules: { + rule2: 'warn', + }, + }, + ] + const scheme = buildValidationScheme(config) + + expect(scheme.parse(config)).toEqual(expectCheckToBeAnyFunction(config)) + }) + + it('should correctly validate a config with unique rule options', () => { + const config = [ + dummyPlugin, + { + ignores: ['/src/shared/**'], + }, + { + ignores: ['/src/entities/**'], + rules: { + rule2: ['warn', { option1: 'value1' }], + }, + }, + ] + const scheme = buildValidationScheme(config) + + expect(scheme.parse(config)).toEqual(expectCheckToBeAnyFunction(config)) + }) + + it('should successfully validate when the config provides a rule with multiple but identical options', () => { + const config: Config = [ + dummyPlugin, + { + rules: { + rule1: ['warn', { option1: 'value1' }], + rule2: ['error', {}], + }, + }, + { + files: ['src/shared/ui/**/*', 'src/entities/user/ui/**/*'], + rules: { + rule1: ['warn', { option1: 'value1' }], + }, + }, + ] + const scheme = buildValidationScheme(config) + + expect(scheme.parse(config)).toEqual(expectCheckToBeAnyFunction(config)) + }) + + it('should throw an error when the config provides a rule with multiple but different options', () => { + const config: Config = [ + dummyPlugin, + { + rules: { + rule1: ['warn', { option1: 'value1' }], + rule2: ['error', {}], + }, + }, + { + files: ['src/shared/ui/**/*', 'src/entities/user/ui/**/*'], + rules: { + rule1: [ + 'warn', + { + option1: 'value2', + }, + ], + }, + }, + ] + const scheme = buildValidationScheme(config) + + expect(() => scheme.parse(config)).toThrow() + }) + + it('should throw an error when duplicate rule definition are provided', () => { + const config: Config = [ + dummyPlugin, + dummyPlugin, + { + rules: { + rule1: ['warn', { option1: 'value1' }], + rule2: ['error', {}], + }, + }, + { + files: ['src/shared/ui/**/*', 'src/entities/user/ui/**/*'], + rules: { + rule1: ['warn', { option1: 'value1' }], + }, + }, + ] + const scheme = buildValidationScheme(config) + + expect(() => scheme.parse(config)).toThrow() + }) +}) diff --git a/packages/steiger/src/models/config/build-validation-scheme.ts b/packages/steiger/src/models/config/build-validation-scheme.ts new file mode 100644 index 0000000..93c104f --- /dev/null +++ b/packages/steiger/src/models/config/build-validation-scheme.ts @@ -0,0 +1,126 @@ +import z from 'zod' + +import { getOptions, isConfigObject, isPlugin } from './raw-config' +import { isEqual } from '../../shared/objects' +import { BaseRuleOptions, Config, Plugin, Severity } from '@steiger/types' + +function getAllRuleNames(plugins: Array) { + const allRules = plugins.flatMap((plugin) => plugin.ruleDefinitions) + 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: 'At least one config object must be provided!', + }) + } +} + +function validateRuleUniqueness(value: Config, ctx: z.RefinementCtx) { + const allRuleNames = getAllRuleNames(value.filter(isPlugin)) + const uniqueNames = new Set(allRuleNames) + + // Check conflicts in rule names (each rule can only be defined once) + if (uniqueNames.size !== allRuleNames.length) { + const duplicates = allRuleNames.filter((name, index) => allRuleNames.indexOf(name) !== index) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Conflicting rule definitions found: ${duplicates.join(', ')}. Rules must be unique! Please check your plugins.`, + }) + } +} + +function validateRuleOptions(value: Config, ctx: z.RefinementCtx) { + const ruleToOptions: Record = {} + + value.forEach((configObject) => { + if (isConfigObject(configObject)) { + Object.entries(configObject.rules).forEach( + ([ruleName, severityOrTuple]: [string, Severity | [Severity, Record]]) => { + const prevOptions = ruleToOptions[ruleName] + const ruleOptions: BaseRuleOptions | null = getOptions(severityOrTuple) + + if (!prevOptions) { + ruleToOptions[ruleName] = ruleOptions + return + } + + if (ruleOptions && prevOptions && !isEqual(ruleOptions, prevOptions)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: ` + Rule "${ruleName}" has multiple options provided! + ${JSON.stringify(ruleToOptions[ruleName])} + and + ${JSON.stringify(ruleOptions)}. + You can only provide options for a rule once.`, + }) + } + }, + ) + } + }) +} + +/** + * Dynamically build a validation scheme based on the rules provided by plugins. + * */ +export default function buildValidationScheme(rawConfig: Config) { + const allRuleNames = getAllRuleNames(rawConfig.filter(isPlugin)) + + // Make sure there's at least one rule registered by plugins + // Need to check this before creating the scheme, because zod.enum requires at least one element + if (allRuleNames.length === 0) { + throw new Error('At least one rule must be provided by plugins!') + } + + // 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(), + }), + ruleDefinitions: z.array( + z.object({ + name: z.string(), + check: z + .function() + .args() + .returns(z.union([z.promise(ruleResultScheme), ruleResultScheme])), + }), + ), + }), + ]), + ) + .superRefine(validateConfigObjectsNumber) + .superRefine(validateRuleOptions) + .superRefine(validateRuleUniqueness) +} diff --git a/packages/steiger/src/models/config/create-rule-instructions.spec.ts b/packages/steiger/src/models/config/create-rule-instructions.spec.ts new file mode 100644 index 0000000..0ebb297 --- /dev/null +++ b/packages/steiger/src/models/config/create-rule-instructions.spec.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { Config } from '@steiger/types' + +import createRuleInstructions from './create-rule-instructions' + +describe('createRuleInstructions', () => { + it('should create rule instructions for each rule present in the config', () => { + const config: Config = [ + { + rules: { + rule1: 'warn', + rule2: 'error', + }, + files: ['src/shared/ui/**/*', 'src/entities/user/ui/**/*'], + ignores: ['src/entities/user/ui/index.ts'], + }, + { + rules: { + rule3: 'off', + }, + }, + ] + + const result = createRuleInstructions(config) + + expect(result).toEqual({ + rule1: { + options: null, + globGroups: [ + { + severity: 'warn', + files: ['src/shared/ui/**/*', 'src/entities/user/ui/**/*'], + ignores: ['src/entities/user/ui/index.ts'], + }, + ], + }, + rule2: { + options: null, + globGroups: [ + { + severity: 'error', + files: ['src/shared/ui/**/*', 'src/entities/user/ui/**/*'], + ignores: ['src/entities/user/ui/index.ts'], + }, + ], + }, + rule3: { + options: null, + globGroups: [ + { + severity: 'off', + }, + ], + }, + }) + }) + + it('should add several glob groups for a rule if they are provided in the config', () => { + const config: Config = [ + { + rules: { + rule1: 'warn', + }, + files: ['src/shared/ui/**/*'], + ignores: ['src/shared/ui/index.ts'], + }, + { + files: ['src/entities/user/ui/**/*'], + rules: { + rule1: 'error', + }, + }, + ] + + expect(createRuleInstructions(config)).toEqual({ + rule1: { + options: null, + globGroups: [ + { + severity: 'warn', + files: ['src/shared/ui/**/*'], + ignores: ['src/shared/ui/index.ts'], + }, + { + severity: 'error', + files: ['src/entities/user/ui/**/*'], + }, + ], + }, + }) + }) +}) diff --git a/packages/steiger/src/models/config/create-rule-instructions.ts b/packages/steiger/src/models/config/create-rule-instructions.ts new file mode 100644 index 0000000..0e0c0c4 --- /dev/null +++ b/packages/steiger/src/models/config/create-rule-instructions.ts @@ -0,0 +1,54 @@ +import { Config, ConfigObject, Severity } from '@steiger/types' + +import { RuleInstructions } from './types' +import { getOptions, getSeverity, isConfigObject } from './raw-config' + +function createEmptyInstructions(): RuleInstructions { + return { + options: null, + globGroups: [], + } +} + +function extractRuleNames(configObject: ConfigObject) { + return Object.keys(configObject.rules) +} + +function preCreateRuleInstructions(config: Config) { + return config + .filter(isConfigObject) + .flatMap(extractRuleNames) + .reduce( + (acc, item) => ({ + ...acc, + [item]: createEmptyInstructions(), + }), + {}, + ) +} + +export default function createRuleInstructions(config: Config): Record { + const ruleNameToInstructions: Record = preCreateRuleInstructions(config) + + return config.reduce((acc: Record, item) => { + if (isConfigObject(item)) { + Object.entries(item.rules).forEach( + ([ruleName, severityOrTuple]: [string, Severity | [Severity, Record]]) => { + const ruleOptions: Record | null = getOptions(severityOrTuple) + + if (ruleOptions) { + acc[ruleName].options = ruleOptions + } + + acc[ruleName].globGroups.push({ + severity: getSeverity(severityOrTuple), + files: item.files, + ignores: item.ignores, + }) + }, + ) + } + + return acc + }, ruleNameToInstructions) +} diff --git a/packages/steiger/src/models/config/index.ts b/packages/steiger/src/models/config/index.ts new file mode 100644 index 0000000..d42bac1 --- /dev/null +++ b/packages/steiger/src/models/config/index.ts @@ -0,0 +1,63 @@ +import { combine, createEvent, createStore } from 'effector' +import { Config, GlobalIgnore, Plugin } from '@steiger/types' + +import createRuleInstructions from './create-rule-instructions' +import { RuleInstructions } from './types' +import buildValidationScheme from './build-validation-scheme' +import { isGlobalIgnore, isPlugin } from './raw-config' +import { transformGlobs } from './transform-globs' + +type RuleInstructionsPerRule = Record + +export { GlobGroupWithSeverity } from './types' + +const $ruleInstructions = createStore(null) +const setRuleInstructions = createEvent() +$ruleInstructions.on(setRuleInstructions, (_state, payload) => payload) + +const $globalIgnores = createStore>([]) +const setGlobalIgnores = createEvent>() +$globalIgnores.on(setGlobalIgnores, (_state, payload) => payload) + +const $plugins = createStore>([]) +const setPlugins = createEvent>() +$plugins.on(setPlugins, (_state, payload) => payload) + +// Rules that are configured in the config file +export const $enabledRules = combine($ruleInstructions, $plugins, (ruleInstructions, plugins) => { + const rulesThatHaveInstructions = ruleInstructions ? Object.keys(ruleInstructions) : [] + const allRules = plugins.flatMap((plugin) => plugin.ruleDefinitions) + + return allRules.filter((rule) => rulesThatHaveInstructions.includes(rule.name)) +}) + +export function processConfiguration(rawConfig: Config, configLocationFolder: string | null) { + const validationScheme = buildValidationScheme(rawConfig) + const validatedConfig = validationScheme.parse(rawConfig) + + const plugins = rawConfig.filter(isPlugin) + const configTransformedGlobs = transformGlobs(validatedConfig, configLocationFolder) + const ruleInstructions = createRuleInstructions(configTransformedGlobs) + + setPlugins(plugins) + setGlobalIgnores(configTransformedGlobs.filter(isGlobalIgnore)) + setRuleInstructions(ruleInstructions) + + return validatedConfig +} + +export function getEnabledRules() { + return $enabledRules.getState() +} + +export function getRuleOptions(ruleName: string) { + return $ruleInstructions.getState()?.[ruleName].options || null +} + +export function getGlobalIgnores() { + return $globalIgnores.getState() +} + +export function getGlobsForRule(ruleName: string) { + return $ruleInstructions.getState()?.[ruleName].globGroups || [] +} diff --git a/packages/steiger/src/models/config/raw-config.ts b/packages/steiger/src/models/config/raw-config.ts new file mode 100644 index 0000000..16b4b70 --- /dev/null +++ b/packages/steiger/src/models/config/raw-config.ts @@ -0,0 +1,25 @@ +import { BaseRuleOptions, ConfigObject, GlobalIgnore, Plugin, Severity } from '@steiger/types' + +export function getSeverity(severityOrTuple: Severity | [Severity, BaseRuleOptions]): Severity { + return Array.isArray(severityOrTuple) ? severityOrTuple[0] : severityOrTuple +} + +export function getOptions(severityOrTuple: Severity | [Severity, BaseRuleOptions]): BaseRuleOptions | null { + return Array.isArray(severityOrTuple) ? severityOrTuple[1] : null +} + +export function isGlobalIgnore(obj: unknown): obj is GlobalIgnore { + return typeof obj === 'object' && obj !== null && 'ignores' in obj && Object.keys(obj).length === 1 +} + +export function isConfigObject(obj: unknown): obj is ConfigObject { + return typeof obj === 'object' && obj !== null && 'rules' in obj +} + +export function isPlugin(obj: unknown): obj is Plugin { + return typeof obj === 'object' && obj !== null && 'ruleDefinitions' in obj +} + +export function isConfiguration(obj: unknown): obj is ConfigObject | GlobalIgnore { + return isConfigObject(obj) || isGlobalIgnore(obj) +} diff --git a/packages/steiger/src/models/config/transform-globs.spec.ts b/packages/steiger/src/models/config/transform-globs.spec.ts new file mode 100644 index 0000000..d4efa2d --- /dev/null +++ b/packages/steiger/src/models/config/transform-globs.spec.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest' +import { Config } from '@steiger/types' + +import { joinFromRoot } from '../../_lib/prepare-test' +import { transformGlobs } from './transform-globs' + +describe('transformGlobs', () => { + it('should convert relative globs to absolute', () => { + const config: Config = [ + { + ignores: ['./src/entities/**'], + }, + { + rules: { + rule1: 'warn', + }, + files: ['./src/shared/ui/**/*'], + ignores: ['./src/shared/ui/index.ts'], + }, + ] + + expect(transformGlobs(config, joinFromRoot('projects', 'dummy-project'))).toEqual([ + { + ignores: ['/projects/dummy-project/src/entities/**'], + }, + { + rules: { + rule1: 'warn', + }, + files: ['/projects/dummy-project/src/shared/ui/**/*'], + ignores: ['/projects/dummy-project/src/shared/ui/index.ts'], + }, + ]) + }) + + it('should strip trailing slashes', () => { + const config: Config = [ + { + ignores: ['./src/entities/', '**/shared/'], + }, + { + rules: { + rule1: 'warn', + }, + files: ['./src/shared/ui/', '**/pages/'], + ignores: ['./src/shared/ui/index.ts'], + }, + ] + + expect(transformGlobs(config, joinFromRoot('projects', 'dummy-project'))).toEqual([ + { + ignores: ['/projects/dummy-project/src/entities', '/projects/dummy-project/**/shared'], + }, + { + rules: { + rule1: 'warn', + }, + files: ['/projects/dummy-project/src/shared/ui', '/projects/dummy-project/**/pages'], + ignores: ['/projects/dummy-project/src/shared/ui/index.ts'], + }, + ]) + }) + + it("should correctly transform globs that are relative but don't start with a dot", () => { + const config: Config = [ + { + ignores: ['src/entities/**'], + }, + { + rules: { + rule1: 'warn', + }, + files: ['src/shared/ui/**/*'], + ignores: ['src/shared/ui/index.ts'], + }, + ] + + expect(transformGlobs(config, joinFromRoot('projects', 'dummy-project'))).toEqual([ + { + ignores: ['/projects/dummy-project/src/entities/**'], + }, + { + rules: { + rule1: 'warn', + }, + files: ['/projects/dummy-project/src/shared/ui/**/*'], + ignores: ['/projects/dummy-project/src/shared/ui/index.ts'], + }, + ]) + }) + + it('should correctly transform negated globs', () => { + const config: Config = [ + { + ignores: ['!src/entities/**'], + }, + { + rules: { + rule1: 'warn', + }, + files: ['!**/shared/ui/**/*'], + ignores: ['!src/shared/ui/index.ts'], + }, + ] + + expect(transformGlobs(config, joinFromRoot('projects', 'dummy-project'))).toEqual([ + { + ignores: ['!/projects/dummy-project/src/entities/**'], + }, + { + rules: { + rule1: 'warn', + }, + files: ['!/projects/dummy-project/**/shared/ui/**/*'], + ignores: ['!/projects/dummy-project/src/shared/ui/index.ts'], + }, + ]) + }) +}) diff --git a/packages/steiger/src/models/config/transform-globs.ts b/packages/steiger/src/models/config/transform-globs.ts new file mode 100644 index 0000000..32755e1 --- /dev/null +++ b/packages/steiger/src/models/config/transform-globs.ts @@ -0,0 +1,59 @@ +import { posix, sep, isAbsolute } from 'node:path' +import { Config } from '@steiger/types' + +import { isConfigObject, isConfiguration } from './raw-config' +import { getGlobPath, replaceGlobPath } from '../../shared/globs' + +function convertRelativeGlobsToAbsolute(rootPath: string, globs: Array) { + function composeAbsolutePath(root: string, glob: string) { + // Remove '/'. The root has platform-specific separators + const segmentsOfRoot = root.slice(1).split(sep) + + return `/${posix.join(...segmentsOfRoot, glob)}` + } + + const needsConversion = (glob: string) => !isAbsolute(glob) + + return globs.map((originalGlob) => { + const globClearPath = getGlobPath(originalGlob) + + return needsConversion(globClearPath) + ? replaceGlobPath(originalGlob, composeAbsolutePath(rootPath, globClearPath)) + : originalGlob + }) +} + +function stripTrailingSlashes(globs: Array) { + return globs.map((ignore) => ignore.replace(/\/$/, '')) +} + +export function transformGlobs(config: Config, configLocationPath: string | null) { + if (!configLocationPath) { + return config + } + + const globsTransformationPipeline = function (globs: Array) { + const convertToAbsolute = (globs: Array) => convertRelativeGlobsToAbsolute(configLocationPath, globs) + return convertToAbsolute(stripTrailingSlashes(globs)) + } + + return config.map((item) => { + if (!isConfiguration(item)) { + return item + } + + const newItem = { + ...item, + } + + if (newItem.ignores) { + newItem.ignores = globsTransformationPipeline(newItem.ignores) + } + + if (isConfigObject(newItem) && newItem.files) { + newItem.files = globsTransformationPipeline(newItem.files) + } + + return newItem + }) +} diff --git a/packages/steiger/src/models/config/types.ts b/packages/steiger/src/models/config/types.ts new file mode 100644 index 0000000..40d207b --- /dev/null +++ b/packages/steiger/src/models/config/types.ts @@ -0,0 +1,11 @@ +import { Severity, BaseRuleOptions } from '@steiger/types' +import { GlobGroup } from '../../shared/globs' + +export interface GlobGroupWithSeverity extends GlobGroup { + severity: Severity +} + +export interface RuleInstructions { + options: BaseRuleOptions | null + globGroups: Array +} diff --git a/packages/steiger/src/shared/file-system.ts b/packages/steiger/src/shared/file-system.ts index 539f03a..a01661b 100644 --- a/packages/steiger/src/shared/file-system.ts +++ b/packages/steiger/src/shared/file-system.ts @@ -1,29 +1,31 @@ import { File, Folder } from '@steiger/types' -/** - * Turn a tree folder structure into a flat array of files. - * */ -export function flattenFolder(folder: Folder): File[] { - return folder.children.reduce((acc, child) => { - if (child.type === 'file') { - return [...acc, child] - } +export function isPathInTree(vfs: Folder, paths: string | Array) { + const pathsToCheck = Array.isArray(paths) ? paths : [paths] + const results: Array = new Array(pathsToCheck.length).fill(false) + let found = 0 - return [...acc, ...flattenFolder(child)] - }, [] as File[]) -} + const stack: Array = [vfs] + + while (stack.length > 0) { + const node = stack.pop()! + const currentPathIndex = pathsToCheck.indexOf(node.path) -export function copyFsEntity(fsEntity: T, deep: boolean = false) { - if (fsEntity.type === 'folder') { - const newChildren: Array = deep - ? fsEntity.children.map((child) => (child.type === 'folder' ? copyFsEntity(child, true) : child)) - : [] + if (currentPathIndex !== -1) { + results[currentPathIndex] = true + found++ + } + + if (found === pathsToCheck.length) { + break + } - return { - ...fsEntity, - children: newChildren, + if (node.type === 'folder') { + for (let i = 0; i < node.children.length; i++) { + stack.push(node.children[i]) + } } } - return { ...fsEntity } + return typeof paths === 'string' ? results[0] : results } diff --git a/packages/steiger/src/shared/globs.ts b/packages/steiger/src/shared/globs.ts deleted file mode 100644 index fd28077..0000000 --- a/packages/steiger/src/shared/globs.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function isNegationPattern(pattern: string) { - return pattern.startsWith('!') -} diff --git a/packages/steiger/src/shared/globs/apply-exclusion.spec.ts b/packages/steiger/src/shared/globs/apply-exclusion.spec.ts new file mode 100644 index 0000000..196f92a --- /dev/null +++ b/packages/steiger/src/shared/globs/apply-exclusion.spec.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from 'vitest' + +import { applyExclusion } from './apply-exclusion' +import { not } from './not' +import { joinFromRoot, parseIntoFsdRoot } from '../../_lib/prepare-test' + +describe('applyExclusion', () => { + it('should apply exclusions with a normal glob group', () => { + const vfs = parseIntoFsdRoot( + ` + πŸ“‚ shared + πŸ“‚ ui + πŸ“„ index.ts + πŸ“„ Button.tsx + πŸ“‚ lib + πŸ“„ device-detect.ts + πŸ“„ get-query-params.ts + πŸ“„ index.ts + πŸ“‚ entities + πŸ“‚ user + πŸ“‚ ui + πŸ“„ UserAvatar.tsx + πŸ“„ index.ts + πŸ“„ index.ts + πŸ“‚ post + πŸ“‚ ui + πŸ“„ index.ts + πŸ“„ PostList.tsx + πŸ“„ index.ts + `, + joinFromRoot('src'), + ) + + const expectedVfs = parseIntoFsdRoot( + ` + πŸ“‚ shared + πŸ“‚ lib + πŸ“„ device-detect.ts + πŸ“„ get-query-params.ts + πŸ“„ index.ts + πŸ“‚ entities + πŸ“‚ user + πŸ“„ index.ts + πŸ“‚ post + πŸ“„ index.ts + `, + joinFromRoot('src'), + ) + + const globs = [ + { + files: ['/src/shared/**', '/src/entities/**'], + ignores: ['**/ui/**'], + }, + ] + + expect(applyExclusion(vfs, globs)).toEqual(expectedVfs) + }) + + it('should correctly apply exclusions with an inverted glob group', () => { + const vfs = parseIntoFsdRoot( + ` + πŸ“‚ shared + πŸ“‚ ui + πŸ“„ index.ts + πŸ“„ Button.tsx + πŸ“‚ lib + πŸ“„ device-detect.ts + πŸ“„ get-query-params.ts + πŸ“„ index.ts + πŸ“‚ entities + πŸ“‚ user + πŸ“‚ ui + πŸ“„ UserAvatar.tsx + πŸ“„ index.ts + πŸ“„ index.ts + πŸ“‚ post + πŸ“‚ ui + πŸ“„ index.ts + πŸ“„ PostList.tsx + πŸ“„ index.ts + `, + joinFromRoot('src'), + ) + + const expectedVfs = parseIntoFsdRoot( + ` + πŸ“‚ shared + πŸ“‚ lib + πŸ“„ device-detect.ts + πŸ“„ index.ts`, + joinFromRoot('src'), + ) + + const globs = [ + { + files: ['/src/shared/**', '/src/entities/**'], + ignores: ['**/ui/**'], + }, + not({ + files: ['/src/entities/**', '/src/shared/lib/get-query-params.ts'], + }), + ] + + expect(applyExclusion(vfs, globs)).toEqual(expectedVfs) + }) + + it('should correctly apply exclusions with several normal and inverted glob groups', () => { + const vfs = parseIntoFsdRoot( + ` + πŸ“‚ shared + πŸ“‚ ui + πŸ“„ index.ts + πŸ“„ Button.tsx + πŸ“‚ lib + πŸ“„ device-detect.ts + πŸ“„ get-query-params.ts + πŸ“„ index.ts + πŸ“‚ entities + πŸ“‚ user + πŸ“‚ ui + πŸ“„ UserAvatar.tsx + πŸ“„ index.ts + πŸ“„ index.ts + πŸ“‚ post + πŸ“‚ ui + πŸ“„ index.ts + πŸ“„ PostList.tsx + πŸ“„ index.ts + πŸ“‚ pages + πŸ“‚ main + πŸ“„ index.ts + πŸ“‚ account + πŸ“„ index.ts + `, + joinFromRoot('src'), + ) + + const expectedVfs = parseIntoFsdRoot( + ` + πŸ“‚ shared + πŸ“‚ lib + πŸ“„ device-detect.ts + πŸ“„ index.ts + πŸ“‚ pages + πŸ“‚ main + πŸ“„ index.ts`, + joinFromRoot('src'), + ) + + const globs = [ + { + files: ['/src/shared/**', '/src/entities/**'], + ignores: ['**/ui/**'], + }, + not({ + files: ['/src/entities/**', '/src/shared/lib/get-query-params.ts'], + }), + { + files: ['/src/pages/**'], + }, + not({ + files: ['/src/pages/account/**'], + }), + ] + + expect(applyExclusion(vfs, globs)).toEqual(expectedVfs) + }) + + it('should correctly apply exclusions for an empty glob group', () => { + const globs = [not({}), { files: ['/src/shared/ui/Button.ts'], ignores: [] }] + + const vfs = parseIntoFsdRoot( + ` + πŸ“‚ shared + πŸ“‚ ui + πŸ“„ Button.ts + πŸ“„ Button.spec.ts + πŸ“„ Input.ts + πŸ“„ Input.spec.ts + πŸ“„ index.ts + πŸ“‚ lib + πŸ“„ get-query-params.ts + πŸ“„ get-query-params.spec.ts + πŸ“„ device-detection.ts + πŸ“„ device-detection.spec.ts + πŸ“„ index.ts + πŸ“‚ entities + πŸ“‚ user + πŸ“‚ ui + πŸ“„ UserAvatar.ts + πŸ“„ UserAvatar.spec.ts + πŸ“„ Input.ts + πŸ“‚ pages + πŸ“‚ profile + πŸ“„ index.ts + πŸ“‚ main + πŸ“„ index.ts + `, + joinFromRoot('src'), + ) + + const expectedVfs = parseIntoFsdRoot( + ` + πŸ“‚ shared + πŸ“‚ ui + πŸ“„ Button.ts + `, + joinFromRoot('src'), + ) + + expect(applyExclusion(vfs, globs)).toEqual(expectedVfs) + }) + + it('should correctly apply exclusions for brace sets', () => { + const globs = [{}, not({ files: ['**/*.spec.{ts,tsx}'] })] + + const vfs = parseIntoFsdRoot( + ` + πŸ“‚ shared + πŸ“‚ ui + πŸ“„ Button.ts + πŸ“„ Button.spec.tsx + πŸ“„ Input.ts + πŸ“„ Input.spec.tsx + πŸ“„ index.ts + πŸ“‚ lib + πŸ“„ get-query-params.ts + πŸ“„ get-query-params.spec.ts + πŸ“„ device-detection.ts + πŸ“„ device-detection.spec.ts + πŸ“„ index.ts + πŸ“‚ entities + πŸ“‚ user + πŸ“‚ ui + πŸ“„ UserAvatar.ts + πŸ“„ UserAvatar.spec.tsx + πŸ“„ Input.ts + πŸ“‚ pages + πŸ“‚ profile + πŸ“„ index.ts + πŸ“‚ main + πŸ“„ index.ts + `, + joinFromRoot('src'), + ) + + const expectedVfs = parseIntoFsdRoot( + ` + πŸ“‚ shared + πŸ“‚ ui + πŸ“„ Button.ts + πŸ“„ Input.ts + πŸ“„ index.ts + πŸ“‚ lib + πŸ“„ get-query-params.ts + πŸ“„ device-detection.ts + πŸ“„ index.ts + πŸ“‚ entities + πŸ“‚ user + πŸ“‚ ui + πŸ“„ UserAvatar.ts + πŸ“„ Input.ts + πŸ“‚ pages + πŸ“‚ profile + πŸ“„ index.ts + πŸ“‚ main + πŸ“„ index.ts + `, + joinFromRoot('src'), + ) + + expect(applyExclusion(vfs, globs)).toEqual(expectedVfs) + }) +}) diff --git a/packages/steiger/src/shared/globs/apply-exclusion.ts b/packages/steiger/src/shared/globs/apply-exclusion.ts new file mode 100644 index 0000000..1a93c6e --- /dev/null +++ b/packages/steiger/src/shared/globs/apply-exclusion.ts @@ -0,0 +1,95 @@ +import { File, Folder } from '@steiger/types' + +import { createFilterAccordingToGlobs } from './create-filter-according-to-globs' +import { GlobGroup, InvertedGlobGroup } from './index' + +function removeEmptyFolders(node: Folder): Folder { + const children = node.children + .map((node) => (node.type === 'folder' ? removeEmptyFolders(node) : node)) + .filter((node) => (node.type === 'folder' ? node.children.length > 0 : true)) + + return { + ...node, + children, + } +} + +function copyNode(node: T, deep: boolean = false) { + if (node.type === 'folder') { + const newChildren: Array = deep + ? node.children.map((child) => (child.type === 'folder' ? copyNode(child, true) : child)) + : [] + + return { + ...node, + children: newChildren, + } + } + + return { ...node } +} + +function excludeFilesBasedOnGlobs(vfs: Folder, globs: Array): Folder { + const vfsCopy = copyNode(vfs, true) + + function isIncluded(path: string) { + return globs.reduce((prev, { files, ignores, ...rest }) => { + const invertedGlob = 'inverted' in rest + const matches = createFilterAccordingToGlobs({ inclusions: files, exclusions: ignores }) + const current = matches(path) + + /** + * Combinations map + * + * If an inverted glob matches the current path, exclude it: + * invertedGlob | current | prev = false + * invertedGlob | current | !prev = false + * + * If a normal glob matches the current path, return the current value + * !invertedGlob | current | prev = current + * !invertedGlob | current | !prev = current + * + * If a normal glob does not match the current path, keep the decision from the previous iteration. + * As if the glob is not inverted it's intended to add more paths and not override the previous decision. + * !invertedGlob | !current | prev = prev + * !invertedGlob | !current | !prev = prev + * + * If an inverted glob does not match the current path, keep the decision from the previous iterations. + * invertedGlob | !current | prev = prev + * invertedGlob | !current | !prev = prev + * + * */ + + if (invertedGlob && current) { + return false + } else if ((invertedGlob && !current) || (!invertedGlob && !current)) { + return prev + } else { + return current + } + }, false) + } + + function walk(node: Folder): Folder { + return { + ...node, + children: (node.children as Array) + .filter(({ type, path }) => (type === 'folder' ? true : isIncluded(path))) + .map((child) => { + if (child.type === 'folder') { + return walk(child) + } + + return child + }), + } + } + + return walk(vfsCopy) +} + +export function applyExclusion(vfs: Folder, globs: Array) { + const vfsCopy = copyNode(vfs, true) + + return removeEmptyFolders(excludeFilesBasedOnGlobs(vfsCopy, globs)) +} diff --git a/packages/steiger/src/shared/globs/create-filter-according-to-globs.spec.ts b/packages/steiger/src/shared/globs/create-filter-according-to-globs.spec.ts new file mode 100644 index 0000000..83ac1b7 --- /dev/null +++ b/packages/steiger/src/shared/globs/create-filter-according-to-globs.spec.ts @@ -0,0 +1,247 @@ +import { expect, it, describe } from 'vitest' + +import { createFilterAccordingToGlobs } from './create-filter-according-to-globs' + +describe('createFilterAccordingToGlobs', () => { + it('should return false if empty "files" are provided', () => { + const filter = createFilterAccordingToGlobs({ + inclusions: [], + exclusions: [], + }) + const files = [ + '/src/shared/ui/styles.css', + '/src/shared/ui/Button.tsx', + '/src/shared/ui/TextField.tsx', + '/src/shared/ui/index.ts', + ] + + const filteredFiles = files.filter(filter) + + expect(filteredFiles).toEqual([]) + }) + + it('should return the picked folder if a specific folder is passed to inclusion patterns', () => { + const files = [ + '/src/shared/ui/styles.css', + '/src/shared/ui/Button.tsx', + '/src/shared/ui/TextField.tsx', + '/src/shared/ui/index.ts', + '/src/entities/user/UserAvatar.tsx', + '/src/entities/product/ProductCard.tsx', + ] + + const expected = [ + '/src/shared/ui/styles.css', + '/src/shared/ui/Button.tsx', + '/src/shared/ui/TextField.tsx', + '/src/shared/ui/index.ts', + ] + + const filter = createFilterAccordingToGlobs({ + inclusions: ['/src/shared/**'], + exclusions: [], + }) + + const filteredFiles = files.filter(filter) + + expect(filteredFiles).toEqual(expected) + }) + + it('should return all __mock__ folders if the inclusion pattern says to include them all', () => { + const files = [ + '/src/shared/ui/__mocks__/Button.tsx', + '/src/shared/ui/styles.css', + '/src/shared/ui/Button.tsx', + '/src/shared/ui/TextField.tsx', + '/src/shared/ui/index.ts', + '/src/entities/user/__mocks__/UserAvatar.tsx', + '/src/entities/user/UserAvatar.tsx', + '/src/entities/product/ProductCard.tsx', + ] + + const expectedFiles = ['/src/shared/ui/__mocks__/Button.tsx', '/src/entities/user/__mocks__/UserAvatar.tsx'] + + const filter = createFilterAccordingToGlobs({ + inclusions: ['**/__mocks__/**'], + exclusions: [], + }) + + const filteredFiles = files.filter(filter) + + expect(filteredFiles).toEqual(expectedFiles) + }) + + it('should return files picked by extension if the inclusion patterns pick by extension', () => { + const files = [ + '/src/shared/ui/styles.css', + '/src/shared/ui/Button.tsx', + '/src/shared/ui/TextField.tsx', + '/src/shared/ui/index.ts', + '/src/entities/user/UserAvatar.tsx', + '/src/entities/user/styles.css', + '/src/entities/product/ProductCard.tsx', + ] + + const expected = ['/src/shared/ui/styles.css', '/src/entities/user/styles.css'] + + const filter = createFilterAccordingToGlobs({ + inclusions: ['**/*.css'], + exclusions: [], + }) + + const filteredFiles = files.filter(filter) + + expect(filteredFiles).toEqual(expected) + }) + + it('should return a specific single file if the inclusion pattern pick that only file', () => { + const files = [ + '/src/shared/ui/styles.css', + '/src/shared/ui/Button.tsx', + '/src/shared/ui/TextField.tsx', + '/src/shared/ui/index.ts', + '/src/entities/user/UserAvatar.tsx', + '/src/entities/user/styles.css', + '/src/entities/product/ProductCard.tsx', + ] + + const expected = ['/src/shared/ui/TextField.tsx'] + + const filter = createFilterAccordingToGlobs({ + inclusions: ['/src/shared/ui/TextField.tsx'], + exclusions: [], + }) + + const filteredFiles = files.filter(filter) + + expect(filteredFiles).toEqual(expected) + }) + + it('should correctly handle negations in exclusion patterns', () => { + const files = [ + '/src/shared/ui/styles.css', + '/src/shared/ui/Button.tsx', + '/src/shared/ui/TextField.tsx', + '/src/shared/ui/index.ts', + '/src/shared/config/eslint.config.js', + '/src/shared/config/styling.config.js', + '/src/entities/user/UserAvatar.tsx', + '/src/entities/user/styles.css', + '/src/entities/product/ProductCard.tsx', + ] + + const expected = [ + '/src/shared/ui/styles.css', + '/src/shared/ui/Button.tsx', + '/src/shared/ui/TextField.tsx', + '/src/shared/ui/index.ts', + '/src/shared/config/eslint.config.js', + '/src/entities/user/UserAvatar.tsx', + '/src/entities/user/styles.css', + '/src/entities/product/ProductCard.tsx', + ] + + const filter = createFilterAccordingToGlobs({ + exclusions: ['**/*.config.js', '!**/eslint.config.js'], + }) + + const filteredFiles = files.filter(filter) + + expect(filteredFiles).toEqual(expected) + }) + + it('should return the fs tree without all __mock__ folders if the exclusion pattern says to ignore them all', () => { + const files = [ + '/src/shared/ui/__mocks__/Button.tsx', + '/src/shared/ui/styles.css', + '/src/shared/ui/Button.tsx', + '/src/shared/ui/TextField.tsx', + '/src/shared/ui/index.ts', + '/src/entities/user/__mocks__/UserAvatar.tsx', + '/src/entities/user/UserAvatar.tsx', + '/src/entities/product/ProductCard.tsx', + ] + + const expected = [ + '/src/shared/ui/styles.css', + '/src/shared/ui/Button.tsx', + '/src/shared/ui/TextField.tsx', + '/src/shared/ui/index.ts', + '/src/entities/user/UserAvatar.tsx', + '/src/entities/product/ProductCard.tsx', + ] + + const filter = createFilterAccordingToGlobs({ + exclusions: ['**/__mocks__/**'], + }) + + const filteredFiles = files.filter(filter) + + expect(filteredFiles).toEqual(expected) + }) + + it('should exclude files with specific extension if exclusion pattern is set up accordingly', () => { + const files = [ + '/src/shared/lib/get-query-params.ts', + '/src/shared/lib/get-query-params.test.ts', + '/src/shared/ui/styles.css', + '/src/shared/ui/Button.tsx', + '/src/shared/ui/Button.test.tsx', + '/src/shared/ui/TextField.tsx', + '/src/shared/ui/TextField.test.tsx', + '/src/shared/ui/index.ts', + '/src/entities/user/UserAvatar.tsx', + '/src/entities/user/UserAvatar.test.tsx', + '/src/entities/user/styles.css', + '/src/entities/product/ProductCard.tsx', + ] + + const expected = [ + '/src/shared/lib/get-query-params.ts', + '/src/shared/ui/styles.css', + '/src/shared/ui/Button.tsx', + '/src/shared/ui/TextField.tsx', + '/src/shared/ui/index.ts', + ] + + const filter = createFilterAccordingToGlobs({ + inclusions: ['/src/shared/**'], + exclusions: ['**/*.test.{tsx,ts}'], + }) + + const filteredFiles = files.filter(filter) + + expect(filteredFiles).toEqual(expected) + }) + + it('should correctly handle folder paths', () => { + const files = [ + '/src/shared/ui/styles.css', + '/src/shared/ui/Button.tsx', + '/src/shared/ui/TextField.tsx', + '/src/shared/ui/index.ts', + '/src/shared', + '/src/shared/ui', + '/src/entities/user/UserAvatar.tsx', + '/src/entities/user/styles.css', + '/src/entities/product/ProductCard.tsx', + ] + + const expected = [ + '/src/shared/ui/styles.css', + '/src/shared/ui/Button.tsx', + '/src/shared/ui/TextField.tsx', + '/src/shared/ui/index.ts', + '/src/shared', + '/src/shared/ui', + ] + + const filter = createFilterAccordingToGlobs({ + inclusions: ['**/shared', '**/shared/**'], + }) + + const filteredFiles = files.filter(filter) + + expect(filteredFiles).toEqual(expected) + }) +}) diff --git a/packages/steiger/src/shared/globs/create-filter-according-to-globs.ts b/packages/steiger/src/shared/globs/create-filter-according-to-globs.ts new file mode 100644 index 0000000..5f3381a --- /dev/null +++ b/packages/steiger/src/shared/globs/create-filter-according-to-globs.ts @@ -0,0 +1,42 @@ +import { isNegatedGlob } from './utilities' +import { minimatch } from 'minimatch' + +// ! Don't use platform specific path separators in the glob patterns for globby/minimatch +// as it only works with forward slashes! + +interface ApplyGlobsOptions { + inclusions?: string[] + exclusions?: string[] +} + +export function createFilterAccordingToGlobs({ inclusions, exclusions }: ApplyGlobsOptions) { + const thereAreInclusions = Array.isArray(inclusions) + const thereAreExclusions = Array.isArray(exclusions) + const inclusionsEmpty = thereAreInclusions && inclusions.length === 0 + + function filterAccordingToGlobs(path: string) { + const matchesInclusionPatterns = !thereAreInclusions || inclusions.some((pattern) => minimatch(path, pattern)) + let isIgnored = false + + if (inclusionsEmpty) { + return false + } + + if (matchesInclusionPatterns && thereAreExclusions) { + isIgnored = exclusions.filter((pattern) => !isNegatedGlob(pattern)).some((pattern) => minimatch(path, pattern)) + + // If the path is ignored, check for any negated patterns that would include it back + if (isIgnored) { + const isNegated = exclusions.some( + (ignorePattern) => isNegatedGlob(ignorePattern) && minimatch(path, ignorePattern.slice(1)), + ) + + isIgnored = !isNegated + } + } + + return matchesInclusionPatterns && !isIgnored + } + + return filterAccordingToGlobs +} diff --git a/packages/steiger/src/shared/globs/index.ts b/packages/steiger/src/shared/globs/index.ts new file mode 100644 index 0000000..c34b52a --- /dev/null +++ b/packages/steiger/src/shared/globs/index.ts @@ -0,0 +1,4 @@ +export { GlobGroup, InvertedGlobGroup } from './types' +export { not } from './not' +export { getGlobPath, replaceGlobPath } from './utilities' +export { applyExclusion } from './apply-exclusion' diff --git a/packages/steiger/src/shared/globs/merge-glob-groups.spec.ts b/packages/steiger/src/shared/globs/merge-glob-groups.spec.ts new file mode 100644 index 0000000..babbac2 --- /dev/null +++ b/packages/steiger/src/shared/globs/merge-glob-groups.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { mergeGlobGroups } from './merge-glob-groups' + +describe('mergeGlobGroups', () => { + it('should merge glob groups into one', () => { + const merged = mergeGlobGroups([ + { files: ['/src/shared/**'], ignores: ['**/*.spec.ts'] }, + { files: ['/src/entities/**'], ignores: ['**/__mocks__/**'] }, + ]) + + expect(merged).toEqual({ + files: ['/src/shared/**', '/src/entities/**'], + ignores: ['**/*.spec.ts', '**/__mocks__/**'], + }) + }) + + it('should correctly merge glob groups when the last one is empty', () => { + const merged = mergeGlobGroups([{ files: ['/src/shared/**'], ignores: ['**/*.spec.ts'] }, {}]) + + expect(merged).toEqual({ + files: ['/src/shared/**'], + ignores: ['**/*.spec.ts'], + }) + }) + + it('should correctly merge glob groups that miss files or ignores', () => { + const merged = mergeGlobGroups([{ files: ['/src/shared/**'] }, { ignores: ['**/*.spec.ts'] }]) + + expect(merged).toEqual({ + files: ['/src/shared/**'], + ignores: ['**/*.spec.ts'], + }) + }) +}) diff --git a/packages/steiger/src/shared/globs/merge-glob-groups.ts b/packages/steiger/src/shared/globs/merge-glob-groups.ts new file mode 100644 index 0000000..f63c904 --- /dev/null +++ b/packages/steiger/src/shared/globs/merge-glob-groups.ts @@ -0,0 +1,17 @@ +import { GlobGroup } from './types' + +export function mergeGlobGroups(globs: Array) { + return globs.reduce((acc, { files, ignores }) => { + const interimGlobGroup: GlobGroup = { ...acc } + + if (files) { + interimGlobGroup.files = [...(acc.files || []), ...files] + } + + if (ignores) { + interimGlobGroup.ignores = [...(acc.ignores || []), ...ignores] + } + + return interimGlobGroup + }) +} diff --git a/packages/steiger/src/shared/globs/not.ts b/packages/steiger/src/shared/globs/not.ts new file mode 100644 index 0000000..fd6cd30 --- /dev/null +++ b/packages/steiger/src/shared/globs/not.ts @@ -0,0 +1,9 @@ +import { GlobGroup, InvertedGlobGroup } from './types' + +export function not(globs: GlobGroup): InvertedGlobGroup { + return { + files: globs.files, + ignores: globs.ignores, + inverted: true, + } +} diff --git a/packages/steiger/src/shared/globs/types.ts b/packages/steiger/src/shared/globs/types.ts new file mode 100644 index 0000000..7f7891b --- /dev/null +++ b/packages/steiger/src/shared/globs/types.ts @@ -0,0 +1,8 @@ +export interface GlobGroup { + files?: string[] + ignores?: string[] +} + +export interface InvertedGlobGroup extends GlobGroup { + inverted: boolean +} diff --git a/packages/steiger/src/shared/globs/utilities.spec.ts b/packages/steiger/src/shared/globs/utilities.spec.ts new file mode 100644 index 0000000..2987aeb --- /dev/null +++ b/packages/steiger/src/shared/globs/utilities.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import { getGlobPath, replaceGlobPath, isNegatedGlob } from './utilities' + +describe('isNegated', () => { + it('should return true if the pattern is negated', () => { + expect(isNegatedGlob('!src/shared/ui/**')).toBe(true) + }) + + it('should return false if the pattern is not negated', () => { + expect(isNegatedGlob('src/shared/ui/**')).toBe(false) + }) +}) + +describe('getGlobPath', () => { + it('should return the path without the negation', () => { + expect(getGlobPath('!src/shared/ui/**')).toBe('src/shared/ui/**') + }) + + it('should return the path as is if it is not negated', () => { + expect(getGlobPath('src/shared/ui/**')).toBe('src/shared/ui/**') + }) +}) + +describe('replaceGlobPath', () => { + it('should replace the path with the provided one', () => { + expect(replaceGlobPath('!src/shared/ui/**', '/projects/project-1/src/shared/ui/**')).toBe( + '!/projects/project-1/src/shared/ui/**', + ) + }) + + it('should replace the path with the provided one', () => { + expect(replaceGlobPath('src/shared/ui/**', '/projects/project-1/src/shared/ui/**')).toBe( + '/projects/project-1/src/shared/ui/**', + ) + }) +}) diff --git a/packages/steiger/src/shared/globs/utilities.ts b/packages/steiger/src/shared/globs/utilities.ts new file mode 100644 index 0000000..4bc64b8 --- /dev/null +++ b/packages/steiger/src/shared/globs/utilities.ts @@ -0,0 +1,12 @@ +export function isNegatedGlob(pattern: string) { + return pattern.startsWith('!') +} + +export function getGlobPath(pattern: string) { + return isNegatedGlob(pattern) ? pattern.slice(1) : pattern +} + +export function replaceGlobPath(pattern: string, replacement: string) { + const sanitizedReplacement = getGlobPath(replacement) + return isNegatedGlob(pattern) ? `!${sanitizedReplacement}` : sanitizedReplacement +} diff --git a/packages/steiger/src/shared/objects.ts b/packages/steiger/src/shared/objects.ts new file mode 100644 index 0000000..d28d3de --- /dev/null +++ b/packages/steiger/src/shared/objects.ts @@ -0,0 +1,3 @@ +import { isEqual as lodashEqual } from 'lodash-es' + +export const isEqual = lodashEqual diff --git a/packages/types/index.ts b/packages/types/index.ts index 36a8db2..5d823c4 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -18,10 +18,10 @@ export interface Rule { } export interface RuleResult { - diagnostics: Array + diagnostics: Array } -export interface Diagnostic { +export interface PartialDiagnostic { message: string fixes?: Array location: { @@ -32,6 +32,12 @@ export interface Diagnostic { } } +export interface Diagnostic extends PartialDiagnostic { + ruleName: string + severity: Exclude + getRuleDescriptionUrl(ruleName: string): URL +} + export type Fix = | { type: 'rename' @@ -57,21 +63,23 @@ export type Fix = content: string } -export type Config = Array +export type Config = Array export type Severity = 'off' | 'warn' | 'error' -export interface ConfigObject { +export type ConfigObject = { /** Globs of files to check */ files?: Array /** Globs of files to ignore */ ignores?: Array /** Severity of rules and individual rule options. */ - rules?: { + rules: { [ruleName: string]: Severity | [Severity, BaseRuleOptions] } } +export type GlobalIgnore = { ignores: Array } + export interface Plugin { meta: { name: string diff --git a/packages/types/package.json b/packages/types/package.json index fa94bec..0a39c7a 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -27,6 +27,7 @@ ], "devDependencies": { "@steiger/tsconfig": "workspace:*", + "@types/node": "^20.14.2", "typescript": "^5.5.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa1e0d4..019d386 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: immer: specifier: ^10.1.1 version: 10.1.1 + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 minimatch: specifier: ^10.0.1 version: 10.0.1 @@ -133,6 +136,9 @@ importers: '@total-typescript/ts-reset': specifier: ^0.5.1 version: 0.5.1 + '@types/lodash-es': + specifier: ^4.17.12 + version: 4.17.12 '@types/yargs': specifier: ^17.0.32 version: 17.0.32 @@ -200,6 +206,9 @@ importers: '@steiger/tsconfig': specifier: workspace:* version: link:../../tooling/tsconfig + '@types/node': + specifier: ^20.14.2 + version: 20.14.2 typescript: specifier: ^5.5.3 version: 5.5.3