Shift-Left Code Quality for Kong Lua Plugins with ESLint


2 AM. Production alerts are firing. A recently deployed custom Kong plugin is causing a storm of 5xx errors on core API traffic. After a tense investigation, the root cause is traced to something trivial: deep within a rarely-triggered logic branch, a variable was misspelled as ngx.ctx.flwo_id instead of ngx.ctx.flow_id. This simple typo slipped past unit tests, evaded code review, and ultimately caused a production outage. The incident forced our team to confront a long-neglected problem: when it comes to code quality assurance, our Kong Lua plugin development was still in the Wild West.

Our team’s primary tech stack is JavaScript/TypeScript, so we’re accustomed to a mature toolchain of ESLint, Prettier, and Jest. With every commit, our CI/CD pipeline’s static analysis stage catches a huge number of low-level errors and style violations. But when we switch contexts to writing Lua plugins for Kong, this modern development experience vanishes. Team members rely on personal diligence and code reviews, which is clearly not a systematic way to guarantee code quality.

The initial idea was to introduce a static analysis tool from the Lua community, like luacheck. It does solve some problems, such as undefined variables. However, it has two core weaknesses. First, it doesn’t seamlessly integrate with our existing Node.js-based ecosystem for frontend and backend projects. We would need to set up a separate environment and CI process for it, increasing maintenance overhead. Second, and more importantly, luacheck‘s rule extension capabilities are nowhere near as powerful as the ESLint ecosystem. We needed more than just syntax checking; we needed to enforce our team’s internal best practices for Kong development. For instance, we mandate that logs must use kong.log.err() instead of print(), or that the health of an upstream service must be checked in the plugin’s access phase. luacheck is ill-equipped to handle these business-specific rules.

This led to a seemingly audacious idea: could we use ESLint, the tool we know best, to check our Lua code? It sounded far-fetched, as ESLint was built for JavaScript. But after some research, we found a crucial bridge: eslint-plugin-lua. This plugin works by parsing Lua code into an ESTree-compatible Abstract Syntax Tree (AST), allowing the ESLint core engine to understand and inspect it. This was a game-changer. It meant we could bring our Kong Lua plugin development fully into our mature, existing Node.js toolchain.

Step 1: Setting Up the Basic Environment

Our goal was to create a centralized, reusable linting configuration that all our Kong plugin projects could share. We started by initializing a new Node.js project to serve as our linting utility library.

# Create the project directory
mkdir kong-lua-linter
cd kong-lua-linter

# Initialize package.json
npm init -y

# Install core dependencies
npm install eslint eslint-plugin-lua @babel/eslint-parser --save-dev
  • eslint: The core ESLint library.
  • eslint-plugin-lua: The key plugin that enables Lua support in ESLint.
  • @babel/eslint-parser: The parser recommended by eslint-plugin-lua for better handling of non-standard syntax structures.

Next, we created our core configuration file, .eslintrc.js. This is our starting point, which we’ll progressively enhance.

// .eslintrc.js
module.exports = {
  // Specify the parser
  parser: '@babel/eslint-parser',
  // Use the recommended configuration from eslint-plugin-lua
  extends: ['plugin:lua/recommended'],
  // List of plugins
  plugins: ['lua'],
  // Global environment settings
  env: {
    browser: false,
    node: false, // Explicitly state this is not a Node.js environment
    es6: false,
  },
  // Parser options
  parserOptions: {
    ecmaVersion: 2020,
    sourceType: 'module',
    requireConfigFile: false, // Disable Babel config file lookup
  },
  // Rule configuration
  rules: {
    // Custom rules or overrides will go here
  },
};

Now, let’s test it with a problematic snippet from a Kong plugin.

-- file: ./kong/plugins/my-auth/handler.lua

local BasePlugin = require "kong.plugins.base_plugin"

local MyAuthHandler = BasePlugin:extend()

MyAuthHandler.PRIORITY = 1000
MyAuthHandler.VERSION = "1.0.0"

