Skip to content

Commit e0f3c10

Browse files
authored
Merge pull request #335 from constructive-io/feat/pgsql-lint
feat(lint): add @pgsql/lint — standalone source-level SQL/PL-pgSQL convention linter + CLI
2 parents 0fba1b6 + 84eb982 commit e0f3c10

28 files changed

Lines changed: 5023 additions & 5534 deletions

.agents/skills/pgsql-lint/SKILL.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
---
2+
name: pgsql-lint
3+
description: How to lint SQL/PL-pgSQL source with @pgsql/lint and how to author new rules, severities, and source adapters. Use when running the convention linter, adding a rule, wiring it into a tool (CLI, pre-commit, safegres), or debugging a finding.
4+
---
5+
6+
# @pgsql/lint
7+
8+
`@pgsql/lint` (`packages/lint`) is a **source-level** convention linter: source
9+
text in → findings out. It parses a `CREATE FUNCTION` definition, walks the AST,
10+
and reports style/safety violations. It has **no `pg` / catalog dependency**, so
11+
the same engine runs over a migration on disk, an editor buffer, a pre-commit
12+
hook, or a definition read from a live catalog via `pg_get_functiondef`
13+
(safegres consumes it exactly this way).
14+
15+
Runtime footprint is only the parser stack in this repo: `pgsql-parser`
16+
(SQL → AST), `libpg-query` (`parsePlPgSQL`), `@pgsql/traverse` (`walk`).
17+
18+
## The built-in rules
19+
20+
| Code | Id | Flags | Reason required? |
21+
|------|----|-------|------------------|
22+
| `C1` | `no-set-search-path` | `SET search_path` clause **or** `set_config('search_path', …)` | no |
23+
| `C2` | `no-variable-conflict` | a PL/pgSQL `#variable_conflict` directive | no |
24+
| `C3` | `require-qualified-refs` | an unqualified relation reference (`FROM users`); CTE names excluded | no |
25+
| `C4` | `no-dynamic-sql` | `EXECUTE`, `EXECUTE … USING`, `FOR … IN EXECUTE` | **yes** |
26+
27+
The discipline: never depend on `search_path` — fully qualify everything
28+
(`C1` + `C3`); don't paper over ambiguity (`C2`); treat dynamic SQL as opaque
29+
and exceptional (`C4`).
30+
31+
## Running it
32+
33+
```bash
34+
pgsql-lint ./migrations # dir, recursive .sql
35+
pgsql-lint schema.sql --json # machine-readable
36+
pgsql-lint . --rules no-dynamic-sql # subset
37+
pgsql-lint . --warn require-qualified-refs # downgrade (won't fail)
38+
pgsql-lint . --off C2 # disable (id or code)
39+
```
40+
41+
Exit code is `1` when any **error**-severity, non-waived finding remains, `0`
42+
otherwise. `--warn` findings print but don't fail the run.
43+
44+
Programmatic entry points (all pure, DB-free):
45+
46+
```ts
47+
import { lintDefinition, lintSqlText, lintFiles } from '@pgsql/lint';
48+
49+
await lintDefinition(defText, 'plpgsql'); // one definition (pg_get_functiondef)
50+
await lintSqlText(migrationSql); // a source string, many statements
51+
await lintFiles(['./migrations']); // files/dirs on disk
52+
```
53+
54+
`lintSqlText`/`lintFiles` slice out each top-level `CREATE FUNCTION` using the
55+
parser's `stmt_location`/`stmt_len`, lint each in isolation, and **re-anchor**
56+
findings to absolute file lines — so a mixed migration is never treated as one
57+
malformed definition.
58+
59+
## Authoring a new rule
60+
61+
A rule is a plain value — **no magic npm names**. Author it with `defineRule`
62+
(type-only helper) and hand it to `createLinter`:
63+
64+
```ts
65+
import { createLinter, defineRule, LINT_RULES } from '@pgsql/lint';
66+
67+
const noWritesInView = defineRule({
68+
id: 'no-writes-in-view', // stable, ESLint-style id
69+
code: 'X1', // registry code
70+
title: 'views must be read-only',
71+
reasonRequired: false, // true ⇒ a bare suppression won't silence it
72+
run: (unit) => {
73+
// unit.fragments — parsed SQL fragments, each with lineForOffset(offset)
74+
// unit.dynamicSql — detected EXECUTE / dynamic sites (line + form)
75+
// unit.lines — raw source lines (1-based reporting)
76+
return []; // LintProblem[] { ruleId, line, message, hint?, context? }
77+
}
78+
});
79+
80+
const linter = createLinter({ rules: [...LINT_RULES, noWritesInView] });
81+
await linter.lintFiles(['./migrations']);
82+
```
83+
84+
Rule bodies must use `walk` from `@pgsql/traverse` (via the package's `findAll`
85+
helper) — never hand-roll a `transformSync(..., { hydrate: true })` loop. See the
86+
`ast-traversal` skill.
87+
88+
### Severity is config, not rule state
89+
90+
Severity (`off` / `warn` / `error`, ESLint-style) is decided by the *consumer*,
91+
keyed by rule id or code; a rule never hard-codes its own severity. Unmapped
92+
rules default to `error`; `off` rules don't run.
93+
94+
```ts
95+
createLinter({ severity: { 'require-qualified-refs': 'warn', C2: 'off' } });
96+
```
97+
98+
This is the safegres seam: its registry maps `high/medium/low``error/warn/off`
99+
and passes a `severity` map in — no duplicated severity logic downstream.
100+
101+
### Source adapters — where definitions come from
102+
103+
A rule is pure `unit → problems`; an **adapter** decides *where* definitions come
104+
from. The package ships `filesAdapter` and `sqlTextAdapter`; a consumer
105+
implements `SourceAdapter` and calls `linter.lintSource(adapter)`:
106+
107+
```ts
108+
interface SourceAdapter {
109+
id: string;
110+
definitions: () => Promise<LintDefinitionInput[]> | LintDefinitionInput[];
111+
}
112+
```
113+
114+
safegres is "the catalog adapter": it yields `LintDefinitionInput`s from
115+
`pg_get_functiondef`, over the same engine and rules.
116+
117+
## Suppressions
118+
119+
ESLint/Prettier-style, authored in the function body (they survive
120+
`pg_get_functiondef`). Keywords `pgsql-lint` and `safegres` are both accepted:
121+
122+
```sql
123+
-- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: building an IN-list of ints
124+
EXECUTE format('SELECT … WHERE id = ANY(%L)', ids);
125+
```
126+
127+
Forms: `disable-next-line`, `disable-line`, `disable``enable` (range),
128+
`disable-file`. `no-dynamic-sql` **requires** a reason — a reasonless waiver does
129+
not silence it (the finding stands, tagged `invalidSuppression: 'missing-reason'`).
130+
Suppressed findings are reported as *acknowledged* accepted-risk, never dropped.
131+
132+
## Files
133+
134+
| File | What |
135+
|------|------|
136+
| `src/engine.ts` | `lintDefinition` — parse, run rules, apply suppressions, attach severity |
137+
| `src/linter.ts` | `createLinter` — bind a rule set + severities + keyword; `lintDefinition`/`lintSqlText`/`lintFiles`/`lintSource` |
138+
| `src/file-runner.ts` | file/sql-text slicing + re-anchoring; `filesAdapter`, `sqlTextAdapter`, `lintSource` |
139+
| `src/rules/*` | the built-in C1–C4 rules |
140+
| `src/suppressions.ts` | the ESLint/Prettier-style directive parser |
141+
| `src/parse-unit.ts` | `CREATE FUNCTION``LintUnit` (SQL + PL/pgSQL bodies) |
142+
| `src/cli.ts` | the `pgsql-lint` CLI |
143+
| `src/types.ts` | public types + `defineRule` |

