Skip to content

Commit 84eb982

Browse files
committed
docs(lint): logo/badge README header + pgsql-lint skill
1 parent 697156f commit 84eb982

3 files changed

Lines changed: 162 additions & 0 deletions

File tree

.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` |

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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
11
# @pgsql/lint
22

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+
315
A source-level SQL / PL/pgSQL **convention linter**. It reasons about the *text*
416
of a `CREATE FUNCTION` definition — from its AST — and carries **no `pg` /
517
catalog dependency**, so the exact same engine runs over a definition in a
618
migration, an editor buffer, a pre-commit hook, or one read from a live catalog
719
via `pg_get_functiondef`.
820

21+
## Installation
22+
23+
```bash
24+
npm install @pgsql/lint
25+
```
26+
927
## Rules
1028

1129
| Code | Id | Flags |

0 commit comments

Comments
 (0)