function MyAuthHandler:new()
  MyAuthHandler.super.new(self, "my-auth")
end

function MyAuthHandler:access(conf)
  MyAuthHandler.super.access(self)

  local authorization = kong.request.get_header("Authorization")
  if not authorization then
    -- Intentionally introduce an undefined variable
    return kong.response.exit(401, { message = "Unauthorized Request" }, unknow_variable)
  end

  -- Using the print function is discouraged in production
  print("Authorization header found")

  local user_id = self:validate_token(authorization)

  if not user_id then
    return kong.response.exit(403, { message = "Invalid Token" })
  end

  kong.ctx.shared.user_id = user_id
end

return MyAuthHandler

We add a lint script to our package.json:

{
  "name": "kong-lua-linter",
  "version": "1.0.0",
  "scripts": {
    "lint": "eslint --ext .lua ."
  },
  "devDependencies": {
    "@babel/eslint-parser": "^7.22.15",
    "eslint": "^8.51.0",
    "eslint-plugin-lua": "^1.4.0"
  }
}

Running npm run lint gives us the expected errors:

/path/to/project/kong/plugins/my-auth/handler.lua
  18:71  error  'unknow_variable' is not defined  lua/no-undef
  22:3   error  'print' is not defined            lua/no-undef

✖ 2 problems (2 errors, 0 warnings)

The lua/no-undef rule successfully caught the undefined variable unknow_variable. However, it also incorrectly flagged print as undefined. More critically, it has no awareness of the global kong object. If we used ngx in our code, that would also trigger an error. This is because ESLint, by default, is unaware of the specific runtime environment of a Kong plugin.

Step 2: Defining the Kong Global Environment

To make ESLint understand Kong’s execution context, we need to define all the global variables accessible to a Kong plugin in our .eslintrc.js. This includes LuaJIT built-in functions, the ngx object from the Nginx Lua module, and the kong object injected by Kong.

Here is a fairly complete list of globals compiled from a real-world project:

// .eslintrc.js
module.exports = {
  // ... other configs ...
  globals: {
    // Lua Built-ins (partial)
    'assert': 'readonly',
    'collectgarbage': 'readonly',
    'dofile': 'readonly',
    'error': 'readonly',
    'getmetatable': 'readonly',
    'ipairs': 'readonly',
    'load': 'readonly',
    'loadfile': 'readonly',
    'next': 'readonly',
    'pairs': 'readonly',
    'pcall': 'readonly',
    'print': 'readonly', // It's a global, even if we discourage its use
    'rawequal': 'readonly',
    'rawget': 'readonly',
    'rawlen': 'readonly',
    'rawset': 'readonly',
    'require': 'readonly',
    'select': 'readonly',
    'setmetatable': 'readonly',
    'tonumber': 'readonly',
    'tostring': 'readonly',
    'type': 'readonly',
    'xpcall': 'readonly',
    '_G': 'readonly',
    '_VERSION': 'readonly',

    // Lua Standard Libraries
    'coroutine': 'readonly',
    'string': 'readonly',
    'table': 'readonly',
    'math': 'readonly',
    'io': 'readonly',
    'os': 'readonly',
    'package': 'readonly',
    'debug': 'readonly',

    // LuaJIT specific
    'jit': 'readonly',
    'bit': 'readonly',
    'ffi': 'readonly',

    // OpenResty / ngx_lua
    'ngx': 'readonly',
    'ndk': 'readonly',

    // Kong Globals
    'kong': 'readonly',
  },
  rules: {
    // ...
  }
};

After configuring the globals, we run npm run lint again:

/path/to/project/kong/plugins/my-auth/handler.lua
  18:71  error  'unknow_variable' is not defined  lua/no-undef

✖ 1 problem (1 error, 0 warnings)

Now, print and kong no longer cause errors, leaving only the legitimate issue with unknow_variable. We’re halfway there.

Step 3: Writing Custom Rules to Enforce Team Best Practices

