@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,131 @@
|
|
|
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
|
+
/** The directive keyword(s) recognised when none is configured explicitly. */
|
|
23
|
+
export const DEFAULT_KEYWORDS = ['pgsql-lint', 'safegres'];
|
|
24
|
+
function escapeRegExp(s) {
|
|
25
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
26
|
+
}
|
|
27
|
+
function directiveRe(keywords) {
|
|
28
|
+
const kw = keywords.map(escapeRegExp).join('|');
|
|
29
|
+
return new RegExp(`(?:${kw})-(disable-next-line|disable-line|disable-file|disable|enable)\\b[ \\t]*([^\\r\\n]*)`, 'i');
|
|
30
|
+
}
|
|
31
|
+
function splitReason(rest) {
|
|
32
|
+
const dashIdx = rest.indexOf('--');
|
|
33
|
+
if (dashIdx >= 0) {
|
|
34
|
+
return { ruleSpec: rest.slice(0, dashIdx), reason: normalizeReason(rest.slice(dashIdx + 2)) };
|
|
35
|
+
}
|
|
36
|
+
const colonIdx = rest.indexOf(':');
|
|
37
|
+
if (colonIdx >= 0) {
|
|
38
|
+
return { ruleSpec: rest.slice(0, colonIdx), reason: normalizeReason(rest.slice(colonIdx + 1)) };
|
|
39
|
+
}
|
|
40
|
+
return { ruleSpec: rest, reason: null };
|
|
41
|
+
}
|
|
42
|
+
function normalizeReason(s) {
|
|
43
|
+
const t = s.trim().replace(/\*\/\s*$/, '').trim();
|
|
44
|
+
return t.length > 0 ? t : null;
|
|
45
|
+
}
|
|
46
|
+
function parseRules(ruleSpec) {
|
|
47
|
+
const parts = ruleSpec.split(/[\s,]+/).map((p) => p.trim()).filter((p) => p.length > 0);
|
|
48
|
+
return parts.length > 0 ? new Set(parts) : null;
|
|
49
|
+
}
|
|
50
|
+
/** Parsed suppression state for one definition, queryable by (rule, line). */
|
|
51
|
+
export class Suppressions {
|
|
52
|
+
lineDirectives = [];
|
|
53
|
+
intervals = [];
|
|
54
|
+
fileDirectives = [];
|
|
55
|
+
constructor(lines, keywords = DEFAULT_KEYWORDS) {
|
|
56
|
+
const re = directiveRe(keywords);
|
|
57
|
+
// Range bookkeeping: an open disable per rule (and one for "all").
|
|
58
|
+
const open = new Map();
|
|
59
|
+
lines.forEach((text, i) => {
|
|
60
|
+
const line = i + 1;
|
|
61
|
+
const m = re.exec(text);
|
|
62
|
+
if (!m)
|
|
63
|
+
return;
|
|
64
|
+
const kind = m[1].toLowerCase();
|
|
65
|
+
const { ruleSpec, reason } = splitReason(m[2] ?? '');
|
|
66
|
+
const rules = parseRules(ruleSpec);
|
|
67
|
+
switch (kind) {
|
|
68
|
+
case 'disable-next-line':
|
|
69
|
+
this.lineDirectives.push({ scope: 'next-line', targetLine: line + 1, rules, reason });
|
|
70
|
+
break;
|
|
71
|
+
case 'disable-line':
|
|
72
|
+
this.lineDirectives.push({ scope: 'line', targetLine: line, rules, reason });
|
|
73
|
+
break;
|
|
74
|
+
case 'disable-file':
|
|
75
|
+
this.fileDirectives.push({ rules, reason });
|
|
76
|
+
break;
|
|
77
|
+
case 'disable': {
|
|
78
|
+
const keys = rules ? [...rules] : [null];
|
|
79
|
+
for (const k of keys)
|
|
80
|
+
if (!open.has(k))
|
|
81
|
+
open.set(k, { start: line, reason });
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case 'enable': {
|
|
85
|
+
const keys = rules ? [...rules] : [...open.keys()];
|
|
86
|
+
for (const k of keys) {
|
|
87
|
+
const o = open.get(k);
|
|
88
|
+
if (o) {
|
|
89
|
+
this.intervals.push({ rule: k, start: o.start, end: line, reason: o.reason });
|
|
90
|
+
open.delete(k);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
for (const [rule, o] of open) {
|
|
98
|
+
this.intervals.push({ rule, start: o.start, end: Number.POSITIVE_INFINITY, reason: o.reason });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
match(ruleId, line) {
|
|
102
|
+
for (const f of this.fileDirectives) {
|
|
103
|
+
if (f.rules === null || f.rules.has(ruleId))
|
|
104
|
+
return { scope: 'file', reason: f.reason };
|
|
105
|
+
}
|
|
106
|
+
for (const d of this.lineDirectives) {
|
|
107
|
+
if (d.targetLine === line && (d.rules === null || d.rules.has(ruleId))) {
|
|
108
|
+
return { scope: d.scope, reason: d.reason };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const iv of this.intervals) {
|
|
112
|
+
if ((iv.rule === null || iv.rule === ruleId) && line >= iv.start && line < iv.end) {
|
|
113
|
+
return { scope: 'range', reason: iv.reason };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Resolve whether a finding is suppressed. `reasonRequired` rules are only
|
|
120
|
+
* silenced by a directive that carries a reason.
|
|
121
|
+
*/
|
|
122
|
+
resolve(ruleId, line, reasonRequired) {
|
|
123
|
+
const m = this.match(ruleId, line);
|
|
124
|
+
if (!m)
|
|
125
|
+
return { suppressed: false };
|
|
126
|
+
if (reasonRequired && (m.reason === null || m.reason.length === 0)) {
|
|
127
|
+
return { suppressed: false, invalidMissingReason: true, scope: m.scope };
|
|
128
|
+
}
|
|
129
|
+
return { suppressed: true, scope: m.scope, reason: m.reason };
|
|
130
|
+
}
|
|
131
|
+
}
|
package/esm/types.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
/**
|
|
11
|
+
* Authoring helper for third-party rules. A no-op at runtime (identity), it
|
|
12
|
+
* exists only to pin the `LintRule` shape at the call site so a rule package
|
|
13
|
+
* gets full type-checking without importing internals.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* export const noWritesInView = defineRule({
|
|
17
|
+
* id: 'no-writes-in-view', code: 'X1', title: '…', reasonRequired: false,
|
|
18
|
+
* run(unit) { return []; }
|
|
19
|
+
* });
|
|
20
|
+
*/
|
|
21
|
+
export function defineRule(rule) {
|
|
22
|
+
return rule;
|
|
23
|
+
}
|
package/esm/util.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Absolute 1-based line of a char offset within `text`. */
|
|
2
|
+
export function lineOfOffset(text, offset) {
|
|
3
|
+
let n = 0;
|
|
4
|
+
const end = Math.min(Math.max(offset, 0), text.length);
|
|
5
|
+
for (let i = 0; i < end; i++)
|
|
6
|
+
if (text.charCodeAt(i) === 10)
|
|
7
|
+
n++;
|
|
8
|
+
return n + 1;
|
|
9
|
+
}
|
package/esm/walk.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one AST helper the rules need, layered on `@pgsql/traverse`'s `walk`.
|
|
3
|
+
*/
|
|
4
|
+
import { walk } from '@pgsql/traverse';
|
|
5
|
+
/** Find every node tagged with `tag` (e.g. `RangeVar`, `CommonTableExpr`). */
|
|
6
|
+
export function findAll(root, tag) {
|
|
7
|
+
const out = [];
|
|
8
|
+
walk(root, (path) => {
|
|
9
|
+
if (path.tag === tag) {
|
|
10
|
+
out.push(path.node);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
return out;
|
|
14
|
+
}
|
package/file-runner.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
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 { LintOptions } from './engine';
|
|
13
|
+
import type { LintDefinitionInput, Severity, SourceAdapter, SuppressionScope } from './types';
|
|
14
|
+
/** A finding re-anchored to a source file. */
|
|
15
|
+
export interface FileFinding {
|
|
16
|
+
ruleId: string;
|
|
17
|
+
/** Registry code, e.g. `C4`. */
|
|
18
|
+
code: string;
|
|
19
|
+
/** 1-based line within the file. */
|
|
20
|
+
line: number;
|
|
21
|
+
message: string;
|
|
22
|
+
hint?: string;
|
|
23
|
+
/** Configured severity (`error` by default; `warn` does not fail a run). */
|
|
24
|
+
severity: Severity;
|
|
25
|
+
/** The function it was found in, e.g. `app.grant_role`. */
|
|
26
|
+
subject: string;
|
|
27
|
+
/** True when a suppression comment acknowledged it (accepted risk, kept visible). */
|
|
28
|
+
acknowledged: boolean;
|
|
29
|
+
/** Waiver reason, when acknowledged and one was given. */
|
|
30
|
+
reason?: string | null;
|
|
31
|
+
/** Suppression scope, when acknowledged. */
|
|
32
|
+
scope?: SuppressionScope;
|
|
33
|
+
context?: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
/** The result of linting one source file. */
|
|
36
|
+
export interface FileReport {
|
|
37
|
+
file: string;
|
|
38
|
+
findings: FileFinding[];
|
|
39
|
+
/** A parse error on the whole file (not a single definition). */
|
|
40
|
+
parseError?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Split a SQL source string into the function definitions it contains. Each
|
|
44
|
+
* `CREATE FUNCTION` is sliced back out with its absolute start line, so a
|
|
45
|
+
* mixed migration (schema + tables + functions) yields one input per function
|
|
46
|
+
* rather than being treated as a single malformed definition.
|
|
47
|
+
*/
|
|
48
|
+
export declare function sqlTextDefinitions(source: string, file?: string): Promise<LintDefinitionInput[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Lint a SQL source string that may contain many statements. Findings are
|
|
51
|
+
* re-anchored to absolute lines within `source`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function lintSqlText(source: string, options?: LintOptions): Promise<FileReport>;
|
|
54
|
+
/** Resolve a mix of file and directory paths to a sorted list of `.sql` files. */
|
|
55
|
+
export declare function resolveSqlFiles(paths: string[]): Promise<string[]>;
|
|
56
|
+
/** Lint every `.sql` file reachable from `paths` (files or directories). */
|
|
57
|
+
export declare function lintFiles(paths: string[], options?: LintOptions): Promise<FileReport[]>;
|
|
58
|
+
/** A {@link SourceAdapter} over an in-memory SQL string. */
|
|
59
|
+
export declare function sqlTextAdapter(source: string, file?: string): SourceAdapter;
|
|
60
|
+
/** A {@link SourceAdapter} over `.sql` files/directories on disk. */
|
|
61
|
+
export declare function filesAdapter(paths: string[]): SourceAdapter;
|
|
62
|
+
/**
|
|
63
|
+
* Lint every definition an adapter yields, grouped into one {@link FileReport}
|
|
64
|
+
* per origin file. This is the generic entry point: `@pgsql/lint` ships file
|
|
65
|
+
* and sql-text adapters; a consumer (e.g. safegres over a live catalog) can
|
|
66
|
+
* pass its own.
|
|
67
|
+
*/
|
|
68
|
+
export declare function lintSource(adapter: SourceAdapter, options?: LintOptions): Promise<FileReport[]>;
|
package/file-runner.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Run the linter over SQL *source files* — migrations, `CREATE FUNCTION`
|
|
4
|
+
* scripts, anything on disk. This is the "local" entry point (CLI, pre-commit,
|
|
5
|
+
* editor): it finds every function definition in a file, lints each one, and
|
|
6
|
+
* re-anchors the findings to absolute file lines.
|
|
7
|
+
*
|
|
8
|
+
* The library entry point ({@link lintDefinition}) takes a single definition
|
|
9
|
+
* as safegres feeds it from `pg_get_functiondef`; this layer adds the file
|
|
10
|
+
* plumbing on top so a human or an agent can point it at `.sql` and get the
|
|
11
|
+
* same findings without a database.
|
|
12
|
+
*/
|
|
13
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
16
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
17
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
18
|
+
}
|
|
19
|
+
Object.defineProperty(o, k2, desc);
|
|
20
|
+
}) : (function(o, m, k, k2) {
|
|
21
|
+
if (k2 === undefined) k2 = k;
|
|
22
|
+
o[k2] = m[k];
|
|
23
|
+
}));
|
|
24
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
25
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
26
|
+
}) : function(o, v) {
|
|
27
|
+
o["default"] = v;
|
|
28
|
+
});
|
|
29
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
30
|
+
var ownKeys = function(o) {
|
|
31
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
32
|
+
var ar = [];
|
|
33
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
34
|
+
return ar;
|
|
35
|
+
};
|
|
36
|
+
return ownKeys(o);
|
|
37
|
+
};
|
|
38
|
+
return function (mod) {
|
|
39
|
+
if (mod && mod.__esModule) return mod;
|
|
40
|
+
var result = {};
|
|
41
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
42
|
+
__setModuleDefault(result, mod);
|
|
43
|
+
return result;
|
|
44
|
+
};
|
|
45
|
+
})();
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
exports.sqlTextDefinitions = sqlTextDefinitions;
|
|
48
|
+
exports.lintSqlText = lintSqlText;
|
|
49
|
+
exports.resolveSqlFiles = resolveSqlFiles;
|
|
50
|
+
exports.lintFiles = lintFiles;
|
|
51
|
+
exports.sqlTextAdapter = sqlTextAdapter;
|
|
52
|
+
exports.filesAdapter = filesAdapter;
|
|
53
|
+
exports.lintSource = lintSource;
|
|
54
|
+
const fs_1 = require("fs");
|
|
55
|
+
const path = __importStar(require("path"));
|
|
56
|
+
const pgsql_parser_1 = require("pgsql-parser");
|
|
57
|
+
const engine_1 = require("./engine");
|
|
58
|
+
const walk_1 = require("./walk");
|
|
59
|
+
/** Count newlines before `offset` — the 0-based line the offset sits on. */
|
|
60
|
+
function lineAtOffset(text, offset) {
|
|
61
|
+
let n = 0;
|
|
62
|
+
const end = Math.min(Math.max(offset, 0), text.length);
|
|
63
|
+
for (let i = 0; i < end; i++)
|
|
64
|
+
if (text.charCodeAt(i) === 10)
|
|
65
|
+
n++;
|
|
66
|
+
return n;
|
|
67
|
+
}
|
|
68
|
+
/** Printable `schema.name` of a `CreateFunctionStmt.funcname`. */
|
|
69
|
+
function funcName(createFnStmt) {
|
|
70
|
+
const funcname = createFnStmt.funcname;
|
|
71
|
+
if (!Array.isArray(funcname))
|
|
72
|
+
return '<anonymous>';
|
|
73
|
+
const parts = funcname
|
|
74
|
+
.map((n) => {
|
|
75
|
+
const str = n.String;
|
|
76
|
+
return str && typeof str.sval === 'string' ? str.sval : '';
|
|
77
|
+
})
|
|
78
|
+
.filter((s) => s.length > 0);
|
|
79
|
+
return parts.length > 0 ? parts.join('.') : '<anonymous>';
|
|
80
|
+
}
|
|
81
|
+
/** The `LANGUAGE` of a `CreateFunctionStmt` (lower-cased), defaulting to `sql`. */
|
|
82
|
+
function funcLanguage(createFnStmt) {
|
|
83
|
+
const options = createFnStmt.options;
|
|
84
|
+
if (Array.isArray(options)) {
|
|
85
|
+
for (const opt of options) {
|
|
86
|
+
const de = opt.DefElem;
|
|
87
|
+
if (de?.defname === 'language') {
|
|
88
|
+
const str = de.arg?.String;
|
|
89
|
+
if (str && typeof str.sval === 'string')
|
|
90
|
+
return str.sval.toLowerCase();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return 'sql';
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Split a SQL source string into the function definitions it contains. Each
|
|
98
|
+
* `CREATE FUNCTION` is sliced back out with its absolute start line, so a
|
|
99
|
+
* mixed migration (schema + tables + functions) yields one input per function
|
|
100
|
+
* rather than being treated as a single malformed definition.
|
|
101
|
+
*/
|
|
102
|
+
async function sqlTextDefinitions(source, file) {
|
|
103
|
+
const parsed = (await (0, pgsql_parser_1.parse)(source));
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const entry of parsed.stmts ?? []) {
|
|
106
|
+
const createFnStmt = entry.stmt ? (0, walk_1.findAll)(entry.stmt, 'CreateFunctionStmt')[0] : undefined;
|
|
107
|
+
if (!createFnStmt)
|
|
108
|
+
continue;
|
|
109
|
+
const start = entry.stmt_location ?? 0;
|
|
110
|
+
const len = entry.stmt_len ?? source.length - start;
|
|
111
|
+
out.push({
|
|
112
|
+
text: source.slice(start, start + len),
|
|
113
|
+
language: funcLanguage(createFnStmt),
|
|
114
|
+
name: funcName(createFnStmt),
|
|
115
|
+
file,
|
|
116
|
+
startLine: lineAtOffset(source, start) // 0-based line the definition begins on
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Lint a SQL source string that may contain many statements. Findings are
|
|
123
|
+
* re-anchored to absolute lines within `source`.
|
|
124
|
+
*/
|
|
125
|
+
async function lintSqlText(source, options = {}) {
|
|
126
|
+
let inputs;
|
|
127
|
+
try {
|
|
128
|
+
inputs = await sqlTextDefinitions(source);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
return { file: '<source>', findings: [], parseError: err.message };
|
|
132
|
+
}
|
|
133
|
+
const findings = await lintInputs(inputs, options);
|
|
134
|
+
return { file: '<source>', findings };
|
|
135
|
+
}
|
|
136
|
+
/** Lint a set of adapter-produced definitions, re-anchored to file lines. */
|
|
137
|
+
async function lintInputs(inputs, options) {
|
|
138
|
+
const findings = [];
|
|
139
|
+
for (const input of inputs) {
|
|
140
|
+
const subject = input.name ?? '<anonymous>';
|
|
141
|
+
const startLine = input.startLine ?? 0;
|
|
142
|
+
const { problems, suppressed } = await (0, engine_1.lintDefinition)(input.text, input.language, subject, options);
|
|
143
|
+
const absLine = (p) => startLine + p.line; // p.line is 1-based within input.text
|
|
144
|
+
for (const p of problems)
|
|
145
|
+
findings.push(toFinding(p, subject, absLine(p), false));
|
|
146
|
+
for (const s of suppressed) {
|
|
147
|
+
findings.push(toFinding(s, subject, absLine(s), true, s.reason, s.scope));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
findings.sort((a, b) => a.line - b.line || a.ruleId.localeCompare(b.ruleId));
|
|
151
|
+
return findings;
|
|
152
|
+
}
|
|
153
|
+
function toFinding(p, subject, line, acknowledged, reason, scope) {
|
|
154
|
+
const meta = {
|
|
155
|
+
ruleId: p.ruleId,
|
|
156
|
+
code: codeFor(p.ruleId),
|
|
157
|
+
line,
|
|
158
|
+
message: p.message,
|
|
159
|
+
severity: p.severity ?? 'error',
|
|
160
|
+
subject,
|
|
161
|
+
acknowledged,
|
|
162
|
+
context: p.context
|
|
163
|
+
};
|
|
164
|
+
if (p.hint)
|
|
165
|
+
meta.hint = p.hint;
|
|
166
|
+
if (acknowledged) {
|
|
167
|
+
meta.reason = reason ?? null;
|
|
168
|
+
meta.scope = scope;
|
|
169
|
+
}
|
|
170
|
+
return meta;
|
|
171
|
+
}
|
|
172
|
+
const RULE_CODE = {
|
|
173
|
+
'no-set-search-path': 'C1',
|
|
174
|
+
'no-variable-conflict': 'C2',
|
|
175
|
+
'require-qualified-refs': 'C3',
|
|
176
|
+
'no-dynamic-sql': 'C4'
|
|
177
|
+
};
|
|
178
|
+
function codeFor(ruleId) {
|
|
179
|
+
return RULE_CODE[ruleId] ?? ruleId;
|
|
180
|
+
}
|
|
181
|
+
/** Recursively collect `.sql` files under a directory. */
|
|
182
|
+
async function sqlFilesUnder(dir) {
|
|
183
|
+
const out = [];
|
|
184
|
+
const entries = await fs_1.promises.readdir(dir, { withFileTypes: true });
|
|
185
|
+
for (const e of entries) {
|
|
186
|
+
const full = path.join(dir, e.name);
|
|
187
|
+
if (e.isDirectory()) {
|
|
188
|
+
if (e.name === 'node_modules' || e.name === '.git' || e.name === 'dist')
|
|
189
|
+
continue;
|
|
190
|
+
out.push(...(await sqlFilesUnder(full)));
|
|
191
|
+
}
|
|
192
|
+
else if (e.isFile() && full.toLowerCase().endsWith('.sql')) {
|
|
193
|
+
out.push(full);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
/** Resolve a mix of file and directory paths to a sorted list of `.sql` files. */
|
|
199
|
+
async function resolveSqlFiles(paths) {
|
|
200
|
+
const files = new Set();
|
|
201
|
+
for (const p of paths) {
|
|
202
|
+
const st = await fs_1.promises.stat(p);
|
|
203
|
+
if (st.isDirectory()) {
|
|
204
|
+
for (const f of await sqlFilesUnder(p))
|
|
205
|
+
files.add(f);
|
|
206
|
+
}
|
|
207
|
+
else if (st.isFile()) {
|
|
208
|
+
files.add(p);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return [...files].sort();
|
|
212
|
+
}
|
|
213
|
+
/** Lint every `.sql` file reachable from `paths` (files or directories). */
|
|
214
|
+
async function lintFiles(paths, options = {}) {
|
|
215
|
+
const files = await resolveSqlFiles(paths);
|
|
216
|
+
const reports = [];
|
|
217
|
+
for (const file of files) {
|
|
218
|
+
const source = await fs_1.promises.readFile(file, 'utf8');
|
|
219
|
+
const report = await lintSqlText(source, options);
|
|
220
|
+
reports.push({ ...report, file });
|
|
221
|
+
}
|
|
222
|
+
return reports;
|
|
223
|
+
}
|
|
224
|
+
/** A {@link SourceAdapter} over an in-memory SQL string. */
|
|
225
|
+
function sqlTextAdapter(source, file) {
|
|
226
|
+
return { id: 'sql-text', definitions: () => sqlTextDefinitions(source, file) };
|
|
227
|
+
}
|
|
228
|
+
/** A {@link SourceAdapter} over `.sql` files/directories on disk. */
|
|
229
|
+
function filesAdapter(paths) {
|
|
230
|
+
return {
|
|
231
|
+
id: 'files',
|
|
232
|
+
definitions: async () => {
|
|
233
|
+
const files = await resolveSqlFiles(paths);
|
|
234
|
+
const out = [];
|
|
235
|
+
for (const file of files) {
|
|
236
|
+
out.push(...(await sqlTextDefinitions(await fs_1.promises.readFile(file, 'utf8'), file)));
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Lint every definition an adapter yields, grouped into one {@link FileReport}
|
|
244
|
+
* per origin file. This is the generic entry point: `@pgsql/lint` ships file
|
|
245
|
+
* and sql-text adapters; a consumer (e.g. safegres over a live catalog) can
|
|
246
|
+
* pass its own.
|
|
247
|
+
*/
|
|
248
|
+
async function lintSource(adapter, options = {}) {
|
|
249
|
+
const inputs = await adapter.definitions();
|
|
250
|
+
const byFile = new Map();
|
|
251
|
+
for (const input of inputs) {
|
|
252
|
+
const file = input.file ?? `<${adapter.id}>`;
|
|
253
|
+
const arr = byFile.get(file) ?? [];
|
|
254
|
+
arr.push(input);
|
|
255
|
+
byFile.set(file, arr);
|
|
256
|
+
}
|
|
257
|
+
const reports = [];
|
|
258
|
+
for (const [file, group] of byFile) {
|
|
259
|
+
reports.push({ file, findings: await lintInputs(group, options) });
|
|
260
|
+
}
|
|
261
|
+
reports.sort((a, b) => a.file.localeCompare(b.file));
|
|
262
|
+
return reports;
|
|
263
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
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 type { LintOptions } from './engine';
|
|
14
|
+
export { lintDefinition, severityOf } from './engine';
|
|
15
|
+
export { FileFinding, FileReport, filesAdapter, lintFiles, lintSource, lintSqlText, resolveSqlFiles, sqlTextAdapter, sqlTextDefinitions } from './file-runner';
|
|
16
|
+
export type { CallOptions, Linter, LinterConfig } from './linter';
|
|
17
|
+
export { createLinter } from './linter';
|
|
18
|
+
export { parseUnit } from './parse-unit';
|
|
19
|
+
export { LINT_RULES, LINT_RULES_BY_CODE, LINT_RULES_BY_ID, noDynamicSql, noSetSearchPath, noVariableConflict, requireQualifiedRefs } from './rules';
|
|
20
|
+
export { DEFAULT_KEYWORDS, Suppressions } from './suppressions';
|
|
21
|
+
export type { DynamicSqlSite, LintDefinitionInput, LintProblem, LintResult, LintRule, LintRuleMeta, LintUnit, Severity, SeverityMap, SourceAdapter, SqlFragment, SuppressedProblem, SuppressionScope } from './types';
|
|
22
|
+
export { defineRule } from './types';
|
package/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `@pgsql/lint` — a source-level SQL/PL/pgSQL convention linter.
|
|
4
|
+
*
|
|
5
|
+
* It reasons about the *text* of a function definition — fully-qualified
|
|
6
|
+
* references, `search_path` settings, `#variable_conflict`, dynamic SQL — from
|
|
7
|
+
* its AST, and carries no `pg` / catalog dependency. The same engine runs over
|
|
8
|
+
* a `CREATE FUNCTION` from a migration, an editor buffer, or a definition read
|
|
9
|
+
* from a live catalog via `pg_get_functiondef`.
|
|
10
|
+
*
|
|
11
|
+
* Rules are injected as values, never discovered by npm name: build a linter
|
|
12
|
+
* with `createLinter({ rules, severity })` and pass your own rules in.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.defineRule = exports.Suppressions = exports.DEFAULT_KEYWORDS = exports.requireQualifiedRefs = exports.noVariableConflict = exports.noSetSearchPath = exports.noDynamicSql = exports.LINT_RULES_BY_ID = exports.LINT_RULES_BY_CODE = exports.LINT_RULES = exports.parseUnit = exports.createLinter = exports.sqlTextDefinitions = exports.sqlTextAdapter = exports.resolveSqlFiles = exports.lintSqlText = exports.lintSource = exports.lintFiles = exports.filesAdapter = exports.severityOf = exports.lintDefinition = void 0;
|
|
16
|
+
var engine_1 = require("./engine");
|
|
17
|
+
Object.defineProperty(exports, "lintDefinition", { enumerable: true, get: function () { return engine_1.lintDefinition; } });
|
|
18
|
+
Object.defineProperty(exports, "severityOf", { enumerable: true, get: function () { return engine_1.severityOf; } });
|
|
19
|
+
var file_runner_1 = require("./file-runner");
|
|
20
|
+
Object.defineProperty(exports, "filesAdapter", { enumerable: true, get: function () { return file_runner_1.filesAdapter; } });
|
|
21
|
+
Object.defineProperty(exports, "lintFiles", { enumerable: true, get: function () { return file_runner_1.lintFiles; } });
|
|
22
|
+
Object.defineProperty(exports, "lintSource", { enumerable: true, get: function () { return file_runner_1.lintSource; } });
|
|
23
|
+
Object.defineProperty(exports, "lintSqlText", { enumerable: true, get: function () { return file_runner_1.lintSqlText; } });
|
|
24
|
+
Object.defineProperty(exports, "resolveSqlFiles", { enumerable: true, get: function () { return file_runner_1.resolveSqlFiles; } });
|
|
25
|
+
Object.defineProperty(exports, "sqlTextAdapter", { enumerable: true, get: function () { return file_runner_1.sqlTextAdapter; } });
|
|
26
|
+
Object.defineProperty(exports, "sqlTextDefinitions", { enumerable: true, get: function () { return file_runner_1.sqlTextDefinitions; } });
|
|
27
|
+
var linter_1 = require("./linter");
|
|
28
|
+
Object.defineProperty(exports, "createLinter", { enumerable: true, get: function () { return linter_1.createLinter; } });
|
|
29
|
+
var parse_unit_1 = require("./parse-unit");
|
|
30
|
+
Object.defineProperty(exports, "parseUnit", { enumerable: true, get: function () { return parse_unit_1.parseUnit; } });
|
|
31
|
+
var rules_1 = require("./rules");
|
|
32
|
+
Object.defineProperty(exports, "LINT_RULES", { enumerable: true, get: function () { return rules_1.LINT_RULES; } });
|
|
33
|
+
Object.defineProperty(exports, "LINT_RULES_BY_CODE", { enumerable: true, get: function () { return rules_1.LINT_RULES_BY_CODE; } });
|
|
34
|
+
Object.defineProperty(exports, "LINT_RULES_BY_ID", { enumerable: true, get: function () { return rules_1.LINT_RULES_BY_ID; } });
|
|
35
|
+
Object.defineProperty(exports, "noDynamicSql", { enumerable: true, get: function () { return rules_1.noDynamicSql; } });
|
|
36
|
+
Object.defineProperty(exports, "noSetSearchPath", { enumerable: true, get: function () { return rules_1.noSetSearchPath; } });
|
|
37
|
+
Object.defineProperty(exports, "noVariableConflict", { enumerable: true, get: function () { return rules_1.noVariableConflict; } });
|
|
38
|
+
Object.defineProperty(exports, "requireQualifiedRefs", { enumerable: true, get: function () { return rules_1.requireQualifiedRefs; } });
|
|
39
|
+
var suppressions_1 = require("./suppressions");
|
|
40
|
+
Object.defineProperty(exports, "DEFAULT_KEYWORDS", { enumerable: true, get: function () { return suppressions_1.DEFAULT_KEYWORDS; } });
|
|
41
|
+
Object.defineProperty(exports, "Suppressions", { enumerable: true, get: function () { return suppressions_1.Suppressions; } });
|
|
42
|
+
var types_1 = require("./types");
|
|
43
|
+
Object.defineProperty(exports, "defineRule", { enumerable: true, get: function () { return types_1.defineRule; } });
|
package/linter.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
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 { LintOptions } from './engine';
|
|
12
|
+
import { FileReport, filesAdapter, sqlTextAdapter } from './file-runner';
|
|
13
|
+
import type { LintResult, LintRule, SeverityMap, SourceAdapter } from './types';
|
|
14
|
+
/** Configuration for a {@link Linter}. */
|
|
15
|
+
export interface LinterConfig {
|
|
16
|
+
/** The rule set. Defaults to the built-in C1–C4. */
|
|
17
|
+
rules?: LintRule[];
|
|
18
|
+
/** Per-rule severity, keyed by id or code. Unmapped rules default to `error`. */
|
|
19
|
+
severity?: SeverityMap;
|
|
20
|
+
/** Suppression directive keyword(s). Defaults to `['pgsql-lint', 'safegres']`. */
|
|
21
|
+
keyword?: string | string[];
|
|
22
|
+
}
|
|
23
|
+
/** Narrow a single call to a subset of the linter's rules. */
|
|
24
|
+
export type CallOptions = Pick<LintOptions, 'rules'>;
|
|
25
|
+
/**
|
|
26
|
+
* A linter bound to a rule set, severities, and keyword. Build one with your
|
|
27
|
+
* own rules and point it at definitions, SQL text, files, or a custom adapter.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* const linter = createLinter({
|
|
31
|
+
* rules: [...LINT_RULES, noWritesInView],
|
|
32
|
+
* severity: { 'require-qualified-refs': 'warn', C2: 'off' }
|
|
33
|
+
* });
|
|
34
|
+
* await linter.lintFiles(['./migrations']);
|
|
35
|
+
*/
|
|
36
|
+
export interface Linter {
|
|
37
|
+
readonly rules: LintRule[];
|
|
38
|
+
readonly severity: SeverityMap;
|
|
39
|
+
readonly keyword: string | string[];
|
|
40
|
+
/** Lint one function definition (e.g. from `pg_get_functiondef`). */
|
|
41
|
+
lintDefinition: (text: string, language: string, name?: string, options?: CallOptions) => Promise<LintResult>;
|
|
42
|
+
/** Lint a SQL source string containing any number of statements. */
|
|
43
|
+
lintSqlText: (source: string, options?: CallOptions) => Promise<FileReport>;
|
|
44
|
+
/** Lint every `.sql` file reachable from `paths` (files or directories). */
|
|
45
|
+
lintFiles: (paths: string[], options?: CallOptions) => Promise<FileReport[]>;
|
|
46
|
+
/** Lint every definition a {@link SourceAdapter} yields. */
|
|
47
|
+
lintSource: (adapter: SourceAdapter, options?: CallOptions) => Promise<FileReport[]>;
|
|
48
|
+
}
|
|
49
|
+
/** Build a {@link Linter} bound to a rule set, severities, and keyword. */
|
|
50
|
+
export declare function createLinter(config?: LinterConfig): Linter;
|
|
51
|
+
export { filesAdapter, sqlTextAdapter };
|
package/linter.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `createLinter` — the ecosystem entry point.
|
|
4
|
+
*
|
|
5
|
+
* A linter is nothing but a rule set + severities + directive keyword bound
|
|
6
|
+
* together. Rules are passed *in* as values (not discovered by npm name), so
|
|
7
|
+
* anyone can publish a rule package, `import` it, and add it here. Severity is
|
|
8
|
+
* configuration (ESLint-style `off` / `warn` / `error`), keyed by rule id or
|
|
9
|
+
* code, so a downstream consumer (safegres) keeps full control of how loud
|
|
10
|
+
* each rule is without duplicating any engine logic.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.sqlTextAdapter = exports.filesAdapter = void 0;
|
|
14
|
+
exports.createLinter = createLinter;
|
|
15
|
+
const engine_1 = require("./engine");
|
|
16
|
+
const file_runner_1 = require("./file-runner");
|
|
17
|
+
Object.defineProperty(exports, "filesAdapter", { enumerable: true, get: function () { return file_runner_1.filesAdapter; } });
|
|
18
|
+
Object.defineProperty(exports, "sqlTextAdapter", { enumerable: true, get: function () { return file_runner_1.sqlTextAdapter; } });
|
|
19
|
+
const rules_1 = require("./rules");
|
|
20
|
+
const suppressions_1 = require("./suppressions");
|
|
21
|
+
/** Build a {@link Linter} bound to a rule set, severities, and keyword. */
|
|
22
|
+
function createLinter(config = {}) {
|
|
23
|
+
const rules = config.rules ?? rules_1.LINT_RULES;
|
|
24
|
+
const severity = config.severity ?? {};
|
|
25
|
+
const keyword = config.keyword ?? suppressions_1.DEFAULT_KEYWORDS;
|
|
26
|
+
const bind = (options = {}) => ({
|
|
27
|
+
ruleSet: rules,
|
|
28
|
+
severity,
|
|
29
|
+
keyword,
|
|
30
|
+
rules: options.rules
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
rules,
|
|
34
|
+
severity,
|
|
35
|
+
keyword,
|
|
36
|
+
lintDefinition: (text, language, name, options) => (0, engine_1.lintDefinition)(text, language, name, bind(options)),
|
|
37
|
+
lintSqlText: (source, options) => (0, file_runner_1.lintSqlText)(source, bind(options)),
|
|
38
|
+
lintFiles: (paths, options) => (0, file_runner_1.lintFiles)(paths, bind(options)),
|
|
39
|
+
lintSource: (adapter, options) => (0, file_runner_1.lintSource)(adapter, bind(options))
|
|
40
|
+
};
|
|
41
|
+
}
|