.github/workflows/run-tests.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ jobs:
2525
- '@pgsql/transform-ast'
2626
- '@pgsql/traverse'
2727
- '@pgsql/semantics'
28+
- '@pgsql/lint'
2829
- '@pgsql/transform'
2930
- '@pgsql/scripts'
3031
steps:

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Detailed workflow documentation lives in `.agents/skills/`:
4040
| **AST Traversal** | `.agents/skills/ast-traversal/SKILL.md` | Walking SQL and PL/pgSQL ASTs: choosing `walk` / `walkSql` / `walkSqlAst` / `walkPlpgsqlAst` / `traverse`, statement context, visitor composition, abort, mutation |
4141
| **Testing & Fixtures** | `.agents/skills/testing-fixtures/SKILL.md` | Fixture-based testing pipeline, adding new test fixtures, kitchen-sink workflow, PL/pgSQL fixtures, transform tests |
4242
| **Code Generation** | `.agents/skills/code-generation/SKILL.md` | Protobuf codegen (`build:proto`), type inference/generation (`pgsql-types`), keyword generation (`@pgsql/quotes`), version-specific deparsers |
43+
| **pgsql-lint** | `.agents/skills/pgsql-lint/SKILL.md` | Source-level SQL/PL-pgSQL convention linting (`@pgsql/lint`): running the CLI, authoring rules with `defineRule`/`createLinter`, severity config, source adapters, suppressions |
4344

4445
## Root Scripts
4546

