@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
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pgsql/lint",
|
|
3
|
+
"version": "18.1.0",
|
|
4
|
+
"author": "Constructive <developers@constructive.io>",
|
|
5
|
+
"description": "Source-level SQL/PL/pgSQL convention linter: fully-qualified references, no search_path, no dynamic SQL, with ESLint-style inline waivers. Pure AST, no database.",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"module": "esm/index.js",
|
|
8
|
+
"types": "index.d.ts",
|
|
9
|
+
"homepage": "https://github.com/constructive-io/pgsql-parser/tree/main/packages/lint#readme",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public",
|
|
13
|
+
"directory": "dist"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/constructive-io/pgsql-parser"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/constructive-io/pgsql-parser/issues"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"pgsql-lint": "cli.js"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"copy": "makage assets",
|
|
27
|
+
"clean": "makage clean dist",
|
|
28
|
+
"prepublishOnly": "npm run build",
|
|
29
|
+
"build": "npm run clean && tsc && tsc -p tsconfig.esm.json && npm run copy",
|
|
30
|
+
"build:dev": "npm run clean && tsc --declarationMap && tsc -p tsconfig.esm.json && npm run copy",
|
|
31
|
+
"dev": "ts-node src/cli",
|
|
32
|
+
"lint": "eslint . --fix",
|
|
33
|
+
"test": "jest",
|
|
34
|
+
"test:watch": "jest --watch"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@pgsql/traverse": "18.7.1",
|
|
38
|
+
"chalk": "^4.1.0",
|
|
39
|
+
"libpg-query": "18.1.2",
|
|
40
|
+
"minimist": "1.2.8",
|
|
41
|
+
"pgsql-parser": "18.2.1"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/minimist": "^1.2.5",
|
|
45
|
+
"makage": "^0.1.8"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"sql",
|
|
49
|
+
"postgres",
|
|
50
|
+
"postgresql",
|
|
51
|
+
"pg",
|
|
52
|
+
"ast",
|
|
53
|
+
"lint",
|
|
54
|
+
"linter",
|
|
55
|
+
"plpgsql",
|
|
56
|
+
"search_path",
|
|
57
|
+
"dynamic-sql"
|
|
58
|
+
],
|
|
59
|
+
"gitHead": "a437944187066a60705bcf11fda60bc457fa14e5"
|
|
60
|
+
}
|
package/parse-unit.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
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 type { LintUnit } from './types';
|
|
12
|
+
/**
|
|
13
|
+
* Parse a function definition into a {@link LintUnit}. Never throws: an
|
|
14
|
+
* unparseable definition comes back with `parseError` set and no fragments,
|
|
15
|
+
* so rules that need the AST simply find nothing.
|
|
16
|
+
*/
|
|
17
|
+
export declare function parseUnit(text: string, language: string, name?: string): Promise<LintUnit>;
|
package/parse-unit.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Turn a `CREATE FUNCTION …` definition into a {@link LintUnit}: the parsed
|
|
4
|
+
* SQL statement, the embedded body fragments, and the machinery to map any
|
|
5
|
+
* AST location back to an absolute line in the original text.
|
|
6
|
+
*
|
|
7
|
+
* Line mapping is the whole trick. PL/pgSQL statement line numbers are
|
|
8
|
+
* relative to the *body* (`prosrc`), and embedded SQL expressions are parsed
|
|
9
|
+
* in isolation, so both have to be re-anchored to the definition text before a
|
|
10
|
+
* finding — or a suppression comment — can be matched to them.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.parseUnit = parseUnit;
|
|
14
|
+
const libpg_query_1 = require("libpg-query");
|
|
15
|
+
const pgsql_parser_1 = require("pgsql-parser");
|
|
16
|
+
const walk_1 = require("./walk");
|
|
17
|
+
/** Count newlines in `s[0..offset)` — i.e. how many lines precede `offset`. */
|
|
18
|
+
function newlinesBefore(s, offset) {
|
|
19
|
+
let n = 0;
|
|
20
|
+
const end = Math.min(offset, s.length);
|
|
21
|
+
for (let i = 0; i < end; i++)
|
|
22
|
+
if (s.charCodeAt(i) === 10)
|
|
23
|
+
n++;
|
|
24
|
+
return n;
|
|
25
|
+
}
|
|
26
|
+
/** Absolute 1-based line of a char offset within `text`. */
|
|
27
|
+
function lineOf(text, offset) {
|
|
28
|
+
return newlinesBefore(text, offset) + 1;
|
|
29
|
+
}
|
|
30
|
+
/** The body string of a `CreateFunctionStmt` (`AS $$ … $$`), or null. */
|
|
31
|
+
function functionBody(createFnStmt) {
|
|
32
|
+
const options = createFnStmt.options;
|
|
33
|
+
if (!Array.isArray(options))
|
|
34
|
+
return null;
|
|
35
|
+
for (const opt of options) {
|
|
36
|
+
const de = opt.DefElem;
|
|
37
|
+
if (!de || de.defname !== 'as')
|
|
38
|
+
continue;
|
|
39
|
+
const arg = de.arg;
|
|
40
|
+
const list = arg?.List;
|
|
41
|
+
const items = list?.items;
|
|
42
|
+
if (!Array.isArray(items) || items.length === 0)
|
|
43
|
+
return null;
|
|
44
|
+
// A two-item AS (`obj_file`, `link_symbol`) is a C function — no SQL body.
|
|
45
|
+
if (items.length > 1)
|
|
46
|
+
return null;
|
|
47
|
+
const str = items[0].String;
|
|
48
|
+
const sval = str?.sval;
|
|
49
|
+
return typeof sval === 'string' ? sval : null;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Walk the PL/pgSQL JSON tree, collecting (a) every embedded SQL expression
|
|
55
|
+
* with the line number of its enclosing statement and (b) every dynamic-SQL
|
|
56
|
+
* site. Line numbers here are body-relative; the caller re-anchors them.
|
|
57
|
+
*/
|
|
58
|
+
function collectPlpgsql(node, currentLine, exprs, dynamic) {
|
|
59
|
+
if (Array.isArray(node)) {
|
|
60
|
+
for (const item of node)
|
|
61
|
+
collectPlpgsql(item, currentLine, exprs, dynamic);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!node || typeof node !== 'object')
|
|
65
|
+
return;
|
|
66
|
+
const rec = node;
|
|
67
|
+
// A statement node carries its own line; descendants inherit it until the
|
|
68
|
+
// next statement re-sets it.
|
|
69
|
+
let line = currentLine;
|
|
70
|
+
for (const [key, value] of Object.entries(rec)) {
|
|
71
|
+
if (key.startsWith('PLpgSQL_stmt_')) {
|
|
72
|
+
const stmt = value;
|
|
73
|
+
if (typeof stmt.lineno === 'number')
|
|
74
|
+
line = stmt.lineno;
|
|
75
|
+
if (key === 'PLpgSQL_stmt_dynexecute') {
|
|
76
|
+
dynamic.push({ line, form: 'EXECUTE' });
|
|
77
|
+
}
|
|
78
|
+
else if (key === 'PLpgSQL_stmt_dynfors') {
|
|
79
|
+
dynamic.push({ line, form: 'FOR … IN EXECUTE' });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const expr = rec.PLpgSQL_expr;
|
|
84
|
+
if (expr && typeof expr.query === 'string') {
|
|
85
|
+
exprs.push({
|
|
86
|
+
query: expr.query,
|
|
87
|
+
parseMode: typeof expr.parseMode === 'number' ? expr.parseMode : 2,
|
|
88
|
+
line
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
for (const value of Object.values(rec))
|
|
92
|
+
collectPlpgsql(value, line, exprs, dynamic);
|
|
93
|
+
}
|
|
94
|
+
/** Reconstruct a parseable SQL string from a PL/pgSQL embedded expression. */
|
|
95
|
+
function fragmentSql(query, parseMode) {
|
|
96
|
+
// parseMode 0 = full statement; 3 = assignment (strip the anchored target so
|
|
97
|
+
// the RHS parses); anything else is a bare expression.
|
|
98
|
+
if (parseMode === 0)
|
|
99
|
+
return query;
|
|
100
|
+
let q = query;
|
|
101
|
+
if (parseMode === 3) {
|
|
102
|
+
q = q.replace(/^\s*[a-zA-Z_"][\w$".]*(\[[^\]]*\])*\s*:?=\s*/, '');
|
|
103
|
+
}
|
|
104
|
+
return `SELECT ${q}`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Parse a function definition into a {@link LintUnit}. Never throws: an
|
|
108
|
+
* unparseable definition comes back with `parseError` set and no fragments,
|
|
109
|
+
* so rules that need the AST simply find nothing.
|
|
110
|
+
*/
|
|
111
|
+
async function parseUnit(text, language, name) {
|
|
112
|
+
const lines = text.split('\n');
|
|
113
|
+
const base = { text, lines, language, name, fragments: [], dynamicSql: [] };
|
|
114
|
+
let sqlAst;
|
|
115
|
+
try {
|
|
116
|
+
sqlAst = await (0, pgsql_parser_1.parse)(text);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
return { ...base, parseError: `definition failed to parse: ${err.message}` };
|
|
120
|
+
}
|
|
121
|
+
const createFnStmt = (0, walk_1.findAll)(sqlAst, 'CreateFunctionStmt')[0];
|
|
122
|
+
if (!createFnStmt)
|
|
123
|
+
return { ...base, parseError: 'not a CREATE FUNCTION statement' };
|
|
124
|
+
const body = functionBody(createFnStmt);
|
|
125
|
+
const bodyOffset = body !== null ? text.indexOf(body) : -1;
|
|
126
|
+
const bodyStartLine = bodyOffset >= 0 ? lineOf(text, bodyOffset) : undefined;
|
|
127
|
+
const fragments = [];
|
|
128
|
+
const dynamicSql = [];
|
|
129
|
+
const lang = language.toLowerCase();
|
|
130
|
+
if (lang === 'sql' && body !== null && bodyStartLine !== undefined) {
|
|
131
|
+
// A SQL-language body is itself SQL: parse it whole. Locations are
|
|
132
|
+
// relative to the body, so re-anchor them onto the definition.
|
|
133
|
+
try {
|
|
134
|
+
const ast = await (0, pgsql_parser_1.parse)(body);
|
|
135
|
+
fragments.push({
|
|
136
|
+
ast,
|
|
137
|
+
lineForOffset: (offset) => bodyStartLine + newlinesBefore(body, offset)
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Body may contain positional parameters etc. that don't parse alone —
|
|
142
|
+
// leave it as no fragment rather than erroring the whole unit.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else if (lang === 'plpgsql') {
|
|
146
|
+
let plpgsql;
|
|
147
|
+
try {
|
|
148
|
+
plpgsql = await (0, libpg_query_1.parsePlPgSQL)(text);
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
return { ...base, createFnStmt, bodyStartLine, parseError: `PL/pgSQL body failed to parse: ${err.message}` };
|
|
152
|
+
}
|
|
153
|
+
const exprs = [];
|
|
154
|
+
const dyn = [];
|
|
155
|
+
collectPlpgsql(plpgsql, 0, exprs, dyn);
|
|
156
|
+
const anchor = (bodyLine) => bodyStartLine !== undefined && bodyLine > 0 ? bodyStartLine + (bodyLine - 1) : (bodyStartLine ?? 1);
|
|
157
|
+
for (const d of dyn)
|
|
158
|
+
dynamicSql.push({ line: anchor(d.line), form: d.form });
|
|
159
|
+
for (const e of exprs) {
|
|
160
|
+
const sql = fragmentSql(e.query, e.parseMode);
|
|
161
|
+
let ast;
|
|
162
|
+
try {
|
|
163
|
+
ast = await (0, pgsql_parser_1.parse)(sql);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
continue; // opaque fragment — not the linter's concern
|
|
167
|
+
}
|
|
168
|
+
const absLine = anchor(e.line);
|
|
169
|
+
fragments.push({ ast, lineForOffset: () => absLine });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { ...base, createFnStmt, bodyStartLine, fragments, dynamicSql };
|
|
173
|
+
}
|
package/rules/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LintRule } from '../types';
|
|
2
|
+
import { noDynamicSql } from './no-dynamic-sql';
|
|
3
|
+
import { noSetSearchPath } from './no-set-search-path';
|
|
4
|
+
import { noVariableConflict } from './no-variable-conflict';
|
|
5
|
+
import { requireQualifiedRefs } from './require-qualified-refs';
|
|
6
|
+
/** The lint rules, in report order. */
|
|
7
|
+
export declare const LINT_RULES: LintRule[];
|
|
8
|
+
export declare const LINT_RULES_BY_ID: Map<string, LintRule>;
|
|
9
|
+
export declare const LINT_RULES_BY_CODE: Map<string, LintRule>;
|
|
10
|
+
export { noDynamicSql, noSetSearchPath, noVariableConflict, requireQualifiedRefs };
|
package/rules/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.requireQualifiedRefs = exports.noVariableConflict = exports.noSetSearchPath = exports.noDynamicSql = exports.LINT_RULES_BY_CODE = exports.LINT_RULES_BY_ID = exports.LINT_RULES = void 0;
|
|
4
|
+
const no_dynamic_sql_1 = require("./no-dynamic-sql");
|
|
5
|
+
Object.defineProperty(exports, "noDynamicSql", { enumerable: true, get: function () { return no_dynamic_sql_1.noDynamicSql; } });
|
|
6
|
+
const no_set_search_path_1 = require("./no-set-search-path");
|
|
7
|
+
Object.defineProperty(exports, "noSetSearchPath", { enumerable: true, get: function () { return no_set_search_path_1.noSetSearchPath; } });
|
|
8
|
+
const no_variable_conflict_1 = require("./no-variable-conflict");
|
|
9
|
+
Object.defineProperty(exports, "noVariableConflict", { enumerable: true, get: function () { return no_variable_conflict_1.noVariableConflict; } });
|
|
10
|
+
const require_qualified_refs_1 = require("./require-qualified-refs");
|
|
11
|
+
Object.defineProperty(exports, "requireQualifiedRefs", { enumerable: true, get: function () { return require_qualified_refs_1.requireQualifiedRefs; } });
|
|
12
|
+
/** The lint rules, in report order. */
|
|
13
|
+
exports.LINT_RULES = [
|
|
14
|
+
no_set_search_path_1.noSetSearchPath,
|
|
15
|
+
require_qualified_refs_1.requireQualifiedRefs,
|
|
16
|
+
no_variable_conflict_1.noVariableConflict,
|
|
17
|
+
no_dynamic_sql_1.noDynamicSql
|
|
18
|
+
];
|
|
19
|
+
exports.LINT_RULES_BY_ID = new Map(exports.LINT_RULES.map((r) => [r.id, r]));
|
|
20
|
+
exports.LINT_RULES_BY_CODE = new Map(exports.LINT_RULES.map((r) => [r.code, r]));
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
import type { LintRule } from '../types';
|
|
13
|
+
export declare const noDynamicSql: LintRule;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `no-dynamic-sql` (C4): a function must not use dynamic SQL.
|
|
4
|
+
*
|
|
5
|
+
* Dynamic SQL (`EXECUTE`, `EXECUTE … USING`, `FOR … IN EXECUTE`) is permitted
|
|
6
|
+
* only for lookup-only or code-generation work, and never for writes — but the
|
|
7
|
+
* string handed to `EXECUTE` is opaque to the parser, so we cannot statically
|
|
8
|
+
* tell read from write. The enforceable form is therefore: flag every site,
|
|
9
|
+
* and require a categorized waiver. This is the one rule whose suppression
|
|
10
|
+
* must carry a reason (see `reasonRequired`), so an approved use always names
|
|
11
|
+
* *why* (`lookup-only` / `codegen`).
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.noDynamicSql = void 0;
|
|
15
|
+
exports.noDynamicSql = {
|
|
16
|
+
id: 'no-dynamic-sql',
|
|
17
|
+
code: 'C4',
|
|
18
|
+
title: 'Function must not use dynamic SQL',
|
|
19
|
+
reasonRequired: true,
|
|
20
|
+
run(unit) {
|
|
21
|
+
return unit.dynamicSql.map((site) => ({
|
|
22
|
+
ruleId: 'no-dynamic-sql',
|
|
23
|
+
line: site.line,
|
|
24
|
+
message: `Function uses dynamic SQL (${site.form})`,
|
|
25
|
+
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>`.',
|
|
26
|
+
context: { form: site.form }
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
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 type { LintRule } from '../types';
|
|
12
|
+
export declare const noSetSearchPath: LintRule;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `no-set-search-path` (C1): a function must never set `search_path`.
|
|
4
|
+
*
|
|
5
|
+
* House rule: rather than pin `search_path` (the usual CWE-426 mitigation for
|
|
6
|
+
* SECURITY DEFINER), we fully-qualify every reference and never touch the
|
|
7
|
+
* setting at all. This flags both forms:
|
|
8
|
+
* - the declarative `CREATE FUNCTION … SET search_path = …` clause (this is
|
|
9
|
+
* exactly what `pg_proc.proconfig` / `searchPathPinned` records), and
|
|
10
|
+
* - a runtime `set_config('search_path', …)` in the body.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.noSetSearchPath = void 0;
|
|
14
|
+
const util_1 = require("../util");
|
|
15
|
+
function optionSites(unit) {
|
|
16
|
+
const out = [];
|
|
17
|
+
const options = unit.createFnStmt?.options;
|
|
18
|
+
if (!Array.isArray(options))
|
|
19
|
+
return out;
|
|
20
|
+
for (const opt of options) {
|
|
21
|
+
const de = opt.DefElem;
|
|
22
|
+
if (!de || de.defname !== 'set')
|
|
23
|
+
continue;
|
|
24
|
+
const vss = de.arg?.VariableSetStmt;
|
|
25
|
+
if (!vss || vss.name !== 'search_path')
|
|
26
|
+
continue;
|
|
27
|
+
const loc = typeof de.location === 'number' ? de.location : 0;
|
|
28
|
+
out.push({
|
|
29
|
+
ruleId: 'no-set-search-path',
|
|
30
|
+
line: (0, util_1.lineOfOffset)(unit.text, loc),
|
|
31
|
+
message: 'Function sets search_path',
|
|
32
|
+
hint: 'Never set search_path. Fully-qualify every relation, function and type reference instead.',
|
|
33
|
+
context: { form: 'SET clause' }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function setConfigSites(unit) {
|
|
39
|
+
const out = [];
|
|
40
|
+
const re = /\bset_config\s*\(\s*'search_path'/i;
|
|
41
|
+
unit.lines.forEach((text, i) => {
|
|
42
|
+
if (re.test(text)) {
|
|
43
|
+
out.push({
|
|
44
|
+
ruleId: 'no-set-search-path',
|
|
45
|
+
line: i + 1,
|
|
46
|
+
message: 'Function sets search_path via set_config()',
|
|
47
|
+
hint: 'Never set search_path. Fully-qualify references instead of relying on it.',
|
|
48
|
+
context: { form: 'set_config()' }
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
exports.noSetSearchPath = {
|
|
55
|
+
id: 'no-set-search-path',
|
|
56
|
+
code: 'C1',
|
|
57
|
+
title: 'Function must not set search_path',
|
|
58
|
+
reasonRequired: false,
|
|
59
|
+
run(unit) {
|
|
60
|
+
const byLine = new Map();
|
|
61
|
+
for (const p of [...optionSites(unit), ...setConfigSites(unit)]) {
|
|
62
|
+
if (!byLine.has(p.line))
|
|
63
|
+
byLine.set(p.line, p);
|
|
64
|
+
}
|
|
65
|
+
return [...byLine.values()].sort((a, b) => a.line - b.line);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
import type { LintRule } from '../types';
|
|
12
|
+
export declare const noVariableConflict: LintRule;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `no-variable-conflict` (C2): a PL/pgSQL body must not use a
|
|
4
|
+
* `#variable_conflict` directive.
|
|
5
|
+
*
|
|
6
|
+
* The directive papers over an ambiguity between a column name and a PL/pgSQL
|
|
7
|
+
* variable; the house style is to remove the ambiguity (rename the variable,
|
|
8
|
+
* qualify the column) rather than declare a winner. The directive is a
|
|
9
|
+
* compiler pragma that must start a line at the top of the body, so a line
|
|
10
|
+
* scan is exact — it never appears inside an expression or string.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.noVariableConflict = void 0;
|
|
14
|
+
const RE = /^\s*#variable_conflict\b\s*(\S+)?/i;
|
|
15
|
+
exports.noVariableConflict = {
|
|
16
|
+
id: 'no-variable-conflict',
|
|
17
|
+
code: 'C2',
|
|
18
|
+
title: 'Function must not use #variable_conflict',
|
|
19
|
+
reasonRequired: false,
|
|
20
|
+
run(unit) {
|
|
21
|
+
if (unit.language.toLowerCase() !== 'plpgsql')
|
|
22
|
+
return [];
|
|
23
|
+
const out = [];
|
|
24
|
+
for (let i = 0; i < unit.lines.length; i++) {
|
|
25
|
+
const m = RE.exec(unit.lines[i]);
|
|
26
|
+
if (!m)
|
|
27
|
+
continue;
|
|
28
|
+
const mode = m[1] ?? '';
|
|
29
|
+
out.push({
|
|
30
|
+
ruleId: 'no-variable-conflict',
|
|
31
|
+
line: i + 1,
|
|
32
|
+
message: `Function uses #variable_conflict${mode ? ` ${mode}` : ''}`,
|
|
33
|
+
hint: 'Remove the directive and disambiguate explicitly: rename the variable or qualify the column reference.',
|
|
34
|
+
context: { mode }
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
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 type { LintRule } from '../types';
|
|
16
|
+
export declare const requireQualifiedRefs: LintRule;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `require-qualified-refs` (C3): every relation reference must be
|
|
4
|
+
* schema-qualified.
|
|
5
|
+
*
|
|
6
|
+
* Banning `SET search_path` (C1) only removes the footgun; it does not make
|
|
7
|
+
* name resolution safe on its own. This is the rule that actually enforces the
|
|
8
|
+
* discipline: an unqualified `FROM users` resolves against whatever
|
|
9
|
+
* search_path happens to be, so it must be `FROM app_public.users`.
|
|
10
|
+
*
|
|
11
|
+
* v1 covers relation references (`RangeVar`). Names introduced by a CTE in the
|
|
12
|
+
* same query are excluded — they are not schema objects. Unqualified *function*
|
|
13
|
+
* calls are deferred (they need a built-in allowlist to avoid flagging
|
|
14
|
+
* `now()`, `count()`, …).
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.requireQualifiedRefs = void 0;
|
|
18
|
+
const walk_1 = require("../walk");
|
|
19
|
+
function cteNames(ast) {
|
|
20
|
+
const out = new Set();
|
|
21
|
+
for (const cte of (0, walk_1.findAll)(ast, 'CommonTableExpr')) {
|
|
22
|
+
if (typeof cte.ctename === 'string')
|
|
23
|
+
out.add(cte.ctename);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
function fragmentProblems(fragment) {
|
|
28
|
+
const out = [];
|
|
29
|
+
const ctes = cteNames(fragment.ast);
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
for (const rv of (0, walk_1.findAll)(fragment.ast, 'RangeVar')) {
|
|
32
|
+
const relname = typeof rv.relname === 'string' ? rv.relname : undefined;
|
|
33
|
+
if (!relname)
|
|
34
|
+
continue;
|
|
35
|
+
if (typeof rv.schemaname === 'string' && rv.schemaname.length > 0)
|
|
36
|
+
continue;
|
|
37
|
+
if (ctes.has(relname))
|
|
38
|
+
continue;
|
|
39
|
+
const loc = typeof rv.location === 'number' ? rv.location : -1;
|
|
40
|
+
const line = fragment.lineForOffset(loc >= 0 ? loc : 0);
|
|
41
|
+
const key = `${line}:${relname}`;
|
|
42
|
+
if (seen.has(key))
|
|
43
|
+
continue;
|
|
44
|
+
seen.add(key);
|
|
45
|
+
out.push({
|
|
46
|
+
ruleId: 'require-qualified-refs',
|
|
47
|
+
line,
|
|
48
|
+
message: `Unqualified relation reference "${relname}"`,
|
|
49
|
+
hint: 'Schema-qualify the reference (e.g. `app_public.' + relname + '`). Unqualified names resolve against search_path.',
|
|
50
|
+
context: { relation: relname }
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
exports.requireQualifiedRefs = {
|
|
56
|
+
id: 'require-qualified-refs',
|
|
57
|
+
code: 'C3',
|
|
58
|
+
title: 'Relation references must be schema-qualified',
|
|
59
|
+
reasonRequired: false,
|
|
60
|
+
run(unit) {
|
|
61
|
+
const byKey = new Map();
|
|
62
|
+
for (const fragment of unit.fragments) {
|
|
63
|
+
for (const p of fragmentProblems(fragment)) {
|
|
64
|
+
const key = `${p.line}:${p.context.relation}`;
|
|
65
|
+
if (!byKey.has(key))
|
|
66
|
+
byKey.set(key, p);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...byKey.values()].sort((a, b) => a.line - b.line);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint / Prettier-style suppression comments, embedded in the SQL body as
|
|
3
|
+
* `--` line comments. Because they live in the function source, a waiver
|
|
4
|
+
* authored in a migration survives `pg_get_functiondef` and is visible to any
|
|
5
|
+
* downstream consumer (a live-database audit, a pre-commit hook) — the comment
|
|
6
|
+
* is the single source of truth.
|
|
7
|
+
*
|
|
8
|
+
* Grammar (a `--` comment containing), with `<kw>` the directive keyword
|
|
9
|
+
* (`pgsql-lint` by default; `safegres` is also accepted):
|
|
10
|
+
*
|
|
11
|
+
* <kw>-disable-next-line [<rule>…] [-- <reason>] next physical line
|
|
12
|
+
* <kw>-disable-line [<rule>…] [-- <reason>] this physical line
|
|
13
|
+
* <kw>-disable [<rule>…] [-- <reason>] until a matching enable
|
|
14
|
+
* <kw>-enable [<rule>…] closes a disable range
|
|
15
|
+
* <kw>-disable-file [<rule>…] [-- <reason>] the whole definition
|
|
16
|
+
*
|
|
17
|
+
* With no rule listed a directive applies to every rule. A reason follows a
|
|
18
|
+
* second `--` (ESLint style) or a `:`. Rules whose metadata requires a reason
|
|
19
|
+
* are *not* silenced by a reasonless directive — the finding stands, so a
|
|
20
|
+
* waiver is never silent.
|
|
21
|
+
*/
|
|
22
|
+
import type { SuppressionScope } from './types';
|
|
23
|
+
/** The directive keyword(s) recognised when none is configured explicitly. */
|
|
24
|
+
export declare const DEFAULT_KEYWORDS: string[];
|
|
25
|
+
export interface SuppressionMatch {
|
|
26
|
+
scope: SuppressionScope;
|
|
27
|
+
reason: string | null;
|
|
28
|
+
}
|
|
29
|
+
export interface SuppressionResolution {
|
|
30
|
+
/** The directive silences the finding. */
|
|
31
|
+
suppressed: boolean;
|
|
32
|
+
scope?: SuppressionScope;
|
|
33
|
+
reason?: string | null;
|
|
34
|
+
/** A directive matched but lacked a required reason, so it does not apply. */
|
|
35
|
+
invalidMissingReason?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/** Parsed suppression state for one definition, queryable by (rule, line). */
|
|
38
|
+
export declare class Suppressions {
|
|
39
|
+
private readonly lineDirectives;
|
|
40
|
+
private readonly intervals;
|
|
41
|
+
private readonly fileDirectives;
|
|
42
|
+
constructor(lines: string[], keywords?: string[]);
|
|
43
|
+
private match;
|
|
44
|
+
/**
|
|
45
|
+
* Resolve whether a finding is suppressed. `reasonRequired` rules are only
|
|
46
|
+
* silenced by a directive that carries a reason.
|
|
47
|
+
*/
|
|
48
|
+
resolve(ruleId: string, line: number, reasonRequired: boolean): SuppressionResolution;
|
|
49
|
+
}
|