Skip to content

Commit

Permalink
feat!: report leaks, improve cli output
Browse files Browse the repository at this point in the history
  • Loading branch information
levchak0910 committed Apr 8, 2024
1 parent 11af4b0 commit 44e698a
Show file tree
Hide file tree
Showing 36 changed files with 569 additions and 165 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ jobs:

tests:
runs-on: ubuntu-latest
env:
NO_COLOR: true
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.husky
.prettierignore
.prettierignore
*.snap
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# @vkcn/report

The tool that helps to detect VKCN violations:
The tool that helps to detect [VKCN](https://www.npmjs.com/package/@vkcn/eslint-plugin) (vue-kebab-class-naming) violations:

- find class selectors that are defined in different files.
- [element](https://github.com/levchak0910/vkcn-eslint-plugin/blob/HEAD/docs/rules/no-convention-violation.md#element-class) class selectors that are defined in different files.
- [modifier](https://github.com/levchak0910/vkcn-eslint-plugin/blob/HEAD/docs/rules/no-convention-violation.md#modifier-class) class selectors that may leak in different files.

It was created specifically for [vue-kebab-class-naming](https://www.npmjs.com/package/@vkcn/eslint-plugin) convention and will not work with other naming conventions.
It was created specifically for VKCN convention and will not work with any other naming conventions.

Class extraction supported from files: `.css`, `.scss`, `.vue`.

Expand All @@ -26,7 +27,7 @@ pnpm add -D @vkcn/reporter

This tool can be used:

- programmatically - in a script for the custom reporter
- programmatically - in a script for a custom reporter
- cli - run as a command from the terminal

### Programmatic usage
Expand All @@ -44,16 +45,16 @@ const duplicates = await findDuplicatesInFiles({
doSomethingWithDuplicates(duplicates)
```

Options: `<files>` and `<ignore>` should be an array of patterns (provided by [glob](https://www.npmjs.com/package/glob) package)
Options: `files` and `ignore` should be an array of patterns (provided by [glob](https://www.npmjs.com/package/glob) package)

### CLI usage

Use it via a shell

```bash
pnpm vkcn-reporter <files> -i <ignore>
vkcn-reporter <files> -i <ignore>
```

Where `files` and `ignore` - are patterns provided by [glob](https://www.npmjs.com/package/glob) package. Can be used for multiple patterns split by a space `vkcn-reporter components/**/*.vue styles/**/*.scss`
Where `<files>` and `<ignore>` - are patterns provided by [glob](https://www.npmjs.com/package/glob) package. Can be used for multiple patterns split by a space `vkcn-reporter 'components/**/*.vue styles/**/*.scss'`. (_Make sure your folder and file names do not contain space_)

If violations are found, the process will finish with a code `1`
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@csstools/postcss-extract": "^3.0.1",
"citty": "^0.1.6",
"glob": "^10.3.12",
"picocolors": "^1.0.0",
"postcss": "^8.4.38",
"postcss-scss": "^4.0.9",
"vue": "^3.4.21"
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion src/array.ts

This file was deleted.

15 changes: 9 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import path from "node:path"

import { defineCommand, runMain } from "citty"

import { findDuplicatesInFiles } from "./program.js"
import { getReport } from "./report.js"
import { findViolationsInFiles } from "./program.js"
import { getDuplicatesReport, getLeaksReport } from "./functions/report.js"

const { version, description } = JSON.parse(fs.readFileSync(path.resolve("package.json"), "utf-8"))

Expand Down Expand Up @@ -35,11 +35,14 @@ const main = defineCommand({
const ignore =
typeof ignoreArg === "string" ? ignoreArg.split(" ") : (ignoreArg as string[]).flatMap(s => s.split(" "))

const duplicates = await findDuplicatesInFiles({ files, ignore })
const [report, code] = getReport(duplicates)
const { duplicates, leaks } = await findViolationsInFiles({ files, ignore })

console.log(report)
process.exit(code)
const [duplicatesReport, duplicatesCode] = getDuplicatesReport(duplicates)
const [leaksReport, leaksCode] = getLeaksReport(leaks)

console.log(duplicatesReport)
console.log(leaksReport)
process.exit(duplicatesCode || leaksCode)
},
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { readFile } from "fs/promises"
import { parse } from "vue/compiler-sfc"

import { uniqArray } from "../utils/array.js"

import { extractClassSelectorsFromStyleSource } from "./extract-classes-from-style.js"
import { uniqArray } from "./array.js"

export const extractClassSelectorsFromStyleFile = async (filePath: string): Promise<string[]> => {
const source = await readFile(filePath, "utf-8")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@ export const extractClassSelectorsFromStyleSource = async (source: string): Prom
await postcss([
postcssExtract({
extractLate: true,
queries: { classes: 'rule[selector^="."]' },
queries: {
elementClasses: 'rule[selector^="."]',
modifierClasses: 'rule[selector^="&."]',
},
results(results) {
return (results.classes ?? []).flatMap(entry => {
const extractedClasses = [results.elementClasses ?? [], results.modifierClasses ?? []].flat()

extractedClasses.flatMap(entry => {
const { selectors: classSelectors } = entry as { selectors: string[] }

classSelectors
.flatMap(classSelector => classSelector.split(" "))
.flatMap(classSelector => classSelector.split("."))
.filter(classSelector => classSelector.includes("--"))
.forEach(filteredClassSelector => classes.add(filteredClassSelector))
.forEach(classSelector => classes.add(classSelector))
})
},
} satisfies pluginOptions),
Expand Down
20 changes: 20 additions & 0 deletions src/functions/find-duplicates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { FileSelector } from "../models/FileSelector.js"
import type { SelectorsReport } from "../models/SelectorsReport.js"

import { replenishSetList } from "../utils/list.js"

export const findDuplicates = (fileSelectors: FileSelector[]): SelectorsReport => {
const selectorsReport: SelectorsReport = {}

fileSelectors.forEach(fileSelector => {
fileSelector.selectors
.flatMap(selector => selector.split("."))
.filter(selector => selector.includes("--"))
.forEach(selector => {
replenishSetList(selectorsReport, selector, fileSelector.filePath)
})
})

const duplicateEntries = Object.entries(selectorsReport).filter(([, files]) => files.size > 1)
return Object.fromEntries(duplicateEntries)
}
57 changes: 57 additions & 0 deletions src/functions/find-leaks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { FileSelector } from "../models/FileSelector.js"
import type { SelectorsReport } from "../models/SelectorsReport.js"

import { replenishSetList } from "../utils/list.js"

export const findLeaks = (fileSelectors: FileSelector[]): SelectorsReport => {
const globalSelectorsReport: SelectorsReport = {}
const modifierSelectorsReport: SelectorsReport = {}

fileSelectors.forEach(fileSelector => {
fileSelector.selectors.forEach(selector => {
const classSelectors = selector.split(".")

// case 1: vkcn element with modifiers
if (classSelectors[1]?.includes("--")) {
const [, , ...modifierSelectors] = classSelectors

modifierSelectors.forEach(modifier => {
replenishSetList(modifierSelectorsReport, modifier, fileSelector.filePath)
})
}

// case 2: nested modifiers
else if (classSelectors[0] === "&") {
const [, ...modifierSelectors] = classSelectors

modifierSelectors.forEach(modifier => {
replenishSetList(modifierSelectorsReport, modifier, fileSelector.filePath)
})
}

// case 3: global class selector
else if (classSelectors[0] === "") {
classSelectors.slice(1).forEach(classSelector => {
replenishSetList(globalSelectorsReport, classSelector, fileSelector.filePath)
})
}

// case X: unhandled
else throw new Error("UNHANDLED CASE! PLEASE REPORT A NEW ISSUE.")
})
})

const leakEntries = Object.entries(modifierSelectorsReport)
.map(([selector, files]) => {
const globalSelectors = globalSelectorsReport[selector]

if (globalSelectors) {
globalSelectors.forEach(gs => files.add(gs))
}

return [selector, files] as const
})
.filter(([, files]) => files.size > 1)

return Object.fromEntries(leakEntries)
}
52 changes: 52 additions & 0 deletions src/functions/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import pc from "picocolors"

import type { SelectorsReport } from "../models/SelectorsReport.js"

const renderReport = (lines: string[]): string => lines.join("\n")

const getReport = (
selectorFiles: SelectorsReport,
messages: {
none: string
title: (amount: number) => string
line: (selector: string, amount: number) => string
},
): [string, number] => {
const lines: string[] = []

const duplicateClassSelectors = Object.keys(selectorFiles)
if (duplicateClassSelectors.length === 0) {
lines.push(messages.none)
return [renderReport(lines), 0]
}

lines.push(messages.title(duplicateClassSelectors.length))
lines.push("")

duplicateClassSelectors.forEach(classSelector => {
const files = selectorFiles[classSelector]!

lines.push(messages.line(classSelector, files.size))
for (const file of files) lines.push(` - ${file}`)
lines.push("")
})

return [renderReport(lines), 1]
}

export const getDuplicatesReport = (selectorFiles: SelectorsReport): [string, number] => {
return getReport(selectorFiles, {
none: pc.green("👍 No duplicate class selectors found"),
title: duplicatesAmount => `❌ Found ${pc.bold(duplicatesAmount)} ${pc.red("duplicated")} class selectors`,
line: (classSelector, filesAmount) =>
`Class selector ".${pc.yellow(classSelector)}" defined in ${pc.blue(filesAmount)} files`,
})
}
export const getLeaksReport = (selectorFiles: SelectorsReport): [string, number] => {
return getReport(selectorFiles, {
none: pc.green("👍 No potentially leaking class selectors found"),
title: leaksAmount => `❌ Found ${pc.bold(leaksAmount)} ${pc.red("potentially leaking")} class selectors`,
line: (classSelector, filesAmount) =>
`Class selector ".${pc.yellow(classSelector)}" can potentially leak in ${pc.blue(filesAmount)} files`,
})
}
4 changes: 4 additions & 0 deletions src/models/FileSelector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type FileSelector = {
filePath: string
selectors: string[]
}
2 changes: 2 additions & 0 deletions src/models/SelectorsReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Object with key as selector class and value as set of file paths */
export type SelectorsReport = Record<string, Set<string>>
48 changes: 19 additions & 29 deletions src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,15 @@ import path from "path"

import { glob } from "glob"

import { extractClassSelectorsFromStyleFile, extractClassSelectorsFromVueFile } from "./extract-classes-from-file.js"
import type { FileSelector } from "./models/FileSelector.js"
import type { SelectorsReport } from "./models/SelectorsReport.js"

export type FileSelectors = {
filePath: string
selectors: string[]
}

export type SelectorFiles = Record<string, Set<string>>

const findDuplicates = (fileSelectors: FileSelectors[]) => {
const selectorFiles: SelectorFiles = {}

fileSelectors.forEach(fileSelector => {
fileSelector.selectors.forEach(selector => {
const files = selectorFiles[selector]

if (!files) {
selectorFiles[selector] = new Set([fileSelector.filePath])
} else {
files.add(fileSelector.filePath)
}
})
})

const duplicateEntries = Object.entries(selectorFiles).filter(([, files]) => files.size > 1)
return Object.fromEntries(duplicateEntries)
}
import {
extractClassSelectorsFromStyleFile,
extractClassSelectorsFromVueFile,
} from "./functions/extract-classes-from-file.js"
import { findDuplicates } from "./functions/find-duplicates.js"
import { findLeaks } from "./functions/find-leaks.js"

type Options = {
files: string[]
Expand All @@ -41,7 +23,9 @@ const handlers: Record<string, (arg: string) => Promise<string[]>> = {
".vue": extractClassSelectorsFromVueFile,
}

export const findDuplicatesInFiles = async (options: Options) => {
type Violations = Record<"duplicates" | "leaks", SelectorsReport>

export const findViolationsInFiles = async (options: Options): Promise<Violations> => {
const files = await Promise.all(
options.files.map(filesGlob =>
glob(filesGlob, {
Expand All @@ -51,7 +35,7 @@ export const findDuplicatesInFiles = async (options: Options) => {
)

const fileSelectors = await Promise.all(
files.flat().map<Promise<FileSelectors>>(filePath => {
files.flat().map<Promise<FileSelector>>(filePath => {
const { ext } = path.parse(filePath)
const handler = handlers[ext]

Expand All @@ -64,5 +48,11 @@ export const findDuplicatesInFiles = async (options: Options) => {
}),
)

return findDuplicates(fileSelectors)
const duplicates = findDuplicates(fileSelectors)
const leaks = findLeaks(fileSelectors)

return {
duplicates,
leaks,
}
}
26 changes: 0 additions & 26 deletions src/report.ts

This file was deleted.

1 change: 1 addition & 0 deletions src/utils/array.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const uniqArray = (list: string[]): string[] => Array.from(new Set<string>(list))
Loading

0 comments on commit 44e698a

Please sign in to comment.