2 Commits

Author SHA1 Message Date
k3nny a0e2582cf1 fix(linter): support bare true/false and integer literals in rules:if:
release / Build and publish release (push) Successful in 1m15s
GitLab CI expressions allow unquoted true, false, and integers as
comparison operands (all treated as their string representations):

  $GATEWAY_ENABLED == true    (equivalent to == "true")
  $FEATURE_FLAG == false      (equivalent to == "false")
  $PARALLEL == 4              (equivalent to == "4")
  $ENABLED == 1 / == 0

Previously these fell through to permissive true because parseValue
only recognised $VAR, "${VAR}", quoted strings, and null. Added:
  - true/false keyword branch → returns "true"/"false"
  - integer literal branch (digits only) → returns decimal string

All three new forms are correctly excluded from longer identifier
prefixes (identByte boundary check). Adds 8 new unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:20:59 +02:00
k3nny e931b9d1c9 fix(linter): improve rules:if: expression evaluator
Four correctness fixes to the GitLab CI expression parser in
internal/cicontext/eval.go:

- Multi-line: \n and \r are now treated as whitespace in skipWS so
  block-scalar or folded-scalar if: values with || / && on continuation
  lines evaluate correctly instead of falling back to permissive true.
- ${VAR} curly-brace variable syntax now supported in parseValue.
- Regex flags (/pattern/i, /pattern/m, /pattern/s) are now consumed and
  translated to Go (?i)/(?m)/(?s) prefixes via applyRegexFlags.
- Variable on RHS of =~ / !~: when the right operand is $VAR, the
  variable's value is interpreted as a /regex/[flags] string via
  extractRegexFromString; non-regex values fall back to permissive true.

