cloudflare-expression-lint 0.1.0

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.
package/CLAUDE.md ADDED
@@ -0,0 +1,57 @@
1
+ # cloudflare-expression-lint
2
+
3
+ ## What This Is
4
+ A TypeScript parser, validator, and linter for Cloudflare Rules Language expressions. It catches errors in Cloudflare expressions before they reach `terraform apply`.
5
+
6
+ ## Project Structure
7
+ - `src/lexer.ts` — Tokenizer (string → Token[])
8
+ - `src/parser.ts` — Recursive-descent parser (Token[] → AST)
9
+ - `src/validator.ts` — Semantic validator (AST → Diagnostic[])
10
+ - `src/yaml-scanner.ts` — YAML file scanner with phase inference
11
+ - `src/cli.ts` — CLI entry point
12
+ - `src/types.ts` — All type definitions
13
+ - `src/schemas/fields.ts` — Field registry (211+ fields with types, deprecation, phase availability)
14
+ - `src/schemas/functions.ts` — Function registry (25+ functions with context restrictions)
15
+ - `src/schemas/operators.ts` — Operator definitions
16
+ - `src/__tests__/` — Test suite (203+ tests)
17
+
18
+ ## Commands
19
+ - `npm test` — Run tests (vitest)
20
+ - `npm run build` — Build TypeScript to dist/
21
+ - `node dist/cli.js -e 'EXPRESSION'` — Validate a single expression
22
+ - `node dist/cli.js config/**/*.yaml` — Scan YAML files
23
+
24
+ ## How to Add a New Field
25
+ Add an entry to the `FIELDS` array in `src/schemas/fields.ts`:
26
+ ```typescript
27
+ { name: 'cf.new_field', type: 'String' },
28
+ // With deprecation:
29
+ { name: 'old.field', type: 'String', deprecated: true, replacement: 'new.field' },
30
+ // With phase restriction:
31
+ { name: 'http.response.new', type: 'String', phases: ['http_response_headers_transform'] },
32
+ ```
33
+
34
+ ## How to Add a New Function
35
+ Add an entry to the `FUNCTIONS` array in `src/schemas/functions.ts`:
36
+ ```typescript
37
+ {
38
+ name: 'new_function',
39
+ params: [{ name: 'input', type: 'String' }],
40
+ returnType: 'String',
41
+ contexts: ['all'], // or ['filter'], ['rewrite_url', 'rewrite_header'], etc.
42
+ maxPerExpression: 1, // optional usage limit
43
+ },
44
+ ```
45
+
46
+ ## Expression Types
47
+ - `filter` — Boolean expressions (the "when" condition in rules)
48
+ - `rewrite_url` — URL rewrite value expressions (e.g., regex_replace result)
49
+ - `rewrite_header` — Header value expressions
50
+ - `redirect_target` — Redirect target URL expressions
51
+
52
+ ## Key Design Decisions
53
+ - Schemas are data, not code — field/function definitions are in simple arrays
54
+ - Parser is custom (not using wirefilter WASM) for better error messages
55
+ - ESLint is NOT a dependency — this is a standalone tool
56
+ - The validator produces warnings for deprecated fields, errors for unknown/invalid ones
57
+ - YAML scanner infers Cloudflare phase from parent YAML keys (e.g., `waf_rules` → `http_request_firewall_custom`)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Troy Jones
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,375 @@
1
+ # cloudflare-expression-lint
2
+
3
+ A parser, validator, and linter for [Cloudflare Rules Language](https://developers.cloudflare.com/ruleset-engine/rules-language/) expressions with phase-aware field and function checking.
4
+
5
+ Catches errors **before** `terraform apply` — no API calls required.
6
+
7
+ ## Features
8
+
9
+ - **Full expression parser** — lexer + recursive-descent parser for the Cloudflare wirefilter expression syntax
10
+ - **211+ known fields** with type information
11
+ - **Deprecated field detection** — warns on legacy fields like `ip.geoip.country` with replacement suggestions
12
+ - **Phase-aware validation** — knows which fields are available in which Cloudflare phase (e.g., response fields only in response phases)
13
+ - **Function context validation** — `regex_replace()` is only valid in rewrite/redirect contexts, not filter expressions
14
+ - **Function usage limits** — enforces max 1 `regex_replace()` / `wildcard_replace()` per expression
15
+ - **YAML scanner** — auto-detects expressions in YAML config files and infers the Cloudflare phase from context
16
+ - **CLI tool** — validate expressions from the command line or CI/CD pipelines
17
+ - **Programmatic API** — use as a library in your own tools
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install cloudflare-expression-lint
23
+ ```
24
+
25
+ Or run directly with npx:
26
+
27
+ ```bash
28
+ npx cloudflare-expression-lint config/**/*.yaml
29
+ ```
30
+
31
+ ## CLI Usage
32
+
33
+ ### Validate YAML files
34
+
35
+ ```bash
36
+ # Scan all YAML files for expressions
37
+ cf-expr-lint config/**/*.yaml
38
+
39
+ # Scan with JSON output (for CI integration)
40
+ cf-expr-lint --format json config/**/*.yaml
41
+
42
+ # Only show errors (suppress warnings)
43
+ cf-expr-lint --quiet config/**/*.yaml
44
+ ```
45
+
46
+ ### Validate a single expression
47
+
48
+ ```bash
49
+ # Filter expression (default)
50
+ cf-expr-lint -e '(http.host eq "example.com")'
51
+
52
+ # Rewrite expression
53
+ cf-expr-lint -e 'regex_replace(http.request.uri.path, "^/old/", "/new/")' -t rewrite_url
54
+
55
+ # With phase validation
56
+ cf-expr-lint -e 'http.response.code eq 200' -p http_request_firewall_custom
57
+ # ✗ [field-not-in-phase]: Field "http.response.code" is not available in phase "http_request_firewall_custom"
58
+
59
+ # From stdin
60
+ echo '(ip.src.country in {"US" "JP"})' | cf-expr-lint --stdin
61
+ ```
62
+
63
+ ### Custom YAML key mappings (CLI)
64
+
65
+ By default, the scanner only looks for the `expression` key (the standard
66
+ Cloudflare Terraform provider attribute). If your YAML uses other key
67
+ names for expressions, tell the scanner about them:
68
+
69
+ ```bash
70
+ # Add custom expression keys and phase mappings via flags
71
+ cf-expr-lint \
72
+ --expr-key rewrite_expression:rewrite_url:http_request_transform \
73
+ --expr-key source_url_expression:filter:http_request_dynamic_redirect \
74
+ --phase-map waf_rules:http_request_firewall_custom \
75
+ --phase-map transform_rules:http_request_late_transform \
76
+ config/**/*.yaml
77
+ ```
78
+
79
+ The `--expr-key` format is `key_name:expression_type[:phase]`.
80
+ The `--phase-map` format is `yaml_parent_key:cloudflare_phase`.
81
+
82
+ Both merge with the built-in defaults — your custom mappings extend them,
83
+ they don't replace them.
84
+
85
+ ### Config file
86
+
87
+ For projects with many custom mappings, use a `.cf-expr-lint.json` config
88
+ file in your project root (auto-detected) or specified with `--config`:
89
+
90
+ ```json
91
+ {
92
+ "expressionKeys": {
93
+ "rewrite_expression": { "type": "rewrite_url", "phaseHint": "http_request_transform" },
94
+ "source_url_expression": { "type": "filter", "phaseHint": "http_request_dynamic_redirect" },
95
+ "target_url_expression": { "type": "redirect_target", "phaseHint": "http_request_dynamic_redirect" }
96
+ },
97
+ "phaseMappings": {
98
+ "waf_rules": "http_request_firewall_custom",
99
+ "custom_rules": "http_request_firewall_custom",
100
+ "configuration_rules": "http_config_settings",
101
+ "transform_request_header_rules": "http_request_late_transform",
102
+ "transform_response_header_rules": "http_response_headers_transform",
103
+ "transform_url_rewrite_rules": "http_request_transform"
104
+ }
105
+ }
106
+ ```
107
+
108
+ Then just run: `cf-expr-lint config/**/*.yaml`
109
+
110
+ ### CLI Options
111
+
112
+ | Option | Short | Description |
113
+ |--------|-------|-------------|
114
+ | `--expression` | `-e` | Validate a single expression string |
115
+ | `--stdin` | | Read expression from stdin |
116
+ | `--type` | `-t` | Expression type: `filter` (default), `rewrite_url`, `rewrite_header`, `redirect_target` |
117
+ | `--phase` | `-p` | Cloudflare phase for field validation |
118
+ | `--config` | `-c` | Path to config file (JSON) with custom mappings |
119
+ | `--expr-key` | | Add expression key mapping: `key:type[:phase]` (repeatable) |
120
+ | `--phase-map` | | Add phase mapping: `yaml_key:phase` (repeatable) |
121
+ | `--format` | `-f` | Output format: `text` (default), `json` |
122
+ | `--quiet` | `-q` | Only show errors (suppress warnings) |
123
+ | `--help` | `-h` | Show help |
124
+
125
+ ## Programmatic API
126
+
127
+ ```typescript
128
+ import { validate, parse, tokenize } from 'cloudflare-expression-lint';
129
+
130
+ // Validate an expression
131
+ const result = validate('(http.host eq "example.com")', {
132
+ expressionType: 'filter',
133
+ phase: 'http_request_firewall_custom',
134
+ });
135
+
136
+ console.log(result.valid); // true
137
+ console.log(result.diagnostics); // []
138
+
139
+ // Validate with warnings
140
+ const result2 = validate('(ip.geoip.country eq "US")', {
141
+ expressionType: 'filter',
142
+ });
143
+ // result2.valid === true (warnings don't make it invalid)
144
+ // result2.diagnostics[0].code === 'deprecated-field'
145
+ // result2.diagnostics[0].message === 'Field "ip.geoip.country" is deprecated. Use "ip.src.country" instead'
146
+
147
+ // Parse to AST
148
+ const ast = parse('http.host eq "example.com"');
149
+ console.log(ast.kind); // 'Comparison'
150
+
151
+ // Tokenize
152
+ const tokens = tokenize('http.host eq "example.com"');
153
+ ```
154
+
155
+ ### YAML Scanner
156
+
157
+ ```typescript
158
+ import { scanYaml } from 'cloudflare-expression-lint/yaml-scanner';
159
+ import { readFileSync } from 'fs';
160
+
161
+ const content = readFileSync('config/zones/example.yaml', 'utf-8');
162
+ const result = scanYaml(content, 'example.yaml');
163
+
164
+ for (const expr of result.expressions) {
165
+ if (!expr.result.valid) {
166
+ console.error(`${expr.file} → ${expr.yamlPath}: ${expr.result.diagnostics[0].message}`);
167
+ }
168
+ }
169
+ ```
170
+
171
+ ## Supported Expression Syntax
172
+
173
+ This tool supports the full Cloudflare Rules Language syntax:
174
+
175
+ ### Operators
176
+
177
+ | Type | English | C-like |
178
+ |------|---------|--------|
179
+ | Equal | `eq` | `==` |
180
+ | Not equal | `ne` | `!=` |
181
+ | Less than | `lt` | `<` |
182
+ | Less/equal | `le` | `<=` |
183
+ | Greater than | `gt` | `>` |
184
+ | Greater/equal | `ge` | `>=` |
185
+ | Contains | `contains` | |
186
+ | Wildcard | `wildcard` | |
187
+ | Strict wildcard | `strict wildcard` | |
188
+ | Regex match | `matches` | `~` |
189
+ | Set membership | `in` | |
190
+ | AND | `and` | `&&` |
191
+ | OR | `or` | `\|\|` |
192
+ | NOT | `not` | `!` |
193
+ | XOR | `xor` | `^^` |
194
+
195
+ ### Value Types
196
+
197
+ - **Strings**: `"value"` with `\"` and `\\` escaping
198
+ - **Integers**: `42`, `0`, `396507`
199
+ - **Booleans**: `true`, `false`
200
+ - **IP Addresses**: `1.2.3.4`, with CIDR (`1.2.3.0/24`) in `in` lists
201
+ - **Named Lists**: `$list_name`, `$cf.malware`
202
+ - **In-lists**: `{"US" "JP"}`, `{8000..8009}`, `{1.2.3.0/24}`
203
+
204
+ ### Functions
205
+
206
+ All standard Cloudflare functions are supported, with context-aware validation:
207
+
208
+ | Function | Available In |
209
+ |----------|-------------|
210
+ | `lower()`, `upper()`, `len()`, `starts_with()`, `ends_with()`, `contains()` | All contexts |
211
+ | `concat()`, `substring()`, `url_decode()` | All contexts |
212
+ | `any()`, `all()`, `has_key()`, `has_value()` | All contexts |
213
+ | `lookup_json_string()`, `lookup_json_integer()` | All contexts |
214
+ | `regex_replace()` | Rewrite, redirect (max 1 per expression) |
215
+ | `wildcard_replace()` | Rewrite, redirect (max 1 per expression) |
216
+ | `to_string()`, `encode_base64()`, `uuidv4()`, `sha256()` | Rewrite/transform only |
217
+ | `cidr()`, `cidr6()` | Filter only |
218
+
219
+ ## Diagnostic Codes
220
+
221
+ | Code | Severity | Description |
222
+ |------|----------|-------------|
223
+ | `parse-error` | error | Syntax error in expression |
224
+ | `unknown-field` | error | Field name not recognized |
225
+ | `unknown-function` | error | Function name not recognized |
226
+ | `field-not-in-phase` | error | Field not available in the specified Cloudflare phase |
227
+ | `function-not-in-context` | error | Function not available in the expression context (filter vs rewrite) |
228
+ | `function-max-exceeded` | error | Function used more times than allowed |
229
+ | `operator-type-mismatch` | error | Operator not compatible with field type (e.g., `contains` on IP) |
230
+ | `deprecated-field` | warning | Field is deprecated; replacement suggested |
231
+ | `expression-too-long` | warning | Expression exceeds 4096 character limit |
232
+ | `header-key-not-lowercase` | warning | Header map key should be lowercase |
233
+ | `invalid-list-name` | warning | Named list name doesn't match Cloudflare naming rules |
234
+ | `invalid-cidr-mask` | error | CIDR mask out of valid range |
235
+ | `invalid-wildcard-pattern` | warning | Wildcard contains `**` (prohibited) |
236
+ | `invalid-list-name` | warning | Named list name doesn't follow Cloudflare naming rules |
237
+ | `empty-in-list` | warning | Empty `in {}` list will never match |
238
+ | `too-many-regex` | warning | More than 64 regex patterns per expression |
239
+ | `prefer-bare-boolean` | info | Prefer `ssl` over `ssl == true` |
240
+
241
+ ## How Mappings Work
242
+
243
+ The scanner needs to know two things about each YAML file:
244
+
245
+ 1. **Which keys contain expressions?** — By default, only `expression` (the Terraform attribute name).
246
+ 2. **What Cloudflare phase does an expression belong to?** — Inferred from YAML parent keys.
247
+
248
+ Both are **extensible** — your custom mappings always merge with the built-in
249
+ defaults. You never lose the defaults unless you explicitly opt out.
250
+
251
+ ### Programmatic API
252
+
253
+ ```typescript
254
+ import { scanYaml } from 'cloudflare-expression-lint';
255
+
256
+ const result = scanYaml(yamlContent, 'config.yaml', {
257
+ // These MERGE with the built-in defaults
258
+ expressionKeys: {
259
+ 'rewrite_expression': { type: 'rewrite_url', phaseHint: 'http_request_transform' },
260
+ 'source_url_expression': { type: 'filter', phaseHint: 'http_request_dynamic_redirect' },
261
+ },
262
+ phaseMappings: {
263
+ 'waf_rules': 'http_request_firewall_custom',
264
+ 'my_transform_rules': 'http_request_transform',
265
+ },
266
+ });
267
+
268
+ // Inspect defaults
269
+ import { getDefaultExpressionKeys, getDefaultPhaseMappings } from 'cloudflare-expression-lint';
270
+ console.log(getDefaultExpressionKeys()); // { expression: { type: 'filter' } }
271
+ console.log(getDefaultPhaseMappings()); // { cache_rules: '...', http_request_firewall_custom: '...', ... }
272
+ ```
273
+
274
+ ### Built-in Phase Mappings
275
+
276
+ The defaults include all Cloudflare phase names as self-mappings plus common shorthands:
277
+
278
+ | YAML Key | Phase |
279
+ |----------|-------|
280
+ | `http_request_firewall_custom` | `http_request_firewall_custom` |
281
+ | `http_ratelimit` | `http_ratelimit` |
282
+ | `http_request_cache_settings` | `http_request_cache_settings` |
283
+ | `http_request_transform` | `http_request_transform` |
284
+ | `http_request_late_transform` | `http_request_late_transform` |
285
+ | `http_response_headers_transform` | `http_response_headers_transform` |
286
+ | `cache_rules` | `http_request_cache_settings` |
287
+ | `rate_limit_rules` | `http_ratelimit` |
288
+ | `single_redirects` | `http_request_dynamic_redirect` |
289
+ | `origin_rules` | `http_request_origin` |
290
+
291
+ If you need to **replace** all defaults instead of merging, pass
292
+ `replaceExpressionKeys: true` or `replacePhaseMappings: true`.
293
+
294
+ ### ESLint Plugin
295
+
296
+ ```javascript
297
+ // eslint.config.js (flat config)
298
+ import cfExprLint from 'cloudflare-expression-lint/eslint-plugin';
299
+
300
+ export default [
301
+ {
302
+ files: ['config/**/*.yaml'],
303
+ plugins: { 'cf-expr': cfExprLint },
304
+ rules: {
305
+ 'cf-expr/validate-expression': ['error', {
306
+ // Custom mappings (merged with defaults)
307
+ customKeyMappings: {
308
+ 'rewrite_expression': 'rewrite_url',
309
+ 'source_url_expression': 'filter',
310
+ },
311
+ customPhaseMappings: {
312
+ 'waf_rules': 'http_request_firewall_custom',
313
+ },
314
+ }],
315
+ },
316
+ },
317
+ ];
318
+ ```
319
+
320
+ ## CI/CD Integration
321
+
322
+ ### GitLab CI
323
+
324
+ ```yaml
325
+ lint-expressions:
326
+ stage: validate
327
+ script:
328
+ - npx cloudflare-expression-lint config/**/*.yaml
329
+ allow_failure: false
330
+ ```
331
+
332
+ ### GitHub Actions
333
+
334
+ ```yaml
335
+ - name: Lint Cloudflare expressions
336
+ run: npx cloudflare-expression-lint config/**/*.yaml
337
+ ```
338
+
339
+ ### Pre-commit Hook
340
+
341
+ ```bash
342
+ #!/bin/sh
343
+ npx cloudflare-expression-lint $(git diff --cached --name-only --diff-filter=ACM -- '*.yaml' '*.yml')
344
+ ```
345
+
346
+ ## Extending the Schema
347
+
348
+ The field and function registries are defined in TypeScript files under `src/schemas/`:
349
+
350
+ - **`fields.ts`** — All known fields with types, deprecation status, and phase availability
351
+ - **`functions.ts`** — All known functions with parameter types, return types, context restrictions, and usage limits
352
+ - **`operators.ts`** — All comparison and logical operators with type constraints
353
+
354
+ To add a new field or function, edit the relevant schema file and add a new entry to the array. The tool will automatically pick it up.
355
+
356
+ ## Architecture
357
+
358
+ ```
359
+ src/
360
+ ├── lexer.ts # Tokenizer: string → Token[]
361
+ ├── parser.ts # Parser: Token[] → AST (recursive descent)
362
+ ├── validator.ts # Validator: AST → Diagnostic[] (semantic analysis)
363
+ ├── yaml-scanner.ts # YAML file scanner with phase inference
364
+ ├── cli.ts # CLI entry point
365
+ ├── types.ts # Shared type definitions
366
+ ├── index.ts # Public API exports
367
+ └── schemas/
368
+ ├── fields.ts # 211+ field definitions
369
+ ├── functions.ts # 25+ function definitions
370
+ └── operators.ts # Operator definitions
371
+ ```
372
+
373
+ ## License
374
+
375
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI for cloudflare-expression-lint.
4
+ *
5
+ * Usage:
6
+ * cf-expr-lint [options] <files...>
7
+ * cf-expr-lint --expression "http.host eq \"test.com\""
8
+ * cf-expr-lint --config .cf-expr-lint.json config/**\/*.yaml
9
+ *
10
+ * Options:
11
+ * --expression, -e Validate a single expression string
12
+ * --stdin Read expression from stdin
13
+ * --type, -t Expression type: filter, rewrite_url, rewrite_header, redirect_target
14
+ * --phase, -p Cloudflare phase (e.g., http_request_firewall_custom)
15
+ * --config, -c Path to config file (.json) with custom mappings
16
+ * --expr-key Add expression key mapping: key:type[:phase]
17
+ * --phase-map Add phase mapping: yaml_key:phase_name
18
+ * --format, -f Output format: text (default), json
19
+ * --quiet, -q Only output errors (suppress warnings)
20
+ * --help, -h Show this help message
21
+ */
22
+ export {};