From 12b72d85d4943d5cd0a2dd233733362163e380f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20T=C3=B3ta?= Date: Wed, 11 Sep 2024 13:57:32 +0300 Subject: [PATCH] AG-35594 Update AGTree to v2 Merge in ADGUARD-FILTERS/aglint from fix/AG-35594 to master Squashed commit of the following: commit 446cf597788c8abe016c20c9217d034007a6787d Author: scripthunter7 Date: Wed Sep 11 11:55:00 2024 +0200 add date to changelog commit e37600d02b26d6428caf490daaf622d546ea44ea Author: scripthunter7 Date: Tue Sep 10 19:40:02 2024 +0200 add rule to tests commit 0914676745fd851e961c99f19c9829062e3983eb Author: scripthunter7 Date: Tue Sep 10 19:36:54 2024 +0200 update packages commit 2a0306410d4ba8fb51d0c34588515f65b058302b Author: scripthunter7 Date: Mon Sep 9 18:00:58 2024 +0200 fixes commit aa961ce8f563880f5df62b243d71be02ad95e780 Author: Slava Leleka Date: Mon Sep 9 18:35:49 2024 +0300 src/linter/helpers/css-generate.ts edited online with Bitbucket commit 4082945d9acd9f674eb500ec83b5745ba23db5c5 Author: scripthunter7 Date: Mon Sep 9 13:37:36 2024 +0200 make message consistent commit 3294981ebdbec3abb06af5bc997a208717fa5247 Author: scripthunter7 Date: Mon Sep 9 12:25:08 2024 +0200 add quotes to parsing error message commit 2eccd9e15cca826ea6ed694eea279f9b4ab7b0cc Author: scripthunter7 Date: Mon Sep 9 12:07:04 2024 +0200 change parsing error message commit 458aec7ffa796dc32027fe01b4c6ca25b6b04712 Author: scripthunter7 Date: Mon Sep 9 10:51:23 2024 +0200 rename fn commit 0a0828cd6b7df74096e3110b9810fb48cc055a89 Author: scripthunter7 Date: Mon Sep 9 10:48:48 2024 +0200 enable decl validator commit 23c741cdb69b5ae73e14db7e53ffbf18361660a0 Author: scripthunter7 Date: Mon Sep 9 10:03:16 2024 +0200 fix nits commit db827701864714a37c1ce6d6df7d2d4163b3e0ab Author: Slava Leleka Date: Mon Sep 9 10:44:39 2024 +0300 CHANGELOG.md edited online with Bitbucket commit 11ffc40aaf04e591616e68a06485e37c59114f86 Author: scripthunter7 Date: Mon Sep 9 09:42:38 2024 +0200 finalize commit 593ca329c2ddc98cf73ca27e2256e3ebdd7075cf Author: scripthunter7 Date: Fri Sep 6 19:11:06 2024 +0200 update changelog commit a02f4b4fbbc3b863dbe600f9367c3ce204f641ef Author: scripthunter7 Date: Fri Sep 6 19:10:44 2024 +0200 update readme commit bea4753e42e3a4895c2f8c0e4edb1b3261f6769c Author: scripthunter7 Date: Fri Sep 6 18:49:33 2024 +0200 Update AGTree to v2 --- .eslintrc.cjs | 8 + CHANGELOG.md | 27 ++ README.md | 44 ++ package.json | 31 +- rollup.config.ts | 32 +- src/linter/common.ts | 54 ++- src/linter/config-presets/aglint-all.ts | 2 + .../config-presets/aglint-recommended.ts | 2 + src/linter/helpers/css-cache.ts | 58 +++ src/linter/helpers/css-errors.ts | 50 ++ src/linter/helpers/css-generate.ts | 434 ++++++++++++++++++ src/linter/helpers/css-loc-extractor.ts | 36 ++ src/linter/helpers/css-parse.ts | 27 ++ src/linter/helpers/css-tree-types.ts | 115 +++++ src/linter/helpers/css-validator.ts | 88 ++++ src/linter/index.ts | 216 ++++++--- src/linter/rules/duplicated-hint-platforms.ts | 4 + src/linter/rules/duplicated-modifiers.ts | 2 +- .../rules/inconsistent-hint-platforms.ts | 20 +- src/linter/rules/index.ts | 4 + src/linter/rules/invalid-modifiers.ts | 4 +- .../rules/no-invalid-css-declaration.ts | 52 +++ src/linter/rules/no-invalid-css-syntax.ts | 37 ++ src/linter/rules/single-selector.ts | 17 +- .../rules/unknown-hints-and-platforms.ts | 4 + src/utils/error.ts | 49 ++ src/utils/type-guards.ts | 64 +++ test/fixtures/cli/.aglintrc.yaml | 4 +- test/linter/cli/cli.test.ts | 32 +- test/linter/helpers/css-generate.test.ts | 390 ++++++++++++++++ test/linter/linter.test.ts | 42 +- test/linter/rules/invalid-modifiers.test.ts | 2 +- .../rules/no-invalid-css-declaration.test.ts | 89 ++++ .../rules/no-invalid-css-syntax.test.ts | 85 ++++ .../rules/unknown-hints-and-platforms.test.ts | 12 +- yarn.lock | 90 ++-- 36 files changed, 2029 insertions(+), 198 deletions(-) create mode 100644 src/linter/helpers/css-cache.ts create mode 100644 src/linter/helpers/css-errors.ts create mode 100644 src/linter/helpers/css-generate.ts create mode 100644 src/linter/helpers/css-loc-extractor.ts create mode 100644 src/linter/helpers/css-parse.ts create mode 100644 src/linter/helpers/css-tree-types.ts create mode 100644 src/linter/helpers/css-validator.ts create mode 100644 src/linter/rules/no-invalid-css-declaration.ts create mode 100644 src/linter/rules/no-invalid-css-syntax.ts create mode 100644 src/utils/error.ts create mode 100644 src/utils/type-guards.ts create mode 100644 test/linter/helpers/css-generate.test.ts create mode 100644 test/linter/rules/no-invalid-css-declaration.test.ts create mode 100644 test/linter/rules/no-invalid-css-syntax.test.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 2a4315fe..bab3f28a 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -80,5 +80,13 @@ module.exports = { fixMixedExportsWithInlineTypeSpecifier: true, }, ], + 'jsdoc/check-tag-names': [ + 'warn', + { + // Define additional tags + // https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-tag-names.md#definedtags + definedTags: ['note'], + }, + ], }, }; diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c2f5f1..bfd37954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ The format is based on [Keep a Changelog][keepachangelog], and this project adhe [keepachangelog]: https://keepachangelog.com/en/1.0.0/ [semver]: https://semver.org/spec/v2.0.0.html +## [2.1.0] - 2024-09-11 + +### Added + +- Various CSSTree utilities. +- [`@adguard/ecss-tree`][ecss-tree] library to work with CSS and Extended CSS syntax. +- `no-invalid-css-syntax` linter rule to check CSS syntax in `adblock` rules. +- `no-invalid-css-declaration` linter rule to check valid CSS declarations in `adblock` rules. + +### Changed + +- Since AGTree v2 no longer parses the full CSS syntax as AGTree v1 did, + we now use the [@adguard/ecss-tree][ecss-tree] library to maintain the same functionality. + Additionally, we provide a special layer for CSS handling in the linter core. +- More semantic parsing error messages. +- Changed module type to CJS. +- Updated [@adguard/agtree] to `v2.0.2` [#215]. From this major version, AGTree no longer parses all CSS syntax, + so we need to use a separate library for this purpose in the linter. + +### Fixed + +- Improved exports in `package.json`. + +[#215]: https://github.com/AdguardTeam/AGLint/issues/215 +[ecss-tree]: https://github.com/AdguardTeam/ecsstree +[2.1.0]: https://github.com/AdguardTeam/AGLint/compare/v2.0.10...v2.1.0 + ## [2.0.10] - 2024-09-04 ### Added diff --git a/README.md b/README.md index 4f6235bc..e467883d 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ Table of Contents: - [`inconsistent-hint-platforms`](#inconsistent-hint-platforms) - [`no-short-rules`](#no-short-rules) - [`no-excluded-rules`](#no-excluded-rules) + - [`no-invalid-css-syntax`](#no-invalid-css-syntax) + - [`no-invalid-css-declaration`](#no-invalid-css-declaration) - [Compatibility](#compatibility) - [Use programmatically](#use-programmatically) - [Development \& Contribution](#development--contribution) @@ -834,6 +836,48 @@ Requested in https://github.com/AdguardTeam/AGLint/issues/214 - example\.com\/bad\/query\/ ``` +### `no-invalid-css-syntax` + +Check if a rule contains syntactically invalid CSS. +It uses the [ECSSTree][ecss-tree] parser internally to check the CSS syntax. + +- **Severity:** `error` (2) +- **Fixable:** no +- **Options:** none +- **Example:** + ```adblock + ##.#bar + ``` + will be reported as error: + ```txt + 1:4 error Cannot parse CSS due to the following error: Name is expected + ``` + since the `##.#bar` rule contains invalid CSS syntax, `.` should be followed by a valid class name. + Also works with CSS injection rules and their declaration lists. + +[ecss-tree]: https://github.com/AdguardTeam/ecsstree + +### `no-invalid-css-declaration` + +Checks whether a CSS injection rule contains any unknown properties or invalid values. +This process utilizes the [CSSTree][css-tree] lexer, which is based on the [MDN Data][mdn-data] database. + +- **Severity:** `error` (2) +- **Fixable:** no +- **Options:** none +- **Example:** + ```adblock + #$#.foo { color: bar; } + ``` + will be reported as error: + ```txt + 1:17 error Invalid value for `color` property, mismatch with syntax + ``` + since `bar` is not a valid color value. + +[css-tree]: https://github.com/csstree/csstree +[mdn-data]: https://github.com/mdn/data/tree/main + ## Compatibility The linter is compatible with all modern browsers and Node.js versions. Minimum required versions are: diff --git a/package.json b/package.json index 02c1fb64..65f0ae09 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adguard/aglint", - "version": "2.0.10", + "version": "2.1.0", "description": "Universal adblock filter list linter.", "keywords": [ "adblock", @@ -20,18 +20,25 @@ "url": "https://github.com/AdguardTeam/AGLint/issues" }, "homepage": "https://github.com/AdguardTeam/AGLint#readme", - "type": "module", - "main": "dist/aglint.cjs", - "module": "dist/aglint.esm.js", - "browser": "dist/aglint.umd.min.js", + "main": "dist/aglint.js", + "module": "dist/aglint.esm.mjs", "types": "dist/aglint.d.ts", "bin": "dist/aglint.cli.js", + "exports": { + ".": { + "types": "./dist/aglint.d.ts", + "import": "./dist/aglint.esm.mjs", + "require": "./dist/aglint.js" + }, + "./es": "./dist/aglint.esm.mjs", + "./iife": "./dist/aglint.iife.min.js", + "./umd": "./dist/aglint.umd.min.js" + }, "files": [ - "dist", - "src" + "dist" ], "engines": { - "node": ">=14" + "node": ">=17" }, "scripts": { "build": "yarn clean && yarn build-txt && yarn build-types && yarn rollup --config rollup.config.ts --configPlugin @rollup/plugin-json --configPlugin @rollup/plugin-typescript && yarn clean-types", @@ -41,7 +48,7 @@ "clean": "rimraf dist", "clean-types": "rimraf dist/types", "coverage": "jest --coverage", - "lint": "yarn lint:ts && yarn lint:md", + "lint": "yarn check-types && yarn lint:ts && yarn lint:md", "lint:md": "markdownlint .", "lint:ts": "eslint . --cache --ext .ts", "prepare": "node .husky/install.mjs", @@ -51,11 +58,10 @@ "devDependencies": { "@babel/core": "^7.22.5", "@babel/preset-env": "^7.22.5", - "@rollup/plugin-alias": "^5.0.0", "@rollup/plugin-babel": "^6.0.3", "@rollup/plugin-commonjs": "^25.0.2", "@rollup/plugin-json": "^6.0.0", - "@rollup/plugin-node-resolve": "^15.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-terser": "^0.4.3", "@rollup/plugin-typescript": "^11.1.1", "@swc/core": "^1.3.74", @@ -89,7 +95,8 @@ "typescript": "^4.9.5" }, "dependencies": { - "@adguard/agtree": "^1.1.8", + "@adguard/agtree": "^2.0.2", + "@adguard/ecss-tree": "^1.1.0", "@inquirer/checkbox": "^1.3.7", "@inquirer/select": "^1.2.7", "chalk": "4.1.2", diff --git a/rollup.config.ts b/rollup.config.ts index 11f12bf6..92ce1f8d 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -10,7 +10,6 @@ import commonjs from '@rollup/plugin-commonjs'; import externals from 'rollup-plugin-node-externals'; import dtsPlugin from 'rollup-plugin-dts'; import nodePolyfills from 'rollup-plugin-polyfill-node'; -import alias from '@rollup/plugin-alias'; import { getBabelOutputPlugin } from '@rollup/plugin-babel'; import json from '@rollup/plugin-json'; import terser from '@rollup/plugin-terser'; @@ -72,32 +71,24 @@ const typeScriptPlugin = typescript({ const commonPlugins = [ json({ preferConst: true }), commonjs({ sourceMap: false }), - resolve({ preferBuiltins: false }), typeScriptPlugin, ]; // Plugins for Node.js builds const nodePlugins = [ ...commonPlugins, + resolve({ preferBuiltins: false }), externals(), ]; // Plugins for browser builds const browserPlugins = [ ...commonPlugins, - nodePolyfills(), - // The build of CSSTree is a bit complicated (patches, require "emulation", etc.), - // so here we only specify the pre-built version by an alias - alias({ - entries: [ - { - find: '@adguard/ecss-tree', - replacement: path.join( - 'node_modules/@adguard/ecss-tree/dist/ecsstree.umd.min.js', - ), - }, - ], + resolve({ + browser: true, + preferBuiltins: false, }), + nodePolyfills(), // Provide better browser compatibility with Babel getBabelOutputPlugin({ presets: [ @@ -139,7 +130,7 @@ const cjs = { input: path.join(ROOT_DIR, 'src', 'index.node.ts'), output: [ { - file: path.join(distDirLocation, `${BASE_FILE_NAME}.cjs`), + file: path.join(distDirLocation, `${BASE_FILE_NAME}.js`), format: 'cjs', exports: 'auto', sourcemap: false, @@ -154,7 +145,10 @@ const cjs = { [ '@babel/preset-env', { - targets: 'node >= 14', + // at least Node.js 17 + targets: { + node: '17', + }, }, ], ], @@ -169,7 +163,7 @@ const esm = { input: path.join(ROOT_DIR, 'src', 'index.node.ts'), output: [ { - file: path.join(distDirLocation, `${BASE_FILE_NAME}.esm.js`), + file: path.join(distDirLocation, `${BASE_FILE_NAME}.esm.mjs`), format: 'esm', sourcemap: false, banner: BANNER, @@ -192,11 +186,11 @@ const cli = { output: [ { file: path.join(distDirLocation, `${BASE_FILE_NAME}.cli.js`), - format: 'esm', + format: 'cjs', sourcemap: false, // Replace import './index.node' with './aglint.esm.js' paths: { - [linterIndex]: path.join('./', `${BASE_FILE_NAME}.esm.js`), + [linterIndex]: path.join('./', `${BASE_FILE_NAME}.js`), }, banner: `${SHEBANG}\n${BANNER}`, }, diff --git a/src/linter/common.ts b/src/linter/common.ts index 065f70b0..17da84bd 100644 --- a/src/linter/common.ts +++ b/src/linter/common.ts @@ -1,7 +1,13 @@ import { type Struct } from 'superstruct'; -import { type AdblockSyntax, type AnyRule, type Node } from '@adguard/agtree'; +import { + type Value, + type AdblockSyntax, + type AnyRule, + type Node, +} from '@adguard/agtree'; import { type AnySeverity } from './severity'; +import { type CssTreeParsingContext, type CssTreeParsingContextToNode } from './helpers/css-tree-types'; /** * Represents any linter rule @@ -146,6 +152,19 @@ export interface LinterConfig { rules?: LinterRuleConfigObject; } +/** + * Type definition for the linter rule context getter function. + * + * @param rawValueNode The raw value node. + * @param context The context, see {@link CssTreeParsingContext}. + * + * @returns The CSS node or `null` if the CSS could not be parsed. + */ +type CssNodeGetter = ( + rawValueNode: Value, + context: T +) => CssTreeParsingContextToNode[T] | null; + /** * Represents a linter context that is passed to the rules when their events are triggered */ @@ -189,6 +208,21 @@ export interface GenericRuleContext, Co * @param problem - The problem to report */ report: (problem: LinterProblemReport) => void; + + /** + * Returns the CSS node for the given raw value node and context. + * + * @param rawValueNode - The raw value node + * @param context - The context, see {@link CssTreeParsingContext}. + * For more information, please check https://github.com/csstree/csstree/blob/master/docs/parsing.md#context + * + * @returns The CSS node or `null` if the CSS could not be parsed + * + * @note When you call this function from a rule and it cannot parse the CSS, + * it will automatically report a problem to the linter and marks your linter rule as the source. + * Reported problem will have the same severity as the rule. + */ + getCssNode: CssNodeGetter; } /** @@ -253,10 +287,26 @@ export interface LinterProblemReport { message: string; /** - * Node that caused the problem + * Node that caused the problem. If provided, the linter will use its offsets to determine the problem location. */ node?: Node; + /** + * Relative start offset to the start of the node that caused the problem. + * Useful when you do not want to mark the whole node as problematic. + * + * @note Only takes effect when `node` is provided. + */ + relativeNodeStartOffset?: number; + + /** + * Relative start offset to the start of the node that caused the problem. + * Useful when you do not want to mark the whole node as problematic. + * + * @note Only takes effect when `node` is provided. + */ + relativeNodeEndOffset?: number; + /** * The location of the problem */ diff --git a/src/linter/config-presets/aglint-all.ts b/src/linter/config-presets/aglint-all.ts index 54cfad75..3eeb1ec7 100644 --- a/src/linter/config-presets/aglint-all.ts +++ b/src/linter/config-presets/aglint-all.ts @@ -9,6 +9,8 @@ import { type LinterConfig } from '../common'; const config: LinterConfig = { syntax: [AdblockSyntax.Common], rules: { + 'no-invalid-css-syntax': 'error', + 'no-invalid-css-declaration': 'error', 'duplicated-hint-platforms': 'error', 'duplicated-hints': 'error', 'duplicated-modifiers': 'error', diff --git a/src/linter/config-presets/aglint-recommended.ts b/src/linter/config-presets/aglint-recommended.ts index d8735dd7..4502bdf4 100644 --- a/src/linter/config-presets/aglint-recommended.ts +++ b/src/linter/config-presets/aglint-recommended.ts @@ -10,6 +10,8 @@ import { type LinterConfig } from '../common'; const config: LinterConfig = { syntax: [AdblockSyntax.Common], rules: { + 'no-invalid-css-syntax': 'error', + 'no-invalid-css-declaration': 'error', 'duplicated-hint-platforms': 'error', 'duplicated-hints': 'error', 'duplicated-modifiers': 'error', diff --git a/src/linter/helpers/css-cache.ts b/src/linter/helpers/css-cache.ts new file mode 100644 index 00000000..d05537a0 --- /dev/null +++ b/src/linter/helpers/css-cache.ts @@ -0,0 +1,58 @@ +import { CssTreeParsingContext, type CssTreeParsingContextToNode } from './css-tree-types'; + +/** + * Possible values for the cache map. + */ +export type CssCacheValue = CssTreeParsingContextToNode[K] | Error; + +/** + * Cache maps for CSS tree nodes. Key is the CSS tree parsing context and value is a map of CSS to node or error + * (if parsing failed before). + */ +export type CssCacheMaps = { + [K in CssTreeParsingContext]: Map>; +}; + +/** + * Cache for CSS tree nodes. We share the cache between all rules to avoid parsing the same CSS multiple times. + * For errors, we cache the relative error position, so the error position can be calculated for any AGTree node. + */ +export class CssCache { + /** + * Internal cache maps. + */ + private maps: CssCacheMaps; + + /** + * Creates a new cache instance for CSS tree nodes. + */ + constructor() { + this.maps = Object.values(CssTreeParsingContext).reduce((acc, context) => { + acc[context] = new Map(); + return acc; + }, {} as CssCacheMaps); + } + + /** + * Gets entry from the cache. + * + * @param context The parsing context. + * @param css The CSS string. + * + * @returns The CSS tree node / error or undefined if CSS string is not cached. + */ + public get(context: K, css: string): CssCacheValue | undefined { + return this.maps[context].get(css); + } + + /** + * Adds entry to the cache. + * + * @param context The parsing context. + * @param css The CSS string. + * @param node The CSS tree node / error. + */ + public set(context: K, css: string, node: CssCacheValue): void { + this.maps[context].set(css, node); + } +} diff --git a/src/linter/helpers/css-errors.ts b/src/linter/helpers/css-errors.ts new file mode 100644 index 00000000..bc291b26 --- /dev/null +++ b/src/linter/helpers/css-errors.ts @@ -0,0 +1,50 @@ +import { type SyntaxMatchError, type SyntaxReferenceError } from '@adguard/ecss-tree'; + +import { isNumber } from '../../utils/type-guards'; + +/** + * Interface for errors with offset. + */ +interface ErrorWithOffset extends Error { + offset: number; +} + +/** + * Check if the error is an instance of ErrorWithOffset. + * + * @param possibleError Possible error to check. + * + * @returns `true` if the error is an instance of ErrorWithOffset. + */ +export const isErrorContainingOffset = (possibleError: unknown): possibleError is ErrorWithOffset => { + if (!(possibleError instanceof Error)) { + return false; + } + + return 'offset' in possibleError && isNumber(possibleError.offset); +}; + +/** + * Check if the error is an instance of SyntaxError, SyntaxMatchError or SyntaxReferenceError. + * + * Based on https://github.com/csstree/validator/blob/38e7319d7b049c190f04665df77c836646f3b35e/lib/validate.js#L5-L17 + * + * @param possibleError Error to check. + * + * @returns `true` if the error is an instance of SyntaxError, SyntaxMatchError or SyntaxReferenceError. + */ +export const isCssTreeSyntaxError = ( + possibleError: unknown, +): possibleError is SyntaxError | SyntaxMatchError | SyntaxReferenceError => { + if (!possibleError || !(possibleError instanceof Error)) { + return false; + } + + if (!('name' in possibleError)) { + return false; + } + + return possibleError.name === 'SyntaxError' + || possibleError.name === 'SyntaxMatchError' + || possibleError.name === 'SyntaxReferenceError'; +}; diff --git a/src/linter/helpers/css-generate.ts b/src/linter/helpers/css-generate.ts new file mode 100644 index 00000000..b15d259a --- /dev/null +++ b/src/linter/helpers/css-generate.ts @@ -0,0 +1,434 @@ +/** + * Migrated from https://github.com/AdguardTeam/tsurlfilter/blob/2144959e92f94e4738274ce577c2fe26e3a0c08b/packages/agtree/src/utils/csstree.ts + * + * We implement our custom generate logic for some CSS nodes because CSSTree's default + * generator does not add spaces, just in some edge cases, but we want to show some formatted code. + */ + +import { + type CssNode, + type DeclarationList, + type FunctionNode, + generate, + type MediaQuery, + type MediaQueryList, + type PseudoClassSelector, + type Selector, + type SelectorList, + walk, +} from '@adguard/ecss-tree'; + +import { + CLOSE_PARENTHESIS, + CLOSE_SQUARE_BRACKET, + COLON, + COMMA, + CSS_IMPORTANT, + DOT, + EMPTY, + HASHMARK, + OPEN_PARENTHESIS, + OPEN_SQUARE_BRACKET, + SEMICOLON, + SPACE, +} from '../../common/constants'; +import { CssTreeNodeType } from './css-tree-types'; + +/** + * Generates string representation of the media query. + * + * @param mediaQueryNode Media query AST + * @returns String representation of the media query + */ +export const generateMediaQuery = (mediaQueryNode: MediaQuery): string => { + let result = EMPTY; + + if (!mediaQueryNode.children || mediaQueryNode.children.size === 0) { + throw new Error('Media query cannot be empty'); + } + + mediaQueryNode.children.forEach((node: CssNode, listItem) => { + if (node.type === CssTreeNodeType.MediaFeature) { + result += OPEN_PARENTHESIS; + result += node.name; + + if (node.value !== null) { + result += COLON; + result += SPACE; + + // Use default generator for media feature value + result += generate(node.value); + } + + result += CLOSE_PARENTHESIS; + } else if (node.type === CssTreeNodeType.Identifier) { + result += node.name; + } else { + throw new Error(`Unexpected node type: ${node.type}`); + } + + if (listItem.next !== null) { + result += SPACE; + } + }); + + return result; +}; + +/** + * Generates string representation of the media query list. + * + * @param mediaQueryListNode Media query list AST + * @returns String representation of the media query list + */ +export const generateMediaQueryList = (mediaQueryListNode: MediaQueryList): string => { + let result = EMPTY; + + if (!mediaQueryListNode.children || mediaQueryListNode.children.size === 0) { + throw new Error('Media query list cannot be empty'); + } + + mediaQueryListNode.children.forEach((mediaQuery: CssNode, listItem) => { + if (mediaQuery.type !== CssTreeNodeType.MediaQuery) { + throw new Error(`Unexpected node type: ${mediaQuery.type}`); + } + + result += generateMediaQuery(mediaQuery); + + if (listItem.next !== null) { + result += COMMA; + result += SPACE; + } + }); + + return result; +}; + +/** + * Selector generation based on CSSTree's AST. This is necessary because CSSTree + * only adds spaces in some edge cases. + * + * @param selectorNode CSS Tree AST + * @returns CSS selector as string + */ +export const generateSelector = (selectorNode: Selector): string => { + let result = EMPTY; + + let inAttributeSelector = false; + let depth = 0; + let selectorListDepth = -1; + let prevNode: CssNode = selectorNode; + + walk(selectorNode, { + enter: (node: CssNode) => { + depth += 1; + + // Skip attribute selector or selector list children + if (inAttributeSelector || selectorListDepth > -1) { + return; + } + + switch (node.type) { + // "Trivial" nodes + case CssTreeNodeType.TypeSelector: + result += node.name; + break; + + case CssTreeNodeType.ClassSelector: + result += DOT; + result += node.name; + break; + + case CssTreeNodeType.IdSelector: + result += HASHMARK; + result += node.name; + break; + + case CssTreeNodeType.Identifier: + result += node.name; + break; + + case CssTreeNodeType.Raw: + result += node.value; + break; + + // "Advanced" nodes + case CssTreeNodeType.Nth: + // Default generation enough + result += generate(node); + break; + + // For example :not([id], [name]) + case CssTreeNodeType.SelectorList: + // eslint-disable-next-line no-case-declarations + const selectors: string[] = []; + + node.children.forEach((selector) => { + if (selector.type === CssTreeNodeType.Selector) { + selectors.push(generateSelector(selector)); + } else if (selector.type === CssTreeNodeType.Raw) { + selectors.push(selector.value); + } + }); + + // Join selector lists + result += selectors.join(COMMA + SPACE); + + // Skip nodes here + selectorListDepth = depth; + break; + + case CssTreeNodeType.Combinator: + if (node.name === SPACE) { + result += node.name; + break; + } + + // Prevent this case (unnecessary space): has( > .something) + if (prevNode.type !== CssTreeNodeType.Selector) { + result += SPACE; + } + + result += node.name; + result += SPACE; + break; + + case CssTreeNodeType.AttributeSelector: + result += OPEN_SQUARE_BRACKET; + + // Identifier name + if (node.name) { + result += node.name.name; + } + + // Matcher operator, eg = + if (node.matcher) { + result += node.matcher; + + // Value can be String, Identifier or null + if (node.value !== null) { + // String node + if (node.value.type === CssTreeNodeType.String) { + result += generate(node.value); + } else if (node.value.type === CssTreeNodeType.Identifier) { + // Identifier node + result += node.value.name; + } + } + } + + // Flags + if (node.flags) { + // Space before flags + result += SPACE; + result += node.flags; + } + + result += CLOSE_SQUARE_BRACKET; + + inAttributeSelector = true; + break; + + case CssTreeNodeType.PseudoElementSelector: + result += COLON; + result += COLON; + result += node.name; + + if (node.children !== null) { + result += OPEN_PARENTHESIS; + } + + break; + + case CssTreeNodeType.PseudoClassSelector: + result += COLON; + result += node.name; + + if (node.children !== null) { + result += OPEN_PARENTHESIS; + } + break; + + default: + break; + } + + prevNode = node; + }, + leave: (node: CssNode) => { + depth -= 1; + + if (node.type === CssTreeNodeType.SelectorList && depth + 1 === selectorListDepth) { + selectorListDepth = -1; + } + + if (selectorListDepth > -1) { + return; + } + + if (node.type === CssTreeNodeType.AttributeSelector) { + inAttributeSelector = false; + } + + if (inAttributeSelector) { + return; + } + + switch (node.type) { + case CssTreeNodeType.PseudoElementSelector: + case CssTreeNodeType.PseudoClassSelector: + if (node.children !== null) { + result += CLOSE_PARENTHESIS; + } + break; + + default: + break; + } + }, + }); + + return result.trim(); +}; + +/** + * Generates string representation of the selector list. + * + * @param selectorListNode SelectorList AST + * @returns String representation of the selector list + */ +export const generateSelectorList = (selectorListNode: SelectorList): string => { + let result = EMPTY; + + if (!selectorListNode.children || selectorListNode.children.size === 0) { + throw new Error('Selector list cannot be empty'); + } + + selectorListNode.children.forEach((selector: CssNode, listItem) => { + if (selector.type !== CssTreeNodeType.Selector) { + throw new Error(`Unexpected node type: ${selector.type}`); + } + + result += generateSelector(selector); + + if (listItem.next !== null) { + result += COMMA; + result += SPACE; + } + }); + + return result; +}; + +/** + * Block generation based on CSSTree's AST. This is necessary because CSSTree only adds spaces in some edge cases. + * + * @param declarationListNode CSS Tree AST + * @returns CSS selector as string + */ +export const generateDeclarationList = (declarationListNode: DeclarationList): string => { + let result = EMPTY; + + walk(declarationListNode, { + enter: (node: CssNode) => { + switch (node.type) { + case CssTreeNodeType.Declaration: { + result += node.property; + + if (node.value) { + result += COLON; + result += SPACE; + + // Fallback to CSSTree's default generate function for the value (enough at this point) + result += generate(node.value); + } + + if (node.important) { + result += SPACE; + result += CSS_IMPORTANT; + } + + break; + } + + default: + break; + } + }, + leave: (node: CssNode) => { + switch (node.type) { + case CssTreeNodeType.Declaration: { + result += SEMICOLON; + result += SPACE; + break; + } + + default: + break; + } + }, + }); + + return result.trim(); +}; + +/** + * Helper function to generate a raw string from a pseudo-class + * selector's children + * + * @param node Pseudo-class selector node + * @returns Generated pseudo-class value + * @example + * - `:nth-child(2n+1)` -> `2n+1` + * - `:matches-path(/foo/bar)` -> `/foo/bar` + */ +export const generatePseudoClassValue = (node: PseudoClassSelector): string => { + let result = EMPTY; + + node.children?.forEach((child) => { + switch (child.type) { + case CssTreeNodeType.Selector: + result += generateSelector(child); + break; + + case CssTreeNodeType.SelectorList: + result += generateSelectorList(child); + break; + + case CssTreeNodeType.Raw: + result += child.value; + break; + + default: + // Fallback to CSSTree's default generate function + result += generate(child); + } + }); + + return result; +}; + +/** + * Helper function to generate a raw string from a function selector's children + * + * @param node Function node + * @returns Generated function value + * @example `responseheader(name)` -> `name` + */ +export const generateFunctionValue = (node: FunctionNode): string => { + let result = EMPTY; + + node.children?.forEach((child) => { + switch (child.type) { + case CssTreeNodeType.Raw: + result += child.value; + break; + + default: + // Fallback to CSSTree's default generate function + result += generate(child); + } + }); + + return result; +}; diff --git a/src/linter/helpers/css-loc-extractor.ts b/src/linter/helpers/css-loc-extractor.ts new file mode 100644 index 00000000..3b4acb74 --- /dev/null +++ b/src/linter/helpers/css-loc-extractor.ts @@ -0,0 +1,36 @@ +import { mask, number, object } from 'superstruct'; + +/** + * Minimal schema for location object. + */ +const locSchema = object({ + start: object({ + offset: number(), + }), + end: object({ + offset: number(), + }), +}); + +/** + * Schema for object with `loc` property. + */ +const objWithLocSchema = object({ + loc: locSchema, +}); + +/** + * Get start and end offsets from the object with `loc` property. + * + * @param input Object with `loc` property. + * + * @returns Tuple with start and end offsets or `null` if the object does not have `loc` property. + */ +export const getCssTreeStartAndEndOffsetsFromObject = (input: object): [number, number] | null => { + try { + const { loc } = mask(input, objWithLocSchema); + return [loc.start.offset, loc.end.offset]; + } catch { + return null; + } +}; diff --git a/src/linter/helpers/css-parse.ts b/src/linter/helpers/css-parse.ts new file mode 100644 index 00000000..c20619e4 --- /dev/null +++ b/src/linter/helpers/css-parse.ts @@ -0,0 +1,27 @@ +import { parse, type SyntaxParseError } from '@adguard/ecss-tree'; + +import { type CssTreeParsingContextToNode, type CssTreeParsingContext } from './css-tree-types'; + +/** + * Helper function to parse CSS string into CSSTree node by using proper settings for the linter. + * + * @param rawCss Raw CSS string to parse. + * @param context Parsing context. See {@link CssTreeParsingContext}. + * + * @returns The parsed CSS node. + * + * @throws SyntaxParseError if the CSS parsing fails. + */ +export const parseCss = ( + rawCss: string, + context: T, +): CssTreeParsingContextToNode[T] => { + // https://github.com/csstree/csstree/blob/master/docs/parsing.md#parsesource-options + return parse(rawCss, { + context, + positions: true, + onParseError: (error: SyntaxParseError) => { + throw error; + }, + }) as CssTreeParsingContextToNode[T]; +}; diff --git a/src/linter/helpers/css-tree-types.ts b/src/linter/helpers/css-tree-types.ts new file mode 100644 index 00000000..40c57fef --- /dev/null +++ b/src/linter/helpers/css-tree-types.ts @@ -0,0 +1,115 @@ +/** + * @file Helper file for CSSTree to provide better compatibility with TypeScript. + * @see {@link https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/62536} + */ + +import { + type DeclarationList as DeclarationListNode, + type Selector as SelectorNode, + type SelectorList as SelectorListNode, + type StyleSheet as StyleSheetNode, + type Atrule as AtruleNode, + type AtrulePrelude as AtrulePreludeNode, + type MediaQueryList as MediaQueryListNode, + type MediaQuery as MediaQueryNode, + type Rule as RuleNode, + type Block as BlockNode, + type Declaration as DeclarationNode, + type Value as ValueNode, + type CssNodeCommon, + type CssLocation, +} from '@adguard/ecss-tree'; + +/** + * CSS tree node with location. + * Its just a small helper as in the AGLint context we always had the location. + */ +export type CssNodeWithLocation = T & { loc: CssLocation }; + +/** + * CSSTree node types. + * + * @see {@link https://github.com/csstree/csstree/blob/master/docs/ast.md#node-types} + */ +export enum CssTreeNodeType { + AnPlusB = 'AnPlusB', + Atrule = 'Atrule', + AtrulePrelude = 'AtrulePrelude', + AttributeSelector = 'AttributeSelector', + Block = 'Block', + Brackets = 'Brackets', + CDC = 'CDC', + CDO = 'CDO', + ClassSelector = 'ClassSelector', + Combinator = 'Combinator', + Comment = 'Comment', + Declaration = 'Declaration', + DeclarationList = 'DeclarationList', + Dimension = 'Dimension', + Function = 'Function', + Hash = 'Hash', + Identifier = 'Identifier', + IdSelector = 'IdSelector', + MediaFeature = 'MediaFeature', + MediaQuery = 'MediaQuery', + MediaQueryList = 'MediaQueryList', + NestingSelector = 'NestingSelector', + Nth = 'Nth', + Number = 'Number', + Operator = 'Operator', + Parentheses = 'Parentheses', + Percentage = 'Percentage', + PseudoClassSelector = 'PseudoClassSelector', + PseudoElementSelector = 'PseudoElementSelector', + Ratio = 'Ratio', + Raw = 'Raw', + Rule = 'Rule', + Selector = 'Selector', + SelectorList = 'SelectorList', + String = 'String', + StyleSheet = 'StyleSheet', + TypeSelector = 'TypeSelector', + UnicodeRange = 'UnicodeRange', + Url = 'Url', + Value = 'Value', + WhiteSpace = 'WhiteSpace', +} + +/** + * Parsing context for CSS tree. + * + * @see {@link https://github.com/csstree/csstree/blob/master/docs/parsing.md#context} + */ +export enum CssTreeParsingContext { + Stylesheet = 'stylesheet', + Atrule = 'atrule', + AtrulePrelude = 'atrulePrelude', + MediaQueryList = 'mediaQueryList', + MediaQuery = 'mediaQuery', + Rule = 'rule', + SelectorList = 'selectorList', + Selector = 'selector', + Block = 'block', + DeclarationList = 'declarationList', + Declaration = 'declaration', + Value = 'value', +} + +/** + * Mapping of parsing context to CSS tree node. + * This helps us to provide better types for CSS node getters. + */ +export type CssTreeParsingContextToNode = { + [CssTreeParsingContext.Stylesheet]: CssNodeWithLocation; + [CssTreeParsingContext.Atrule]: CssNodeWithLocation; + [CssTreeParsingContext.AtrulePrelude]: CssNodeWithLocation; + [CssTreeParsingContext.MediaQueryList]: CssNodeWithLocation; + [CssTreeParsingContext.MediaQuery]: CssNodeWithLocation; + [CssTreeParsingContext.Rule]: CssNodeWithLocation; + [CssTreeParsingContext.SelectorList]: CssNodeWithLocation; + [CssTreeParsingContext.Selector]: CssNodeWithLocation; + [CssTreeParsingContext.Block]: CssNodeWithLocation; + [CssTreeParsingContext.DeclarationList]: CssNodeWithLocation; + [CssTreeParsingContext.Declaration]: CssNodeWithLocation; + [CssTreeParsingContext.Value]: CssNodeWithLocation; +}; diff --git a/src/linter/helpers/css-validator.ts b/src/linter/helpers/css-validator.ts new file mode 100644 index 00000000..4d3b0b14 --- /dev/null +++ b/src/linter/helpers/css-validator.ts @@ -0,0 +1,88 @@ +import { lexer, property as getPropertyDescriptor, type Declaration } from '@adguard/ecss-tree'; + +import { isCssTreeSyntaxError } from './css-errors'; +import { isString } from '../../utils/type-guards'; +import { getCssTreeStartAndEndOffsetsFromObject } from './css-loc-extractor'; + +/** + * Interface for CSS validator error. + */ +interface CssValidatorError { + /** + * Error message. + */ + message: string; + + /** + * Start offset of the error. Relative to the specified node. + */ + start?: number; + + /** + * End offset of the error. Relative to the specified node. + */ + end?: number; +} + +/** + * Helper function to validate CSS declaration. + * Idea from https://github.com/csstree/validator/blob/38e7319d7b049c190f04665df77c836646f3b35e/lib/validate.js#L99-L121 + * + * @param declarationNode - CSS declaration node to validate. + * + * @returns Array of syntax errors. + */ +export const validateDeclaration = (declarationNode: Declaration): CssValidatorError[] => { + const errors: CssValidatorError[] = []; + + const { property, value } = declarationNode; + + // Ignore custom properties, like `--foo` + if (getPropertyDescriptor(property).custom) { + return errors; + } + + let possibleError: unknown; + + // TODO: Improve CSSTree typing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + possibleError = (lexer as any).checkPropertyName(property); + + if (isCssTreeSyntaxError(possibleError)) { + errors.push({ + message: possibleError.message, + start: declarationNode.loc?.start.offset, + end: declarationNode.loc?.end.offset, + }); + } else { + possibleError = lexer.matchProperty(property, value).error; + + if (isCssTreeSyntaxError(possibleError)) { + let message = `Invalid value for '${property}' property`; + + if ('syntax' in possibleError && isString(possibleError.syntax)) { + message += `, mismatch with syntax ${possibleError.syntax}`; + } + + let start: number | undefined; + let end: number | undefined; + + const possibleErrorOffsets = getCssTreeStartAndEndOffsetsFromObject(possibleError); + + if (!possibleErrorOffsets) { + start = declarationNode.loc?.start.offset; + end = declarationNode.loc?.end.offset; + } else { + [start, end] = possibleErrorOffsets; + } + + errors.push({ + message, + start, + end, + }); + } + } + + return errors; +}; diff --git a/src/linter/index.ts b/src/linter/index.ts index 18f91a98..ee768fe5 100644 --- a/src/linter/index.ts +++ b/src/linter/index.ts @@ -2,6 +2,7 @@ * @file AGLint core */ +import { type CssNode } from '@adguard/ecss-tree'; import { assert } from 'superstruct'; import cloneDeep from 'clone-deep'; import { @@ -11,6 +12,9 @@ import { type FilterList, FilterListParser, RuleCategory, + PositionProvider, + defaultParserOptions, + type Value, } from '@adguard/agtree'; import { @@ -40,6 +44,12 @@ import { } from './common'; import { validateLinterConfig } from './config-validator'; import { defaultConfigPresets } from './config-presets'; +import { isNull, isUndefined } from '../utils/type-guards'; +import { CssCache } from './helpers/css-cache'; +import { getErrorMessage } from '../utils/error'; +import { type CssTreeParsingContext } from './helpers/css-tree-types'; +import { isErrorContainingOffset } from './helpers/css-errors'; +import { parseCss } from './helpers/css-parse'; /** * Represents a linter result that is returned by the `lint` method @@ -579,6 +589,8 @@ export class Linter { fatalErrorCount: 0, }; + const positionProvider = new PositionProvider(content); + let isDisabled = false; // A set of linter rule names that are disabled on the next line @@ -596,6 +608,9 @@ export class Linter { let actualAdblockRuleAst: AnyRule; let actualAdblockRuleRaw: string; + // Shared CSS cache to avoid parsing the same CSS multiple times + const cssCache = new CssCache(); + /** * Invokes an event for all rules. This function is only used internally * by the actual linting, so we define it here. @@ -621,6 +636,85 @@ export class Linter { assert(data.configOverride || data.rule.meta.config.default, data.rule.meta.config.schema); } + // eslint-disable-next-line @typescript-eslint/no-loop-func + const report = (problem: LinterProblemReport) => { + let severity = getSeverity(data.rule.meta.severity); + + if (!nextLineEnabled.has(name)) { + // rely on the result of network rules modifiers validation; + // see src/linter/rules/invalid-modifiers.ts + if (isSeverity(problem.customSeverity)) { + severity = getSeverity(problem.customSeverity); + } else if (isSeverity(data.severityOverride)) { + severity = getSeverity(data.severityOverride); + } + } + + // Default problem location: whole line + let position: LinterPosition = { + startLine: actualLine, + startColumn: 0, + endLine: actualLine, + endColumn: actualAdblockRuleRaw.length, + }; + + if (problem.position) { + position = problem.position; + } else if ( + !isUndefined(problem.node) + && !isUndefined(problem.node.start) + && !isUndefined(problem.node.end) + ) { + let startOffset = problem.node.start; + let endOffset = problem.node.end; + + const { relativeNodeStartOffset, relativeNodeEndOffset } = problem; + + if (!isUndefined(relativeNodeStartOffset)) { + startOffset = Math.min(problem.node.start + relativeNodeStartOffset, problem.node.end); + } + + if (!isUndefined(relativeNodeEndOffset)) { + endOffset = Math.min(problem.node.start + relativeNodeEndOffset, problem.node.end); + } + + const start = positionProvider.convertOffsetToPosition(startOffset); + const end = positionProvider.convertOffsetToPosition(endOffset); + + if (!isNull(start) && !isNull(end)) { + position = { + startLine: start.line, + startColumn: start.column - 1, + endLine: end.line, + endColumn: end.column - 1, + }; + } + } + + result.problems.push({ + rule: name, + severity, + message: problem.message, + position, + fix: problem.fix, + }); + + // Update problem counts + switch (severity) { + case SEVERITY.warn: + result.warningCount += 1; + break; + case SEVERITY.error: + result.errorCount += 1; + break; + case SEVERITY.fatal: + result.fatalErrorCount += 1; + break; + default: + break; + } + }; + const genericContext: GenericRuleContext = Object.freeze({ // Deep copy of the linter configuration getLinterConfig: () => { @@ -638,62 +732,54 @@ export class Linter { // Rule configuration config: data.configOverride || data.rule.meta.config?.default, - // Reporter function + report, + // eslint-disable-next-line @typescript-eslint/no-loop-func - report: (problem: LinterProblemReport) => { - let severity = getSeverity(data.rule.meta.severity); - - if (!nextLineEnabled.has(name)) { - // rely on the result of network rules modifiers validation; - // see src/linter/rules/invalid-modifiers.ts - if (isSeverity(problem.customSeverity)) { - severity = getSeverity(problem.customSeverity); - } else if (isSeverity(data.severityOverride)) { - severity = getSeverity(data.severityOverride); + getCssNode: (rawValueNode: Value, context: CssTreeParsingContext) => { + const rawCss = rawValueNode.value; + const possibleCssNode = cssCache.get(context, rawCss); + + const reportError = (error: Error) => { + const problem: LinterProblemReport = { + // eslint-disable-next-line max-len + message: `Cannot parse CSS due to the following error: ${getErrorMessage(error)}`, + node: rawValueNode, + }; + + if (isErrorContainingOffset(error)) { + problem.relativeNodeStartOffset = error.offset; } - } - // Default problem location: whole line - let position: LinterPosition = { - startLine: actualLine, - startColumn: 0, - endLine: actualLine, - endColumn: actualAdblockRuleRaw.length, + report(problem); }; - if (problem.position) { - position = problem.position; - } else if (problem.node && problem.node.loc !== undefined) { - position = { - startLine: problem.node.loc.start.line, - startColumn: problem.node.loc.start.column - 1, - endLine: problem.node.loc.end.line, - endColumn: problem.node.loc.end.column - 1, - }; + if (!isUndefined(possibleCssNode)) { + if (possibleCssNode instanceof Error) { + reportError(possibleCssNode); + return null; + } + + return possibleCssNode; } - result.problems.push({ - rule: name, - severity, - message: problem.message, - position, - fix: problem.fix, - }); + let cssNode: CssNode; - // Update problem counts - switch (severity) { - case SEVERITY.warn: - result.warningCount += 1; - break; - case SEVERITY.error: - result.errorCount += 1; - break; - case SEVERITY.fatal: - result.fatalErrorCount += 1; - break; - default: - break; + try { + // https://github.com/csstree/csstree/blob/master/docs/parsing.md#parsesource-options + cssNode = parseCss(rawCss, context); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cssCache.set(context, rawCss, cssNode as any); + } catch (error: unknown) { + cssCache.set(context, rawCss, error as Error); + + if (error instanceof Error) { + reportError(error); + } + return null; } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return cssNode as any; }, }); @@ -732,7 +818,10 @@ export class Linter { invokeEvent('onStartFilterList'); // Parse the filter list - const filterList = FilterListParser.parse(content); + const filterList = FilterListParser.parse(content, { + ...defaultParserOptions, + tolerant: true, + }); // Iterate over all filter list adblock rules filterList.children.forEach((ast, index) => { @@ -759,18 +848,21 @@ export class Linter { // and we report the error for the entire line (from the beginning to the end). /* eslint-disable @typescript-eslint/no-non-null-assertion */ + const start = positionProvider.convertOffsetToPosition(ast.error.start!); + const end = positionProvider.convertOffsetToPosition(ast.error.end!); + const position: LinterPosition = { - startLine: ast.error.loc!.start!.line, - startColumn: ast.error.loc!.start!.column - 1, - endLine: ast.error.loc!.end!.line, - endColumn: ast.error.loc!.end!.column - 1, + startLine: start!.line, + startColumn: start!.column - 1, + endLine: end!.line, + endColumn: end!.column - 1, }; /* eslint-enable @typescript-eslint/no-non-null-assertion */ // Store the error in the result object result.problems.push({ severity: SEVERITY.fatal, - message: `AGLint parsing error: ${ast.error.message}`, + message: `Cannot parse adblock rule due to the following error: ${ast.error.message}`, position, }); @@ -788,7 +880,7 @@ export class Linter { // Process the inline config comment switch (ast.command.value) { case ConfigCommentType.Main: { - if (ast.params && ast.params.type === 'Value') { + if (ast.params && ast.params.type === 'ConfigNode') { assert(ast.params.value, linterRulesSchema); this.config = mergeConfigs(this.config, { @@ -804,7 +896,9 @@ export class Linter { case ConfigCommentType.Disable: { if (ast.params && ast.params.type === 'ParameterList') { for (const param of ast.params.children) { - this.disableRule(param.value); + if (param) { + this.disableRule(param.value); + } } break; @@ -817,7 +911,9 @@ export class Linter { case ConfigCommentType.Enable: { if (ast.params && ast.params.type === 'ParameterList') { for (const param of ast.params.children) { - this.enableRule(param.value); + if (param) { + this.enableRule(param.value); + } } break; @@ -831,7 +927,9 @@ export class Linter { // Disable specific rules for the next line if (ast.params && ast.params.type === 'ParameterList') { for (const param of ast.params.children) { - nextLineDisabled.add(param.value); + if (param) { + nextLineDisabled.add(param.value); + } } } else { // Disable all rules for the next line @@ -845,7 +943,9 @@ export class Linter { // Enable specific rules for the next line if (ast.params && ast.params.type === 'ParameterList') { for (const param of ast.params.children) { - nextLineEnabled.add(param.value); + if (param) { + nextLineEnabled.add(param.value); + } } } else { // Disable all rules for the next line diff --git a/src/linter/rules/duplicated-hint-platforms.ts b/src/linter/rules/duplicated-hint-platforms.ts index 4db7d8a5..f4efcb8d 100644 --- a/src/linter/rules/duplicated-hint-platforms.ts +++ b/src/linter/rules/duplicated-hint-platforms.ts @@ -37,6 +37,10 @@ export const DuplicatedHintPlatforms: LinterRule = { // Iterate over all platforms within the hint for (const param of hint.params.children) { + if (!param) { + continue; + } + const platform = param.value; const platformToLowerCase = platform.toLowerCase(); diff --git a/src/linter/rules/duplicated-modifiers.ts b/src/linter/rules/duplicated-modifiers.ts index b1a5609b..55a6c65e 100644 --- a/src/linter/rules/duplicated-modifiers.ts +++ b/src/linter/rules/duplicated-modifiers.ts @@ -26,7 +26,7 @@ export const DuplicatedModifiers: LinterRule = { } for (const modifier of ast.modifiers.children) { - const name = modifier.modifier.value; + const name = modifier.name.value; const nameToLowerCase = name.toLowerCase(); if (!occurred.has(nameToLowerCase)) { diff --git a/src/linter/rules/inconsistent-hint-platforms.ts b/src/linter/rules/inconsistent-hint-platforms.ts index f15a8898..5a3e0588 100644 --- a/src/linter/rules/inconsistent-hint-platforms.ts +++ b/src/linter/rules/inconsistent-hint-platforms.ts @@ -1,5 +1,5 @@ import equal from 'fast-deep-equal'; -import { CommentRuleType, type Parameter, RuleCategory } from '@adguard/agtree'; +import { CommentRuleType, RuleCategory, type Value } from '@adguard/agtree'; import { type LinterRule } from '../common'; import { SEVERITY } from '../severity'; @@ -27,14 +27,18 @@ export const InconsistentHintPlatforms: LinterRule = { } // Platforms targeted by a PLATFORM() hint - const platforms: Parameter[] = []; + const platforms: Value[] = []; // Platforms excluded by a NOT_PLATFORM() hint - const notPlatforms: Parameter[] = []; + const notPlatforms: Value[] = []; // Iterate over all hints within the hint comment rule for (const hint of ast.children) { for (const param of hint.params?.children ?? []) { + if (!param) { + continue; + } + // Add actual platform (parameter) to the corresponding array if (hint.name.value === PLATFORM) { platforms.push(param); @@ -46,29 +50,29 @@ export const InconsistentHintPlatforms: LinterRule = { // Find platforms that are targeted by a PLATFORM() hint and excluded by a // NOT_PLATFORM() hint at the same time, but take duplicates into account - const commonPlatforms: Parameter[] = []; + const commonPlatforms: Value[] = []; for (const platform of platforms) { for (const notPlatform of notPlatforms) { if (platform.value === notPlatform.value) { // Check if the platform is already in the array (loc property is unique // and definietly exists, since we configured the parser to do so) - if (!commonPlatforms.some((e) => equal(e.loc, platform.loc))) { + if (!commonPlatforms.some((e) => equal(e.start, platform.start))) { commonPlatforms.push(platform); } - if (!commonPlatforms.some((e) => equal(e.loc, notPlatform.loc))) { + if (!commonPlatforms.some((e) => equal(e.start, notPlatform.start))) { commonPlatforms.push(notPlatform); } } } } - // Sort platforms by their location (loc.start.offset) to get a consistent order + // Sort platforms by their location ("loc.start.offset") to get a consistent order // It is safe to use the non-null assertion operator here, because the loc property // is always defined for parameters, since we configured the parser to do so // eslint-disable-next-line max-len, @typescript-eslint/no-non-null-assertion - const commonPlatformsOrdered = commonPlatforms.sort((a, b) => a.loc!.start.offset - b.loc!.start.offset); + const commonPlatformsOrdered = commonPlatforms.sort((a, b) => a.start! - b.start!); // Report all platforms that are targeted by a PLATFORM() hint and excluded by a // NOT_PLATFORM() hint at the same time diff --git a/src/linter/rules/index.ts b/src/linter/rules/index.ts index 1769b91f..bbc52043 100644 --- a/src/linter/rules/index.ts +++ b/src/linter/rules/index.ts @@ -11,8 +11,12 @@ import { SingleSelector } from './single-selector'; import { UnknownPreProcessorDirectives } from './unknown-preprocessor-directives'; import { NoShortRules } from './no-short-rules'; import { NoExcludedRules } from './no-excluded-rules'; +import { NoInvalidCssSyntax } from './no-invalid-css-syntax'; +import { NoInvalidCssDeclaration } from './no-invalid-css-declaration'; export const defaultLinterRules = new Map([ + ['no-invalid-css-syntax', NoInvalidCssSyntax], + ['no-invalid-css-declaration', NoInvalidCssDeclaration], ['if-closed', IfClosed], ['single-selector', SingleSelector], ['duplicated-modifiers', DuplicatedModifiers], diff --git a/src/linter/rules/invalid-modifiers.ts b/src/linter/rules/invalid-modifiers.ts index 60faef34..a0222c1f 100644 --- a/src/linter/rules/invalid-modifiers.ts +++ b/src/linter/rules/invalid-modifiers.ts @@ -1,4 +1,4 @@ -import { RuleCategory, modifierValidator } from '@adguard/agtree'; +import { NetworkRuleType, RuleCategory, modifierValidator } from '@adguard/agtree'; import { SEVERITY } from '../severity'; import type { LinterRule, SpecificRuleContext } from '../common'; @@ -15,7 +15,7 @@ export const InvalidModifiers: LinterRule = { // Get actually iterated adblock rule const ast = context.getActualAdblockRuleAst(); - if (ast.category === RuleCategory.Network) { + if (ast.category === RuleCategory.Network && ast.type === NetworkRuleType.NetworkRule) { // get lint of syntaxes to validate modifiers for const validateSyntax = context.getLinterConfig().syntax; diff --git a/src/linter/rules/no-invalid-css-declaration.ts b/src/linter/rules/no-invalid-css-declaration.ts new file mode 100644 index 00000000..8a34d594 --- /dev/null +++ b/src/linter/rules/no-invalid-css-declaration.ts @@ -0,0 +1,52 @@ +import { CosmeticRuleType, RuleCategory } from '@adguard/agtree'; +import { type CssNode, type Declaration } from '@adguard/ecss-tree'; + +import { type LinterRule } from '../common'; +import { SEVERITY } from '../severity'; +import { isUndefined } from '../../utils/type-guards'; +import { CssTreeParsingContext } from '../helpers/css-tree-types'; +import { validateDeclaration } from '../helpers/css-validator'; + +/** + * Rule that checks if CSS declarations are valid + */ +export const NoInvalidCssDeclaration: LinterRule = { + meta: { + severity: SEVERITY.error, + }, + events: { + onRule: (context): void => { + const ast = context.getActualAdblockRuleAst(); + + if (ast.category !== RuleCategory.Cosmetic || ast.type !== CosmeticRuleType.CssInjectionRule) { + return; + } + + const rawDeclarationList = ast.body.declarationList; + + if (ast.body.remove !== true && !isUndefined(rawDeclarationList)) { + const declarationListNode = context.getCssNode( + rawDeclarationList, + CssTreeParsingContext.DeclarationList, + ); + + if (!declarationListNode) { + return; + } + + declarationListNode.children.forEach((declarationNode: CssNode) => { + validateDeclaration(declarationNode as Declaration).forEach((error) => { + context.report({ + message: error.message, + // Note: DO NOT use `declarationNode` CSS node here, we should use the AGTree node instead. + // Error locations are relative to the AGTree node. + node: rawDeclarationList, + relativeNodeStartOffset: error.start, + relativeNodeEndOffset: error.end, + }); + }); + }); + } + }, + }, +}; diff --git a/src/linter/rules/no-invalid-css-syntax.ts b/src/linter/rules/no-invalid-css-syntax.ts new file mode 100644 index 00000000..18c41f2e --- /dev/null +++ b/src/linter/rules/no-invalid-css-syntax.ts @@ -0,0 +1,37 @@ +import { CosmeticRuleType, RuleCategory } from '@adguard/agtree'; + +import { type LinterRule } from '../common'; +import { SEVERITY } from '../severity'; +import { isUndefined } from '../../utils/type-guards'; +import { CssTreeParsingContext } from '../helpers/css-tree-types'; + +/** + * Rule that checks if all CSS is syntactically valid + */ +export const NoInvalidCssSyntax: LinterRule = { + meta: { + severity: SEVERITY.error, + }, + events: { + onRule: (context): void => { + const ast = context.getActualAdblockRuleAst(); + + if (ast.category !== RuleCategory.Cosmetic) { + return; + } + + if (ast.type === CosmeticRuleType.ElementHidingRule) { + const rawSelectorList = ast.body.selectorList; + context.getCssNode(rawSelectorList, CssTreeParsingContext.SelectorList); + } else if (ast.type === CosmeticRuleType.CssInjectionRule) { + const rawSelectorList = ast.body.selectorList; + context.getCssNode(rawSelectorList, CssTreeParsingContext.SelectorList); + + const rawDeclarationList = ast.body.declarationList; + if (ast.body.remove !== true && !isUndefined(rawDeclarationList)) { + context.getCssNode(rawDeclarationList, CssTreeParsingContext.DeclarationList); + } + } + }, + }, +}; diff --git a/src/linter/rules/single-selector.ts b/src/linter/rules/single-selector.ts index 17c8d59f..af698ac8 100644 --- a/src/linter/rules/single-selector.ts +++ b/src/linter/rules/single-selector.ts @@ -1,8 +1,12 @@ import cloneDeep from 'clone-deep'; import { CosmeticRuleType, RuleCategory } from '@adguard/agtree'; +import { type Selector } from '@adguard/ecss-tree'; import { type LinterProblemReport, type LinterRule } from '../common'; import { SEVERITY } from '../severity'; +import { isNull } from '../../utils/type-guards'; +import { CssTreeParsingContext } from '../helpers/css-tree-types'; +import { generateSelector } from '../helpers/css-generate'; /** * Rule that checks if a cosmetic rule contains multiple selectors @@ -18,8 +22,15 @@ export const SingleSelector: LinterRule = { // Check if the rule is an element hiding rule if (ast.category === RuleCategory.Cosmetic && ast.type === CosmeticRuleType.ElementHidingRule) { + const rawSelectorList = ast.body.selectorList; + const selectorListNode = context.getCssNode(rawSelectorList, CssTreeParsingContext.SelectorList); + + if (isNull(selectorListNode)) { + return; + } + // Only makes sense to check this, if there are at least two selectors within the rule - if (ast.body.selectorList.children.length < 2) { + if (selectorListNode.children.size < 2) { return; } @@ -35,13 +46,13 @@ export const SingleSelector: LinterRule = { report.fix = []; // Iterate over all selectors in the current rule - for (const selector of ast.body.selectorList.children) { + for (const selector of selectorListNode.children) { // Create a new rule with the same properties as the current rule. const clone = cloneDeep(ast); // Replace the selector list with a new selector list containing only // the currently iterated selector - clone.body.selectorList.children = [selector]; + clone.body.selectorList.value = generateSelector(selector as Selector); // The only difference is that the new rule only contains one selector, // which has the currently iterated selector in its body. diff --git a/src/linter/rules/unknown-hints-and-platforms.ts b/src/linter/rules/unknown-hints-and-platforms.ts index 4cb7b730..a924b438 100644 --- a/src/linter/rules/unknown-hints-and-platforms.ts +++ b/src/linter/rules/unknown-hints-and-platforms.ts @@ -57,6 +57,10 @@ export const UnknownHintsAndPlatforms: LinterRule = { }); } else { for (const param of hint.params.children) { + if (!param) { + continue; + } + // Check if the platform is known (case sensitive) if (!KNOWN_PLATFORMS.has(param.value)) { context.report({ diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 00000000..8e427fe8 --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,49 @@ +type ErrorWithMessage = { + message: string +}; + +/** + * Checks if error has message. + * + * @param error Error object. + * @returns If param is error. + */ +function isErrorWithMessage(error: unknown): error is ErrorWithMessage { + return ( + typeof error === 'object' + && error !== null + && 'message' in error + && typeof (error as Record).message === 'string' + ); +} + +/** + * Converts error to the error with message. + * + * @param maybeError Possible error. + * @returns Error with message. + */ +function toErrorWithMessage(maybeError: unknown): ErrorWithMessage { + if (isErrorWithMessage(maybeError)) { + return maybeError; + } + + try { + return new Error(JSON.stringify(maybeError)); + } catch { + // fallback in case there's an error stringifying the maybeError + // like with circular references for example. + return new Error(String(maybeError)); + } +} + +/** + * Converts error object to error with message. This method might be helpful to handle thrown errors. + * + * @param error Error object. + * + * @returns Message of the error. + */ +export function getErrorMessage(error: unknown): string { + return toErrorWithMessage(error).message; +} diff --git a/src/utils/type-guards.ts b/src/utils/type-guards.ts new file mode 100644 index 00000000..c0316eda --- /dev/null +++ b/src/utils/type-guards.ts @@ -0,0 +1,64 @@ +/** + * Checks whether the given value is undefined. + * + * @param value Value to check. + * + * @returns `true` if the value is 'undefined', `false` otherwise. + */ +export const isUndefined = (value: unknown): value is undefined => { + return typeof value === 'undefined'; +}; + +/** + * Checks whether the given value is null. + * + * @param value Value to check. + * + * @returns `true` if the value is 'null', `false` otherwise. + */ +export const isNull = (value: unknown): value is null => { + return value === null; +}; + +/** + * Checks whether the given value is a number. + * + * @param value Value to check. + * + * @returns `true` if the value is a number, `false` otherwise. + */ +export const isNumber = (value: unknown): value is number => { + return typeof value === 'number' && !Number.isNaN(value); +}; + +/** + * Checks whether the given value is an integer. + * + * @param value Value to check. + * + * @returns `true` if the value is an integer, `false` otherwise. + */ +export const isInteger = (value: unknown): value is number => { + return Number.isInteger(value); +}; + +/** + * Checks whether the given value is a string. + * + * @param value Value to check. + * @returns `true` if the value is a string, `false` otherwise. + */ +export const isString = (value: unknown): value is string => { + return typeof value === 'string'; +}; + +/** + * Checks whether the given value is an array of Uint8Arrays. + * + * @param value Value to check. + * + * @returns True if the value type is an array of Uint8Arrays. + */ +export const isArrayOfUint8Arrays = (value: unknown): value is Uint8Array[] => { + return Array.isArray(value) && value.every((chunk) => chunk instanceof Uint8Array); +}; diff --git a/test/fixtures/cli/.aglintrc.yaml b/test/fixtures/cli/.aglintrc.yaml index a9ca1a80..04295e02 100644 --- a/test/fixtures/cli/.aglintrc.yaml +++ b/test/fixtures/cli/.aglintrc.yaml @@ -1 +1,3 @@ -root: true \ No newline at end of file +root: true +rules: + no-invalid-css-syntax: error diff --git a/test/linter/cli/cli.test.ts b/test/linter/cli/cli.test.ts index dd05ed8a..f86970a3 100644 --- a/test/linter/cli/cli.test.ts +++ b/test/linter/cli/cli.test.ts @@ -89,8 +89,8 @@ describe('CLI tests', () => { result: { problems: [ { - severity: 3, - message: "AGLint parsing error: ECSSTree parsing error: 'Name is expected'", + severity: 2, + message: 'Cannot parse CSS due to the following error: Name is expected', position: { startLine: 2, startColumn: 14, @@ -100,8 +100,8 @@ describe('CLI tests', () => { }, ], warningCount: 0, - errorCount: 0, - fatalErrorCount: 1, + errorCount: 1, + fatalErrorCount: 0, }, }, ], @@ -115,7 +115,7 @@ describe('CLI tests', () => { { severity: 3, // eslint-disable-next-line max-len - message: "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + message: "Cannot parse adblock rule due to the following error: Invalid ADG scriptlet call, no closing parentheses ')' found", position: { startLine: 2, startColumn: 25, @@ -153,7 +153,7 @@ describe('CLI tests', () => { { severity: 3, // eslint-disable-next-line max-len - message: "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + message: "Cannot parse adblock rule due to the following error: Invalid ADG scriptlet call, no closing parentheses ')' found", position: { startLine: 2, startColumn: 25, @@ -164,7 +164,7 @@ describe('CLI tests', () => { { severity: 3, // eslint-disable-next-line max-len - message: "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + message: "Cannot parse adblock rule due to the following error: Invalid ADG scriptlet call, no closing parentheses ')' found", position: { startLine: 6, startColumn: 25, @@ -230,8 +230,8 @@ describe('CLI tests', () => { result: { problems: [ { - severity: 3, - message: "AGLint parsing error: ECSSTree parsing error: 'Name is expected'", + severity: 2, + message: 'Cannot parse CSS due to the following error: Name is expected', position: { startLine: 2, startColumn: 14, @@ -241,8 +241,8 @@ describe('CLI tests', () => { }, ], warningCount: 0, - errorCount: 0, - fatalErrorCount: 1, + errorCount: 1, + fatalErrorCount: 0, }, }, ], @@ -256,7 +256,7 @@ describe('CLI tests', () => { { severity: 3, // eslint-disable-next-line max-len - message: "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + message: "Cannot parse adblock rule due to the following error: Invalid ADG scriptlet call, no closing parentheses ')' found", position: { startLine: 2, startColumn: 25, @@ -281,7 +281,7 @@ describe('CLI tests', () => { { severity: 3, // eslint-disable-next-line max-len - message: "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + message: "Cannot parse adblock rule due to the following error: Invalid ADG scriptlet call, no closing parentheses ')' found", position: { startLine: 2, startColumn: 25, @@ -319,7 +319,7 @@ describe('CLI tests', () => { { severity: 3, // eslint-disable-next-line max-len - message: "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + message: "Cannot parse adblock rule due to the following error: Invalid ADG scriptlet call, no closing parentheses ')' found", position: { startLine: 2, startColumn: 25, @@ -330,7 +330,7 @@ describe('CLI tests', () => { { severity: 3, // eslint-disable-next-line max-len - message: "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + message: "Cannot parse adblock rule due to the following error: Invalid ADG scriptlet call, no closing parentheses ')' found", position: { startLine: 6, startColumn: 25, @@ -422,7 +422,7 @@ describe('CLI tests', () => { { severity: 3, // eslint-disable-next-line max-len - message: "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + message: "Cannot parse adblock rule due to the following error: Invalid ADG scriptlet call, no closing parentheses ')' found", position: { startLine: 2, startColumn: 25, diff --git a/test/linter/helpers/css-generate.test.ts b/test/linter/helpers/css-generate.test.ts new file mode 100644 index 00000000..4d9e8bae --- /dev/null +++ b/test/linter/helpers/css-generate.test.ts @@ -0,0 +1,390 @@ +/** + * Migrated from https://github.com/AdguardTeam/tsurlfilter/blob/2144959e92f94e4738274ce577c2fe26e3a0c08b/packages/agtree/test/utils/csstree.test.ts + */ + +import { + type CssNode, + find, + type FunctionNode, + List, + type PseudoClassSelector, +} from '@adguard/ecss-tree'; + +import { + generateDeclarationList, + generateFunctionValue, + generateMediaQuery, + generateMediaQueryList, + generatePseudoClassValue, + generateSelector, + generateSelectorList, +} from '../../../src/linter/helpers/css-generate'; +import { parseCss } from '../../../src/linter/helpers/css-parse'; +import { CssTreeNodeType, CssTreeParsingContext } from '../../../src/linter/helpers/css-tree-types'; + +describe('CSSTree utils', () => { + describe('generateSelector', () => { + test.each([ + { + actual: 'div', + expected: 'div', + }, + { + actual: '#test', + expected: '#test', + }, + { + actual: '.test', + expected: '.test', + }, + { + actual: '.test .test', + expected: '.test .test', + }, + { + actual: '[a=b]', + expected: '[a=b]', + }, + { + actual: '[a="b"i]', + expected: '[a="b" i]', + }, + { + actual: '[a="b" i]', + expected: '[a="b" i]', + }, + { + actual: 'div::first-child', + expected: 'div::first-child', + }, + { + actual: 'div::a(b)', + expected: 'div::a(b)', + }, + { + actual: 'div.test', + expected: 'div.test', + }, + { + actual: 'div#test', + expected: 'div#test', + }, + { + actual: 'div[data-advert]', + expected: 'div[data-advert]', + }, + { + actual: ':lang(hu-hu)', + expected: ':lang(hu-hu)', + }, + { + actual: 'div[data-advert] > #test ~ div[class="advert"][id="something"]:nth-child(3n+0):first-child', + expected: 'div[data-advert] > #test ~ div[class="advert"][id="something"]:nth-child(3n+0):first-child', + }, + { + actual: ':not(:not([name]))', + expected: ':not(:not([name]))', + }, + { + actual: ':not(:not([name]):contains(2))', + expected: ':not(:not([name]):contains(2))', + }, + { + // eslint-disable-next-line max-len + actual: '.teasers > div[class=" display"]:has(> div[class] > div[class] > div:not([class]):not([id]) > div:not([class]):not([id]):contains(/^REKLAMA$/))', + // eslint-disable-next-line max-len + expected: '.teasers > div[class=" display"]:has(> div[class] > div[class] > div:not([class]):not([id]) > div:not([class]):not([id]):contains(/^REKLAMA$/))', + }, + ])('should generate \'$expected\' from \'$actual\'', ({ actual, expected }) => { + const generated = generateSelector(parseCss(actual, CssTreeParsingContext.Selector)); + expect(generated).toEqual(expected); + }); + }); + + describe('generateSelectorList', () => { + test.each([ + { + actual: 'div,div', + expected: 'div, div', + }, + { + actual: 'div, div', + expected: 'div, div', + }, + { + actual: 'div, div, div', + expected: 'div, div, div', + }, + { + actual: '#test, div', + expected: '#test, div', + }, + { + actual: '#test,div,#test', + expected: '#test, div, #test', + }, + { + actual: '#test, div, #test', + expected: '#test, div, #test', + }, + { + actual: '.test, div', + expected: '.test, div', + }, + { + actual: '[a=b],#test', + expected: '[a=b], #test', + }, + { + actual: '[a=b], #test', + expected: '[a=b], #test', + }, + { + actual: '[a="b"i],#test', + expected: '[a="b" i], #test', + }, + { + actual: '[a="b" i], #test', + expected: '[a="b" i], #test', + }, + { + actual: 'div::first-child,#test', + expected: 'div::first-child, #test', + }, + { + actual: 'div::first-child, #test', + expected: 'div::first-child, #test', + }, + { + actual: 'div::a(b),#test', + expected: 'div::a(b), #test', + }, + { + actual: 'div::a(b), #test', + expected: 'div::a(b), #test', + }, + { + actual: 'div.test,#test', + expected: 'div.test, #test', + }, + { + actual: 'div.test, #test', + expected: 'div.test, #test', + }, + { + actual: 'div#test,#test', + expected: 'div#test, #test', + }, + { + actual: 'div#test, #test', + expected: 'div#test, #test', + }, + { + actual: 'div[data-advert],#test', + expected: 'div[data-advert], #test', + }, + { + actual: ':lang(hu-hu),#test', + expected: ':lang(hu-hu), #test', + }, + { + // eslint-disable-next-line max-len + actual: 'div[data-advert] > #test ~ div[class="advert"][id="something"]:nth-child(3n+0):first-child,#test', + // eslint-disable-next-line max-len + expected: 'div[data-advert] > #test ~ div[class="advert"][id="something"]:nth-child(3n+0):first-child, #test', + }, + ])('should generate \'$expected\' from \'$actual\'', ({ actual, expected }) => { + const generated = generateSelectorList(parseCss(actual, CssTreeParsingContext.SelectorList)); + expect(generated).toEqual(expected); + }); + + test('should generate a selector list with a raw value', () => { + expect( + generateSelector({ + type: 'Selector', + children: new List().fromArray([ + { + type: 'PseudoClassSelector', + name: 'not', + children: new List().fromArray([ + { + type: 'SelectorList', + children: new List().fromArray([ + { + type: 'Selector', + children: new List().fromArray([ + { + type: 'ClassSelector', + name: 'a', + }, + ]), + }, + { + type: 'Raw', + value: '/raw/', + }, + ]), + }, + ]), + }, + ]), + }), + ).toEqual( + ':not(.a, /raw/)', + ); + }); + }); + + describe('generateMediaQuery', () => { + test.each([ + { + actual: 'screen', + expected: 'screen', + }, + { + actual: '(max-width: 100px)', + expected: '(max-width: 100px)', + }, + ])('should generate \'$expected\' from \'$actual\'', ({ actual, expected }) => { + const generated = generateMediaQuery(parseCss(actual, CssTreeParsingContext.MediaQuery)); + expect(generated).toEqual(expected); + }); + }); + + describe('generateMediaQueryList', () => { + test.each([ + { + actual: 'screen and (max-width: 100px)', + expected: 'screen and (max-width: 100px)', + }, + { + actual: 'screen and (max-width: 100px) and (min-width: 50px)', + expected: 'screen and (max-width: 100px) and (min-width: 50px)', + }, + { + // eslint-disable-next-line max-len + actual: 'screen and (max-width: 100px) and (min-width: 50px) and (orientation: landscape)', + // eslint-disable-next-line max-len + expected: 'screen and (max-width: 100px) and (min-width: 50px) and (orientation: landscape)', + }, + { + actual: 'screen, print', + expected: 'screen, print', + }, + ])('should generate \'$expected\' from \'$actual\'', ({ actual, expected }) => { + const generated = generateMediaQueryList(parseCss(actual, CssTreeParsingContext.MediaQueryList)); + expect(generated).toEqual(expected); + }); + }); + + describe('generateDeclarationList', () => { + test.each([ + { + actual: 'padding: 0;', + expected: 'padding: 0;', + }, + { + actual: 'padding: 0', + expected: 'padding: 0;', + }, + { + actual: 'padding: 0!important', + expected: 'padding: 0 !important;', + }, + { + actual: 'padding: 0 !important', + expected: 'padding: 0 !important;', + }, + { + actual: 'padding: 0!important;', + expected: 'padding: 0 !important;', + }, + { + actual: 'padding: 0 !important;', + expected: 'padding: 0 !important;', + }, + { + actual: 'padding: 0!important; margin: 2px', + expected: 'padding: 0 !important; margin: 2px;', + }, + { + actual: 'padding: 0 1px 2px 3px', + expected: 'padding: 0 1px 2px 3px;', + }, + { + // eslint-disable-next-line max-len + actual: 'padding: 0 1px 2px 3px; margin: 0 1px 2px 3px; background: url(http://example.com)', + // eslint-disable-next-line max-len + expected: 'padding: 0 1px 2px 3px; margin: 0 1px 2px 3px; background: url(http://example.com);', + }, + ])('should generate \'$expected\' from \'$actual\'', ({ actual, expected }) => { + const generated = generateDeclarationList(parseCss(actual, CssTreeParsingContext.DeclarationList)); + expect(generated).toEqual(expected); + }); + }); + + describe('generatePseudoClassValue', () => { + test.each([ + { + actual: ':not(.a, .b)', + expected: '.a, .b', + }, + { + actual: ':nth-child(2n+1)', + expected: '2n+1', + }, + { + actual: ':matches-path(/path)', + expected: '/path', + }, + { + actual: ':matches-path(/^\\/path/)', + expected: '/^\\/path/', + }, + { + actual: ':matches-path(/\\/(sub1|sub2)\\/page\\.html/)', + expected: '/\\/(sub1|sub2)\\/page\\.html/', + }, + { + actual: ':has(> [class^="a"])', + expected: '> [class^="a"]', + }, + ])('should generate \'$expected\' from \'$actual\'', ({ actual, expected }) => { + // Parse the actual value as a selector, then find the first pseudo class node + const selectorNode = parseCss(actual, CssTreeParsingContext.Selector); + const pseudo = find(selectorNode, (node) => node.type === CssTreeNodeType.PseudoClassSelector); + + if (!pseudo) { + throw new Error('Pseudo class not found'); + } + + expect( + generatePseudoClassValue(pseudo as PseudoClassSelector), + ).toEqual(expected); + }); + }); + + describe('generateFunctionValue', () => { + test.each([ + { + actual: 'func(aaa)', + expected: 'aaa', + }, + { + actual: 'responseheader(header-name)', + expected: 'header-name', + }, + ])('should generate \'$expected\' from \'$actual\'', ({ actual, expected }) => { + const valueNode = parseCss(actual, CssTreeParsingContext.Value); + const func = find(valueNode, (node) => node.type === CssTreeNodeType.Function); + + if (!func) { + throw new Error('Function node not found'); + } + + expect( + generateFunctionValue(func as FunctionNode), + ).toEqual(expected); + }); + }); +}); diff --git a/test/linter/linter.test.ts b/test/linter/linter.test.ts index f97a5f46..27b4852b 100644 --- a/test/linter/linter.test.ts +++ b/test/linter/linter.test.ts @@ -869,7 +869,7 @@ describe('Linter', () => { severity: SEVERITY.fatal, message: // eslint-disable-next-line max-len - "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + "Cannot parse adblock rule due to the following error: Invalid uBO scriptlet call, no closing parentheses ')' found", position: { startLine: 5, startColumn: 16, @@ -893,7 +893,6 @@ describe('Linter', () => { 'example.com##+js(aopr, test', // Missing closing bracket 'example.com##+jsaopr, test)', // Missing opening bracket 'example.com##+js...', // Invalid scriptlet rule, missing opening bracket - 'example.com#$#body { padding 2px !important; }', // Invalid CSS rule (missing : after padding) '! comment', '||example.net^$third-party', ].join(NEWLINE), @@ -903,7 +902,7 @@ describe('Linter', () => { { severity: 3, // eslint-disable-next-line max-len - message: "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + message: "Cannot parse adblock rule due to the following error: Invalid uBO scriptlet call, no closing parentheses ')' found", position: { startLine: 4, startColumn: 16, @@ -914,7 +913,7 @@ describe('Linter', () => { { severity: 3, // eslint-disable-next-line max-len - message: "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no opening parentheses '(' found", + message: "Cannot parse adblock rule due to the following error: Invalid uBO scriptlet call, no opening parentheses '(' found", position: { startLine: 5, startColumn: 16, @@ -925,7 +924,7 @@ describe('Linter', () => { { severity: 3, // eslint-disable-next-line max-len - message: "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no opening parentheses '(' found", + message: "Cannot parse adblock rule due to the following error: Invalid uBO scriptlet call, no opening parentheses '(' found", position: { startLine: 6, startColumn: 16, @@ -933,21 +932,10 @@ describe('Linter', () => { endColumn: 19, }, }, - { - severity: 3, - // eslint-disable-next-line max-len - message: "AGLint parsing error: Invalid rule block, expected a declaration but got 'Raw' instead", - position: { - startLine: 7, - startColumn: 21, - endLine: 7, - endColumn: 44, - }, - }, ], warningCount: 0, errorCount: 0, - fatalErrorCount: 4, + fatalErrorCount: 3, }); }); @@ -1235,7 +1223,7 @@ describe('Linter', () => { severity: SEVERITY.fatal, message: // eslint-disable-next-line max-len - "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + "Cannot parse adblock rule due to the following error: Invalid uBO scriptlet call, no closing parentheses ')' found", position: { startLine: 6, startColumn: 16, @@ -1266,7 +1254,7 @@ describe('Linter', () => { severity: SEVERITY.fatal, message: // eslint-disable-next-line max-len - "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + "Cannot parse adblock rule due to the following error: Invalid uBO scriptlet call, no closing parentheses ')' found", position: { startLine: 6, startColumn: 16, @@ -1361,7 +1349,7 @@ describe('Linter', () => { severity: SEVERITY.fatal, message: // eslint-disable-next-line max-len - "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + "Cannot parse adblock rule due to the following error: Invalid uBO scriptlet call, no closing parentheses ')' found", position: { startLine: 7, startColumn: 16, @@ -1373,7 +1361,7 @@ describe('Linter', () => { severity: SEVERITY.fatal, message: // eslint-disable-next-line max-len - "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + "Cannot parse adblock rule due to the following error: Invalid uBO scriptlet call, no closing parentheses ')' found", position: { startLine: 8, startColumn: 16, @@ -1416,7 +1404,7 @@ describe('Linter', () => { severity: SEVERITY.fatal, message: // eslint-disable-next-line max-len - "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + "Cannot parse adblock rule due to the following error: Invalid uBO scriptlet call, no closing parentheses ')' found", position: { startLine: 10, startColumn: 15, @@ -1428,7 +1416,7 @@ describe('Linter', () => { severity: SEVERITY.fatal, message: // eslint-disable-next-line max-len - "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + "Cannot parse adblock rule due to the following error: Invalid uBO scriptlet call, no closing parentheses ')' found", position: { startLine: 11, startColumn: 15, @@ -1440,7 +1428,7 @@ describe('Linter', () => { severity: SEVERITY.fatal, message: // eslint-disable-next-line max-len - "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + "Cannot parse adblock rule due to the following error: Invalid uBO scriptlet call, no closing parentheses ')' found", position: { startLine: 17, startColumn: 15, @@ -1477,7 +1465,7 @@ describe('Linter', () => { severity: SEVERITY.fatal, message: // eslint-disable-next-line max-len - "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + "Cannot parse adblock rule due to the following error: Invalid uBO scriptlet call, no closing parentheses ')' found", position: { startLine: 7, startColumn: 16, @@ -1489,7 +1477,7 @@ describe('Linter', () => { severity: SEVERITY.fatal, message: // eslint-disable-next-line max-len - "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + "Cannot parse adblock rule due to the following error: Invalid uBO scriptlet call, no closing parentheses ')' found", position: { startLine: 10, startColumn: 16, @@ -1501,7 +1489,7 @@ describe('Linter', () => { severity: SEVERITY.fatal, message: // eslint-disable-next-line max-len - "AGLint parsing error: Invalid AdGuard/uBlock scriptlet call, no closing parentheses ')' found", + "Cannot parse adblock rule due to the following error: Invalid uBO scriptlet call, no closing parentheses ')' found", position: { startLine: 11, startColumn: 16, diff --git a/test/linter/rules/invalid-modifiers.test.ts b/test/linter/rules/invalid-modifiers.test.ts index 022c21a7..75c00f74 100644 --- a/test/linter/rules/invalid-modifiers.test.ts +++ b/test/linter/rules/invalid-modifiers.test.ts @@ -181,7 +181,7 @@ describe('invalid-modifiers', () => { expected: { rule: 'invalid-modifiers', severity: 2, - message: 'Empty value specified in the list', + message: 'Value list cannot start with a separator', position: { startColumn: 15, endColumn: 31, diff --git a/test/linter/rules/no-invalid-css-declaration.test.ts b/test/linter/rules/no-invalid-css-declaration.test.ts new file mode 100644 index 00000000..d007f965 --- /dev/null +++ b/test/linter/rules/no-invalid-css-declaration.test.ts @@ -0,0 +1,89 @@ +import { Linter } from '../../../src/linter'; +import { NEWLINE } from '../../../src/common/constants'; +import { NoInvalidCssDeclaration } from '../../../src/linter/rules/no-invalid-css-declaration'; + +let linter: Linter; + +describe('no-invalid-css-declaration', () => { + beforeAll(() => { + // Configure linter with the rule + linter = new Linter(false); + linter.addRule('no-invalid-css-declaration', NoInvalidCssDeclaration); + }); + + test('should ignore non-problematic cases', () => { + // if is closed properly + expect( + linter.lint( + [ + // not contain any CSS + 'rule', + '!#if (condition1)', + 'rule', + '!#endif', + 'rule', + + // contain valid CSS + '##.foo:has(.bar)', + + // contain valid declaration + '#$#.foo { color: red; }', + ].join(NEWLINE), + ), + ).toMatchObject({ + problems: [], + }); + }); + + test('should detect invalid CSS declarations', () => { + expect( + linter.lint( + [ + '#$#.foo { color: bar; }', // invalid color + '#$#.bar { background: url(foo.png) [foo]; }', // invalid background + '#$#.bar { foo: url(foo.png); }', // invalid property + ].join(NEWLINE), + ), + ).toMatchObject({ + problems: [ + { + rule: 'no-invalid-css-declaration', + severity: 2, + message: "Invalid value for 'color' property, mismatch with syntax ", + position: { + startLine: 1, + startColumn: 17, + endLine: 1, + endColumn: 20, + }, + }, + { + rule: 'no-invalid-css-declaration', + severity: 2, + // eslint-disable-next-line max-len + message: "Invalid value for 'background' property, mismatch with syntax [ , ]* ", + position: { + startLine: 2, + startColumn: 35, + endLine: 2, + endColumn: 40, + }, + }, + { + rule: 'no-invalid-css-declaration', + severity: 2, + message: 'Unknown property `foo`', + position: { + startLine: 3, + startColumn: 10, + endLine: 3, + endColumn: 27, + }, + }, + ], + warningCount: 0, + errorCount: 3, + fatalErrorCount: 0, + }); + }); +}); diff --git a/test/linter/rules/no-invalid-css-syntax.test.ts b/test/linter/rules/no-invalid-css-syntax.test.ts new file mode 100644 index 00000000..a8ee3cac --- /dev/null +++ b/test/linter/rules/no-invalid-css-syntax.test.ts @@ -0,0 +1,85 @@ +import { Linter } from '../../../src/linter'; +import { NEWLINE } from '../../../src/common/constants'; +import { NoInvalidCssSyntax } from '../../../src/linter/rules/no-invalid-css-syntax'; + +let linter: Linter; + +describe('no-invalid-css-syntax', () => { + beforeAll(() => { + // Configure linter with the rule + linter = new Linter(false); + linter.addRule('no-invalid-css-syntax', NoInvalidCssSyntax); + }); + + test('should ignore non-problematic cases', () => { + // if is closed properly + expect( + linter.lint( + [ + // not contain any CSS + 'rule', + '!#if (condition1)', + 'rule', + '!#endif', + 'rule', + + // contain valid CSS + '##.foo:has(.bar)', + ].join(NEWLINE), + ), + ).toMatchObject({ + problems: [], + }); + }); + + test('should detect invalid CSS', () => { + expect( + linter.lint( + [ + '##.#foo', // class selector contains hash + '##.bar:has(', // unclosed pseudo-class + 'example.com#$#body { padding 2px !important; }', // missing colon + ].join(NEWLINE), + ), + ).toMatchObject({ + problems: [ + { + rule: 'no-invalid-css-syntax', + severity: 2, + message: 'Cannot parse CSS due to the following error: Identifier is expected', + position: { + startLine: 1, + startColumn: 3, + endLine: 1, + endColumn: 7, + }, + }, + { + rule: 'no-invalid-css-syntax', + severity: 2, + message: 'Cannot parse CSS due to the following error: ")" is expected', + position: { + startLine: 2, + startColumn: 11, + endLine: 2, + endColumn: 11, + }, + }, + { + rule: 'no-invalid-css-syntax', + severity: 2, + message: 'Cannot parse CSS due to the following error: Colon is expected', + position: { + startLine: 3, + startColumn: 29, + endLine: 3, + endColumn: 44, + }, + }, + ], + warningCount: 0, + errorCount: 3, + fatalErrorCount: 0, + }); + }); +}); diff --git a/test/linter/rules/unknown-hints-and-platforms.test.ts b/test/linter/rules/unknown-hints-and-platforms.test.ts index d6f0ebe9..eb8c1adc 100644 --- a/test/linter/rules/unknown-hints-and-platforms.test.ts +++ b/test/linter/rules/unknown-hints-and-platforms.test.ts @@ -63,7 +63,8 @@ describe('unknown-hints-and-platforms', () => { problems: [ { severity: 3, - message: 'AGLint parsing error: Missing closing parenthesis for hint "NOT_OPTIMIZED"', + // eslint-disable-next-line max-len + message: 'Cannot parse adblock rule due to the following error: Missing closing parenthesis for hint "NOT_OPTIMIZED"', position: { startColumn: 3, endColumn: 17, @@ -137,7 +138,8 @@ describe('unknown-hints-and-platforms', () => { problems: [ { severity: 3, - message: 'AGLint parsing error: Missing closing parenthesis for hint "PLATFORM"', + // eslint-disable-next-line max-len + message: 'Cannot parse adblock rule due to the following error: Missing closing parenthesis for hint "PLATFORM"', position: { startColumn: 3, endColumn: 12, @@ -220,7 +222,8 @@ describe('unknown-hints-and-platforms', () => { problems: [ { severity: 3, - message: 'AGLint parsing error: Missing closing parenthesis for hint "NOT_PLATFORM"', + // eslint-disable-next-line max-len + message: 'Cannot parse adblock rule due to the following error: Missing closing parenthesis for hint "NOT_PLATFORM"', position: { startColumn: 3, endColumn: 16, @@ -303,7 +306,8 @@ describe('unknown-hints-and-platforms', () => { problems: [ { severity: 3, - message: 'AGLint parsing error: Missing closing parenthesis for hint "HINT"', + // eslint-disable-next-line max-len + message: 'Cannot parse adblock rule due to the following error: Missing closing parenthesis for hint "HINT"', position: { startColumn: 3, endColumn: 8, diff --git a/yarn.lock b/yarn.lock index a93f452a..1ab7f4f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,33 +2,32 @@ # yarn lockfile v1 -"@adguard/agtree@^1.1.8": - version "1.1.8" - resolved "https://registry.yarnpkg.com/@adguard/agtree/-/agtree-1.1.8.tgz#e001389bdb08476eb3c55e56922179a14e9b5fbf" - integrity sha512-5k9bYA+JSfZgYTvwahkM8ihIf1fvP+RxA1dKLgkRIGa6ixOSWNKv/pN0Rpiy0DwZJbC9X/OeZrtdW66jASH/JA== +"@adguard/agtree@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@adguard/agtree/-/agtree-2.0.2.tgz#8932a645016c447c0d70707380bb6cc2efe664c4" + integrity sha512-T+fH4qi+UBM8xVQyxPbw8bagCkeBvBbf2AzQYaP3YqO++pmuhTSBE5uYChwH+ZJSQaFjCTACVxdsEbsA5UFyqg== dependencies: - "@adguard/ecss-tree" "^1.0.8" - "@adguard/scriptlets" "^1.9.61" + "@adguard/css-tokenizer" "^1.0.0" clone-deep "^4.0.1" + is-ip "3.1.0" json5 "^2.2.3" semver "^7.5.3" + sprintf-js "^1.1.3" tldts "^5.7.112" xregexp "^5.1.1" + zod "3.21.4" -"@adguard/ecss-tree@^1.0.8": - version "1.0.8" - resolved "https://registry.yarnpkg.com/@adguard/ecss-tree/-/ecss-tree-1.0.8.tgz#9209c2118c88821fc822851153cc042d58abef67" - integrity sha512-Y5dfzWH5nnzEH9URuzOQ1RXl0bzmLiGO7Nt9Wc/na7uD5UHqoz4PlzVllFpO1bLA+Cqq5ebNrz+uWRKN3BxSTg== - dependencies: - css-tree "^2.3.1" +"@adguard/css-tokenizer@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@adguard/css-tokenizer/-/css-tokenizer-1.0.0.tgz#670c011ccba42bdbc5e10717cf6d6daadec7105b" + integrity sha512-23yhZWGZ4+XuYQslzvORx8fwsUmVOOQgyJcGOpyaH097KOJTniVF9UeaxEZpdJIhErJ6zDqPx2ifGer5QdZ0zw== -"@adguard/scriptlets@^1.9.61": - version "1.9.62" - resolved "https://registry.yarnpkg.com/@adguard/scriptlets/-/scriptlets-1.9.62.tgz#f60b83bb928c160f59153989c9491504e510498b" - integrity sha512-uWSlfMnAJUmIVsChl7KrECBXDJotSfq/N94iNLtfwnJI7br9Q5Gl65iuH89rh0Fs62OwexKlj3cnrzHL4TEFyw== +"@adguard/ecss-tree@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@adguard/ecss-tree/-/ecss-tree-1.1.0.tgz#27d8650ae7fb7fb08b780a41f765564e7251f59b" + integrity sha512-Qs7cpUH5AexO9JAXxMPmh6CwdNEnP1qUBSpvHnGxPmHQDjBzpAn4qz8zsJILmX4Rc5Up0iqeYcYC7Pq5HBvoyQ== dependencies: - "@babel/runtime" "^7.20.13" - js-yaml "^3.13.1" + css-tree "^2.3.1" "@ampproject/remapping@^2.2.0": version "2.2.1" @@ -968,13 +967,6 @@ core-js-pure "^3.30.2" regenerator-runtime "^0.14.0" -"@babel/runtime@^7.20.13": - version "7.22.10" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.10.tgz#ae3e9631fd947cb7e3610d3e9d8fef5f76696682" - integrity sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ== - dependencies: - regenerator-runtime "^0.14.0" - "@babel/runtime@^7.8.4": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.5.tgz#8564dd588182ce0047d55d7a75e93921107b57ec" @@ -1604,13 +1596,6 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@rollup/plugin-alias@^5.0.0": - version "5.0.0" - resolved "https://registry.yarnpkg.com/@rollup/plugin-alias/-/plugin-alias-5.0.0.tgz#70f3d504bd17d8922e35c6b61c08b40a6ec25af2" - integrity sha512-l9hY5chSCjuFRPsnRm16twWBiSApl2uYFLsepQYwtBuAxNMQ/1dJqADld40P0Jkqm65GRTLy/AC6hnpVebtLsA== - dependencies: - slash "^4.0.0" - "@rollup/plugin-babel@^6.0.3": version "6.0.3" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-6.0.3.tgz#07ccde15de278c581673034ad6accdb4a153dfeb" @@ -1647,10 +1632,10 @@ dependencies: "@rollup/pluginutils" "^5.0.1" -"@rollup/plugin-node-resolve@^15.1.0": - version "15.1.0" - resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.1.0.tgz#9ffcd8e8c457080dba89bb9fcb583a6778dc757e" - integrity sha512-xeZHCgsiZ9pzYVgAo9580eCGqwh/XCEUM9q6iQfGNocjgkufHAqC3exA+45URvhiYV8sBF9RlBai650eNs7AsA== +"@rollup/plugin-node-resolve@^15.2.3": + version "15.2.3" + resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz#e5e0b059bd85ca57489492f295ce88c2d4b0daf9" + integrity sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ== dependencies: "@rollup/pluginutils" "^5.0.1" "@types/resolve" "1.20.2" @@ -3478,6 +3463,11 @@ internal-slot@^1.0.5: has "^1.0.3" side-channel "^1.0.4" +ip-regex@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.3.0.tgz#687275ab0f57fa76978ff8f4dddc8a23d5990db5" + integrity sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q== + is-array-buffer@^3.0.1, is-array-buffer@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.2.tgz#f2653ced8412081638ecb0ebbd0c41c6e0aecbbe" @@ -3555,6 +3545,13 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: dependencies: is-extglob "^2.1.1" +is-ip@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-ip/-/is-ip-3.1.0.tgz#2ae5ddfafaf05cb8008a62093cf29734f657c5d8" + integrity sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q== + dependencies: + ip-regex "^4.0.0" + is-module@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" @@ -5041,20 +5038,15 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== -slash@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" - integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== - smob@^1.0.0: version "1.4.0" resolved "https://registry.yarnpkg.com/smob/-/smob-1.4.0.tgz#ac9751fe54b1fc1fc8286a628d4e7f824273b95a" integrity sha512-MqR3fVulhjWuRNSMydnTlweu38UhQ0HXM4buStD/S3mc/BzX3CuM9OmhyQpmtYCvoYdl5ris6TI0ZqH355Ymqg== source-map-js@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" - integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== + version "1.2.0" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" + integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== source-map-support@0.5.13: version "0.5.13" @@ -5095,6 +5087,11 @@ spdx-license-ids@^3.0.0: resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz#7189a474c46f8d47c7b0da4b987bb45e908bd2d5" integrity sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w== +sprintf-js@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" + integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -5594,3 +5591,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@3.21.4: + version "3.21.4" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db" + integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==