Adds 16 new unit tests covering all four cases and a testdata fixture
(rules_if_expr.yml) exercising multi-line, ${VAR}, and /pattern/i in a
real pipeline with context flags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:08:08 +02:00
5 changed files with 263 additions and 22 deletions
+8
View File
@@ -9,6 +9,14 @@ This project uses [Semantic Versioning](https://semver.org).
### Added
- **`rules:if:` expression evaluator improvements** — six correctness fixes to the GitLab CI expression parser:
- **Multi-line expressions** — newlines (`\n`, `\r`) are now treated as whitespace between tokens, so block-scalar `if:` values (e.g. `if: | ...`) and folded YAML scalars with `||`/`&&` on a continuation line are parsed correctly instead of falling back to permissive `true`.
- **`${VAR}` curly-brace variable syntax** — `${CI_COMMIT_BRANCH}` is now equivalent to `$CI_COMMIT_BRANCH` everywhere a value is expected.
- **Regex flags** — `/pattern/i`, `/pattern/m`, `/pattern/s` are now honoured; the `i` flag (case-insensitive) is translated to Go's `(?i)` prefix before compiling. Unknown flags are silently ignored.
- **Variable as regex RHS** — `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/[flags]` string is now evaluated by extracting and compiling the pattern from the variable's value; if the value is empty or does not look like a regex literal the expression falls back to permissive `true`.
- **`true` / `false` keywords** — bare `true` and `false` (without quotes) are now recognised as the string values `"true"` and `"false"`, matching GitLab CI's own behaviour. `$GATEWAY_ENABLED == true` and `$FEATURE_FLAG == false` now evaluate correctly.
- **Integer literals** — bare integers (e.g. `$PARALLEL == 4`, `$ENABLED == 1`, `$DISABLED == 0`) are now parsed as their decimal string representations and compared accordingly.
- **File and line numbers on findings** — every finding now includes the source file and line where the job is defined, e.g. `[ERROR] job "deploy" (src/deploy.yml:14): …`. For jobs that come from local or fetched includes the file reflects the include source. Pipeline-level findings (workflow rules, missing stages) reference the root pipeline file.
- **`glint graph includes` shows jobs per file** — each node in the Mermaid include dependency graph now shows the jobs defined directly in that file. Jobs are rendered as rounded nodes (`(name)`) in a distinct light-purple style, connected with dashed arrows (`-.->`) to distinguish ownership from the include hierarchy (solid `-->` arrows). The root pipeline file always shows its direct jobs; local and fetched project/component nodes show theirs when the file can be read.
+6
View File
@@ -59,6 +59,12 @@ tasks:
ignore_error: false
- cmd: ./{{.BINARY}} check --tag v1.0.0 testdata/context_rules.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/rules_if_expr.yml
ignore_error: false
- cmd: ./{{.BINARY}} check --branch main testdata/rules_if_expr.yml
ignore_error: false
- cmd: ./{{.BINARY}} check --branch feat/x testdata/rules_if_expr.yml
ignore_error: false
- cmd: ./{{.BINARY}} check samba-testdata/.gitlab-ci.yml
ignore_error: false
- cmd: ./{{.BINARY}} check samba-testdata/.gitlab-ci-coverage.yml
+145 -17
View File
@@ -9,12 +9,15 @@ import (
// variable resolver.
//
// Supported:
// - Variable references: $VAR_NAME
// - Variable references: $VAR_NAME or ${VAR_NAME}
// - String literals: "value" or 'value'
// - Null keyword: null
// - Comparison: == != =~ !~
// - Boolean: && || !
// - Grouping: ( )
// - Regex flags: /pattern/i (case-insensitive), /pattern/m, /pattern/s
// - Multi-line: newlines between tokens are treated as whitespace
// - Variable regex RHS: $VAR =~ $PATTERN when $PATTERN holds a /regex/ string
//
// Regex patterns use Go's regexp syntax, which covers the common RE2 subset
// used by GitLab CI. Unsupported or unparseable expressions fall back to true
@@ -56,8 +59,13 @@ func (p *exprParser) consume(tok string) bool {
}
func (p *exprParser) skipWS() {
for p.pos < len(p.s) && (p.s[p.pos] == ' ' || p.s[p.pos] == '\t') {
for p.pos < len(p.s) {
b := p.s[p.pos]
if b == ' ' || b == '\t' || b == '\n' || b == '\r' {
p.pos++
continue
}
break
}
}
@@ -67,11 +75,11 @@ func (p *exprParser) skipWS() {
// and_expr → not_expr ( '&&' not_expr )*
// not_expr → '!' not_expr | primary
// primary → '(' or_expr ')' | comparison
// comparison → value ( op value | regex_op regex )? | value
// value → '$' ident | '"' … '"' | "'" … "'" | 'null'
// comparison → value ( op value | regex_op regex_rhs )?
// value → '$' '{' ident '}' | '$' ident | '"' … '"' | "'" … "'" | 'null'
// op → '==' | '!='
// regex_op → '=~' | '!~'
// regex '/' … '/'
// regex_rhs → '/' … '/' flags? | '$' ident (where ident value is '/…/flags')
func (p *exprParser) parseOr() (bool, bool) {
left, ok := p.parseAnd()
@@ -165,8 +173,11 @@ func (p *exprParser) parseComparison() (bool, bool) {
case p.consume("=~"):
p.skipWS()
pat, ok := p.parseRegexLiteral()
if !ok {
pat, patOk, permissive := p.parseRegexRHS()
if permissive {
return true, true
}
if !patOk {
return false, false
}
re, err := regexp.Compile(pat)
@@ -177,8 +188,11 @@ func (p *exprParser) parseComparison() (bool, bool) {
case p.consume("!~"):
p.skipWS()
pat, ok := p.parseRegexLiteral()
if !ok {
pat, patOk, permissive := p.parseRegexRHS()
if permissive {
return true, true
}
if !patOk {
return false, false
}
re, err := regexp.Compile(pat)
@@ -192,13 +206,49 @@ func (p *exprParser) parseComparison() (bool, bool) {
return leftStr != "", true
}
// parseValue reads $VAR, "string", 'string', or null.
// null and undefined variables both produce an empty string.
// parseRegexRHS parses the right-hand side of =~ / !~ operators.
// Returns (pattern, ok, permissive):
// - /regex/flags literal → (pattern, true, false)
// - $VAR whose value is /regex/flags → (pattern, true, false)
// - $VAR whose value is empty or not a /regex/ → ("", false, true) — caller uses permissive true
// - parse error → ("", false, false)
func (p *exprParser) parseRegexRHS() (pat string, ok bool, permissive bool) {
if p.peek() == '/' {
pat, ok = p.parseRegexLiteral()
return pat, ok, false
}
if p.peek() == '$' {
varVal, varOk := p.parseValue()
if !varOk {
return "", false, false
}
pat, ok = extractRegexFromString(varVal)
if !ok {
return "", false, true // variable is not a /regex/ value → permissive
}
return pat, true, false
}
return "", false, false
}
// parseValue reads $VAR, ${VAR}, "string", 'string', null, true, false, or an
// integer literal. null and undefined variables both produce an empty string.
// true/false and integers produce their string representations (GitLab CI
// compares all values as strings).
func (p *exprParser) parseValue() (string, bool) {
p.skipWS()
if p.peek() == '$' {
p.pos++ // consume '$'
if p.peek() == '{' {
p.pos++ // consume '{'
name := p.parseIdent()
if name == "" || p.peek() != '}' {
return "", false
}
p.pos++ // consume '}'
return p.vars(name), true
}
name := p.parseIdent()
if name == "" {
return "", false
@@ -206,12 +256,18 @@ func (p *exprParser) parseValue() (string, bool) {
return p.vars(name), true
}
// null keyword — must not be a prefix of a longer identifier.
if p.startsWith("null") {
end := p.pos + 4
// Keywords and string literals must not be prefixes of longer identifiers.
for _, kw := range []struct{ tok, val string }{
{"null", ""},
{"true", "true"},
{"false", "false"},
} {
if p.startsWith(kw.tok) {
end := p.pos + len(kw.tok)
if end >= len(p.s) || !isIdentByte(p.s[end]) {
p.pos += 4
return "", true // null → empty string
p.pos += len(kw.tok)
return kw.val, true
}
}
}
@@ -219,6 +275,15 @@ func (p *exprParser) parseValue() (string, bool) {
return p.parseStringLiteral()
}
// Integer literal — returned as its decimal string for string comparison.
if p.peek() >= '0' && p.peek() <= '9' {
start := p.pos
for p.pos < len(p.s) && p.s[p.pos] >= '0' && p.s[p.pos] <= '9' {
p.pos++
}
return p.s[start:p.pos], true
}
return "", false
}
@@ -261,7 +326,8 @@ func (p *exprParser) parseRegexLiteral() (string, bool) {
b := p.s[p.pos]
if b == '/' {
p.pos++ // consume closing '/'
return sb.String(), true
flags := p.parseRegexFlags()
return applyRegexFlags(flags, sb.String()), true
}
if b == '\\' && p.pos+1 < len(p.s) {
p.pos++
@@ -275,6 +341,68 @@ func (p *exprParser) parseRegexLiteral() (string, bool) {
return "", false // unterminated regex
}
// parseRegexFlags reads zero or more regex flag letters (i, m, s) after the
// closing '/'. Unknown letters are consumed but ignored.
func (p *exprParser) parseRegexFlags() string {
start := p.pos
for p.pos < len(p.s) && isIdentByte(p.s[p.pos]) {
p.pos++
}
return p.s[start:p.pos]
}
// applyRegexFlags prepends Go regexp flag groups to pattern (e.g. (?i) for 'i').
// Unknown flags are silently ignored.
func applyRegexFlags(flags, pattern string) string {
if flags == "" {
return pattern
}
var prefix strings.Builder
for _, f := range flags {
switch f {
case 'i':
prefix.WriteString("(?i)")
case 'm':
prefix.WriteString("(?m)")
case 's':
prefix.WriteString("(?s)")
}
}
return prefix.String() + pattern
}
// extractRegexFromString parses a /pattern/flags string (typically from a CI
// variable) and returns a Go regexp pattern with flags applied.
func extractRegexFromString(s string) (string, bool) {
s = strings.TrimSpace(s)
if len(s) == 0 || s[0] != '/' {
return "", false
}
var sb strings.Builder
i := 1
for i < len(s) {
b := s[i]
if b == '/' {
i++ // past closing '/'
var flags strings.Builder
for i < len(s) && isIdentByte(s[i]) {
flags.WriteByte(s[i])
i++
}
return applyRegexFlags(flags.String(), sb.String()), true
}
if b == '\\' && i+1 < len(s) {
i++
sb.WriteByte('\\')
sb.WriteByte(s[i])
} else {
sb.WriteByte(b)
}
i++
}
return "", false // unterminated
}
func isIdentByte(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
}
+48
View File
@@ -9,6 +9,10 @@ func TestEvalIf(t *testing.T) {
"CI_COMMIT_TAG": "",
"CI_PIPELINE_SOURCE": "push",
"DEPLOY_ENV": "staging",
"BRANCH_PATTERN": "/^dev/",
"BRANCH_PATTERN_CI": "/^DEV/i",
"EMPTY_PATTERN": "",
"PLAIN_PATTERN": "develop",
}
return m[key]
}
@@ -69,6 +73,50 @@ func TestEvalIf(t *testing.T) {
{"extra spaces", ` $CI_COMMIT_BRANCH == "develop" `, true},
{"tabs", "$CI_COMMIT_BRANCH\t==\t\"develop\"", true},
// ── Multi-line expressions (newlines between tokens) ──────────────────
{"multiline or true", "$CI_COMMIT_BRANCH == \"develop\" ||\n$CI_COMMIT_TAG != null", true},
{"multiline or false", "$CI_COMMIT_BRANCH == \"main\" ||\n$CI_COMMIT_TAG != null", false},
{"multiline and true", "$CI_COMMIT_BRANCH == \"develop\" &&\n$CI_PIPELINE_SOURCE == \"push\"", true},
{"multiline and false", "$CI_COMMIT_BRANCH == \"main\" &&\n$CI_PIPELINE_SOURCE == \"push\"", false},
{"multiline with crlf", "$CI_COMMIT_BRANCH == \"develop\" ||\r\n$CI_COMMIT_TAG != null", true},
// ── ${VAR} curly-brace syntax ─────────────────────────────────────────
{"curly var eq match", `${CI_COMMIT_BRANCH} == "develop"`, true},
{"curly var eq no match", `${CI_COMMIT_BRANCH} == "main"`, false},
{"curly var truthiness", `${CI_COMMIT_BRANCH}`, true},
{"curly var falsy", `${CI_COMMIT_TAG}`, false},
{"curly var neq null", `${CI_COMMIT_BRANCH} != null`, true},
{"curly mixed", `${CI_COMMIT_BRANCH} == "develop" && $CI_PIPELINE_SOURCE == "push"`, true},
// ── Regex flags (/pattern/i etc.) ─────────────────────────────────────
{"regex flag i match", `$CI_COMMIT_BRANCH =~ /^DEV/i`, true},
{"regex flag i no match", `$CI_COMMIT_BRANCH =~ /^MAIN/i`, false},
{"regex flag i not match", `$CI_COMMIT_BRANCH !~ /^MAIN/i`, true},
{"regex no flag case sensitive", `$CI_COMMIT_BRANCH =~ /^DEV/`, false},
{"regex flag i version tag", `$CI_PIPELINE_SOURCE =~ /^PUSH$/i`, true},
// ── Variable on right side of =~ ──────────────────────────────────────
{"var regex rhs match", `$CI_COMMIT_BRANCH =~ $BRANCH_PATTERN`, true},
{"var regex rhs no match", `$CI_PIPELINE_SOURCE =~ $BRANCH_PATTERN`, false},
{"var regex rhs ci flag match", `$CI_COMMIT_BRANCH =~ $BRANCH_PATTERN_CI`, true},
{"var regex rhs empty permissive", `$CI_COMMIT_BRANCH =~ $EMPTY_PATTERN`, true},
{"var regex rhs plain permissive", `$CI_COMMIT_BRANCH =~ $PLAIN_PATTERN`, true},
{"var regex rhs not match", `$CI_COMMIT_BRANCH !~ $BRANCH_PATTERN`, false},
// ── Bare true/false keywords ─────────────────────────────────────────
// GitLab CI treats true/false as the string values "true"/"false".
{"bare true match", `$CI_PIPELINE_SOURCE == true`, false}, // "push" != "true"
{"bare false match", `$CI_COMMIT_TAG == false`, false}, // "" != "false"
{"bare true var set to true", `$DEPLOY_ENV == true`, false}, // "staging" != "true"
{"bare false neq", `$CI_COMMIT_BRANCH != false`, true}, // "develop" != "false"
{"bare true in compound", `$CI_COMMIT_BRANCH != null && $CI_COMMIT_TAG == false`, false},
// ── Integer literals ──────────────────────────────────────────────────
// Compared as decimal strings (GitLab CI converts integers to strings).
{"int eq match", `$CI_PIPELINE_SOURCE != 0`, true}, // "push" != "0"
{"int eq no match", `$CI_COMMIT_TAG == 0`, false}, // "" != "0"
{"int in compound", `$CI_COMMIT_BRANCH != null && $CI_COMMIT_BRANCH != 0`, true},
// ── Permissive fallback ───────────────────────────────────────────────
{"unparseable returns true", `this is not valid syntax %%%`, true},
{"empty expr returns true", ``, true},
+51
View File
@@ -0,0 +1,51 @@
---
# rules_if_expr.yml
# Exercises the rules:if: expression evaluator for:
# - Multi-line block-scalar expressions (|| on next line)
# - ${VAR} curly-brace variable syntax
# - Regex flags (/pattern/i case-insensitive)
# - Parenthesised compound expressions
# Expected: exits 0 (lints clean with no context).
stages:
- build
- deploy
variables:
DEPLOY_ENVIRONMENTS:
value: "staging"
description: "Target deployment environment"
build:
stage: build
script: make build
rules:
# Multi-line expression: || on next line
- if: |
$CI_COMMIT_BRANCH == "main" ||
$CI_COMMIT_BRANCH == "develop"
when: on_success
- when: never
deploy-feature:
stage: deploy
script: make deploy
rules:
# ${VAR} curly-brace syntax
- if: '${CI_COMMIT_BRANCH} != null && ${CI_COMMIT_TAG} == null'
when: manual
- when: never
release:
stage: deploy
script: make release
rules:
# Case-insensitive regex flag
- if: '$CI_COMMIT_BRANCH =~ /^(main|master)$/i'
when: on_success
# Parenthesised compound with multi-line
- if: >-
($CI_COMMIT_TAG != null) &&
($CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "web")
when: on_success
- when: never