@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/suppressions.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ESLint / Prettier-style suppression comments, embedded in the SQL body as
|
|
4
|
+
* `--` line comments. Because they live in the function source, a waiver
|
|
5
|
+
* authored in a migration survives `pg_get_functiondef` and is visible to any
|
|
6
|
+
* downstream consumer (a live-database audit, a pre-commit hook) — the comment
|
|
7
|
+
* is the single source of truth.
|
|
8
|
+
*
|
|
9
|
+
* Grammar (a `--` comment containing), with `<kw>` the directive keyword
|
|
10
|
+
* (`pgsql-lint` by default; `safegres` is also accepted):
|
|
11
|
+
*
|
|
12
|
+
* <kw>-disable-next-line [<rule>…] [-- <reason>] next physical line
|
|
13
|
+
* <kw>-disable-line [<rule>…] [-- <reason>] this physical line
|
|
14
|
+
* <kw>-disable [<rule>…] [-- <reason>] until a matching enable
|
|
15
|
+
* <kw>-enable [<rule>…] closes a disable range
|
|
16
|
+
* <kw>-disable-file [<rule>…] [-- <reason>] the whole definition
|
|
17
|
+
*
|
|
18
|
+
* With no rule listed a directive applies to every rule. A reason follows a
|
|
19
|
+
* second `--` (ESLint style) or a `:`. Rules whose metadata requires a reason
|
|
20
|
+
* are *not* silenced by a reasonless directive — the finding stands, so a
|
|
21
|
+
* waiver is never silent.
|
|
22
|
+
*/
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.Suppressions = exports.DEFAULT_KEYWORDS = void 0;
|
|
25
|
+
/** The directive keyword(s) recognised when none is configured explicitly. */
|
|
26
|
+
exports.DEFAULT_KEYWORDS = ['pgsql-lint', 'safegres'];
|
|
27
|
+
function escapeRegExp(s) {
|
|
28
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
29
|
+
}
|
|
30
|
+
function directiveRe(keywords) {
|
|
31
|
+
const kw = keywords.map(escapeRegExp).join('|');
|
|
32
|
+
return new RegExp(`(?:${kw})-(disable-next-line|disable-line|disable-file|disable|enable)\\b[ \\t]*([^\\r\\n]*)`, 'i');
|
|
33
|
+
}
|
|
34
|
+
function splitReason(rest) {
|
|
35
|
+
const dashIdx = rest.indexOf('--');
|
|
36
|
+
if (dashIdx >= 0) {
|
|
37
|
+
return { ruleSpec: rest.slice(0, dashIdx), reason: normalizeReason(rest.slice(dashIdx + 2)) };
|
|
38
|
+
}
|
|
39
|
+
const colonIdx = rest.indexOf(':');
|
|
40
|
+
if (colonIdx >= 0) {
|
|
41
|
+
return { ruleSpec: rest.slice(0, colonIdx), reason: normalizeReason(rest.slice(colonIdx + 1)) };
|
|
42
|
+
}
|
|
43
|
+
return { ruleSpec: rest, reason: null };
|
|
44
|
+
}
|
|
45
|
+
function normalizeReason(s) {
|
|
46
|
+
const t = s.trim().replace(/\*\/\s*$/, '').trim();
|
|
47
|
+
return t.length > 0 ? t : null;
|
|
48
|
+
}
|
|
49
|
+
function parseRules(ruleSpec) {
|
|
50
|
+
const parts = ruleSpec.split(/[\s,]+/).map((p) => p.trim()).filter((p) => p.length > 0);
|
|
51
|
+
return parts.length > 0 ? new Set(parts) : null;
|
|
52
|
+
}
|
|
53
|
+
/** Parsed suppression state for one definition, queryable by (rule, line). */
|
|
54
|
+
class Suppressions {
|
|
55
|
+
lineDirectives = [];
|
|
56
|
+
intervals = [];
|
|
57
|
+
fileDirectives = [];
|
|
58
|
+
constructor(lines, keywords = exports.DEFAULT_KEYWORDS) {
|
|
59
|
+
const re = directiveRe(keywords);
|
|
60
|
+
// Range bookkeeping: an open disable per rule (and one for "all").
|
|
61
|
+
const open = new Map();
|
|
62
|
+
lines.forEach((text, i) => {
|
|
63
|
+
const line = i + 1;
|
|
64
|
+
const m = re.exec(text);
|
|
65
|
+
if (!m)
|
|
66
|
+
return;
|
|
67
|
+
const kind = m[1].toLowerCase();
|
|
68
|
+
const { ruleSpec, reason } = splitReason(m[2] ?? '');
|
|
69
|
+
const rules = parseRules(ruleSpec);
|
|
70
|
+
switch (kind) {
|
|
71
|
+
case 'disable-next-line':
|
|
72
|
+
this.lineDirectives.push({ scope: 'next-line', targetLine: line + 1, rules, reason });
|
|
73
|
+
break;
|
|
74
|
+
case 'disable-line':
|
|
75
|
+
this.lineDirectives.push({ scope: 'line', targetLine: line, rules, reason });
|
|
76
|
+
break;
|
|
77
|
+
case 'disable-file':
|
|
78
|
+
this.fileDirectives.push({ rules, reason });
|
|
79
|
+
break;
|
|
80
|
+
case 'disable': {
|
|
81
|
+
const keys = rules ? [...rules] : [null];
|
|
82
|
+
for (const k of keys)
|
|
83
|
+
if (!open.has(k))
|
|
84
|
+
open.set(k, { start: line, reason });
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case 'enable': {
|
|
88
|
+
const keys = rules ? [...rules] : [...open.keys()];
|
|
89
|
+
for (const k of keys) {
|
|
90
|
+
const o = open.get(k);
|
|
91
|
+
if (o) {
|
|
92
|
+
this.intervals.push({ rule: k, start: o.start, end: line, reason: o.reason });
|
|
93
|
+
open.delete(k);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
for (const [rule, o] of open) {
|
|
101
|
+
this.intervals.push({ rule, start: o.start, end: Number.POSITIVE_INFINITY, reason: o.reason });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
match(ruleId, line) {
|
|
105
|
+
for (const f of this.fileDirectives) {
|
|
106
|
+
if (f.rules === null || f.rules.has(ruleId))
|
|
107
|
+
return { scope: 'file', reason: f.reason };
|
|
108
|
+
}
|
|
109
|
+
for (const d of this.lineDirectives) {
|
|
110
|
+
if (d.targetLine === line && (d.rules === null || d.rules.has(ruleId))) {
|
|
111
|
+
return { scope: d.scope, reason: d.reason };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const iv of this.intervals) {
|
|
115
|
+
if ((iv.rule === null || iv.rule === ruleId) && line >= iv.start && line < iv.end) {
|
|
116
|
+
return { scope: 'range', reason: iv.reason };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Resolve whether a finding is suppressed. `reasonRequired` rules are only
|
|
123
|
+
* silenced by a directive that carries a reason.
|
|
124
|
+
*/
|
|
125
|
+
resolve(ruleId, line, reasonRequired) {
|
|
126
|
+
const m = this.match(ruleId, line);
|
|
127
|
+
if (!m)
|
|
128
|
+
return { suppressed: false };
|
|
129
|
+
if (reasonRequired && (m.reason === null || m.reason.length === 0)) {
|
|
130
|
+
return { suppressed: false, invalidMissingReason: true, scope: m.scope };
|
|
131
|
+
}
|
|
132
|
+
return { suppressed: true, scope: m.scope, reason: m.reason };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
exports.Suppressions = Suppressions;
|
package/types.d.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-level SQL/PL/pgSQL linter — types.
|
|
3
|
+
*
|
|
4
|
+
* This package is deliberately free of any `pg` / catalog dependency: it takes
|
|
5
|
+
* a function *definition* (the `CREATE FUNCTION …` text, as `pg_get_functiondef`
|
|
6
|
+
* returns it, or as authored in a migration) and returns findings. Everything
|
|
7
|
+
* here is decided from source text and its AST, so the linter runs the same in
|
|
8
|
+
* a migration, an editor, a pre-commit hook, or a live-database auditor.
|
|
9
|
+
*/
|
|
10
|
+
/** A parsed function definition, in the coordinate space the linter reports in. */
|
|
11
|
+
export interface LintUnit {
|
|
12
|
+
/** The full definition text — the line/column space every finding refers to. */
|
|
13
|
+
text: string;
|
|
14
|
+
/** `text` split on `\n`, 1-based when indexed as `lines[line - 1]`. */
|
|
15
|
+
lines: string[];
|
|
16
|
+
/** `sql`, `plpgsql`, `c`, `internal`, … (lower-cased `pg_proc.prolang`). */
|
|
17
|
+
language: string;
|
|
18
|
+
/** Display name for messages, e.g. `app.grant_role(text)`. */
|
|
19
|
+
name?: string;
|
|
20
|
+
/** The `CreateFunctionStmt` AST node, when the text parsed as one. */
|
|
21
|
+
createFnStmt?: Record<string, unknown>;
|
|
22
|
+
/** Absolute line (1-based, within `text`) the function body's first char sits on. */
|
|
23
|
+
bodyStartLine?: number;
|
|
24
|
+
/** Embedded SQL fragments (body statements / expressions) with a line mapper. */
|
|
25
|
+
fragments: SqlFragment[];
|
|
26
|
+
/** Dynamic-SQL statements found in a PL/pgSQL body, by absolute line. */
|
|
27
|
+
dynamicSql: DynamicSqlSite[];
|
|
28
|
+
/** True when the definition (or its body) could not be parsed. */
|
|
29
|
+
parseError?: string;
|
|
30
|
+
}
|
|
31
|
+
/** One embedded SQL statement/expression, with a char-offset → absolute-line mapper. */
|
|
32
|
+
export interface SqlFragment {
|
|
33
|
+
/** Parsed SQL AST for this fragment. */
|
|
34
|
+
ast: unknown;
|
|
35
|
+
/** Map a char offset within this fragment's source to an absolute line in `text`. */
|
|
36
|
+
lineForOffset: (offset: number) => number;
|
|
37
|
+
}
|
|
38
|
+
export interface DynamicSqlSite {
|
|
39
|
+
line: number;
|
|
40
|
+
/** `EXECUTE`, `EXECUTE … USING`, or `FOR … IN EXECUTE`. */
|
|
41
|
+
form: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* How loud a rule is, per project. Severity is *configuration*, not a property
|
|
45
|
+
* of the rule (same model as ESLint): a rule declares only its identity, and
|
|
46
|
+
* the linter config decides `off` / `warn` / `error` per rule id or code.
|
|
47
|
+
* - `off` — the rule is not run.
|
|
48
|
+
* - `warn` — reported, but does not fail the run.
|
|
49
|
+
* - `error` — reported and fails the run (drives a non-zero exit code).
|
|
50
|
+
*/
|
|
51
|
+
export type Severity = 'off' | 'warn' | 'error';
|
|
52
|
+
/** A severity per rule, keyed by rule id (`no-dynamic-sql`) or code (`C4`). */
|
|
53
|
+
export type SeverityMap = Record<string, Severity>;
|
|
54
|
+
/** A single lint finding, before suppressions are applied. */
|
|
55
|
+
export interface LintProblem {
|
|
56
|
+
ruleId: string;
|
|
57
|
+
/** Absolute line (1-based) within the definition. */
|
|
58
|
+
line: number;
|
|
59
|
+
message: string;
|
|
60
|
+
hint?: string;
|
|
61
|
+
/** Configured severity for the rule (`error` by default). */
|
|
62
|
+
severity?: Severity;
|
|
63
|
+
context?: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
/** A problem that a suppression comment silenced — reported, never dropped. */
|
|
66
|
+
export interface SuppressedProblem extends LintProblem {
|
|
67
|
+
/** The reason text from the directive, or null when none was given. */
|
|
68
|
+
reason: string | null;
|
|
69
|
+
scope: SuppressionScope;
|
|
70
|
+
}
|
|
71
|
+
export type SuppressionScope = 'next-line' | 'line' | 'range' | 'file';
|
|
72
|
+
/** The result of linting one definition. */
|
|
73
|
+
export interface LintResult {
|
|
74
|
+
/** Active findings — not suppressed. */
|
|
75
|
+
problems: LintProblem[];
|
|
76
|
+
/** Suppressed findings, kept for the "accepted risk" report bucket. */
|
|
77
|
+
suppressed: SuppressedProblem[];
|
|
78
|
+
}
|
|
79
|
+
/** Static metadata for a lint rule. */
|
|
80
|
+
export interface LintRuleMeta {
|
|
81
|
+
/** ESLint-style stable id, e.g. `no-dynamic-sql`. */
|
|
82
|
+
id: string;
|
|
83
|
+
/** Registry code this rule maps to, e.g. `C4` (the `safegres:constructive` code). */
|
|
84
|
+
code: string;
|
|
85
|
+
title: string;
|
|
86
|
+
/**
|
|
87
|
+
* Whether a suppression of this rule must carry a reason. When true, a bare
|
|
88
|
+
* disable directive does not suppress — the finding stands — so a waiver is
|
|
89
|
+
* never silent. Only `no-dynamic-sql` requires it: it is the one rule we
|
|
90
|
+
* expect to be waived (lookup-only / codegen), and the waiver's whole value
|
|
91
|
+
* is the documented reason.
|
|
92
|
+
*/
|
|
93
|
+
reasonRequired: boolean;
|
|
94
|
+
}
|
|
95
|
+
/** A lint rule: pure `unit → problems`. */
|
|
96
|
+
export interface LintRule extends LintRuleMeta {
|
|
97
|
+
run: (unit: LintUnit) => LintProblem[];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Authoring helper for third-party rules. A no-op at runtime (identity), it
|
|
101
|
+
* exists only to pin the `LintRule` shape at the call site so a rule package
|
|
102
|
+
* gets full type-checking without importing internals.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* export const noWritesInView = defineRule({
|
|
106
|
+
* id: 'no-writes-in-view', code: 'X1', title: '…', reasonRequired: false,
|
|
107
|
+
* run(unit) { return []; }
|
|
108
|
+
* });
|
|
109
|
+
*/
|
|
110
|
+
export declare function defineRule(rule: LintRule): LintRule;
|
|
111
|
+
/**
|
|
112
|
+
* A definition to lint, as produced by a {@link SourceAdapter}. This is the
|
|
113
|
+
* unit of work the engine consumes regardless of where it came from — a file,
|
|
114
|
+
* a live catalog (`pg_get_functiondef`), a git diff, an editor buffer.
|
|
115
|
+
*/
|
|
116
|
+
export interface LintDefinitionInput {
|
|
117
|
+
/** The full `CREATE FUNCTION …` text. */
|
|
118
|
+
text: string;
|
|
119
|
+
/** `sql`, `plpgsql`, … (lower-cased). */
|
|
120
|
+
language: string;
|
|
121
|
+
/** Display name for messages, e.g. `app.grant_role`. */
|
|
122
|
+
name?: string;
|
|
123
|
+
/** Origin file, when the definition came from disk. */
|
|
124
|
+
file?: string;
|
|
125
|
+
/**
|
|
126
|
+
* Absolute 0-based line the definition begins on within `file`. Findings
|
|
127
|
+
* are re-anchored by this so they point at the real file location.
|
|
128
|
+
*/
|
|
129
|
+
startLine?: number;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* A source of definitions to lint. Adapters are a *different* seam from rules:
|
|
133
|
+
* a rule is pure `unit → problems`, while an adapter decides *where* the
|
|
134
|
+
* definitions come from. `@pgsql/lint` ships file/source adapters; safegres is
|
|
135
|
+
* "the catalog adapter" over the same engine.
|
|
136
|
+
*/
|
|
137
|
+
export interface SourceAdapter {
|
|
138
|
+
/** A short id for diagnostics, e.g. `files`, `sql-text`, `catalog`. */
|
|
139
|
+
id: string;
|
|
140
|
+
/** Yield every definition this adapter can see. */
|
|
141
|
+
definitions: () => Promise<LintDefinitionInput[]> | LintDefinitionInput[];
|
|
142
|
+
}
|
package/types.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Source-level SQL/PL/pgSQL linter — types.
|
|
4
|
+
*
|
|
5
|
+
* This package is deliberately free of any `pg` / catalog dependency: it takes
|
|
6
|
+
* a function *definition* (the `CREATE FUNCTION …` text, as `pg_get_functiondef`
|
|
7
|
+
* returns it, or as authored in a migration) and returns findings. Everything
|
|
8
|
+
* here is decided from source text and its AST, so the linter runs the same in
|
|
9
|
+
* a migration, an editor, a pre-commit hook, or a live-database auditor.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.defineRule = defineRule;
|
|
13
|
+
/**
|
|
14
|
+
* Authoring helper for third-party rules. A no-op at runtime (identity), it
|
|
15
|
+
* exists only to pin the `LintRule` shape at the call site so a rule package
|
|
16
|
+
* gets full type-checking without importing internals.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* export const noWritesInView = defineRule({
|
|
20
|
+
* id: 'no-writes-in-view', code: 'X1', title: '…', reasonRequired: false,
|
|
21
|
+
* run(unit) { return []; }
|
|
22
|
+
* });
|
|
23
|
+
*/
|
|
24
|
+
function defineRule(rule) {
|
|
25
|
+
return rule;
|
|
26
|
+
}
|
package/util.d.ts
ADDED
package/util.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.lineOfOffset = lineOfOffset;
|
|
4
|
+
/** Absolute 1-based line of a char offset within `text`. */
|
|
5
|
+
function lineOfOffset(text, offset) {
|
|
6
|
+
let n = 0;
|
|
7
|
+
const end = Math.min(Math.max(offset, 0), text.length);
|
|
8
|
+
for (let i = 0; i < end; i++)
|
|
9
|
+
if (text.charCodeAt(i) === 10)
|
|
10
|
+
n++;
|
|
11
|
+
return n + 1;
|
|
12
|
+
}
|
package/walk.d.ts
ADDED
package/walk.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The one AST helper the rules need, layered on `@pgsql/traverse`'s `walk`.
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.findAll = findAll;
|
|
7
|
+
const traverse_1 = require("@pgsql/traverse");
|
|
8
|
+
/** Find every node tagged with `tag` (e.g. `RangeVar`, `CommonTableExpr`). */
|
|
9
|
+
function findAll(root, tag) {
|
|
10
|
+
const out = [];
|
|
11
|
+
(0, traverse_1.walk)(root, (path) => {
|
|
12
|
+
if (path.tag === tag) {
|
|
13
|
+
out.push(path.node);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
return out;
|
|
17
|
+
}
|