@pgsql/lint 18.3.3 → 18.3.5
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/README.md +4 -0
- package/cli.js +0 -5
- package/engine.d.ts +8 -2
- package/engine.js +16 -3
- package/esm/cli.js +0 -5
- package/esm/engine.js +15 -3
- package/esm/file-runner.js +41 -15
- package/esm/index.js +1 -1
- package/esm/parse-unit.js +10 -12
- package/esm/util.js +27 -5
- package/file-runner.d.ts +3 -0
- package/file-runner.js +40 -14
- package/index.d.ts +1 -1
- package/index.js +2 -1
- package/package.json +4 -4
- package/parse-unit.js +10 -12
- package/types.d.ts +6 -0
- package/util.d.ts +16 -2
- package/util.js +30 -5
package/README.md
CHANGED
|
@@ -36,6 +36,10 @@ Agent skill (rules, CLI, authoring new rules):
|
|
|
36
36
|
| `C3` | `require-qualified-refs` | an unqualified relation reference (`FROM users` → `FROM app_public.users`) |
|
|
37
37
|
| `C4` | `no-dynamic-sql` | `EXECUTE`, `EXECUTE … USING`, `FOR … IN EXECUTE` |
|
|
38
38
|
|
|
39
|
+
A definition the parser rejects is reported as `parse-error` (`P0`) rather than
|
|
40
|
+
skipped: no rule ran over it, so it is not clean. Downgrade it like any rule
|
|
41
|
+
(`--warn parse-error`) when the parser, not the SQL, is at fault.
|
|
42
|
+
|
|
39
43
|
The rules encode a single discipline: never depend on `search_path` — fully
|
|
40
44
|
qualify everything (`C1` + `C3`) — don't paper over ambiguity (`C2`), and treat
|
|
41
45
|
dynamic SQL as opaque and exceptional (`C4`).
|
package/cli.js
CHANGED
|
@@ -209,11 +209,6 @@ async function main() {
|
|
|
209
209
|
let warnings = 0;
|
|
210
210
|
let acknowledged = 0;
|
|
211
211
|
for (const report of reports) {
|
|
212
|
-
if (report.parseError) {
|
|
213
|
-
if (!argv.json)
|
|
214
|
-
console.error(chalk_1.default.yellow(`⚠ ${report.file}: ${report.parseError}`));
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
212
|
const shown = report.findings.filter((f) => !f.acknowledged || !argv.quiet);
|
|
218
213
|
if (!argv.json && shown.length > 0) {
|
|
219
214
|
console.log(chalk_1.default.underline(report.file));
|
package/engine.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* the rule objects in (see `createLinter`), so a third-party rule ships as an
|
|
8
8
|
* ordinary import, not a magic `pgsql-lint-plugin-*` package.
|
|
9
9
|
*/
|
|
10
|
-
import type { LintResult, LintRule, Severity, SeverityMap } from './types';
|
|
10
|
+
import type { LintResult, LintRule, LintRuleMeta, Severity, SeverityMap } from './types';
|
|
11
11
|
export interface LintOptions {
|
|
12
12
|
/** Restrict to these rule ids/codes; omit to run every rule in the set. */
|
|
13
13
|
rules?: string[];
|
|
@@ -25,6 +25,12 @@ export interface LintOptions {
|
|
|
25
25
|
keyword?: string | string[];
|
|
26
26
|
}
|
|
27
27
|
/** Resolve a rule's configured severity (id wins over code; default `error`). */
|
|
28
|
-
export declare function severityOf(rule:
|
|
28
|
+
export declare function severityOf(rule: LintRuleMeta, severity?: SeverityMap): Severity;
|
|
29
|
+
/**
|
|
30
|
+
* The pseudo-rule a parse failure is reported under. Not a {@link LintRule}
|
|
31
|
+
* (it has no `run`): it exists so the failure has an id/code a consumer can
|
|
32
|
+
* configure (`off` / `warn`) exactly like a rule.
|
|
33
|
+
*/
|
|
34
|
+
export declare const PARSE_ERROR: LintRuleMeta;
|
|
29
35
|
/** Lint a single function definition. */
|
|
30
36
|
export declare function lintDefinition(text: string, language: string, name?: string, options?: LintOptions): Promise<LintResult>;
|
package/engine.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* ordinary import, not a magic `pgsql-lint-plugin-*` package.
|
|
10
10
|
*/
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.PARSE_ERROR = void 0;
|
|
12
13
|
exports.severityOf = severityOf;
|
|
13
14
|
exports.lintDefinition = lintDefinition;
|
|
14
15
|
const parse_unit_1 = require("./parse-unit");
|
|
@@ -18,6 +19,17 @@ const suppressions_1 = require("./suppressions");
|
|
|
18
19
|
function severityOf(rule, severity = {}) {
|
|
19
20
|
return severity[rule.id] ?? severity[rule.code] ?? 'error';
|
|
20
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* The pseudo-rule a parse failure is reported under. Not a {@link LintRule}
|
|
24
|
+
* (it has no `run`): it exists so the failure has an id/code a consumer can
|
|
25
|
+
* configure (`off` / `warn`) exactly like a rule.
|
|
26
|
+
*/
|
|
27
|
+
exports.PARSE_ERROR = {
|
|
28
|
+
id: 'parse-error',
|
|
29
|
+
code: 'P0',
|
|
30
|
+
title: 'Definition could not be parsed',
|
|
31
|
+
reasonRequired: false
|
|
32
|
+
};
|
|
21
33
|
/** Lint a single function definition. */
|
|
22
34
|
async function lintDefinition(text, language, name, options = {}) {
|
|
23
35
|
const active = [];
|
|
@@ -33,10 +45,11 @@ async function lintDefinition(text, language, name, options = {}) {
|
|
|
33
45
|
if (selected.length === 0)
|
|
34
46
|
return { problems: active, suppressed };
|
|
35
47
|
const unit = await (0, parse_unit_1.parseUnit)(text, language, name);
|
|
36
|
-
// An unparseable definition
|
|
37
|
-
//
|
|
48
|
+
// An unparseable definition was checked by no rule. That is never a pass:
|
|
49
|
+
// the failure is returned so the caller reports it (the file runner turns
|
|
50
|
+
// it into a `parse-error` finding).
|
|
38
51
|
if (unit.parseError)
|
|
39
|
-
return { problems: active, suppressed };
|
|
52
|
+
return { problems: active, suppressed, parseError: unit.parseError };
|
|
40
53
|
const keywords = options.keyword
|
|
41
54
|
? (Array.isArray(options.keyword) ? options.keyword : [options.keyword])
|
|
42
55
|
: suppressions_1.DEFAULT_KEYWORDS;
|
package/esm/cli.js
CHANGED
|
@@ -171,11 +171,6 @@ async function main() {
|
|
|
171
171
|
let warnings = 0;
|
|
172
172
|
let acknowledged = 0;
|
|
173
173
|
for (const report of reports) {
|
|
174
|
-
if (report.parseError) {
|
|
175
|
-
if (!argv.json)
|
|
176
|
-
console.error(chalk.yellow(`⚠ ${report.file}: ${report.parseError}`));
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
174
|
const shown = report.findings.filter((f) => !f.acknowledged || !argv.quiet);
|
|
180
175
|
if (!argv.json && shown.length > 0) {
|
|
181
176
|
console.log(chalk.underline(report.file));
|
package/esm/engine.js
CHANGED
|
@@ -14,6 +14,17 @@ import { DEFAULT_KEYWORDS, Suppressions } from './suppressions';
|
|
|
14
14
|
export function severityOf(rule, severity = {}) {
|
|
15
15
|
return severity[rule.id] ?? severity[rule.code] ?? 'error';
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* The pseudo-rule a parse failure is reported under. Not a {@link LintRule}
|
|
19
|
+
* (it has no `run`): it exists so the failure has an id/code a consumer can
|
|
20
|
+
* configure (`off` / `warn`) exactly like a rule.
|
|
21
|
+
*/
|
|
22
|
+
export const PARSE_ERROR = {
|
|
23
|
+
id: 'parse-error',
|
|
24
|
+
code: 'P0',
|
|
25
|
+
title: 'Definition could not be parsed',
|
|
26
|
+
reasonRequired: false
|
|
27
|
+
};
|
|
17
28
|
/** Lint a single function definition. */
|
|
18
29
|
export async function lintDefinition(text, language, name, options = {}) {
|
|
19
30
|
const active = [];
|
|
@@ -29,10 +40,11 @@ export async function lintDefinition(text, language, name, options = {}) {
|
|
|
29
40
|
if (selected.length === 0)
|
|
30
41
|
return { problems: active, suppressed };
|
|
31
42
|
const unit = await parseUnit(text, language, name);
|
|
32
|
-
// An unparseable definition
|
|
33
|
-
//
|
|
43
|
+
// An unparseable definition was checked by no rule. That is never a pass:
|
|
44
|
+
// the failure is returned so the caller reports it (the file runner turns
|
|
45
|
+
// it into a `parse-error` finding).
|
|
34
46
|
if (unit.parseError)
|
|
35
|
-
return { problems: active, suppressed };
|
|
47
|
+
return { problems: active, suppressed, parseError: unit.parseError };
|
|
36
48
|
const keywords = options.keyword
|
|
37
49
|
? (Array.isArray(options.keyword) ? options.keyword : [options.keyword])
|
|
38
50
|
: DEFAULT_KEYWORDS;
|
package/esm/file-runner.js
CHANGED
|
@@ -12,18 +12,10 @@
|
|
|
12
12
|
import { promises as fs } from 'fs';
|
|
13
13
|
import * as path from 'path';
|
|
14
14
|
import { parse } from 'pgsql-parser';
|
|
15
|
-
import { lintDefinition } from './engine';
|
|
15
|
+
import { lintDefinition, PARSE_ERROR, severityOf } from './engine';
|
|
16
16
|
import { applyIgnore } from './ignore';
|
|
17
|
+
import { newlinesBeforeByte, sliceBytes, utf8 } from './util';
|
|
17
18
|
import { findAll } from './walk';
|
|
18
|
-
/** Count newlines before `offset` — the 0-based line the offset sits on. */
|
|
19
|
-
function lineAtOffset(text, offset) {
|
|
20
|
-
let n = 0;
|
|
21
|
-
const end = Math.min(Math.max(offset, 0), text.length);
|
|
22
|
-
for (let i = 0; i < end; i++)
|
|
23
|
-
if (text.charCodeAt(i) === 10)
|
|
24
|
-
n++;
|
|
25
|
-
return n;
|
|
26
|
-
}
|
|
27
19
|
/** Printable `schema.name` of a `CreateFunctionStmt.funcname`. */
|
|
28
20
|
function funcName(createFnStmt) {
|
|
29
21
|
const funcname = createFnStmt.funcname;
|
|
@@ -57,22 +49,26 @@ function funcLanguage(createFnStmt) {
|
|
|
57
49
|
* `CREATE FUNCTION` is sliced back out with its absolute start line, so a
|
|
58
50
|
* mixed migration (schema + tables + functions) yields one input per function
|
|
59
51
|
* rather than being treated as a single malformed definition.
|
|
52
|
+
*
|
|
53
|
+
* `stmt_location` / `stmt_len` are byte offsets into the UTF-8 source, so the
|
|
54
|
+
* slicing happens over the encoded bytes (see `util.ts`).
|
|
60
55
|
*/
|
|
61
56
|
export async function sqlTextDefinitions(source, file) {
|
|
62
57
|
const parsed = (await parse(source));
|
|
58
|
+
const bytes = utf8(source);
|
|
63
59
|
const out = [];
|
|
64
60
|
for (const entry of parsed.stmts ?? []) {
|
|
65
61
|
const createFnStmt = entry.stmt ? findAll(entry.stmt, 'CreateFunctionStmt')[0] : undefined;
|
|
66
62
|
if (!createFnStmt)
|
|
67
63
|
continue;
|
|
68
64
|
const start = entry.stmt_location ?? 0;
|
|
69
|
-
const len = entry.stmt_len ??
|
|
65
|
+
const len = entry.stmt_len ?? bytes.length - start;
|
|
70
66
|
out.push({
|
|
71
|
-
text:
|
|
67
|
+
text: sliceBytes(bytes, start, len),
|
|
72
68
|
language: funcLanguage(createFnStmt),
|
|
73
69
|
name: funcName(createFnStmt),
|
|
74
70
|
file,
|
|
75
|
-
startLine:
|
|
71
|
+
startLine: newlinesBeforeByte(bytes, start) // 0-based line the definition begins on
|
|
76
72
|
});
|
|
77
73
|
}
|
|
78
74
|
return out;
|
|
@@ -87,7 +83,9 @@ export async function lintSqlText(source, options = {}) {
|
|
|
87
83
|
inputs = await sqlTextDefinitions(source);
|
|
88
84
|
}
|
|
89
85
|
catch (err) {
|
|
90
|
-
|
|
86
|
+
const message = err.message;
|
|
87
|
+
const finding = parseErrorFinding(message, '<file>', 1, options);
|
|
88
|
+
return { file: '<source>', findings: finding ? [finding] : [], parseError: message };
|
|
91
89
|
}
|
|
92
90
|
const findings = await lintInputs(inputs, options);
|
|
93
91
|
return { file: '<source>', findings };
|
|
@@ -98,7 +96,12 @@ async function lintInputs(inputs, options) {
|
|
|
98
96
|
for (const input of inputs) {
|
|
99
97
|
const subject = input.name ?? '<anonymous>';
|
|
100
98
|
const startLine = input.startLine ?? 0;
|
|
101
|
-
const { problems, suppressed } = await lintDefinition(input.text, input.language, subject, options);
|
|
99
|
+
const { problems, suppressed, parseError } = await lintDefinition(input.text, input.language, subject, options);
|
|
100
|
+
if (parseError) {
|
|
101
|
+
const finding = parseErrorFinding(parseError, subject, startLine + 1, options);
|
|
102
|
+
if (finding)
|
|
103
|
+
findings.push(finding);
|
|
104
|
+
}
|
|
102
105
|
const absLine = (p) => startLine + p.line; // p.line is 1-based within input.text
|
|
103
106
|
for (const p of problems)
|
|
104
107
|
findings.push(toFinding(p, subject, absLine(p), false));
|
|
@@ -129,6 +132,7 @@ function toFinding(p, subject, line, acknowledged, reason, scope) {
|
|
|
129
132
|
return meta;
|
|
130
133
|
}
|
|
131
134
|
const RULE_CODE = {
|
|
135
|
+
[PARSE_ERROR.id]: PARSE_ERROR.code,
|
|
132
136
|
'no-set-search-path': 'C1',
|
|
133
137
|
'no-variable-conflict': 'C2',
|
|
134
138
|
'require-qualified-refs': 'C3',
|
|
@@ -137,6 +141,28 @@ const RULE_CODE = {
|
|
|
137
141
|
function codeFor(ruleId) {
|
|
138
142
|
return RULE_CODE[ruleId] ?? ruleId;
|
|
139
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* A definition (or file) the parser rejected. Nothing in it was checked, so
|
|
146
|
+
* the failure is reported as a finding rather than dropped: an unparseable
|
|
147
|
+
* function would otherwise pass every rule by default. Severity is configured
|
|
148
|
+
* like any rule (`parse-error` / `P0`): `error` unless downgraded, and `off`
|
|
149
|
+
* drops it like any other rule turned off.
|
|
150
|
+
*/
|
|
151
|
+
function parseErrorFinding(message, subject, line, options) {
|
|
152
|
+
const severity = severityOf(PARSE_ERROR, options.severity);
|
|
153
|
+
if (severity === 'off')
|
|
154
|
+
return undefined;
|
|
155
|
+
return {
|
|
156
|
+
ruleId: PARSE_ERROR.id,
|
|
157
|
+
code: PARSE_ERROR.code,
|
|
158
|
+
line,
|
|
159
|
+
message: `Definition could not be parsed, so no rule was checked: ${message}`,
|
|
160
|
+
hint: 'Fix the syntax (or, if the parser is wrong, downgrade `parse-error` in the lint config).',
|
|
161
|
+
severity,
|
|
162
|
+
subject,
|
|
163
|
+
acknowledged: false
|
|
164
|
+
};
|
|
165
|
+
}
|
|
140
166
|
/** Recursively collect `.sql` files under a directory. */
|
|
141
167
|
async function sqlFilesUnder(dir) {
|
|
142
168
|
const out = [];
|
package/esm/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export { changedFiles, changedSqlFiles, resolveChangedBase } from './changed';
|
|
14
14
|
export { CONFIG_FILENAMES, configDir, findConfigFile, loadLintConfig } from './config';
|
|
15
|
-
export { lintDefinition, severityOf } from './engine';
|
|
15
|
+
export { lintDefinition, PARSE_ERROR, severityOf } from './engine';
|
|
16
16
|
export { filesAdapter, lintFiles, lintSource, lintSqlText, resolveSqlFiles, sqlTextAdapter, sqlTextDefinitions } from './file-runner';
|
|
17
17
|
export { applyIgnore, makeIgnoreFilter } from './ignore';
|
|
18
18
|
export { createLinter } from './linter';
|
package/esm/parse-unit.js
CHANGED
|
@@ -10,19 +10,16 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { parsePlPgSQL } from 'libpg-query';
|
|
12
12
|
import { parse } from 'pgsql-parser';
|
|
13
|
+
import { newlinesBeforeByte, utf8 } from './util';
|
|
13
14
|
import { findAll } from './walk';
|
|
14
|
-
/**
|
|
15
|
-
function
|
|
15
|
+
/** Absolute 1-based line of a char offset within `text`. */
|
|
16
|
+
function lineOf(text, offset) {
|
|
16
17
|
let n = 0;
|
|
17
|
-
const end = Math.min(offset,
|
|
18
|
+
const end = Math.min(offset, text.length);
|
|
18
19
|
for (let i = 0; i < end; i++)
|
|
19
|
-
if (
|
|
20
|
+
if (text.charCodeAt(i) === 10)
|
|
20
21
|
n++;
|
|
21
|
-
return n;
|
|
22
|
-
}
|
|
23
|
-
/** Absolute 1-based line of a char offset within `text`. */
|
|
24
|
-
function lineOf(text, offset) {
|
|
25
|
-
return newlinesBefore(text, offset) + 1;
|
|
22
|
+
return n + 1;
|
|
26
23
|
}
|
|
27
24
|
/** The body string of a `CreateFunctionStmt` (`AS $$ … $$`), or null. */
|
|
28
25
|
function functionBody(createFnStmt) {
|
|
@@ -125,13 +122,14 @@ export async function parseUnit(text, language, name) {
|
|
|
125
122
|
const dynamicSql = [];
|
|
126
123
|
const lang = language.toLowerCase();
|
|
127
124
|
if (lang === 'sql' && body !== null && bodyStartLine !== undefined) {
|
|
128
|
-
// A SQL-language body is itself SQL: parse it whole. Locations are
|
|
129
|
-
// relative to the body, so re-anchor them onto the definition.
|
|
125
|
+
// A SQL-language body is itself SQL: parse it whole. Locations are byte
|
|
126
|
+
// offsets relative to the body, so re-anchor them onto the definition.
|
|
130
127
|
try {
|
|
131
128
|
const ast = await parse(body);
|
|
129
|
+
const bodyBytes = utf8(body);
|
|
132
130
|
fragments.push({
|
|
133
131
|
ast,
|
|
134
|
-
lineForOffset: (offset) => bodyStartLine +
|
|
132
|
+
lineForOffset: (offset) => bodyStartLine + newlinesBeforeByte(bodyBytes, offset)
|
|
135
133
|
});
|
|
136
134
|
}
|
|
137
135
|
catch {
|
package/esm/util.js
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Offset arithmetic. Every location libpg_query reports — `stmt_location`,
|
|
3
|
+
* `stmt_len`, a node's `location` — is a **byte** offset into the UTF-8 source,
|
|
4
|
+
* while a JS string is indexed by UTF-16 code unit. The two agree only while
|
|
5
|
+
* the text is pure ASCII: one `→` or `—` earlier in a file shifts every later
|
|
6
|
+
* offset, and a definition sliced by character then fails to parse. So slice
|
|
7
|
+
* and count newlines over the UTF-8 bytes, never over the string.
|
|
8
|
+
*/
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
const decoder = new TextDecoder();
|
|
11
|
+
/** The UTF-8 bytes of `text` — the coordinate space parser offsets live in. */
|
|
12
|
+
export function utf8(text) {
|
|
13
|
+
return encoder.encode(text);
|
|
14
|
+
}
|
|
15
|
+
/** `bytes[start, start + len)` decoded back to a string. */
|
|
16
|
+
export function sliceBytes(bytes, start, len) {
|
|
17
|
+
return decoder.decode(bytes.subarray(start, start + len));
|
|
18
|
+
}
|
|
19
|
+
/** Count newline bytes in `bytes[0, byteOffset)` — the 0-based line the offset sits on. */
|
|
20
|
+
export function newlinesBeforeByte(bytes, byteOffset) {
|
|
3
21
|
let n = 0;
|
|
4
|
-
const end = Math.min(Math.max(
|
|
22
|
+
const end = Math.min(Math.max(byteOffset, 0), bytes.length);
|
|
5
23
|
for (let i = 0; i < end; i++)
|
|
6
|
-
if (
|
|
24
|
+
if (bytes[i] === 10)
|
|
7
25
|
n++;
|
|
8
|
-
return n
|
|
26
|
+
return n;
|
|
27
|
+
}
|
|
28
|
+
/** Absolute 1-based line of a parser (byte) offset within `text`. */
|
|
29
|
+
export function lineOfOffset(text, byteOffset) {
|
|
30
|
+
return newlinesBeforeByte(utf8(text), byteOffset) + 1;
|
|
9
31
|
}
|
package/file-runner.d.ts
CHANGED
|
@@ -44,6 +44,9 @@ export interface FileReport {
|
|
|
44
44
|
* `CREATE FUNCTION` is sliced back out with its absolute start line, so a
|
|
45
45
|
* mixed migration (schema + tables + functions) yields one input per function
|
|
46
46
|
* rather than being treated as a single malformed definition.
|
|
47
|
+
*
|
|
48
|
+
* `stmt_location` / `stmt_len` are byte offsets into the UTF-8 source, so the
|
|
49
|
+
* slicing happens over the encoded bytes (see `util.ts`).
|
|
47
50
|
*/
|
|
48
51
|
export declare function sqlTextDefinitions(source: string, file?: string): Promise<LintDefinitionInput[]>;
|
|
49
52
|
/**
|
package/file-runner.js
CHANGED
|
@@ -56,16 +56,8 @@ const path = __importStar(require("path"));
|
|
|
56
56
|
const pgsql_parser_1 = require("pgsql-parser");
|
|
57
57
|
const engine_1 = require("./engine");
|
|
58
58
|
const ignore_1 = require("./ignore");
|
|
59
|
+
const util_1 = require("./util");
|
|
59
60
|
const walk_1 = require("./walk");
|
|
60
|
-
/** Count newlines before `offset` — the 0-based line the offset sits on. */
|
|
61
|
-
function lineAtOffset(text, offset) {
|
|
62
|
-
let n = 0;
|
|
63
|
-
const end = Math.min(Math.max(offset, 0), text.length);
|
|
64
|
-
for (let i = 0; i < end; i++)
|
|
65
|
-
if (text.charCodeAt(i) === 10)
|
|
66
|
-
n++;
|
|
67
|
-
return n;
|
|
68
|
-
}
|
|
69
61
|
/** Printable `schema.name` of a `CreateFunctionStmt.funcname`. */
|
|
70
62
|
function funcName(createFnStmt) {
|
|
71
63
|
const funcname = createFnStmt.funcname;
|
|
@@ -99,22 +91,26 @@ function funcLanguage(createFnStmt) {
|
|
|
99
91
|
* `CREATE FUNCTION` is sliced back out with its absolute start line, so a
|
|
100
92
|
* mixed migration (schema + tables + functions) yields one input per function
|
|
101
93
|
* rather than being treated as a single malformed definition.
|
|
94
|
+
*
|
|
95
|
+
* `stmt_location` / `stmt_len` are byte offsets into the UTF-8 source, so the
|
|
96
|
+
* slicing happens over the encoded bytes (see `util.ts`).
|
|
102
97
|
*/
|
|
103
98
|
async function sqlTextDefinitions(source, file) {
|
|
104
99
|
const parsed = (await (0, pgsql_parser_1.parse)(source));
|
|
100
|
+
const bytes = (0, util_1.utf8)(source);
|
|
105
101
|
const out = [];
|
|
106
102
|
for (const entry of parsed.stmts ?? []) {
|
|
107
103
|
const createFnStmt = entry.stmt ? (0, walk_1.findAll)(entry.stmt, 'CreateFunctionStmt')[0] : undefined;
|
|
108
104
|
if (!createFnStmt)
|
|
109
105
|
continue;
|
|
110
106
|
const start = entry.stmt_location ?? 0;
|
|
111
|
-
const len = entry.stmt_len ??
|
|
107
|
+
const len = entry.stmt_len ?? bytes.length - start;
|
|
112
108
|
out.push({
|
|
113
|
-
text:
|
|
109
|
+
text: (0, util_1.sliceBytes)(bytes, start, len),
|
|
114
110
|
language: funcLanguage(createFnStmt),
|
|
115
111
|
name: funcName(createFnStmt),
|
|
116
112
|
file,
|
|
117
|
-
startLine:
|
|
113
|
+
startLine: (0, util_1.newlinesBeforeByte)(bytes, start) // 0-based line the definition begins on
|
|
118
114
|
});
|
|
119
115
|
}
|
|
120
116
|
return out;
|
|
@@ -129,7 +125,9 @@ async function lintSqlText(source, options = {}) {
|
|
|
129
125
|
inputs = await sqlTextDefinitions(source);
|
|
130
126
|
}
|
|
131
127
|
catch (err) {
|
|
132
|
-
|
|
128
|
+
const message = err.message;
|
|
129
|
+
const finding = parseErrorFinding(message, '<file>', 1, options);
|
|
130
|
+
return { file: '<source>', findings: finding ? [finding] : [], parseError: message };
|
|
133
131
|
}
|
|
134
132
|
const findings = await lintInputs(inputs, options);
|
|
135
133
|
return { file: '<source>', findings };
|
|
@@ -140,7 +138,12 @@ async function lintInputs(inputs, options) {
|
|
|
140
138
|
for (const input of inputs) {
|
|
141
139
|
const subject = input.name ?? '<anonymous>';
|
|
142
140
|
const startLine = input.startLine ?? 0;
|
|
143
|
-
const { problems, suppressed } = await (0, engine_1.lintDefinition)(input.text, input.language, subject, options);
|
|
141
|
+
const { problems, suppressed, parseError } = await (0, engine_1.lintDefinition)(input.text, input.language, subject, options);
|
|
142
|
+
if (parseError) {
|
|
143
|
+
const finding = parseErrorFinding(parseError, subject, startLine + 1, options);
|
|
144
|
+
if (finding)
|
|
145
|
+
findings.push(finding);
|
|
146
|
+
}
|
|
144
147
|
const absLine = (p) => startLine + p.line; // p.line is 1-based within input.text
|
|
145
148
|
for (const p of problems)
|
|
146
149
|
findings.push(toFinding(p, subject, absLine(p), false));
|
|
@@ -171,6 +174,7 @@ function toFinding(p, subject, line, acknowledged, reason, scope) {
|
|
|
171
174
|
return meta;
|
|
172
175
|
}
|
|
173
176
|
const RULE_CODE = {
|
|
177
|
+
[engine_1.PARSE_ERROR.id]: engine_1.PARSE_ERROR.code,
|
|
174
178
|
'no-set-search-path': 'C1',
|
|
175
179
|
'no-variable-conflict': 'C2',
|
|
176
180
|
'require-qualified-refs': 'C3',
|
|
@@ -179,6 +183,28 @@ const RULE_CODE = {
|
|
|
179
183
|
function codeFor(ruleId) {
|
|
180
184
|
return RULE_CODE[ruleId] ?? ruleId;
|
|
181
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* A definition (or file) the parser rejected. Nothing in it was checked, so
|
|
188
|
+
* the failure is reported as a finding rather than dropped: an unparseable
|
|
189
|
+
* function would otherwise pass every rule by default. Severity is configured
|
|
190
|
+
* like any rule (`parse-error` / `P0`): `error` unless downgraded, and `off`
|
|
191
|
+
* drops it like any other rule turned off.
|
|
192
|
+
*/
|
|
193
|
+
function parseErrorFinding(message, subject, line, options) {
|
|
194
|
+
const severity = (0, engine_1.severityOf)(engine_1.PARSE_ERROR, options.severity);
|
|
195
|
+
if (severity === 'off')
|
|
196
|
+
return undefined;
|
|
197
|
+
return {
|
|
198
|
+
ruleId: engine_1.PARSE_ERROR.id,
|
|
199
|
+
code: engine_1.PARSE_ERROR.code,
|
|
200
|
+
line,
|
|
201
|
+
message: `Definition could not be parsed, so no rule was checked: ${message}`,
|
|
202
|
+
hint: 'Fix the syntax (or, if the parser is wrong, downgrade `parse-error` in the lint config).',
|
|
203
|
+
severity,
|
|
204
|
+
subject,
|
|
205
|
+
acknowledged: false
|
|
206
|
+
};
|
|
207
|
+
}
|
|
182
208
|
/** Recursively collect `.sql` files under a directory. */
|
|
183
209
|
async function sqlFilesUnder(dir) {
|
|
184
210
|
const out = [];
|
package/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export { changedFiles, changedSqlFiles, resolveChangedBase } from './changed';
|
|
|
15
15
|
export type { LintConfigFile, LoadedLintConfig } from './config';
|
|
16
16
|
export { CONFIG_FILENAMES, configDir, findConfigFile, loadLintConfig } from './config';
|
|
17
17
|
export type { LintOptions } from './engine';
|
|
18
|
-
export { lintDefinition, severityOf } from './engine';
|
|
18
|
+
export { lintDefinition, PARSE_ERROR, severityOf } from './engine';
|
|
19
19
|
export { FileFinding, FileLintOptions, FileReport, filesAdapter, lintFiles, lintSource, lintSqlText, resolveSqlFiles, sqlTextAdapter, sqlTextDefinitions } from './file-runner';
|
|
20
20
|
export { applyIgnore, makeIgnoreFilter } from './ignore';
|
|
21
21
|
export type { CallOptions, Linter, LinterConfig } from './linter';
|
package/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* with `createLinter({ rules, severity })` and pass your own rules in.
|
|
13
13
|
*/
|
|
14
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.makeIgnoreFilter = exports.applyIgnore = exports.sqlTextDefinitions = exports.sqlTextAdapter = exports.resolveSqlFiles = exports.lintSqlText = exports.lintSource = exports.lintFiles = exports.filesAdapter = exports.severityOf = exports.lintDefinition = exports.loadLintConfig = exports.findConfigFile = exports.configDir = exports.CONFIG_FILENAMES = exports.resolveChangedBase = exports.changedSqlFiles = exports.changedFiles = void 0;
|
|
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.makeIgnoreFilter = exports.applyIgnore = exports.sqlTextDefinitions = exports.sqlTextAdapter = exports.resolveSqlFiles = exports.lintSqlText = exports.lintSource = exports.lintFiles = exports.filesAdapter = exports.severityOf = exports.PARSE_ERROR = exports.lintDefinition = exports.loadLintConfig = exports.findConfigFile = exports.configDir = exports.CONFIG_FILENAMES = exports.resolveChangedBase = exports.changedSqlFiles = exports.changedFiles = void 0;
|
|
16
16
|
var changed_1 = require("./changed");
|
|
17
17
|
Object.defineProperty(exports, "changedFiles", { enumerable: true, get: function () { return changed_1.changedFiles; } });
|
|
18
18
|
Object.defineProperty(exports, "changedSqlFiles", { enumerable: true, get: function () { return changed_1.changedSqlFiles; } });
|
|
@@ -24,6 +24,7 @@ Object.defineProperty(exports, "findConfigFile", { enumerable: true, get: functi
|
|
|
24
24
|
Object.defineProperty(exports, "loadLintConfig", { enumerable: true, get: function () { return config_1.loadLintConfig; } });
|
|
25
25
|
var engine_1 = require("./engine");
|
|
26
26
|
Object.defineProperty(exports, "lintDefinition", { enumerable: true, get: function () { return engine_1.lintDefinition; } });
|
|
27
|
+
Object.defineProperty(exports, "PARSE_ERROR", { enumerable: true, get: function () { return engine_1.PARSE_ERROR; } });
|
|
27
28
|
Object.defineProperty(exports, "severityOf", { enumerable: true, get: function () { return engine_1.severityOf; } });
|
|
28
29
|
var file_runner_1 = require("./file-runner");
|
|
29
30
|
Object.defineProperty(exports, "filesAdapter", { enumerable: true, get: function () { return file_runner_1.filesAdapter; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pgsql/lint",
|
|
3
|
-
"version": "18.3.
|
|
3
|
+
"version": "18.3.5",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
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
6
|
"main": "index.js",
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
"test:watch": "jest --watch"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@pgsql/traverse": "18.8.
|
|
37
|
+
"@pgsql/traverse": "18.8.3",
|
|
38
38
|
"chalk": "^4.1.0",
|
|
39
39
|
"git-changed": "^0.3.0",
|
|
40
40
|
"libpg-query": "18.2.1",
|
|
41
41
|
"minimist": "1.2.8",
|
|
42
|
-
"pgsql-parser": "18.3.
|
|
42
|
+
"pgsql-parser": "18.3.3"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/minimist": "^1.2.5",
|
|
@@ -57,5 +57,5 @@
|
|
|
57
57
|
"search_path",
|
|
58
58
|
"dynamic-sql"
|
|
59
59
|
],
|
|
60
|
-
"gitHead": "
|
|
60
|
+
"gitHead": "ce03e09604aea4b0fd7d62cf23e72d0de8ad33c1"
|
|
61
61
|
}
|
package/parse-unit.js
CHANGED
|
@@ -13,19 +13,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
13
13
|
exports.parseUnit = parseUnit;
|
|
14
14
|
const libpg_query_1 = require("libpg-query");
|
|
15
15
|
const pgsql_parser_1 = require("pgsql-parser");
|
|
16
|
+
const util_1 = require("./util");
|
|
16
17
|
const walk_1 = require("./walk");
|
|
17
|
-
/**
|
|
18
|
-
function
|
|
18
|
+
/** Absolute 1-based line of a char offset within `text`. */
|
|
19
|
+
function lineOf(text, offset) {
|
|
19
20
|
let n = 0;
|
|
20
|
-
const end = Math.min(offset,
|
|
21
|
+
const end = Math.min(offset, text.length);
|
|
21
22
|
for (let i = 0; i < end; i++)
|
|
22
|
-
if (
|
|
23
|
+
if (text.charCodeAt(i) === 10)
|
|
23
24
|
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;
|
|
25
|
+
return n + 1;
|
|
29
26
|
}
|
|
30
27
|
/** The body string of a `CreateFunctionStmt` (`AS $$ … $$`), or null. */
|
|
31
28
|
function functionBody(createFnStmt) {
|
|
@@ -128,13 +125,14 @@ async function parseUnit(text, language, name) {
|
|
|
128
125
|
const dynamicSql = [];
|
|
129
126
|
const lang = language.toLowerCase();
|
|
130
127
|
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.
|
|
128
|
+
// A SQL-language body is itself SQL: parse it whole. Locations are byte
|
|
129
|
+
// offsets relative to the body, so re-anchor them onto the definition.
|
|
133
130
|
try {
|
|
134
131
|
const ast = await (0, pgsql_parser_1.parse)(body);
|
|
132
|
+
const bodyBytes = (0, util_1.utf8)(body);
|
|
135
133
|
fragments.push({
|
|
136
134
|
ast,
|
|
137
|
-
lineForOffset: (offset) => bodyStartLine +
|
|
135
|
+
lineForOffset: (offset) => bodyStartLine + (0, util_1.newlinesBeforeByte)(bodyBytes, offset)
|
|
138
136
|
});
|
|
139
137
|
}
|
|
140
138
|
catch {
|
package/types.d.ts
CHANGED
|
@@ -75,6 +75,12 @@ export interface LintResult {
|
|
|
75
75
|
problems: LintProblem[];
|
|
76
76
|
/** Suppressed findings, kept for the "accepted risk" report bucket. */
|
|
77
77
|
suppressed: SuppressedProblem[];
|
|
78
|
+
/**
|
|
79
|
+
* Set when the definition could not be parsed. `problems` is then empty
|
|
80
|
+
* because no rule ran — not because the definition is clean — so a consumer
|
|
81
|
+
* must report this rather than treat the result as a pass.
|
|
82
|
+
*/
|
|
83
|
+
parseError?: string;
|
|
78
84
|
}
|
|
79
85
|
/** Static metadata for a lint rule. */
|
|
80
86
|
export interface LintRuleMeta {
|
package/util.d.ts
CHANGED
|
@@ -1,2 +1,16 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Offset arithmetic. Every location libpg_query reports — `stmt_location`,
|
|
3
|
+
* `stmt_len`, a node's `location` — is a **byte** offset into the UTF-8 source,
|
|
4
|
+
* while a JS string is indexed by UTF-16 code unit. The two agree only while
|
|
5
|
+
* the text is pure ASCII: one `→` or `—` earlier in a file shifts every later
|
|
6
|
+
* offset, and a definition sliced by character then fails to parse. So slice
|
|
7
|
+
* and count newlines over the UTF-8 bytes, never over the string.
|
|
8
|
+
*/
|
|
9
|
+
/** The UTF-8 bytes of `text` — the coordinate space parser offsets live in. */
|
|
10
|
+
export declare function utf8(text: string): Uint8Array;
|
|
11
|
+
/** `bytes[start, start + len)` decoded back to a string. */
|
|
12
|
+
export declare function sliceBytes(bytes: Uint8Array, start: number, len: number): string;
|
|
13
|
+
/** Count newline bytes in `bytes[0, byteOffset)` — the 0-based line the offset sits on. */
|
|
14
|
+
export declare function newlinesBeforeByte(bytes: Uint8Array, byteOffset: number): number;
|
|
15
|
+
/** Absolute 1-based line of a parser (byte) offset within `text`. */
|
|
16
|
+
export declare function lineOfOffset(text: string, byteOffset: number): number;
|
package/util.js
CHANGED
|
@@ -1,12 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Offset arithmetic. Every location libpg_query reports — `stmt_location`,
|
|
4
|
+
* `stmt_len`, a node's `location` — is a **byte** offset into the UTF-8 source,
|
|
5
|
+
* while a JS string is indexed by UTF-16 code unit. The two agree only while
|
|
6
|
+
* the text is pure ASCII: one `→` or `—` earlier in a file shifts every later
|
|
7
|
+
* offset, and a definition sliced by character then fails to parse. So slice
|
|
8
|
+
* and count newlines over the UTF-8 bytes, never over the string.
|
|
9
|
+
*/
|
|
2
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.utf8 = utf8;
|
|
12
|
+
exports.sliceBytes = sliceBytes;
|
|
13
|
+
exports.newlinesBeforeByte = newlinesBeforeByte;
|
|
3
14
|
exports.lineOfOffset = lineOfOffset;
|
|
4
|
-
|
|
5
|
-
|
|
15
|
+
const encoder = new TextEncoder();
|
|
16
|
+
const decoder = new TextDecoder();
|
|
17
|
+
/** The UTF-8 bytes of `text` — the coordinate space parser offsets live in. */
|
|
18
|
+
function utf8(text) {
|
|
19
|
+
return encoder.encode(text);
|
|
20
|
+
}
|
|
21
|
+
/** `bytes[start, start + len)` decoded back to a string. */
|
|
22
|
+
function sliceBytes(bytes, start, len) {
|
|
23
|
+
return decoder.decode(bytes.subarray(start, start + len));
|
|
24
|
+
}
|
|
25
|
+
/** Count newline bytes in `bytes[0, byteOffset)` — the 0-based line the offset sits on. */
|
|
26
|
+
function newlinesBeforeByte(bytes, byteOffset) {
|
|
6
27
|
let n = 0;
|
|
7
|
-
const end = Math.min(Math.max(
|
|
28
|
+
const end = Math.min(Math.max(byteOffset, 0), bytes.length);
|
|
8
29
|
for (let i = 0; i < end; i++)
|
|
9
|
-
if (
|
|
30
|
+
if (bytes[i] === 10)
|
|
10
31
|
n++;
|
|
11
|
-
return n
|
|
32
|
+
return n;
|
|
33
|
+
}
|
|
34
|
+
/** Absolute 1-based line of a parser (byte) offset within `text`. */
|
|
35
|
+
function lineOfOffset(text, byteOffset) {
|
|
36
|
+
return newlinesBeforeByte(utf8(text), byteOffset) + 1;
|
|
12
37
|
}
|