hana-linter 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
2
+ # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages
3
+
4
+ name: Publish NPM Package
5
+
6
+ on:
7
+ push:
8
+ branches:
9
+ - main
10
+ release:
11
+ types: [created]
12
+
13
+ permissions:
14
+ id-token: write # required for OIDC
15
+ contents: read
16
+
17
+ jobs:
18
+ publish:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-node@v4
23
+ with:
24
+ node-version: '24'
25
+ registry-url: 'https://registry.npmjs.org'
26
+ - run: npm ci
27
+ - run: npm run build --if-present
28
+ - run: npm test
29
+ - run: npm publish
package/.prettierrc ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "tabWidth": 4,
3
+ "useTabs": false,
4
+ "singleQuote": true,
5
+ "trailingComma": "none",
6
+ "printWidth": 150
7
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ---
6
+
7
+ ### 0.1.1
8
+
9
+ - Added `hana-linter init` command to generate a default `.hana-linter.json` in the current project root
10
+ - Added `hana-linter init --force` option to overwrite an existing config file
11
+
12
+ ### Initial Release
13
+
14
+ - Initial version with core linter functionality
package/README.md ADDED
@@ -0,0 +1,286 @@
1
+ # hana-linter
2
+
3
+ [![npm version](https://img.shields.io/npm/v/hana-linter)](https://www.npmjs.com/package/hana-linter)
4
+ [![CI](https://github.com/qualiture/hana-linter/actions/workflows/npm-publish.yml/badge.svg?branch=main)](https://github.com/qualiture/hana-linter/actions/workflows/npm-publish.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/qualiture/hana-linter/blob/main/LICENSE)
6
+ [![Node >=14](https://img.shields.io/badge/node-%3E%3D14-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
7
+
8
+ Regex-first naming lint for SAP HANA artifacts in CAP projects.
9
+
10
+ [NPM package](https://www.npmjs.com/package/hana-linter) • [Report issue](https://github.com/qualiture/hana-linter/issues) • [Releases](https://github.com/qualiture/hana-linter/releases)
11
+
12
+ Lint SAP HANA artifact names in CAP projects using configurable regex-based naming rules.
13
+
14
+ ## Why
15
+
16
+ Teams often rely on naming conventions for HANA artifacts such as tables and views. When these conventions are enforced manually, drift is common and code reviews become noisy.
17
+
18
+ hana-linter helps you:
19
+
20
+ - enforce naming standards consistently
21
+ - catch violations early in local development and CI
22
+ - apply different rules per file extension
23
+ - keep naming policy in version control via a single config file
24
+
25
+ ## How It Works
26
+
27
+ hana-linter reads a `.hana-linter.json` file and validates artifact file names against rules grouped by extension.
28
+
29
+ It supports two lint modes:
30
+
31
+ - Full scan mode: no file arguments, recursively scans `rootDir`
32
+ - File-list mode: pass file paths, only those files are validated
33
+
34
+ Rule groups per extension:
35
+
36
+ - `groups.all`: every rule must match (AND)
37
+ - `groups.any`: at least one rule must match (OR)
38
+
39
+ You can define `extension: "*"` as a shared rule set. Its rules are applied to every file extension and are merged with any extension-specific rule set.
40
+
41
+ ## Install
42
+
43
+ ### Local (recommended for projects)
44
+
45
+ ```bash
46
+ npm install --save-dev hana-linter
47
+ ```
48
+
49
+ Run with:
50
+
51
+ ```bash
52
+ npx hana-linter
53
+ ```
54
+
55
+ ### Global
56
+
57
+ ```bash
58
+ npm install -g hana-linter
59
+ ```
60
+
61
+ ## Quick Start
62
+
63
+ 1. Generate a default config in your project root:
64
+
65
+ ```bash
66
+ hana-linter init
67
+ ```
68
+
69
+ 2. Run the linter:
70
+
71
+ ```bash
72
+ hana-linter
73
+ ```
74
+
75
+ 3. If needed, regenerate and overwrite config:
76
+
77
+ ```bash
78
+ hana-linter init --force
79
+ ```
80
+
81
+ ## Commands
82
+
83
+ ### `hana-linter`
84
+
85
+ Run lint using `.hana-linter.json` from the current working directory.
86
+
87
+ ```bash
88
+ hana-linter
89
+ ```
90
+
91
+ Lint specific files only:
92
+
93
+ ```bash
94
+ hana-linter db/src/T_CUSTOMER.hdbtable db/src/V_ACTIVE_USERS.hdbview
95
+ ```
96
+
97
+ Use a custom config path:
98
+
99
+ ```bash
100
+ hana-linter --config ./config/.hana-linter.json
101
+ ```
102
+
103
+ ### `hana-linter init`
104
+
105
+ Create `.hana-linter.json` in the current working directory from the bundled default template.
106
+
107
+ ```bash
108
+ hana-linter init
109
+ ```
110
+
111
+ Overwrite existing config:
112
+
113
+ ```bash
114
+ hana-linter init --force
115
+ ```
116
+
117
+ ## Configuration
118
+
119
+ Create a `.hana-linter.json` file in your project root.
120
+
121
+ ### Configuration Fields
122
+
123
+ - `rootDir` (string): directory to scan in full scan mode
124
+ - `ignoredDirectories` (string[]): folder names ignored during recursive traversal
125
+ - `extensionRuleSets` (array): rule definitions grouped by file extension
126
+ - `contentRuleSets` (optional array): naming rules for identifiers extracted from file contents (for example table fields and procedure/function parameters)
127
+
128
+ Each `extensionRuleSets` item contains:
129
+
130
+ - `extension` (string): target extension, for example `.hdbtable`; use `*` to target all extensions
131
+ - `folderName` (optional string): enforce that matching files are located in a folder with this name (at any depth under `rootDir`)
132
+ - `groups.all` (optional array): all rules must match
133
+ - `groups.any` (optional array): at least one rule must match
134
+
135
+ Each rule contains:
136
+
137
+ - `description` (string): readable rule label for output
138
+ - `pattern` (string): regex source (without `/` delimiters)
139
+ - `flags` (optional string): regex flags, for example `i`, `u`, `iu`
140
+
141
+ At least one of `groups.all` or `groups.any` must be present for each extension.
142
+
143
+ When `folderName` is omitted, no folder-location enforcement is applied.
144
+
145
+ Each `contentRuleSets` item contains:
146
+
147
+ - `extension` (string): target extension, for example `.hdbtable`; use `*` to target all extensions
148
+ - `target` (string): extracted identifier type to validate; one of `field`, `inputParameter`, `outputParameter`
149
+ - `groups.all` (optional array): all rules must match
150
+ - `groups.any` (optional array): at least one rule must match
151
+
152
+ Supported extractors in this version:
153
+
154
+ - `field`: `.hdbtable`
155
+ - `inputParameter`: `.hdbprocedure`, `.hdbfunction`
156
+ - `outputParameter`: `.hdbprocedure`, `.hdbfunction`
157
+
158
+ ### Default Config Example
159
+
160
+ ```json
161
+ {
162
+ "rootDir": "db",
163
+ "ignoredDirectories": ["node_modules", ".git", "gen"],
164
+ "extensionRuleSets": [
165
+ {
166
+ "extension": "*",
167
+ "groups": {
168
+ "all": [
169
+ {
170
+ "description": "Upper snake case only",
171
+ "pattern": "^[A-Z0-9]+(?:_[A-Z0-9]+)*$"
172
+ },
173
+ {
174
+ "description": "Max length 30",
175
+ "pattern": "^.{1,30}$",
176
+ "flags": "u"
177
+ }
178
+ ]
179
+ }
180
+ },
181
+ {
182
+ "extension": ".hdbtable",
183
+ "folderName": "tables",
184
+ "groups": {
185
+ "any": [
186
+ {
187
+ "description": "Prefix T_",
188
+ "pattern": "^T_.+"
189
+ },
190
+ {
191
+ "description": "Prefix TX_",
192
+ "pattern": "^TX_.+"
193
+ }
194
+ ]
195
+ }
196
+ },
197
+ {
198
+ "extension": ".hdbview",
199
+ "groups": {
200
+ "all": [
201
+ {
202
+ "description": "Starts with V_",
203
+ "pattern": "^V_.+"
204
+ }
205
+ ]
206
+ }
207
+ }
208
+ ],
209
+ "contentRuleSets": [
210
+ {
211
+ "extension": ".hdbtable",
212
+ "target": "field",
213
+ "groups": {
214
+ "all": [
215
+ {
216
+ "description": "Field names in uppercase snake case",
217
+ "pattern": "^[A-Z0-9]+(?:_[A-Z0-9]+)*$"
218
+ }
219
+ ]
220
+ }
221
+ },
222
+ {
223
+ "extension": ".hdbprocedure",
224
+ "target": "inputParameter",
225
+ "groups": {
226
+ "all": [
227
+ {
228
+ "description": "Input parameters prefixed with IP_",
229
+ "pattern": "^IP_[A-Z0-9_]+$"
230
+ }
231
+ ]
232
+ }
233
+ },
234
+ {
235
+ "extension": ".hdbprocedure",
236
+ "target": "outputParameter",
237
+ "groups": {
238
+ "all": [
239
+ {
240
+ "description": "Output parameters prefixed with OP_",
241
+ "pattern": "^OP_[A-Z0-9_]+$"
242
+ }
243
+ ]
244
+ }
245
+ }
246
+ ]
247
+ }
248
+ ```
249
+
250
+ ## Exit Codes
251
+
252
+ - `0`: lint passed or `init` completed successfully
253
+ - `1`: lint violations found or command failed
254
+
255
+ This makes the CLI suitable for CI pipelines.
256
+
257
+ ## CI Example
258
+
259
+ ```yaml
260
+ name: lint-hana-names
261
+ on: [push, pull_request]
262
+
263
+ jobs:
264
+ hana-lint:
265
+ runs-on: ubuntu-latest
266
+ steps:
267
+ - uses: actions/checkout@v4
268
+ - uses: actions/setup-node@v4
269
+ with:
270
+ node-version: 20
271
+ - run: npm ci
272
+ - run: npx hana-linter
273
+ ```
274
+
275
+ ## Requirements
276
+
277
+ - Node.js >= 14
278
+ - npm >= 7
279
+
280
+ ## Contributing
281
+
282
+ Contributions are welcome. Please open an issue or submit a pull request.
283
+
284
+ ## License
285
+
286
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseCliInput = parseCliInput;
7
+ exports.runInit = runInit;
8
+ const node_fs_1 = require("node:fs");
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const DEFAULT_CONFIG_PATH = '.hana-linter.json';
11
+ /**
12
+ * Parse command-line arguments.
13
+ *
14
+ * Supported:
15
+ * - --config <path>
16
+ * - remaining arguments are treated as file paths
17
+ *
18
+ * @returns Parsed CLI input.
19
+ */
20
+ function parseCliInput() {
21
+ const args = process.argv.slice(2);
22
+ if (args[0] === 'init') {
23
+ const initArgs = args.slice(1);
24
+ let force = false;
25
+ for (const value of initArgs) {
26
+ if (value === '--force') {
27
+ force = true;
28
+ continue;
29
+ }
30
+ throw new Error(`Unknown argument for init: "${value}". Supported: --force`);
31
+ }
32
+ return {
33
+ command: 'init',
34
+ files: [],
35
+ configPath: DEFAULT_CONFIG_PATH,
36
+ force
37
+ };
38
+ }
39
+ const files = [];
40
+ let configPath = DEFAULT_CONFIG_PATH;
41
+ for (let index = 0; index < args.length; index += 1) {
42
+ const value = args[index];
43
+ if (value === '--config') {
44
+ const nextValue = args[index + 1];
45
+ if (!nextValue || nextValue.startsWith('--')) {
46
+ throw new Error('Missing value for --config');
47
+ }
48
+ configPath = nextValue;
49
+ index += 1;
50
+ continue;
51
+ }
52
+ files.push(value);
53
+ }
54
+ return {
55
+ command: 'lint',
56
+ files: files.filter((value) => value.trim().length > 0),
57
+ configPath,
58
+ force: false
59
+ };
60
+ }
61
+ /**
62
+ * Create .hana-linter.json in current working directory from bundled template.
63
+ *
64
+ * @param force - Overwrite existing file when true.
65
+ */
66
+ async function runInit(force) {
67
+ const templatePath = await resolveTemplateConfigPath();
68
+ const targetPath = node_path_1.default.resolve(process.cwd(), DEFAULT_CONFIG_PATH);
69
+ try {
70
+ await node_fs_1.promises.copyFile(templatePath, targetPath, force ? 0 : node_fs_1.constants.COPYFILE_EXCL);
71
+ console.info(`✅ Created ${DEFAULT_CONFIG_PATH} at ${targetPath}`);
72
+ }
73
+ catch (error) {
74
+ if (!force && typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') {
75
+ throw new Error(`${DEFAULT_CONFIG_PATH} already exists at ${targetPath}. Use "hana-linter init --force" to overwrite.`);
76
+ }
77
+ throw error;
78
+ }
79
+ }
80
+ /**
81
+ * Resolve the packaged default config template path.
82
+ *
83
+ * Supports both:
84
+ * - built artifact layout (dist/assets)
85
+ * - source layout (src/assets) during local development
86
+ *
87
+ * @returns Existing template path.
88
+ */
89
+ async function resolveTemplateConfigPath() {
90
+ const candidatePaths = [
91
+ node_path_1.default.resolve(__dirname, 'assets', DEFAULT_CONFIG_PATH),
92
+ node_path_1.default.resolve(__dirname, '..', 'src', 'assets', DEFAULT_CONFIG_PATH)
93
+ ];
94
+ for (const candidatePath of candidatePaths) {
95
+ try {
96
+ await node_fs_1.promises.access(candidatePath);
97
+ return candidatePath;
98
+ }
99
+ catch {
100
+ // Try next candidate.
101
+ }
102
+ }
103
+ throw new Error('Could not locate bundled default configuration template. Expected one of: ' + candidatePaths.join(', '));
104
+ }
package/dist/config.js ADDED
@@ -0,0 +1,227 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readJsonConfig = readJsonConfig;
4
+ exports.toLintConfig = toLintConfig;
5
+ const node_fs_1 = require("node:fs");
6
+ /**
7
+ * Read and parse JSON config file.
8
+ *
9
+ * @param configPath - Path to JSON configuration.
10
+ * @returns Parsed JSON config object.
11
+ */
12
+ async function readJsonConfig(configPath) {
13
+ const rawContent = await node_fs_1.promises.readFile(configPath, { encoding: 'utf-8' });
14
+ const parsed = JSON.parse(rawContent);
15
+ if (!isJsonLintConfig(parsed)) {
16
+ throw new Error(`Invalid configuration schema in "${configPath}". Please verify required fields.`);
17
+ }
18
+ return parsed;
19
+ }
20
+ /**
21
+ * Convert JSON config into runtime compiled config.
22
+ *
23
+ * @param jsonConfig - Parsed JSON config.
24
+ * @returns Runtime lint config with compiled regex.
25
+ */
26
+ function toLintConfig(jsonConfig) {
27
+ const extensionFolderNames = new Map();
28
+ const extensionRuleSets = jsonConfig.extensionRuleSets.map((jsonRuleSet) => {
29
+ const folderName = jsonRuleSet.folderName?.trim();
30
+ if (folderName !== undefined && folderName.length === 0) {
31
+ throw new Error(`Invalid folderName for extension "${jsonRuleSet.extension}": value must not be empty.`);
32
+ }
33
+ if (folderName !== undefined && (folderName.includes('/') || folderName.includes('\\'))) {
34
+ throw new Error(`Invalid folderName for extension "${jsonRuleSet.extension}": use only a folder name, not a path.`);
35
+ }
36
+ if (folderName !== undefined) {
37
+ const existingFolderName = extensionFolderNames.get(jsonRuleSet.extension);
38
+ if (existingFolderName && existingFolderName !== folderName) {
39
+ throw new Error(`Conflicting folderName values configured for extension "${jsonRuleSet.extension}": "${existingFolderName}" and "${folderName}".`);
40
+ }
41
+ extensionFolderNames.set(jsonRuleSet.extension, folderName);
42
+ }
43
+ const compiledGroups = compileRuleGroup(jsonRuleSet.groups, `extension "${jsonRuleSet.extension}"`);
44
+ const allRules = compiledGroups.all ?? [];
45
+ const anyRules = compiledGroups.any ?? [];
46
+ const hasAllRules = allRules.length > 0;
47
+ const hasAnyRules = anyRules.length > 0;
48
+ if (!hasAllRules && !hasAnyRules) {
49
+ throw new Error(`Invalid rule configuration for extension "${jsonRuleSet.extension}": at least one rule is required in "groups.all" or "groups.any".`);
50
+ }
51
+ return {
52
+ extension: jsonRuleSet.extension,
53
+ folderName,
54
+ groups: compiledGroups
55
+ };
56
+ });
57
+ const contentRuleSets = (jsonConfig.contentRuleSets ?? []).map((jsonRuleSet) => {
58
+ const compiledGroups = compileRuleGroup(jsonRuleSet.groups, `content target "${jsonRuleSet.target}" extension "${jsonRuleSet.extension}"`);
59
+ const hasAllRules = (compiledGroups.all ?? []).length > 0;
60
+ const hasAnyRules = (compiledGroups.any ?? []).length > 0;
61
+ if (!hasAllRules && !hasAnyRules) {
62
+ throw new Error(`Invalid content rule configuration for target "${jsonRuleSet.target}" extension "${jsonRuleSet.extension}": at least one rule is required in "groups.all" or "groups.any".`);
63
+ }
64
+ return {
65
+ extension: jsonRuleSet.extension,
66
+ target: jsonRuleSet.target,
67
+ groups: compiledGroups
68
+ };
69
+ });
70
+ return {
71
+ rootDir: jsonConfig.rootDir,
72
+ ignoredDirectories: jsonConfig.ignoredDirectories,
73
+ extensionRuleSets,
74
+ contentRuleSets
75
+ };
76
+ }
77
+ /**
78
+ * Basic runtime schema guard for JSON config.
79
+ *
80
+ * @param value - Unknown parsed JSON value.
81
+ * @returns True when value matches expected shape.
82
+ */
83
+ function isJsonLintConfig(value) {
84
+ if (!value || typeof value !== 'object') {
85
+ return false;
86
+ }
87
+ const candidate = value;
88
+ if (typeof candidate.rootDir !== 'string') {
89
+ return false;
90
+ }
91
+ if (!Array.isArray(candidate.ignoredDirectories)) {
92
+ return false;
93
+ }
94
+ if (!Array.isArray(candidate.extensionRuleSets)) {
95
+ return false;
96
+ }
97
+ if (candidate.contentRuleSets !== undefined && !Array.isArray(candidate.contentRuleSets)) {
98
+ return false;
99
+ }
100
+ const extensionRuleSetsValid = candidate.extensionRuleSets.every((ruleSet) => {
101
+ if (!ruleSet || typeof ruleSet !== 'object') {
102
+ return false;
103
+ }
104
+ const typedRuleSet = ruleSet;
105
+ if (typeof typedRuleSet.extension !== 'string') {
106
+ return false;
107
+ }
108
+ if (typedRuleSet.folderName !== undefined && typeof typedRuleSet.folderName !== 'string') {
109
+ return false;
110
+ }
111
+ if (!typedRuleSet.groups || typeof typedRuleSet.groups !== 'object') {
112
+ return false;
113
+ }
114
+ const groups = typedRuleSet.groups;
115
+ const allRulesValid = groups.all === undefined ||
116
+ (Array.isArray(groups.all) &&
117
+ groups.all.every((rule) => !!rule &&
118
+ typeof rule.description === 'string' &&
119
+ typeof rule.pattern === 'string' &&
120
+ (rule.flags === undefined || typeof rule.flags === 'string')));
121
+ const anyRulesValid = groups.any === undefined ||
122
+ (Array.isArray(groups.any) &&
123
+ groups.any.every((rule) => !!rule &&
124
+ typeof rule.description === 'string' &&
125
+ typeof rule.pattern === 'string' &&
126
+ (rule.flags === undefined || typeof rule.flags === 'string')));
127
+ return allRulesValid && anyRulesValid;
128
+ });
129
+ if (!extensionRuleSetsValid) {
130
+ return false;
131
+ }
132
+ return (candidate.contentRuleSets ?? []).every((ruleSet) => {
133
+ if (!ruleSet || typeof ruleSet !== 'object') {
134
+ return false;
135
+ }
136
+ const typedRuleSet = ruleSet;
137
+ if (typeof typedRuleSet.extension !== 'string') {
138
+ return false;
139
+ }
140
+ if (!isContentTarget(typedRuleSet.target)) {
141
+ return false;
142
+ }
143
+ if (!typedRuleSet.groups || typeof typedRuleSet.groups !== 'object') {
144
+ return false;
145
+ }
146
+ const groups = typedRuleSet.groups;
147
+ const allRulesValid = groups.all === undefined ||
148
+ (Array.isArray(groups.all) &&
149
+ groups.all.every((rule) => !!rule &&
150
+ typeof rule.description === 'string' &&
151
+ typeof rule.pattern === 'string' &&
152
+ (rule.flags === undefined || typeof rule.flags === 'string')));
153
+ const anyRulesValid = groups.any === undefined ||
154
+ (Array.isArray(groups.any) &&
155
+ groups.any.every((rule) => !!rule &&
156
+ typeof rule.description === 'string' &&
157
+ typeof rule.pattern === 'string' &&
158
+ (rule.flags === undefined || typeof rule.flags === 'string')));
159
+ return allRulesValid && anyRulesValid;
160
+ });
161
+ }
162
+ function isContentTarget(target) {
163
+ return target === 'field' || target === 'inputParameter' || target === 'outputParameter';
164
+ }
165
+ function compileRuleGroup(groups, contextRoot) {
166
+ const allRules = (groups.all ?? []).map((rule, index) => {
167
+ const flags = rule.flags ?? '';
168
+ const context = `${contextRoot} group "all" rule #${index + 1}`;
169
+ return {
170
+ description: rule.description,
171
+ source: rule.pattern,
172
+ flags,
173
+ pattern: compileRegex(rule.pattern, flags, context)
174
+ };
175
+ });
176
+ const anyRules = (groups.any ?? []).map((rule, index) => {
177
+ const flags = rule.flags ?? '';
178
+ const context = `${contextRoot} group "any" rule #${index + 1}`;
179
+ return {
180
+ description: rule.description,
181
+ source: rule.pattern,
182
+ flags,
183
+ pattern: compileRegex(rule.pattern, flags, context)
184
+ };
185
+ });
186
+ return {
187
+ all: allRules.length > 0 ? allRules : undefined,
188
+ any: anyRules.length > 0 ? anyRules : undefined
189
+ };
190
+ }
191
+ /**
192
+ * Validate regex flags for duplicate/unsupported values.
193
+ *
194
+ * @param flags - Raw regex flags string.
195
+ * @param context - Rule context for clear error messages.
196
+ */
197
+ function validateRegexFlags(flags, context) {
198
+ const allowedFlags = new Set(['d', 'g', 'i', 'm', 's', 'u', 'v', 'y']);
199
+ const seen = new Set();
200
+ for (const flag of flags) {
201
+ if (!allowedFlags.has(flag)) {
202
+ throw new Error(`Invalid regex flag in ${context}: "${flag}"`);
203
+ }
204
+ if (seen.has(flag)) {
205
+ throw new Error(`Duplicate regex flag in ${context}: "${flag}"`);
206
+ }
207
+ seen.add(flag);
208
+ }
209
+ }
210
+ /**
211
+ * Compile regex source and flags into RegExp with helpful error context.
212
+ *
213
+ * @param source - Regex source from config.
214
+ * @param flags - Regex flags from config.
215
+ * @param context - Rule context for error reporting.
216
+ * @returns Compiled RegExp.
217
+ */
218
+ function compileRegex(source, flags, context) {
219
+ validateRegexFlags(flags, context);
220
+ try {
221
+ return new RegExp(source, flags);
222
+ }
223
+ catch (error) {
224
+ const message = error instanceof Error ? error.message : 'Unknown regex error';
225
+ throw new Error(`Invalid regex in ${context}: pattern="${source}" flags="${flags}". ${message}`, { cause: error });
226
+ }
227
+ }