@pgsql/lint 18.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/cli.d.ts +12 -0
- package/cli.js +130 -0
- package/engine.d.ts +30 -0
- package/engine.js +66 -0
- package/esm/cli.js +125 -0
- package/esm/engine.js +62 -0
- package/esm/file-runner.js +221 -0
- package/esm/index.js +19 -0
- package/esm/linter.js +36 -0
- package/esm/parse-unit.js +170 -0
- package/esm/rules/index.js +14 -0
- package/esm/rules/no-dynamic-sql.js +26 -0
- package/esm/rules/no-set-search-path.js +64 -0
- package/esm/rules/no-variable-conflict.js +36 -0
- package/esm/rules/require-qualified-refs.js +68 -0
- package/esm/suppressions.js +131 -0
- package/esm/types.js +23 -0
- package/esm/util.js +9 -0
- package/esm/walk.js +14 -0
- package/file-runner.d.ts +68 -0
- package/file-runner.js +263 -0
- package/index.d.ts +22 -0
- package/index.js +43 -0
- package/linter.d.ts +51 -0
- package/linter.js +41 -0
- package/package.json +60 -0
- package/parse-unit.d.ts +17 -0
- package/parse-unit.js +173 -0
- package/rules/index.d.ts +10 -0
- package/rules/index.js +20 -0
- package/rules/no-dynamic-sql.d.ts +13 -0
- package/rules/no-dynamic-sql.js +29 -0
- package/rules/no-set-search-path.d.ts +12 -0
- package/rules/no-set-search-path.js +67 -0
- package/rules/no-variable-conflict.d.ts +12 -0
- package/rules/no-variable-conflict.js +39 -0
- package/rules/require-qualified-refs.d.ts +16 -0
- package/rules/require-qualified-refs.js +71 -0
- package/suppressions.d.ts +49 -0
- package/suppressions.js +135 -0
- package/types.d.ts +142 -0
- package/types.js +26 -0
- package/util.d.ts +2 -0
- package/util.js +12 -0
- package/walk.d.ts +5 -0
- package/walk.js +17 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run the linter over SQL *source files* — migrations, `CREATE FUNCTION`
|
|
3
|
+
* scripts, anything on disk. This is the "local" entry point (CLI, pre-commit,
|
|
4
|
+
* editor): it finds every function definition in a file, lints each one, and
|
|
5
|
+
* re-anchors the findings to absolute file lines.
|
|
6
|
+
*
|
|
7
|
+
* The library entry point ({@link lintDefinition}) takes a single definition
|
|
8
|
+
* as safegres feeds it from `pg_get_functiondef`; this layer adds the file
|
|
9
|
+
* plumbing on top so a human or an agent can point it at `.sql` and get the
|
|
10
|
+
* same findings without a database.
|
|
11
|
+
*/
|
|
12
|
+
import { promises as fs } from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import { parse } from 'pgsql-parser';
|
|
15
|
+
import { lintDefinition } from './engine';
|
|
16
|
+
import { findAll } from './walk';
|
|
17
|
+
/** Count newlines before `offset` — the 0-based line the offset sits on. */
|
|
18
|
+
function lineAtOffset(text, offset) {
|
|
19
|
+
let n = 0;
|
|
20
|
+
const end = Math.min(Math.max(offset, 0), text.length);
|
|
21
|
+
for (let i = 0; i < end; i++)
|
|
22
|
+
if (text.charCodeAt(i) === 10)
|
|
23
|
+
n++;
|
|
24
|
+
return n;
|
|
25
|
+
}
|
|
26
|
+
/** Printable `schema.name` of a `CreateFunctionStmt.funcname`. */
|
|
27
|
+
function funcName(createFnStmt) {
|
|
28
|
+
const funcname = createFnStmt.funcname;
|
|
29
|
+
if (!Array.isArray(funcname))
|
|
30
|
+
return '<anonymous>';
|
|
31
|
+
const parts = funcname
|
|
32
|
+
.map((n) => {
|
|
33
|
+
const str = n.String;
|
|
34
|
+
return str && typeof str.sval === 'string' ? str.sval : '';
|
|
35
|
+
})
|
|
36
|
+
.filter((s) => s.length > 0);
|
|
37
|
+
return parts.length > 0 ? parts.join('.') : '<anonymous>';
|
|
38
|
+
}
|
|
39
|
+
/** The `LANGUAGE` of a `CreateFunctionStmt` (lower-cased), defaulting to `sql`. */
|
|
40
|
+
function funcLanguage(createFnStmt) {
|
|
41
|
+
const options = createFnStmt.options;
|
|
42
|
+
if (Array.isArray(options)) {
|
|
43
|
+
for (const opt of options) {
|
|
44
|
+
const de = opt.DefElem;
|
|
45
|
+
if (de?.defname === 'language') {
|
|
46
|
+
const str = de.arg?.String;
|
|
47
|
+
if (str && typeof str.sval === 'string')
|
|
48
|
+
return str.sval.toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return 'sql';
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Split a SQL source string into the function definitions it contains. Each
|
|
56
|
+
* `CREATE FUNCTION` is sliced back out with its absolute start line, so a
|
|
57
|
+
* mixed migration (schema + tables + functions) yields one input per function
|
|
58
|
+
* rather than being treated as a single malformed definition.
|
|
59
|
+
*/
|
|
60
|
+
export async function sqlTextDefinitions(source, file) {
|
|
61
|
+
const parsed = (await parse(source));
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const entry of parsed.stmts ?? []) {
|
|
64
|
+
const createFnStmt = entry.stmt ? findAll(entry.stmt, 'CreateFunctionStmt')[0] : undefined;
|
|
65
|
+
if (!createFnStmt)
|
|
66
|
+
continue;
|
|
67
|
+
const start = entry.stmt_location ?? 0;
|
|
68
|
+
const len = entry.stmt_len ?? source.length - start;
|
|
69
|
+
out.push({
|
|
70
|
+
text: source.slice(start, start + len),
|
|
71
|
+
language: funcLanguage(createFnStmt),
|
|
72
|
+
name: funcName(createFnStmt),
|
|
73
|
+
file,
|
|
74
|
+
startLine: lineAtOffset(source, start) // 0-based line the definition begins on
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Lint a SQL source string that may contain many statements. Findings are
|
|
81
|
+
* re-anchored to absolute lines within `source`.
|
|
82
|
+
*/
|
|
83
|
+
export async function lintSqlText(source, options = {}) {
|
|
84
|
+
let inputs;
|
|
85
|
+
try {
|
|
86
|
+
inputs = await sqlTextDefinitions(source);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
return { file: '<source>', findings: [], parseError: err.message };
|
|
90
|
+
}
|
|
91
|
+
const findings = await lintInputs(inputs, options);
|
|
92
|
+
return { file: '<source>', findings };
|
|
93
|
+
}
|
|
94
|
+
/** Lint a set of adapter-produced definitions, re-anchored to file lines. */
|
|
95
|
+
async function lintInputs(inputs, options) {
|
|
96
|
+
const findings = [];
|
|
97
|
+
for (const input of inputs) {
|
|
98
|
+
const subject = input.name ?? '<anonymous>';
|
|
99
|
+
const startLine = input.startLine ?? 0;
|
|
100
|
+
const { problems, suppressed } = await lintDefinition(input.text, input.language, subject, options);
|
|
101
|
+
const absLine = (p) => startLine + p.line; // p.line is 1-based within input.text
|
|
102
|
+
for (const p of problems)
|
|
103
|
+
findings.push(toFinding(p, subject, absLine(p), false));
|
|
104
|
+
for (const s of suppressed) {
|
|
105
|
+
findings.push(toFinding(s, subject, absLine(s), true, s.reason, s.scope));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
findings.sort((a, b) => a.line - b.line || a.ruleId.localeCompare(b.ruleId));
|
|
109
|
+
return findings;
|
|
110
|
+
}
|
|
111
|
+
function toFinding(p, subject, line, acknowledged, reason, scope) {
|
|
112
|
+
const meta = {
|
|
113
|
+
ruleId: p.ruleId,
|
|
114
|
+
code: codeFor(p.ruleId),
|
|
115
|
+
line,
|
|
116
|
+
message: p.message,
|
|
117
|
+
severity: p.severity ?? 'error',
|
|
118
|
+
subject,
|
|
119
|
+
acknowledged,
|
|
120
|
+
context: p.context
|
|
121
|
+
};
|
|
122
|
+
if (p.hint)
|
|
123
|
+
meta.hint = p.hint;
|
|
124
|
+
if (acknowledged) {
|
|
125
|
+
meta.reason = reason ?? null;
|
|
126
|
+
meta.scope = scope;
|
|
127
|
+
}
|
|
128
|
+
return meta;
|
|
129
|
+
}
|
|
130
|
+
const RULE_CODE = {
|
|
131
|
+
'no-set-search-path': 'C1',
|
|
132
|
+
'no-variable-conflict': 'C2',
|
|
133
|
+
'require-qualified-refs': 'C3',
|
|
134
|
+
'no-dynamic-sql': 'C4'
|
|
135
|
+
};
|
|
136
|
+
function codeFor(ruleId) {
|
|
137
|
+
return RULE_CODE[ruleId] ?? ruleId;
|
|
138
|
+
}
|
|
139
|
+
/** Recursively collect `.sql` files under a directory. */
|
|
140
|
+
async function sqlFilesUnder(dir) {
|
|
141
|
+
const out = [];
|
|
142
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
143
|
+
for (const e of entries) {
|
|
144
|
+
const full = path.join(dir, e.name);
|
|
145
|
+
if (e.isDirectory()) {
|
|
146
|
+
if (e.name === 'node_modules' || e.name === '.git' || e.name === 'dist')
|
|
147
|
+
continue;
|
|
148
|
+
out.push(...(await sqlFilesUnder(full)));
|
|
149
|
+
}
|
|
150
|
+
else if (e.isFile() && full.toLowerCase().endsWith('.sql')) {
|
|
151
|
+
out.push(full);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
/** Resolve a mix of file and directory paths to a sorted list of `.sql` files. */
|
|
157
|
+
export async function resolveSqlFiles(paths) {
|
|
158
|
+
const files = new Set();
|
|
159
|
+
for (const p of paths) {
|
|
160
|
+
const st = await fs.stat(p);
|
|
161
|
+
if (st.isDirectory()) {
|
|
162
|
+
for (const f of await sqlFilesUnder(p))
|
|
163
|
+
files.add(f);
|
|
164
|
+
}
|
|
165
|
+
else if (st.isFile()) {
|
|
166
|
+
files.add(p);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return [...files].sort();
|
|
170
|
+
}
|
|
171
|
+
/** Lint every `.sql` file reachable from `paths` (files or directories). */
|
|
172
|
+
export async function lintFiles(paths, options = {}) {
|
|
173
|
+
const files = await resolveSqlFiles(paths);
|
|
174
|
+
const reports = [];
|
|
175
|
+
for (const file of files) {
|
|
176
|
+
const source = await fs.readFile(file, 'utf8');
|
|
177
|
+
const report = await lintSqlText(source, options);
|
|
178
|
+
reports.push({ ...report, file });
|
|
179
|
+
}
|
|
180
|
+
return reports;
|
|
181
|
+
}
|
|
182
|
+
/** A {@link SourceAdapter} over an in-memory SQL string. */
|
|
183
|
+
export function sqlTextAdapter(source, file) {
|
|
184
|
+
return { id: 'sql-text', definitions: () => sqlTextDefinitions(source, file) };
|
|
185
|
+
}
|
|
186
|
+
/** A {@link SourceAdapter} over `.sql` files/directories on disk. */
|
|
187
|
+
export function filesAdapter(paths) {
|
|
188
|
+
return {
|
|
189
|
+
id: 'files',
|
|
190
|
+
definitions: async () => {
|
|
191
|
+
const files = await resolveSqlFiles(paths);
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const file of files) {
|
|
194
|
+
out.push(...(await sqlTextDefinitions(await fs.readFile(file, 'utf8'), file)));
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Lint every definition an adapter yields, grouped into one {@link FileReport}
|
|
202
|
+
* per origin file. This is the generic entry point: `@pgsql/lint` ships file
|
|
203
|
+
* and sql-text adapters; a consumer (e.g. safegres over a live catalog) can
|
|
204
|
+
* pass its own.
|
|
205
|
+
*/
|
|
206
|
+
export async function lintSource(adapter, options = {}) {
|
|
207
|
+
const inputs = await adapter.definitions();
|
|
208
|
+
const byFile = new Map();
|
|
209
|
+
for (const input of inputs) {
|
|
210
|
+
const file = input.file ?? `<${adapter.id}>`;
|
|
211
|
+
const arr = byFile.get(file) ?? [];
|
|
212
|
+
arr.push(input);
|
|
213
|
+
byFile.set(file, arr);
|
|
214
|
+
}
|
|
215
|
+
const reports = [];
|
|
216
|
+
for (const [file, group] of byFile) {
|
|
217
|
+
reports.push({ file, findings: await lintInputs(group, options) });
|
|
218
|
+
}
|
|
219
|
+
reports.sort((a, b) => a.file.localeCompare(b.file));
|
|
220
|
+
return reports;
|
|
221
|
+
}
|
package/esm/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@pgsql/lint` — a source-level SQL/PL/pgSQL convention linter.
|
|
3
|
+
*
|
|
4
|
+
* It reasons about the *text* of a function definition — fully-qualified
|
|
5
|
+
* references, `search_path` settings, `#variable_conflict`, dynamic SQL — from
|
|
6
|
+
* its AST, and carries no `pg` / catalog dependency. The same engine runs over
|
|
7
|
+
* a `CREATE FUNCTION` from a migration, an editor buffer, or a definition read
|
|
8
|
+
* from a live catalog via `pg_get_functiondef`.
|
|
9
|
+
*
|
|
10
|
+
* Rules are injected as values, never discovered by npm name: build a linter
|
|
11
|
+
* with `createLinter({ rules, severity })` and pass your own rules in.
|
|
12
|
+
*/
|
|
13
|
+
export { lintDefinition, severityOf } from './engine';
|
|
14
|
+
export { filesAdapter, lintFiles, lintSource, lintSqlText, resolveSqlFiles, sqlTextAdapter, sqlTextDefinitions } from './file-runner';
|
|
15
|
+
export { createLinter } from './linter';
|
|
16
|
+
export { parseUnit } from './parse-unit';
|
|
17
|
+
export { LINT_RULES, LINT_RULES_BY_CODE, LINT_RULES_BY_ID, noDynamicSql, noSetSearchPath, noVariableConflict, requireQualifiedRefs } from './rules';
|
|
18
|
+
export { DEFAULT_KEYWORDS, Suppressions } from './suppressions';
|
|
19
|
+
export { defineRule } from './types';
|
package/esm/linter.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createLinter` — the ecosystem entry point.
|
|
3
|
+
*
|
|
4
|
+
* A linter is nothing but a rule set + severities + directive keyword bound
|
|
5
|
+
* together. Rules are passed *in* as values (not discovered by npm name), so
|
|
6
|
+
* anyone can publish a rule package, `import` it, and add it here. Severity is
|
|
7
|
+
* configuration (ESLint-style `off` / `warn` / `error`), keyed by rule id or
|
|
8
|
+
* code, so a downstream consumer (safegres) keeps full control of how loud
|
|
9
|
+
* each rule is without duplicating any engine logic.
|
|
10
|
+
*/
|
|
11
|
+
import { lintDefinition } from './engine';
|
|
12
|
+
import { filesAdapter, lintFiles, lintSource, lintSqlText, sqlTextAdapter } from './file-runner';
|
|
13
|
+
import { LINT_RULES } from './rules';
|
|
14
|
+
import { DEFAULT_KEYWORDS } from './suppressions';
|
|
15
|
+
/** Build a {@link Linter} bound to a rule set, severities, and keyword. */
|
|
16
|
+
export function createLinter(config = {}) {
|
|
17
|
+
const rules = config.rules ?? LINT_RULES;
|
|
18
|
+
const severity = config.severity ?? {};
|
|
19
|
+
const keyword = config.keyword ?? DEFAULT_KEYWORDS;
|
|
20
|
+
const bind = (options = {}) => ({
|
|
21
|
+
ruleSet: rules,
|
|
22
|
+
severity,
|
|
23
|
+
keyword,
|
|
24
|
+
rules: options.rules
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
rules,
|
|
28
|
+
severity,
|
|
29
|
+
keyword,
|
|
30
|
+
lintDefinition: (text, language, name, options) => lintDefinition(text, language, name, bind(options)),
|
|
31
|
+
lintSqlText: (source, options) => lintSqlText(source, bind(options)),
|
|
32
|
+
lintFiles: (paths, options) => lintFiles(paths, bind(options)),
|
|
33
|
+
lintSource: (adapter, options) => lintSource(adapter, bind(options))
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export { filesAdapter, sqlTextAdapter };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a `CREATE FUNCTION …` definition into a {@link LintUnit}: the parsed
|
|
3
|
+
* SQL statement, the embedded body fragments, and the machinery to map any
|
|
4
|
+
* AST location back to an absolute line in the original text.
|
|
5
|
+
*
|
|
6
|
+
* Line mapping is the whole trick. PL/pgSQL statement line numbers are
|
|
7
|
+
* relative to the *body* (`prosrc`), and embedded SQL expressions are parsed
|
|
8
|
+
* in isolation, so both have to be re-anchored to the definition text before a
|
|
9
|
+
* finding — or a suppression comment — can be matched to them.
|
|
10
|
+
*/
|
|
11
|
+
import { parsePlPgSQL } from 'libpg-query';
|
|
12
|
+
import { parse } from 'pgsql-parser';
|
|
13
|
+
import { findAll } from './walk';
|
|
14
|
+
/** Count newlines in `s[0..offset)` — i.e. how many lines precede `offset`. */
|
|
15
|
+
function newlinesBefore(s, offset) {
|
|
16
|
+
let n = 0;
|
|
17
|
+
const end = Math.min(offset, s.length);
|
|
18
|
+
for (let i = 0; i < end; i++)
|
|
19
|
+
if (s.charCodeAt(i) === 10)
|
|
20
|
+
n++;
|
|
21
|
+
return n;
|
|
22
|
+
}
|
|
23
|
+
/** Absolute 1-based line of a char offset within `text`. */
|
|
24
|
+
function lineOf(text, offset) {
|
|
25
|
+
return newlinesBefore(text, offset) + 1;
|
|
26
|
+
}
|
|
27
|
+
/** The body string of a `CreateFunctionStmt` (`AS $$ … $$`), or null. */
|
|
28
|
+
function functionBody(createFnStmt) {
|
|
29
|
+
const options = createFnStmt.options;
|
|
30
|
+
if (!Array.isArray(options))
|
|
31
|
+
return null;
|
|
32
|
+
for (const opt of options) {
|
|
33
|
+
const de = opt.DefElem;
|
|
34
|
+
if (!de || de.defname !== 'as')
|
|
35
|
+
continue;
|
|
36
|
+
const arg = de.arg;
|
|
37
|
+
const list = arg?.List;
|
|
38
|
+
const items = list?.items;
|
|
39
|
+
if (!Array.isArray(items) || items.length === 0)
|
|
40
|
+
return null;
|
|
41
|
+
// A two-item AS (`obj_file`, `link_symbol`) is a C function — no SQL body.
|
|
42
|
+
if (items.length > 1)
|
|
43
|
+
return null;
|
|
44
|
+
const str = items[0].String;
|
|
45
|
+
const sval = str?.sval;
|
|
46
|
+
return typeof sval === 'string' ? sval : null;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Walk the PL/pgSQL JSON tree, collecting (a) every embedded SQL expression
|
|
52
|
+
* with the line number of its enclosing statement and (b) every dynamic-SQL
|
|
53
|
+
* site. Line numbers here are body-relative; the caller re-anchors them.
|
|
54
|
+
*/
|
|
55
|
+
function collectPlpgsql(node, currentLine, exprs, dynamic) {
|
|
56
|
+
if (Array.isArray(node)) {
|
|
57
|
+
for (const item of node)
|
|
58
|
+
collectPlpgsql(item, currentLine, exprs, dynamic);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!node || typeof node !== 'object')
|
|
62
|
+
return;
|
|
63
|
+
const rec = node;
|
|
64
|
+
// A statement node carries its own line; descendants inherit it until the
|
|
65
|
+
// next statement re-sets it.
|
|
66
|
+
let line = currentLine;
|
|
67
|
+
for (const [key, value] of Object.entries(rec)) {
|
|
68
|
+
if (key.startsWith('PLpgSQL_stmt_')) {
|
|
69
|
+
const stmt = value;
|
|
70
|
+
if (typeof stmt.lineno === 'number')
|
|
71
|
+
line = stmt.lineno;
|
|
72
|
+
if (key === 'PLpgSQL_stmt_dynexecute') {
|
|
73
|
+
dynamic.push({ line, form: 'EXECUTE' });
|
|
74
|
+
}
|
|
75
|
+
else if (key === 'PLpgSQL_stmt_dynfors') {
|
|
76
|
+
dynamic.push({ line, form: 'FOR … IN EXECUTE' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const expr = rec.PLpgSQL_expr;
|
|
81
|
+
if (expr && typeof expr.query === 'string') {
|
|
82
|
+
exprs.push({
|
|
83
|
+
query: expr.query,
|
|
84
|
+
parseMode: typeof expr.parseMode === 'number' ? expr.parseMode : 2,
|
|
85
|
+
line
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
for (const value of Object.values(rec))
|
|
89
|
+
collectPlpgsql(value, line, exprs, dynamic);
|
|
90
|
+
}
|
|
91
|
+
/** Reconstruct a parseable SQL string from a PL/pgSQL embedded expression. */
|
|
92
|
+
function fragmentSql(query, parseMode) {
|
|
93
|
+
// parseMode 0 = full statement; 3 = assignment (strip the anchored target so
|
|
94
|
+
// the RHS parses); anything else is a bare expression.
|
|
95
|
+
if (parseMode === 0)
|
|
96
|
+
return query;
|
|
97
|
+
let q = query;
|
|
98
|
+
if (parseMode === 3) {
|
|
99
|
+
q = q.replace(/^\s*[a-zA-Z_"][\w$".]*(\[[^\]]*\])*\s*:?=\s*/, '');
|
|
100
|
+
}
|
|
101
|
+
return `SELECT ${q}`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Parse a function definition into a {@link LintUnit}. Never throws: an
|
|
105
|
+
* unparseable definition comes back with `parseError` set and no fragments,
|
|
106
|
+
* so rules that need the AST simply find nothing.
|
|
107
|
+
*/
|
|
108
|
+
export async function parseUnit(text, language, name) {
|
|
109
|
+
const lines = text.split('\n');
|
|
110
|
+
const base = { text, lines, language, name, fragments: [], dynamicSql: [] };
|
|
111
|
+
let sqlAst;
|
|
112
|
+
try {
|
|
113
|
+
sqlAst = await parse(text);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
return { ...base, parseError: `definition failed to parse: ${err.message}` };
|
|
117
|
+
}
|
|
118
|
+
const createFnStmt = findAll(sqlAst, 'CreateFunctionStmt')[0];
|
|
119
|
+
if (!createFnStmt)
|
|
120
|
+
return { ...base, parseError: 'not a CREATE FUNCTION statement' };
|
|
121
|
+
const body = functionBody(createFnStmt);
|
|
122
|
+
const bodyOffset = body !== null ? text.indexOf(body) : -1;
|
|
123
|
+
const bodyStartLine = bodyOffset >= 0 ? lineOf(text, bodyOffset) : undefined;
|
|
124
|
+
const fragments = [];
|
|
125
|
+
const dynamicSql = [];
|
|
126
|
+
const lang = language.toLowerCase();
|
|
127
|
+
if (lang === 'sql' && body !== null && bodyStartLine !== undefined) {
|
|
128
|
+
// A SQL-language body is itself SQL: parse it whole. Locations are
|
|
129
|
+
// relative to the body, so re-anchor them onto the definition.
|
|
130
|
+
try {
|
|
131
|
+
const ast = await parse(body);
|
|
132
|
+
fragments.push({
|
|
133
|
+
ast,
|
|
134
|
+
lineForOffset: (offset) => bodyStartLine + newlinesBefore(body, offset)
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Body may contain positional parameters etc. that don't parse alone —
|
|
139
|
+
// leave it as no fragment rather than erroring the whole unit.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else if (lang === 'plpgsql') {
|
|
143
|
+
let plpgsql;
|
|
144
|
+
try {
|
|
145
|
+
plpgsql = await parsePlPgSQL(text);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
return { ...base, createFnStmt, bodyStartLine, parseError: `PL/pgSQL body failed to parse: ${err.message}` };
|
|
149
|
+
}
|
|
150
|
+
const exprs = [];
|
|
151
|
+
const dyn = [];
|
|
152
|
+
collectPlpgsql(plpgsql, 0, exprs, dyn);
|
|
153
|
+
const anchor = (bodyLine) => bodyStartLine !== undefined && bodyLine > 0 ? bodyStartLine + (bodyLine - 1) : (bodyStartLine ?? 1);
|
|
154
|
+
for (const d of dyn)
|
|
155
|
+
dynamicSql.push({ line: anchor(d.line), form: d.form });
|
|
156
|
+
for (const e of exprs) {
|
|
157
|
+
const sql = fragmentSql(e.query, e.parseMode);
|
|
158
|
+
let ast;
|
|
159
|
+
try {
|
|
160
|
+
ast = await parse(sql);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
continue; // opaque fragment — not the linter's concern
|
|
164
|
+
}
|
|
165
|
+
const absLine = anchor(e.line);
|
|
166
|
+
fragments.push({ ast, lineForOffset: () => absLine });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { ...base, createFnStmt, bodyStartLine, fragments, dynamicSql };
|
|
170
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { noDynamicSql } from './no-dynamic-sql';
|
|
2
|
+
import { noSetSearchPath } from './no-set-search-path';
|
|
3
|
+
import { noVariableConflict } from './no-variable-conflict';
|
|
4
|
+
import { requireQualifiedRefs } from './require-qualified-refs';
|
|
5
|
+
/** The lint rules, in report order. */
|
|
6
|
+
export const LINT_RULES = [
|
|
7
|
+
noSetSearchPath,
|
|
8
|
+
requireQualifiedRefs,
|
|
9
|
+
noVariableConflict,
|
|
10
|
+
noDynamicSql
|
|
11
|
+
];
|
|
12
|
+
export const LINT_RULES_BY_ID = new Map(LINT_RULES.map((r) => [r.id, r]));
|
|
13
|
+
export const LINT_RULES_BY_CODE = new Map(LINT_RULES.map((r) => [r.code, r]));
|
|
14
|
+
export { noDynamicSql, noSetSearchPath, noVariableConflict, requireQualifiedRefs };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `no-dynamic-sql` (C4): a function must not use dynamic SQL.
|
|
3
|
+
*
|
|
4
|
+
* Dynamic SQL (`EXECUTE`, `EXECUTE … USING`, `FOR … IN EXECUTE`) is permitted
|
|
5
|
+
* only for lookup-only or code-generation work, and never for writes — but the
|
|
6
|
+
* string handed to `EXECUTE` is opaque to the parser, so we cannot statically
|
|
7
|
+
* tell read from write. The enforceable form is therefore: flag every site,
|
|
8
|
+
* and require a categorized waiver. This is the one rule whose suppression
|
|
9
|
+
* must carry a reason (see `reasonRequired`), so an approved use always names
|
|
10
|
+
* *why* (`lookup-only` / `codegen`).
|
|
11
|
+
*/
|
|
12
|
+
export const noDynamicSql = {
|
|
13
|
+
id: 'no-dynamic-sql',
|
|
14
|
+
code: 'C4',
|
|
15
|
+
title: 'Function must not use dynamic SQL',
|
|
16
|
+
reasonRequired: true,
|
|
17
|
+
run(unit) {
|
|
18
|
+
return unit.dynamicSql.map((site) => ({
|
|
19
|
+
ruleId: 'no-dynamic-sql',
|
|
20
|
+
line: site.line,
|
|
21
|
+
message: `Function uses dynamic SQL (${site.form})`,
|
|
22
|
+
hint: 'Avoid dynamic SQL. If it is genuinely lookup-only or code-generation (never a write), waive it with a reason: `-- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: <why>`.',
|
|
23
|
+
context: { form: site.form }
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `no-set-search-path` (C1): a function must never set `search_path`.
|
|
3
|
+
*
|
|
4
|
+
* House rule: rather than pin `search_path` (the usual CWE-426 mitigation for
|
|
5
|
+
* SECURITY DEFINER), we fully-qualify every reference and never touch the
|
|
6
|
+
* setting at all. This flags both forms:
|
|
7
|
+
* - the declarative `CREATE FUNCTION … SET search_path = …` clause (this is
|
|
8
|
+
* exactly what `pg_proc.proconfig` / `searchPathPinned` records), and
|
|
9
|
+
* - a runtime `set_config('search_path', …)` in the body.
|
|
10
|
+
*/
|
|
11
|
+
import { lineOfOffset } from '../util';
|
|
12
|
+
function optionSites(unit) {
|
|
13
|
+
const out = [];
|
|
14
|
+
const options = unit.createFnStmt?.options;
|
|
15
|
+
if (!Array.isArray(options))
|
|
16
|
+
return out;
|
|
17
|
+
for (const opt of options) {
|
|
18
|
+
const de = opt.DefElem;
|
|
19
|
+
if (!de || de.defname !== 'set')
|
|
20
|
+
continue;
|
|
21
|
+
const vss = de.arg?.VariableSetStmt;
|
|
22
|
+
if (!vss || vss.name !== 'search_path')
|
|
23
|
+
continue;
|
|
24
|
+
const loc = typeof de.location === 'number' ? de.location : 0;
|
|
25
|
+
out.push({
|
|
26
|
+
ruleId: 'no-set-search-path',
|
|
27
|
+
line: lineOfOffset(unit.text, loc),
|
|
28
|
+
message: 'Function sets search_path',
|
|
29
|
+
hint: 'Never set search_path. Fully-qualify every relation, function and type reference instead.',
|
|
30
|
+
context: { form: 'SET clause' }
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function setConfigSites(unit) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const re = /\bset_config\s*\(\s*'search_path'/i;
|
|
38
|
+
unit.lines.forEach((text, i) => {
|
|
39
|
+
if (re.test(text)) {
|
|
40
|
+
out.push({
|
|
41
|
+
ruleId: 'no-set-search-path',
|
|
42
|
+
line: i + 1,
|
|
43
|
+
message: 'Function sets search_path via set_config()',
|
|
44
|
+
hint: 'Never set search_path. Fully-qualify references instead of relying on it.',
|
|
45
|
+
context: { form: 'set_config()' }
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
export const noSetSearchPath = {
|
|
52
|
+
id: 'no-set-search-path',
|
|
53
|
+
code: 'C1',
|
|
54
|
+
title: 'Function must not set search_path',
|
|
55
|
+
reasonRequired: false,
|
|
56
|
+
run(unit) {
|
|
57
|
+
const byLine = new Map();
|
|
58
|
+
for (const p of [...optionSites(unit), ...setConfigSites(unit)]) {
|
|
59
|
+
if (!byLine.has(p.line))
|
|
60
|
+
byLine.set(p.line, p);
|
|
61
|
+
}
|
|
62
|
+
return [...byLine.values()].sort((a, b) => a.line - b.line);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `no-variable-conflict` (C2): a PL/pgSQL body must not use a
|
|
3
|
+
* `#variable_conflict` directive.
|
|
4
|
+
*
|
|
5
|
+
* The directive papers over an ambiguity between a column name and a PL/pgSQL
|
|
6
|
+
* variable; the house style is to remove the ambiguity (rename the variable,
|
|
7
|
+
* qualify the column) rather than declare a winner. The directive is a
|
|
8
|
+
* compiler pragma that must start a line at the top of the body, so a line
|
|
9
|
+
* scan is exact — it never appears inside an expression or string.
|
|
10
|
+
*/
|
|
11
|
+
const RE = /^\s*#variable_conflict\b\s*(\S+)?/i;
|
|
12
|
+
export const noVariableConflict = {
|
|
13
|
+
id: 'no-variable-conflict',
|
|
14
|
+
code: 'C2',
|
|
15
|
+
title: 'Function must not use #variable_conflict',
|
|
16
|
+
reasonRequired: false,
|
|
17
|
+
run(unit) {
|
|
18
|
+
if (unit.language.toLowerCase() !== 'plpgsql')
|
|
19
|
+
return [];
|
|
20
|
+
const out = [];
|
|
21
|
+
for (let i = 0; i < unit.lines.length; i++) {
|
|
22
|
+
const m = RE.exec(unit.lines[i]);
|
|
23
|
+
if (!m)
|
|
24
|
+
continue;
|
|
25
|
+
const mode = m[1] ?? '';
|
|
26
|
+
out.push({
|
|
27
|
+
ruleId: 'no-variable-conflict',
|
|
28
|
+
line: i + 1,
|
|
29
|
+
message: `Function uses #variable_conflict${mode ? ` ${mode}` : ''}`,
|
|
30
|
+
hint: 'Remove the directive and disambiguate explicitly: rename the variable or qualify the column reference.',
|
|
31
|
+
context: { mode }
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `require-qualified-refs` (C3): every relation reference must be
|
|
3
|
+
* schema-qualified.
|
|
4
|
+
*
|
|
5
|
+
* Banning `SET search_path` (C1) only removes the footgun; it does not make
|
|
6
|
+
* name resolution safe on its own. This is the rule that actually enforces the
|
|
7
|
+
* discipline: an unqualified `FROM users` resolves against whatever
|
|
8
|
+
* search_path happens to be, so it must be `FROM app_public.users`.
|
|
9
|
+
*
|
|
10
|
+
* v1 covers relation references (`RangeVar`). Names introduced by a CTE in the
|
|
11
|
+
* same query are excluded — they are not schema objects. Unqualified *function*
|
|
12
|
+
* calls are deferred (they need a built-in allowlist to avoid flagging
|
|
13
|
+
* `now()`, `count()`, …).
|
|
14
|
+
*/
|
|
15
|
+
import { findAll } from '../walk';
|
|
16
|
+
function cteNames(ast) {
|
|
17
|
+
const out = new Set();
|
|
18
|
+
for (const cte of findAll(ast, 'CommonTableExpr')) {
|
|
19
|
+
if (typeof cte.ctename === 'string')
|
|
20
|
+
out.add(cte.ctename);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
function fragmentProblems(fragment) {
|
|
25
|
+
const out = [];
|
|
26
|
+
const ctes = cteNames(fragment.ast);
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
for (const rv of findAll(fragment.ast, 'RangeVar')) {
|
|
29
|
+
const relname = typeof rv.relname === 'string' ? rv.relname : undefined;
|
|
30
|
+
if (!relname)
|
|
31
|
+
continue;
|
|
32
|
+
if (typeof rv.schemaname === 'string' && rv.schemaname.length > 0)
|
|
33
|
+
continue;
|
|
34
|
+
if (ctes.has(relname))
|
|
35
|
+
continue;
|
|
36
|
+
const loc = typeof rv.location === 'number' ? rv.location : -1;
|
|
37
|
+
const line = fragment.lineForOffset(loc >= 0 ? loc : 0);
|
|
38
|
+
const key = `${line}:${relname}`;
|
|
39
|
+
if (seen.has(key))
|
|
40
|
+
continue;
|
|
41
|
+
seen.add(key);
|
|
42
|
+
out.push({
|
|
43
|
+
ruleId: 'require-qualified-refs',
|
|
44
|
+
line,
|
|
45
|
+
message: `Unqualified relation reference "${relname}"`,
|
|
46
|
+
hint: 'Schema-qualify the reference (e.g. `app_public.' + relname + '`). Unqualified names resolve against search_path.',
|
|
47
|
+
context: { relation: relname }
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
export const requireQualifiedRefs = {
|
|
53
|
+
id: 'require-qualified-refs',
|
|
54
|
+
code: 'C3',
|
|
55
|
+
title: 'Relation references must be schema-qualified',
|
|
56
|
+
reasonRequired: false,
|
|
57
|
+
run(unit) {
|
|
58
|
+
const byKey = new Map();
|
|
59
|
+
for (const fragment of unit.fragments) {
|
|
60
|
+
for (const p of fragmentProblems(fragment)) {
|
|
61
|
+
const key = `${p.line}:${p.context.relation}`;
|
|
62
|
+
if (!byKey.has(key))
|
|
63
|
+
byKey.set(key, p);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return [...byKey.values()].sort((a, b) => a.line - b.line);
|
|
67
|
+
}
|
|
68
|
+
};
|