The true power of static analysis lies in its ability to codify a team’s domain knowledge and best practices. One of our pain points was developers using print for debugging and leaving it in production code, which degrades performance and generates noisy, useless logs. Our standard is clear: all logging must go through the kong.log family of functions.

To enforce this rule in CI, we need to write a custom ESLint rule.

First, let’s create the rule file. In our kong-lua-linter project, we’ll create a new directory rules and a file no-global-print.js.

// rules/no-global-print.js

/**
 * @fileoverview Disallows the use of the global `print` function.
 * @author Your Team Name
 */

"use strict";

module.exports = {
  // Rule metadata
  meta: {
    type: "problem", // This is a problem, not a style suggestion
    docs: {
      description: "Disallow the use of the global `print` function in favor of `kong.log`",
      category: "Best Practices",
      recommended: true, // Recommended to enable in configs
      url: "https://your-internal-docs/eslint-rule-no-global-print", // Link to internal docs
    },
    fixable: "code", // This rule is not auto-fixable
    schema: [], // This rule has no options
    messages: {
      // Error message template
      avoidPrint: "Do not use global `print`. Use `kong.log()` or `kong.log.err()` instead.",
    },
  },
  // Rule creation function
  create(context) {
    return {
      // Visitor pattern: define handlers for specific AST node types
      // We are looking for function call expressions
      CallExpression(node) {
        // Check if the called object is an Identifier
        // and if that identifier's name is 'print'
        if (node.callee.type === 'Identifier' && node.callee.name === 'print') {
          // Use ESLint's scope analysis to check if 'print' has been redefined
          // If it refers to a global variable, scope.through will contain it
          const scope = context.getScope();
          const printVar = scope.set.get('print');
          
          // If printVar is not defined or has no definitions, it's likely a global
          // variable that hasn't been declared in the current scope.
          // This is a reliable way to check if it's the global print.
          if (!printVar || printVar.defs.length === 0) {
            context.report({
              node: node.callee, // Highlight the 'print' identifier
              messageId: "avoidPrint", // Use the message defined in meta
            });
          }
        }
      },
    };
  },
};

The core of this rule is AST traversal. ESLint parses code into an AST, and our rule acts as a visitor traversing this tree. When it encounters a CallExpression node, it checks if the callee is an Identifier named print. It also uses context.getScope() to ensure it’s catching the global print call and not a user-defined local function with the same name.

To enable this rule, we need to do two things:

  1. Create a plugin to house our custom rules.
  2. Configure .eslintrc.js to use this plugin and rule.

Let’s create a simple plugin entry point, index.js:

// index.js (in the root of kong-lua-linter)

"use strict";

module.exports = {
  rules: {
    "no-global-print": require("./rules/no-global-print"),
  },
};

Then, we modify .eslintrc.js to use this project as a shareable ESLint plugin.

// .eslintrc.js
module.exports = {
  // ... other configs ...
  plugins: ['lua', 'custom-kong'], // Add our custom plugin
  // ... globals ...
  rules: {
    // Enable our custom rule and set its level to 'error'
    'custom-kong/no-global-print': 'error',

    // We can also adjust rules from other plugins
    'lua/no-undef': 'error',
    'lua/no-unused-vars': 'warn', // Downgrade unused vars to a warning
  },
};

To make ESLint find our custom-kong plugin, we need to declare it in package.json and make it accessible to other projects, either via npm link or by publishing it to a private npm registry. In a monorepo, you can often reference it directly via a relative path.

Now, when ESLint runs, it will load our custom rule. Let’s lint the handler.lua file again:

/path/to/project/kong/plugins/my-auth/handler.lua
  18:71  error  'unknow_variable' is not defined                                lua/no-undef
  22:3   error  Do not use global `print`. Use `kong.log()` or `kong.log.err()` instead.  custom-kong/no-global-print

✖ 2 problems (2 errors, 0 warnings)

