From 626d11f648cf8806443305d3bb01f36feba31bdf Mon Sep 17 00:00:00 2001 From: Maciej Mensfeld Date: Mon, 26 Jan 2026 14:45:43 +0100 Subject: [PATCH 1/2] extra linters --- CHANGELOG.md | 18 ++++ README.md | 12 ++- src/parsers/alex.ts | 118 +++++++++++++++++++++ src/parsers/ansible-lint.ts | 93 ++++++++++++++++ src/parsers/buf.ts | 165 +++++++++++++++++++++++++++++ src/parsers/commitlint.ts | 108 +++++++++++++++++++ src/parsers/eslint-flat.ts | 100 +++++++++++++++++ src/parsers/index.ts | 54 +++++++++- src/parsers/lefthook.ts | 148 ++++++++++++++++++++++++++ src/parsers/lint-staged.ts | 91 ++++++++++++++++ src/parsers/oxlint.ts | 83 +++++++++++++++ src/parsers/pre-commit.ts | 138 ++++++++++++++++++++++++ src/parsers/sqlfluff.ts | 109 +++++++++++++++++++ tests/parsers/alex.test.ts | 124 ++++++++++++++++++++++ tests/parsers/ansible-lint.test.ts | 100 +++++++++++++++++ tests/parsers/buf.test.ts | 124 ++++++++++++++++++++++ tests/parsers/commitlint.test.ts | 97 +++++++++++++++++ tests/parsers/eslint-flat.test.ts | 132 +++++++++++++++++++++++ tests/parsers/lefthook.test.ts | 136 ++++++++++++++++++++++++ tests/parsers/lint-staged.test.ts | 107 +++++++++++++++++++ tests/parsers/oxlint.test.ts | 102 ++++++++++++++++++ tests/parsers/pre-commit.test.ts | 124 ++++++++++++++++++++++ tests/parsers/sqlfluff.test.ts | 142 +++++++++++++++++++++++++ 23 files changed, 2423 insertions(+), 2 deletions(-) create mode 100644 src/parsers/alex.ts create mode 100644 src/parsers/ansible-lint.ts create mode 100644 src/parsers/buf.ts create mode 100644 src/parsers/commitlint.ts create mode 100644 src/parsers/eslint-flat.ts create mode 100644 src/parsers/lefthook.ts create mode 100644 src/parsers/lint-staged.ts create mode 100644 src/parsers/oxlint.ts create mode 100644 src/parsers/pre-commit.ts create mode 100644 src/parsers/sqlfluff.ts create mode 100644 tests/parsers/alex.test.ts create mode 100644 tests/parsers/ansible-lint.test.ts create mode 100644 tests/parsers/buf.test.ts create mode 100644 tests/parsers/commitlint.test.ts create mode 100644 tests/parsers/eslint-flat.test.ts create mode 100644 tests/parsers/lefthook.test.ts create mode 100644 tests/parsers/lint-staged.test.ts create mode 100644 tests/parsers/oxlint.test.ts create mode 100644 tests/parsers/pre-commit.test.ts create mode 100644 tests/parsers/sqlfluff.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7587194..fe476bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Support for 10 new popular linters and configuration tools: + - **ESLint Flat Config** (`eslint.config.json`) - New ESLint v9+ flat configuration format (JSON only) + - **Oxlint** (`.oxlintrc.json`) - Fast Rust-based JavaScript/TypeScript linter + - **commitlint** (`.commitlintrc`, `.commitlintrc.json`) - Commit message linting + - **lint-staged** (`.lintstagedrc`, `.lintstagedrc.json`) - Run linters on staged files + - **lefthook** (`lefthook.yml`, `.lefthook.yml`) - Fast Go-based git hooks manager + - **pre-commit** (`.pre-commit-config.yaml`) - Python-based git hooks framework + - **ansible-lint** (`.ansible-lint`, `.ansible-lint.yaml`) - Ansible playbook linter + - **SQLFluff** (`.sqlfluff`, `setup.cfg`) - SQL linter supporting 24+ dialects + - **buf** (`buf.yaml`, `buf.work.yaml`) - Protocol Buffers linter and generator + - **alex** (`.alexignore`, `.alexrc`, `.alexrc.json`) - Inclusive language linter +- Total supported configuration files increased from 48+ to 58+ +- Comprehensive test coverage with 100+ new tests for all new parsers + +### Changed +- Updated README with new linter documentation and usage examples + ## [0.2.1] - 2026-01-26 ### Fixed diff --git a/README.md b/README.md index 23a0611..db439cb 100644 --- a/README.md +++ b/README.md @@ -115,22 +115,27 @@ npx lostconf --exclude "**/test/**" --exclude "**/tests/**" ## Supported Config Files -lostconf supports **48+ configuration files** from popular tools across **15+ languages**: +lostconf supports **58+ configuration files** from popular tools across **15+ languages**: | Language/Category | Tool | Config File(s) | What We Check | |-------------------|------|----------------|---------------| | **JavaScript/TypeScript** | ESLint | `.eslintignore` | File paths and glob patterns in ignore list | +| | ESLint Flat Config | `eslint.config.json` | Patterns in `ignores` and `files` arrays (JSON only) | | | Prettier | `.prettierignore` | File paths and glob patterns in ignore list | | | TypeScript | `tsconfig.json` | Files in `exclude`, `include` arrays | | | Jest | `jest.config.json` | Test paths, coverage paths, module paths | | | Stylelint | `.stylelintignore`, `.stylelintrc.json` | File paths and glob patterns, ignore patterns in config | | | Biome | `biome.json`, `biome.jsonc` | Patterns in `files.ignore`, `linter.ignore`, `formatter.ignore` | | | Deno | `deno.json`, `deno.jsonc` | Global `exclude`, `lint.exclude/include`, `fmt.exclude/include`, `test.exclude/include` | +| | Oxlint | `.oxlintrc.json`, `oxlint.config.json` | Patterns in `ignorePatterns` array | +| | commitlint | `.commitlintrc`, `.commitlintrc.json` | Commit message patterns in `ignores`, file paths in `extends` | +| | lint-staged | `.lintstagedrc`, `.lintstagedrc.json` | Object keys are glob patterns matching staged files | | **Python** | pytest, coverage, mypy, ruff, black, isort | `pyproject.toml` | Test paths, source paths, exclude patterns, omit patterns | | | Flake8 | `.flake8`, `setup.cfg` | Exclude patterns, extend-exclude, filename patterns, per-file-ignores | | | Pylint | `.pylintrc`, `pylintrc` | Ignore paths, ignore patterns in `[MASTER]`/`[MAIN]` section | | | Bandit | `.bandit` | Exclude directories, exclude files, test paths | | | Pyright | `pyrightconfig.json` | `include`, `exclude`, `ignore`, `extraPaths` patterns | +| **SQL** | SQLFluff | `.sqlfluff`, `setup.cfg` | Exclude patterns, ignore patterns, template paths | | **Ruby** | RuboCop | `.rubocop.yml` | Exclude patterns, Include patterns in AllCops | | **Go** | golangci-lint | `.golangci.yml` | Skip-dirs, skip-files, exclude patterns | | **Rust** | rustfmt | `rustfmt.toml` | Ignore patterns | @@ -155,9 +160,14 @@ lostconf supports **48+ configuration files** from popular tools across **15+ la | **Security** | Semgrep | `.semgrep.yml`, `.semgrep.yaml`, `.semgrepignore` | `paths.exclude`, `paths.include` in rules, ignore patterns | | | Gitleaks | `.gitleaks.toml` | `allowlist.paths`, `allowlist.regexes`, rule-specific allowlists | | **Docker** | Hadolint | `.hadolint.yaml`, `.hadolint.yml` | `ignored` patterns, `trustedRegistries` (non-URL paths) | +| **DevOps** | ansible-lint | `.ansible-lint`, `.ansible-lint.yaml` | Patterns in `exclude_paths` list | +| **Protocol Buffers** | buf | `buf.yaml`, `buf.work.yaml` | Patterns in `lint.ignore`, `breaking.ignore`, workspace `directories` | +| **Git Hooks** | lefthook | `lefthook.yml`, `.lefthook.yml` | Patterns in `files`, `glob`, `exclude`, `skip` fields | +| | pre-commit | `.pre-commit-config.yaml` | Regex patterns in top-level and hook-level `files` and `exclude` | | **General** | Git | `.gitignore` | All file paths and patterns | | | Docker | `.dockerignore` | All file paths and patterns | | | markdownlint | `.markdownlintignore` | All file paths and patterns | +| **Documentation** | alex | `.alexignore`, `.alexrc`, `.alexrc.json` | Ignore patterns and allowed terms | ## What Does lostconf Validate? diff --git a/src/parsers/alex.ts b/src/parsers/alex.ts new file mode 100644 index 0000000..051ca3c --- /dev/null +++ b/src/parsers/alex.ts @@ -0,0 +1,118 @@ +/** + * Parser for alex (inclusive language linter) configuration files + * https://alexjs.com/ + */ + +import type { Pattern } from '../core/types.js'; +import { PatternType } from '../core/types.js'; +import type { Parser } from '../plugin/types.js'; +import { isGlobPattern } from '../validator/glob.js'; + +/** Parse .alexignore file (same format as .gitignore) */ +function parseAlexIgnore(_filename: string, content: string): Pattern[] { + const patterns: Pattern[] = []; + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + // Skip empty lines and comments + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + // Check for negation + let patternValue = trimmed; + let negated = false; + if (patternValue.startsWith('!')) { + negated = true; + patternValue = patternValue.slice(1); + } + + // Determine pattern type + const type = isGlobPattern(patternValue) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value: patternValue, + type, + line: lineNum, + column: 1, + negated + }); + } + + return patterns; +} + +/** Parse .alexrc/.alexrc.json configuration */ +function parseAlexRc(_filename: string, content: string): Pattern[] { + const patterns: Pattern[] = []; + + let config: { allow?: string[] }; + try { + config = JSON.parse(content); + } catch { + return patterns; + } + + if (!config || typeof config !== 'object') { + return patterns; + } + + const lineMap = buildLineMap(content); + + // Extract 'allow' patterns - these are words/phrases to allow + if (Array.isArray(config.allow)) { + for (const value of config.allow) { + if (typeof value !== 'string') continue; + const lineInfo = lineMap.get(value); + + // Allow patterns are typically simple strings (words/phrases), not file paths + // We'll treat them as PATH type for validation purposes + patterns.push({ + value, + type: PatternType.PATH, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + + return patterns; +} + +function buildLineMap(content: string): Map { + const map = new Map(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + const stringMatches = line.matchAll(/"([^"]+)"/g); + for (const match of stringMatches) { + const value = match[1]; + if (value && !map.has(value)) { + map.set(value, { line: lineNum, column: (match.index ?? 0) + 1 }); + } + } + } + + return map; +} + +/** alex ignore file parser */ +export const alexIgnoreParser: Parser = { + name: 'alexignore', + filePatterns: ['.alexignore', '**/.alexignore'], + parse: parseAlexIgnore +}; + +/** alex config file parser */ +export const alexRcParser: Parser = { + name: 'alexrc', + filePatterns: ['.alexrc', '.alexrc.json', '**/.alexrc', '**/.alexrc.json'], + parse: parseAlexRc +}; diff --git a/src/parsers/ansible-lint.ts b/src/parsers/ansible-lint.ts new file mode 100644 index 0000000..95c2fd6 --- /dev/null +++ b/src/parsers/ansible-lint.ts @@ -0,0 +1,93 @@ +/** + * Parser for ansible-lint configuration files + * https://ansible-lint.readthedocs.io/ + */ + +import { parse as parseYaml } from 'yaml'; +import type { Pattern } from '../core/types.js'; +import { PatternType } from '../core/types.js'; +import type { Parser } from '../plugin/types.js'; +import { isGlobPattern } from '../validator/glob.js'; + +interface AnsibleLintConfig { + exclude_paths?: string[]; + skip_list?: string[]; + warn_list?: string[]; + [key: string]: unknown; +} + +/** Parse .ansible-lint configuration */ +function parseAnsibleLint(_filename: string, content: string): Pattern[] { + const patterns: Pattern[] = []; + + let config: AnsibleLintConfig; + try { + config = parseYaml(content) as AnsibleLintConfig; + } catch { + return patterns; + } + + if (!config || typeof config !== 'object') { + return patterns; + } + + const lineMap = buildLineMap(content); + + // Extract exclude_paths (file paths and globs) + if (Array.isArray(config.exclude_paths)) { + for (const value of config.exclude_paths) { + if (typeof value !== 'string') continue; + + const lineInfo = lineMap.get(value); + const type = isGlobPattern(value) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + + // Note: skip_list and warn_list contain rule IDs, not file paths, so we skip them + + return patterns; +} + +function buildLineMap(content: string): Map { + const map = new Map(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + // Match YAML string values (quoted and unquoted) + const quotedMatches = line.matchAll(/["']([^"']+)["']/g); + for (const match of quotedMatches) { + const value = match[1]; + if (value && !map.has(value)) { + map.set(value, { line: lineNum, column: (match.index ?? 0) + 1 }); + } + } + + // Match unquoted list items + const listItemMatch = line.match(/^\s*-\s+([^\s#]+)/); + if (listItemMatch && listItemMatch[1]) { + const value = listItemMatch[1]; + if (!map.has(value)) { + map.set(value, { line: lineNum, column: (listItemMatch.index ?? 0) + 1 }); + } + } + } + + return map; +} + +/** ansible-lint config parser */ +export const ansibleLintParser: Parser = { + name: 'ansible-lint', + filePatterns: ['.ansible-lint', '.ansible-lint.yaml', '.ansible-lint.yml', '**/.ansible-lint'], + parse: parseAnsibleLint +}; diff --git a/src/parsers/buf.ts b/src/parsers/buf.ts new file mode 100644 index 0000000..ea799f3 --- /dev/null +++ b/src/parsers/buf.ts @@ -0,0 +1,165 @@ +/** + * Parser for buf configuration files + * https://buf.build/docs/ + */ + +import { parse as parseYaml } from 'yaml'; +import type { Pattern } from '../core/types.js'; +import { PatternType } from '../core/types.js'; +import type { Parser } from '../plugin/types.js'; +import { isGlobPattern } from '../validator/glob.js'; + +interface BufConfig { + version?: string; + lint?: { + use?: string[]; + except?: string[]; + ignore?: string[]; + ignore_only?: Record; + }; + breaking?: { + use?: string[]; + except?: string[]; + ignore?: string[]; + ignore_only?: Record; + }; + [key: string]: unknown; +} + +interface BufWorkConfig { + version?: string; + directories?: string[]; + [key: string]: unknown; +} + +/** Parse buf.yaml or buf.work.yaml configuration */ +function parseBuf(filename: string, content: string): Pattern[] { + const patterns: Pattern[] = []; + + let config: BufConfig | BufWorkConfig; + try { + config = parseYaml(content) as BufConfig | BufWorkConfig; + } catch { + return patterns; + } + + if (!config || typeof config !== 'object') { + return patterns; + } + + const lineMap = buildLineMap(content); + + // Handle buf.work.yaml (workspace config) + if ('directories' in config && Array.isArray(config.directories)) { + for (const value of config.directories) { + if (typeof value !== 'string') continue; + + const lineInfo = lineMap.get(value); + const type = isGlobPattern(value) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + + // Handle buf.yaml (module config) + const bufConfig = config as BufConfig; + + // Extract lint ignore patterns + if (bufConfig.lint?.ignore && Array.isArray(bufConfig.lint.ignore)) { + for (const value of bufConfig.lint.ignore) { + if (typeof value !== 'string') continue; + + const lineInfo = lineMap.get(value); + const type = isGlobPattern(value) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + + // Extract breaking ignore patterns + if (bufConfig.breaking?.ignore && Array.isArray(bufConfig.breaking.ignore)) { + for (const value of bufConfig.breaking.ignore) { + if (typeof value !== 'string') continue; + + const lineInfo = lineMap.get(value); + const type = isGlobPattern(value) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + + // Extract ignore_only patterns (per-rule ignores) + if (bufConfig.lint?.ignore_only && typeof bufConfig.lint.ignore_only === 'object') { + for (const paths of Object.values(bufConfig.lint.ignore_only)) { + if (Array.isArray(paths)) { + for (const value of paths) { + if (typeof value !== 'string') continue; + + const lineInfo = lineMap.get(value); + const type = isGlobPattern(value) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + } + } + + return patterns; +} + +function buildLineMap(content: string): Map { + const map = new Map(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + // Match YAML string values (quoted and unquoted) + const quotedMatches = line.matchAll(/["']([^"']+)["']/g); + for (const match of quotedMatches) { + const value = match[1]; + if (value && !map.has(value)) { + map.set(value, { line: lineNum, column: (match.index ?? 0) + 1 }); + } + } + + // Match unquoted list items + const listItemMatch = line.match(/^\s*-\s+([^\s#]+)/); + if (listItemMatch && listItemMatch[1]) { + const value = listItemMatch[1]; + if (!map.has(value)) { + map.set(value, { line: lineNum, column: (listItemMatch.index ?? 0) + 1 }); + } + } + } + + return map; +} + +/** buf config parser */ +export const bufParser: Parser = { + name: 'buf', + filePatterns: ['buf.yaml', 'buf.work.yaml', 'buf.gen.yaml', '**/buf.yaml', '**/buf.work.yaml'], + parse: parseBuf +}; diff --git a/src/parsers/commitlint.ts b/src/parsers/commitlint.ts new file mode 100644 index 0000000..6589ebb --- /dev/null +++ b/src/parsers/commitlint.ts @@ -0,0 +1,108 @@ +/** + * Parser for commitlint configuration files + * https://commitlint.js.org/ + */ + +import type { Pattern } from '../core/types.js'; +import { PatternType } from '../core/types.js'; +import type { Parser } from '../plugin/types.js'; +import { looksLikeRegex } from '../validator/regex.js'; + +interface CommitlintConfig { + ignores?: ((commit: string) => boolean | string)[]; + defaultIgnores?: boolean; + rules?: Record; + extends?: string[]; +} + +/** Parse commitlint config and extract patterns */ +function parseCommitlintConfig(_filename: string, content: string): Pattern[] { + const patterns: Pattern[] = []; + + let config: CommitlintConfig; + try { + config = JSON.parse(content) as CommitlintConfig; + } catch { + return patterns; + } + + if (!config || typeof config !== 'object') { + return patterns; + } + + const lineMap = buildLineMap(content); + + // Extract ignores patterns (regex strings for commit message patterns) + if (Array.isArray(config.ignores)) { + for (const value of config.ignores) { + // Ignores can be functions or strings in JS, but in JSON they'll be strings + if (typeof value !== 'string') continue; + const lineInfo = lineMap.get(value); + + // Commit message patterns are typically regexes + const type = looksLikeRegex(value) ? PatternType.REGEX : PatternType.PATH; + + patterns.push({ + value, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + + // Extract extends (package names or file paths) + if (Array.isArray(config.extends)) { + for (const value of config.extends) { + if (typeof value !== 'string') continue; + + // Skip npm package names (start with @) + if (value.startsWith('@') || value.startsWith('.') === false) { + continue; + } + + const lineInfo = lineMap.get(value); + + patterns.push({ + value, + type: PatternType.PATH, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + + return patterns; +} + +function buildLineMap(content: string): Map { + const map = new Map(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + const stringMatches = line.matchAll(/"([^"]+)"/g); + for (const match of stringMatches) { + const value = match[1]; + if (value && !map.has(value)) { + map.set(value, { line: lineNum, column: (match.index ?? 0) + 1 }); + } + } + } + + return map; +} + +/** commitlint config parser */ +export const commitlintParser: Parser = { + name: 'commitlint', + filePatterns: [ + '.commitlintrc', + '.commitlintrc.json', + '**/.commitlintrc', + '**/.commitlintrc.json' + ], + parse: parseCommitlintConfig +}; diff --git a/src/parsers/eslint-flat.ts b/src/parsers/eslint-flat.ts new file mode 100644 index 0000000..fdf81ec --- /dev/null +++ b/src/parsers/eslint-flat.ts @@ -0,0 +1,100 @@ +/** + * Parser for ESLint flat config files + * https://eslint.org/docs/latest/use/configure/configuration-files + * Note: Only supports JSON format for safety. JS/MJS/CJS require code execution. + */ + +import type { Pattern } from '../core/types.js'; +import { PatternType } from '../core/types.js'; +import type { Parser } from '../plugin/types.js'; +import { isGlobPattern } from '../validator/glob.js'; + +interface ESLintFlatConfig { + ignores?: string[]; + files?: string[]; + [key: string]: unknown; +} + +type ESLintFlatConfigArray = ESLintFlatConfig[]; + +/** Parse eslint.config.json and extract patterns */ +function parseESLintFlat(_filename: string, content: string): Pattern[] { + const patterns: Pattern[] = []; + + let config: ESLintFlatConfigArray | ESLintFlatConfig; + try { + config = JSON.parse(content); + } catch { + return patterns; + } + + // Config can be an array or a single object + const configs = Array.isArray(config) ? config : [config]; + + const lineMap = buildLineMap(content); + + for (const configObj of configs) { + if (!configObj || typeof configObj !== 'object') continue; + + // Extract ignores patterns + if (Array.isArray(configObj.ignores)) { + for (const value of configObj.ignores) { + if (typeof value !== 'string') continue; + const lineInfo = lineMap.get(value); + const type = isGlobPattern(value) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + + // Extract files patterns + if (Array.isArray(configObj.files)) { + for (const value of configObj.files) { + if (typeof value !== 'string') continue; + const lineInfo = lineMap.get(value); + const type = isGlobPattern(value) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + } + + return patterns; +} + +function buildLineMap(content: string): Map { + const map = new Map(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + const stringMatches = line.matchAll(/"([^"]+)"/g); + for (const match of stringMatches) { + const value = match[1]; + if (value && !map.has(value)) { + map.set(value, { line: lineNum, column: (match.index ?? 0) + 1 }); + } + } + } + + return map; +} + +/** ESLint flat config parser (JSON only) */ +export const eslintFlatParser: Parser = { + name: 'eslint-flat', + filePatterns: ['eslint.config.json', '**/eslint.config.json'], + parse: parseESLintFlat +}; diff --git a/src/parsers/index.ts b/src/parsers/index.ts index a603991..51c09a2 100644 --- a/src/parsers/index.ts +++ b/src/parsers/index.ts @@ -7,12 +7,16 @@ export { gitignoreParser, dockerignoreParser, createIgnoreParser } from './gitig // JavaScript/TypeScript export { eslintIgnoreParser } from './eslint.js'; +export { eslintFlatParser } from './eslint-flat.js'; export { prettierIgnoreParser } from './prettier.js'; export { tsconfigParser } from './typescript.js'; export { jestConfigParser } from './jest.js'; export { stylelintIgnoreParser, stylelintRcParser } from './stylelint.js'; export { biomeParser } from './biome.js'; export { denoParser } from './deno.js'; +export { oxlintParser } from './oxlint.js'; +export { commitlintParser } from './commitlint.js'; +export { lintStagedParser } from './lint-staged.js'; // Python export { pyprojectParser } from './pyproject.js'; @@ -73,17 +77,37 @@ export { gitleaksParser } from './gitleaks.js'; // Docker export { hadolintParser } from './hadolint.js'; +// Git Hooks +export { lefthookParser } from './lefthook.js'; +export { preCommitParser } from './pre-commit.js'; + +// DevOps +export { ansibleLintParser } from './ansible-lint.js'; + +// SQL +export { sqlfluffParser, sqlfluffSetupCfgParser } from './sqlfluff.js'; + +// Protocol Buffers +export { bufParser } from './buf.js'; + +// Documentation +export { alexIgnoreParser, alexRcParser } from './alex.js'; + import type { Parser } from '../plugin/types.js'; // Import all parsers for getBuiltinParsers import { gitignoreParser, dockerignoreParser } from './gitignore.js'; import { eslintIgnoreParser } from './eslint.js'; +import { eslintFlatParser } from './eslint-flat.js'; import { prettierIgnoreParser } from './prettier.js'; import { tsconfigParser } from './typescript.js'; import { jestConfigParser } from './jest.js'; import { stylelintIgnoreParser, stylelintRcParser } from './stylelint.js'; import { biomeParser } from './biome.js'; import { denoParser } from './deno.js'; +import { oxlintParser } from './oxlint.js'; +import { commitlintParser } from './commitlint.js'; +import { lintStagedParser } from './lint-staged.js'; import { pyprojectParser } from './pyproject.js'; import { flake8Parser, flake8SetupCfgParser } from './flake8.js'; import { pylintrcParser } from './pylint.js'; @@ -107,6 +131,12 @@ import { tflintParser } from './tflint.js'; import { semgrepYmlParser, semgrepIgnoreParser } from './semgrep.js'; import { gitleaksParser } from './gitleaks.js'; import { hadolintParser } from './hadolint.js'; +import { lefthookParser } from './lefthook.js'; +import { preCommitParser } from './pre-commit.js'; +import { ansibleLintParser } from './ansible-lint.js'; +import { sqlfluffParser, sqlfluffSetupCfgParser } from './sqlfluff.js'; +import { bufParser } from './buf.js'; +import { alexIgnoreParser, alexRcParser } from './alex.js'; /** Get all built-in parsers */ export function getBuiltinParsers(): Parser[] { @@ -117,6 +147,7 @@ export function getBuiltinParsers(): Parser[] { // JavaScript/TypeScript eslintIgnoreParser, + eslintFlatParser, prettierIgnoreParser, tsconfigParser, jestConfigParser, @@ -124,6 +155,9 @@ export function getBuiltinParsers(): Parser[] { stylelintRcParser, biomeParser, denoParser, + oxlintParser, + commitlintParser, + lintStagedParser, // Python pyprojectParser, @@ -191,6 +225,24 @@ export function getBuiltinParsers(): Parser[] { gitleaksParser, // Docker - hadolintParser + hadolintParser, + + // Git Hooks + lefthookParser, + preCommitParser, + + // DevOps + ansibleLintParser, + + // SQL + sqlfluffParser, + sqlfluffSetupCfgParser, + + // Protocol Buffers + bufParser, + + // Documentation + alexIgnoreParser, + alexRcParser ]; } diff --git a/src/parsers/lefthook.ts b/src/parsers/lefthook.ts new file mode 100644 index 0000000..4cd66e9 --- /dev/null +++ b/src/parsers/lefthook.ts @@ -0,0 +1,148 @@ +/** + * Parser for lefthook configuration files + * https://lefthook.dev/ + */ + +import { parse as parseYaml } from 'yaml'; +import type { Pattern } from '../core/types.js'; +import { PatternType } from '../core/types.js'; +import type { Parser } from '../plugin/types.js'; +import { isGlobPattern } from '../validator/glob.js'; + +interface LefthookHook { + files?: string; + glob?: string; + exclude?: string; + skip?: string | string[]; + commands?: Record; +} + +interface LefthookConfig { + skip_output?: string[]; + [hookName: string]: unknown; +} + +/** Parse lefthook.yml configuration */ +function parseLefthook(_filename: string, content: string): Pattern[] { + const patterns: Pattern[] = []; + + let config: LefthookConfig; + try { + config = parseYaml(content) as LefthookConfig; + } catch { + return patterns; + } + + if (!config || typeof config !== 'object') { + return patterns; + } + + const lineMap = buildLineMap(content); + + // Iterate through all hooks (pre-commit, pre-push, etc.) + for (const [key, value] of Object.entries(config)) { + // Skip top-level config keys + if (key === 'skip_output' || key === 'source_dir' || key === 'rc' || key === 'colors') { + continue; + } + + if (typeof value !== 'object' || value === null) { + continue; + } + + const hook = value as LefthookHook; + + // Extract file patterns from hook-level + if (hook.files) { + addPattern(patterns, hook.files, lineMap); + } + if (hook.glob) { + addPattern(patterns, hook.glob, lineMap); + } + if (hook.exclude) { + addPattern(patterns, hook.exclude, lineMap); + } + + // Extract skip patterns + if (hook.skip) { + const skipPatterns = Array.isArray(hook.skip) ? hook.skip : [hook.skip]; + for (const pattern of skipPatterns) { + if (typeof pattern === 'string') { + addPattern(patterns, pattern, lineMap); + } + } + } + + // Extract patterns from commands + if (hook.commands && typeof hook.commands === 'object') { + for (const command of Object.values(hook.commands)) { + if (typeof command === 'object' && command !== null) { + if (command.files) { + addPattern(patterns, command.files, lineMap); + } + if (command.glob) { + addPattern(patterns, command.glob, lineMap); + } + if (command.exclude) { + addPattern(patterns, command.exclude, lineMap); + } + } + } + } + } + + return patterns; +} + +function addPattern( + patterns: Pattern[], + value: string, + lineMap: Map +): void { + const lineInfo = lineMap.get(value); + const type = isGlobPattern(value) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); +} + +function buildLineMap(content: string): Map { + const map = new Map(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + // Match YAML string values (quoted and unquoted) + const quotedMatches = line.matchAll(/["']([^"']+)["']/g); + for (const match of quotedMatches) { + const value = match[1]; + if (value && !map.has(value)) { + map.set(value, { line: lineNum, column: (match.index ?? 0) + 1 }); + } + } + + // Match unquoted values after colon + const unquotedMatch = line.match(/:\s*([^\s#]+)/); + if (unquotedMatch && unquotedMatch[1]) { + const value = unquotedMatch[1]; + if (!map.has(value)) { + map.set(value, { line: lineNum, column: (unquotedMatch.index ?? 0) + 1 }); + } + } + } + + return map; +} + +/** lefthook config parser */ +export const lefthookParser: Parser = { + name: 'lefthook', + filePatterns: ['lefthook.yml', '.lefthook.yml', 'lefthook-local.yml', '**/lefthook.yml'], + parse: parseLefthook +}; diff --git a/src/parsers/lint-staged.ts b/src/parsers/lint-staged.ts new file mode 100644 index 0000000..6738b34 --- /dev/null +++ b/src/parsers/lint-staged.ts @@ -0,0 +1,91 @@ +/** + * Parser for lint-staged configuration files + * https://github.com/lint-staged/lint-staged + */ + +import type { Pattern } from '../core/types.js'; +import { PatternType } from '../core/types.js'; +import type { Parser } from '../plugin/types.js'; +import { isGlobPattern } from '../validator/glob.js'; + +type LintStagedConfig = Record; + +/** Parse lint-staged config and extract patterns */ +function parseLintStagedConfig(_filename: string, content: string): Pattern[] { + const patterns: Pattern[] = []; + + let config: LintStagedConfig; + try { + config = JSON.parse(content) as LintStagedConfig; + } catch { + return patterns; + } + + if (!config || typeof config !== 'object') { + return patterns; + } + + const lineMap = buildLineMap(content); + + // In lint-staged, the keys are glob patterns matching files + // The values are commands to run (which we don't need to validate) + for (const key of Object.keys(config)) { + // Skip non-pattern keys (like $schema, etc.) + if (key.startsWith('$')) { + continue; + } + + const lineInfo = lineMap.get(key); + const type = isGlobPattern(key) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value: key, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + + return patterns; +} + +function buildLineMap(content: string): Map { + const map = new Map(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + // Match both property names and string values + const keyMatches = line.matchAll(/"([^"]+)":/g); + for (const match of keyMatches) { + const value = match[1]; + if (value && !map.has(value)) { + map.set(value, { line: lineNum, column: (match.index ?? 0) + 1 }); + } + } + + const stringMatches = line.matchAll(/"([^"]+)"/g); + for (const match of stringMatches) { + const value = match[1]; + if (value && !map.has(value)) { + map.set(value, { line: lineNum, column: (match.index ?? 0) + 1 }); + } + } + } + + return map; +} + +/** lint-staged config parser */ +export const lintStagedParser: Parser = { + name: 'lint-staged', + filePatterns: [ + '.lintstagedrc', + '.lintstagedrc.json', + '**/.lintstagedrc', + '**/.lintstagedrc.json' + ], + parse: parseLintStagedConfig +}; diff --git a/src/parsers/oxlint.ts b/src/parsers/oxlint.ts new file mode 100644 index 0000000..6d116a8 --- /dev/null +++ b/src/parsers/oxlint.ts @@ -0,0 +1,83 @@ +/** + * Parser for oxlint configuration files + * https://oxc.rs/docs/guide/usage/linter.html + */ + +import type { Pattern } from '../core/types.js'; +import { PatternType } from '../core/types.js'; +import type { Parser } from '../plugin/types.js'; +import { isGlobPattern } from '../validator/glob.js'; + +interface OxlintConfig { + ignorePatterns?: string[]; + rules?: Record; + plugins?: string[]; +} + +/** Parse .oxlintrc.json and extract patterns */ +function parseOxlintConfig(_filename: string, content: string): Pattern[] { + const patterns: Pattern[] = []; + + let config: OxlintConfig; + try { + config = JSON.parse(content) as OxlintConfig; + } catch { + return patterns; + } + + if (!config || typeof config !== 'object') { + return patterns; + } + + const lineMap = buildLineMap(content); + + // Extract ignorePatterns + if (Array.isArray(config.ignorePatterns)) { + for (const value of config.ignorePatterns) { + if (typeof value !== 'string') continue; + const lineInfo = lineMap.get(value); + const type = isGlobPattern(value) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + + return patterns; +} + +function buildLineMap(content: string): Map { + const map = new Map(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + const stringMatches = line.matchAll(/"([^"]+)"/g); + for (const match of stringMatches) { + const value = match[1]; + if (value && !map.has(value)) { + map.set(value, { line: lineNum, column: (match.index ?? 0) + 1 }); + } + } + } + + return map; +} + +/** Oxlint config parser */ +export const oxlintParser: Parser = { + name: 'oxlint', + filePatterns: [ + '.oxlintrc.json', + 'oxlint.config.json', + '**/.oxlintrc.json', + '**/oxlint.config.json' + ], + parse: parseOxlintConfig +}; diff --git a/src/parsers/pre-commit.ts b/src/parsers/pre-commit.ts new file mode 100644 index 0000000..ceea516 --- /dev/null +++ b/src/parsers/pre-commit.ts @@ -0,0 +1,138 @@ +/** + * Parser for pre-commit configuration files + * https://pre-commit.com/ + */ + +import { parse as parseYaml } from 'yaml'; +import type { Pattern } from '../core/types.js'; +import { PatternType } from '../core/types.js'; +import type { Parser } from '../plugin/types.js'; +import { looksLikeRegex } from '../validator/regex.js'; + +interface PreCommitHook { + id: string; + files?: string; + exclude?: string; + types?: string[]; + exclude_types?: string[]; +} + +interface PreCommitRepo { + repo: string; + hooks: PreCommitHook[]; +} + +interface PreCommitConfig { + repos?: PreCommitRepo[]; + exclude?: string; + files?: string; + default_language_version?: Record; +} + +/** Parse .pre-commit-config.yaml */ +function parsePreCommit(_filename: string, content: string): Pattern[] { + const patterns: Pattern[] = []; + + let config: PreCommitConfig; + try { + config = parseYaml(content) as PreCommitConfig; + } catch { + return patterns; + } + + if (!config || typeof config !== 'object') { + return patterns; + } + + const lineMap = buildLineMap(content); + + // Extract top-level exclude pattern (regex) + if (config.exclude) { + addPattern(patterns, config.exclude, lineMap); + } + + // Extract top-level files pattern (regex) + if (config.files) { + addPattern(patterns, config.files, lineMap); + } + + // Extract patterns from repo hooks + if (Array.isArray(config.repos)) { + for (const repo of config.repos) { + if (!repo || typeof repo !== 'object') continue; + + if (Array.isArray(repo.hooks)) { + for (const hook of repo.hooks) { + if (!hook || typeof hook !== 'object') continue; + + // Extract files pattern (regex) + if (hook.files) { + addPattern(patterns, hook.files, lineMap); + } + + // Extract exclude pattern (regex) + if (hook.exclude) { + addPattern(patterns, hook.exclude, lineMap); + } + } + } + } + } + + return patterns; +} + +function addPattern( + patterns: Pattern[], + value: string, + lineMap: Map +): void { + const lineInfo = lineMap.get(value); + + // pre-commit uses regex patterns for files/exclude + const type = looksLikeRegex(value) ? PatternType.REGEX : PatternType.PATH; + + patterns.push({ + value, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); +} + +function buildLineMap(content: string): Map { + const map = new Map(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + // Match YAML string values (quoted and unquoted) + const quotedMatches = line.matchAll(/["']([^"']+)["']/g); + for (const match of quotedMatches) { + const value = match[1]; + if (value && !map.has(value)) { + map.set(value, { line: lineNum, column: (match.index ?? 0) + 1 }); + } + } + + // Match unquoted values after colon + const unquotedMatch = line.match(/:\s*([^\s#]+)/); + if (unquotedMatch && unquotedMatch[1]) { + const value = unquotedMatch[1]; + if (!map.has(value)) { + map.set(value, { line: lineNum, column: (unquotedMatch.index ?? 0) + 1 }); + } + } + } + + return map; +} + +/** pre-commit config parser */ +export const preCommitParser: Parser = { + name: 'pre-commit', + filePatterns: ['.pre-commit-config.yaml', '.pre-commit-config.yml', '**/.pre-commit-config.yaml'], + parse: parsePreCommit +}; diff --git a/src/parsers/sqlfluff.ts b/src/parsers/sqlfluff.ts new file mode 100644 index 0000000..d3ae08a --- /dev/null +++ b/src/parsers/sqlfluff.ts @@ -0,0 +1,109 @@ +/** + * Parser for sqlfluff configuration files + * https://docs.sqlfluff.com/ + */ + +import type { Pattern } from '../core/types.js'; +import { PatternType } from '../core/types.js'; +import type { Parser } from '../plugin/types.js'; +import { isGlobPattern } from '../validator/glob.js'; + +/** Parse .sqlfluff or setup.cfg file */ +function parseSqlfluff(filename: string, content: string): Pattern[] { + const patterns: Pattern[] = []; + const lines = content.split('\n'); + + let inSqlfluffSection = false; + let currentKey = ''; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + const trimmed = line.trim(); + + // Check for [sqlfluff] or [sqlfluff:*] sections + if (trimmed === '[sqlfluff]' || trimmed.startsWith('[sqlfluff:')) { + inSqlfluffSection = true; + continue; + } + + // Check if we've entered a different section + if (trimmed.startsWith('[') && trimmed.endsWith(']') && !trimmed.startsWith('[sqlfluff')) { + inSqlfluffSection = false; + continue; + } + + if (!inSqlfluffSection) continue; + + // Skip empty lines and comments + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith(';')) { + continue; + } + + // Parse key = value + if (trimmed.includes('=')) { + const [key, value] = trimmed.split('=').map((s) => s.trim()); + currentKey = key; + + // Check if this is a path-related field + if (isPathField(key) && value) { + extractPatterns(value, lineNum, patterns); + } + } else if (currentKey && isPathField(currentKey)) { + // Continuation line + extractPatterns(trimmed, lineNum, patterns); + } + } + + return patterns; +} + +/** Check if a key is a path-related field */ +function isPathField(key: string): boolean { + return [ + 'exclude_rules', + 'ignore', + 'ignore_templated_areas', + 'template_path', + 'library_path', + 'sql_file_exts' + ].includes(key); +} + +/** Extract patterns from a value string */ +function extractPatterns(value: string, line: number, patterns: Pattern[]): void { + // Split by comma or newline + const parts = value + .split(/[,\n]/) + .map((s) => s.trim()) + .filter((s) => s && !s.startsWith('#') && !s.startsWith(';')); + + for (const part of parts) { + // Skip empty parts + if (!part) continue; + + // Determine type + const type = isGlobPattern(part) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value: part, + type, + line, + column: 1 + }); + } +} + +/** sqlfluff .sqlfluff file parser */ +export const sqlfluffParser: Parser = { + name: 'sqlfluff', + filePatterns: ['.sqlfluff', '**/.sqlfluff'], + parse: parseSqlfluff +}; + +/** sqlfluff setup.cfg parser */ +export const sqlfluffSetupCfgParser: Parser = { + name: 'sqlfluff-setup', + filePatterns: ['setup.cfg', '**/setup.cfg'], + parse: parseSqlfluff +}; diff --git a/tests/parsers/alex.test.ts b/tests/parsers/alex.test.ts new file mode 100644 index 0000000..84df986 --- /dev/null +++ b/tests/parsers/alex.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { alexIgnoreParser, alexRcParser } from '../../src/parsers/alex.js'; +import { PatternType } from '../../src/core/types.js'; + +describe('alexIgnoreParser', () => { + it('should have correct name and patterns', () => { + expect(alexIgnoreParser.name).toBe('alexignore'); + expect(alexIgnoreParser.filePatterns).toContain('.alexignore'); + }); + + it('should parse simple paths', () => { + const content = ` +node_modules +dist +coverage +`; + const patterns = alexIgnoreParser.parse('.alexignore', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('node_modules'); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[1].value).toBe('dist'); + expect(patterns[2].value).toBe('coverage'); + }); + + it('should parse glob patterns', () => { + const content = ` +*.md +docs/**/*.txt +`; + const patterns = alexIgnoreParser.parse('.alexignore', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].type).toBe(PatternType.GLOB); + expect(patterns[0].value).toBe('*.md'); + expect(patterns[1].type).toBe(PatternType.GLOB); + expect(patterns[1].value).toBe('docs/**/*.txt'); + }); + + it('should handle negated patterns', () => { + const content = ` +*.md +!README.md +`; + const patterns = alexIgnoreParser.parse('.alexignore', content); + + expect(patterns).toHaveLength(2); + expect(patterns[1].negated).toBe(true); + expect(patterns[1].value).toBe('README.md'); + }); + + it('should skip comments and empty lines', () => { + const content = ` +# This is a comment +*.md + +# Another comment +docs/ +`; + const patterns = alexIgnoreParser.parse('.alexignore', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('*.md'); + expect(patterns[1].value).toBe('docs/'); + }); +}); + +describe('alexRcParser', () => { + it('should have correct name and patterns', () => { + expect(alexRcParser.name).toBe('alexrc'); + expect(alexRcParser.filePatterns).toContain('.alexrc'); + expect(alexRcParser.filePatterns).toContain('.alexrc.json'); + }); + + it('should parse allow list', () => { + const content = `{ + "allow": ["boogeyman", "garbageman", "mailman"] +}`; + const patterns = alexRcParser.parse('.alexrc', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('boogeyman'); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[1].value).toBe('garbageman'); + expect(patterns[2].value).toBe('mailman'); + }); + + it('should handle empty config', () => { + const content = '{}'; + const patterns = alexRcParser.parse('.alexrc', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle config without allow field', () => { + const content = `{ + "profanitySureness": 1 +}`; + const patterns = alexRcParser.parse('.alexrc', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle invalid JSON', () => { + const content = 'not valid json'; + const patterns = alexRcParser.parse('.alexrc', content); + + expect(patterns).toHaveLength(0); + }); + + it('should track line numbers', () => { + const content = `{ + "allow": [ + "boogeyman", + "garbageman" + ] +}`; + const patterns = alexRcParser.parse('.alexrc', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].line).toBe(3); + expect(patterns[1].line).toBe(4); + }); +}); diff --git a/tests/parsers/ansible-lint.test.ts b/tests/parsers/ansible-lint.test.ts new file mode 100644 index 0000000..ebc2de7 --- /dev/null +++ b/tests/parsers/ansible-lint.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { ansibleLintParser } from '../../src/parsers/ansible-lint.js'; +import { PatternType } from '../../src/core/types.js'; + +describe('ansibleLintParser', () => { + it('should have correct name and patterns', () => { + expect(ansibleLintParser.name).toBe('ansible-lint'); + expect(ansibleLintParser.filePatterns).toContain('.ansible-lint'); + expect(ansibleLintParser.filePatterns).toContain('.ansible-lint.yaml'); + }); + + it('should parse exclude_paths with simple paths', () => { + const content = ` +exclude_paths: + - .cache/ + - test/fixtures/ + - roles/vendor/ +`; + const patterns = ansibleLintParser.parse('.ansible-lint', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('.cache/'); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[1].value).toBe('test/fixtures/'); + expect(patterns[2].value).toBe('roles/vendor/'); + }); + + it('should parse exclude_paths with glob patterns', () => { + const content = ` +exclude_paths: + - '*.retry' + - 'roles/*/files/**' + - 'playbooks/legacy/**/*.yml' +`; + const patterns = ansibleLintParser.parse('.ansible-lint', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].type).toBe(PatternType.GLOB); + expect(patterns[0].value).toBe('*.retry'); + expect(patterns[1].type).toBe(PatternType.GLOB); + expect(patterns[2].type).toBe(PatternType.GLOB); + }); + + it('should skip skip_list (rule IDs, not paths)', () => { + const content = ` +skip_list: + - yaml[line-length] + - name[casing] +exclude_paths: + - .cache/ +`; + const patterns = ansibleLintParser.parse('.ansible-lint', content); + + expect(patterns).toHaveLength(1); + expect(patterns[0].value).toBe('.cache/'); + }); + + it('should handle empty config', () => { + const content = ''; + const patterns = ansibleLintParser.parse('.ansible-lint', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle config without exclude_paths', () => { + const content = ` +skip_list: + - yaml[line-length] +warn_list: + - experimental +`; + const patterns = ansibleLintParser.parse('.ansible-lint', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle invalid YAML', () => { + const content = 'not: valid: yaml:'; + const patterns = ansibleLintParser.parse('.ansible-lint', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle mixed paths and globs', () => { + const content = ` +exclude_paths: + - .git/ + - '*.retry' + - roles/vendor/ + - 'test/**/*.yml' +`; + const patterns = ansibleLintParser.parse('.ansible-lint', content); + + expect(patterns).toHaveLength(4); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[1].type).toBe(PatternType.GLOB); + expect(patterns[2].type).toBe(PatternType.PATH); + expect(patterns[3].type).toBe(PatternType.GLOB); + }); +}); diff --git a/tests/parsers/buf.test.ts b/tests/parsers/buf.test.ts new file mode 100644 index 0000000..dac68cf --- /dev/null +++ b/tests/parsers/buf.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { bufParser } from '../../src/parsers/buf.js'; +import { PatternType } from '../../src/core/types.js'; + +describe('bufParser', () => { + it('should have correct name and patterns', () => { + expect(bufParser.name).toBe('buf'); + expect(bufParser.filePatterns).toContain('buf.yaml'); + expect(bufParser.filePatterns).toContain('buf.work.yaml'); + }); + + it('should parse lint ignore patterns', () => { + const content = ` +version: v1 +lint: + ignore: + - proto/legacy + - proto/deprecated +`; + const patterns = bufParser.parse('buf.yaml', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('proto/legacy'); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[1].value).toBe('proto/deprecated'); + }); + + it('should parse breaking ignore patterns', () => { + const content = ` +version: v1 +breaking: + ignore: + - proto/experimental + - proto/v1alpha +`; + const patterns = bufParser.parse('buf.yaml', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('proto/experimental'); + expect(patterns[1].value).toBe('proto/v1alpha'); + }); + + it('should parse ignore_only patterns', () => { + const content = ` +version: v1 +lint: + ignore_only: + ENUM_ZERO_VALUE_SUFFIX: + - proto/legacy/old.proto + FIELD_LOWER_SNAKE_CASE: + - proto/v1/types.proto +`; + const patterns = bufParser.parse('buf.yaml', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('proto/legacy/old.proto'); + expect(patterns[1].value).toBe('proto/v1/types.proto'); + }); + + it('should parse workspace directories', () => { + const content = ` +version: v1 +directories: + - proto + - vendor/proto + - third_party +`; + const patterns = bufParser.parse('buf.work.yaml', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('proto'); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[1].value).toBe('vendor/proto'); + expect(patterns[2].value).toBe('third_party'); + }); + + it('should handle glob patterns', () => { + const content = ` +version: v1 +lint: + ignore: + - 'proto/**/*_test.proto' + - '*.deprecated.proto' +`; + const patterns = bufParser.parse('buf.yaml', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].type).toBe(PatternType.GLOB); + expect(patterns[1].type).toBe(PatternType.GLOB); + }); + + it('should handle empty config', () => { + const content = ` +version: v1 +`; + const patterns = bufParser.parse('buf.yaml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle invalid YAML', () => { + const content = 'not: valid: yaml:'; + const patterns = bufParser.parse('buf.yaml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should parse mixed lint and breaking ignores', () => { + const content = ` +version: v1 +lint: + ignore: + - proto/legacy +breaking: + ignore: + - proto/experimental +`; + const patterns = bufParser.parse('buf.yaml', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('proto/legacy'); + expect(patterns[1].value).toBe('proto/experimental'); + }); +}); diff --git a/tests/parsers/commitlint.test.ts b/tests/parsers/commitlint.test.ts new file mode 100644 index 0000000..56f4554 --- /dev/null +++ b/tests/parsers/commitlint.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { commitlintParser } from '../../src/parsers/commitlint.js'; +import { PatternType } from '../../src/core/types.js'; + +describe('commitlintParser', () => { + it('should have correct name and patterns', () => { + expect(commitlintParser.name).toBe('commitlint'); + expect(commitlintParser.filePatterns).toContain('.commitlintrc'); + expect(commitlintParser.filePatterns).toContain('.commitlintrc.json'); + }); + + it('should parse ignores with regex patterns', () => { + const content = `{ + "ignores": [ + "^WIP:", + "^Merge branch", + "^Revert" + ] +}`; + const patterns = commitlintParser.parse('.commitlintrc.json', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('^WIP:'); + expect(patterns[0].type).toBe(PatternType.REGEX); + expect(patterns[1].value).toBe('^Merge branch'); + expect(patterns[2].value).toBe('^Revert'); + }); + + it('should parse extends with file paths', () => { + const content = `{ + "extends": [ + "./custom-config.js", + "../shared/commitlint.config.js" + ] +}`; + const patterns = commitlintParser.parse('.commitlintrc.json', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('./custom-config.js'); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[1].value).toBe('../shared/commitlint.config.js'); + }); + + it('should skip npm package names in extends', () => { + const content = `{ + "extends": [ + "@commitlint/config-conventional", + "@commitlint/config-angular" + ] +}`; + const patterns = commitlintParser.parse('.commitlintrc.json', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle empty config', () => { + const content = '{}'; + const patterns = commitlintParser.parse('.commitlintrc.json', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle invalid JSON', () => { + const content = 'not valid json'; + const patterns = commitlintParser.parse('.commitlintrc.json', content); + + expect(patterns).toHaveLength(0); + }); + + it('should track line numbers', () => { + const content = `{ + "ignores": [ + "^WIP:", + "^Merge" + ] +}`; + const patterns = commitlintParser.parse('.commitlintrc.json', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].line).toBe(3); + expect(patterns[1].line).toBe(4); + }); + + it('should handle mixed extends with packages and paths', () => { + const content = `{ + "extends": [ + "@commitlint/config-conventional", + "./local-config.js" + ] +}`; + const patterns = commitlintParser.parse('.commitlintrc.json', content); + + expect(patterns).toHaveLength(1); + expect(patterns[0].value).toBe('./local-config.js'); + expect(patterns[0].type).toBe(PatternType.PATH); + }); +}); diff --git a/tests/parsers/eslint-flat.test.ts b/tests/parsers/eslint-flat.test.ts new file mode 100644 index 0000000..5b08975 --- /dev/null +++ b/tests/parsers/eslint-flat.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; +import { eslintFlatParser } from '../../src/parsers/eslint-flat.js'; +import { PatternType } from '../../src/core/types.js'; + +describe('eslintFlatParser', () => { + it('should have correct name and patterns', () => { + expect(eslintFlatParser.name).toBe('eslint-flat'); + expect(eslintFlatParser.filePatterns).toContain('eslint.config.json'); + }); + + it('should parse top-level ignores array', () => { + const content = `{ + "ignores": [ + "dist", + "node_modules", + "coverage" + ] +}`; + const patterns = eslintFlatParser.parse('eslint.config.json', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('dist'); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[1].value).toBe('node_modules'); + expect(patterns[2].value).toBe('coverage'); + }); + + it('should parse ignores with glob patterns', () => { + const content = `{ + "ignores": [ + "**/*.min.js", + "src/**/*.test.ts", + "*.config.js" + ] +}`; + const patterns = eslintFlatParser.parse('eslint.config.json', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].type).toBe(PatternType.GLOB); + expect(patterns[0].value).toBe('**/*.min.js'); + expect(patterns[1].type).toBe(PatternType.GLOB); + expect(patterns[2].type).toBe(PatternType.GLOB); + }); + + it('should parse files patterns', () => { + const content = `{ + "files": [ + "src/**/*.js", + "lib/**/*.ts" + ] +}`; + const patterns = eslintFlatParser.parse('eslint.config.json', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].type).toBe(PatternType.GLOB); + expect(patterns[0].value).toBe('src/**/*.js'); + expect(patterns[1].type).toBe(PatternType.GLOB); + }); + + it('should parse config array with multiple objects', () => { + const content = `[ + { + "ignores": ["dist", "node_modules"] + }, + { + "files": ["src/**/*.js"], + "ignores": ["src/**/*.test.js"] + } +]`; + const patterns = eslintFlatParser.parse('eslint.config.json', content); + + expect(patterns).toHaveLength(4); + expect(patterns[0].value).toBe('dist'); + expect(patterns[1].value).toBe('node_modules'); + // Note: ignores are processed before files in each config object + expect(patterns[2].value).toBe('src/**/*.test.js'); + expect(patterns[3].value).toBe('src/**/*.js'); + }); + + it('should handle empty config', () => { + const content = '{}'; + const patterns = eslintFlatParser.parse('eslint.config.json', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle config without ignores or files', () => { + const content = `{ + "languageOptions": { + "ecmaVersion": 2022 + } +}`; + const patterns = eslintFlatParser.parse('eslint.config.json', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle invalid JSON', () => { + const content = 'not valid json'; + const patterns = eslintFlatParser.parse('eslint.config.json', content); + + expect(patterns).toHaveLength(0); + }); + + it('should track line numbers', () => { + const content = `{ + "ignores": [ + "dist", + "node_modules" + ] +}`; + const patterns = eslintFlatParser.parse('eslint.config.json', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].line).toBe(3); + expect(patterns[1].line).toBe(4); + }); + + it('should handle both files and ignores in same config', () => { + const content = `{ + "files": ["src/**/*.js"], + "ignores": ["src/**/*.test.js", "src/**/*.spec.js"] +}`; + const patterns = eslintFlatParser.parse('eslint.config.json', content); + + expect(patterns).toHaveLength(3); + // Note: ignores are processed before files + expect(patterns[0].value).toBe('src/**/*.test.js'); + expect(patterns[1].value).toBe('src/**/*.spec.js'); + expect(patterns[2].value).toBe('src/**/*.js'); + }); +}); diff --git a/tests/parsers/lefthook.test.ts b/tests/parsers/lefthook.test.ts new file mode 100644 index 0000000..445e89f --- /dev/null +++ b/tests/parsers/lefthook.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest'; +import { lefthookParser } from '../../src/parsers/lefthook.js'; +import { PatternType } from '../../src/core/types.js'; + +describe('lefthookParser', () => { + it('should have correct name and patterns', () => { + expect(lefthookParser.name).toBe('lefthook'); + expect(lefthookParser.filePatterns).toContain('lefthook.yml'); + expect(lefthookParser.filePatterns).toContain('.lefthook.yml'); + }); + + it('should parse files patterns from hooks', () => { + const content = ` +pre-commit: + commands: + lint: + files: "*.js" + format: + files: "*.ts" +`; + const patterns = lefthookParser.parse('lefthook.yml', content); + + expect(patterns.length).toBeGreaterThanOrEqual(2); + const jsPattern = patterns.find((p) => p.value === '*.js'); + const tsPattern = patterns.find((p) => p.value === '*.ts'); + expect(jsPattern).toBeDefined(); + expect(jsPattern?.type).toBe(PatternType.GLOB); + expect(tsPattern).toBeDefined(); + expect(tsPattern?.type).toBe(PatternType.GLOB); + }); + + it('should parse glob patterns', () => { + const content = ` +pre-commit: + commands: + lint: + glob: "src/**/*.{js,ts}" +`; + const patterns = lefthookParser.parse('lefthook.yml', content); + + const pattern = patterns.find((p) => p.value === 'src/**/*.{js,ts}'); + expect(pattern).toBeDefined(); + expect(pattern?.type).toBe(PatternType.GLOB); + }); + + it('should parse exclude patterns', () => { + const content = ` +pre-commit: + commands: + lint: + exclude: "node_modules" +`; + const patterns = lefthookParser.parse('lefthook.yml', content); + + const pattern = patterns.find((p) => p.value === 'node_modules'); + expect(pattern).toBeDefined(); + }); + + it('should parse skip patterns as array', () => { + const content = ` +pre-commit: + skip: + - merge + - rebase +`; + const patterns = lefthookParser.parse('lefthook.yml', content); + + const mergePattern = patterns.find((p) => p.value === 'merge'); + const rebasePattern = patterns.find((p) => p.value === 'rebase'); + expect(mergePattern).toBeDefined(); + expect(rebasePattern).toBeDefined(); + }); + + it('should parse skip patterns as string', () => { + const content = ` +pre-commit: + skip: merge +`; + const patterns = lefthookParser.parse('lefthook.yml', content); + + const pattern = patterns.find((p) => p.value === 'merge'); + expect(pattern).toBeDefined(); + }); + + it('should handle empty config', () => { + const content = ''; + const patterns = lefthookParser.parse('lefthook.yml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle invalid YAML', () => { + const content = 'not: valid: yaml:'; + const patterns = lefthookParser.parse('lefthook.yml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should parse multiple hooks', () => { + const content = ` +pre-commit: + commands: + lint: + files: "*.js" + +pre-push: + commands: + test: + files: "*.test.js" +`; + const patterns = lefthookParser.parse('lefthook.yml', content); + + expect(patterns.length).toBeGreaterThanOrEqual(2); + const jsPattern = patterns.find((p) => p.value === '*.js'); + const testPattern = patterns.find((p) => p.value === '*.test.js'); + expect(jsPattern).toBeDefined(); + expect(testPattern).toBeDefined(); + }); + + it('should handle hook with both files and glob', () => { + const content = ` +pre-commit: + commands: + lint: + files: "*.js" + glob: "src/**/*.ts" +`; + const patterns = lefthookParser.parse('lefthook.yml', content); + + expect(patterns.length).toBeGreaterThanOrEqual(2); + const jsPattern = patterns.find((p) => p.value === '*.js'); + const tsPattern = patterns.find((p) => p.value === 'src/**/*.ts'); + expect(jsPattern).toBeDefined(); + expect(tsPattern).toBeDefined(); + }); +}); diff --git a/tests/parsers/lint-staged.test.ts b/tests/parsers/lint-staged.test.ts new file mode 100644 index 0000000..d5ee1b9 --- /dev/null +++ b/tests/parsers/lint-staged.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { lintStagedParser } from '../../src/parsers/lint-staged.js'; +import { PatternType } from '../../src/core/types.js'; + +describe('lintStagedParser', () => { + it('should have correct name and patterns', () => { + expect(lintStagedParser.name).toBe('lint-staged'); + expect(lintStagedParser.filePatterns).toContain('.lintstagedrc'); + expect(lintStagedParser.filePatterns).toContain('.lintstagedrc.json'); + }); + + it('should parse glob patterns as keys', () => { + const content = `{ + "*.js": "eslint --fix", + "*.ts": ["eslint --fix", "prettier --write"], + "*.css": "stylelint --fix" +}`; + const patterns = lintStagedParser.parse('.lintstagedrc.json', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('*.js'); + expect(patterns[0].type).toBe(PatternType.GLOB); + expect(patterns[1].value).toBe('*.ts'); + expect(patterns[1].type).toBe(PatternType.GLOB); + expect(patterns[2].value).toBe('*.css'); + expect(patterns[2].type).toBe(PatternType.GLOB); + }); + + it('should parse complex glob patterns', () => { + const content = `{ + "src/**/*.{js,jsx,ts,tsx}": "eslint", + "**/*.test.js": "jest", + "!(node_modules)/**/*.js": "prettier" +}`; + const patterns = lintStagedParser.parse('.lintstagedrc.json', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].type).toBe(PatternType.GLOB); + expect(patterns[0].value).toBe('src/**/*.{js,jsx,ts,tsx}'); + expect(patterns[1].type).toBe(PatternType.GLOB); + expect(patterns[2].type).toBe(PatternType.GLOB); + }); + + it('should handle empty config', () => { + const content = '{}'; + const patterns = lintStagedParser.parse('.lintstagedrc.json', content); + + expect(patterns).toHaveLength(0); + }); + + it('should skip special keys like $schema', () => { + const content = `{ + "$schema": "https://json.schemastore.org/lintstagedrc.json", + "*.js": "eslint" +}`; + const patterns = lintStagedParser.parse('.lintstagedrc.json', content); + + expect(patterns).toHaveLength(1); + expect(patterns[0].value).toBe('*.js'); + }); + + it('should handle invalid JSON', () => { + const content = 'not valid json'; + const patterns = lintStagedParser.parse('.lintstagedrc.json', content); + + expect(patterns).toHaveLength(0); + }); + + it('should track line numbers', () => { + const content = `{ + "*.js": "eslint", + "*.ts": "eslint", + "*.css": "stylelint" +}`; + const patterns = lintStagedParser.parse('.lintstagedrc.json', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].line).toBe(2); + expect(patterns[1].line).toBe(3); + expect(patterns[2].line).toBe(4); + }); + + it('should handle both string and array command values', () => { + const content = `{ + "*.js": "eslint --fix", + "*.ts": ["eslint --fix", "prettier --write"] +}`; + const patterns = lintStagedParser.parse('.lintstagedrc.json', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('*.js'); + expect(patterns[1].value).toBe('*.ts'); + }); + + it('should handle path patterns', () => { + const content = `{ + "src": "echo 'linting src'", + "lib/*.js": "eslint" +}`; + const patterns = lintStagedParser.parse('.lintstagedrc.json', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[0].value).toBe('src'); + expect(patterns[1].type).toBe(PatternType.GLOB); + }); +}); diff --git a/tests/parsers/oxlint.test.ts b/tests/parsers/oxlint.test.ts new file mode 100644 index 0000000..90b69fa --- /dev/null +++ b/tests/parsers/oxlint.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { oxlintParser } from '../../src/parsers/oxlint.js'; +import { PatternType } from '../../src/core/types.js'; + +describe('oxlintParser', () => { + it('should have correct name and patterns', () => { + expect(oxlintParser.name).toBe('oxlint'); + expect(oxlintParser.filePatterns).toContain('.oxlintrc.json'); + expect(oxlintParser.filePatterns).toContain('oxlint.config.json'); + }); + + it('should parse ignorePatterns with paths', () => { + const content = `{ + "ignorePatterns": [ + "dist", + "node_modules", + "coverage" + ] +}`; + const patterns = oxlintParser.parse('.oxlintrc.json', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('dist'); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[1].value).toBe('node_modules'); + expect(patterns[2].value).toBe('coverage'); + }); + + it('should parse ignorePatterns with globs', () => { + const content = `{ + "ignorePatterns": [ + "**/*.min.js", + "src/**/*.test.ts", + "*.config.js" + ] +}`; + const patterns = oxlintParser.parse('.oxlintrc.json', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].type).toBe(PatternType.GLOB); + expect(patterns[0].value).toBe('**/*.min.js'); + expect(patterns[1].type).toBe(PatternType.GLOB); + expect(patterns[2].type).toBe(PatternType.GLOB); + }); + + it('should handle empty config', () => { + const content = '{}'; + const patterns = oxlintParser.parse('.oxlintrc.json', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle config without ignorePatterns', () => { + const content = `{ + "rules": { + "no-unused-vars": "error" + } +}`; + const patterns = oxlintParser.parse('.oxlintrc.json', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle invalid JSON', () => { + const content = 'not valid json'; + const patterns = oxlintParser.parse('.oxlintrc.json', content); + + expect(patterns).toHaveLength(0); + }); + + it('should track line numbers', () => { + const content = `{ + "ignorePatterns": [ + "dist", + "node_modules" + ] +}`; + const patterns = oxlintParser.parse('.oxlintrc.json', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].line).toBe(3); + expect(patterns[1].line).toBe(4); + }); + + it('should handle mixed paths and globs', () => { + const content = `{ + "ignorePatterns": [ + "dist", + "**/*.min.js", + "node_modules", + "src/**/*.test.ts" + ] +}`; + const patterns = oxlintParser.parse('.oxlintrc.json', content); + + expect(patterns).toHaveLength(4); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[1].type).toBe(PatternType.GLOB); + expect(patterns[2].type).toBe(PatternType.PATH); + expect(patterns[3].type).toBe(PatternType.GLOB); + }); +}); diff --git a/tests/parsers/pre-commit.test.ts b/tests/parsers/pre-commit.test.ts new file mode 100644 index 0000000..de1fd2d --- /dev/null +++ b/tests/parsers/pre-commit.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { preCommitParser } from '../../src/parsers/pre-commit.js'; +import { PatternType } from '../../src/core/types.js'; + +describe('preCommitParser', () => { + it('should have correct name and patterns', () => { + expect(preCommitParser.name).toBe('pre-commit'); + expect(preCommitParser.filePatterns).toContain('.pre-commit-config.yaml'); + expect(preCommitParser.filePatterns).toContain('.pre-commit-config.yml'); + }); + + it('should parse top-level exclude regex', () => { + const content = ` +exclude: '^(migrations/|tests/fixtures/)' +repos: [] +`; + const patterns = preCommitParser.parse('.pre-commit-config.yaml', content); + + expect(patterns.length).toBeGreaterThanOrEqual(1); + const pattern = patterns.find((p) => p.value === '^(migrations/|tests/fixtures/)'); + expect(pattern).toBeDefined(); + expect(pattern?.type).toBe(PatternType.REGEX); + }); + + it('should parse top-level files pattern', () => { + const content = ` +files: '\\.py$' +repos: [] +`; + const patterns = preCommitParser.parse('.pre-commit-config.yaml', content); + + expect(patterns.length).toBeGreaterThanOrEqual(1); + const pattern = patterns.find((p) => p.value === '\\.py$'); + expect(pattern).toBeDefined(); + expect(pattern?.type).toBe(PatternType.REGEX); + }); + + it('should parse hook-level files patterns', () => { + const content = ` +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + hooks: + - id: trailing-whitespace + files: '\\.py$' + - id: end-of-file-fixer + files: '\\.(js|ts)$' +`; + const patterns = preCommitParser.parse('.pre-commit-config.yaml', content); + + expect(patterns.length).toBeGreaterThanOrEqual(2); + const pyPattern = patterns.find((p) => p.value === '\\.py$'); + const jsPattern = patterns.find((p) => p.value === '\\.(js|ts)$'); + expect(pyPattern).toBeDefined(); + expect(pyPattern?.type).toBe(PatternType.REGEX); + expect(jsPattern).toBeDefined(); + expect(jsPattern?.type).toBe(PatternType.REGEX); + }); + + it('should parse hook-level exclude patterns', () => { + const content = ` +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + hooks: + - id: check-yaml + exclude: '^tests/' +`; + const patterns = preCommitParser.parse('.pre-commit-config.yaml', content); + + expect(patterns.length).toBeGreaterThanOrEqual(1); + const pattern = patterns.find((p) => p.value === '^tests/'); + expect(pattern).toBeDefined(); + expect(pattern?.type).toBe(PatternType.REGEX); + }); + + it('should handle empty repos', () => { + const content = ` +repos: [] +`; + const patterns = preCommitParser.parse('.pre-commit-config.yaml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle invalid YAML', () => { + const content = 'not: valid: yaml:'; + const patterns = preCommitParser.parse('.pre-commit-config.yaml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should parse multiple repos with multiple hooks', () => { + const content = ` +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + hooks: + - id: trailing-whitespace + files: '\\.py$' + - repo: https://github.com/psf/black + hooks: + - id: black + exclude: '^migrations/' +`; + const patterns = preCommitParser.parse('.pre-commit-config.yaml', content); + + expect(patterns.length).toBeGreaterThanOrEqual(2); + const pyPattern = patterns.find((p) => p.value === '\\.py$'); + const migrationsPattern = patterns.find((p) => p.value === '^migrations/'); + expect(pyPattern).toBeDefined(); + expect(migrationsPattern).toBeDefined(); + }); + + it('should handle hooks without file patterns', () => { + const content = ` +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer +`; + const patterns = preCommitParser.parse('.pre-commit-config.yaml', content); + + expect(patterns).toHaveLength(0); + }); +}); diff --git a/tests/parsers/sqlfluff.test.ts b/tests/parsers/sqlfluff.test.ts new file mode 100644 index 0000000..8e47f5d --- /dev/null +++ b/tests/parsers/sqlfluff.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest'; +import { sqlfluffParser, sqlfluffSetupCfgParser } from '../../src/parsers/sqlfluff.js'; +import { PatternType } from '../../src/core/types.js'; + +describe('sqlfluffParser', () => { + it('should have correct name and patterns', () => { + expect(sqlfluffParser.name).toBe('sqlfluff'); + expect(sqlfluffParser.filePatterns).toContain('.sqlfluff'); + }); + + it('should parse exclude_rules', () => { + const content = ` +[sqlfluff] +exclude_rules = L001, L002, L003 +dialect = postgres +`; + const patterns = sqlfluffParser.parse('.sqlfluff', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('L001'); + expect(patterns[1].value).toBe('L002'); + expect(patterns[2].value).toBe('L003'); + }); + + it('should parse ignore patterns', () => { + const content = ` +[sqlfluff] +ignore = migrations/, scripts/legacy/ +`; + const patterns = sqlfluffParser.parse('.sqlfluff', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('migrations/'); + expect(patterns[1].value).toBe('scripts/legacy/'); + }); + + it('should parse glob patterns', () => { + const content = ` +[sqlfluff] +ignore = *.sql.j2, tests/**/*.sql +`; + const patterns = sqlfluffParser.parse('.sqlfluff', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].type).toBe(PatternType.GLOB); + expect(patterns[0].value).toBe('*.sql.j2'); + expect(patterns[1].type).toBe(PatternType.GLOB); + }); + + it('should parse template_path', () => { + const content = ` +[sqlfluff] +template_path = templates/, custom/templates/ +`; + const patterns = sqlfluffParser.parse('.sqlfluff', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('templates/'); + expect(patterns[1].value).toBe('custom/templates/'); + }); + + it('should handle continuation lines', () => { + const content = ` +[sqlfluff] +exclude_rules = + L001 + L002 + L003 +`; + const patterns = sqlfluffParser.parse('.sqlfluff', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('L001'); + expect(patterns[1].value).toBe('L002'); + expect(patterns[2].value).toBe('L003'); + }); + + it('should skip non-sqlfluff sections', () => { + const content = ` +[tool.other] +exclude = something + +[sqlfluff] +ignore = migrations/ + +[another_section] +exclude = other +`; + const patterns = sqlfluffParser.parse('.sqlfluff', content); + + expect(patterns).toHaveLength(1); + expect(patterns[0].value).toBe('migrations/'); + }); + + it('should handle sqlfluff subsections', () => { + const content = ` +[sqlfluff:rules] +ignore = rules/custom/ + +[sqlfluff:templater] +template_path = templates/ +`; + const patterns = sqlfluffParser.parse('.sqlfluff', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('rules/custom/'); + expect(patterns[1].value).toBe('templates/'); + }); + + it('should handle empty config', () => { + const content = ` +[sqlfluff] +dialect = postgres +`; + const patterns = sqlfluffParser.parse('.sqlfluff', content); + + expect(patterns).toHaveLength(0); + }); +}); + +describe('sqlfluffSetupCfgParser', () => { + it('should have correct name and patterns', () => { + expect(sqlfluffSetupCfgParser.name).toBe('sqlfluff-setup'); + expect(sqlfluffSetupCfgParser.filePatterns).toContain('setup.cfg'); + }); + + it('should parse sqlfluff section from setup.cfg', () => { + const content = ` +[metadata] +name = myproject + +[sqlfluff] +ignore = migrations/, tests/ +dialect = postgres +`; + const patterns = sqlfluffSetupCfgParser.parse('setup.cfg', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('migrations/'); + expect(patterns[1].value).toBe('tests/'); + }); +}); From d0786d8112aba07ebfdcb04bd35c8d869d1a7f19 Mon Sep 17 00:00:00 2001 From: Maciej Mensfeld Date: Mon, 26 Jan 2026 15:58:36 +0100 Subject: [PATCH 2/2] remarks --- CHANGELOG.md | 9 +- README.md | 6 +- src/parsers/alex.ts | 55 ++---------- src/parsers/brakeman.ts | 107 ++++++++++++++++++++++ src/parsers/buf.ts | 2 +- src/parsers/bundler-audit.ts | 92 +++++++++++++++++++ src/parsers/commitlint.ts | 5 +- src/parsers/index.ts | 12 +++ src/parsers/reek.ts | 89 ++++++++++++++++++ src/parsers/sqlfluff.ts | 13 +-- src/parsers/yard-lint.ts | 130 +++++++++++++++++++++++++++ tests/parsers/alex.test.ts | 25 +----- tests/parsers/brakeman.test.ts | 109 ++++++++++++++++++++++ tests/parsers/bundler-audit.test.ts | 103 +++++++++++++++++++++ tests/parsers/reek.test.ts | 98 ++++++++++++++++++++ tests/parsers/sqlfluff.test.ts | 24 +++-- tests/parsers/yard-lint.test.ts | 134 ++++++++++++++++++++++++++++ 17 files changed, 914 insertions(+), 99 deletions(-) create mode 100644 src/parsers/brakeman.ts create mode 100644 src/parsers/bundler-audit.ts create mode 100644 src/parsers/reek.ts create mode 100644 src/parsers/yard-lint.ts create mode 100644 tests/parsers/brakeman.test.ts create mode 100644 tests/parsers/bundler-audit.test.ts create mode 100644 tests/parsers/reek.test.ts create mode 100644 tests/parsers/yard-lint.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe476bb..9cf2250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **SQLFluff** (`.sqlfluff`, `setup.cfg`) - SQL linter supporting 24+ dialects - **buf** (`buf.yaml`, `buf.work.yaml`) - Protocol Buffers linter and generator - **alex** (`.alexignore`, `.alexrc`, `.alexrc.json`) - Inclusive language linter -- Total supported configuration files increased from 48+ to 58+ -- Comprehensive test coverage with 100+ new tests for all new parsers +- Support for 4 essential Ruby/Rails linters: + - **Brakeman** (`config/brakeman.yml`, `.brakeman.yml`) - Security vulnerability scanner for Rails + - **Reek** (`.reek.yml`, `.reek`, `config.reek`) - Code smell detector for Ruby + - **bundler-audit** (`.bundler-audit.yml`) - Dependency vulnerability scanner + - **YARD-Lint** (`.yard-lint.yml`) - Documentation linter for YARD docs +- Total supported configuration files increased from 48+ to 62+ +- Comprehensive test coverage with 140+ new tests for all new parsers ### Changed - Updated README with new linter documentation and usage examples diff --git a/README.md b/README.md index db439cb..1d8f413 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ npx lostconf --exclude "**/test/**" --exclude "**/tests/**" ## Supported Config Files -lostconf supports **58+ configuration files** from popular tools across **15+ languages**: +lostconf supports **62+ configuration files** from popular tools across **15+ languages**: | Language/Category | Tool | Config File(s) | What We Check | |-------------------|------|----------------|---------------| @@ -137,6 +137,10 @@ lostconf supports **58+ configuration files** from popular tools across **15+ la | | Pyright | `pyrightconfig.json` | `include`, `exclude`, `ignore`, `extraPaths` patterns | | **SQL** | SQLFluff | `.sqlfluff`, `setup.cfg` | Exclude patterns, ignore patterns, template paths | | **Ruby** | RuboCop | `.rubocop.yml` | Exclude patterns, Include patterns in AllCops | +| | Brakeman | `config/brakeman.yml`, `.brakeman.yml` | Patterns in `skip-files` and `only-files` | +| | Reek | `.reek.yml`, `.reek`, `config.reek` | Directory paths in `exclude_paths` | +| | bundler-audit | `.bundler-audit.yml` | Advisory IDs in `ignore` (CVE, OSVDB, GHSA) | +| | YARD-Lint | `.yard-lint.yml` | File patterns in global and per-validator `Exclude` | | **Go** | golangci-lint | `.golangci.yml` | Skip-dirs, skip-files, exclude patterns | | **Rust** | rustfmt | `rustfmt.toml` | Ignore patterns | | | Clippy | `clippy.toml` | Excluded files | diff --git a/src/parsers/alex.ts b/src/parsers/alex.ts index 051ca3c..415603a 100644 --- a/src/parsers/alex.ts +++ b/src/parsers/alex.ts @@ -47,62 +47,17 @@ function parseAlexIgnore(_filename: string, content: string): Pattern[] { } /** Parse .alexrc/.alexrc.json configuration */ -function parseAlexRc(_filename: string, content: string): Pattern[] { +function parseAlexRc(_filename: string, _content: string): Pattern[] { const patterns: Pattern[] = []; - let config: { allow?: string[] }; - try { - config = JSON.parse(content); - } catch { - return patterns; - } - - if (!config || typeof config !== 'object') { - return patterns; - } - - const lineMap = buildLineMap(content); - - // Extract 'allow' patterns - these are words/phrases to allow - if (Array.isArray(config.allow)) { - for (const value of config.allow) { - if (typeof value !== 'string') continue; - const lineInfo = lineMap.get(value); - - // Allow patterns are typically simple strings (words/phrases), not file paths - // We'll treat them as PATH type for validation purposes - patterns.push({ - value, - type: PatternType.PATH, - line: lineInfo?.line ?? 1, - column: lineInfo?.column - }); - } - } + // Note: The 'allow' field in .alexrc contains linguistic terms (words/phrases), + // not filesystem paths. These cannot be validated against the filesystem, + // so we intentionally skip parsing them. + // Example: ["boogeyman", "garbageman"] are words to allow, not file paths. return patterns; } -function buildLineMap(content: string): Map { - const map = new Map(); - const lines = content.split('\n'); - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const lineNum = i + 1; - - const stringMatches = line.matchAll(/"([^"]+)"/g); - for (const match of stringMatches) { - const value = match[1]; - if (value && !map.has(value)) { - map.set(value, { line: lineNum, column: (match.index ?? 0) + 1 }); - } - } - } - - return map; -} - /** alex ignore file parser */ export const alexIgnoreParser: Parser = { name: 'alexignore', diff --git a/src/parsers/brakeman.ts b/src/parsers/brakeman.ts new file mode 100644 index 0000000..c1be7a8 --- /dev/null +++ b/src/parsers/brakeman.ts @@ -0,0 +1,107 @@ +/** + * Parser for Brakeman configuration files + * https://brakemanscanner.org/ + */ + +import { parse as parseYaml } from 'yaml'; +import type { Pattern } from '../core/types.js'; +import { PatternType } from '../core/types.js'; +import type { Parser } from '../plugin/types.js'; +import { isGlobPattern } from '../validator/glob.js'; + +interface BrakemanConfig { + 'skip-files'?: string[]; + 'only-files'?: string[]; + [key: string]: unknown; +} + +/** Parse Brakeman configuration */ +function parseBrakeman(_filename: string, content: string): Pattern[] { + const patterns: Pattern[] = []; + + let config: BrakemanConfig; + try { + config = parseYaml(content) as BrakemanConfig; + } catch { + return patterns; + } + + if (!config || typeof config !== 'object') { + return patterns; + } + + const lineMap = buildLineMap(content); + + // Extract skip-files patterns + if (Array.isArray(config['skip-files'])) { + for (const value of config['skip-files']) { + if (typeof value !== 'string') continue; + + const lineInfo = lineMap.get(value); + const type = isGlobPattern(value) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + + // Extract only-files patterns + if (Array.isArray(config['only-files'])) { + for (const value of config['only-files']) { + if (typeof value !== 'string') continue; + + const lineInfo = lineMap.get(value); + const type = isGlobPattern(value) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + + return patterns; +} + +function buildLineMap(content: string): Map { + const map = new Map(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + // Match YAML string values (quoted and unquoted) + const quotedMatches = line.matchAll(/["']([^"']+)["']/g); + for (const match of quotedMatches) { + const value = match[1]; + if (value && !map.has(value)) { + map.set(value, { line: lineNum, column: (match.index ?? 0) + 1 }); + } + } + + // Match unquoted list items + const listItemMatch = line.match(/^\s*-\s+([^\s#]+)/); + if (listItemMatch && listItemMatch[1]) { + const value = listItemMatch[1]; + if (!map.has(value)) { + map.set(value, { line: lineNum, column: (listItemMatch.index ?? 0) + 1 }); + } + } + } + + return map; +} + +/** Brakeman config parser */ +export const brakemanParser: Parser = { + name: 'brakeman', + filePatterns: ['config/brakeman.yml', '.brakeman.yml', '**/config/brakeman.yml'], + parse: parseBrakeman +}; diff --git a/src/parsers/buf.ts b/src/parsers/buf.ts index ea799f3..84fb860 100644 --- a/src/parsers/buf.ts +++ b/src/parsers/buf.ts @@ -33,7 +33,7 @@ interface BufWorkConfig { } /** Parse buf.yaml or buf.work.yaml configuration */ -function parseBuf(filename: string, content: string): Pattern[] { +function parseBuf(_filename: string, content: string): Pattern[] { const patterns: Pattern[] = []; let config: BufConfig | BufWorkConfig; diff --git a/src/parsers/bundler-audit.ts b/src/parsers/bundler-audit.ts new file mode 100644 index 0000000..d268e6f --- /dev/null +++ b/src/parsers/bundler-audit.ts @@ -0,0 +1,92 @@ +/** + * Parser for bundler-audit configuration files + * https://github.com/rubysec/bundler-audit + * + * Note: bundler-audit's ignore field contains advisory IDs (CVE-YYYY-XXXX), + * not file paths. These are tracked but cannot be validated against the filesystem. + */ + +import { parse as parseYaml } from 'yaml'; +import type { Pattern } from '../core/types.js'; +import { PatternType } from '../core/types.js'; +import type { Parser } from '../plugin/types.js'; + +interface BundlerAuditConfig { + ignore?: string[]; + [key: string]: unknown; +} + +/** Parse bundler-audit configuration */ +function parseBundlerAudit(_filename: string, content: string): Pattern[] { + const patterns: Pattern[] = []; + + let config: BundlerAuditConfig; + try { + config = parseYaml(content) as BundlerAuditConfig; + } catch { + return patterns; + } + + if (!config || typeof config !== 'object') { + return patterns; + } + + const lineMap = buildLineMap(content); + + // Extract ignore list (advisory IDs like CVE-2021-22885, OSVDB-108664, GHSA-xxx) + // Note: These are not file paths, but we track them as PATH type patterns + if (Array.isArray(config.ignore)) { + for (const value of config.ignore) { + if (typeof value !== 'string') continue; + + const lineInfo = lineMap.get(value); + + // Advisory IDs are not file paths, but we use PATH type for tracking + patterns.push({ + value, + type: PatternType.PATH, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + + return patterns; +} + +function buildLineMap(content: string): Map { + const map = new Map(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + // Match YAML string values (quoted and unquoted) + const quotedMatches = line.matchAll(/["']([^"']+)["']/g); + for (const match of quotedMatches) { + const value = match[1]; + if (value && !map.has(value)) { + map.set(value, { line: lineNum, column: (match.index ?? 0) + 1 }); + } + } + + // Match unquoted list items + const listItemMatch = line.match(/^\s*-\s+([^\s#]+)/); + if (listItemMatch && listItemMatch[1]) { + const value = listItemMatch[1]; + if (!map.has(value)) { + map.set(value, { line: lineNum, column: (listItemMatch.index ?? 0) + 1 }); + } + } + } + + return map; +} + +/** bundler-audit config parser */ +export const bundlerAuditParser: Parser = { + name: 'bundler-audit', + filePatterns: ['.bundler-audit.yml', '**/.bundler-audit.yml'], + parse: parseBundlerAudit +}; diff --git a/src/parsers/commitlint.ts b/src/parsers/commitlint.ts index 6589ebb..a5464e1 100644 --- a/src/parsers/commitlint.ts +++ b/src/parsers/commitlint.ts @@ -56,8 +56,9 @@ function parseCommitlintConfig(_filename: string, content: string): Pattern[] { for (const value of config.extends) { if (typeof value !== 'string') continue; - // Skip npm package names (start with @) - if (value.startsWith('@') || value.startsWith('.') === false) { + // Skip npm package names (start with @ or don't start with . or /) + // Only include relative paths (./...) or absolute paths (/...) + if (value.startsWith('@') || (!value.startsWith('.') && !value.startsWith('/'))) { continue; } diff --git a/src/parsers/index.ts b/src/parsers/index.ts index 51c09a2..d18f2f6 100644 --- a/src/parsers/index.ts +++ b/src/parsers/index.ts @@ -27,6 +27,10 @@ export { pyrightParser } from './pyright.js'; // Ruby export { rubocopParser } from './rubocop.js'; +export { brakemanParser } from './brakeman.js'; +export { reekParser } from './reek.js'; +export { bundlerAuditParser } from './bundler-audit.js'; +export { yardLintParser } from './yard-lint.js'; // Go export { golangciParser } from './golangci.js'; @@ -114,6 +118,10 @@ import { pylintrcParser } from './pylint.js'; import { banditParser } from './bandit.js'; import { pyrightParser } from './pyright.js'; import { rubocopParser } from './rubocop.js'; +import { brakemanParser } from './brakeman.js'; +import { reekParser } from './reek.js'; +import { bundlerAuditParser } from './bundler-audit.js'; +import { yardLintParser } from './yard-lint.js'; import { golangciParser } from './golangci.js'; import { rustfmtParser, clippyParser } from './rust.js'; import { checkstyleParser, pmdParser, spotbugsParser } from './java.js'; @@ -169,6 +177,10 @@ export function getBuiltinParsers(): Parser[] { // Ruby rubocopParser, + brakemanParser, + reekParser, + bundlerAuditParser, + yardLintParser, // Go golangciParser, diff --git a/src/parsers/reek.ts b/src/parsers/reek.ts new file mode 100644 index 0000000..33b0d9a --- /dev/null +++ b/src/parsers/reek.ts @@ -0,0 +1,89 @@ +/** + * Parser for Reek configuration files + * https://github.com/troessner/reek + */ + +import { parse as parseYaml } from 'yaml'; +import type { Pattern } from '../core/types.js'; +import { PatternType } from '../core/types.js'; +import type { Parser } from '../plugin/types.js'; +import { isGlobPattern } from '../validator/glob.js'; + +interface ReekConfig { + exclude_paths?: string[]; + [key: string]: unknown; +} + +/** Parse Reek configuration */ +function parseReek(_filename: string, content: string): Pattern[] { + const patterns: Pattern[] = []; + + let config: ReekConfig; + try { + config = parseYaml(content) as ReekConfig; + } catch { + return patterns; + } + + if (!config || typeof config !== 'object') { + return patterns; + } + + const lineMap = buildLineMap(content); + + // Extract exclude_paths + if (Array.isArray(config.exclude_paths)) { + for (const value of config.exclude_paths) { + if (typeof value !== 'string') continue; + + const lineInfo = lineMap.get(value); + const type = isGlobPattern(value) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + + return patterns; +} + +function buildLineMap(content: string): Map { + const map = new Map(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + // Match YAML string values (quoted and unquoted) + const quotedMatches = line.matchAll(/["']([^"']+)["']/g); + for (const match of quotedMatches) { + const value = match[1]; + if (value && !map.has(value)) { + map.set(value, { line: lineNum, column: (match.index ?? 0) + 1 }); + } + } + + // Match unquoted list items + const listItemMatch = line.match(/^\s*-\s+([^\s#]+)/); + if (listItemMatch && listItemMatch[1]) { + const value = listItemMatch[1]; + if (!map.has(value)) { + map.set(value, { line: lineNum, column: (listItemMatch.index ?? 0) + 1 }); + } + } + } + + return map; +} + +/** Reek config parser */ +export const reekParser: Parser = { + name: 'reek', + filePatterns: ['.reek.yml', '.reek', 'config.reek', '**/.reek.yml'], + parse: parseReek +}; diff --git a/src/parsers/sqlfluff.ts b/src/parsers/sqlfluff.ts index d3ae08a..bea0cc7 100644 --- a/src/parsers/sqlfluff.ts +++ b/src/parsers/sqlfluff.ts @@ -9,7 +9,7 @@ import type { Parser } from '../plugin/types.js'; import { isGlobPattern } from '../validator/glob.js'; /** Parse .sqlfluff or setup.cfg file */ -function parseSqlfluff(filename: string, content: string): Pattern[] { +function parseSqlfluff(_filename: string, content: string): Pattern[] { const patterns: Pattern[] = []; const lines = content.split('\n'); @@ -60,14 +60,9 @@ function parseSqlfluff(filename: string, content: string): Pattern[] { /** Check if a key is a path-related field */ function isPathField(key: string): boolean { - return [ - 'exclude_rules', - 'ignore', - 'ignore_templated_areas', - 'template_path', - 'library_path', - 'sql_file_exts' - ].includes(key); + return ['ignore', 'ignore_templated_areas', 'template_path', 'library_path'].includes(key); + // Note: exclude_rules contains rule IDs (L001, L002), not paths + // Note: sql_file_exts contains file extensions (.sql, .sql.j2), not paths } /** Extract patterns from a value string */ diff --git a/src/parsers/yard-lint.ts b/src/parsers/yard-lint.ts new file mode 100644 index 0000000..6b3a8c7 --- /dev/null +++ b/src/parsers/yard-lint.ts @@ -0,0 +1,130 @@ +/** + * Parser for YARD-Lint configuration files + * https://github.com/mensfeld/yard-lint + */ + +import { parse as parseYaml } from 'yaml'; +import type { Pattern } from '../core/types.js'; +import { PatternType } from '../core/types.js'; +import type { Parser } from '../plugin/types.js'; +import { isGlobPattern } from '../validator/glob.js'; + +interface YardLintValidator { + Exclude?: string[]; + ExcludedMethods?: string[]; + [key: string]: unknown; +} + +interface YardLintConfig { + AllValidators?: { + Exclude?: string[]; + [key: string]: unknown; + }; + [validatorName: string]: unknown; +} + +/** Parse YARD-Lint configuration */ +function parseYardLint(_filename: string, content: string): Pattern[] { + const patterns: Pattern[] = []; + + let config: YardLintConfig; + try { + config = parseYaml(content) as YardLintConfig; + } catch { + return patterns; + } + + if (!config || typeof config !== 'object') { + return patterns; + } + + const lineMap = buildLineMap(content); + + // Extract global exclusions from AllValidators + if (config.AllValidators && typeof config.AllValidators === 'object') { + if (Array.isArray(config.AllValidators.Exclude)) { + for (const value of config.AllValidators.Exclude) { + if (typeof value !== 'string') continue; + + const lineInfo = lineMap.get(value); + const type = isGlobPattern(value) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + } + + // Extract per-validator exclusions + for (const [key, value] of Object.entries(config)) { + // Skip AllValidators (already processed) and non-object values + if (key === 'AllValidators' || typeof value !== 'object' || value === null) { + continue; + } + + const validator = value as YardLintValidator; + + // Extract Exclude patterns + if (Array.isArray(validator.Exclude)) { + for (const pattern of validator.Exclude) { + if (typeof pattern !== 'string') continue; + + const lineInfo = lineMap.get(pattern); + const type = isGlobPattern(pattern) ? PatternType.GLOB : PatternType.PATH; + + patterns.push({ + value: pattern, + type, + line: lineInfo?.line ?? 1, + column: lineInfo?.column + }); + } + } + + // Note: ExcludedMethods contains method signatures, not file paths, + // but we could track them if needed in the future + } + + return patterns; +} + +function buildLineMap(content: string): Map { + const map = new Map(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + // Match YAML string values (quoted and unquoted) + const quotedMatches = line.matchAll(/["']([^"']+)["']/g); + for (const match of quotedMatches) { + const value = match[1]; + if (value && !map.has(value)) { + map.set(value, { line: lineNum, column: (match.index ?? 0) + 1 }); + } + } + + // Match unquoted list items + const listItemMatch = line.match(/^\s*-\s+([^\s#]+)/); + if (listItemMatch && listItemMatch[1]) { + const value = listItemMatch[1]; + if (!map.has(value)) { + map.set(value, { line: lineNum, column: (listItemMatch.index ?? 0) + 1 }); + } + } + } + + return map; +} + +/** YARD-Lint config parser */ +export const yardLintParser: Parser = { + name: 'yard-lint', + filePatterns: ['.yard-lint.yml', '**/.yard-lint.yml'], + parse: parseYardLint +}; diff --git a/tests/parsers/alex.test.ts b/tests/parsers/alex.test.ts index 84df986..e3dc3f5 100644 --- a/tests/parsers/alex.test.ts +++ b/tests/parsers/alex.test.ts @@ -72,17 +72,14 @@ describe('alexRcParser', () => { expect(alexRcParser.filePatterns).toContain('.alexrc.json'); }); - it('should parse allow list', () => { + it('should not parse allow list (linguistic terms, not paths)', () => { const content = `{ "allow": ["boogeyman", "garbageman", "mailman"] }`; const patterns = alexRcParser.parse('.alexrc', content); - expect(patterns).toHaveLength(3); - expect(patterns[0].value).toBe('boogeyman'); - expect(patterns[0].type).toBe(PatternType.PATH); - expect(patterns[1].value).toBe('garbageman'); - expect(patterns[2].value).toBe('mailman'); + // The 'allow' field contains words to allow, not file paths + expect(patterns).toHaveLength(0); }); it('should handle empty config', () => { @@ -92,7 +89,7 @@ describe('alexRcParser', () => { expect(patterns).toHaveLength(0); }); - it('should handle config without allow field', () => { + it('should handle config with profanitySureness', () => { const content = `{ "profanitySureness": 1 }`; @@ -107,18 +104,4 @@ describe('alexRcParser', () => { expect(patterns).toHaveLength(0); }); - - it('should track line numbers', () => { - const content = `{ - "allow": [ - "boogeyman", - "garbageman" - ] -}`; - const patterns = alexRcParser.parse('.alexrc', content); - - expect(patterns).toHaveLength(2); - expect(patterns[0].line).toBe(3); - expect(patterns[1].line).toBe(4); - }); }); diff --git a/tests/parsers/brakeman.test.ts b/tests/parsers/brakeman.test.ts new file mode 100644 index 0000000..588665f --- /dev/null +++ b/tests/parsers/brakeman.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { brakemanParser } from '../../src/parsers/brakeman.js'; +import { PatternType } from '../../src/core/types.js'; + +describe('brakemanParser', () => { + it('should have correct name and patterns', () => { + expect(brakemanParser.name).toBe('brakeman'); + expect(brakemanParser.filePatterns).toContain('config/brakeman.yml'); + expect(brakemanParser.filePatterns).toContain('.brakeman.yml'); + }); + + it('should parse skip-files with paths', () => { + const content = ` +skip-files: + - app/views + - app/controllers/legacy + - lib/deprecated +`; + const patterns = brakemanParser.parse('config/brakeman.yml', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('app/views'); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[1].value).toBe('app/controllers/legacy'); + expect(patterns[2].value).toBe('lib/deprecated'); + }); + + it('should parse skip-files with glob patterns', () => { + const content = ` +skip-files: + - 'app/views/**/*' + - '*.erb' + - 'lib/**/*.rake' +`; + const patterns = brakemanParser.parse('config/brakeman.yml', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].type).toBe(PatternType.GLOB); + expect(patterns[0].value).toBe('app/views/**/*'); + expect(patterns[1].type).toBe(PatternType.GLOB); + expect(patterns[2].type).toBe(PatternType.GLOB); + }); + + it('should parse only-files patterns', () => { + const content = ` +only-files: + - app/models + - app/controllers +`; + const patterns = brakemanParser.parse('config/brakeman.yml', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('app/models'); + expect(patterns[1].value).toBe('app/controllers'); + }); + + it('should parse both skip-files and only-files', () => { + const content = ` +skip-files: + - app/views +only-files: + - app/models + - app/controllers +`; + const patterns = brakemanParser.parse('config/brakeman.yml', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('app/views'); + expect(patterns[1].value).toBe('app/models'); + expect(patterns[2].value).toBe('app/controllers'); + }); + + it('should handle empty config', () => { + const content = ''; + const patterns = brakemanParser.parse('config/brakeman.yml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle config without skip-files or only-files', () => { + const content = ` +quiet: true +confidence_threshold: 2 +`; + const patterns = brakemanParser.parse('config/brakeman.yml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle invalid YAML', () => { + const content = 'not: valid: yaml:'; + const patterns = brakemanParser.parse('config/brakeman.yml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle directory patterns with trailing slashes', () => { + const content = ` +skip-files: + - app/views/ + - vendor/ +`; + const patterns = brakemanParser.parse('config/brakeman.yml', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('app/views/'); + expect(patterns[1].value).toBe('vendor/'); + }); +}); diff --git a/tests/parsers/bundler-audit.test.ts b/tests/parsers/bundler-audit.test.ts new file mode 100644 index 0000000..97cee05 --- /dev/null +++ b/tests/parsers/bundler-audit.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { bundlerAuditParser } from '../../src/parsers/bundler-audit.js'; +import { PatternType } from '../../src/core/types.js'; + +describe('bundlerAuditParser', () => { + it('should have correct name and patterns', () => { + expect(bundlerAuditParser.name).toBe('bundler-audit'); + expect(bundlerAuditParser.filePatterns).toContain('.bundler-audit.yml'); + }); + + it('should parse ignore list with CVE IDs', () => { + const content = ` +ignore: + - CVE-2021-22885 + - CVE-2020-8184 + - CVE-2019-16782 +`; + const patterns = bundlerAuditParser.parse('.bundler-audit.yml', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('CVE-2021-22885'); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[1].value).toBe('CVE-2020-8184'); + expect(patterns[2].value).toBe('CVE-2019-16782'); + }); + + it('should parse ignore list with OSVDB IDs', () => { + const content = ` +ignore: + - OSVDB-108664 + - OSVDB-120415 +`; + const patterns = bundlerAuditParser.parse('.bundler-audit.yml', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('OSVDB-108664'); + expect(patterns[1].value).toBe('OSVDB-120415'); + }); + + it('should parse ignore list with GHSA IDs', () => { + const content = ` +ignore: + - GHSA-wrrw-crp8-979q + - GHSA-pg8v-g4xq-hww9 +`; + const patterns = bundlerAuditParser.parse('.bundler-audit.yml', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('GHSA-wrrw-crp8-979q'); + expect(patterns[1].value).toBe('GHSA-pg8v-g4xq-hww9'); + }); + + it('should parse mixed advisory ID types', () => { + const content = ` +ignore: + - CVE-2021-22885 + - OSVDB-108664 + - GHSA-wrrw-crp8-979q +`; + const patterns = bundlerAuditParser.parse('.bundler-audit.yml', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('CVE-2021-22885'); + expect(patterns[1].value).toBe('OSVDB-108664'); + expect(patterns[2].value).toBe('GHSA-wrrw-crp8-979q'); + }); + + it('should handle empty config', () => { + const content = ''; + const patterns = bundlerAuditParser.parse('.bundler-audit.yml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle config without ignore field', () => { + const content = ` +other_field: value +`; + const patterns = bundlerAuditParser.parse('.bundler-audit.yml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle invalid YAML', () => { + const content = 'not: valid: yaml:'; + const patterns = bundlerAuditParser.parse('.bundler-audit.yml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should track line numbers', () => { + const content = ` +ignore: + - CVE-2021-22885 + - CVE-2020-8184 +`; + const patterns = bundlerAuditParser.parse('.bundler-audit.yml', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].line).toBeGreaterThan(1); + expect(patterns[1].line).toBeGreaterThan(patterns[0].line); + }); +}); diff --git a/tests/parsers/reek.test.ts b/tests/parsers/reek.test.ts new file mode 100644 index 0000000..632b280 --- /dev/null +++ b/tests/parsers/reek.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { reekParser } from '../../src/parsers/reek.js'; +import { PatternType } from '../../src/core/types.js'; + +describe('reekParser', () => { + it('should have correct name and patterns', () => { + expect(reekParser.name).toBe('reek'); + expect(reekParser.filePatterns).toContain('.reek.yml'); + expect(reekParser.filePatterns).toContain('.reek'); + }); + + it('should parse exclude_paths with directory paths', () => { + const content = ` +exclude_paths: + - app/views + - app/controllers/legacy + - lib/deprecated +`; + const patterns = reekParser.parse('.reek.yml', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('app/views'); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[1].value).toBe('app/controllers/legacy'); + expect(patterns[2].value).toBe('lib/deprecated'); + }); + + it('should parse exclude_paths with glob patterns', () => { + const content = ` +exclude_paths: + - 'lib/legacy/**/*' + - 'vendor/**/*.rb' + - 'spec/**/*' +`; + const patterns = reekParser.parse('.reek.yml', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].type).toBe(PatternType.GLOB); + expect(patterns[0].value).toBe('lib/legacy/**/*'); + expect(patterns[1].type).toBe(PatternType.GLOB); + expect(patterns[2].type).toBe(PatternType.GLOB); + }); + + it('should handle empty config', () => { + const content = ''; + const patterns = reekParser.parse('.reek.yml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle config without exclude_paths', () => { + const content = ` +detectors: + UtilityFunction: + enabled: false +`; + const patterns = reekParser.parse('.reek.yml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle invalid YAML', () => { + const content = 'not: valid: yaml:'; + const patterns = reekParser.parse('.reek.yml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle mixed paths and globs', () => { + const content = ` +exclude_paths: + - app/views + - 'lib/**/*.rake' + - vendor + - 'spec/**/*_spec.rb' +`; + const patterns = reekParser.parse('.reek.yml', content); + + expect(patterns).toHaveLength(4); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[1].type).toBe(PatternType.GLOB); + expect(patterns[2].type).toBe(PatternType.PATH); + expect(patterns[3].type).toBe(PatternType.GLOB); + }); + + it('should track line numbers', () => { + const content = ` +exclude_paths: + - app/views + - lib/legacy +`; + const patterns = reekParser.parse('.reek.yml', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].line).toBeGreaterThan(1); + expect(patterns[1].line).toBeGreaterThan(patterns[0].line); + }); +}); diff --git a/tests/parsers/sqlfluff.test.ts b/tests/parsers/sqlfluff.test.ts index 8e47f5d..0a7db1e 100644 --- a/tests/parsers/sqlfluff.test.ts +++ b/tests/parsers/sqlfluff.test.ts @@ -8,7 +8,7 @@ describe('sqlfluffParser', () => { expect(sqlfluffParser.filePatterns).toContain('.sqlfluff'); }); - it('should parse exclude_rules', () => { + it('should not parse exclude_rules (rule IDs, not paths)', () => { const content = ` [sqlfluff] exclude_rules = L001, L002, L003 @@ -16,10 +16,8 @@ dialect = postgres `; const patterns = sqlfluffParser.parse('.sqlfluff', content); - expect(patterns).toHaveLength(3); - expect(patterns[0].value).toBe('L001'); - expect(patterns[1].value).toBe('L002'); - expect(patterns[2].value).toBe('L003'); + // exclude_rules contains rule IDs, not file paths + expect(patterns).toHaveLength(0); }); it('should parse ignore patterns', () => { @@ -59,20 +57,20 @@ template_path = templates/, custom/templates/ expect(patterns[1].value).toBe('custom/templates/'); }); - it('should handle continuation lines', () => { + it('should handle continuation lines for path fields', () => { const content = ` [sqlfluff] -exclude_rules = - L001 - L002 - L003 +ignore = + migrations/ + legacy/ + temp/ `; const patterns = sqlfluffParser.parse('.sqlfluff', content); expect(patterns).toHaveLength(3); - expect(patterns[0].value).toBe('L001'); - expect(patterns[1].value).toBe('L002'); - expect(patterns[2].value).toBe('L003'); + expect(patterns[0].value).toBe('migrations/'); + expect(patterns[1].value).toBe('legacy/'); + expect(patterns[2].value).toBe('temp/'); }); it('should skip non-sqlfluff sections', () => { diff --git a/tests/parsers/yard-lint.test.ts b/tests/parsers/yard-lint.test.ts new file mode 100644 index 0000000..9a2adea --- /dev/null +++ b/tests/parsers/yard-lint.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest'; +import { yardLintParser } from '../../src/parsers/yard-lint.js'; +import { PatternType } from '../../src/core/types.js'; + +describe('yardLintParser', () => { + it('should have correct name and patterns', () => { + expect(yardLintParser.name).toBe('yard-lint'); + expect(yardLintParser.filePatterns).toContain('.yard-lint.yml'); + }); + + it('should parse AllValidators global exclusions', () => { + const content = ` +AllValidators: + Exclude: + - vendor/**/* + - node_modules/**/* + - spec/**/* +`; + const patterns = yardLintParser.parse('.yard-lint.yml', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('vendor/**/*'); + expect(patterns[0].type).toBe(PatternType.GLOB); + expect(patterns[1].value).toBe('node_modules/**/*'); + expect(patterns[2].value).toBe('spec/**/*'); + }); + + it('should parse per-validator exclusions', () => { + const content = ` +Tags/InvalidTypes: + Exclude: + - lib/legacy/**/* + - lib/deprecated/*.rb + +Documentation/UndocumentedObjects: + Exclude: + - lib/experimental/**/* +`; + const patterns = yardLintParser.parse('.yard-lint.yml', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].type).toBe(PatternType.GLOB); + expect(patterns[0].value).toBe('lib/legacy/**/*'); + expect(patterns[1].type).toBe(PatternType.GLOB); + expect(patterns[2].type).toBe(PatternType.GLOB); + }); + + it('should parse both global and per-validator exclusions', () => { + const content = ` +AllValidators: + Exclude: + - vendor/**/* + - spec/**/* + +Tags/InvalidTypes: + Exclude: + - lib/legacy/**/* +`; + const patterns = yardLintParser.parse('.yard-lint.yml', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].value).toBe('vendor/**/*'); + expect(patterns[1].value).toBe('spec/**/*'); + expect(patterns[2].value).toBe('lib/legacy/**/*'); + }); + + it('should handle simple paths', () => { + const content = ` +AllValidators: + Exclude: + - vendor + - tmp + - log +`; + const patterns = yardLintParser.parse('.yard-lint.yml', content); + + expect(patterns).toHaveLength(3); + expect(patterns[0].type).toBe(PatternType.PATH); + expect(patterns[1].type).toBe(PatternType.PATH); + expect(patterns[2].type).toBe(PatternType.PATH); + }); + + it('should handle regex patterns', () => { + const content = ` +AllValidators: + Exclude: + - '\\.git' + - '**/test_*.rb' +`; + const patterns = yardLintParser.parse('.yard-lint.yml', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].value).toBe('\\.git'); + expect(patterns[1].type).toBe(PatternType.GLOB); + }); + + it('should handle empty config', () => { + const content = ''; + const patterns = yardLintParser.parse('.yard-lint.yml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle config without exclusions', () => { + const content = ` +Tags/InvalidTypes: + Enabled: true +`; + const patterns = yardLintParser.parse('.yard-lint.yml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should handle invalid YAML', () => { + const content = 'not: valid: yaml:'; + const patterns = yardLintParser.parse('.yard-lint.yml', content); + + expect(patterns).toHaveLength(0); + }); + + it('should track line numbers', () => { + const content = ` +AllValidators: + Exclude: + - vendor/**/* + - spec/**/* +`; + const patterns = yardLintParser.parse('.yard-lint.yml', content); + + expect(patterns).toHaveLength(2); + expect(patterns[0].line).toBeGreaterThan(1); + expect(patterns[1].line).toBeGreaterThan(patterns[0].line); + }); +});