packages/lint/README.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# @pgsql/lint
2+
3+
<p align="center" width="100%">
4+
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
5+
</p>
6+
7+
<p align="center" width="100%">
8+
<a href="https://github.com/constructive-io/pgsql-parser/actions/workflows/run-tests.yaml">
9+
<img height="20" src="https://github.com/constructive-io/pgsql-parser/actions/workflows/run-tests.yaml/badge.svg" />
10+
</a>
11+
<a href="https://github.com/constructive-io/pgsql-parser/blob/main/LICENSE-MIT"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
12+
<a href="https://www.npmjs.com/package/@pgsql/lint"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/pgsql-parser?filename=packages%2Flint%2Fpackage.json"/></a>
13+
</p>
14+
15+
A source-level SQL / PL/pgSQL **convention linter**. It reasons about the *text*
16+
of a `CREATE FUNCTION` definition — from its AST — and carries **no `pg` /
17+
catalog dependency**, so the exact same engine runs over a definition in a
18+
migration, an editor buffer, a pre-commit hook, or one read from a live catalog
19+
via `pg_get_functiondef`.
20+
21+
## Installation
22+
23+
```bash
24+
npm install @pgsql/lint
25+
```
26+
27+
## Rules
28+
29+
| Code | Id | Flags |
30+
|------|----|-------|
31+
| `C1` | `no-set-search-path` | `SET search_path` clause, or `set_config('search_path', …)` |
32+
| `C2` | `no-variable-conflict` | a PL/pgSQL `#variable_conflict` directive |
33+
| `C3` | `require-qualified-refs` | an unqualified relation reference (`FROM users``FROM app_public.users`) |
34+
| `C4` | `no-dynamic-sql` | `EXECUTE`, `EXECUTE … USING`, `FOR … IN EXECUTE` |
35+
36+
The rules encode a single discipline: never depend on `search_path` — fully
37+
qualify everything (`C1` + `C3`) — don't paper over ambiguity (`C2`), and treat
38+
dynamic SQL as opaque and exceptional (`C4`).
39+
40+
## CLI
41+
42+
```bash
43+
pgsql-lint path/to/migrations # a directory (scanned recursively for .sql)
44+
pgsql-lint schema.sql other.sql # explicit files
45+
pgsql-lint . --rules no-dynamic-sql # only some rules
46+
pgsql-lint . --warn require-qualified-refs # downgrade to a warning (won't fail)
47+
pgsql-lint . --off C2 # disable a rule (by id or code)
48+
pgsql-lint . --json # machine-readable
49+
```
50+
51+
Exit code is `1` when any **error**-severity (and non-waived) finding remains,
52+
`0` otherwise — drop it straight into CI. `--warn` findings print but don't fail.
53+
54+
## Suppressions
55+
56+
ESLint / Prettier-style comments, authored in the function body (they survive
57+
`pg_get_functiondef`). The keyword is `pgsql-lint` (`safegres` is also accepted):
58+
59+
```sql
60+
-- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: building an IN-list of ints
61+
EXECUTE format('SELECT … WHERE id = ANY(%L)', ids);
62+
```
63+
64+
Forms: `disable-next-line`, `disable-line`, `disable``enable` (a range), and
65+
`disable-file`. A directive with no rule listed applies to every rule; a reason
66+
follows a second `--` or a `:`.
67+
68+
`no-dynamic-sql` **requires** a reason: a reasonless waiver does not silence it,
69+
so an approved use always documents *why* (`lookup-only` / `codegen`). Suppressed
70+
findings are reported as *acknowledged* accepted-risk, never dropped.
71+
72+
## Programmatic API
73+
74+
```ts
75+
import { lintDefinition, lintFiles, lintSqlText } from '@pgsql/lint';
76+
77+
// one definition (e.g. from pg_get_functiondef)
78+
const { problems, suppressed } = await lintDefinition(defText, 'plpgsql');
79+
80+
// a SQL source string with many statements
81+
const report = await lintSqlText(migrationSql);
82+
83+
// files / directories on disk
84+
const reports = await lintFiles(['./migrations']);
85+
```
86+
87+
## Custom rules & severity (building an ecosystem)
88+
89+
Rules are **injected as values** — never discovered by a magic npm package name.
90+
A rule is a plain object; publish it, `import` it, and pass it to `createLinter`.
91+
Severity is *configuration* (ESLint-style `off` / `warn` / `error`), keyed by
92+
rule id or code, so a consumer stays in full control of how loud each rule is:
93+
94+
```ts
95+
import { createLinter, defineRule, LINT_RULES } from '@pgsql/lint';
96+
97+
const noWritesInView = defineRule({
98+
id: 'no-writes-in-view',
99+
code: 'X1',
100+
title: 'views must be read-only',
101+
reasonRequired: false,
102+
run: (unit) => [/* … inspect unit.fragments / unit.dynamicSql … */]
103+
});
104+
105+
const linter = createLinter({
106+
rules: [...LINT_RULES, noWritesInView],
107+
severity: { 'require-qualified-refs': 'warn', C2: 'off' }
108+
});
109+
110+
await linter.lintFiles(['./migrations']); // also lintDefinition / lintSqlText / lintSource
111+
```
112+
113+
### Source adapters
114+
115+
Rules are pure `unit → problems`; an **adapter** decides *where* the definitions
116+
come from. `@pgsql/lint` ships `filesAdapter` and `sqlTextAdapter`; a consumer
117+
(e.g. safegres, reading a live catalog via `pg_get_functiondef`) implements the
118+
`SourceAdapter` interface and passes it to `linter.lintSource(adapter)`.
119+
120+
```ts
121+
interface SourceAdapter {
122+
id: string;
123+
definitions: () => Promise<LintDefinitionInput[]> | LintDefinitionInput[];
124+
}
125+
```
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- A mixed migration file: schema DDL, a clean function, and a dirty one.
2+
CREATE SCHEMA app_public;
3+
4+
CREATE TABLE app_public.users (
5+
id serial primary key,
6+
email text not null
7+
);
8+
9+
CREATE FUNCTION app_public.clean() RETURNS setof app_public.users
10+
LANGUAGE sql
11+
AS $$
12+
SELECT * FROM app_public.users
13+
$$;
14+
15+
CREATE FUNCTION app_public.dirty() RETURNS setof app_public.users
16+
LANGUAGE sql
17+
AS $$
18+
SELECT * FROM users
19+
$$;
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { execFile } from 'child_process';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
import { promisify } from 'util';
5+
6+
const execFileAsync = promisify(execFile);
7+
8+
const CLI = path.join(__dirname, '..', 'dist', 'cli.js');
9+
const FIXTURES = path.join(__dirname, '__fixtures__');
10+
11+
interface RunResult {
12+
code: number;
13+
stdout: string;
14+
stderr: string;
15+
}
16+
17+
async function runCli(args: string[]): Promise<RunResult> {
18+
try {
19+
const { stdout, stderr } = await execFileAsync('node', [CLI, ...args]);
20+
return { code: 0, stdout, stderr };
21+
} catch (err) {
22+
const e = err as { code?: number; stdout?: string; stderr?: string };
23+
return { code: e.code ?? 1, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
24+
}
25+
}
26+
27+
// The CLI is exercised against the built dist/ (CI runs `pnpm build` first).
28+
const built = fs.existsSync(CLI);
29+
const describeIfBuilt = built ? describe : describe.skip;
30+
31+
describeIfBuilt('pgsql-lint CLI', () => {
32+
it('exits 1 and reports the C3 finding as JSON', async () => {
33+
const { code, stdout } = await runCli([path.join(FIXTURES, 'migration.sql'), '--json']);
34+
expect(code).toBe(1);
35+
const reports = JSON.parse(stdout);
36+
const findings = reports.flatMap((r: { findings: unknown[] }) => r.findings);
37+
expect(findings).toHaveLength(1);
38+
expect(findings[0].code).toBe('C3');
39+
});
40+
41+
it('exits 0 when only some rules are selected and none match', async () => {
42+
const { code } = await runCli([path.join(FIXTURES, 'migration.sql'), '--rules', 'no-dynamic-sql']);
43+
expect(code).toBe(0);
44+
});
45+
46+
it('exits 0 when the only finding is downgraded to a warning', async () => {
47+
const { code } = await runCli([
48+
path.join(FIXTURES, 'migration.sql'),
49+
'--warn',
50+
'require-qualified-refs'
51+
]);
52+
expect(code).toBe(0);
53+
});
54+
55+
it('reports the finding as a warning in JSON when downgraded', async () => {
56+
const { stdout } = await runCli([
57+
path.join(FIXTURES, 'migration.sql'),
58+
'--warn',
59+
'C3',
60+
'--json'
61+
]);
62+
const reports = JSON.parse(stdout);
63+
const findings = reports.flatMap((r: { findings: { severity: string }[] }) => r.findings);
64+
expect(findings[0].severity).toBe('warn');
65+
});
66+
67+
it('exits 0 when the rule is turned off', async () => {
68+
const { code } = await runCli([
69+
path.join(FIXTURES, 'migration.sql'),
70+
'--off',
71+
'require-qualified-refs'
72+
]);
73+
expect(code).toBe(0);
74+
});
75+
76+
it('prints help and exits 0 with --help', async () => {
77+
const { code, stdout } = await runCli(['--help']);
78+
expect(code).toBe(0);
79+
expect(stdout).toContain('pgsql-lint');
80+
expect(stdout).toContain('no-dynamic-sql');
81+
});
82+
});

0 commit comments

Comments
 (0)