Skip to content

feat(lint): add @pgsql/lint — standalone source-level SQL/PL-pgSQL convention linter + CLI - #335

Merged
pyramation merged 3 commits into
mainfrom
feat/pgsql-lint
Aug 2, 2026
Merged

feat(lint): add @pgsql/lint — standalone source-level SQL/PL-pgSQL convention linter + CLI#335
pyramation merged 3 commits into
mainfrom
feat/pgsql-lint

Conversation

@pyramation

@pyramation pyramation commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

New package @pgsql/lint: a source-level SQL / PL/pgSQL convention linter, extracted from safegres's src/lint/* so it can run without a database. It reasons about the text of a CREATE FUNCTION definition from its AST and carries no pg / catalog dependency, so the same engine runs over a migration on disk, an editor buffer, or a definition read from a live catalog via pg_get_functiondef. safegres will consume it in a follow-up PR (this is step one of "extract, then repoint").

Rules are an ecosystem seam: they are injected as values, never discovered by a magic npm name — you import a rule and pass it to createLinter. Severity is configuration (ESLint-style off/warn/error), so a downstream consumer (safegres) keeps full control without duplicating any engine logic.

Runtime footprint is only the parser stack already in this repo: pgsql-parser (SQL → AST), libpg-query (parsePlPgSQL), @pgsql/traverse (walk), plus chalk/minimist for the CLI.

Rules (stable ids + Cx registry codes)

Code Id Flags
C1 no-set-search-path SET search_path clause or set_config('search_path', …)
C2 no-variable-conflict a PL/pgSQL #variable_conflict directive
C3 require-qualified-refs an unqualified relation reference (FROM users); CTE names excluded
C4 no-dynamic-sql EXECUTE, EXECUTE … USING, FOR … IN EXECUTE

Plugin API — rules as values, severity as config

import { createLinter, defineRule, LINT_RULES } from '@pgsql/lint';

const noWritesInView = defineRule({
  id: 'no-writes-in-view', code: 'X1', title: '…',
  reasonRequired: false, run: (unit) => [/* … */]
});

const linter = createLinter({
  rules: [...LINT_RULES, noWritesInView],
  severity: { 'require-qualified-refs': 'warn', C2: 'off' } // keyed by id OR code
});
await linter.lintFiles(['./migrations']);  // + lintDefinition / lintSqlText / lintSource
  • defineRule(rule) — identity helper that pins the LintRule shape so a third-party rule type-checks without importing internals.
  • severityoff drops the rule (never runs); warn reports but does not fail; error (default when unmapped) fails the run. Attached to every LintProblem as severity.
  • Source adapters — a rule is pure unit → problems; a SourceAdapter decides where definitions come from. Ships filesAdapter/sqlTextAdapter; linter.lintSource(adapter) runs any adapter. safegres becomes "the catalog adapter" over the same engine.

Public API

lintDefinition(text, language, name?, opts?): Promise<{ problems, suppressed }>
lintSqlText(source, opts?): Promise<FileReport>       // slices each CREATE FUNCTION
lintFiles(paths, opts?): Promise<FileReport[]>        // dirs scanned recursively for .sql
lintSource(adapter, opts?): Promise<FileReport[]>
createLinter({ rules?, severity?, keyword? }): Linter

opts.keyword selects the suppression directive keyword; defaults to ['pgsql-lint', 'safegres'] so both brands work (lets safegres adopt without churning its waiver corpus).

File runner / CLI — the "local" entry point

lintDefinition expects a single definition; the file runner slices out each top-level CREATE FUNCTION via stmt_location/stmt_len, lints each, and re-anchors findings to absolute file lines — so a mixed migration is never treated as one malformed definition.

pgsql-lint ./migrations            # dir, recursive .sql
pgsql-lint schema.sql --json
pgsql-lint . --rules no-dynamic-sql
pgsql-lint . --warn require-qualified-refs   # downgrade (won't fail the run)
pgsql-lint . --off C2                         # disable by id or code

Exit code is 1 when any error-severity (non-waived) finding remains, 0 otherwise; --warn findings print but pass.

Suppressions (ESLint/Prettier-style, in the body)

-- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: building an IN-list of ints
EXECUTE format('SELECT … WHERE id = ANY(%L)', ids);

Forms: disable-next-line, disable-line, disableenable (range), disable-file. no-dynamic-sql requires a reason — a reasonless waiver does not silence it (finding stands, tagged invalidSuppression: 'missing-reason'). Suppressed findings are reported as acknowledged, never dropped.

Tests

packages/lint/__tests__ — 37 tests, all DB-free: rule metadata; C1–C4 detection + negative cases; reason-required behavior; suppression scopes + keyword selection; severity (off/warn by id and code, error default); createLinter with an injected custom rule; unparseable definitions; file-runner slicing / re-anchoring / attribution / directory scan; lintSource adapters; CLI JSON, exit codes, --warn/--off, --rules, --help.

Wiring

  • Added @pgsql/lint to the CI matrix in .github/workflows/run-tests.yaml.
  • Package layout copied from @pgsql/semantics; adds bin: { "pgsql-lint": "cli.js" }.
  • README with logo/badge header (footer auto-appended by makage) + a new .agents/skills/pgsql-lint skill (registered in AGENTS.md).

Deferred (next PR)

Repointing safegres's src/lint/* at @pgsql/lint (as the catalog adapter). No safegres changes here.

Link to Devin session: https://app.devin.ai/sessions/af81a09043504701874ca63e67a9cd4b
Requested by: @pyramation

@pyramation pyramation self-assigned this Aug 2, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@pyramation
pyramation merged commit e0f3c10 into main Aug 2, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant