@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +150 -0
  3. package/cli.d.ts +12 -0
  4. package/cli.js +130 -0
  5. package/engine.d.ts +30 -0
  6. package/engine.js +66 -0
  7. package/esm/cli.js +125 -0
  8. package/esm/engine.js +62 -0
  9. package/esm/file-runner.js +221 -0
  10. package/esm/index.js +19 -0
  11. package/esm/linter.js +36 -0
  12. package/esm/parse-unit.js +170 -0
  13. package/esm/rules/index.js +14 -0
  14. package/esm/rules/no-dynamic-sql.js +26 -0
  15. package/esm/rules/no-set-search-path.js +64 -0
  16. package/esm/rules/no-variable-conflict.js +36 -0
  17. package/esm/rules/require-qualified-refs.js +68 -0
  18. package/esm/suppressions.js +131 -0
  19. package/esm/types.js +23 -0
  20. package/esm/util.js +9 -0
  21. package/esm/walk.js +14 -0
  22. package/file-runner.d.ts +68 -0
  23. package/file-runner.js +263 -0
  24. package/index.d.ts +22 -0
  25. package/index.js +43 -0
  26. package/linter.d.ts +51 -0
  27. package/linter.js +41 -0
  28. package/package.json +60 -0
  29. package/parse-unit.d.ts +17 -0
  30. package/parse-unit.js +173 -0
  31. package/rules/index.d.ts +10 -0
  32. package/rules/index.js +20 -0
  33. package/rules/no-dynamic-sql.d.ts +13 -0
  34. package/rules/no-dynamic-sql.js +29 -0
  35. package/rules/no-set-search-path.d.ts +12 -0
  36. package/rules/no-set-search-path.js +67 -0
  37. package/rules/no-variable-conflict.d.ts +12 -0
  38. package/rules/no-variable-conflict.js +39 -0
  39. package/rules/require-qualified-refs.d.ts +16 -0
  40. package/rules/require-qualified-refs.js +71 -0
  41. package/suppressions.d.ts +49 -0
  42. package/suppressions.js +135 -0
  43. package/types.d.ts +142 -0
  44. package/types.js +26 -0
  45. package/util.d.ts +2 -0
  46. package/util.js +12 -0
  47. package/walk.d.ts +5 -0
  48. package/walk.js +17 -0
@@ -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
@@ -0,0 +1,2 @@
1
+ /** Absolute 1-based line of a char offset within `text`. */
2
+ export declare function lineOfOffset(text: string, offset: number): number;
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
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The one AST helper the rules need, layered on `@pgsql/traverse`'s `walk`.
3
+ */
4
+ /** Find every node tagged with `tag` (e.g. `RangeVar`, `CommonTableExpr`). */
5
+ export declare function findAll(root: unknown, tag: string): Record<string, unknown>[];
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
+ }