Success! We’ve not only caught the undefined variable but have also enforced our team’s logging standard.

Step 4: Integrating into the CI/CD Pipeline

Building a powerful local linting capability is just the first step. The real value comes from automating it as a mandatory quality gate before code is merged into the main branch. Here’s an example of how to integrate it in GitLab CI.

# .gitlab-ci.yml

stages:
  - lint
  - test
  - deploy

variables:
  # Use a Node.js 18 image
  NODE_IMAGE: node:18-slim

# A reusable job template for installing Node.js dependencies
.node_dependencies:
  image: ${NODE_IMAGE}
  before_script:
    # Centrally manage linting tools and install them in CI
    - npm ci --prefix ./tools/kong-lua-linter
  cache:
    key:
      files:
        - ./tools/kong-lua-linter/package-lock.json
    paths:
      - ./tools/kong-lua-linter/node_modules/

# Linting job
lint-kong-plugins:
  stage: lint
  extends: .node_dependencies
  script:
    # Navigate to the linter tool directory and run the lint command
    # --ext .lua specifies checking only .lua files
    # ../kong/plugins/ points to our plugin source code directory
    - cd ./tools/kong-lua-linter && npm run lint -- ../kong/plugins/
  rules:
    # Run only for merge requests or on the default branch
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

# ... subsequent test and deploy stages ...

This CI/CD process design embodies several key principles:

  1. Staged Builds: The lint stage is the very first step. If code style or quality standards aren’t met, the pipeline fails immediately, giving developers fast feedback and preventing wasted time on longer-running unit or integration tests.
  2. Dependency Caching: By caching the node_modules directory, subsequent CI jobs are significantly faster.
  3. Separation of Concerns: The kong-lua-linter exists as a separate utility package. All Kong plugin linting depends on it. When we need to update linting rules, we only have to modify this single location, and all plugin projects automatically benefit from the updates, dramatically reducing maintenance costs.

Here’s a diagram of the complete workflow:

graph TD
    A[Developer Pushes Code] --> B{GitLab CI Pipeline Triggered};
    B --> C[Stage: lint];
    C --> D{Run lint-kong-plugins job};
    D -- Linting Errors --> E[Pipeline Fails & Notifies Developer];
    D -- No Errors --> F[Stage: test];
    F --> G[Run Unit & Integration Tests];
    G -- Tests Fail --> E;
    G -- Tests Pass --> H[Stage: deploy];
    H --> I[Deploy to Staging/Production];

With this system in place, any Lua code with low-level errors or violations of team standards will be automatically blocked at the merge request stage, fundamentally preventing the kind of trivial mistake that caused our initial production incident from ever reaching production again.

Limitations and Future Outlook

While this ESLint-based solution has greatly improved the quality and efficiency of our Kong Lua plugin development, it’s not a silver bullet. eslint-plugin-lua is a community-driven project, and its support for advanced Lua language features (especially LuaJIT FFI) may not be as comprehensive as dedicated Lua toolchains. It relies on converting the Lua AST into an ESTree-compatible format, a process that could potentially lose information or have parsing errors in edge cases. In practice, we’ve encountered some complex metaprogramming patterns that caused parsing failures, requiring minor code adjustments to accommodate the linter.

Another consideration is performance. For very large Lua codebases, running ESLint via Node.js might be slightly slower than a native tool like luacheck. In a CI environment, however, this millisecond-level difference is usually negligible.

The path forward is clear. First, we can contribute more granular, custom ESLint rules for our team. For example, we could write rules to validate the correctness of a Kong plugin’s schema.lua or to analyze performance hotspots, such as flagging time-consuming operations in the access phase. Second, we can explore combining this linting system with a TypeScript-to-Lua transpiler like TypeScriptToLua. This would allow our team to write Kong plugins in the more familiar TypeScript, enjoying the benefits of static typing. Then, in the CI process, we could still use our ESLint setup to check the final transpiled Lua code, providing a dual layer of protection.


  TOC