Repository: Kong/kong
Pull request #14364: chore(lint): add ast-grep lint rules and CI workflow
Author: login: flrgh
Labels: name: core/clustering
name: core/docs
name: skip-changelog
name: chore
description: Not part of the core functionality of kong, but still needed
name: size/XL
name: cherry-pick kong-ee
description: schedule this PR for cherry-picking to kong/kong-ee
name: plugins/ai-proxy
name: plugins/ai-request-transformer
name: plugins/ai-response-transformer
Created: 2025-03-17T02:20:06Z | Merged: 2025-03-26T18:08:29Z

## Summary

This is an experiment in using [ast-grep](https://github.com/ast-grep/ast-grep) to craft our own semantic lint rules.

`ast-grep` is a tool for querying source code in a (relatively) language-agnostic manner. It allows us to write lint rules that target patterns that are specific to our codebase and therefore not covered by tools like `luacheck`.

In practice I expect this to be used mostly for lua files, but in theory we could use it to write lints for [all sorts of languages/filetypes](https://ast-grep.github.io/reference/languages.html).

## CI Components

Everything here runs in the new `ast-grep.yml` workflow, which is triggered on a PR event with changes to any `ast-grep`-related files (rules, config, etc) or any `*.lua` source code files.

### Lint

Errors and warnings behave as expected and create annotations on the job summary when encountered:

<details>
<summary>screenshot</summary>

![image](https://github.com/user-attachments/assets/a010e0d7-649e-47f2-8ed7-90a4f1fbe66c)

</details>

### Self Test

`ast-grep` has a test feature which allows you to check your rules against code snippets. Additionally, the workflow runs some bash glue code that checks for well-formed rule files and, importantly, _requires that all rules have tests_.

## New Lints

I've added 3 lints to start with:

* `ngx-log-string-concat`
    * files: `kong/**`
    * severity: error
    * matches: unsafe usage of `ngx.log()`
* `assert-eventually-terminated`
    * files: `**/*_spec.lua`
    * severity: error
    * matches: any unterminated `assert.eventually()` call site
* `helpers-outside-of-setup`
    * files: `**/*_spec.lua`
    * severity: warning
    * matches: using some `spec.helpers` functions outside of `setup()`

Some things could be warnings today but elevated to error level in the future.

KAG-6656


Diff:
diff --git a/.ci/ast-grep/README.md b/.ci/ast-grep/README.md
new file mode 100644
index 00000000000..67e686a8ec9
--- /dev/null
+++ b/.ci/ast-grep/README.md
@@ -0,0 +1,43 @@
+# ast-grep
+
+`ast-grep` is a tool for querying source code in a (relatively)
+language-agnostic manner. It allows us to write lint rules that target patterns
+that are specific to our codebase and therefore not covered by tools like
+`luacheck`.
+
+## Installing ast-grep
+
+See the [installation docs](https://ast-grep.github.io/guide/quick-start.html#installation)
+for guidance.
+
+
+## Crafting a New Lint Rule
+
+The workflow for writing a new lint rule looks like this:
+
+1. Draft your rule at `.ci/ast-grep/rules/${name}.yml`
+    * Use `ast-grep scan --filter ${name} [paths...]` to evaluate your rule's behavior
+2. Write tests for the rule in `.ci/ast-grep/tests/${name}-test.yml`
+    * Make sure to fill out several `valid` and `invalid` code snippets
+    * Use `ast-grep test --interactive`* to test the rule
+3. `git add .gi/ast-grep && git commit ...`
+
+\* `ast-grep test` uses a file snapshot testing pattern. Almost any time a rule
+or test is created/modified, the snapshots must be updated. The `--interactive`
+flag for `ast-grep test` will prompt you to accept these updates. The snapshots
+provide very granular testing for rule behavior, but for many cases where we
+just care about whether or not a rule matches a certain snippet of code, they
+can be overkill. Use `ast-grep --update-all` to automatically accept and save
+new snapshots.
+
+## CI
+
+`ast-grep` is executed in the ([ast-grep lint
+workflow](/.github/workflows/ast-grep.yml)). In addition to running the linter,
+this workflow also performs self-tests and ensures that all existing rules are
+well-formed and have tests associated with them.
+
+### Links
+
+* [ast-grep website and documentation](https://ast-grep.github.io)
+* [ast-grep source code](https://github.com/ast-grep/ast-grep)
diff --git a/.ci/ast-grep/common/.gitkeep b/.ci/ast-grep/common/.gitkeep
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/.ci/ast-grep/rules/.gitkeep b/.ci/ast-grep/rules/.gitkeep
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/.ci/ast-grep/rules/assert-eventually-terminated.yml b/.ci/ast-grep/rules/assert-eventually-terminated.yml
new file mode 100644
index 00000000000..e83d9f8ef12
--- /dev/null
+++ b/.ci/ast-grep/rules/assert-eventually-terminated.yml
@@ -0,0 +1,36 @@
+id: assert-eventually-terminated
+language: lua
+message: Unterminated eventual assertion
+severity: error
+note: |
+  `assert.eventually()` does not perform any assertion unless followed
+  by one of its terminator methods:
+    * `is_truthy(message)`
+    * `is_falsy(message)`
+    * `has_error(message)`
+    * `has_no_error(message)`
+
+files:
+  - '**/*_spec.lua'
+
+rule:
+  all:
+    - kind: function_call
+      pattern: $$$.eventually($$$)
+
+    - has:
+        kind: dot_index_expression
+        any:
+          - pattern: assert.$$$
+          - pattern: luassert.$$$
+        stopBy: end
+
+    - not:
+        inside:
+          kind: function_call
+          any:
+            - pattern: $$$.is_truthy($$$)
+            - pattern: $$$.is_falsy($$$)
+            - pattern: $$$.has_error($$$)
+            - pattern: $$$.has_no_error($$$)
+          stopBy: end
diff --git a/.ci/ast-grep/rules/helpers-outside-of-setup.yml b/.ci/ast-grep/rules/helpers-outside-of-setup.yml
new file mode 100644
index 00000000000..0f7190b2660
--- /dev/null
+++ b/.ci/ast-grep/rules/helpers-outside-of-setup.yml
@@ -0,0 +1,102 @@
+id: helpers-outside-of-setup
+language: lua
+message: Calling test setup helper function in the wrong scope
+severity: warning
+note: |
+  Avoid calling test setup functions outside of `setup()`/`lazy_setup()`.
+
+  ## good
+  ```lua
+  describe("my test", function()
+    local port_a
+    local port_b
+
+    lazy_setup(function()
+      port_a = helpers.get_available_port()
+    end)
+
+    it("my test case", function()
+      port_b = helpers.get_available_port()
+    end)
+  end)
+
+  ## bad
+  ```lua
+  local port_a = helpers.get_available_port()
+  describe("my test", function()
+    local port_b = helpers.get_available_port()
+
+    it("my test case", function()
+    end)
+  end)
+
+
+files:
+  - '**/*_spec.lua'
+
+utils:
+  function-scope:
+    any:
+      - kind: function_call
+      - kind: function_declaration
+      - kind: function_definition
+
+  module-scope:
+    not:
+      inside:
+        matches: function-scope
+        stopBy: end
+
+  busted-test-case:
+    inside:
+      kind: function_call
+      any:
+        - pattern: it($$$)
+        - pattern: pending($$$)
+
+        # aliases for it/pending seen in test files
+        - pattern: do_it($$$)
+        - pattern: postgres_only($$$)
+
+  busted-lifecycle:
+    inside:
+      kind: function_call
+      any:
+        - pattern: setup($$$)
+        - pattern: lazy_setup($$$)
+        - pattern: teardown($$$)
+        - pattern: lazy_teardown($$$)
+        - pattern: before_each($$$)
+        - pattern: after_each($$$)
+
+  busted-describe:
+    inside:
+      kind: function_call
+      pattern: describe($$$)
+      stopBy:
+        any:
+          - matches: busted-test-case
+          - matches: busted-lifecycle
+          - kind: function_declaration
+
+  non-setup-scope:
+    any:
+      - matches: module-scope
+      - matches: busted-describe
+
+rule:
+  kind: function_call
+  pattern: helpers.$FUNC($$$)
+  matches: non-setup-scope
+
+constraints:
+  FUNC:
+    kind: identifier
+    any:
+      - pattern: admin_client
+      - pattern: generate_keys
+      - pattern: get_available_port
+      - pattern: get_db_utils
+      - pattern: setenv
+      - pattern: start_kong
+      - pattern: tcp_server
diff --git a/.ci/ast-grep/rules/ngx-log-string-concat.yml b/.ci/ast-grep/rules/ngx-log-string-concat.yml
new file mode 100644
index 00000000000..397a84df042
--- /dev/null
+++ b/.ci/ast-grep/rules/ngx-log-string-concat.yml
@@ -0,0 +1,65 @@
+id: ngx-log-string-concat
+language: lua
+message: Using string concatenation to build arguments for ngx.log()
+severity: error
+note: |
+  When invoking `ngx.log()` with some variable as input, prefer vararg-style
+  calls rather than using the string concatenation operator (`..`):
+
+  ## bad
+  ```lua
+  ngx.log(ngx.DEBUG, "if `my_var` is nil, this code throws an exception: " .. my_var)
+  ```
+
+  ## good
+  ```lua
+  ngx.log(ngx.DEBUG, "if `my_var` is nil, this code is fine: ", my_var)
+  ```
+
+files:
+  - kong/**
+  - test*.lua
+
+rule:
+  all:
+    - matches: string-concat
+      inside:
+        kind: arguments
+        inside:
+          matches: ngx-log-call
+    - not:
+        matches: string-literal-concat
+
+utils:
+  ngx-log-call:
+    any:
+      # direct invocation of `_G.ngx.log()`
+      - pattern: ngx.log($_LEVEL, $$$)
+
+      # track local var assignments of `_G.ngx.log`
+      - pattern: $IDENT($_LEVEL, $$$)
+        inside:
+          kind: chunk
+          stopBy: end
+          has:
+            pattern: $IDENT = ngx.log
+            stopBy: end
+
+  string-concat:
+    kind: binary_expression
+    pattern: $LHS .. $RHS
+
+  string-literal-concat:
+    kind: binary_expression
+    all:
+      - has:
+          nthChild: 1
+          any:
+            - kind: string
+            - matches: string-literal-concat
+
+      - has:
+          nthChild: 2
+          any:
+            - kind: string
+            - matches: string-literal-concat
diff --git a/.ci/ast-grep/tests/.gitkeep b/.ci/ast-grep/tests/.gitkeep
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/.ci/ast-grep/tests/__snapshots__/assert-eventually-terminated-snapshot.yml b/.ci/ast-grep/tests/__snapshots__/assert-eventually-terminated-snapshot.yml
new file mode 100644
index 00000000000..e0e6118741d
--- /dev/null
+++ b/.ci/ast-grep/tests/__snapshots__/assert-eventually-terminated-snapshot.yml
@@ -0,0 +1,104 @@
+id: assert-eventually-terminated
+snapshots:
+  assert.eventually(function() end):
+    labels:
+    - source: assert.eventually(function() end)
+      style: primary
+      start: 0
+      end: 33
+    - source: assert.eventually
+      style: secondary
+      start: 0
+      end: 17
+  ? |
+    assert.eventually(function() end)
+  : labels:
+    - source: assert.eventually(function() end)
+      style: primary
+      start: 0
+      end: 33
+    - source: assert.eventually
+      style: secondary
+      start: 0
+      end: 17
+  assert.eventually(function() end).with_timeout(1):
+    labels:
+    - source: assert.eventually(function() end)
+      style: primary
+      start: 0
+      end: 33
+    - source: assert.eventually
+      style: secondary
+      start: 0
+      end: 17
+  assert.with_timeout(1).eventually(function() end):
+    labels:
+    - source: assert.with_timeout(1).eventually(function() end)
+      style: primary
+      start: 0
+      end: 49
+    - source: assert.with_timeout
+      style: secondary
+      start: 0
+      end: 19
+  assert.with_timeout(1).eventually(function() end).with_timeout(1):
+    labels:
+    - source: assert.with_timeout(1).eventually(function() end)
+      style: primary
+      start: 0
+      end: 49
+    - source: assert.with_timeout
+      style: secondary
+      start: 0
+      end: 19
+  luassert.eventually(function() end):
+    labels:
+    - source: luassert.eventually(function() end)
+      style: primary
+      start: 0
+      end: 35
+    - source: luassert.eventually
+      style: secondary
+      start: 0
+      end: 19
+  ? |
+    luassert.eventually(function() end)
+  : labels:
+    - source: luassert.eventually(function() end)
+      style: primary
+      start: 0
+      end: 35
+    - source: luassert.eventually
+      style: secondary
+      start: 0
+      end: 19
+  luassert.eventually(function() end).with_timeout(1):
+    labels:
+    - source: luassert.eventually(function() end)
+      style: primary
+      start: 0
+      end: 35
+    - source: luassert.eventually
+      style: secondary
+      start: 0
+      end: 19
+  luassert.with_timeout(1).eventually(function() end):
+    labels:
+    - source: luassert.with_timeout(1).eventually(function() end)
+      style: primary
+      start: 0
+      end: 51
+    - source: luassert.with_timeout
+      style: secondary
+      start: 0
+      end: 21
+  luassert.with_timeout(1).eventually(function() end).with_timeout(1):
+    labels:
+    - source: luassert.with_timeout(1).eventually(function() end)
+      style: primary
+      start: 0
+      end: 51
+    - source: luassert.with_timeout
+      style: secondary
+      start: 0
+      end: 21
diff --git a/.ci/ast-grep/tests/__snapshots__/helpers-outside-of-setup-snapshot.yml b/.ci/ast-grep/tests/__snapshots__/helpers-outside-of-setup-snapshot.yml
new file mode 100644
index 00000000000..93bef2a55c0
--- /dev/null
+++ b/.ci/ast-grep/tests/__snapshots__/helpers-outside-of-setup-snapshot.yml
@@ -0,0 +1,44 @@
+id: helpers-outside-of-setup
+snapshots:
+  ? |
+    describe(function()
+      local a = 123
+      local port = helpers.get_available_port()
+    end)
+  : labels:
+    - source: helpers.get_available_port()
+      style: primary
+      start: 51
+      end: 79
+    - source: |-
+        describe(function()
+          local a = 123
+          local port = helpers.get_available_port()
+        end)
+      style: secondary
+      start: 0
+      end: 84
+  ? |
+    for , strategy in helpers.each_strategy() do
+      local a = 123
+      local port = helpers.get_available_port()
+    end
+  : labels:
+    - source: helpers.get_available_port()
+      style: primary
+      start: 76
+      end: 104
+  ? |
+    local a = 123
+    local port = helpers.get_available_port()
+  : labels:
+    - source: helpers.get_available_port()
+      style: primary
+      start: 27
+      end: 55
+  local port = helpers.get_available_port():
+    labels:
+    - source: helpers.get_available_port()
+      style: primary
+      start: 13
+      end: 41
diff --git a/.ci/ast-grep/tests/__snapshots__/ngx-log-string-concat-snapshot.yml b/.ci/ast-grep/tests/__snapshots__/ngx-log-string-concat-snapshot.yml
new file mode 100644
index 00000000000..de1af2890b2
--- /dev/null
+++ b/.ci/ast-grep/tests/__snapshots__/ngx-log-string-concat-snapshot.yml
@@ -0,0 +1,264 @@
+id: ngx-log-string-concat
+snapshots:
+  ? |
+    local foo = ngx.log
+
+    foo(ngx.NOTICE, b .. c .. ": STRING")
+  : labels:
+    - source: 'b .. c .. ": STRING"'
+      style: primary
+      start: 37
+      end: 57
+    - source: foo = ngx.log
+      style: secondary
+      start: 6
+      end: 19
+    - source: |
+        local foo = ngx.log
+
+        foo(ngx.NOTICE, b .. c .. ": STRING")
+      style: secondary
+      start: 0
+      end: 59
+    - source: 'foo(ngx.NOTICE, b .. c .. ": STRING")'
+      style: secondary
+      start: 21
+      end: 58
+    - source: '(ngx.NOTICE, b .. c .. ": STRING")'
+      style: secondary
+      start: 24
+      end: 58
+  ? |
+    local foo = ngx.log
+
+    if true then
+      local function my_log(a, b, c)
+        foo(ngx.NOTICE, b .. c .. ": STRING")
+      end
+    end
+  : labels:
+    - source: 'b .. c .. ": STRING"'
+      style: primary
+      start: 87
+      end: 107
+    - source: foo = ngx.log
+      style: secondary
+      start: 6
+      end: 19
+    - source: |
+        local foo = ngx.log
+
+        if true then
+          local function my_log(a, b, c)
+            foo(ngx.NOTICE, b .. c .. ": STRING")
+          end
+        end
+      style: secondary
+      start: 0
+      end: 119
+    - source: 'foo(ngx.NOTICE, b .. c .. ": STRING")'
+      style: secondary
+      start: 71
+      end: 108
+    - source: '(ngx.NOTICE, b .. c .. ": STRING")'
+      style: secondary
+      start: 74
+      end: 108
+  ? |
+    local ngx_log = ngx.log
+    local foo = ngx.log
+
+    if true then
+      local function my_log(a, b, c)
+        ngx_log(ngx.ERR, "STRING: " .. a)
+        foo(ngx.NOTICE, b .. c .. ": STRING")
+      end
+    end
+  : labels:
+    - source: '"STRING: " .. a'
+      style: primary
+      start: 112
+      end: 127
+    - source: ngx_log = ngx.log
+      style: secondary
+      start: 6
+      end: 23
+    - source: |
+        local ngx_log = ngx.log
+        local foo = ngx.log
+
+        if true then
+          local function my_log(a, b, c)
+            ngx_log(ngx.ERR, "STRING: " .. a)
+            foo(ngx.NOTICE, b .. c .. ": STRING")
+          end
+        end
+      style: secondary
+      start: 0
+      end: 181
+    - source: 'ngx_log(ngx.ERR, "STRING: " .. a)'
+      style: secondary
+      start: 95
+      end: 128
+    - source: '(ngx.ERR, "STRING: " .. a)'
+      style: secondary
+      start: 102
+      end: 128
+  ? |
+    local ngx_log = ngx.log
+    local foo = ngx.log
+
+    local function my_log(a, b, c)
+      ngx_log(ngx.ERR, "STRING: " .. a)
+      foo(ngx.NOTICE, b .. c .. ": STRING")
+    end
+    my_log(1, 2, 3)
+  : labels:
+    - source: '"STRING: " .. a'
+      style: primary
+      start: 95
+      end: 110
+    - source: ngx_log = ngx.log
+      style: secondary
+      start: 6
+      end: 23
+    - source: |
+        local ngx_log = ngx.log
+        local foo = ngx.log
+
+        local function my_log(a, b, c)
+          ngx_log(ngx.ERR, "STRING: " .. a)
+          foo(ngx.NOTICE, b .. c .. ": STRING")
+        end
+        my_log(1, 2, 3)
+      style: secondary
+      start: 0
+      end: 172
+    - source: 'ngx_log(ngx.ERR, "STRING: " .. a)'
+      style: secondary
+      start: 78
+      end: 111
+    - source: '(ngx.ERR, "STRING: " .. a)'
+      style: secondary
+      start: 85
+      end: 111
+  ? |
+    local ngx_log = ngx.log
+    local foo = ngx.log
+
+    ngx_log(ngx.ERR, "STRING: " .. a)
+    foo(ngx.NOTICE, b .. c .. ": STRING")
+  : labels:
+    - source: '"STRING: " .. a'
+      style: primary
+      start: 62
+      end: 77
+    - source: ngx_log = ngx.log
+      style: secondary
+      start: 6
+      end: 23
+    - source: |
+        local ngx_log = ngx.log
+        local foo = ngx.log
+
+        ngx_log(ngx.ERR, "STRING: " .. a)
+        foo(ngx.NOTICE, b .. c .. ": STRING")
+      style: secondary
+      start: 0
+      end: 117
+    - source: 'ngx_log(ngx.ERR, "STRING: " .. a)'
+      style: secondary
+      start: 45
+      end: 78
+    - source: '(ngx.ERR, "STRING: " .. a)'
+      style: secondary
+      start: 52
+      end: 78
+  ? |
+    ngx.log(ngx.INFO, "STRING: " .. my_var .. ": STRING")
+  : labels:
+    - source: '"STRING: " .. my_var .. ": STRING"'
+      style: primary
+      start: 18
+      end: 52
+    - source: 'ngx.log(ngx.INFO, "STRING: " .. my_var .. ": STRING")'
+      style: secondary
+      start: 0
+      end: 53
+    - source: '(ngx.INFO, "STRING: " .. my_var .. ": STRING")'
+      style: secondary
+      start: 7
+      end: 53
+  'ngx.log(ngx.INFO, "STRING: " .. my_var)':
+    labels:
+    - source: '"STRING: " .. my_var'
+      style: primary
+      start: 18
+      end: 38
+    - source: 'ngx.log(ngx.INFO, "STRING: " .. my_var)'
+      style: secondary
+      start: 0
+      end: 39
+    - source: '(ngx.INFO, "STRING: " .. my_var)'
+      style: secondary
+      start: 7
+      end: 39
+  ? |
+    ngx.log(ngx.INFO, "STRING: " .. my_var)
+  : labels:
+    - source: '"STRING: " .. my_var'
+      style: primary
+      start: 18
+      end: 38
+    - source: 'ngx.log(ngx.INFO, "STRING: " .. my_var)'
+      style: secondary
+      start: 0
+      end: 39
+    - source: '(ngx.INFO, "STRING: " .. my_var)'
+      style: secondary
+      start: 7
+      end: 39
+  ? |
+    ngx.log(ngx.INFO, my_var .. ": STRING :" .. my_other_var)
+  : labels:
+    - source: 'my_var .. ": STRING :" .. my_other_var'
+      style: primary
+      start: 18
+      end: 56
+    - source: 'ngx.log(ngx.INFO, my_var .. ": STRING :" .. my_other_var)'
+      style: secondary
+      start: 0
+      end: 57
+    - source: '(ngx.INFO, my_var .. ": STRING :" .. my_other_var)'
+      style: secondary
+      start: 7
+      end: 57
+  'ngx.log(ngx.INFO, my_var .. ": STRING")':
+    labels:
+    - source: 'my_var .. ": STRING"'
+      style: primary
+      start: 18
+      end: 38
+    - source: 'ngx.log(ngx.INFO, my_var .. ": STRING")'
+      style: secondary
+      start: 0
+      end: 39
+    - source: '(ngx.INFO, my_var .. ": STRING")'
+      style: secondary
+      start: 7
+      end: 39
+  ? |
+    ngx.log(ngx.INFO, my_var .. ": STRING")
+  : labels:
+    - source: 'my_var .. ": STRING"'
+      style: primary
+      start: 18
+      end: 38
+    - source: 'ngx.log(ngx.INFO, my_var .. ": STRING")'
+      style: secondary
+      start: 0
+      end: 39
+    - source: '(ngx.INFO, my_var .. ": STRING")'
+      style: secondary
+      start: 7
+      end: 39
diff --git a/.ci/ast-grep/tests/assert-eventually-terminated-test.yml b/.ci/ast-grep/tests/assert-eventually-terminated-test.yml
new file mode 100644
index 00000000000..a81f2c89adf
--- /dev/null
+++ b/.ci/ast-grep/tests/assert-eventually-terminated-test.yml
@@ -0,0 +1,49 @@
+id: assert-eventually-terminated
+
+valid:
+  # simple, all terminators
+  - |
+    assert.eventually(function() end).is_truthy()
+    assert.eventually(function() end).is_falsy()
+    assert.eventually(function() end).has_error()
+    assert.eventually(function() end).has_no_error()
+
+  # luassert counts too
+  - |
+    luassert.eventually(function() end).is_truthy()
+    luassert.eventually(function() end).is_falsy()
+    luassert.eventually(function() end).has_error()
+    luassert.eventually(function() end).has_no_error()
+
+  # with modifiers before .eventually()
+  - |
+    assert
+      .with_timeout(1)
+      .eventually(function() end)
+      .is_truthy()
+
+  # with modifiers after .eventually()
+  - |
+    assert
+      .eventually(function() end)
+      .with_timeout(1)
+      .is_truthy()
+
+  # eventually() but unrelated to assert
+  - |
+    local t = {}
+    t.eventually(function() end)
+
+
+invalid:
+  # unterminated assert
+  - assert.eventually(function() end)
+  - assert.with_timeout(1).eventually(function() end)
+  - assert.with_timeout(1).eventually(function() end).with_timeout(1)
+  - assert.eventually(function() end).with_timeout(1)
+
+  # same, but luassert
+  - luassert.eventually(function() end)
+  - luassert.with_timeout(1).eventually(function() end)
+  - luassert.with_timeout(1).eventually(function() end).with_timeout(1)
+  - luassert.eventually(function() end).with_timeout(1)
diff --git a/.ci/ast-grep/tests/helpers-outside-of-setup-test.yml b/.ci/ast-grep/tests/helpers-outside-of-setup-test.yml
new file mode 100644
index 00000000000..ead06cb506b
--- /dev/null
+++ b/.ci/ast-grep/tests/helpers-outside-of-setup-test.yml
@@ -0,0 +1,45 @@
+id: helpers-outside-of-setup
+
+valid:
+  # inside `lazy_setup()`
+  - |
+    lazy_setup(function()
+      local a = 123
+      local port = helpers.get_available_port()
+    end)
+
+  # inside `setup()`
+  - |
+    setup(function()
+      local a = 123
+      local port = helpers.get_available_port()
+    end)
+
+  # inside `it()`
+  - |
+    it(function()
+      local a = 123
+      local port = helpers.get_available_port()
+    end)
+
+
+invalid:
+  # at the outermost scope
+  - |
+    local a = 123
+    local port = helpers.get_available_port()
+
+  # inside some strategy iterator thing
+  - |
+    for , strategy in helpers.each_strategy() do
+      local a = 123
+      local port = helpers.get_available_port()
+    end
+
+  # directly inside `describe()`
+  - |
+    describe(function()
+      local a = 123
+      local port = helpers.get_available_port()
+    end)
+
diff --git a/.ci/ast-grep/tests/ngx-log-string-concat-test.yml b/.ci/ast-grep/tests/ngx-log-string-concat-test.yml
new file mode 100644
index 00000000000..8486e1a947b
--- /dev/null
+++ b/.ci/ast-grep/tests/ngx-log-string-concat-test.yml
@@ -0,0 +1,59 @@
+id: ngx-log-string-concat
+
+valid:
+  # normal, expected usage
+  - |
+    ngx.log(ngx.ERR, "STRING: ", my_var)
+
+  # string literals can be concatenated to keep line lengths in check
+  - |
+    ngx.log(ngx.ERR, "my very super long line"
+                  .. " my continuation of that line")
+
+  # chained/nested concatenation of string literals is allowed
+  - |
+    ngx.log(ngx.ERR, "my very super long line"
+                  .. " my continuation of that line"
+                  .. " my extra continuation of that line")
+
+  # only ngx.log() calls are checked
+  - |
+    my_other_function(ngx.ERR, "STRING: " .. my_var)
+
+  # saving a local reference ngx.log doesn't affect other function calls
+  - |
+    local ngx_log = ngx.log
+    my_other_function(ngx.ERR, "STRING: " .. my_var)
+
+invalid:
+  # string .. variable
+  - |
+    ngx.log(ngx.INFO, "STRING: " .. my_var)
+
+  # variable .. string
+  - |
+    ngx.log(ngx.INFO, my_var .. ": STRING")
+
+  # variable .. string .. variable
+  - |
+    ngx.log(ngx.INFO, my_var .. ": STRING :" .. my_other_var)
+
+  # string .. variable .. string
+  - |
+    ngx.log(ngx.INFO, "STRING: " .. my_var .. ": STRING")
+
+  # calling ngx.log via local var reference
+  - |
+    local foo = ngx.log
+
+    foo(ngx.NOTICE, b .. c .. ": STRING")
+
+  # calling ngx.log via local var reference (nested)
+  - |
+    local foo = ngx.log
+
+    if true then
+      local function my_log(a, b, c)
+        foo(ngx.NOTICE, b .. c .. ": STRING")
+      end
+    end
diff --git a/.github/workflows/ast-grep.yml b/.github/workflows/ast-grep.yml
new file mode 100644
index 00000000000..b1e2e660285
--- /dev/null
+++ b/.github/workflows/ast-grep.yml
@@ -0,0 +1,156 @@
+name: ast-grep lint
+
+on:
+  pull_request:
+    paths:
+      - .github/workflows/ast-grep.yml # this workflow
+      - sgconfig.yml
+      - .ci/ast-grep/**
+
+      # globs for files that we want to check with ast-grep here
+      - '**/*.lua'
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
+  cancel-in-progress: true
+
+jobs:
+  lint:
+    name: lint
+
+    runs-on: ubuntu-22.04
+
+    defaults:
+      run:
+        shell: bash
+
+    steps:
+      - name: git checkout
+        uses: actions/checkout@v4
+
+      - name: ensure all rules are properly formed and have tests
+        run: |
+          shopt -s failglob
+
+          declare -i failed=0
+          fail() {
+            failed=1
+
+            local -r fname=${1:?}
+            shift
+
+            local entry
+            printf -v entry '::error file=%s' "$fname"
+
+            while (( $# > 0 )); do
+              case $1 in
+                -t|--title)
+                  local title=${2:?}
+                  shift 2
+                  printf -v entry '%s,title=%s' "$entry" "$title"
+                  ;;
+                *)
+                  break
+                  ;;
+              esac
+            done
+
+            local msg
+            printf -v msg "$@"
+            printf '%s::%s\n' "$entry" "$msg"
+          }
+
+          declare -i count=0
+
+          for rule in .ci/ast-grep/rules/*.yml; do
+            count+=1
+
+            name=${rule##*/}
+            name=${name%*.yml}
+
+            printf 'Rule(%s): %s\n' "$name" "$rule"
+
+            id=$(yq -r .id < "$rule")
+
+            if [[ $id != "$name" ]]; then
+              fail "$rule" \
+                --title 'Rule .id/filename mismatch' \
+                'Rule(%s) ${filename}.yml must match its .id (%s)' \
+                "$name" "$id"
+            fi
+
+            test=.ci/ast-grep/tests/${name}-test.yml
+
+            if [[ ! -e $test ]]; then
+              failed=1
+              fail "$rule" \
+                --title 'Rule test required' \
+                'Rule test file (%s) not found' "$test"
+
+              continue
+            fi
+
+            printf 'Rule(%s): test file: %s\n' "$name" "$test"
+
+            test_id=$(yq -r .id < "$test")
+            if [[ $test_id != $id ]]; then
+              fail "$test" \
+                --title 'Rule test file/.id mismatch' \
+                'Rule test file .id (%s) does not match rule .id (%s)' \
+                "$test_id" "$id"
+            fi
+
+            declare -i valid invalid
+            valid=$(yq -r '.valid | length' < "$test")
+            invalid=$(yq -r '.invalid | length' < "$test")
+
+            if (( valid < 1 || invalid < 1 )); then
+              fail "$test" \
+                --title 'Rule tests insufficient' \
+                'Rule test file must contain at least one valid and one invalid test case'
+            fi
+
+            printf 'Rule(%s) test has %s valid and %s invalid test cases\n' \
+              "$name" "$valid" "$invalid"
+          done
+
+          printf 'Checked %s rules\n' "$count"
+
+          if (( failed > 0 )); then
+            printf '::error::Found one or more problems while checking ast-grep rules and tests\n'
+            exit 1
+          fi
+
+      # NOTE: this is basically an inline of the official, public gh action
+      # (https://github.com/ast-grep/action).
+      - name: install ast-grep
+        run: |
+          set -euo pipefail
+
+          readonly VERSION=0.36.2
+          readonly CHECKSUM=7fd693b013447582d8befa1695f00d17301c2cff1763cfb0b52191096309dbef
+          readonly FILENAME=app-x86_64-unknown-linux-gnu.zip
+          readonly BINDIR=$HOME/.local/bin
+
+          readonly URL=https://github.com/ast-grep/ast-grep/releases/download/${VERSION}/${FILENAME}
+
+          curl --fail \
+            --silent \
+            --location \
+            --output "$FILENAME" \
+            "$URL"
+
+          sha256sum --check --strict <<< "${CHECKSUM} ${FILENAME}"
+
+          unzip "$FILENAME" ast-grep
+          ./ast-grep --version
+
+          mkdir -p "$BINDIR"
+          mv ast-grep "$BINDIR"
+          echo "$BINDIR" >> $GITHUB_PATH
+
+      - name: ast-grep test
+        run: ast-grep test
+
+      - name: ast-grep scan
+        run: ast-grep scan --format github
diff --git a/kong/clustering/rpc/manager.lua b/kong/clustering/rpc/manager.lua
index 160acdad562..af6c1819fcb 100644
--- a/kong/clustering/rpc/manager.lua
+++ b/kong/clustering/rpc/manager.lua
@@ -471,8 +471,8 @@ function _M:handle_websocket()
   end
 
   if not meta_v1_supported then
-    ngx_log(ngx_ERR, _log_prefix, "unknown RPC protocol: " ..
-                     tostring(rpc_protocol) ..
+    ngx_log(ngx_ERR, _log_prefix, "unknown RPC protocol: ",
+                     tostring(rpc_protocol),
                      ", doesn't know how to communicate with client")
     return ngx_exit(ngx.HTTP_CLOSE)
   end
diff --git a/kong/llm/drivers/gemini.lua b/kong/llm/drivers/gemini.lua
index 2069e5c528c..504ef7d7d20 100644
--- a/kong/llm/drivers/gemini.lua
+++ b/kong/llm/drivers/gemini.lua
@@ -83,7 +83,7 @@ local function handle_stream_event(event_t, model_info, route_type)
 
   local event, err = cjson.decode(event_t.data)
   if err then
-    ngx.log(ngx.WARN, "failed to decode stream event frame from gemini: " .. err)
+    ngx.log(ngx.WARN, "failed to decode stream event frame from gemini: ", err)
     return nil, "failed to decode stream event frame from gemini", nil
   end
 
@@ -116,7 +116,7 @@ local function to_tools(in_tools)
   if not in_tools then
     return nil
   end
- 
+
   local out_tools
 
   for i, v in ipairs(in_tools) do
diff --git a/kong/llm/drivers/huggingface.lua b/kong/llm/drivers/huggingface.lua
index 88b0a2ca2d7..5f1987554e7 100644
--- a/kong/llm/drivers/huggingface.lua
+++ b/kong/llm/drivers/huggingface.lua
@@ -141,7 +141,7 @@ local function handle_huggingface_stream(event_t, model_info, route_type)
   local event, err = cjson.decode(event_t.data)
 
   if err then
-    ngx.log(ngx.WARN, "failed to decode stream event frame from Hugging Face: " .. err)
+    ngx.log(ngx.WARN, "failed to decode stream event frame from Hugging Face: ", err)
     return nil, "failed to decode stream event frame from Hugging Face", nil
   end
 
diff --git a/kong/resty/dns/client.lua b/kong/resty/dns/client.lua
index 73f23a131de..f994f112892 100644
--- a/kong/resty/dns/client.lua
+++ b/kong/resty/dns/client.lua
@@ -527,7 +527,7 @@ _M.init = function(options)
     hosts, err = utils.parseHosts(hostsfile)  -- results will be all lowercase!
     if not hosts then return hosts, err end
   else
-    log(WARN, PREFIX, "Hosts file not found: "..tostring(hostsfile))
+    log(WARN, PREFIX, "Hosts file not found: ", tostring(hostsfile))
     hosts = {}
   end
 
@@ -590,7 +590,7 @@ _M.init = function(options)
     resolv, err = utils.applyEnv(utils.parseResolvConf(resolvconffile))
     if not resolv then return resolv, err end
   else
-    log(WARN, PREFIX, "Resolv.conf file not found: "..tostring(resolvconffile))
+    log(WARN, PREFIX, "Resolv.conf file not found: ", tostring(resolvconffile))
     resolv = {}
   end
   if not resolv.options then resolv.options = {} end
diff --git a/kong/resty/mlcache/init.lua b/kong/resty/mlcache/init.lua
index 88bd8689b7c..00dd5d27e33 100644
--- a/kong/resty/mlcache/init.lua
+++ b/kong/resty/mlcache/init.lua
@@ -426,8 +426,8 @@ local function del_shm(self, shm_key, value)
     local ok, err = dict:delete(shm_key)
 
     if not ok then
-        ngx_log(WARN, "could not delete from lua_shared_dict '" .. shm
-                      .. "': " .. err)
+        ngx_log(WARN, "could not delete from lua_shared_dict '", shm,
+                      "': ", err)
         return
     end
 
diff --git a/sgconfig.yml b/sgconfig.yml
new file mode 100644
index 00000000000..59c2454c0b2
--- /dev/null
+++ b/sgconfig.yml
@@ -0,0 +1,8 @@
+ruleDirs:
+  - .ci/ast-grep/rules
+
+testConfigs:
+  - testDir: .ci/ast-grep/tests
+
+utilDirs:
+  - .ci/ast-grep/common


Reviews:
user: login: flrgh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T20:18:32Z
user: login: nowNick
body: I love it 😍 ! 
🏆 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T08:09:10Z
user: login: iykon
body: great change!
> Calling test setup helper function in the wrong scope
good to see complaints, could you help me understand this rule, why are these use case not good?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T16:27:39Z
user: login: locao
body: This is really good stuff 🚀 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T19:09:15Z
user: login: iykon
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T20:39:09Z
user: login: flrgh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T20:49:07Z
user: login: flrgh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T16:36:59Z

Review comments:
diff_hunk: @@ -123,6 +123,19 @@ jobs:
     - name: Check labeler configuration
       run: scripts/check-labeler.pl .github/labeler.yml
 
+
+    # NOTE: this is basically an inline of the official, public gh action
+    # (https://github.com/ast-grep/action).
path: .github/workflows/build_and_test.yml
user: login: flrgh
body: The official action is a full-on JavaScript wrapper around `npm install ast-grep && ast-grep scan [...]`. With all the noise about github actions and needing to audit/lock things down, it makes more sense to just maintain these <10 lines of code ourselves.
created_at: 2025-03-17T20:18:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1 @@
+0.36.2
path: .ci/ast-grep/VERSION.txt
user: login: locao
body: I didn't try, but I guess it would be possible to use SHAs instead of release names.
```suggestion
#46847521abf3745686d89460809b84e6a9f10e33
```
created_at: 2025-03-20T19:01:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+name: Test ast-grep lint rules
+
+on:
+  pull_request:
+    paths:
+      - .github/workflows/ast-grep.yml # this workflow
+      - sgconfig.yml
+      - .ci/ast-grep/**
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
+  cancel-in-progress: true
+
+jobs:
+  test:
+    name: Test ast-grep lint rules
+
+    runs-on: ubuntu-22.04
+
+    defaults:
+      run:
+        shell: bash
+
+    steps:
+      - name: git checkout
+        uses: actions/checkout@v4
+
+      - name: ensure all rules are properly formed and have tests
+        run: |
+          shopt -s failglob
+
+          declare -i failed=0
+          fail() {
+            failed=1
+
+            local -r fname=${1:?}
+            shift
+
+            local entry
+            printf -v entry '::error file=%s' "$fname"
+
+            while (( $# > 0 )); do
+              case $1 in
+                -t|--title)
+                  local title=${2:?}
+                  shift 2
+                  printf -v entry '%s,title=%s' "$entry" "$title"
+                  ;;
+                *)
+                  break
+                  ;;
+              esac
+            done
+
+            local msg
+            printf -v msg "$@"
+            printf '%s::%s\n' "$entry" "$msg"
+          }
+
+          declare -i count=0
+
+          for rule in .ci/ast-grep/rules/*.yml; do
+            count+=1
+
+            name=${rule##*/}
+            name=${name%*.yml}
+
+            printf 'Rule(%s): %s\n' "$name" "$rule"
+
+            id=$(yq -r .id < "$rule")
+
+            if [[ $id != "$name" ]]; then
+              fail "$rule" \
+                --title 'Rule .id/filename mismatch' \
+                'Rule(%s) ${filename}.yml must match its .id (%s)' \
+                "$name" "$id"
+            fi
+
+            test=.ci/ast-grep/tests/${name}-test.yml
+
+            if [[ ! -e $test ]]; then
+              failed=1
+              fail "$rule" \
+                --title 'Rule test required' \
+                'Rule test file (%s) not found' "$test"
+
+              continue
+            fi
+
+            printf 'Rule(%s): test file: %s\n' "$name" "$test"
+
+            test_id=$(yq -r .id < "$test")
+            if [[ $test_id != $id ]]; then
+              fail "$test" \
+                --title 'Rule test file/.id mismatch' \
+                'Rule test file .id (%s) does not match rule .id (%s)' \
+                "$test_id" "$id"
+            fi
+
+            declare -i valid invalid
+            valid=$(yq -r '.valid | length' < "$test")
+            invalid=$(yq -r '.invalid | length' < "$test")
+
+            if (( valid < 1 || invalid < 1 )); then
+              fail "$test" \
+                --title 'Rule tests insufficient' \
+                'Rule test file must contain at least one valid and one invalid test case'
+            fi
+
+            printf 'Rule(%s) test has %s valid and %s invalid test cases\n' \
+              "$name" "$valid" "$invalid"
+          done
+
+          printf 'Checked %s rules\n' "$count"
+
+          if (( failed > 0 )); then
+            printf '::error::Found one or more problems while checking ast-grep rules and tests\n'
+            exit 1
+          fi
+
+      - name: install ast-grep
+        run: |
+          version=$(< .ci/ast-grep/VERSION.txt)
+          if [[ -z ${version:-} ]]; then
+            echo "failed to extract ast-grep version from .ci/ast-grep/VERSION.txt"
+            exit 1
+          fi
+          npm install --global "@ast-grep/cli@${version}"
path: .github/workflows/ast-grep.yml
user: login: locao
body: ```suggestion
          npm install --global "@ast-grep/cli${version}"
```
If we are allowing to use SHAs and tags and releases, `#` and `@` must be explicit in `VERSION.txt`.
created_at: 2025-03-20T19:05:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -123,6 +123,19 @@ jobs:
     - name: Check labeler configuration
       run: scripts/check-labeler.pl .github/labeler.yml
 
+
+    # NOTE: this is basically an inline of the official, public gh action
+    # (https://github.com/ast-grep/action).
+    - name: Run ast-grep lints
+      run: |
+        version=$(< .ci/ast-grep/VERSION.txt)
+        if [[ -z ${version:-} ]]; then
+          echo "failed to extract ast-grep version from .ci/ast-grep/VERSION.txt"
+          exit 1
+        fi
+        npm install --global "@ast-grep/cli@${version}"
path: .github/workflows/build_and_test.yml
user: login: locao
body: ```suggestion
        npm install --global "@ast-grep/cli${version}"
```
created_at: 2025-03-20T19:07:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1 @@
+0.36.2
path: .ci/ast-grep/VERSION.txt
user: login: flrgh
body: I will see what I can do... npm does not seem to support this very well.
created_at: 2025-03-20T20:49:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1 @@
+0.36.2
path: .ci/ast-grep/VERSION.txt
user: login: flrgh
body: Updated. There was no good way to install from commit sha, so instead I am fetching the binary asset (via version/tag) and validating its sha256 checksum on install.
created_at: 2025-03-26T16:36:58Z
author_association: CONTRIBUTOR

PR comments:
user: login: nowNick
created_at: 2025-03-18T08:11:52Z
body: @flrgh - is it possible to run it locally with CLI? I see there's info about it on their docs page ([link](https://ast-grep.github.io/reference/cli.html#command-line-reference)) but I'm not sure it'll pickup the GH actions folder to learn about the rules?
author_association: CONTRIBUTOR
user: login: flrgh
created_at: 2025-03-18T16:16:23Z
body: > @flrgh - is it possible to run it locally with CLI? I see there's info about it on their docs page ([link](https://ast-grep.github.io/reference/cli.html#command-line-reference)) but I'm not sure it'll pickup the GH actions folder to learn about the rules?

Yup! The config file (`sgconfig.yml`) included in this PR configures all the paths and is located at the root of the repository tree, so running `ast-grep` from within `/path/to/git/kong` works:

```
~/git/kong/kong $ ast-grep scan --filter helpers-outside-of-setup spec/03-plugins/27-aws-lambda/99-access_spec.lua
warning[helpers-outside-of-setup]: Calling test setup helper function in the wrong scope
     ┌─ spec/03-plugins/27-aws-lambda/99-access_spec.lua:19:35
     │  
  16 │ ╭   describe("Plugin: AWS Lambda (access) [#" .. strategy .. "]", function()
     ·  
  19 │ │     local mock_http_server_port = helpers.get_available_port()
     │ │                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     · │
1441 │ │   end)
     │ ╰──────'
     │  
     = Avoid calling test setup functions outside of `setup()`/`lazy_setup()`.
       
       ## good
       ```lua
       describe("my test", function()
         local port_a
         local port_b
       
         lazy_setup(function()
           port_a = helpers.get_available_port()
         end)
       
         it("my test case", function()
           port_b = helpers.get_available_port()
         end)
       end)
       
       ## bad
       ```lua
       local port_a = helpers.get_available_port()
       describe("my test", function()
         local port_b = helpers.get_available_port()
       
         it("my test case", function()
         end)
       end)
```
author_association: CONTRIBUTOR
user: login: jschmid1
created_at: 2025-03-20T09:19:30Z
body: really cool stuff 👍🏼 
author_association: CONTRIBUTOR
user: login: tao12345666333
created_at: 2025-03-20T10:45:03Z
body: I love ast-grep 🦍❤️⚡
author_association: MEMBER
user: login: flrgh
created_at: 2025-03-20T20:12:06Z
body: > > Calling test setup helper function in the wrong scope

> could you help me understand this rule, why are these use case not good?

@iykon sure! The pattern that I want to catch and discourage is performing any test/fixture setup tasks at the module/file level or directly within a `describe()` block. Creating a temporary file is a pretty universal integration test pattern--let's use that as an example:

```lua
local function create_temp_file(context)
  print("creating temp file in " .. context .. " context")
  -- simulate slowness
  ngx.sleep(1)
end

local temp = create_temp_file("file")

describe("my test suite #suite", function()
  local other_temp = create_temp_file("describe()")

  it("my test case #testcase", function()
    -- do something with `temp`
  end)

  it("my second test case #testcase", function()
    -- do something with `other_temp`
  end)
end)
```

If we put this into a file and evaluate its behavior, some problems appear:

```
# excluding the entire suite still calls create_temp_file() once
$ time busted --exclude-tags suite demo_spec.lua
creating temp file in file context

0 successes / 0 failures / 0 errors / 0 pending : 0.975825 seconds

real    0m1.089s
user    0m0.052s
sys     0m0.035s

# excluding all test cases still calls create_temp_file() twice
$ time busted --exclude-tags testcase demo_spec.lua
creating temp file in file context
creating temp file in describe() context

0 successes / 0 failures / 0 errors / 0 pending : 1.974669 seconds

real    0m2.062s
user    0m0.049s
sys     0m0.037s
```

Even just listing test cases with no intent to run anything calls `create_temp_file()`:

```
$ time busted --list demo_spec.lua
creating temp file in file context
creating temp file in describe() context
demo_spec.lua:11: my test suite #suite my test case #testcase
demo_spec.lua:15: my test suite #suite my second test case #testcase

real    0m2.060s
user    0m0.047s
sys     0m0.036s
```

Slow test execution due to eagerly performing unnecessary work is one problem. The bigger problem is that these kinds of setup functions often have side effects that live beyond the execution of the test suite (filesystem, database, etc):


1. at `T0` run dbless tests w/ `busted --exclude-tags postgres spec/02-integration`
2. at `T0 + N` a test tagged as `#postgres` mutates `$state` even though we've excluded `#postgres` tests
3. at `T0 + N + M` a dbless test fails because it was expecting `$state` to be empty

Test execution order might not be deterministic, so this might not fail right away. Maybe most of the time the order of steps 2 and 3 are reversed, and everything works just fine. We have soooo much of this in our test code right now, and it's really painful to debug.
author_association: CONTRIBUTOR